# 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)全绿,零回归。