Commit 15385d6054f9954c33fccb40c80efa4a9b0e4a82

Authored by bboonnee
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>
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&quot;hit your (session|weekly|opus) limit&quot;, 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
... ...