Files
codex-app-server/cloud/tests/test_longtask_auto_resume.py
T
host 7983567491
ci / test (push) Successful in 25m2s
feat: side-thread task kind (Muse role alignment phase 2)
Tasks gain kind='main'|'side' (NULL=main). A side thread hangs under a
parent task, defaults to the local executor, never builds a worktree,
branch or container unless explicitly asked, and writes its conclusion
back to the parent via task_followups on terminal transition.

- server: kind validation + side-thread gates (parent required, no
  coordinator_parts/upload_id/environment_id, parent must not be a
  coordinator), local executor default, GET /tasks kind filter,
  /worktree 404 and /fork /merge /pr 409 for side threads.
- worker: side-default shunt in _run_attempt (no-worktree scratch cwd,
  in-memory plan=1 when a repo is present, seed/checkpoint skipped),
  _writeback_side_thread, marker task-create kind field + inheritance,
  side threads excluded from marker spawn counts.
- host/sandbox: --no-worktree plumbing for dispatch/goal/popen
  (Local + Docker), scratch cwd work/side-threads/<task-id>.
- console (classic only): kind filter + [旁聊] rows + '开旁聊' entry,
  side-thread dispatch mode, side-thread list in detail, merge/PR/fork
  hidden for side threads; build exit 0, console.html sha df39db5a.
- tests: new test_sidethread.py (29 tests) + kind backfill tests in
  test_p2_migrations.py; full cloud suite 3900 tests OK (skipped=13).

Design: verification/codex-muse-role-alignment-design-20261108.md
Summary: verification/codex-muse-role-align-p1p2-20261108.md
Local only: not pushed, not merged, not deployed.
2026-11-07 20:51:23 +00:00

290 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""长任务自动恢复单测:只有用户暂停才暂停。
只测本路改动,不跑真实模型回合、不联网。
规则(见 worker.auto_resume_interrupted docstring):
- 系统中断的 goal 任务 -> 回 queued + goal_resume_pending=1 + count+1
- 用户暂停/取消(paused_by_user=1)-> 不动
- 配额仍超限 -> 不动(不重排队);配额已恢复 -> 自动续跑
- 协调员孤儿、非平台中断的非 goal 任务 -> 不动
- 平台中断(worker_shutdown/sweep)的非 goal 任务 -> 回 queued
全新跑(goal_resume_pending=0,resume_message 清掉)
- 自动续跑次数到上限 -> 不动
- no_time_limit 任务的 _run_attempt 不设 worker 级截止
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _insert_task(db_path, task_id, status="interrupted", **cols):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, ?, ?, ?, NULL, '[]', 0, ?, ?, ?, NULL)",
(task_id, "p1", "do work", status, "tester", now, now),
)
for key, value in cols.items():
conn.execute(
"UPDATE tasks SET %s = ? WHERE id = ?" % key,
(value, task_id))
conn.commit()
finally:
conn.close()
def _row(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT status, goal_resume_pending, auto_resume_count,"
" paused_by_user, interrupted_reason, no_time_limit"
" FROM tasks WHERE id = ?", (task_id,)).fetchone()
return row
finally:
conn.close()
def _resume_msg(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT resume_message FROM tasks WHERE id = ?",
(task_id,)).fetchone()
return row[0] if row else None
finally:
conn.close()
class LongtaskAutoResumeTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "test.db")
init_db(self.db_path)
self.worker = Worker(
self.db_path, "/fake/host.py", env={"XCM_API_KEY": "x"},
task_timeout=1800)
def tearDown(self):
self.tmp.cleanup()
def test_system_interrupted_goal_resumes(self):
_insert_task(self.db_path, "T1", goal_mode=1, goal="g",
interrupted_reason="sweep", paused_by_user=0)
resumed = self.worker.auto_resume_interrupted()
self.assertEqual(resumed, ["T1"])
status, pending, count, _paused, reason, _ntl = _row(
self.db_path, "T1")
self.assertEqual(status, "queued")
self.assertEqual(pending, 1)
self.assertEqual(count, 1)
self.assertIsNone(reason)
def test_user_pause_and_cancel_do_not_resume(self):
_insert_task(self.db_path, "T1", goal_mode=1, goal="g",
interrupted_reason="user_pause", paused_by_user=1)
_insert_task(self.db_path, "T2", goal_mode=1, goal="g",
interrupted_reason="user_cancel", paused_by_user=1)
self.assertEqual(self.worker.auto_resume_interrupted(), [])
for tid in ("T1", "T2"):
status, pending, count, _p, _r, _n = _row(self.db_path, tid)
self.assertEqual(status, "interrupted")
self.assertEqual(pending, 0)
self.assertEqual(count, 0)
def test_quota_orphan_nongoal_do_not_resume(self):
# 配额仍超限时不重排队(mock 现查超限)。
self.worker._quota_exceeded = lambda task, usage, on_error="closed": (
"quota_exceeded: 本月配额已用完")
_insert_task(self.db_path, "T1", goal_mode=1, goal="g",
interrupted_reason="quota", paused_by_user=0)
_insert_task(self.db_path, "T2", goal_mode=1, goal="g",
interrupted_reason="coordinator_orphan",
paused_by_user=0, parent_task_id="P0")
_insert_task(self.db_path, "T3", goal_mode=0)
self.assertEqual(self.worker.auto_resume_interrupted(), [])
for tid in ("T1", "T2", "T3"):
status, _pending, _c, _p, _r, _n = _row(self.db_path, tid)
self.assertEqual(status, "interrupted")
def test_platform_interrupted_nongoal_resumes_fresh(self):
# 平台中断的非 goal 任务:全新跑,不置断点标记,
# 残留的续跑消息清掉,旧断点标记清掉。
_insert_task(self.db_path, "T1", goal_mode=0,
interrupted_reason="worker_shutdown",
paused_by_user=0, goal_resume_pending=1,
resume_message="stale-msg")
_insert_task(self.db_path, "T2", goal_mode=0,
interrupted_reason="sweep", paused_by_user=0)
resumed = self.worker.auto_resume_interrupted()
self.assertEqual(sorted(resumed), ["T1", "T2"])
for tid in ("T1", "T2"):
status, pending, count, _p, reason, _n = _row(
self.db_path, tid)
self.assertEqual(status, "queued")
self.assertEqual(pending, 0)
self.assertEqual(count, 1)
self.assertIsNone(reason)
self.assertIsNone(_resume_msg(self.db_path, "T1"))
def test_other_reason_nongoal_does_not_resume(self):
# 非平台中断的非 goal 任务仍不接:原因 NULL、配额
# (即使配额已恢复,配额口径只放 goal 任务)。
self.worker._quota_exceeded = lambda task, usage, on_error="closed": None
_insert_task(self.db_path, "T1", goal_mode=0)
_insert_task(self.db_path, "T2", goal_mode=0,
interrupted_reason="quota", paused_by_user=0)
self.assertEqual(self.worker.auto_resume_interrupted(), [])
for tid in ("T1", "T2"):
status, _pending, _c, _p, _r, _n = _row(self.db_path, tid)
self.assertEqual(status, "interrupted")
def test_quota_restored_resumes(self):
# 配额已恢复(现查为 None)时配额中断也自动续跑。
self.worker._quota_exceeded = lambda task, usage, on_error="closed": None
_insert_task(self.db_path, "T1", goal_mode=1, goal="g",
interrupted_reason="quota", paused_by_user=0)
self.assertEqual(self.worker.auto_resume_interrupted(), ["T1"])
status, pending, count, _p, reason, _n = _row(self.db_path, "T1")
self.assertEqual(status, "queued")
self.assertEqual(pending, 1)
self.assertEqual(count, 1)
self.assertIsNone(reason)
def test_auto_resume_max_cap(self):
_insert_task(self.db_path, "T1", goal_mode=1, goal="g",
interrupted_reason="sweep", paused_by_user=0,
auto_resume_count=50)
self.assertEqual(self.worker.auto_resume_interrupted(), [])
status, _p, count, _pa, _r, _n = _row(self.db_path, "T1")
self.assertEqual(status, "interrupted")
self.assertEqual(count, 50)
def test_no_time_limit_run_attempt_has_no_deadline(self):
captured = {}
def fake_execute(task, kind="dispatch", deadline_seconds=None,
seed_host_dir=None, no_worktree=False,
quota_check=None):
captured["deadline"] = deadline_seconds
return {"status": "completed", "stdout": "", "stderr": ""}
self.worker._execute_via_process = fake_execute
self.worker._ensure_gateway = lambda task: (None, {})
self.worker.process_factory = object()
task = {"id": "T1", "project_id": "p1", "prompt": "x",
"goal_mode": 0, "no_time_limit": 1}
self.worker._run_attempt(task)
self.assertIsNone(captured["deadline"])
task2 = dict(task, no_time_limit=0)
self.worker._run_attempt(task2)
self.assertEqual(captured["deadline"], 1800)
class ServerPauseTestCase(unittest.TestCase):
"""POST /tasks/{id}/pause:用户暂停标记 + 自动恢复不接它。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
os.environ["CODEX_GLOBAL_ADMINS"] = self.owner_id
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201, project)
self.project_id = project["id"]
_insert_task(self.db_path, "TP", status="running",
goal_mode=1, goal="g")
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
if getattr(self, "_old_admins", None) is None:
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
else:
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def test_pause_marks_user_paused_and_auto_resume_skips(self):
# 注意:_insert_task 的 project_id 是 "p1",与本用例项目
# 不同——pause 的权限校验按任务所属项目查,owner 不是
# p1 成员会 403;所以先把任务项目改成本用例项目。
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET project_id = ? WHERE id = ?",
(self.project_id, "TP"))
conn.commit()
finally:
conn.close()
status, body = self._req(
"POST", "/tasks/TP/pause", {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "interrupted")
self.assertEqual(body["paused_by_user"], 1)
self.assertEqual(body["interrupted_reason"], "user_pause")
worker = Worker(self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "x"})
self.assertEqual(worker.auto_resume_interrupted(), [])
row = _row(self.db_path, "TP")
self.assertEqual(row[0], "interrupted")
if __name__ == "__main__":
unittest.main()