feat: 长任务中断自动恢复,仅用户暂停才暂停,不限时
- 新增 tasks 列 paused_by_user/interrupted_reason/auto_resume_count/no_time_limit(幂等迁移,server+worker 双侧)
- POST /tasks/{id}/pause:用户暂停标 interrupted + paused_by_user=1;/cancel 同款标记;人工 /retry、/goal-resume 清标记
- sweep 标 sweep、配额标 quota、协调员孤儿标 coordinator_orphan、worker 退出标 worker_shutdown
- worker.run_loop 每轮后 auto_resume_interrupted:只接系统中断的 goal 任务,CAS 回 queued + goal_resume_pending=1(G10 断点续跑),用户暂停/配额/孤儿/非 goal 不接,上限 50 次防无限烧额度
- no_time_limit=1 的任务 worker/Docker 级不限时(其它任务仍默认 1800 秒;host 单轮 600 秒仍是单轮护栏)
- 前置 cherry-pick:G10 断点续跑透传 model_provider(否则 Docker 断点续跑在 sub2api 下必死,自动恢复无法实证;本 PR supersedes #127,勿另行合并 #127)
This commit is contained in:
1 parent
a19b1567bb
commit
c9cfe22a36
3 files changed
+454
-11
No files matched your search
+85
-4
@@ -1113,6 +1113,19 @@ def migrate_tasks_table(conn):
|
||||
# 话发一条新消息、开新循环”,本标记是“从
|
||||
# goal_rounds_done 的下一轮接着跑同一循环”。
|
||||
"goal_resume_pending": "INTEGER DEFAULT 0",
|
||||
# 长任务自动恢复:paused_by_user=1 只由用户暂停/
|
||||
# 取消(/pause、/cancel)写,系统中断(sweep、worker
|
||||
# 退出、配额除外)绝不写它;interrupted_reason 记
|
||||
# 中断来源(user_pause/user_cancel/sweep/quota/
|
||||
# coordinator_orphan/worker_shutdown),NULL = 老行或
|
||||
# 未分类系统中断,按可自动恢复处理;auto_resume_count
|
||||
# 记自动续跑次数(防确定性失败无限循环的上限门);
|
||||
# no_time_limit=1 的任务 worker 级不限时(见 worker
|
||||
# _run_attempt,host 单轮 600 秒仍是单轮护栏)。
|
||||
"paused_by_user": "INTEGER DEFAULT 0",
|
||||
"interrupted_reason": "TEXT",
|
||||
"auto_resume_count": "INTEGER DEFAULT 0",
|
||||
"no_time_limit": "INTEGER DEFAULT 0",
|
||||
}
|
||||
for name, coltype in new_cols.items():
|
||||
if name not in cols:
|
||||
@@ -1635,6 +1648,10 @@ def _task_to_dict(row, attachments=None):
|
||||
"parent_task_id": _row_get(row, "parent_task_id"),
|
||||
"coordinator_part": _row_get(row, "coordinator_part"),
|
||||
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
|
||||
"paused_by_user": _row_get(row, "paused_by_user") or 0,
|
||||
"interrupted_reason": _row_get(row, "interrupted_reason"),
|
||||
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
|
||||
"no_time_limit": _row_get(row, "no_time_limit") or 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -1659,6 +1676,8 @@ def sweep_stale_tasks(db_path, threshold_seconds=300):
|
||||
心跳取 last_heartbeat,无心跳则用 updated_at;早于
|
||||
now - threshold_seconds 即过期。审计记一条 "sweep_interrupted"
|
||||
(user_id 记 "system",因为回收不是某个用户的操作)。
|
||||
这是系统中断:interrupted_reason='sweep'、paused_by_user=0,
|
||||
长任务自动恢复可接它(用户暂停绝不走这条路)。
|
||||
独立可单测,不依赖 HTTP。
|
||||
"""
|
||||
now = time.time()
|
||||
@@ -1674,7 +1693,9 @@ def sweep_stale_tasks(db_path, threshold_seconds=300):
|
||||
swept = []
|
||||
for row in rows:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted', updated_at = ?"
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" interrupted_reason = 'sweep', paused_by_user = 0,"
|
||||
" updated_at = ?"
|
||||
" WHERE id = ? AND status = 'running'",
|
||||
(now, row["id"]),
|
||||
)
|
||||
@@ -2059,6 +2080,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._task_agents(user_id, rest[: -len("/agents")])
|
||||
elif rest.endswith("/cancel") and method == "POST":
|
||||
self._cancel_task(user_id, rest[: -len("/cancel")])
|
||||
elif rest.endswith("/pause") and method == "POST":
|
||||
self._pause_task(user_id, rest[: -len("/pause")])
|
||||
elif rest.endswith("/merge") and method == "POST":
|
||||
self._merge_task(user_id, rest[: -len("/merge")])
|
||||
elif rest.endswith("/pr") and method == "POST":
|
||||
@@ -4213,6 +4236,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
json.dumps(coord_parts) if coord_parts else None,
|
||||
None, None, child_model, child_effort),
|
||||
)
|
||||
# 长任务不限时:API 字段 no_time_limit=1 时写标记,
|
||||
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
|
||||
# 不传或非真值时保持默认 0,现有任务行为不变。
|
||||
if body.get("no_time_limit") in (True, 1):
|
||||
conn.execute(
|
||||
"UPDATE tasks SET no_time_limit = 1"
|
||||
" WHERE id = ?", (task_id,))
|
||||
for rel_path, size in bundle_files:
|
||||
conn.execute(
|
||||
"INSERT INTO task_attachments"
|
||||
@@ -6442,8 +6472,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
# 用户取消 = 用户暂停:自动恢复绝不接它,人工
|
||||
# /retry 或 /goal-resume 才能再跑(那两处会清标记)。
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted', updated_at = ?"
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" paused_by_user = 1, interrupted_reason = 'user_cancel',"
|
||||
" updated_at = ?"
|
||||
" WHERE id = ?",
|
||||
(now, task_id),
|
||||
)
|
||||
@@ -6476,6 +6510,49 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
store.audit(user_id, task["project_id"], "cancel_task", task_id)
|
||||
self._send_json(200, task)
|
||||
|
||||
def _pause_task(self, user_id, task_id):
|
||||
"""用户暂停:与 /cancel 同终态、不同语义标记。
|
||||
|
||||
只有用户暂停才暂停:本端点把任务标 interrupted 并置
|
||||
paused_by_user=1、interrupted_reason='user_pause',worker
|
||||
自动恢复(auto_resume_interrupted)绝不接它;人工
|
||||
/retry 或 /goal-resume 才能再跑(那两处会清标记)。
|
||||
权限与 /cancel 同款(create_task),终态任务 400。
|
||||
"""
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "task not found"})
|
||||
return
|
||||
task = _task_to_dict(row)
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
if not store.check_permission(
|
||||
user_id, task["project_id"], "create_task"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if task["status"] in TERMINAL_STATUSES:
|
||||
self._send_json(400, {"error": "task already terminal"})
|
||||
return
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" paused_by_user = 1, interrupted_reason = 'user_pause',"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(now, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
task = _task_to_dict(
|
||||
conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, task["project_id"], "pause_task", task_id)
|
||||
self._send_json(200, task)
|
||||
|
||||
def _merge_task(self, user_id, task_id):
|
||||
"""把云端任务标记为已合并:只补状态,不执行合并本身。
|
||||
|
||||
@@ -6924,7 +7001,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'queued',"
|
||||
" goal_resume_pending = 1, result_text = NULL,"
|
||||
" error_text = NULL, actual_model = NULL, updated_at = ?"
|
||||
" error_text = NULL, actual_model = NULL,"
|
||||
" paused_by_user = 0, interrupted_reason = NULL,"
|
||||
" updated_at = ?"
|
||||
" WHERE id = ? AND status = 'interrupted'",
|
||||
(now, task_id),
|
||||
)
|
||||
@@ -6993,7 +7072,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'queued', result_text = NULL,"
|
||||
" error_text = NULL, actual_model = NULL, updated_at = ?"
|
||||
" error_text = NULL, actual_model = NULL,"
|
||||
" paused_by_user = 0, interrupted_reason = NULL,"
|
||||
" updated_at = ?"
|
||||
" WHERE id = ? AND status IN"
|
||||
" ('failed', 'interrupted', 'merge-failed')",
|
||||
(now, task_id),
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""长任务自动恢复单测:只有用户暂停才暂停。
|
||||
|
||||
只测本路改动,不跑真实模型回合、不联网。
|
||||
规则(见 worker.auto_resume_interrupted docstring):
|
||||
- 系统中断的 goal 任务 -> 回 queued + goal_resume_pending=1 + count+1
|
||||
- 用户暂停/取消(paused_by_user=1)-> 不动
|
||||
- 配额中断、协调员孤儿、非 goal 任务 -> 不动
|
||||
- 自动续跑次数到上限 -> 不动
|
||||
- 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()
|
||||
|
||||
|
||||
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):
|
||||
_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_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()
|
||||
+142
-7
@@ -360,6 +360,12 @@ def _task_from_row(row):
|
||||
"resume_message": _col("resume_message"),
|
||||
# G10:goal 断点续跑标记,老行缺列时 0 = 普通排队。
|
||||
"goal_resume_pending": _col("goal_resume_pending") or 0,
|
||||
# 长任务自动恢复:用户暂停标记、中断原因、自动续跑
|
||||
# 次数、不限时标记,老行缺列时按默认(可恢复、不限时关)。
|
||||
"paused_by_user": _col("paused_by_user") or 0,
|
||||
"interrupted_reason": _col("interrupted_reason"),
|
||||
"auto_resume_count": _col("auto_resume_count") or 0,
|
||||
"no_time_limit": _col("no_time_limit") or 0,
|
||||
# 协调员:老行缺列时 coordinator_parts 为 [](= 普通任务),
|
||||
# parent_task_id/coordinator_part 缺列时为 None。
|
||||
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
|
||||
@@ -395,6 +401,7 @@ class Worker:
|
||||
outbox_backoff_seconds=60,
|
||||
outbox_max_attempts=3,
|
||||
quota_poll_interval_seconds=30,
|
||||
auto_resume_max=50,
|
||||
ignore_host_budget=True):
|
||||
self.db_path = str(db_path)
|
||||
self.host_py_path = host_py_path
|
||||
@@ -494,6 +501,11 @@ class Worker:
|
||||
self.task_timeout = task_timeout
|
||||
self.max_retries = max_retries
|
||||
self.retry_backoff_seconds = retry_backoff_seconds
|
||||
# 长任务自动恢复上限(次数,不是时间):同一任务被
|
||||
# 系统中断后自动续跑超过该次数即停住、等人工处理,
|
||||
# 防确定性失败(每次续跑立刻又中断)无限烧额度;
|
||||
# 正常部署重启/偶发中断远达不到 50 次。
|
||||
self.auto_resume_max = auto_resume_max
|
||||
# 项目仓库检出根目录:CLI --repos-dir 可调(默认 ./repos);
|
||||
# Docker 执行器下它必须放在 extra-mounts 已挂载的目录下,
|
||||
# 否则容器内 task["repo"] 路径不存在——见 cloud/README.md。
|
||||
@@ -813,6 +825,96 @@ class Worker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _mark_interrupted_reason(self, task_id, reason):
|
||||
"""给已是 interrupted 的任务补中断原因(不改状态本身)。
|
||||
|
||||
只写 reason,不碰 paused_by_user:调用方是系统路径
|
||||
(配额、sweep 已在 server 侧写、worker 退出、协调员
|
||||
孤儿),用户暂停的 paused 标记由 server /pause、
|
||||
/cancel 写,worker 侧绝不覆盖它。
|
||||
老库缺列时先迁移,迁移失败则静默跳过(不炸终局路径)。
|
||||
"""
|
||||
try:
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
self._ensure_result_columns(conn)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET interrupted_reason = ?"
|
||||
" WHERE id = ? AND status = 'interrupted'",
|
||||
(reason, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def auto_resume_interrupted(self):
|
||||
"""长任务自动恢复:只接系统中断的 goal 任务。
|
||||
|
||||
只有用户暂停才暂停:paused_by_user=1(/pause、/cancel)
|
||||
的任务绝不自动动;配额中断(quota)与协调员孤儿
|
||||
(coordinator_orphan)也不接——前者要等配额恢复、
|
||||
后者要由父协调员收口,自动重跑只会烧钱或跑丢父子关系。
|
||||
非 goal 任务不接:它没有断点语义,重跑等于从头再来,
|
||||
可能重复副作用,留给人工 /retry 决定。
|
||||
接到的任务 CAS 回 queued 并置 goal_resume_pending=1,
|
||||
下一轮认领即走 G10 断点续跑(同一线程、同一 worktree、
|
||||
从 goal_rounds_done 下一轮起),同时清旧结果、清
|
||||
interrupted_reason、auto_resume_count+1;上限
|
||||
self.auto_resume_max 到顶的任务停住并记审计,等人工。
|
||||
返回自动续跑的任务 id 列表;任何异常只记日志,不炸
|
||||
run_loop(恢复是辅助路径,不能带走常驻 worker)。
|
||||
"""
|
||||
resumed = []
|
||||
try:
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
self._ensure_result_columns(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id, project_id FROM tasks"
|
||||
" WHERE status = 'interrupted'"
|
||||
" AND (paused_by_user IS NULL OR paused_by_user = 0)"
|
||||
" AND (interrupted_reason IS NULL OR"
|
||||
" interrupted_reason NOT IN"
|
||||
" ('quota', 'coordinator_orphan'))"
|
||||
" AND parent_task_id IS NULL"
|
||||
" AND (goal_mode = 1 OR verify_command IS NOT NULL"
|
||||
" OR goal IS NOT NULL)"
|
||||
" AND (auto_resume_count IS NULL OR"
|
||||
" auto_resume_count < ?)",
|
||||
(self.auto_resume_max,),
|
||||
).fetchall()
|
||||
now = time.time()
|
||||
for row in rows:
|
||||
task_id = row[0]
|
||||
project_id = row[1]
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'queued',"
|
||||
" goal_resume_pending = 1,"
|
||||
" result_text = NULL, error_text = NULL,"
|
||||
" actual_model = NULL,"
|
||||
" interrupted_reason = NULL,"
|
||||
" auto_resume_count ="
|
||||
" COALESCE(auto_resume_count, 0) + 1,"
|
||||
" updated_at = ?"
|
||||
" WHERE id = ? AND status = 'interrupted'"
|
||||
" AND (paused_by_user IS NULL"
|
||||
" OR paused_by_user = 0)",
|
||||
(now, task_id),
|
||||
)
|
||||
if cur.rowcount == 1:
|
||||
resumed.append(task_id)
|
||||
self._audit(project_id, "worker_auto_resumed",
|
||||
task_id)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
log_event(self.logger, "worker_auto_resume_failed",
|
||||
message="auto resume failed: %s" % exc)
|
||||
return resumed
|
||||
|
||||
def _ensure_result_columns(self, conn):
|
||||
"""worker 侧的迁移兜底:与 server.migrate_tasks_table 同列。
|
||||
|
||||
@@ -851,7 +953,11 @@ class Worker:
|
||||
"coordinator_parts": "TEXT",
|
||||
"parent_task_id": "TEXT",
|
||||
"coordinator_part": "TEXT",
|
||||
"goal_resume_pending": "INTEGER DEFAULT 0"}.items():
|
||||
"goal_resume_pending": "INTEGER DEFAULT 0",
|
||||
"paused_by_user": "INTEGER DEFAULT 0",
|
||||
"interrupted_reason": "TEXT",
|
||||
"auto_resume_count": "INTEGER DEFAULT 0",
|
||||
"no_time_limit": "INTEGER DEFAULT 0"}.items():
|
||||
if name not in cols:
|
||||
try:
|
||||
conn.execute(
|
||||
@@ -2930,21 +3036,29 @@ class Worker:
|
||||
container=self.executor.container_name(task))
|
||||
except Exception:
|
||||
pass
|
||||
# 长任务不限时:任务带 no_time_limit=1 时,本次 worker
|
||||
# 级与 Docker 级超时一律按 None 算(不限时),不改全局
|
||||
# task_timeout(其它任务仍走默认 1800 秒);host 单轮
|
||||
# turn_timeout(默认 600 秒)不在 worker 侧传参,仍是
|
||||
# 单轮护栏——不限的是任务总墙钟与总轮次,不是单轮无限。
|
||||
_no_time_limit = bool(task.get("no_time_limit"))
|
||||
_effective_task_timeout = None if _no_time_limit \
|
||||
else self.task_timeout
|
||||
# Docker 超时取较小值:两个超时都不应静默延长另一个。
|
||||
# 注意绝不能改共享的 executor.timeout(并发下两个任务会
|
||||
# 互相覆盖对方的超时);超时只作为本次调用的参数传下去。
|
||||
effective_docker_timeout = None
|
||||
is_docker = isinstance(self.executor, DockerExecutor)
|
||||
if is_docker and self.task_timeout is not None:
|
||||
if is_docker and _effective_task_timeout is not None:
|
||||
if self.executor.timeout is None:
|
||||
effective_docker_timeout = self.task_timeout
|
||||
effective_docker_timeout = _effective_task_timeout
|
||||
else:
|
||||
try:
|
||||
effective_docker_timeout = min(
|
||||
float(self.task_timeout),
|
||||
float(_effective_task_timeout),
|
||||
float(self.executor.timeout))
|
||||
except (TypeError, ValueError):
|
||||
effective_docker_timeout = self.task_timeout
|
||||
effective_docker_timeout = _effective_task_timeout
|
||||
# P0 安全批后:Docker 同步路径起取消看门狗线程
|
||||
# (阻塞在 docker run 里时,库状态变 interrupted 就
|
||||
# 按容器名真杀;非 Docker 路径不启动,避免空线程)。
|
||||
@@ -2988,7 +3102,7 @@ class Worker:
|
||||
popen_kind = "dispatch"
|
||||
result = self._execute_via_process(
|
||||
task, kind=popen_kind,
|
||||
deadline_seconds=self.task_timeout,
|
||||
deadline_seconds=_effective_task_timeout,
|
||||
seed_host_dir=seed_host_dir,
|
||||
quota_check=quota_check)
|
||||
combined = (result.get("stdout", "") or "") + \
|
||||
@@ -2999,7 +3113,7 @@ class Worker:
|
||||
_ALREADY_EXISTS_MARKER in combined:
|
||||
result = self._execute_via_process(
|
||||
task, kind="run",
|
||||
deadline_seconds=self.task_timeout,
|
||||
deadline_seconds=_effective_task_timeout,
|
||||
quota_check=quota_check)
|
||||
else:
|
||||
if is_docker:
|
||||
@@ -3428,6 +3542,7 @@ class Worker:
|
||||
for ch in pending:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" interrupted_reason = 'coordinator_orphan',"
|
||||
" updated_at = ? WHERE id = ? AND status NOT IN"
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')",
|
||||
@@ -3490,6 +3605,7 @@ class Worker:
|
||||
continue
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" interrupted_reason = 'coordinator_orphan',"
|
||||
" updated_at = ? WHERE id = ? AND status NOT IN"
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')",
|
||||
@@ -3830,6 +3946,9 @@ class Worker:
|
||||
quota_error = self._quota_exceeded(task, None, on_error="closed")
|
||||
if quota_error is not None:
|
||||
self._set_status(task_id, "interrupted")
|
||||
# 配额中断不自动恢复:要等配额恢复/调高,自动
|
||||
# 回 queued 只会让 autoscaler 按队列深度白拉 worker。
|
||||
self._mark_interrupted_reason(task_id, "quota")
|
||||
self._write_execution_result(
|
||||
task_id, error_text=quota_error, actual_model=self.model,
|
||||
started_at=started_at, finished_at=time.time(),
|
||||
@@ -4280,6 +4399,8 @@ class Worker:
|
||||
self._audit(task["project_id"], "worker_fallback",
|
||||
"%s -> %s" % (self.model, nxt))
|
||||
final_written = self._set_status_if_running(task_id, final)
|
||||
if quota_stop and final_written and final == "interrupted":
|
||||
self._mark_interrupted_reason(task_id, "quota")
|
||||
finished_at = time.time()
|
||||
# P0 安全批后:CAS 没改到行(任务已被取消/回收)时
|
||||
# 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果
|
||||
@@ -4764,6 +4885,13 @@ class Worker:
|
||||
except Exception as exc:
|
||||
log_event(self.logger, "worker_orphan_sweep_failed",
|
||||
message="orphan sweep failed: %s" % exc)
|
||||
# 长任务自动恢复:接在 sweep 之后——刚被 sweep
|
||||
# 标 interrupted 的系统中断 goal 任务,本轮即可
|
||||
# 回 queued、下一轮认领即断点续跑;用户暂停、
|
||||
# 配额、协调员孤儿一律不接(见方法 docstring)。
|
||||
# 放在 sweep 块外:sweep 关掉时,已由其它路径
|
||||
# (如 worker 退出)标 interrupted 的任务仍要恢复。
|
||||
self.auto_resume_interrupted()
|
||||
# 每轮结束写心跳:哪怕本轮一个任务都没派,worker 活着
|
||||
# 这件事本身也要被 server 看到(文件心跳 + 注册表心跳)。
|
||||
self._write_heartbeat()
|
||||
@@ -4810,6 +4938,8 @@ class Worker:
|
||||
pass
|
||||
self._running_clear(task_id)
|
||||
self._set_status(task_id, "interrupted")
|
||||
# worker 退出时的系统中断,可自动恢复。
|
||||
self._mark_interrupted_reason(task_id, "worker_shutdown")
|
||||
return all_results
|
||||
|
||||
|
||||
@@ -4970,6 +5100,10 @@ def main(argv=None):
|
||||
"取消与已记 fallback 的失败不重试)")
|
||||
parser.add_argument("--retry-backoff-seconds", type=float, default=5.0,
|
||||
help="重试前等待秒数(默认 5)")
|
||||
parser.add_argument("--auto-resume-max", type=int, default=50,
|
||||
help="长任务自动恢复上限(次数,非时间,默认 50):"
|
||||
"同一 goal 任务被系统中断后自动续跑超过该次数"
|
||||
"即停住等人工,防确定性失败无限烧额度")
|
||||
parser.add_argument("--loop", action="store_true",
|
||||
help="常驻循环,每 --interval 秒跑一轮")
|
||||
parser.add_argument("--interval", type=int, default=300)
|
||||
@@ -5002,6 +5136,7 @@ def main(argv=None):
|
||||
task_timeout=args.task_timeout,
|
||||
max_retries=args.max_retries,
|
||||
retry_backoff_seconds=args.retry_backoff_seconds,
|
||||
auto_resume_max=args.auto_resume_max,
|
||||
repos_dir=args.repos_dir,
|
||||
heartbeat_file=args.heartbeat_file,
|
||||
worker_id=args.worker_id,
|
||||
|
||||
Reference in new issue
Block a user