Files
codex-app-server/cloud/tests/test_longtask_auto_resume.py
T
host 4428a721a6 feat: auto-resume platform-interrupted non-goal tasks
Deploy restarts kill running tasks: the worker marks them
interrupted (worker_shutdown on graceful exit, sweep on stale
reclaim), and auto_resume_interrupted used to accept goal tasks
only, so every non-goal task killed by a deploy stayed
interrupted forever unless a human retried it (the 2026-10-30
demo task died this way).

Now worker_shutdown/sweep interruptions are re-queued for
non-goal tasks too: the platform killed the task, so the
platform re-queues it. The re-queue is a fresh run, not a
breakpoint resume: goal_resume_pending is forced to 0 and any
stale resume_message is cleared, so the new run cannot be
hijacked by the goal-resume path or an old continue message;
files in the same worktree persist. All existing guards stay:
user pause, coordinator orphans, quota (still goal-only),
backoff, the 3-strike stall guard (non-goal tasks have no
checkpoints, so each platform re-queue counts as no-progress),
and the auto_resume_max cap. Other non-goal reasons (NULL,
quota, user cancel) still do not auto-resume.
2026-10-30 15:39:55 +08:00

289 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, 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()