work-graph-partial-artifact-persistence-requirements.md 9.16 KB

Work Graph 产出应逐节点落盘,而不是整图成功才批量落盘——需求文档

日期: 2026-08-07 性质: 需求文档(BRD/PRD 层面),回应一次真实事故的根因排查——任务 bfd3ee82(导航栏功能)和 b243dfe8(#/skills 页面 bug,已有同目标的 7fc3a7a3 成功过)都撞上了同一个真实 bug。 状态: ✅ 已实现(2026-08-07)。

1. 背景

src/e2e_pipeline.py::plan_documents() 和 validate_implementation_with_own_graph() 都是同一个模式:先调用 work_graph_orchestrate.run_graph() 跑完整张图的全部节点,拿到返回的 outputs 字典后,再用一个 for node in topological_order(...) 循环把每个节点的产出批量写成 artifact 行。

问题:如果图里任何一个节点(哪怕是最后一个,比如 test_script)在 run_graph() 内部失败到耗尽重试(ConsistencyCheckFailedError),run_graph() 会直接抛异常,outputs 这个局部变量随着异常传播被整个丢弃——哪怕 BRD/PRD/SDD/Benchmark 都已经成功生成,一个 artifact 都不会落盘。

真实复现链路(bfd3ee82):

  1. Planner 的文档链 BRD→PRD→SDD→Benchmark→TestDataset→TestScript,前五个节点都成功,TestScript 节点因为另一个已修复的 bug(check_script_compiles 返回裸字符串导致 join() 逐字符拆解)连续两次验证失败,run_graph() 抛 ConsistencyCheckFailedError。
  2. 异常一路传到 scheduler.py/task_execution.py 的兜底处理,task.status 变成 blocked_pending_human,但此时没有任何 artifact 被写入。
  3. 人工点 Resume,task.status 恢复到之前记录的 planning_brd_prd_sdd(这是 Planner 的 POST-state,语义是"Planner 已完成,该轮到 Coder 了")。
  4. task_execution.py::run_or_resume_task_lifecycle() 的 planning_brd_prd_sdd 分支直接读取 SDD/PRD/Benchmark artifact 内容准备交给 Coder——但因为第 2 步根本没写任何 artifact,_artifact_content(..., "sdd") 查不到行,抛 TaskExecutionError("task ... has no sdd artifact to resume from"),任务再次卡死,且这次连"重跑一次 Planner"都做不到(resume 逻辑假设走到这个状态时 Planner 一定已经把 artifact 写好了)。

b243dfe8 是同一个根因的更早一次复现(当时判断为"陈旧重复项",实际是同一个 bug,只是恰好有另一个任务 7fc3a7a3 后来重跑成功了,掩盖了这不是任务本身的问题)。

2. 目标

  1. Work Graph 里每个节点成功完成后立即落盘为 artifact,不再等整张图全部成功才批量写。
  2. 一张图里某个节点失败(重试耗尽)时,已经成功的节点产出必须已经落盘——任务变成 blocked_pending_human 后,人工点 Resume 应该能利用这些已落盘的产出继续,而不是从零开始、也不是像现在这样连"没有 artifact"这种最基础的前提都保证不了。
  3. 覆盖两个已知有这个问题的调用点:plan_documents()(Planner 的 BRD→PRD→SDD→Benchmark→TestDataset→TestScript 链)和 validate_implementation_with_own_graph()(Validator 自己的 benchmark→test_dataset→test_script 链)——两处是同一个模式复制出来的,应该用同一个修复方式,不要修一处漏一处。

3. 非目标

  1. 不改 work_graph_orchestrate.run_graph() 本身的执行逻辑——那是 G(并行执行)刚重做过的核心路径,改动面和回归风险都更大。这次修复只动 plan_documents()/validate_implementation_with_own_graph() 这两个调用方自己怎么处理 run_graph() 的返回值/异常,不碰 run_graph() 内部。
  2. 不要求"部分失败"之后自动决定要不要继续跑剩下的节点——那是失败处理策略的问题(比如要不要自动重试整张图剩下的部分),本需求只解决"已经成功的部分有没有被记住"这一件事,继续不继续是既有的 Org-level Loop/人工 Resume 机制的职责,不重新设计。
  3. 不改变任务失败后的对外状态语义——图里有节点失败,任务依然应该变成 blocked_pending_human,不是这次改动想让它"看起来像成功了"。只是失败之后,已经做对的那部分工作不应该被扔掉。

4. 功能需求

# 需求描述 优先级
FR-1 plan_documents()/validate_implementation_with_own_graph() 里"把节点产出写成 artifact"这一步,数据来源要改成直接查 work_node_execution 表里 status='done' 的行(output_payload 已经是 REQ-06 断点续跑机制本来就会持久化的字段,不是新数据源),而不是依赖 run_graph() 成功返回后的内存 outputs 字典——这样无论 run_graph() 是正常返回还是中途抛异常,只要节点在 DB 里已经是 done,就能被正确落盘。 必须
FR-2 run_graph() 抛出的异常(ConsistencyCheckFailedError 等)在完成 FR-1 的落盘之后必须原样重新抛出——不能吞掉,任务该进 blocked_pending_human 还是要进,这次修复不改变失败语义(见非目标 3)。 必须
FR-3 落盘循环要能正确跳过"没跑到/没成功"的节点,不能因为某个节点没有 output_payload 就整个循环报错——已有的 deps 计算逻辑("依赖没落盘就不算进 deps 里")本来就是为这种情况设计的,复用它,不要重新发明。 必须
FR-4 Planner 和 Validator 两处调用点要复用同一份"按 DB 里的 done 节点落盘"辅助逻辑,不要各写一份——两处现在的写法本来就是复制出来的,统一之后以后再出现第三处类似场景也有地方复用。 应该

5. 验收标准

  1. 人为构造一个"前面节点成功、最后一个节点连续验证失败"的场景(复现 bfd3ee82 的真实序列),验证 run_graph() 抛异常之后,前面成功节点对应的 artifact 确实已经写入 artifact 表,且原始异常被正确重新抛出(调用方拿到的还是 ConsistencyCheckFailedError,不是别的)。
  2. 验证正常全部成功的路径行为不变——既有测试套件不需要修改断言就应该继续通过。
  3. 验证 Planner 侧修好之后,一个曾经因为这个 bug 卡死的任务,点 Resume 后不会再报"没有 SDD artifact 可恢复"。
  4. Validator 侧同样验证一遍(构造一个 Validator 自己的 benchmark→test_dataset→test_script 链中途失败的场景)。

6. 约束与假设

  • 复用 REQ-06 已经建立的"work_node_execution.output_payload 是节点成功后的权威持久化数据"这个既有约定,不引入新的数据来源或新的持久化机制。
  • 这次修复完全在 e2e_pipeline.py 内部(两个调用方函数),不涉及 work_graph_orchestrate.py/work_node_run.py 的任何改动。

7. 已实现

新增 src/e2e_pipeline.py::_write_completed_node_artifacts(),plan_documents()/validate_implementation_with_own_graph() 都改成:run_graph() 包一层 try/except,无论成功还是失败都调用这个共享辅助函数落盘,失败时原始异常原样重新抛出。

实现过程中发现的一个比原设计更细的坑,FR-1 原文没写清楚:work_node_execution.status='done' 只代表"通过了 schema 校验",不代表"通过了 verify_fn 的语义检查"——一个节点被 verify_fn 拒绝、重试一次后仍然被拒绝(ConsistencyCheckFailedError),它自己和它的"-reflect"重试节点在 DB 里其实都是 status='done'(只是内容都不合格)。如果单纯按"状态是不是 done"来判断能不能落盘,会把verify_fn 明确拒绝的内容也当成"已完成"错误地写成 artifact。修复方式:给 ConsistencyCheckFailedError 加了 node_id 属性(work_graph_orchestrate.py),_write_completed_node_artifacts 用它(以及 WorkGraphLayerError.errors,覆盖未来多节点并行层的等价场景)排除掉这些节点,不只是排除"根本没跑完"的节点。

同时顺带修了一个只有在这次实现验证时才会暴露的遗留 bug:validate_implementation_with_own_graph() 早些时候那次"结束时标记 work_graph_run.status='done'"的修复,只覆盖了成功路径——如果 run_graph() 本身抛出的不是 ConsistencyCheckFailedError(那个走的是 _verify_and_maybe_regenerate 自己的 status='blocked' 写入,已经安全),而是别的异常类型,work_graph_run 还是会卡在 running。这次统一走 _write_completed_node_artifacts 之后,失败路径已经不需要再单独处理这个——_verify_and_maybe_regenerate 自己的 blocked 写入本来就覆盖了目前唯一真实会走到这条路径的异常类型,测试里专门加了一条断言确认这一点没有回归。

新增测试:src/test_e2e_pipeline.py 的 PlanDocumentsTest.test_earlier_nodes_survive_a_later_node_failing_verification、ValidateImplementationWithOwnGraphTest.test_earlier_nodes_survive_a_later_node_failing_verification——都验证"前面节点产出确实落盘、失败节点本身不落盘、原始异常原样抛出"。python3 -m unittest discover -s src(641 tests)+ discover -s supervisor -p "test_*.py"(126 tests)全绿,零回归。