Commit 15385d6054f9954c33fccb40c80efa4a9b0e4a82
1 parent
ee6787ba
U-021: Graph Engine 持久化 + 断点恢复(REQ-06)
now 默认值从 time.monotonic 改成 time.time(墙钟),使 quota 等待的 已耗时计算能在进程重启后正确续算;run_work_node 对已 done 的节点直接 返回存量结果不重跑,对 retrying/waiting_for_quota 节点从持久化的 retry_count/quota_wait_started_at 续算而非清零;needs_worktree=True 时复用已存在的 worktree 而不是重新 create。新增 resume_work_graph_run() 返回一次 run 里尚未完成的 node_exec_id 列表, 并对内部不一致的持久化状态统一抛 CorruptPersistedStateError。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
285 additions
and
12 deletions
docs/development-plan.md
| ... | ... | @@ -201,6 +201,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 201 | 201 | - **U-021 [Risk Gate]** Graph Engine 持久化状态 + 断点恢复 |
| 202 | 202 | - 依赖:U-012, U-013 |
| 203 | 203 | - Done:REQ-06 happy/boundary/failure 通过(执行中途中断进程后从持久化状态恢复,不重跑已完成节点) |
| 204 | + - **实现补充(2026-07-31)**:核心问题是 U-013 `run_work_node` 的 `now` 默认值是 `time.monotonic`——单调时钟在进程重启后没有固定的墙钟含义,REQ-06 恰恰要求"进程重启后继续计算 quota 等待已经过去多久",两者根本不兼容。改法:`now` 默认改回 `time.time`(墙钟),新增 `_parse_sqlite_timestamp()` 把 SQLite `CURRENT_TIMESTAMP` 的 UTC 字符串转成可比的 epoch 值;`run_work_node` 一开始就读出该 node_exec_id 已持久化的 `status`/`retry_count`/`quota_wait_started_at`/`git_worktree_ref`,`status='done'` 直接返回存量 `output_payload`(不重跑 `run_fn`),`retrying`/`waiting_for_quota` 从持久化的 `retry_count`/`quota_wait_started_at` 续算而不是清零重来,`needs_worktree=True` 时若已存在对应 worktree 目录则复用而不是重新 `git worktree add`(会直接失败)。新增 `resume_work_graph_run(conn, run_id)`:返回一次 `work_graph_run` 里还没 `done` 的 `node_exec_id` 列表,供调用方决定重新调度谁;对内部不一致的持久化状态(无效的 `work_graph_run.status`/节点 `status`,`status='done'` 但 `output_payload` 为空,`status='waiting_for_quota'` 但 `quota_wait_started_at` 为空)统一抛 `CorruptPersistedStateError`,不会返回一份看似正常但实际有问题的恢复计划。这几类"状态不一致"用正常写入路径其实到不了(各字段都有 `CHECK` 约束),测试里用 `PRAGMA ignore_check_constraints=ON` 模拟外部损坏的行来触发。 | |
| 204 | 205 | - 覆盖:01-M `work_graph_run`/`work_node_execution`;04-P `p_work_graph_execution`;REQ-06 |
| 205 | 206 | |
| 206 | 207 | --- | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -34,7 +34,7 @@ |
| 34 | 34 | {"id": "U-018", "phase": "PH2", "sub_phase": "PH2-S2", "name": "c_skill_update + c_skill_rollback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-015"], "coverage": {"model": ["skill", "skill_iteration"], "view": [], "controller": ["c_skill_update", "c_skill_rollback"], "process": [], "requirement": ["REQ-08"]}, "done": "REQ-08 全部通过;顺带解决了 U-015 的 skill_defect 缺口(bug_record.skill_iteration_suggestion_id 由 update_skill 回填)"}, |
| 35 | 35 | {"id": "U-019", "phase": "PH2", "sub_phase": "PH2-S3", "name": "budget_guardrail_config + c_budget_check(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": ["budget_guardrail_config"], "view": [], "controller": ["c_budget_check"], "process": ["p_budget_guardrail"], "requirement": ["REQ-10"]}, "done": "REQ-10 happy/boundary 通过;boundary 复用 U-016 manual_resume,完整 c_budget_config_update UI 是 U-030 的事"}, |
| 36 | 36 | {"id": "U-020", "phase": "PH2", "sub_phase": "PH2-S3", "name": "p_usage_limit_wait 完整验收(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底);补上了 opus 真实降级重试(U-013 时只做到不退避,没做到真重试)+ 之前漏测的 weekly 类型"}, |
| 37 | - {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过"}, | |
| 37 | + {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过;now 默认值从 time.monotonic 改成 time.time(墙钟)以支持跨进程重启的 quota 等待续算;新增 resume_work_graph_run() 返回未完成节点列表"}, | |
| 38 | 38 | |
| 39 | 39 | {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 全部通过"}, |
| 40 | 40 | {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record"}, | ... | ... |
src/test_work_node_run.py
| ... | ... | @@ -337,5 +337,158 @@ class RealGitWorktreeIsolationTest(WorkNodeRunTestBase): |
| 337 | 337 | self.assertIn("module_b.txt", log.stdout) |
| 338 | 338 | |
| 339 | 339 | |
| 340 | +class BreakpointRecoveryTest(WorkNodeRunTestBase): | |
| 341 | + """U-021 / REQ-06: run_work_node called again for a node_exec_id that was | |
| 342 | + already mid-flight when a process restart happened.""" | |
| 343 | + | |
| 344 | + def test_done_node_returns_stored_output_without_rerunning(self): | |
| 345 | + self.conn.execute( | |
| 346 | + "UPDATE work_node_execution SET status = 'done', output_payload = ? WHERE node_exec_id = 'node-1'", | |
| 347 | + (json.dumps({"value": "already done"}),), | |
| 348 | + ) | |
| 349 | + self.conn.commit() | |
| 350 | + calls = [] | |
| 351 | + output = work_node_run.run_work_node( | |
| 352 | + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, | |
| 353 | + run_fn=lambda cwd, model=None: calls.append(1) or {"value": "should not run"}, | |
| 354 | + ) | |
| 355 | + self.assertEqual(output, {"value": "already done"}) | |
| 356 | + self.assertEqual(calls, []) | |
| 357 | + | |
| 358 | + def test_resuming_retrying_node_continues_persisted_retry_count(self): | |
| 359 | + # node-1 was already at retry_count=2 (not 0) before the restart -- | |
| 360 | + # one more failure must immediately exceed max_retries=2, proving | |
| 361 | + # the count resumed rather than restarting from zero. | |
| 362 | + self.conn.execute( | |
| 363 | + "UPDATE work_node_execution SET status = 'retrying', retry_count = 2 WHERE node_exec_id = 'node-1'" | |
| 364 | + ) | |
| 365 | + self.conn.commit() | |
| 366 | + calls = [] | |
| 367 | + | |
| 368 | + def run_fn(cwd, model=None): | |
| 369 | + calls.append(1) | |
| 370 | + return {"wrong_key": True} | |
| 371 | + | |
| 372 | + with self.assertRaises(work_node_run.SchemaValidationFailedError): | |
| 373 | + work_node_run.run_work_node( | |
| 374 | + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn, max_retries=2, | |
| 375 | + ) | |
| 376 | + self.assertEqual(len(calls), 1) | |
| 377 | + row = self.conn.execute( | |
| 378 | + "SELECT status, retry_count FROM work_node_execution WHERE node_exec_id = 'node-1'" | |
| 379 | + ).fetchone() | |
| 380 | + self.assertEqual(row, ("blocked", 3)) | |
| 381 | + | |
| 382 | + def test_resuming_waiting_for_quota_node_uses_persisted_timestamp_not_a_fresh_clock(self): | |
| 383 | + # if elapsed-time tracking restarted at 0 on resume instead of using | |
| 384 | + # the persisted quota_wait_started_at, this would never time out. | |
| 385 | + original_ts = "2026-07-01 00:00:00" | |
| 386 | + epoch_orig = work_node_run._parse_sqlite_timestamp(original_ts) | |
| 387 | + self.conn.execute( | |
| 388 | + "UPDATE work_node_execution SET status = 'waiting_for_quota', " | |
| 389 | + "quota_wait_started_at = ?, quota_limit_type = 'session' WHERE node_exec_id = 'node-1'", | |
| 390 | + (original_ts,), | |
| 391 | + ) | |
| 392 | + self.conn.commit() | |
| 393 | + | |
| 394 | + def run_fn(cwd, model=None): | |
| 395 | + work_node_run.raise_if_usage_limited("You've hit your session limit") | |
| 396 | + | |
| 397 | + with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError): | |
| 398 | + work_node_run.run_work_node( | |
| 399 | + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn, | |
| 400 | + sleep=lambda s: None, now=lambda: epoch_orig + 8 * 3600 + 1, | |
| 401 | + ) | |
| 402 | + status = self.conn.execute( | |
| 403 | + "SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'" | |
| 404 | + ).fetchone()[0] | |
| 405 | + self.assertEqual(status, "blocked") | |
| 406 | + | |
| 407 | + def test_nonexistent_node_exec_id_raises_corrupt_state(self): | |
| 408 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 409 | + work_node_run.run_work_node( | |
| 410 | + self.conn, "no-such-node", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, | |
| 411 | + run_fn=lambda cwd, model=None: {"value": 1}, | |
| 412 | + ) | |
| 413 | + | |
| 414 | + def test_done_node_with_null_output_payload_raises_corrupt_state(self): | |
| 415 | + self.conn.execute( | |
| 416 | + "UPDATE work_node_execution SET status = 'done', output_payload = NULL WHERE node_exec_id = 'node-1'" | |
| 417 | + ) | |
| 418 | + self.conn.commit() | |
| 419 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 420 | + work_node_run.run_work_node( | |
| 421 | + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, | |
| 422 | + run_fn=lambda cwd, model=None: {"value": 1}, | |
| 423 | + ) | |
| 424 | + | |
| 425 | + def test_waiting_for_quota_node_with_null_timestamp_raises_corrupt_state(self): | |
| 426 | + self.conn.execute( | |
| 427 | + "UPDATE work_node_execution SET status = 'waiting_for_quota', quota_wait_started_at = NULL " | |
| 428 | + "WHERE node_exec_id = 'node-1'" | |
| 429 | + ) | |
| 430 | + self.conn.commit() | |
| 431 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 432 | + work_node_run.run_work_node( | |
| 433 | + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, | |
| 434 | + run_fn=lambda cwd, model=None: {"value": 1}, | |
| 435 | + ) | |
| 436 | + | |
| 437 | + | |
| 438 | +class ResumeWorkGraphRunTest(WorkNodeRunTestBase): | |
| 439 | + """U-021 / REQ-06: resume_work_graph_run's own resume-plan + corrupt-state | |
| 440 | + detection, independent of run_work_node.""" | |
| 441 | + | |
| 442 | + def test_returns_only_non_done_node_exec_ids(self): | |
| 443 | + self.conn.execute( | |
| 444 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | |
| 445 | + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) " | |
| 446 | + "VALUES ('node-2', 'run-1', 'n2', 'coder', 'skill-1', 'in.json', 'out.json', '{}', ?, 'done')", | |
| 447 | + (json.dumps({"value": 1}),), | |
| 448 | + ) | |
| 449 | + self.conn.execute( | |
| 450 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | |
| 451 | + "input_schema_ref, output_schema_ref, input_payload, status) " | |
| 452 | + "VALUES ('node-3', 'run-1', 'n3', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'retrying')" | |
| 453 | + ) | |
| 454 | + self.conn.commit() | |
| 455 | + resumable = work_node_run.resume_work_graph_run(self.conn, "run-1") | |
| 456 | + self.assertEqual(sorted(resumable), ["node-1", "node-3"]) | |
| 457 | + | |
| 458 | + def test_nonexistent_run_raises_corrupt_state(self): | |
| 459 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 460 | + work_node_run.resume_work_graph_run(self.conn, "no-such-run") | |
| 461 | + | |
| 462 | + def test_invalid_run_status_raises_corrupt_state(self): | |
| 463 | + # unreachable via a normal write (work_graph_run.status has its own | |
| 464 | + # CHECK constraint) -- ignore_check_constraints simulates the kind of | |
| 465 | + # externally-corrupted row REQ-06 must still detect. | |
| 466 | + self.conn.execute("PRAGMA ignore_check_constraints = ON") | |
| 467 | + self.conn.execute("UPDATE work_graph_run SET status = 'bogus_status' WHERE run_id = 'run-1'") | |
| 468 | + self.conn.commit() | |
| 469 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 470 | + work_node_run.resume_work_graph_run(self.conn, "run-1") | |
| 471 | + | |
| 472 | + def test_done_node_missing_output_payload_raises_corrupt_state(self): | |
| 473 | + self.conn.execute( | |
| 474 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | |
| 475 | + "input_schema_ref, output_schema_ref, input_payload, status) " | |
| 476 | + "VALUES ('node-2', 'run-1', 'n2', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'done')" | |
| 477 | + ) | |
| 478 | + self.conn.commit() | |
| 479 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 480 | + work_node_run.resume_work_graph_run(self.conn, "run-1") | |
| 481 | + | |
| 482 | + def test_waiting_for_quota_node_missing_timestamp_raises_corrupt_state(self): | |
| 483 | + self.conn.execute( | |
| 484 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | |
| 485 | + "input_schema_ref, output_schema_ref, input_payload, status) " | |
| 486 | + "VALUES ('node-2', 'run-1', 'n2', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'waiting_for_quota')" | |
| 487 | + ) | |
| 488 | + self.conn.commit() | |
| 489 | + with self.assertRaises(work_node_run.CorruptPersistedStateError): | |
| 490 | + work_node_run.resume_work_graph_run(self.conn, "run-1") | |
| 491 | + | |
| 492 | + | |
| 340 | 493 | if __name__ == "__main__": |
| 341 | 494 | unittest.main() | ... | ... |
src/work_node_run.py
| ... | ... | @@ -34,6 +34,7 @@ import re |
| 34 | 34 | import subprocess |
| 35 | 35 | import time |
| 36 | 36 | import uuid |
| 37 | +from datetime import datetime, timezone | |
| 37 | 38 | |
| 38 | 39 | from src import paths |
| 39 | 40 | |
| ... | ... | @@ -41,6 +42,12 @@ _USAGE_LIMIT_RE = re.compile(r"hit your (session|weekly|opus) limit", re.IGNOREC |
| 41 | 42 | _BACKOFF_SCHEDULE_MINUTES = (5, 10, 20, 30) # then repeats at 30 until the type's timeout |
| 42 | 43 | _TIMEOUT_SECONDS = {"session": 8 * 3600, "weekly": 9 * 24 * 3600} |
| 43 | 44 | _GIT_IDENTITY = ["-c", "user.name=graphlemon", "-c", "user.email=graphlemon@localhost"] |
| 45 | +_VALID_WORK_GRAPH_RUN_STATUSES = { | |
| 46 | + "planning", "running", "merging", "verifying", "reflecting", "blocked", "done", "failed", | |
| 47 | +} | |
| 48 | +_VALID_NODE_STATUSES = { | |
| 49 | + "pending", "running", "reflecting", "retrying", "waiting_for_quota", "blocked", "done", "failed", | |
| 50 | +} | |
| 44 | 51 | |
| 45 | 52 | |
| 46 | 53 | class SchemaValidationFailedError(Exception): |
| ... | ... | @@ -63,6 +70,25 @@ class UsageLimitWaitTimeoutError(Exception): |
| 63 | 70 | c_work_node_run itself not knowing about `task` at all (03-C §3.6).""" |
| 64 | 71 | |
| 65 | 72 | |
| 73 | +class CorruptPersistedStateError(Exception): | |
| 74 | + """REQ-06 failure case: persisted work_graph_run/work_node_execution state | |
| 75 | + is internally inconsistent after a restart -- must error clearly, not | |
| 76 | + silently continue on bogus state. No 03-C error code is named for this | |
| 77 | + (it's not a normal state-machine transition), but REQ-06 requires exactly | |
| 78 | + this behavior; callers should route it to task.status='blocked_pending_human' | |
| 79 | + same as c_work_node_run itself doesn't touch `task` directly (03-C §3.6).""" | |
| 80 | + | |
| 81 | + | |
| 82 | +def _parse_sqlite_timestamp(text): | |
| 83 | + """SQLite's CURRENT_TIMESTAMP is UTC 'YYYY-MM-DD HH:MM:SS' with no | |
| 84 | + timezone marker -- parsed as UTC explicitly so it's comparable against | |
| 85 | + time.time() (also UTC epoch seconds) across a process restart. A plain | |
| 86 | + monotonic clock can't do this: it has no fixed relationship to wall-clock | |
| 87 | + time and resets meaning across restarts, which is exactly REQ-06's | |
| 88 | + scenario -- so `now`'s contract here is wall-clock, not monotonic.""" | |
| 89 | + return datetime.strptime(text, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).timestamp() | |
| 90 | + | |
| 91 | + | |
| 66 | 92 | def _validate_schema_lite(payload, schema): |
| 67 | 93 | """object/required only -- everything this codebase's schemas use so far.""" |
| 68 | 94 | if schema.get("type") == "object": |
| ... | ... | @@ -123,7 +149,7 @@ def run_work_node( |
| 123 | 149 | needs_worktree=False, |
| 124 | 150 | repo_path=None, |
| 125 | 151 | sleep=time.sleep, |
| 126 | - now=time.monotonic, | |
| 152 | + now=time.time, | |
| 127 | 153 | opus_downgrade_model="claude-sonnet-5", |
| 128 | 154 | ): |
| 129 | 155 | """Runs one Work Node to contract. `run_fn(cwd, model) -> output_payload` |
| ... | ... | @@ -132,6 +158,14 @@ def run_work_node( |
| 132 | 158 | opus-quota-triggered retry, in which case it's `opus_downgrade_model`. |
| 133 | 159 | Returns the validated output_payload. |
| 134 | 160 | |
| 161 | + REQ-06 (breakpoint recovery): safe to call again after a process restart | |
| 162 | + for a node_exec_id that was already mid-flight. A node already 'done' | |
| 163 | + is never re-run (its stored output_payload is returned as-is); a node | |
| 164 | + interrupted mid-'retrying' or mid-'waiting_for_quota' resumes from its | |
| 165 | + persisted retry_count / quota_wait_started_at rather than restarting | |
| 166 | + those counters at zero -- `now` must be wall-clock (default time.time) | |
| 167 | + for the quota-wait resume math to mean anything across a restart. | |
| 168 | + | |
| 135 | 169 | Raises InvalidInputError (input schema), UsageLimitWaitTimeoutError |
| 136 | 170 | (p_usage_limit_wait exhausted), or leaves work_node_execution.status |
| 137 | 171 | ='blocked' with a SchemaValidationFailedError raised once max_retries is |
| ... | ... | @@ -140,14 +174,38 @@ def run_work_node( |
| 140 | 174 | if not _validate_schema_lite(input_payload, input_schema): |
| 141 | 175 | raise InvalidInputError("input_payload does not match input_schema_ref for " + node_exec_id) |
| 142 | 176 | |
| 143 | - project_id, task_id = conn.execute( | |
| 144 | - "SELECT p.project_id, wgr.task_id FROM work_node_execution wne " | |
| 177 | + existing = conn.execute( | |
| 178 | + "SELECT p.project_id, wgr.task_id, wne.status, wne.retry_count, wne.output_payload, " | |
| 179 | + "wne.quota_wait_started_at, wne.quota_limit_type " | |
| 180 | + "FROM work_node_execution wne " | |
| 145 | 181 | "JOIN work_graph_run wgr ON wgr.run_id = wne.run_id " |
| 146 | 182 | "JOIN task t ON t.task_id = wgr.task_id " |
| 147 | 183 | "JOIN project p ON p.project_id = t.project_id " |
| 148 | 184 | "WHERE wne.node_exec_id = ?", |
| 149 | 185 | (node_exec_id,), |
| 150 | 186 | ).fetchone() |
| 187 | + if existing is None: | |
| 188 | + raise CorruptPersistedStateError("no such work_node_execution: " + node_exec_id) | |
| 189 | + ( | |
| 190 | + project_id, task_id, existing_status, existing_retry_count, existing_output_payload, | |
| 191 | + existing_quota_wait_started_at, existing_quota_limit_type, | |
| 192 | + ) = existing | |
| 193 | + | |
| 194 | + if existing_status == "done": | |
| 195 | + # REQ-06 happy: never re-run an already-completed node. | |
| 196 | + if existing_output_payload is None: | |
| 197 | + raise CorruptPersistedStateError( | |
| 198 | + "work_node_execution {} is status=done but has no output_payload".format(node_exec_id) | |
| 199 | + ) | |
| 200 | + return json.loads(existing_output_payload) | |
| 201 | + if existing_status not in _VALID_NODE_STATUSES: | |
| 202 | + raise CorruptPersistedStateError( | |
| 203 | + "work_node_execution {} has invalid status {!r}".format(node_exec_id, existing_status) | |
| 204 | + ) | |
| 205 | + if existing_status == "waiting_for_quota" and not existing_quota_wait_started_at: | |
| 206 | + raise CorruptPersistedStateError( | |
| 207 | + "work_node_execution {} is status=waiting_for_quota but has no quota_wait_started_at".format(node_exec_id) | |
| 208 | + ) | |
| 151 | 209 | |
| 152 | 210 | log_file_id = str(uuid.uuid4()) |
| 153 | 211 | log_path = paths.log_file_path(project_id, task_id, node_exec_id, node_exec_id) |
| ... | ... | @@ -162,16 +220,29 @@ def run_work_node( |
| 162 | 220 | worktree_path = None |
| 163 | 221 | try: |
| 164 | 222 | if needs_worktree: |
| 165 | - worktree_path = create_worktree(repo_path, node_exec_id) | |
| 166 | - conn.execute( | |
| 167 | - "UPDATE work_node_execution SET git_worktree_ref = ? WHERE node_exec_id = ?", | |
| 168 | - (worktree_path, node_exec_id), | |
| 169 | - ) | |
| 170 | - conn.commit() | |
| 223 | + existing_worktree_ref = conn.execute( | |
| 224 | + "SELECT git_worktree_ref FROM work_node_execution WHERE node_exec_id = ?", (node_exec_id,) | |
| 225 | + ).fetchone()[0] | |
| 226 | + # REQ-06: resuming a node that already had a worktree from before | |
| 227 | + # the restart must reuse it, not `git worktree add` the same | |
| 228 | + # branch/path again (which would just fail). | |
| 229 | + if existing_worktree_ref and os.path.isdir(existing_worktree_ref): | |
| 230 | + worktree_path = existing_worktree_ref | |
| 231 | + else: | |
| 232 | + worktree_path = create_worktree(repo_path, node_exec_id) | |
| 233 | + conn.execute( | |
| 234 | + "UPDATE work_node_execution SET git_worktree_ref = ? WHERE node_exec_id = ?", | |
| 235 | + (worktree_path, node_exec_id), | |
| 236 | + ) | |
| 237 | + conn.commit() | |
| 171 | 238 | cwd = worktree_path or repo_path |
| 172 | 239 | |
| 173 | - retry_count = 0 | |
| 174 | - quota_wait_start = None | |
| 240 | + retry_count = existing_retry_count or 0 | |
| 241 | + quota_wait_start = ( | |
| 242 | + _parse_sqlite_timestamp(existing_quota_wait_started_at) | |
| 243 | + if existing_status == "waiting_for_quota" | |
| 244 | + else None | |
| 245 | + ) | |
| 175 | 246 | current_model = None |
| 176 | 247 | opus_already_downgraded = False |
| 177 | 248 | while True: |
| ... | ... | @@ -274,3 +345,51 @@ def raise_if_usage_limited(text): |
| 274 | 345 | quota_type = _quota_limit_type(text) |
| 275 | 346 | if quota_type: |
| 276 | 347 | raise _UsageLimitSignal(quota_type) |
| 348 | + | |
| 349 | + | |
| 350 | +def resume_work_graph_run(conn, run_id): | |
| 351 | + """REQ-06 (04-P §4.2 p_work_graph_execution, breakpoint recovery): | |
| 352 | + after a process restart, determine which nodes in `run_id` still need | |
| 353 | + work. Nodes already 'done' are excluded (run_work_node itself also | |
| 354 | + refuses to re-run them; this is what a caller uses to decide what to | |
| 355 | + re-dispatch at all). Raises CorruptPersistedStateError instead of | |
| 356 | + returning a bogus resume plan if the persisted state doesn't hold | |
| 357 | + together -- an invalid work_graph_run.status, an invalid node status, a | |
| 358 | + 'done' node with no output, or a 'waiting_for_quota' node with no | |
| 359 | + quota_wait_started_at (the same per-node checks run_work_node applies, | |
| 360 | + checked here for every node up front rather than one at a time). | |
| 361 | + """ | |
| 362 | + run_row = conn.execute("SELECT status FROM work_graph_run WHERE run_id = ?", (run_id,)).fetchone() | |
| 363 | + if run_row is None: | |
| 364 | + raise CorruptPersistedStateError("no such work_graph_run: " + run_id) | |
| 365 | + if run_row[0] not in _VALID_WORK_GRAPH_RUN_STATUSES: | |
| 366 | + raise CorruptPersistedStateError( | |
| 367 | + "work_graph_run {} has invalid status {!r}".format(run_id, run_row[0]) | |
| 368 | + ) | |
| 369 | + | |
| 370 | + nodes = conn.execute( | |
| 371 | + "SELECT node_exec_id, status, output_payload, quota_wait_started_at FROM work_node_execution " | |
| 372 | + "WHERE run_id = ?", | |
| 373 | + (run_id,), | |
| 374 | + ).fetchall() | |
| 375 | + | |
| 376 | + resumable = [] | |
| 377 | + for node_exec_id, status, output_payload, quota_wait_started_at in nodes: | |
| 378 | + if status not in _VALID_NODE_STATUSES: | |
| 379 | + raise CorruptPersistedStateError( | |
| 380 | + "work_node_execution {} has invalid status {!r}".format(node_exec_id, status) | |
| 381 | + ) | |
| 382 | + if status == "done": | |
| 383 | + if output_payload is None: | |
| 384 | + raise CorruptPersistedStateError( | |
| 385 | + "work_node_execution {} is status=done but has no output_payload".format(node_exec_id) | |
| 386 | + ) | |
| 387 | + continue | |
| 388 | + if status == "waiting_for_quota" and not quota_wait_started_at: | |
| 389 | + raise CorruptPersistedStateError( | |
| 390 | + "work_node_execution {} is status=waiting_for_quota but has no quota_wait_started_at".format( | |
| 391 | + node_exec_id | |
| 392 | + ) | |
| 393 | + ) | |
| 394 | + resumable.append(node_exec_id) | |
| 395 | + return resumable | ... | ... |