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,6 +201,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 201 | - **U-021 [Risk Gate]** Graph Engine 持久化状态 + 断点恢复 | 201 | - **U-021 [Risk Gate]** Graph Engine 持久化状态 + 断点恢复 |
| 202 | - 依赖:U-012, U-013 | 202 | - 依赖:U-012, U-013 |
| 203 | - Done:REQ-06 happy/boundary/failure 通过(执行中途中断进程后从持久化状态恢复,不重跑已完成节点) | 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 | - 覆盖:01-M `work_graph_run`/`work_node_execution`;04-P `p_work_graph_execution`;REQ-06 | 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,7 +34,7 @@ | ||
| 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 回填)"}, | 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 | {"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 的事"}, | 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 | {"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 类型"}, | 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 | {"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 全部通过"}, | 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 | {"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"}, | 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,5 +337,158 @@ class RealGitWorktreeIsolationTest(WorkNodeRunTestBase): | ||
| 337 | self.assertIn("module_b.txt", log.stdout) | 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 | if __name__ == "__main__": | 493 | if __name__ == "__main__": |
| 341 | unittest.main() | 494 | unittest.main() |
src/work_node_run.py
| @@ -34,6 +34,7 @@ import re | @@ -34,6 +34,7 @@ import re | ||
| 34 | import subprocess | 34 | import subprocess |
| 35 | import time | 35 | import time |
| 36 | import uuid | 36 | import uuid |
| 37 | +from datetime import datetime, timezone | ||
| 37 | 38 | ||
| 38 | from src import paths | 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,6 +42,12 @@ _USAGE_LIMIT_RE = re.compile(r"hit your (session|weekly|opus) limit", re.IGNOREC | ||
| 41 | _BACKOFF_SCHEDULE_MINUTES = (5, 10, 20, 30) # then repeats at 30 until the type's timeout | 42 | _BACKOFF_SCHEDULE_MINUTES = (5, 10, 20, 30) # then repeats at 30 until the type's timeout |
| 42 | _TIMEOUT_SECONDS = {"session": 8 * 3600, "weekly": 9 * 24 * 3600} | 43 | _TIMEOUT_SECONDS = {"session": 8 * 3600, "weekly": 9 * 24 * 3600} |
| 43 | _GIT_IDENTITY = ["-c", "user.name=graphlemon", "-c", "user.email=graphlemon@localhost"] | 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 | class SchemaValidationFailedError(Exception): | 53 | class SchemaValidationFailedError(Exception): |
| @@ -63,6 +70,25 @@ class UsageLimitWaitTimeoutError(Exception): | @@ -63,6 +70,25 @@ class UsageLimitWaitTimeoutError(Exception): | ||
| 63 | c_work_node_run itself not knowing about `task` at all (03-C §3.6).""" | 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 | def _validate_schema_lite(payload, schema): | 92 | def _validate_schema_lite(payload, schema): |
| 67 | """object/required only -- everything this codebase's schemas use so far.""" | 93 | """object/required only -- everything this codebase's schemas use so far.""" |
| 68 | if schema.get("type") == "object": | 94 | if schema.get("type") == "object": |
| @@ -123,7 +149,7 @@ def run_work_node( | @@ -123,7 +149,7 @@ def run_work_node( | ||
| 123 | needs_worktree=False, | 149 | needs_worktree=False, |
| 124 | repo_path=None, | 150 | repo_path=None, |
| 125 | sleep=time.sleep, | 151 | sleep=time.sleep, |
| 126 | - now=time.monotonic, | 152 | + now=time.time, |
| 127 | opus_downgrade_model="claude-sonnet-5", | 153 | opus_downgrade_model="claude-sonnet-5", |
| 128 | ): | 154 | ): |
| 129 | """Runs one Work Node to contract. `run_fn(cwd, model) -> output_payload` | 155 | """Runs one Work Node to contract. `run_fn(cwd, model) -> output_payload` |
| @@ -132,6 +158,14 @@ def run_work_node( | @@ -132,6 +158,14 @@ def run_work_node( | ||
| 132 | opus-quota-triggered retry, in which case it's `opus_downgrade_model`. | 158 | opus-quota-triggered retry, in which case it's `opus_downgrade_model`. |
| 133 | Returns the validated output_payload. | 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 | Raises InvalidInputError (input schema), UsageLimitWaitTimeoutError | 169 | Raises InvalidInputError (input schema), UsageLimitWaitTimeoutError |
| 136 | (p_usage_limit_wait exhausted), or leaves work_node_execution.status | 170 | (p_usage_limit_wait exhausted), or leaves work_node_execution.status |
| 137 | ='blocked' with a SchemaValidationFailedError raised once max_retries is | 171 | ='blocked' with a SchemaValidationFailedError raised once max_retries is |
| @@ -140,14 +174,38 @@ def run_work_node( | @@ -140,14 +174,38 @@ def run_work_node( | ||
| 140 | if not _validate_schema_lite(input_payload, input_schema): | 174 | if not _validate_schema_lite(input_payload, input_schema): |
| 141 | raise InvalidInputError("input_payload does not match input_schema_ref for " + node_exec_id) | 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 | "JOIN work_graph_run wgr ON wgr.run_id = wne.run_id " | 181 | "JOIN work_graph_run wgr ON wgr.run_id = wne.run_id " |
| 146 | "JOIN task t ON t.task_id = wgr.task_id " | 182 | "JOIN task t ON t.task_id = wgr.task_id " |
| 147 | "JOIN project p ON p.project_id = t.project_id " | 183 | "JOIN project p ON p.project_id = t.project_id " |
| 148 | "WHERE wne.node_exec_id = ?", | 184 | "WHERE wne.node_exec_id = ?", |
| 149 | (node_exec_id,), | 185 | (node_exec_id,), |
| 150 | ).fetchone() | 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 | log_file_id = str(uuid.uuid4()) | 210 | log_file_id = str(uuid.uuid4()) |
| 153 | log_path = paths.log_file_path(project_id, task_id, node_exec_id, node_exec_id) | 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,16 +220,29 @@ def run_work_node( | ||
| 162 | worktree_path = None | 220 | worktree_path = None |
| 163 | try: | 221 | try: |
| 164 | if needs_worktree: | 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 | cwd = worktree_path or repo_path | 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 | current_model = None | 246 | current_model = None |
| 176 | opus_already_downgraded = False | 247 | opus_already_downgraded = False |
| 177 | while True: | 248 | while True: |
| @@ -274,3 +345,51 @@ def raise_if_usage_limited(text): | @@ -274,3 +345,51 @@ def raise_if_usage_limited(text): | ||
| 274 | quota_type = _quota_limit_type(text) | 345 | quota_type = _quota_limit_type(text) |
| 275 | if quota_type: | 346 | if quota_type: |
| 276 | raise _UsageLimitSignal(quota_type) | 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 |