From 0a4c2cfb8b45b5489530853f8aee5ba477feadb3 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Sat, 8 Aug 2026 14:27:24 +0800 Subject: [PATCH] docs: move fully-implemented todo docs to docs/done/ --- docs/done/work-graph-partial-artifact-persistence-requirements.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/work-graph-self-planning-and-loop-back-design.md | 643 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/done/work-graph-self-planning-and-loop-back-requirements.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/todo/org-graph-as-work-graph-unified-loop-requirements.md | 4 ++-- docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md | 4 ++-- docs/todo/validator-test-script-narrative-output-issue.md | 2 +- docs/todo/work-graph-partial-artifact-persistence-requirements.md | 62 -------------------------------------------------------------- docs/todo/work-graph-self-planning-and-loop-back-design.md | 643 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- docs/todo/work-graph-self-planning-and-loop-back-requirements.md | 106 ---------------------------------------------------------------------------------------------------------- src/schema.py | 6 +++--- src/task_query.py | 2 +- src/test_schema.py | 2 +- src/work_graph_plan.py | 2 +- 13 files changed, 822 insertions(+), 822 deletions(-) create mode 100644 docs/done/work-graph-partial-artifact-persistence-requirements.md create mode 100644 docs/done/work-graph-self-planning-and-loop-back-design.md create mode 100644 docs/done/work-graph-self-planning-and-loop-back-requirements.md delete mode 100644 docs/todo/work-graph-partial-artifact-persistence-requirements.md delete mode 100644 docs/todo/work-graph-self-planning-and-loop-back-design.md delete mode 100644 docs/todo/work-graph-self-planning-and-loop-back-requirements.md diff --git a/docs/done/work-graph-partial-artifact-persistence-requirements.md b/docs/done/work-graph-partial-artifact-persistence-requirements.md new file mode 100644 index 0000000..61c35f4 --- /dev/null +++ b/docs/done/work-graph-partial-artifact-persistence-requirements.md @@ -0,0 +1,62 @@ +# 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)全绿,零回归。 diff --git a/docs/done/work-graph-self-planning-and-loop-back-design.md b/docs/done/work-graph-self-planning-and-loop-back-design.md new file mode 100644 index 0000000..3747bcb --- /dev/null +++ b/docs/done/work-graph-self-planning-and-loop-back-design.md @@ -0,0 +1,643 @@ +# Planner 动态自规划 Work Graph(含节点判断与失败回路)——详细设计(SDD) + +**日期**: 2026-08-08 +**性质**: 详细设计,对应需求文档 `docs/done/work-graph-self-planning-and-loop-back-requirements.md`(已确认)。本设计给出可直接实施的 schema/接口/算法/测试方案,逐条落实需求文档「8. 已确认的决策」。 +**依赖代码**:`src/work_graph_orchestrate.py`(`run_graph`/`topological_layers`/`_run_one_node`/`_verify_and_maybe_regenerate`/`_ensure_node_row`/`_LockedConn`)、`src/work_graph_plan.py`(`_PROMPT_TEMPLATE`/`_normalize_graph_manifest`/`GRAPH_MANIFEST_SCHEMA`)、`src/e2e_pipeline.py`(`plan_documents`/`check_script_compiles`/`_check_prd_consistency`)、`src/settings_config.py`(`get_/upsert_concurrency_config` 先例)、`src/schema.py`(`migrate_expand_artifact_types` 先例)。 +**状态**: ✅ 已实现(2026-08-08)。实施顺序按第 11 节执行,`python3 -m unittest discover -s src`(692 tests)+ `discover -s supervisor -p "test_*.py"`(126 tests)全绿。实现过程中用真实测试发现并修正了本设计原文的几处问题(设计文档本身未回填,实现是最终准确来源): +- §2 最初的 `on_fail.target_node_id` 字段设计,在实现前按用户反馈改成了从 `on_failure` 边读取(已反映在正文),但 §4.0(`topological_layers()` 必须过滤 `on_failure` 边)和 §8.3(`_validate_loop_back_targets` 改成校验边而不是字段)是**开始写代码之后**才发现遗漏并现场修的两处,不是设计阶段就想全的。 +- `_next_exec_node_id`(原设计称 `_count_prior_attempts`,按行数计数)在真实测试中暴露两个问题:① 按 DB 行数计数会把"resume 一个从未失败过的节点"误判成需要新开 attempt 后缀;② 只统计"自己是不是直接 loop_back 目标"会漏掉"作为下游节点被牵连重跑"的情况。最终实现改成按 `execution_event` 里真实的 loop_back 触发次数、且用 `_transitive_dependents` 判断是否被牵连来计数,两个问题都已修复并有专门测试覆盖。 +- `_run_one_node` 最初设计里"先统一调用 verify_fn 判断、再决定走哪条分支"的写法,会导致legacy(无 judgment 声明)节点的 verify_fn 被调用两次(`_verify_and_maybe_regenerate` 内部自己还会再调一次)——测试跑出来直接暴露,改成先判断 `has_own_judgment` 决定路径,只有新路径才提前调用 verify_fn。 +- `work_graph_plan.py::_PROMPT_TEMPLATE` 最初设计的"先 `.format()` 填 catalog_json、留 `{goal_text}` 给以后再填一次"写法有真实的转义 bug(catalog JSON 自带的花括号会被第二次 `.format()` 误当成占位符),实现时改成用字符串拼接嵌入 catalog、最终用 `.replace("{goal_text}", goal_text)` 而不是 `.format()` 填充目标文本,彻底绕开转义问题。 +- 顺带在真实测试中发现一个**不在本设计范围内、`work_node_run.py` 自己的既有缺口**:`run_work_node()` 每次调用都无条件往 `execution_log_file` 插入一行新记录,没有检查该 `node_exec_id` 是否已有记录——如果一个节点在 `execute_work_graph()` 真正返回之前就进程崩溃(不是"干净的 done",也不是全新节点),下次对同一个 `node_exec_id` 调用 `run_work_node()` 会撞 `execution_log_file.node_exec_id` 的 UNIQUE 约束报错。这个缺口在 loop_back 机制之前就存在(任何节点崩溃在这个时间窗口都会触发,不是 loop_back 特有的),只是这次因为测试真实构造了"进程崩溃后重启"的场景才被发现——不在本次范围内修复,记录在案供后续单独立项。 + +--- + +## 1. 设计概览 + +现状(`work_graph_orchestrate.py`,见其自身 docstring 与代码):`run_graph()` 把 `graph_manifest` 用 `topological_layers()` 分层,层间屏障、层内(G,2026-08-06)用 `ThreadPoolExecutor` 并发;每个节点的判断逻辑完全靠调用方传入的单一 `verify_fn(node, output) -> (ok, feedback)` 回调,失败处理固定死在 `_verify_and_maybe_regenerate()` 里——原地重新生成同一节点恰好一次,还失败就 `ConsistencyCheckFailedError`,没有"跳回更早节点"这条路径。 + +改造后新增两条能力,**都以 `graph_manifest` 节点自身携带的声明式字段驱动,不再要求调用方为每种 artifact_type 手写 Python 分支**: + +1. **`judgment`**:节点自己说"怎么判断我的输出对不对"(机械检查 or 触发一次额外 LLM 判断)。 +2. **`on_fail`**:节点自己说"判断失败了怎么办"(原地重试 / 跳回更早节点重新执行 / 直接判失败)。 + +**不改变的部分**(刻意保持稳定): +- 每个节点仍然是"渲染成一个单节点 workflow 脚本 + 独立 `claude -p` 子进程调用"(`_make_run_fn`/`_render_node_script` 不变)。 +- G(并行执行)的层内并发机制、`_LockedConn`、`WorkGraphLayerError` 完全不变——回路是"层与层之间的控制流",跟"层内节点并发执行"是正交的两层机制,互不冲突(详见第 4 节)。 +- 不声明 `judgment`/`on_fail` 的现有 `graph_manifest`(Coder 的合成单节点图)逐字节行为不变(FR-A8,第 8 节有完整核对表)。 +- `run_work_node()`/`work_node_run.py` 的断点续跑状态机完全不变,回路复用它现成的"新 node_id → 新行 → 真实重跑"机制(第 5 节),不需要改这一层。 + +## 2. `graph_manifest` 节点 schema 扩展 + +```jsonc +{ + "node_id": "benchmark", + "artifact_type": "benchmark", // 沿用现状;_GENERIC_PROMPT_TEMPLATE 路径用 task_prompt 代替,同样适用 + "depends_on": ["sdd"], + + // 新增,可选。缺省等价于 {"kind": "none"} —— 不做任何自动判断, + // 节点输出直接被接受(如果调用方仍然传了外部 verify_fn,外部的继续生效,见第 3 节)。 + "judgment": { + "kind": "mechanical", // "mechanical" | "llm" | "none" + "check_ref": "check_script_compiles" // 仅 kind="mechanical" 时必填,见第 3.1 节的白名单机制 + // kind="llm" 时改用: + // "judge_prompt": "Review the following content for ...: {content}" + // —— 由规划者(Planner 自己,或人工在固定链里手写)自己撰写, + // self-contained,不依赖调用方额外拼接上下文(同 _GENERIC_PROMPT_TEMPLATE + // 的 task_prompt 设计原则)。 + }, + + // 新增,可选。缺省等价于 {"action": "hard_fail"} 当没有 judgment 时; + // 当声明了 judgment 但没声明 on_fail 时,缺省等价于 {"action": "retry_self", "max_attempts": 1} + // ——即完全复刻现状 _verify_and_maybe_regenerate() 的行为(原地重试恰好一次)。 + "on_fail": { + "action": "loop_back", // "retry_self" | "loop_back" | "hard_fail" + "max_attempts": 3 // 可选,覆盖 FR-A5 配置表给出的默认值 + // 注意:没有 target_node_id 字段——action="loop_back" 的目标节点不是 + // node 自己声明的一个独立字段,而是从 graph_manifest 的 edges 里读出来 + // 的(见下)。retry_self/hard_fail 不需要任何 edge,target 就是自己/无。 + } +} +``` + +**2026-08-08 修订(用户反馈)**:失败路径的目标节点不应该是节点自己声明的、跟 `edges` 脱节的一个独立字段——"下一个节点是谁(成功路径)、失败了回到哪个节点(失败路径),都应该在规划这个节点的时候,用边(edge)定义好",跟成功路径的 `depends_on`/`edges` 是同一套结构,不是节点身上另开的一个影子字段。所以 `on_fail.loop_back` 的目标改成**从一条新的 `edge_type: "on_failure"` 边读出来**,不再单独存 `target_node_id`: + +```jsonc +// graph_manifest 顶层 edges 数组: +{"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, // 成功路径:sdd 产出喂给 benchmark(既有语义,depends_on 的另一种表达) +{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"} // 失败路径:benchmark 判断失败时回到 sdd 重新执行 +``` + +即:一个节点的"下一步去哪"完全由它参与的 `edges` 决定——`needs_judgment`/`zero_token` 两种既有类型继续表达"成功之后数据往哪流",新增的 `on_failure` 表达"判断失败之后控制流跳去哪"。节点自己的 `on_fail.action` 只声明"失败了要不要 loop_back"这个决策本身,不重复存目标是谁,避免两处数据不一致的风险(比如 `on_fail.target_node_id` 和实际 `edges` 没对上)。`GRAPH_MANIFEST_SCHEMA` 的 `edge_type` 枚举(`work_graph_plan.py:60`)加上 `"on_failure"`。 + +`GRAPH_MANIFEST_SCHEMA`(`work_graph_plan.py:33`)同步补上 `judgment`/`on_fail` 的形状 + `on_failure` edge_type(文档性质,跟现状一样不接入真正的 jsonschema 校验——`--json-schema` 和 `--agent` 不能同时用,见该文件已有说明)。 + +## 3. `run_graph()` 改造:judgment 解析 + +### 3.1 机械检查白名单——注入而不是硬编码 + +`work_graph_orchestrate.py` 现在对 `e2e_pipeline.py` **零依赖**(依赖方向是反过来的),不能直接 `import e2e_pipeline` 去拿 `check_script_compiles`(会成环)。机械检查改成调用方注入的白名单字典: + +```python +def run_graph( + conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, + build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, + verify_fn=None, sleep=time.sleep, now=time.time, max_workers=_DEFAULT_MAX_WORKERS, + mechanical_check_registry=None, # 新增:{check_ref: callable(content) -> (ok, detail)} +): +``` + +`e2e_pipeline.py` 的调用点传 `mechanical_check_registry={"check_script_compiles": check_script_compiles}`。`judgment.kind="mechanical"` 但 `check_ref` 不在注册表里,视为配置错误,直接在 `run_graph()` 一开始(还没跑任何节点前)校验并抛 `ValueError`——不要等跑到那个节点才发现拼错了名字,浪费一整条链的 token。**LLM 只能从白名单里选,不能发明新的机械检查**——这是有意的限制,不做"LLM 生成检查代码后拿去执行"这种沙箱执行的大工程。 + +### 3.2 LLM 判断——复用 `_check_prd_consistency` 的隔离机制,泛化提示词 + +```python +def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180): + """通用化的 _check_prd_consistency(e2e_pipeline.py):同样的 self-target + worktree 隔离 + 独立 agent() 调用机制,但判断提示词来自 judgment['judge_prompt'] + (节点自己声明的,不是硬编码的 PRD 专属提示词),返回 schema 也通用化成 + {"ok": bool, "issues": [str, ...]}(PRD 检查原来的 "consistent"/"issues" 改名 + 成更通用的 "ok",语义不变)。""" + is_self = paths.is_self_target(target_folder_path) + + def verify_fn(node, output): + node_exec_id = "judgment-" + uuid.uuid4().hex[:12] + worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None + cwd = worktree_path or target_folder_path + try: + prompt = judgment["judge_prompt"].format(content=output["content"]) + script_name = "graphlemon-judgment-" + uuid.uuid4().hex + script_content = ( + "export const meta = {{ name: {name}, description: 'graphlemon node judgment' }}\n\n" + "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema} }})\n\n" + "return result\n" + ).format( + name=json.dumps(script_name), prompt=json.dumps(prompt), + org_node_json=json.dumps(org_node), schema=json.dumps(_JUDGMENT_SCHEMA), + ) + work_graph_execute.write_workflow_script(cwd, script_name, script_content) + result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _JUDGMENT_SCHEMA, timeout=timeout) + if worktree_path: + work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) + work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") + work_node_run.merge_worktree(target_folder_path, node_exec_id) + ok = _coerce_bool(result["ok"]) + return ok, (result.get("issues") or []) + finally: + if worktree_path: + work_node_run.remove_worktree(target_folder_path, worktree_path) + + return verify_fn + + +_JUDGMENT_SCHEMA = { + "type": "object", "required": ["ok", "issues"], + "properties": {"ok": {"type": "boolean"}, "issues": {"type": "array", "items": {"type": "string"}}}, +} +``` + +`_coerce_bool`(原属 `e2e_pipeline.py`,见其 2026-08-02 的"LLM 把 boolean 回成字符串"教训)需要下沉成 `work_graph_orchestrate.py` 自己的小工具函数(或 `paths.py` 这类共享工具模块),两边都用同一份,不重复实现。`e2e_pipeline.py::_check_prd_consistency`/`_prd_consistency_verify_fn` 保留不动(FR-A8 向后兼容,现有固定链路径不强制迁移),新代码只是提供一个可选的、更通用的平行实现。 + +### 3.3 节点级 verify_fn 解析 + +```python +def _resolve_node_verify_fn(node, external_verify_fn, org_node, target_folder_path, mechanical_check_registry): + judgment = node.get("judgment") + if not judgment or judgment.get("kind", "none") == "none": + return external_verify_fn # 完全现状:没声明 judgment,谁传了外部 verify_fn 就用谁的(可能是 None) + if judgment["kind"] == "mechanical": + check_fn = (mechanical_check_registry or {}).get(judgment["check_ref"]) + if check_fn is None: + raise ValueError("unknown mechanical check_ref: {!r}".format(judgment["check_ref"])) + def verify_fn(node, output, _check_fn=check_fn): + ok, detail = _check_fn(output["content"]) + return ok, ([detail] if detail else None) + return verify_fn + if judgment["kind"] == "llm": + return _llm_judgment_verify_fn(judgment, org_node, target_folder_path) + raise ValueError("unknown judgment.kind: {!r}".format(judgment["kind"])) +``` + +`_run_one_node()` 改成对每个节点各自调用 `_resolve_node_verify_fn(...)` 拿到"这个节点该用哪个 verify_fn",而不是像现在这样整张图共用调用方传入的同一个 `verify_fn`。**向后兼容关键点**:现有调用方(`plan_documents`/`validate_implementation_with_own_graph`)继续传它们现在的 `_combined_planner_verify_fn(...)`/`_test_script_verify_fn(...)` 作为 `external_verify_fn`,只要节点没有 `judgment` 字段(现状 100% 如此),`_resolve_node_verify_fn` 原样返回这个外部函数,行为逐字节不变(FR-A8 验收标准 5)。 + +## 4. `on_fail` 执行:回路引擎 + +### 4.0 前提修复:`topological_layers()` 必须过滤 `on_failure` 边(FR-A7) + +**这是把 loop_back 目标从节点字段改成 `edges` 里的一条边(第 2 节 2026-08-08 修订)之后,必须同步修的一处,否则会引入真环**:现有 `topological_layers()`(`work_graph_orchestrate.py:196-200`)把 `graph_manifest["edges"]` 里**每一条边、不分 `edge_type`**都当成数据依赖合并进 `deps`: + +```python +for edge in graph_manifest.get("edges", []): + to_id, from_id = edge.get("to"), edge.get("from") + if to_id in deps and from_id in nodes: + deps[to_id].add(from_id) +``` + +如果不改,一条 `{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}` 会被这段代码当成"`sdd` 依赖 `benchmark`"塞进 `deps["sdd"]`——而 `benchmark` 本来就依赖 `sdd`(正常数据依赖),两条方向相反的依赖凑在一起就是一个真环,`topological_layers()` 会在图规划阶段还没触发任何回路之前,就先被静态校验直接拒绝,FR-A7 的验收标准 4 会失败。 + +修复:合并 `deps` 时跳过 `edge_type == "on_failure"` 的边——**只有 `on_failure` 需要特殊处理**,`zero_token`/`needs_judgment` 两种既有类型继续按现状合并(它们表达的就是真实数据依赖): + +```python +def topological_layers(graph_manifest): + nodes = {n["node_id"]: n for n in graph_manifest["nodes"]} + deps = {node_id: set(n.get("depends_on") or []) for node_id, n in nodes.items()} + for edge in graph_manifest.get("edges", []): + if edge.get("edge_type") == "on_failure": + continue # 新增:控制流边不参与静态拓扑排序 + to_id, from_id = edge.get("to"), edge.get("from") + if to_id in deps and from_id in nodes: + deps[to_id].add(from_id) + ... # 其余逻辑不变 +``` + +`topological_order()`(薄封装)不用改。这条修复是本设计里**唯一一处直接改动 G(并行执行)已经上线的现有函数**的地方,改动是纯粹的"多一个 `continue` 条件",不影响任何现有(不含 `on_failure` 边的)`graph_manifest` 的分层结果——`src/test_work_graph_orchestrate.py` 里 G 自己的既有测试用例不需要改一行断言即可继续通过。 + +### 4.1 关键简化:`retry_self` 是 `loop_back` 的特例 + +`retry_self` ≡ `loop_back` 且 `target_node_id` = 自己。原因:`retry_self` 触发时这个节点本身还没成功过,不可能已经有下游节点跑完并读取过它的(不存在的)输出,所以"重跑 target 到失败节点之间的下游链"这条规则应用到"target=自己"时,受影响集合就是 `{自己}`,跟现状的"原地重跑"完全等价。**一套机制两种声明方式**,不用维护两条独立代码路径。 + +### 4.2 受影响子图:只重跑真正的下游依赖,不是整层 + +需求文档非目标已声明"不追求完全通用的图执行框架",但"回路触发时把同层不相关的兄弟节点也无谓重跑一遍"没必要,也和已完成节点白白重花 token 相悖,成本可控且实现不复杂: + +```python +def _transitive_dependents(graph_manifest, node_id): + """node_id 自身 + 所有直接/间接依赖它的下游节点 id 集合(沿 depends_on/edges + 正向遍历)。用于 loop_back 时判断哪些节点的输出已经"过期"、必须重跑。""" + dependents = {} # node_id -> [直接依赖它的节点 id, ...] + for node in graph_manifest["nodes"]: + dependents.setdefault(node["node_id"], []) + deps = {n["node_id"]: set(n.get("depends_on") or []) for n in graph_manifest["nodes"]} + for edge in graph_manifest.get("edges", []): + to_id, from_id = edge.get("to"), edge.get("from") + if to_id in deps and from_id in deps: + deps[to_id].add(from_id) + for node_id_, dep_set in deps.items(): + for dep in dep_set: + dependents.setdefault(dep, []).append(node_id_) + + affected, stack = set(), [node_id] + while stack: + current = stack.pop() + if current in affected: + continue + affected.add(current) + stack.extend(dependents.get(current, [])) + return affected + + +def _on_failure_target(graph_manifest, node_id): + """节点 node_id 声明了 on_fail.action='loop_back' 时,目标节点不是节点 + 自己存的字段,而是 graph_manifest['edges'] 里唯一一条 + {"from": node_id, "to": ..., "edge_type": "on_failure"} 边的 "to"—— + 2026-08-08 修订:失败路径跟成功路径一样,都是"边",不是节点身上另开 + 的一个跟 edges 脱节的影子字段。恰好 0 条或 2+ 条这样的边是规划错误, + 在 _validate_loop_back_targets(8.3 节)里提前拦截,这里假设已经过 + 校验,直接找第一条匹配的。""" + for edge in graph_manifest.get("edges", []): + if edge.get("from") == node_id and edge.get("edge_type") == "on_failure": + return edge["to"] + raise ValueError("node {} declares on_fail.action='loop_back' but has no on_failure edge".format(node_id)) +``` + +### 4.3 层级回退循环——`run_graph()` 主循环改造 + +```python +class _LoopBackRequested(Exception): + """节点内部信号:这个节点的 on_fail.action=loop_back 触发了,需要外层 + run_graph() 主循环回退重跑,不是真正的失败——不通过正常返回值传递是 + 因为它可能发生在 ThreadPoolExecutor 的 worker 线程里(层内并发场景), + 异常是唯一能干净地从 future.result() 里带出这个信号的方式。""" + def __init__(self, source_node_id, target_node_id, feedback): + self.source_node_id = source_node_id + self.target_node_id = target_node_id + self.feedback = feedback + super().__init__("node {} requests loop_back to {}".format(source_node_id, target_node_id)) + + +def run_graph(..., mechanical_check_registry=None): + ensure_placeholder_skill(conn, skill_id, org_node) + effective_needs_worktree = needs_worktree or paths.is_self_target(target_folder_path) + layers = topological_layers(graph_manifest) + node_layer_index = {node["node_id"]: i for i, layer in enumerate(layers) for node in layer} + outputs = {} + locked_conn = _LockedConn(conn) + attempt_counts = {} # (target_node_id, source_node_id) -> 已尝试次数,本次 run_graph() 调用内的内存计数 + # 断点续跑时从 execution_event 重建,见第 6.3 节 + + layer_index = 0 + while layer_index < len(layers): + layer = layers[layer_index] + try: + _run_layer( + conn, locked_conn, run_id, org_node, skill_id, target_folder_path, layer, outputs, + build_prompt, input_schema, output_schema, timeout, effective_needs_worktree, + verify_fn, mechanical_check_registry, max_workers, sleep, now, + ) + except _LoopBackRequested as sig: + key = (sig.target_node_id, sig.source_node_id) + attempt_counts[key] = attempt_counts.get(key, 0) + _loop_attempt_count_from_events(conn, run_id, key) + limit = _resolve_loop_limit(conn, org_node, graph_manifest, sig.target_node_id) + if attempt_counts[key] > limit: + conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,)) + conn.commit() + raise ConsistencyCheckFailedError( + "loop_back from {} to {} exceeded max_attempts={}".format( + sig.source_node_id, sig.target_node_id, limit + ), + node_id=sig.source_node_id, + ) + _record_loop_back_event(conn, run_id, sig, attempt_counts[key]) + affected = _transitive_dependents(graph_manifest, sig.target_node_id) + for affected_id in affected: + outputs.pop(affected_id, None) # 强制这些节点在重跑时被当成"未完成" + layer_index = node_layer_index[sig.target_node_id] + continue + layer_index += 1 + + return outputs +``` + +`_run_layer(...)` 是现有 `run_graph()` 主循环体(单节点同步 / 多节点 `ThreadPoolExecutor`)原样搬过来的辅助函数,唯一的改动是:**只重跑本轮 `affected` 集合里的节点**(第一次进入某层时 `affected` 概念不适用,正常跑全部;loop_back 回退重入某层时,只有 `outputs` 里被 `.pop()` 掉的节点需要重新执行,其余节点直接复用已有的 `outputs[node_id]`,不重新调用 `_run_one_node`)——`outputs.pop()` 天然标记了"这个需要重跑",`_run_layer` 判断 `node["node_id"] not in outputs` 决定要不要真的执行这个节点,跳过的节点保留原值。 + +`_run_one_node()` 里判断 `verify_fn` 失败后该走哪条路径(原地重试 vs 抛 `_LoopBackRequested`): + +```python +def _run_one_node(..., node, outputs, ...): + node_id = node["node_id"] + # 每次真正执行都用一个新的 attempt 后缀 node_id,绝不复用同一个已经 + # 'done' 的 node_id——_ensure_node_row 按 (run_id, node_id) 去重,复用 + # 同一个 node_id 会被 run_work_node 的断点续跑短路成"已完成,直接返回 + # 旧输出",这正是必须避免的(详见 5 节)。 + attempt = _count_prior_attempts(conn, run_id, node_id) + 1 + exec_node_id = node_id if attempt == 1 else "{}-attempt{}".format(node_id, attempt) + + node_exec_id = _ensure_node_row(conn, run_id, exec_node_id, org_node, skill_id, ..., {"node_id": node_id}) + prompt = build_prompt(node, outputs) + _loop_history_prompt_suffix(conn, run_id, node_id) + run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) + output = work_node_run.run_work_node(conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, ...) + + resolved_verify_fn = _resolve_node_verify_fn(node, verify_fn, org_node, target_folder_path, mechanical_check_registry) + if resolved_verify_fn is None: + return output + ok, feedback = resolved_verify_fn(node, output) + if ok: + return output + + on_fail = node.get("on_fail") or {"action": "retry_self" if node.get("judgment") else "hard_fail"} + action = on_fail.get("action", "retry_self") + if action == "hard_fail" or (action == "retry_self" and not node.get("judgment")): + # 完全现状路径:没声明 judgment 的老节点,外部 verify_fn 失败时走 + # 原有的 _verify_and_maybe_regenerate()(恰好一次原地重试),逐字节不变。 + return _verify_and_maybe_regenerate(conn, run_id, org_node, skill_id, target_folder_path, + node, node_id, prompt, output, input_schema, output_schema, + timeout, needs_worktree, resolved_verify_fn, sleep, now) + target = node_id if action == "retry_self" else _on_failure_target(graph_manifest, node_id) + raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) +``` + +(`_run_one_node` 需要拿到 `graph_manifest` 才能查 `on_failure` 边——现有签名没有这个参数,改造时顺带传进去;`retry_self` 完全不查边,`target_node_id` 直接等于自己,第 4.1 节已经说明原因。) + +**关键取舍**:`hard_fail`、以及"没声明 `judgment` 的老节点走 `retry_self`"这两种情况,继续复用现有的 `_verify_and_maybe_regenerate()` 一字不改——不是所有失败路径都要改造成新机制,只有节点**主动声明了 `judgment`**(即 Part B 自规划出的新节点,或 Part A 上线后手工升级的固定链节点)才会走新的 `_LoopBackRequested` 回路路径。这保证 FR-A8 的向后兼容不是"理论上兼容"而是"代码路径都没变过"。 + +## 5. 断点续跑兼容性:新 attempt 后缀就是全部答案 + +不需要专门为回路设计新的续跑逻辑。原理:`_run_one_node()` 每次真正执行前,都先查询"这个 node_id 已经有几次真正的执行尝试"(`_count_prior_attempts`,扫 `work_node_execution` 里 `node_id` 匹配 `original_id` 或 `original_id-attemptN` 模式的行数),用下一个序号构造 `exec_node_id`。进程崩溃重启后重新调用 `run_graph()`: + +- 还没跑到的层:`outputs` 里没有对应 key,`_run_layer` 判断需要执行,正常走一遍——`_count_prior_attempts` 会看到之前留下的部分尝试记录(如果崩溃发生在某次 attempt 执行中途),用下一个序号继续,不会跟已存在的行冲突(`_ensure_node_row` 按 `exec_node_id` 去重,`exec_node_id` 本身按 attempt 序号递增,天然不冲突)。 +- 已经 `done` 的 attempt:`run_work_node()` 现有的"已完成短路"逻辑对每个 `exec_node_id` 各自生效,不需要改。 +- `attempt_counts`(内存字典,第 4.3 节):崩溃后是空的,但 `run_graph()` 一旦真的触发 `_LoopBackRequested` 时会先调用 `_loop_attempt_count_from_events(conn, run_id, key)`(第 6.3 节,从 `execution_event` 里查询真实发生过的次数),不依赖内存状态——重启后第一次遇到某个 loop_back key 时,从 DB 查出来的次数就是崩溃前的真实累计值,不会被内存清零重置为 0。 + +## 6. 循环上限配置(FR-A5) + +### 6.1 新表(`settings_config.py` 风格) + +```sql +-- schema.py 新增 +CREATE TABLE work_graph_loop_limit_config ( + config_id TEXT PRIMARY KEY, -- "{org_node}:{node_type}" / "{org_node}:*" / "default" + max_loop_attempts INTEGER NOT NULL, + updated_by TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +```python +# settings_config.py 新增,跟 get_/upsert_concurrency_config 完全同构 +def get_loop_limit_config(conn, config_id): + row = conn.execute( + "SELECT config_id, max_loop_attempts, updated_at FROM work_graph_loop_limit_config WHERE config_id = ?", + (config_id,), + ).fetchone() + return None if row is None else {"config_id": row[0], "max_loop_attempts": row[1], "updated_at": row[2]} + + +def upsert_loop_limit_config(conn, config_id, max_loop_attempts, updated_by): + if max_loop_attempts < 1: + raise InvalidParamError("max_loop_attempts must be >= 1, got {!r}".format(max_loop_attempts)) + exists = conn.execute( + "SELECT 1 FROM work_graph_loop_limit_config WHERE config_id = ?", (config_id,) + ).fetchone() is not None + if exists: + conn.execute( + "UPDATE work_graph_loop_limit_config SET max_loop_attempts = ?, updated_by = ?, " + "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", + (max_loop_attempts, updated_by, config_id), + ) + else: + conn.execute( + "INSERT INTO work_graph_loop_limit_config (config_id, max_loop_attempts, updated_by) VALUES (?, ?, ?)", + (config_id, max_loop_attempts, updated_by), + ) + conn.commit() + return get_loop_limit_config(conn, config_id) +``` + +### 6.2 查找顺序 + 硬编码兜底 + +```python +# work_graph_orchestrate.py +_DEFAULT_LOOP_LIMIT = 3 # 只在配置表连 "default" 行都没有时才会用到(比如全新数据库还没跑过 seed) + +def _resolve_loop_limit(conn, org_node, graph_manifest, target_node_id): + node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id) + node_level = (node.get("on_fail") or {}).get("max_attempts") + if node_level is not None: + return node_level # 节点自己声明的优先级最高(FR-A5) + node_type = node.get("artifact_type", target_node_id) + for config_id in ("{}:{}".format(org_node, node_type), "{}:*".format(org_node), "default"): + config = settings_config.get_loop_limit_config(conn, config_id) + if config: + return config["max_loop_attempts"] + return _DEFAULT_LOOP_LIMIT +``` + +初始 seed:`schema.create_schema()`/迁移里插入一条 `config_id="default", max_loop_attempts=3` 的行(跟 `org_loop_route._LOOP_LIMIT=3` 取值一致,作为一个"暂时先抄现有跨 Org loop 的经验值,后续按真实调用数据调整"的起点——呼应需求文档「7. 风险」第二条,这不是拍脑袋,是有意选择一个已经在生产验证过的数量级作为起点,且明确设计成之后可以不改代码调整)。 + +## 7. loop 历史持久化(FR-A4):`execution_event` 迁移 + +### 7.1 新增 `event_type`:`loop_back_triggered` + +`execution_event.event_type` 是 CHECK 约束枚举(`schema.py:262-266`),SQLite 无法直接 `ALTER`,沿用 `migrate_expand_artifact_types` 的 rebuild-copy-swap 手法: + +```python +# schema.py 新增 +def migrate_add_loop_back_event_type(conn): + """2026-08-08: 给 execution_event.event_type 的 CHECK 枚举加 + 'loop_back_triggered',用于记录 Work Graph 内部回路——见 + docs/done/work-graph-self-planning-and-loop-back-design.md §7。 + 幂等,安全对已有数据库无条件调用(同 migrate_expand_artifact_types 的写法)。 + """ + row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='execution_event'").fetchone() + if row is None or "'loop_back_triggered'" in row[0]: + return + conn.executescript( + """ + CREATE TABLE execution_event_new ( + event_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project(project_id), + task_id TEXT REFERENCES task(task_id), + run_id TEXT REFERENCES work_graph_run(run_id), + node_exec_id TEXT REFERENCES work_node_execution(node_exec_id), + event_type TEXT NOT NULL CHECK (event_type IN ( + 'task_status_changed', 'node_status_changed', 'artifact_created', + 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated', + 'log_appended', 'quota_wait_started', 'loop_back_triggered' + )), + payload TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO execution_event_new SELECT * FROM execution_event; + DROP TABLE execution_event; + ALTER TABLE execution_event_new RENAME TO execution_event; + CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at); + CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); + """ + ) + conn.commit() +``` + +`_ensure_schema()`(`backend_server.py:1020`)加一行无条件调用,跟 `migrate_expand_artifact_types`/`migrate_relax_org_node_constraints` 并列——这正是 2026-08-06 那次"忘记接进 `_ensure_schema`导致生产 500"的教训要求的(该函数自己的注释已经把这条约定写死)。 + +### 7.2 写入 + 查询 + +```python +def _record_loop_back_event(conn, run_id, sig, attempt_number): + task_id, project_id = conn.execute( + "SELECT task_id, project_id FROM work_graph_run wgr JOIN task t ON wgr.task_id = t.task_id " + "WHERE wgr.run_id = ?", (run_id,) + ).fetchone() + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " + "VALUES (?, ?, ?, ?, 'loop_back_triggered', ?)", + (str(uuid.uuid4()), project_id, task_id, run_id, json.dumps({ + "source_node_id": sig.source_node_id, "target_node_id": sig.target_node_id, + "attempt_number": attempt_number, "feedback": sig.feedback, + })), + ) + conn.commit() + + +def _loop_attempt_count_from_events(conn, run_id, key): + target_node_id, source_node_id = key + rows = conn.execute( + "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered'", (run_id,) + ).fetchall() + return sum( + 1 for (payload,) in rows + if json.loads(payload)["target_node_id"] == target_node_id + and json.loads(payload)["source_node_id"] == source_node_id + ) + + +def _loop_history_prompt_suffix(conn, run_id, node_id): + """target_node_id 重新执行时,把它过去每一轮被打回的完整历史(不只 + 最近一轮)拼进提示词——FR-A4。""" + rows = conn.execute( + "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered' " + "ORDER BY created_at", (run_id,) + ).fetchall() + history = [json.loads(p) for (p,) in rows if json.loads(p)["target_node_id"] == node_id] + if not history: + return "" + lines = [ + "Attempt {}: rejected because: {}".format(h["attempt_number"], "; ".join(h["feedback"] or [])) + for h in history + ] + return "\n\nThis node was sent back for revision before, with this history:\n" + "\n".join(lines) +``` + +不新增表,`execution_event` 一张表回答"这个节点被打回几次、每次谁触发的、为什么、按什么顺序"——满足决策 #4,且天然带 `run_id`/`node_exec_id`/`created_at`,现有图可视化机制(`v_graph_visualizer`,若按 `run_id` 拉取 `execution_event` 渲染)不需要新的数据源就能把 `source_node_id → target_node_id` 画成一条回路边。 + +## 8. Part B:Planner 自规划节点集合 + +### 8.1 节点类型清单——数据,不是硬编码提示词字符串(FR-B2) + +```python +# work_graph_plan.py 新增 +_PLANNER_NODE_CATALOG = [ + {"artifact_type": "brd", "description": "商业需求文档...", "judgment": {"kind": "none"}}, + {"artifact_type": "prd", "description": "产品需求文档...", + "judgment": {"kind": "llm", "judge_prompt": _PRD_CONSISTENCY_JUDGE_PROMPT}}, + {"artifact_type": "sdd", "description": "详细设计文档...", "judgment": {"kind": "none"}}, + {"artifact_type": "benchmark", "description": "验收基准...", "judgment": {"kind": "none"}}, + {"artifact_type": "test_dataset", "description": "测试数据集(可选,goal 不需要时回复 N/A)...", + "judgment": {"kind": "none"}}, + {"artifact_type": "test_script", "description": "可执行验收脚本(可选,goal 不需要时回复 N/A)...", + "judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}}, +] +``` + +`_PRD_CONSISTENCY_JUDGE_PROMPT` 是现有 `_PRD_CONSISTENCY_PROMPT`(`e2e_pipeline.py:72`)改写成 `judge_prompt` 的通用形状(`{content}` 占位符替代原来的 `{prd_content}`),供 Planner 在自规划时"抄"进它给 prd 节点声明的 `judgment` 里——这份清单只是给 LLM 的**参考建议**,不是强制:Planner 完全可以给某个节点声明跟清单建议不同的 `judgment`/`on_fail`(FR-B4 已确认"全部自由规划"),清单只是降低它每次都要从零发明判断方式的成本。 + +### 8.2 新 `_PROMPT_TEMPLATE` + +```python +_PROMPT_TEMPLATE = ( + "Plan (do not execute) the Work Graph for this goal: {goal_text}\n" + "Available node types (use any subset, in any order that makes sense -- " + "this goal may not need all of them):\n{catalog_json}\n" + "For each node you include, decide: node_id, artifact_type, depends_on, " + "a judgment config (how to tell if this node's output is acceptable -- you " + "may reuse a catalog entry's suggested judgment, or write your own 'llm' " + "judge_prompt), and an on_fail config (what happens if judgment fails: " + "retry_self, loop_back, or hard_fail). If a node's on_fail.action is " + "'loop_back', you MUST also add a corresponding edge in the top-level " + "'edges' array with edge_type 'on_failure', from that node to the exact " + "earlier node_id it should return to -- the target must be a real " + "upstream dependency of this node (something that genuinely produced " + "input this node used), not an arbitrary node. This mirrors how the " + "success path already works: every node's 'yes, this succeeded, what's " + "next' is expressed as a normal dependency edge, and 'no, this failed, " + "go back to X' is expressed the same way, just with edge_type " + "'on_failure' instead. Every other edge needs edge_type: 'zero_token' " + "for pure aggregation/reduce steps, 'needs_judgment' for anything " + "requiring the agent to actually reason about content.\n" + "End your reply with the graph_manifest as a single fenced ```json code " + "block shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no " + "extra wrapper key." +).format(catalog_json=json.dumps(_PLANNER_NODE_CATALOG), goal_text="{goal_text}") +``` + +(`.format()` 两次是因为 `catalog_json` 在模块加载时就能确定、`goal_text` 要留到调用时才填——用 `{{`/`}}` 转义加上先格式化一次 `catalog_json`,再对外暴露带 `{goal_text}` 占位符的最终模板字符串,写法上等价于现有 `_GENERIC_PROMPT_TEMPLATE` 的处理方式,不是新模式。) + +### 8.3 `_normalize_graph_manifest` 的变化:删除,不是加固 + +`_CANONICAL_DOC_CHAIN` 常量、以及"当六个/五个规范类型全部出现时强制线性依赖顺序"那段逻辑(`work_graph_plan.py`,2026-08-07 已经因为禁用 test_script 缩成 5 元组)**整体删除**——FR-B4 已确认"全部自由规划,不保留任何代码级固定顺序兜底"。`_normalize_graph_manifest` 保留的部分: +- `id`/`node_id` 键名兼容(2026-08-01 真实教训)。 +- 从 `edges` 反推 `depends_on`(同上)。 + +新增的部分: +- **2026-08-08 修订(用户反馈)**:失败路径的目标节点不是节点身上一个跟 `edges` 脱节的独立字段,而是跟成功路径一样,由规划这个节点的时候一并定好的边决定——"yes 走哪个节点、no 走哪个节点,都是边定义好的"。所以校验对象变成:每个声明了 `on_fail.action="loop_back"` 的节点,必须**恰好有一条**从它出发、`edge_type="on_failure"` 的边(见第 4.2 节 `_on_failure_target`),且这条边指向的节点必须是该节点的(直接或间接)上游依赖——LLM 规划时可能"跳"到一个跟自己毫无依赖关系的节点(逻辑上没有意义:loop_back 的语义是"回到更早、真正影响了我的节点"),这种情况在 `_normalize_graph_manifest` 里直接拒绝并抛 `PlanningFailedError`(复用现有异常类),比让它在 `run_graph()` 执行到一半才报错更早暴露问题。 + +```python +def _validate_loop_back_targets(graph_manifest): + node_ids = {n["node_id"] for n in graph_manifest["nodes"]} + for node in graph_manifest["nodes"]: + on_fail = node.get("on_fail") or {} + if on_fail.get("action") != "loop_back": + continue + node_id = node["node_id"] + on_failure_edges = [ + e for e in graph_manifest.get("edges", []) + if e.get("from") == node_id and e.get("edge_type") == "on_failure" + ] + if len(on_failure_edges) != 1: + raise PlanningFailedError( + "node {} declares on_fail.action='loop_back' but has {} on_failure edge(s), expected exactly 1".format( + node_id, len(on_failure_edges) + ) + ) + target = on_failure_edges[0]["to"] + if target not in node_ids: + raise PlanningFailedError( + "node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format( + node_id, target + ) + ) + if target not in _transitive_ancestors(graph_manifest, node_id): + raise PlanningFailedError( + "node {}'s on_failure edge targets {!r}, which is not an upstream dependency -- " + "loop_back must target a node that genuinely influenced this one".format(node_id, target) + ) +``` + +(`_transitive_ancestors` 是 `_transitive_dependents`——第 4.2 节,`work_graph_orchestrate.py`——的反向版本;两边各自独立实现一份即可,`work_graph_plan.py` 不需要因此新引入对 `work_graph_orchestrate.py` 的依赖,两个模块现在也没有互相 import,保持这个边界。`_on_failure_target`——第 4.2 节——运行时可以放心假设"恰好一条"已经在这里校验过,不用再处理 0 条/多条的情况。) + +### 8.4 `plan_documents()` 调用点改动 + +`e2e_pipeline.py::plan_documents()` 不再需要 `_combined_planner_verify_fn`(PRD 一致性检查 + test_script 编译检查两个手写分支)——改成 `verify_fn=None`,因为自规划出的每个节点都自带 `judgment`,`run_graph()` 内部会自动解析。`mechanical_check_registry={"check_script_compiles": check_script_compiles}` 照常传入。旧的 `_combined_planner_verify_fn`/`_prd_consistency_verify_fn`/`_test_script_verify_fn` 函数**不删除**——`validate_implementation_with_own_graph()`(Validator 自己的硬编码 3/2 节点图,本轮不改,见需求文档非目标)继续用它们,且它们仍然是"外部 verify_fn 向后兼容路径"的真实调用方,删除会破坏 FR-A8。 + +## 9. 向后兼容性核对表(FR-A8) + +| 现有调用点 | 是否声明 `judgment`/`on_fail` | 改造后走的路径 | +|---|---|---| +| `implement_code()` 的合成单节点图 | 否 | `_resolve_node_verify_fn` 直接返回 `None`(该调用点本来就不传 `verify_fn`),零改动 | +| `validate_implementation_with_own_graph()`(`_VALIDATOR_MANIFEST`) | 否 | `_resolve_node_verify_fn` 返回外部 `_test_script_verify_fn(...)`,失败时走 `hard_fail`/现状 `_verify_and_maybe_regenerate`,逐字节不变 | +| `work_graph_fork.py`(`_GENERIC_PROMPT_TEMPLATE` 路径) | 否 | 同上,无 `verify_fn` 传入,无判断 | +| `plan_documents()`(Part B 改造后) | 是(每个节点自带) | 走新的 `judgment`/`on_fail` 解析路径 | + +## 10. 测试计划(对应需求文档验收标准 1-10) + +| 验收标准 | 测试方式 | +|---|---| +| 1-2. loop_back 累积历史 + 触发上限 | mock `execute_work_graph`,构造 A→B、B 声明 `on_fail=loop_back(target=A)`,B 连续失败 N 次;断言 A 被重跑 N 次、`execution_event` 里有 N 条 `loop_back_triggered`、A 第 N 次收到的 prompt 包含前 N-1 次的 feedback 文本 | +| 3. 崩溃重启后正确恢复轮次 | 跑到第 2 次 loop_back 时中断(mock 抛异常模拟进程退出),重新调用 `run_graph()`,断言 `_loop_attempt_count_from_events` 从 DB 读到 2,不是从 0 开始 | +| 4. 静态图校验不因 loop_back 报环 | 一个声明了 `on_fail.action="loop_back"` 且带一条指向上游节点的 `on_failure` 边的 `graph_manifest`,`topological_layers()` 正常返回分层,不抛 `ValueError`(因为 `on_failure` 边类型根本不参与 `topological_layers` 的依赖计算,只有 `depends_on`/普通数据依赖 edge 参与) | +| 5. 未声明字段的图逐字节不变 | 现有 `test_work_graph_orchestrate.py`/`test_e2e_pipeline.py` 全部既有用例不改断言即通过 | +| 6-7. Part B 自由规划 + 健壮性兜底 | 同需求文档,mock LLM 规划响应缺 test_dataset / 缺 depends_on,断言分别正确处理 | +| 8. test_script 可选 | mock 规划响应包含 test_script 节点,断言正常规划执行 | +| 9. 按 org_node/node_type 配置上限生效 | `settings_config.upsert_loop_limit_config` 写两条不同配置,构造两个 loop_back 场景,断言各自在配置的上限触发 | +| 10. loop 历史只在 `execution_event` | 触发若干轮 loop_back 后,只查 `execution_event`(不查任何新表)复原完整历史 | +| 新增:`_validate_loop_back_targets` 拒绝非法目标 | 分别构造"`on_failure` 边指向的节点不是上游依赖"、"声明 `loop_back` 但没有 `on_failure` 边"、"同一个节点有 2 条 `on_failure` 边"三种场景,断言 `plan_work_graph()` 都抛 `PlanningFailedError` | +| 新增:mechanical_check_registry 未注册报错 | `judgment.check_ref` 指向一个不存在的名字,断言 `run_graph()` 在执行任何节点前就报错,不是跑到那个节点才发现 | +| 新增:`topological_layers()` 正确忽略 `on_failure` 边(4.0 节修复) | 构造 A→B(普通依赖)+ B→A(`on_failure` 边)的 `graph_manifest`,断言 `topological_layers()` 返回 `[[A], [B]]`(不因为 B→A 那条边被误判成环);另跑一遍 G 既有的 `test_work_graph_orchestrate.py` 全部用例确认零回归 | + +## 11. 实施步骤建议 + +1. `schema.py` 两处迁移(`work_graph_loop_limit_config` 建表 + `migrate_add_loop_back_event_type`)+ `settings_config.py` 两个新函数 + `_ensure_schema()` 接入——纯数据层改动,先落地,不碰任何执行逻辑。 +2. `work_graph_orchestrate.py`:`_resolve_node_verify_fn`/机械检查注册表注入/`_llm_judgment_verify_fn`——先只做"judgment 解析",不做 loop_back(这一步验证:没有 `on_fail` 时行为完全不变,第 9 节核对表跑一遍)。 +3. `work_graph_orchestrate.py`:`_transitive_dependents`/`_LoopBackRequested`/`run_graph()` 主循环回退逻辑/attempt 后缀机制——loop_back 的核心,单独用 mock 场景验证第 10 节测试计划 1-5、9、10。 +4. `work_graph_plan.py`:删除 `_CANONICAL_DOC_CHAIN` 强制顺序逻辑、`_PLANNER_NODE_CATALOG`、新 `_PROMPT_TEMPLATE`、`_validate_loop_back_targets`——Part B,第 10 节测试计划 6-8、新增两项。 +5. `e2e_pipeline.py::plan_documents()` 切到 `verify_fn=None` + `mechanical_check_registry`,端到端跑一次 mock 场景确认整条链路。 +6. 真实场景验证(`RUN_REAL_AGENT_E2E=1`,小心控制次数/成本):观察一次真实 Planner 自规划调用产出的 `graph_manifest` 长什么样,是否合理、是否触发过 `PlanningFailedError`。 diff --git a/docs/done/work-graph-self-planning-and-loop-back-requirements.md b/docs/done/work-graph-self-planning-and-loop-back-requirements.md new file mode 100644 index 0000000..3434448 --- /dev/null +++ b/docs/done/work-graph-self-planning-and-loop-back-requirements.md @@ -0,0 +1,106 @@ +# Planner 动态自规划 Work Graph(含节点判断与失败回路)——需求文档 + +**日期**: 2026-08-08 +**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应用户 2026-08-07/08-08 两轮讨论:先是"要不要让 Planner 自己判断要不要有 test_script 节点",深入后明确为一个更完整的诉求——Planner 第一步应该是自己规划 Work Graph(要不要 brd/prd/sdd/benchmark/test 的组合、未来还有更多 tool/skill 可组合调用),并为每个节点规划输入输出边约束、判断成功/失败的方式,失败时把结果送回更早的节点形成回路(loop),并把 loop 过程中的产出记录下来供后续轮次参考改进。详细设计(SDD 层面:graph_manifest 的具体 JSON 形状、`topological_layers()`/`run_graph()` 的具体改造点、循环历史的具体持久化方式)留到本文档确认后单独产出。 +**状态**: ✅ 已实现(2026-08-08)。原「8. 开放问题」的 7 个分叉点均已拍板,记录见「8. 已确认的决策」,正文相应章节已按决策更新。详细设计见 `docs/done/work-graph-self-planning-and-loop-back-design.md`;Part A(判断+回路引擎)与 Part B(Planner 自规划)均已实现,`python3 -m unittest discover -s src`(692 tests)+ `discover -s supervisor -p "test_*.py"`(126 tests)全绿。尚未做真实 `RUN_REAL_AGENT_E2E=1` 端到端验证(需要真实 API 额度,等待用户决定是否现在跑)。 + +## 1. 背景 + +### 1.1 现状:三条独立的、能力有限的机制 + +- **Planner 自己的文档链**(`work_graph_plan.py::_PROMPT_TEMPLATE`):过一层真实的 LLM 规划调用,但提示词硬编码要求"必须为 brd/prd/sdd/benchmark/test_dataset 各出一个节点"(test_script 已于 2026-08-07 从这个固定清单里移除,见 `docs/todo/validator-test-script-narrative-output-issue.md`)——节点集合本质是固定的,规划这一步只是在确认依赖关系,不是真的自由决策"要不要有某个节点"。这是 2026-08-06 的一次明确设计决定(`docs/done/dynamic-work-graph-design-skill-design.md` §3 原话:"不是让 Claude 完全自由决定 Planner 要生成哪些节点——Planner 那条链是'架构常量'")。 +- **`_GENERIC_PROMPT_TEMPLATE`**(同文件,服务 Coder/Validator 的子任务图):真正让 LLM 自由规划 `{node_id, depends_on, task_prompt}`,但目前只有 `work_graph_fork.py`(人工发起的 time-travel 调试功能)在用,没有接入任何生产路径的自动执行;且和 Planner 的固定链一样,只是一张纯 DAG,没有判断/回路概念。 +- **`work_graph_orchestrate.py::run_graph()` 的验证-重试机制**:`_verify_and_maybe_regenerate()` 只支持"某节点验证失败 → 原地重新生成同一个节点一次 → 还失败就整体 `ConsistencyCheckFailedError`"。这不是回路——失败结果不会被送回更早的节点,历史反馈也只保留最近一轮(`"; ".join(feedback)` 直接拼进 reflect prompt,不会累积多轮)。 + +### 1.2 两个此前未被充分利用的既有事实 + +- **`edge_type`(`zero_token`/`needs_judgment`)字段已经存在于 `GRAPH_MANIFEST_SCHEMA`,但从未被任何执行逻辑读取**——`topological_layers()` 只看 `depends_on`/`from-to` 决定分层,完全不管 `edge_type` 的值,是纯文档字段。 +- **`topological_layers()` 对任何环直接 `raise ValueError`**——现在的执行引擎从数据结构到算法都是严格 DAG,没有"跳回更早节点"这种控制流的容身之处。`docs/sdd/01-M-模型设计.md:60` 里 `work_graph_run.graph_manifest` 字段的文档说明其实从一开始就写了"概念上应涵盖 fan-out/fan-in 分组、loop 规则",只是从未真正实现过。 + +### 1.3 一个必须正面回应的架构决定 + +`docs/graphlemon_architecture.md`"方案 A"明确设想过:Work Graph 内部的 fan-out/fan-in/loop/verifier 应该直接用 Claude Code 原生 Dynamic Workflow 原语(`agent()`/`parallel()`/`pipeline()`,类似 loop-until-dry)编排,而不是手撸 Python 引擎。但 `work_graph_orchestrate.py` 的实际实现故意背离了这个设想(一个节点一个独立 `claude -p` 子进程 + Python 拓扑引擎逐节点持久化状态),原因写在该模块自己的 docstring 里:REQ-06 断点续跑——原生原语跑在一次不透明的 `claude -p` 调用里,中途崩溃就会丢失全部循环历史和已完成节点状态;现在的设计换来的是"进程重启后能从任意已完成节点继续,不重跑"。 + +本需求延续这个既有背离,在 Python 引擎里新增回路能力,而不是回退去用原生原语——因为 REQ-06 是已经在生产里被真实依赖的能力(self-hosting 崩溃恢复、worktree resume),原生原语给不了这个保证。 + +## 2. 目标 + +1. `graph_manifest` 的节点能声明式地表达"怎么判断这个节点成功/失败"(机械检查引用,或另一次 LLM 判断),不再要求调用方在 `e2e_pipeline.py` 里为每种 artifact_type 手写专门的 `verify_fn` 分支。 +2. `graph_manifest` 的节点能声明式地表达"失败之后怎么办":原地重试(现状)、跳回更早的某个指定节点重新执行(新增)、或直接判定整图失败(现状的显式化)。 +3. 触发"跳回更早节点"时,被跳回节点到失败节点之间的整条下游链要被重新执行(下游数据是基于坏输入产出的,不能继续用);同时,这个节点每一轮被拒绝的历史(不只是最近一轮)要被完整地喂给它自己的下一次重新生成,让它能看到"过去几次分别为什么被拒绝",而不是每次都从零开始猜。 +4. 这套回路能力必须有可配置的上限,且在进程中途崩溃重启后能正确恢复"当前是第几轮",不能死循环也不能重置归零。 +5. 在回路执行引擎具备之后,让 Planner 自己的规划调用能真正决定"这个 goal 需要哪些节点",而不是像现在这样被提示词硬编码死一份固定清单——这份清单本身也应该是可扩展的数据,为未来接入更多 tool/skill 节点类型留出空间(这轮不要求真的新增新类型,只要求机制本身可扩展)。 +6. **长期方向(本轮不实施,仅作为设计约束)**:用户明确的最终目标是"Planner 改造成功后,Coder、Validator、甚至未来新增的 org graph node,都需要具备同样的能力——先 plan 自己的 work graph,再按照 work graph 执行"。本轮的 Part A(judgment/on_fail 机制)设计必须是 org-agent 无关的通用能力,不能是 Planner 专属的特殊逻辑,即使这轮只把它接到 Planner 一个 org node 上(见「3. 非目标」第 1 条、「8. 已确认的决策」第 6 条)。 + +## 3. 非目标(本轮明确不做) + +1. **本轮实施范围仍只覆盖 Planner,不把 Coder/Validator 也接上自规划**。`_VALIDATOR_MANIFEST` 现在完全是硬编码的 Python 字面量,连"过一层 LLM 规划"都没有;Coder 现在是合成单节点图。用户已确认长期方向是所有 org agent(含未来新增的)都要具备同样能力(见「2. 目标」第 6 条),但落地顺序是"Planner 改造验证成功后,再依次推广"——不是本轮的实施范围,本轮只是设计时不能把机制设计成 Planner 专属。 +2. **不回退到 Claude Code 原生 Dynamic Workflow 原语**(见 1.3)。继续在 Python 层的 `work_graph_orchestrate.py` 里扩展执行引擎,保住 REQ-06 断点续跑。 +3. **不追求完全通用的图执行框架**。回路的触发方式限定为"某节点判断失败 → 跳回自己声明的 `on_fail.target_node_id`",不支持任意节点间的条件分支、多目标跳转、或图结构在运行时被重新规划——这些是更大的能力,不在这轮范围内。 +4. **不重新设计跨 Org 的既有 loop 机制**(`org_level_loop.py`/`org_loop_route.py`:Validator FAIL/INCONCLUSIVE → 判根因 → 路由回 Planner/Coder,`loop_round` 封顶 3 轮)。本需求的回路是"同一个 Org Agent 自己的 Work Graph 内部"这一层,比跨 Org 的 loop 粒度更细,两者独立计数、不共享额度、不互相替代——内层耗尽自己的上限后照旧抛 `ConsistencyCheckFailedError`,外层机制不变地接管。 +5. **不在本需求里解决 test_script/validator_test_script 节点"叙述文字而非代码"的结构性根因**。这个节点已于 2026-08-07 因该问题禁用(`docs/todo/validator-test-script-narrative-output-issue.md`),根因是"探索后收尾习惯",跟本需求要解决的问题(节点集合是否可以自由规划、失败能否形成回路)完全正交。用户已确认(见「8. 已确认的决策」第 7 条):Part B 上线后,test_script 可以直接作为可选节点提供给 Planner 自由规划选择,不需要等这个结构性问题修复;该结构性问题的真正修复安排在另一份文档里,跟"给 Coder agent 加入 test_script 模块"一起解决——本需求只负责不阻塞它,不负责修它。 +6. **不修改现有 `_GENERIC_PROMPT_TEMPLATE` 路径本身**(Coder/Validator 子任务图、`work_graph_fork.py` 用的那条)。如果这轮的 judgment/on_fail 字段设计得足够通用,未来可以顺带让这条路径也受益,但不是本轮的验收范围。 + +## 4. 功能需求 + +分两部分:Part A 是执行引擎扩展(判断 + 回路能力),Part B 是 Planner 自规划节点集合。**Part B 依赖 Part A 先完成**——自由规划出的图如果没有真正的失败回路可用,只是重新引入"LLM 自由吐图结构"的脆弱性(2026-08-03 那次真实故障:LLM 漏给 `depends_on`/`edges`,`benchmark` 排到 `sdd` 前面执行,直接 `KeyError` 崩溃),却拿不到新能力。 + +### Part A:节点判断 + 失败回路执行引擎(`work_graph_orchestrate.py`) + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-A1 | `graph_manifest` 的节点应支持声明式的 `judgment` 配置(机械检查引用,或触发一次额外 LLM 判断调用),替代现在"调用方在 Python 里为每种 artifact_type 手写 `verify_fn` 分支"的模式。 | 必须 | +| FR-A2 | `graph_manifest` 的节点应支持声明式的 `on_fail` 配置,至少支持三种行为:`retry_self`(现状:原地重新生成同一节点一次)、`loop_back`(跳转到指定的更早节点重新执行)、`hard_fail`(不重试,直接判定整图失败——现状默认行为的显式化)。 | 必须 | +| FR-A3 | 触发 `loop_back` 时,不只重跑 `target_node_id` 本身,还要重跑它到触发失败的节点之间的整条下游链,因为这条链上的产出都是基于(即将被修正的)过期输入生成的。 | 必须 | +| FR-A4 | `loop_back` 的每一轮反馈(这一轮产出了什么、为什么被判定失败)必须可完整还原,且 `target_node_id` 重新执行时,累积的完整历史(不只是最近一轮)要被喂进它的提示词。**不新增专门的历史表**——复用现有 `execution_event` 日志机制(`node_start`/`node_completed` 等既有事件),每次 `loop_back` 触发/重新执行时在事件里记录 predecessor/successor 节点关联,让"这个节点被打回过几次、每次因为什么"可以直接从现有事件流重建,同时让现有的图可视化机制(`v_graph_visualizer`)能把回路自然地呈现成图上的一条边,不需要另外维护一份历史、也不需要事后做双份数据的一致性对账。 | 必须 | +| FR-A5 | 每一对 `(loop_back 目标节点, 触发回路的失败节点)` 需要独立计数,并有上限;超限时整个 `run_graph()` 调用判定失败(复用/扩展现有 `ConsistencyCheckFailedError` 语义),不允许无限循环。**上限不是写死的全局常量**——复用 `settings_config.py`(现有 `org_concurrency_config` 的同类模式:DB 存储、`get_/upsert_` 读写接口)新增一张按 `(org_node, node 类型或 node_id 模式)` 维度可配置的上限表,允许后续根据真实调用经验(哪些节点类型容易被反复打回、上限设多少更合理)动态调整,不需要改代码重新部署;节点自身也可以在 `on_fail.max_attempts` 里覆盖这个默认值。 | 必须 | +| FR-A6 | 循环计数与历史必须能扛得住 REQ-06 式的断点续跑:进程在循环中途崩溃重启后,应正确恢复"当前是第几轮、历史反馈有哪些",不能从零重置,也不能因为状态丢失而死循环。 | 必须 | +| FR-A7 | `topological_layers()` 的静态分层只应基于"数据依赖边"(`depends_on`/`consumes`);节点声明的 `on_fail.target_node_id`(运行时才可能触发的控制流跳转)不参与静态拓扑排序,不应被当成图里的环而报错。 | 必须 | +| FR-A8 | 不声明 `judgment`/`on_fail` 字段的既有 `graph_manifest`(例如 Coder 现在的合成单节点图)行为必须完全不变——这两个字段可选,缺省等价于现状(`judgment=none` 直接通过、`on_fail=hard_fail` 即现在的"验证失败重试一次后硬失败")。 | 必须 | +| FR-A9 | `judgment`/`on_fail` 机制的实现(`graph_manifest` schema、`run_graph()` 的执行逻辑、FR-A5 的上限配置表)必须是 org-agent 无关的通用能力——不能依赖任何 `org_node == "planner"` 的特判。这轮只有 Planner 会真正产出带这些字段的 `graph_manifest`,但 Coder/Validator 未来接入时,不应该需要改 `work_graph_orchestrate.py` 本身,只需要它们自己的规划提示词开始产出这些字段(呼应「2. 目标」第 6 条的长期方向)。 | 必须 | + +### Part B:Planner 自规划节点集合(依赖 Part A) + +| # | 需求描述 | 优先级 | +|---|---|---| +| FR-B1 | Planner 的规划提示词从"必须产出固定清单里的每一个节点"改为:给定一份可用节点类型清单,由 LLM 自己判断这个 goal 需要哪些节点、它们的依赖关系,以及(复用 Part A)每个节点的 `judgment`/`on_fail` 配置。 | 必须 | +| FR-B2 | 可用节点类型清单本身应是可扩展的数据(而不是硬编码在提示词字符串里的固定枚举),为未来接入更多 tool/skill 节点类型留出空间;本轮不要求真的新增除现有文档类型之外的新类型。 | 应该 | +| FR-B3 | 自由规划出的 `graph_manifest` 必须经过与现有 `_normalize_graph_manifest` 同等严格的健壮性校验/兜底,防止 2026-08-03 那次真实故障重演(LLM 输出遗漏 `depends_on`/`edges`,导致执行顺序错误甚至崩溃)——节点集合变自由不代表可以降低这层鲁棒性要求。 | 必须 | +| FR-B4 | **brd/prd/sdd/benchmark/test_dataset(以及 test_script,一旦被规划出来)全部由 LLM 自由规划,不保留任何代码级的固定顺序/固定节点集合兜底**——已确认(「8. 已确认的决策」第 1 条):完全推翻 `docs/done/dynamic-work-graph-design-skill-design.md` §3"Planner 链是架构常量"的决定。FR-B3 的健壮性校验兜底(防止 LLM 漏给 `depends_on`/`edges`)因此变得更重要:没有任何硬编码的顺序可以兜底,健壮性完全依赖 FR-B3。 | 必须 | + +## 5. 验收标准 + +1. 构造一个 `graph_manifest`:节点 A 产出,节点 B 依赖 A、声明 `judgment` 机械检查、`on_fail=loop_back` 指向 A;用 mock 让 B 连续两轮判断失败、第三轮通过。断言:A 被重新执行了两次(不是原地重试 B),且 A 第二次、第三次重新执行时收到的提示词里包含前面所有轮次的失败反馈(不只是最近一轮)。 +2. 同上场景,把 B 的失败改成"每一轮都失败",断言在达到可配置上限后,`run_graph()` 判定整图失败并明确抛出(而不是无限循环挂起),且失败前 A 已经产出的内容仍按现有"部分成功节点落盘"机制(`_write_completed_node_artifacts`)被正确保留。 +3. 模拟"回路进行到第 2 轮时进程中断重启",断言恢复执行后能正确识别"已经是第 2 轮"并继续,而不是把计数器归零重新从第 1 轮开始,也不是把已经持久化的历史反馈丢掉。 +4. 一个声明了 `on_fail.target_node_id` 指向更早节点的 `graph_manifest`,`topological_layers()`/图校验逻辑不应把它当作静态环拒绝(不抛 `ValueError`)。 +5. 一个完全不使用 `judgment`/`on_fail` 字段的既有 `graph_manifest`(例如 Coder 当前用的合成单节点图),跑一遍 `run_graph()`,行为与改造前逐字节一致——现有 `src/test_work_graph_orchestrate.py`/`src/test_e2e_pipeline.py` 里未显式测试新字段的用例,不修改断言即可继续通过。 +6. (Part B)给 Planner 一个明显不需要 test_dataset 的 goal(例如纯配置项调整),断言自规划产出的 `graph_manifest` 里确实没有 test_dataset 节点,且整条链路(Coder 拿到的 `test_dataset_content` 走 `"N/A: not provided"` 兜底)不因为节点缺失而报错——复用现有的 `"test_dataset" in artifact_ids` 容错逻辑,不需要新代码。 +7. (Part B)人为构造一个自由规划回复"遗漏 `depends_on`"的场景(模拟 2026-08-03 那次真实故障的输入),断言健壮性兜底仍能纠正执行顺序,不崩溃。 +8. (Part B)给 Planner 一个明显需要机械化验收脚本的 goal,可用节点类型清单里包含 test_script,断言 Planner 能够(不是必须,是"能够")自由规划出一个 test_script 节点——确认 test_script 已按「8. 已确认的决策」第 7 条重新开放为可选节点,不再被清单排除。 +9. (FR-A5)验证上限配置确实按 `(org_node, node 类型)` 生效:给同一个 org_node 下两种不同 node 类型分别配置不同上限,构造两个各自持续失败的 loop_back 场景,断言各自在自己配置的上限触发失败,不是共用同一个全局值;再验证节点自身 `on_fail.max_attempts` 能覆盖配置表里的默认值。 +10. (FR-A4)验证 loop_back 历史确实通过 `execution_event` 而非新表重建:触发一次多轮 loop_back 后,仅查询 `execution_event` 表(不查询任何新增表)即可还原"这个节点被打回几次、每次的 predecessor/successor 是谁、每次的反馈内容"。 + +## 6. 约束与假设 + +- 复用 REQ-06 已经建立的"`work_node_execution.output_payload` 是节点成功后的权威持久化数据"这一既有约定;回路历史**不新增专门的表**,完整复用 `execution_event` 现有机制(见 FR-A4),必须遵循"重启后可完整恢复"这一约束,不引入新的、易丢失的临时状态。 +- 复用 `org_loop_route.py::_LOOP_LIMIT` 的既有模式("计数 + 达到上限即转 `blocked_pending_human`")作为 Part A 回路上限触发后果的参照,但两者是独立的计数器,不共享同一个 `task.loop_round` 字段;上限的具体数值来源改为复用 `settings_config.py` 的 DB 配置模式(见 FR-A5),不是抄一个固定常量。 +- `judgment.kind="llm"` 复用 `_check_prd_consistency` 现有模式:额外发起一次 `agent()` 调用(`agentType='planner'`),self-target 场景下走独立 worktree 隔离,避免重新设计一套判断机制。 +- Part A 完成后,Coder 现在的合成单节点图、`_GENERIC_PROMPT_TEMPLATE` 路径都不强制迁移使用新字段——新字段是可选能力,不是所有 `graph_manifest` 生产者都必须立刻采用;但机制本身必须 org-agent 无关(见 FR-A9),为后续推广铺路。 + +## 7. 风险与已知权衡 + +- **用户已知情并接受:Part B 上线后 test_script 类节点可能被自由规划回来,narrative-output 问题可能重现**。`docs/todo/validator-test-script-narrative-output-issue.md` 记录的根因是"探索后收尾习惯",跟本需求解决的问题(节点集合能不能自由规划、失败能否形成回路)完全正交,本需求不修它。用户决策(「8. 已确认的决策」第 7 条):不等结构性修复,真正的修复安排在"给 Coder agent 加入 test_script 模块"那份文档里一起解决——本需求上线后,如果自举/真实任务里又复现了叙述文字问题,这是已知的、被接受的风险,不是本需求的回归。 +- **回路能力本身可能被滥用成隐藏的高成本重试**:一个设计不当的 `judgment`(例如过于严格的 LLM 判断标准)配合 `loop_back`,可能导致某个节点反复被打回、消耗大量 token 才勉强通过上限。FR-A5 的按 `(org_node, node 类型)` 可配置上限缓解了"一刀切"的问题,但初始默认值仍需要设计阶段给出具体理由,不能只是"抄一个数字";上线后应该根据真实调用数据回头调整(这正是选择"DB 配置表"而不是"硬编码常量"的原因)。 +- **Part B 是对已有架构决定的正式推翻**,不是渐进式增强。`docs/done/dynamic-work-graph-design-skill-design.md` §3 需要在 Part B 实施后同步更新,避免文档和代码再次出现本 session 里多次遇到的"文档记录的意图与实际代码不一致"的问题。 +- **Part A + Part B 一并实施(已确认,见「8. 已确认的决策」第 5 条),没有中间的"仅 Part A 先跑一段时间验证稳定性"的缓冲阶段**——意味着 Part A 的回路引擎第一次被真实使用,就是在"节点集合本身也是自由规划、不受固定链条约束"这个更不可预测的场景下,两个新变量同时引入,出问题时定位"是回路引擎的 bug 还是自由规划出了一个奇怪的图结构"会比分阶段更难。这是用户已知情的权衡,不是本需求遗漏。 + +## 8. 已确认的决策(2026-08-08) + +1. **Part B 的推翻力度**:全部自由规划。brd/prd/sdd/benchmark/test_dataset(以及重新开放的 test_script)不保留任何代码级固定顺序/固定节点集合兜底,完全推翻 `docs/done/dynamic-work-graph-design-skill-design.md` §3 的既有决定(对应 FR-B4)。健壮性完全依赖 FR-B3 的校验兜底。 +2. **回路上限**:不设单一全局默认值,按 `(org_node, node 类型)` 可配置,复用 `settings_config.py` 的 DB 配置模式,根据实际调用经验动态调整(对应 FR-A5)。 +3. **`judgment.kind="llm"` 的实现**:复用现有 `_check_prd_consistency` 模式(额外发起一次 `agent()` 调用,`agentType='planner'`,self-target 场景走独立 worktree)。 +4. **回路历史持久化**:不新增专门的表,完整复用 `execution_event` 现有日志机制,记录 predecessor/successor 节点关联,天然可被现有图可视化机制呈现为有向图上的边(对应 FR-A4)。 +5. **落地顺序**:Part A、Part B 一起做,不分阶段(见「7. 风险」最后一条,这是已知权衡,不是遗漏)。 +6. **Validator/Coder 是否一起做**:本轮仍只落地 Planner,但 Part A 的机制设计必须 org-agent 无关(FR-A9)——用户明确的长期方向是 Planner 验证成功后,Coder、Validator、乃至未来新增的 org graph node 都要具备"先 plan 自己的 work graph,再按图执行"的同一能力(见「2. 目标」第 6 条)。 +7. **test_script 是否要等结构性修复**:不需要等。Part B 上线后,test_script 直接作为可选节点开放给 Planner 自由规划;narrative-output 问题的真正结构性修复安排在"给 Coder agent 加入 test_script 模块"的另一份文档里一起解决,不是本需求的阻塞项(见「7. 风险」第一条)。 diff --git a/docs/todo/org-graph-as-work-graph-unified-loop-requirements.md b/docs/todo/org-graph-as-work-graph-unified-loop-requirements.md index 5a96358..3795862 100644 --- a/docs/todo/org-graph-as-work-graph-unified-loop-requirements.md +++ b/docs/todo/org-graph-as-work-graph-unified-loop-requirements.md @@ -1,7 +1,7 @@ # Org Graph 本身也变成一个 Work Graph(Planner/Coder 之间加判断+精确回路)——需求文档 **日期**: 2026-08-08 -**性质**: 需求文档草稿(BRD/PRD 层面),尚未逐条讨论确认——跟 `docs/todo/work-graph-self-planning-and-loop-back-requirements.md` 不同,那份是走完"逐条开放问题讨论→用户拍板"流程后才写的,这份是用户提出想法后直接要求"先记到 todo 里",还没有走那个流程。「6. 待确认的问题」列的都是需要讨论、不是已经拍板的。 +**性质**: 需求文档草稿(BRD/PRD 层面),尚未逐条讨论确认——跟 `docs/done/work-graph-self-planning-and-loop-back-requirements.md` 不同,那份是走完"逐条开放问题讨论→用户拍板"流程后才写的,这份是用户提出想法后直接要求"先记到 todo 里",还没有走那个流程。「6. 待确认的问题」列的都是需要讨论、不是已经拍板的。 **触发场景(真实复现)**:任务 `2084590a`(修复 `work_node_run.py` 的 `execution_log_file` bug)里,Planner 自规划出的 `benchmark` 节点连续 4 次产出的都是"叙述我做了什么"的散文(跟 `docs/todo/validator-test-script-narrative-output-issue.md` 同一类问题,但这次发生在 benchmark 上,不是 test_script),LLM judge 的 `on_fail=retry_self` 循环耗尽 `max_attempts=3` 后任务直接 `blocked_pending_human`。用户追问引出两个观察:① Validator 从来没有机会在 Coder 开始实现之前复核 Planner 自己的产出;② 现有的 `org_level_loop.py`/`org_loop_route.py` 只在 Coder 完整实现之后才起作用,而且只能粗粒度地把整个任务打回"Planner 阶段"或"Coder 阶段"重来,不能精确跳到某个具体 Work Graph 节点。 **状态**: 草稿,待讨论。 @@ -51,5 +51,5 @@ ## 7. 相关文档 -- `docs/todo/work-graph-self-planning-and-loop-back-requirements.md`/`-design.md`:今天实现的 Work-Graph-内部判断+回路机制,这份需求本质是把它推广到 Org Graph 层面。 +- `docs/done/work-graph-self-planning-and-loop-back-requirements.md`/`-design.md`:今天实现的 Work-Graph-内部判断+回路机制,这份需求本质是把它推广到 Org Graph 层面。 - `docs/todo/validator-test-script-narrative-output-issue.md`:叙述文字问题的原始记录(目前只覆盖 test_script),这次真实复现证明同一个根因也会发生在 benchmark 上,需要更新范围。 diff --git a/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md b/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md index ba15035..1c2303d 100644 --- a/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md +++ b/docs/todo/task-execution-resume-assumes-planner-artifacts-exist.md @@ -1,7 +1,7 @@ # `task_execution.py` 恢复逻辑假设 Planner 一定已产出 sdd/prd/benchmark artifact——问题记录 **日期**: 2026-08-08 -**性质**: 问题记录(真实复现,根因已定位,未修复)。发现于 `docs/todo/work-graph-self-planning-and-loop-back-requirements.md`(Planner 自规划)上线后的第一次真实端到端验证。 +**性质**: 问题记录(真实复现,根因已定位,未修复)。发现于 `docs/done/work-graph-self-planning-and-loop-back-requirements.md`(Planner 自规划)上线后的第一次真实端到端验证。 **状态**: 待修复,未立项。 ## 1. 现象(真实复现) @@ -16,7 +16,7 @@ `org_node_lifecycle.trigger_org_node_full(conn, "planner", ...)` 在 `run_task_lifecycle()` 里**先于** `plan_documents()` 执行,并把 `task.status` 设成 `_POST_STATE["planner"] = "planning_brd_prd_sdd"`(`org_node_lifecycle.py:32`)——这个状态转移只代表"Planner 的 ready 确认调用成功了",不代表"Planner 的真实规划(`plan_documents()`)已经产出了任何东西"。 -`task_execution.py::run_or_resume_task_lifecycle()` 里 `planning_brd_prd_sdd` 分支(`task_execution.py:126-129`)的注释写的是"Planner done, Coder not yet triggered"——这个假设在 Work Graph 自规划(`docs/todo/work-graph-self-planning-and-loop-back-requirements.md`)上线前基本成立:固定链的第一个节点是 brd,几乎不可能在第一个节点就失败到零产出。自规划之后这个假设不再可靠——自规划出的图可能把一个更容易失败的节点(比如需要更复杂 judgment 的 sdd)排在第一位,如果它在第一次尝试就彻底失败(耗尽 loop_back/retry 上限,或者像这次一样是一个跟自规划本身相关的新 bug),Planner 的 `work_graph_run` 会整个失败,不会有任何 artifact 落盘,但 `task.status` 已经先一步被置成了 `planning_brd_prd_sdd`。 +`task_execution.py::run_or_resume_task_lifecycle()` 里 `planning_brd_prd_sdd` 分支(`task_execution.py:126-129`)的注释写的是"Planner done, Coder not yet triggered"——这个假设在 Work Graph 自规划(`docs/done/work-graph-self-planning-and-loop-back-requirements.md`)上线前基本成立:固定链的第一个节点是 brd,几乎不可能在第一个节点就失败到零产出。自规划之后这个假设不再可靠——自规划出的图可能把一个更容易失败的节点(比如需要更复杂 judgment 的 sdd)排在第一位,如果它在第一次尝试就彻底失败(耗尽 loop_back/retry 上限,或者像这次一样是一个跟自规划本身相关的新 bug),Planner 的 `work_graph_run` 会整个失败,不会有任何 artifact 落盘,但 `task.status` 已经先一步被置成了 `planning_brd_prd_sdd`。 `test_dataset_content`/`test_script_content` 已经用 `_artifact_content_or_default()`(读不到就给默认值,不硬失败)处理过同一类问题(2026-08-06,那次是"这两个 artifact 类型本来就允许不存在"),但 `sdd`/`prd`/`benchmark` 三个仍然用会硬失败的 `_artifact_content()`——这个区分在固定链时代是对的(sdd/prd/benchmark 曾经是保证存在的),自规划之后不再成立。 diff --git a/docs/todo/validator-test-script-narrative-output-issue.md b/docs/todo/validator-test-script-narrative-output-issue.md index 285a282..48d073b 100644 --- a/docs/todo/validator-test-script-narrative-output-issue.md +++ b/docs/todo/validator-test-script-narrative-output-issue.md @@ -2,7 +2,7 @@ **日期**: 2026-08-07 **性质**: 问题记录 + 待调查,不是已确认根因的需求文档(区别于同目录下其他几份——那几份是已经定位到具体代码 bug 并修复过的,这份目前还只是"观察到的现象",根因未确定)。 -**状态**: 根因已定位(见第 6 节)。用户决策(2026-08-07):不再继续在提示词/重试反馈上打补丁,**先直接禁用 test_script/validator_test_script 节点**(Planner 的 `work_graph_plan._PROMPT_TEMPLATE` 不再要求这个节点,Validator 的 `e2e_pipeline._VALIDATOR_MANIFEST` 从 3 节点缩成 2 节点),后续如果真的需要再重新设计(见第 8 节的结构性修复方向:把"探索"和"最终作答"拆成两次独立调用)。`_NARRATION_RETRY_HINT`(第 7 节)代码仍保留在 `_test_script_verify_fn` 里,只是当前这条路径不会被触发——如果以后重新启用这个节点,这个缓解依然生效。**2026-08-08 更新(第四轮,与自规划 Work Graph 需求的交叉决策)**:`docs/todo/work-graph-self-planning-and-loop-back-requirements.md`(Planner 自规划节点集合 + 失败回路需求)上线后,test_script 会重新作为可选节点开放给 Planner 自由规划——用户明确决定**不等本文档的结构性修复**,narrative-output 问题可能因此重现,这是已知且被接受的风险。本文档记录的根因(第 6 节)的真正结构性修复,安排在**另一份尚未撰写、关于"给 Coder agent 加入 test_script 模块"的文档**里一起解决,不是本文档单独继续跟进的范围。**2026-08-07 更新(第二轮):不只是 Validator 的问题——`911861fe` 这个纯后端任务在 Planner 自己的 `n_test_script` 节点上也复现了同一模式。累计 3 个任务、6 次到达 test_script 类节点,6 次全部复现(100%),横跨 Planner 和 Validator 两种场景。建议提高优先级,不要再靠重试撞运气。** **2026-08-07 更新(第三轮,根因排查):这是 08-06 "Benchmark 叙述事故"(commit `07a0a27`)的同类复发——test_script 类节点当时已经同时拿到提示词纠偏和机械拦截网双重防护,仍然 6/6 复现。根因不是"self-hosting 词汇污染"这个泛泛的猜测,而是 test_script 提示词本身要求"先做工具探索再产出内容",模型探索后的收尾话术天然是"汇报我做了/启动了什么"而不是切换成纯文本吐出模式;self-hosting 只是放大器,让叙述读起来更精确(因为探索对象就是 graphlemon 自己,词汇表精确对应),不是必要条件。BRD/PRD/SDD/Benchmark/test_dataset 这几个不需要先探索的纯组合型节点,自 08-06 后再未复现,与此吻合。** +**状态**: 根因已定位(见第 6 节)。用户决策(2026-08-07):不再继续在提示词/重试反馈上打补丁,**先直接禁用 test_script/validator_test_script 节点**(Planner 的 `work_graph_plan._PROMPT_TEMPLATE` 不再要求这个节点,Validator 的 `e2e_pipeline._VALIDATOR_MANIFEST` 从 3 节点缩成 2 节点),后续如果真的需要再重新设计(见第 8 节的结构性修复方向:把"探索"和"最终作答"拆成两次独立调用)。`_NARRATION_RETRY_HINT`(第 7 节)代码仍保留在 `_test_script_verify_fn` 里,只是当前这条路径不会被触发——如果以后重新启用这个节点,这个缓解依然生效。**2026-08-08 更新(第四轮,与自规划 Work Graph 需求的交叉决策)**:`docs/done/work-graph-self-planning-and-loop-back-requirements.md`(Planner 自规划节点集合 + 失败回路需求)上线后,test_script 会重新作为可选节点开放给 Planner 自由规划——用户明确决定**不等本文档的结构性修复**,narrative-output 问题可能因此重现,这是已知且被接受的风险。本文档记录的根因(第 6 节)的真正结构性修复,安排在**另一份尚未撰写、关于"给 Coder agent 加入 test_script 模块"的文档**里一起解决,不是本文档单独继续跟进的范围。**2026-08-07 更新(第二轮):不只是 Validator 的问题——`911861fe` 这个纯后端任务在 Planner 自己的 `n_test_script` 节点上也复现了同一模式。累计 3 个任务、6 次到达 test_script 类节点,6 次全部复现(100%),横跨 Planner 和 Validator 两种场景。建议提高优先级,不要再靠重试撞运气。** **2026-08-07 更新(第三轮,根因排查):这是 08-06 "Benchmark 叙述事故"(commit `07a0a27`)的同类复发——test_script 类节点当时已经同时拿到提示词纠偏和机械拦截网双重防护,仍然 6/6 复现。根因不是"self-hosting 词汇污染"这个泛泛的猜测,而是 test_script 提示词本身要求"先做工具探索再产出内容",模型探索后的收尾话术天然是"汇报我做了/启动了什么"而不是切换成纯文本吐出模式;self-hosting 只是放大器,让叙述读起来更精确(因为探索对象就是 graphlemon 自己,词汇表精确对应),不是必要条件。BRD/PRD/SDD/Benchmark/test_dataset 这几个不需要先探索的纯组合型节点,自 08-06 后再未复现,与此吻合。** **2026-08-08 更新(第五轮,真实复现,推翻了第三轮"Benchmark 不需要先探索、所以不会复现"这个判断)**:任务 `2084590a`(Work Graph 自规划 + 失败回路上线后的第一次真实端到端验证)里,Planner 自规划出的 `benchmark` 节点连续 4 次真实产出全部是叙述文字,例如:"The planner Work Node finished. It produced a Benchmark artifact covering a real bug in `src/work_node_run.py::run_work_node()`... **No further action taken unless the user wants this handed off to the Coder**",以及 "**No files were changed in this checkout (git status clean) — this run only generated the benchmark text; it did not apply the fix or commit anything.**"——都是"我做了什么"的散文,不是真实的 benchmark 场景+断言内容。这次是自规划场景下 `judgment.kind="llm"` 的 LLM judge(不是机械检查)在把关,4 次里第 3 次的 judge 反馈甚至精确诊断出了这一点:"the text only asserts scenarios exist, without stating them... No benchmark.md matching this content was found on disk anywhere in the repo... recommend surfacing the actual artifact file/text... rather than a prose summary of it"——但这个诊断只是混在其他反馈条目里的一条,没有变成能强制拦住的机制,第 4 次原样复现,最终 `loop_back` 耗尽 `max_attempts=3`,任务 `blocked_pending_human`。 diff --git a/docs/todo/work-graph-partial-artifact-persistence-requirements.md b/docs/todo/work-graph-partial-artifact-persistence-requirements.md deleted file mode 100644 index 61c35f4..0000000 --- a/docs/todo/work-graph-partial-artifact-persistence-requirements.md +++ /dev/null @@ -1,62 +0,0 @@ -# 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)全绿,零回归。 diff --git a/docs/todo/work-graph-self-planning-and-loop-back-design.md b/docs/todo/work-graph-self-planning-and-loop-back-design.md deleted file mode 100644 index abf0b2c..0000000 --- a/docs/todo/work-graph-self-planning-and-loop-back-design.md +++ /dev/null @@ -1,643 +0,0 @@ -# Planner 动态自规划 Work Graph(含节点判断与失败回路)——详细设计(SDD) - -**日期**: 2026-08-08 -**性质**: 详细设计,对应需求文档 `docs/todo/work-graph-self-planning-and-loop-back-requirements.md`(已确认)。本设计给出可直接实施的 schema/接口/算法/测试方案,逐条落实需求文档「8. 已确认的决策」。 -**依赖代码**:`src/work_graph_orchestrate.py`(`run_graph`/`topological_layers`/`_run_one_node`/`_verify_and_maybe_regenerate`/`_ensure_node_row`/`_LockedConn`)、`src/work_graph_plan.py`(`_PROMPT_TEMPLATE`/`_normalize_graph_manifest`/`GRAPH_MANIFEST_SCHEMA`)、`src/e2e_pipeline.py`(`plan_documents`/`check_script_compiles`/`_check_prd_consistency`)、`src/settings_config.py`(`get_/upsert_concurrency_config` 先例)、`src/schema.py`(`migrate_expand_artifact_types` 先例)。 -**状态**: ✅ 已实现(2026-08-08)。实施顺序按第 11 节执行,`python3 -m unittest discover -s src`(692 tests)+ `discover -s supervisor -p "test_*.py"`(126 tests)全绿。实现过程中用真实测试发现并修正了本设计原文的几处问题(设计文档本身未回填,实现是最终准确来源): -- §2 最初的 `on_fail.target_node_id` 字段设计,在实现前按用户反馈改成了从 `on_failure` 边读取(已反映在正文),但 §4.0(`topological_layers()` 必须过滤 `on_failure` 边)和 §8.3(`_validate_loop_back_targets` 改成校验边而不是字段)是**开始写代码之后**才发现遗漏并现场修的两处,不是设计阶段就想全的。 -- `_next_exec_node_id`(原设计称 `_count_prior_attempts`,按行数计数)在真实测试中暴露两个问题:① 按 DB 行数计数会把"resume 一个从未失败过的节点"误判成需要新开 attempt 后缀;② 只统计"自己是不是直接 loop_back 目标"会漏掉"作为下游节点被牵连重跑"的情况。最终实现改成按 `execution_event` 里真实的 loop_back 触发次数、且用 `_transitive_dependents` 判断是否被牵连来计数,两个问题都已修复并有专门测试覆盖。 -- `_run_one_node` 最初设计里"先统一调用 verify_fn 判断、再决定走哪条分支"的写法,会导致legacy(无 judgment 声明)节点的 verify_fn 被调用两次(`_verify_and_maybe_regenerate` 内部自己还会再调一次)——测试跑出来直接暴露,改成先判断 `has_own_judgment` 决定路径,只有新路径才提前调用 verify_fn。 -- `work_graph_plan.py::_PROMPT_TEMPLATE` 最初设计的"先 `.format()` 填 catalog_json、留 `{goal_text}` 给以后再填一次"写法有真实的转义 bug(catalog JSON 自带的花括号会被第二次 `.format()` 误当成占位符),实现时改成用字符串拼接嵌入 catalog、最终用 `.replace("{goal_text}", goal_text)` 而不是 `.format()` 填充目标文本,彻底绕开转义问题。 -- 顺带在真实测试中发现一个**不在本设计范围内、`work_node_run.py` 自己的既有缺口**:`run_work_node()` 每次调用都无条件往 `execution_log_file` 插入一行新记录,没有检查该 `node_exec_id` 是否已有记录——如果一个节点在 `execute_work_graph()` 真正返回之前就进程崩溃(不是"干净的 done",也不是全新节点),下次对同一个 `node_exec_id` 调用 `run_work_node()` 会撞 `execution_log_file.node_exec_id` 的 UNIQUE 约束报错。这个缺口在 loop_back 机制之前就存在(任何节点崩溃在这个时间窗口都会触发,不是 loop_back 特有的),只是这次因为测试真实构造了"进程崩溃后重启"的场景才被发现——不在本次范围内修复,记录在案供后续单独立项。 - ---- - -## 1. 设计概览 - -现状(`work_graph_orchestrate.py`,见其自身 docstring 与代码):`run_graph()` 把 `graph_manifest` 用 `topological_layers()` 分层,层间屏障、层内(G,2026-08-06)用 `ThreadPoolExecutor` 并发;每个节点的判断逻辑完全靠调用方传入的单一 `verify_fn(node, output) -> (ok, feedback)` 回调,失败处理固定死在 `_verify_and_maybe_regenerate()` 里——原地重新生成同一节点恰好一次,还失败就 `ConsistencyCheckFailedError`,没有"跳回更早节点"这条路径。 - -改造后新增两条能力,**都以 `graph_manifest` 节点自身携带的声明式字段驱动,不再要求调用方为每种 artifact_type 手写 Python 分支**: - -1. **`judgment`**:节点自己说"怎么判断我的输出对不对"(机械检查 or 触发一次额外 LLM 判断)。 -2. **`on_fail`**:节点自己说"判断失败了怎么办"(原地重试 / 跳回更早节点重新执行 / 直接判失败)。 - -**不改变的部分**(刻意保持稳定): -- 每个节点仍然是"渲染成一个单节点 workflow 脚本 + 独立 `claude -p` 子进程调用"(`_make_run_fn`/`_render_node_script` 不变)。 -- G(并行执行)的层内并发机制、`_LockedConn`、`WorkGraphLayerError` 完全不变——回路是"层与层之间的控制流",跟"层内节点并发执行"是正交的两层机制,互不冲突(详见第 4 节)。 -- 不声明 `judgment`/`on_fail` 的现有 `graph_manifest`(Coder 的合成单节点图)逐字节行为不变(FR-A8,第 8 节有完整核对表)。 -- `run_work_node()`/`work_node_run.py` 的断点续跑状态机完全不变,回路复用它现成的"新 node_id → 新行 → 真实重跑"机制(第 5 节),不需要改这一层。 - -## 2. `graph_manifest` 节点 schema 扩展 - -```jsonc -{ - "node_id": "benchmark", - "artifact_type": "benchmark", // 沿用现状;_GENERIC_PROMPT_TEMPLATE 路径用 task_prompt 代替,同样适用 - "depends_on": ["sdd"], - - // 新增,可选。缺省等价于 {"kind": "none"} —— 不做任何自动判断, - // 节点输出直接被接受(如果调用方仍然传了外部 verify_fn,外部的继续生效,见第 3 节)。 - "judgment": { - "kind": "mechanical", // "mechanical" | "llm" | "none" - "check_ref": "check_script_compiles" // 仅 kind="mechanical" 时必填,见第 3.1 节的白名单机制 - // kind="llm" 时改用: - // "judge_prompt": "Review the following content for ...: {content}" - // —— 由规划者(Planner 自己,或人工在固定链里手写)自己撰写, - // self-contained,不依赖调用方额外拼接上下文(同 _GENERIC_PROMPT_TEMPLATE - // 的 task_prompt 设计原则)。 - }, - - // 新增,可选。缺省等价于 {"action": "hard_fail"} 当没有 judgment 时; - // 当声明了 judgment 但没声明 on_fail 时,缺省等价于 {"action": "retry_self", "max_attempts": 1} - // ——即完全复刻现状 _verify_and_maybe_regenerate() 的行为(原地重试恰好一次)。 - "on_fail": { - "action": "loop_back", // "retry_self" | "loop_back" | "hard_fail" - "max_attempts": 3 // 可选,覆盖 FR-A5 配置表给出的默认值 - // 注意:没有 target_node_id 字段——action="loop_back" 的目标节点不是 - // node 自己声明的一个独立字段,而是从 graph_manifest 的 edges 里读出来 - // 的(见下)。retry_self/hard_fail 不需要任何 edge,target 就是自己/无。 - } -} -``` - -**2026-08-08 修订(用户反馈)**:失败路径的目标节点不应该是节点自己声明的、跟 `edges` 脱节的一个独立字段——"下一个节点是谁(成功路径)、失败了回到哪个节点(失败路径),都应该在规划这个节点的时候,用边(edge)定义好",跟成功路径的 `depends_on`/`edges` 是同一套结构,不是节点身上另开的一个影子字段。所以 `on_fail.loop_back` 的目标改成**从一条新的 `edge_type: "on_failure"` 边读出来**,不再单独存 `target_node_id`: - -```jsonc -// graph_manifest 顶层 edges 数组: -{"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, // 成功路径:sdd 产出喂给 benchmark(既有语义,depends_on 的另一种表达) -{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"} // 失败路径:benchmark 判断失败时回到 sdd 重新执行 -``` - -即:一个节点的"下一步去哪"完全由它参与的 `edges` 决定——`needs_judgment`/`zero_token` 两种既有类型继续表达"成功之后数据往哪流",新增的 `on_failure` 表达"判断失败之后控制流跳去哪"。节点自己的 `on_fail.action` 只声明"失败了要不要 loop_back"这个决策本身,不重复存目标是谁,避免两处数据不一致的风险(比如 `on_fail.target_node_id` 和实际 `edges` 没对上)。`GRAPH_MANIFEST_SCHEMA` 的 `edge_type` 枚举(`work_graph_plan.py:60`)加上 `"on_failure"`。 - -`GRAPH_MANIFEST_SCHEMA`(`work_graph_plan.py:33`)同步补上 `judgment`/`on_fail` 的形状 + `on_failure` edge_type(文档性质,跟现状一样不接入真正的 jsonschema 校验——`--json-schema` 和 `--agent` 不能同时用,见该文件已有说明)。 - -## 3. `run_graph()` 改造:judgment 解析 - -### 3.1 机械检查白名单——注入而不是硬编码 - -`work_graph_orchestrate.py` 现在对 `e2e_pipeline.py` **零依赖**(依赖方向是反过来的),不能直接 `import e2e_pipeline` 去拿 `check_script_compiles`(会成环)。机械检查改成调用方注入的白名单字典: - -```python -def run_graph( - conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, - build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, - verify_fn=None, sleep=time.sleep, now=time.time, max_workers=_DEFAULT_MAX_WORKERS, - mechanical_check_registry=None, # 新增:{check_ref: callable(content) -> (ok, detail)} -): -``` - -`e2e_pipeline.py` 的调用点传 `mechanical_check_registry={"check_script_compiles": check_script_compiles}`。`judgment.kind="mechanical"` 但 `check_ref` 不在注册表里,视为配置错误,直接在 `run_graph()` 一开始(还没跑任何节点前)校验并抛 `ValueError`——不要等跑到那个节点才发现拼错了名字,浪费一整条链的 token。**LLM 只能从白名单里选,不能发明新的机械检查**——这是有意的限制,不做"LLM 生成检查代码后拿去执行"这种沙箱执行的大工程。 - -### 3.2 LLM 判断——复用 `_check_prd_consistency` 的隔离机制,泛化提示词 - -```python -def _llm_judgment_verify_fn(judgment, org_node, target_folder_path, timeout=180): - """通用化的 _check_prd_consistency(e2e_pipeline.py):同样的 self-target - worktree 隔离 + 独立 agent() 调用机制,但判断提示词来自 judgment['judge_prompt'] - (节点自己声明的,不是硬编码的 PRD 专属提示词),返回 schema 也通用化成 - {"ok": bool, "issues": [str, ...]}(PRD 检查原来的 "consistent"/"issues" 改名 - 成更通用的 "ok",语义不变)。""" - is_self = paths.is_self_target(target_folder_path) - - def verify_fn(node, output): - node_exec_id = "judgment-" + uuid.uuid4().hex[:12] - worktree_path = work_node_run.create_worktree(target_folder_path, node_exec_id) if is_self else None - cwd = worktree_path or target_folder_path - try: - prompt = judgment["judge_prompt"].format(content=output["content"]) - script_name = "graphlemon-judgment-" + uuid.uuid4().hex - script_content = ( - "export const meta = {{ name: {name}, description: 'graphlemon node judgment' }}\n\n" - "const result = await agent({prompt}, {{ agentType: {org_node_json}, schema: {schema} }})\n\n" - "return result\n" - ).format( - name=json.dumps(script_name), prompt=json.dumps(prompt), - org_node_json=json.dumps(org_node), schema=json.dumps(_JUDGMENT_SCHEMA), - ) - work_graph_execute.write_workflow_script(cwd, script_name, script_content) - result = work_graph_execute.execute_work_graph(cwd, script_name, {}, _JUDGMENT_SCHEMA, timeout=timeout) - if worktree_path: - work_node_run.ensure_worktree_committed(worktree_path, node_exec_id) - work_node_run.ensure_worktree_committed(target_folder_path, node_exec_id + "-main-checkout-drift") - work_node_run.merge_worktree(target_folder_path, node_exec_id) - ok = _coerce_bool(result["ok"]) - return ok, (result.get("issues") or []) - finally: - if worktree_path: - work_node_run.remove_worktree(target_folder_path, worktree_path) - - return verify_fn - - -_JUDGMENT_SCHEMA = { - "type": "object", "required": ["ok", "issues"], - "properties": {"ok": {"type": "boolean"}, "issues": {"type": "array", "items": {"type": "string"}}}, -} -``` - -`_coerce_bool`(原属 `e2e_pipeline.py`,见其 2026-08-02 的"LLM 把 boolean 回成字符串"教训)需要下沉成 `work_graph_orchestrate.py` 自己的小工具函数(或 `paths.py` 这类共享工具模块),两边都用同一份,不重复实现。`e2e_pipeline.py::_check_prd_consistency`/`_prd_consistency_verify_fn` 保留不动(FR-A8 向后兼容,现有固定链路径不强制迁移),新代码只是提供一个可选的、更通用的平行实现。 - -### 3.3 节点级 verify_fn 解析 - -```python -def _resolve_node_verify_fn(node, external_verify_fn, org_node, target_folder_path, mechanical_check_registry): - judgment = node.get("judgment") - if not judgment or judgment.get("kind", "none") == "none": - return external_verify_fn # 完全现状:没声明 judgment,谁传了外部 verify_fn 就用谁的(可能是 None) - if judgment["kind"] == "mechanical": - check_fn = (mechanical_check_registry or {}).get(judgment["check_ref"]) - if check_fn is None: - raise ValueError("unknown mechanical check_ref: {!r}".format(judgment["check_ref"])) - def verify_fn(node, output, _check_fn=check_fn): - ok, detail = _check_fn(output["content"]) - return ok, ([detail] if detail else None) - return verify_fn - if judgment["kind"] == "llm": - return _llm_judgment_verify_fn(judgment, org_node, target_folder_path) - raise ValueError("unknown judgment.kind: {!r}".format(judgment["kind"])) -``` - -`_run_one_node()` 改成对每个节点各自调用 `_resolve_node_verify_fn(...)` 拿到"这个节点该用哪个 verify_fn",而不是像现在这样整张图共用调用方传入的同一个 `verify_fn`。**向后兼容关键点**:现有调用方(`plan_documents`/`validate_implementation_with_own_graph`)继续传它们现在的 `_combined_planner_verify_fn(...)`/`_test_script_verify_fn(...)` 作为 `external_verify_fn`,只要节点没有 `judgment` 字段(现状 100% 如此),`_resolve_node_verify_fn` 原样返回这个外部函数,行为逐字节不变(FR-A8 验收标准 5)。 - -## 4. `on_fail` 执行:回路引擎 - -### 4.0 前提修复:`topological_layers()` 必须过滤 `on_failure` 边(FR-A7) - -**这是把 loop_back 目标从节点字段改成 `edges` 里的一条边(第 2 节 2026-08-08 修订)之后,必须同步修的一处,否则会引入真环**:现有 `topological_layers()`(`work_graph_orchestrate.py:196-200`)把 `graph_manifest["edges"]` 里**每一条边、不分 `edge_type`**都当成数据依赖合并进 `deps`: - -```python -for edge in graph_manifest.get("edges", []): - to_id, from_id = edge.get("to"), edge.get("from") - if to_id in deps and from_id in nodes: - deps[to_id].add(from_id) -``` - -如果不改,一条 `{"from": "benchmark", "to": "sdd", "edge_type": "on_failure"}` 会被这段代码当成"`sdd` 依赖 `benchmark`"塞进 `deps["sdd"]`——而 `benchmark` 本来就依赖 `sdd`(正常数据依赖),两条方向相反的依赖凑在一起就是一个真环,`topological_layers()` 会在图规划阶段还没触发任何回路之前,就先被静态校验直接拒绝,FR-A7 的验收标准 4 会失败。 - -修复:合并 `deps` 时跳过 `edge_type == "on_failure"` 的边——**只有 `on_failure` 需要特殊处理**,`zero_token`/`needs_judgment` 两种既有类型继续按现状合并(它们表达的就是真实数据依赖): - -```python -def topological_layers(graph_manifest): - nodes = {n["node_id"]: n for n in graph_manifest["nodes"]} - deps = {node_id: set(n.get("depends_on") or []) for node_id, n in nodes.items()} - for edge in graph_manifest.get("edges", []): - if edge.get("edge_type") == "on_failure": - continue # 新增:控制流边不参与静态拓扑排序 - to_id, from_id = edge.get("to"), edge.get("from") - if to_id in deps and from_id in nodes: - deps[to_id].add(from_id) - ... # 其余逻辑不变 -``` - -`topological_order()`(薄封装)不用改。这条修复是本设计里**唯一一处直接改动 G(并行执行)已经上线的现有函数**的地方,改动是纯粹的"多一个 `continue` 条件",不影响任何现有(不含 `on_failure` 边的)`graph_manifest` 的分层结果——`src/test_work_graph_orchestrate.py` 里 G 自己的既有测试用例不需要改一行断言即可继续通过。 - -### 4.1 关键简化:`retry_self` 是 `loop_back` 的特例 - -`retry_self` ≡ `loop_back` 且 `target_node_id` = 自己。原因:`retry_self` 触发时这个节点本身还没成功过,不可能已经有下游节点跑完并读取过它的(不存在的)输出,所以"重跑 target 到失败节点之间的下游链"这条规则应用到"target=自己"时,受影响集合就是 `{自己}`,跟现状的"原地重跑"完全等价。**一套机制两种声明方式**,不用维护两条独立代码路径。 - -### 4.2 受影响子图:只重跑真正的下游依赖,不是整层 - -需求文档非目标已声明"不追求完全通用的图执行框架",但"回路触发时把同层不相关的兄弟节点也无谓重跑一遍"没必要,也和已完成节点白白重花 token 相悖,成本可控且实现不复杂: - -```python -def _transitive_dependents(graph_manifest, node_id): - """node_id 自身 + 所有直接/间接依赖它的下游节点 id 集合(沿 depends_on/edges - 正向遍历)。用于 loop_back 时判断哪些节点的输出已经"过期"、必须重跑。""" - dependents = {} # node_id -> [直接依赖它的节点 id, ...] - for node in graph_manifest["nodes"]: - dependents.setdefault(node["node_id"], []) - deps = {n["node_id"]: set(n.get("depends_on") or []) for n in graph_manifest["nodes"]} - for edge in graph_manifest.get("edges", []): - to_id, from_id = edge.get("to"), edge.get("from") - if to_id in deps and from_id in deps: - deps[to_id].add(from_id) - for node_id_, dep_set in deps.items(): - for dep in dep_set: - dependents.setdefault(dep, []).append(node_id_) - - affected, stack = set(), [node_id] - while stack: - current = stack.pop() - if current in affected: - continue - affected.add(current) - stack.extend(dependents.get(current, [])) - return affected - - -def _on_failure_target(graph_manifest, node_id): - """节点 node_id 声明了 on_fail.action='loop_back' 时,目标节点不是节点 - 自己存的字段,而是 graph_manifest['edges'] 里唯一一条 - {"from": node_id, "to": ..., "edge_type": "on_failure"} 边的 "to"—— - 2026-08-08 修订:失败路径跟成功路径一样,都是"边",不是节点身上另开 - 的一个跟 edges 脱节的影子字段。恰好 0 条或 2+ 条这样的边是规划错误, - 在 _validate_loop_back_targets(8.3 节)里提前拦截,这里假设已经过 - 校验,直接找第一条匹配的。""" - for edge in graph_manifest.get("edges", []): - if edge.get("from") == node_id and edge.get("edge_type") == "on_failure": - return edge["to"] - raise ValueError("node {} declares on_fail.action='loop_back' but has no on_failure edge".format(node_id)) -``` - -### 4.3 层级回退循环——`run_graph()` 主循环改造 - -```python -class _LoopBackRequested(Exception): - """节点内部信号:这个节点的 on_fail.action=loop_back 触发了,需要外层 - run_graph() 主循环回退重跑,不是真正的失败——不通过正常返回值传递是 - 因为它可能发生在 ThreadPoolExecutor 的 worker 线程里(层内并发场景), - 异常是唯一能干净地从 future.result() 里带出这个信号的方式。""" - def __init__(self, source_node_id, target_node_id, feedback): - self.source_node_id = source_node_id - self.target_node_id = target_node_id - self.feedback = feedback - super().__init__("node {} requests loop_back to {}".format(source_node_id, target_node_id)) - - -def run_graph(..., mechanical_check_registry=None): - ensure_placeholder_skill(conn, skill_id, org_node) - effective_needs_worktree = needs_worktree or paths.is_self_target(target_folder_path) - layers = topological_layers(graph_manifest) - node_layer_index = {node["node_id"]: i for i, layer in enumerate(layers) for node in layer} - outputs = {} - locked_conn = _LockedConn(conn) - attempt_counts = {} # (target_node_id, source_node_id) -> 已尝试次数,本次 run_graph() 调用内的内存计数 - # 断点续跑时从 execution_event 重建,见第 6.3 节 - - layer_index = 0 - while layer_index < len(layers): - layer = layers[layer_index] - try: - _run_layer( - conn, locked_conn, run_id, org_node, skill_id, target_folder_path, layer, outputs, - build_prompt, input_schema, output_schema, timeout, effective_needs_worktree, - verify_fn, mechanical_check_registry, max_workers, sleep, now, - ) - except _LoopBackRequested as sig: - key = (sig.target_node_id, sig.source_node_id) - attempt_counts[key] = attempt_counts.get(key, 0) + _loop_attempt_count_from_events(conn, run_id, key) - limit = _resolve_loop_limit(conn, org_node, graph_manifest, sig.target_node_id) - if attempt_counts[key] > limit: - conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,)) - conn.commit() - raise ConsistencyCheckFailedError( - "loop_back from {} to {} exceeded max_attempts={}".format( - sig.source_node_id, sig.target_node_id, limit - ), - node_id=sig.source_node_id, - ) - _record_loop_back_event(conn, run_id, sig, attempt_counts[key]) - affected = _transitive_dependents(graph_manifest, sig.target_node_id) - for affected_id in affected: - outputs.pop(affected_id, None) # 强制这些节点在重跑时被当成"未完成" - layer_index = node_layer_index[sig.target_node_id] - continue - layer_index += 1 - - return outputs -``` - -`_run_layer(...)` 是现有 `run_graph()` 主循环体(单节点同步 / 多节点 `ThreadPoolExecutor`)原样搬过来的辅助函数,唯一的改动是:**只重跑本轮 `affected` 集合里的节点**(第一次进入某层时 `affected` 概念不适用,正常跑全部;loop_back 回退重入某层时,只有 `outputs` 里被 `.pop()` 掉的节点需要重新执行,其余节点直接复用已有的 `outputs[node_id]`,不重新调用 `_run_one_node`)——`outputs.pop()` 天然标记了"这个需要重跑",`_run_layer` 判断 `node["node_id"] not in outputs` 决定要不要真的执行这个节点,跳过的节点保留原值。 - -`_run_one_node()` 里判断 `verify_fn` 失败后该走哪条路径(原地重试 vs 抛 `_LoopBackRequested`): - -```python -def _run_one_node(..., node, outputs, ...): - node_id = node["node_id"] - # 每次真正执行都用一个新的 attempt 后缀 node_id,绝不复用同一个已经 - # 'done' 的 node_id——_ensure_node_row 按 (run_id, node_id) 去重,复用 - # 同一个 node_id 会被 run_work_node 的断点续跑短路成"已完成,直接返回 - # 旧输出",这正是必须避免的(详见 5 节)。 - attempt = _count_prior_attempts(conn, run_id, node_id) + 1 - exec_node_id = node_id if attempt == 1 else "{}-attempt{}".format(node_id, attempt) - - node_exec_id = _ensure_node_row(conn, run_id, exec_node_id, org_node, skill_id, ..., {"node_id": node_id}) - prompt = build_prompt(node, outputs) + _loop_history_prompt_suffix(conn, run_id, node_id) - run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) - output = work_node_run.run_work_node(conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, ...) - - resolved_verify_fn = _resolve_node_verify_fn(node, verify_fn, org_node, target_folder_path, mechanical_check_registry) - if resolved_verify_fn is None: - return output - ok, feedback = resolved_verify_fn(node, output) - if ok: - return output - - on_fail = node.get("on_fail") or {"action": "retry_self" if node.get("judgment") else "hard_fail"} - action = on_fail.get("action", "retry_self") - if action == "hard_fail" or (action == "retry_self" and not node.get("judgment")): - # 完全现状路径:没声明 judgment 的老节点,外部 verify_fn 失败时走 - # 原有的 _verify_and_maybe_regenerate()(恰好一次原地重试),逐字节不变。 - return _verify_and_maybe_regenerate(conn, run_id, org_node, skill_id, target_folder_path, - node, node_id, prompt, output, input_schema, output_schema, - timeout, needs_worktree, resolved_verify_fn, sleep, now) - target = node_id if action == "retry_self" else _on_failure_target(graph_manifest, node_id) - raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) -``` - -(`_run_one_node` 需要拿到 `graph_manifest` 才能查 `on_failure` 边——现有签名没有这个参数,改造时顺带传进去;`retry_self` 完全不查边,`target_node_id` 直接等于自己,第 4.1 节已经说明原因。) - -**关键取舍**:`hard_fail`、以及"没声明 `judgment` 的老节点走 `retry_self`"这两种情况,继续复用现有的 `_verify_and_maybe_regenerate()` 一字不改——不是所有失败路径都要改造成新机制,只有节点**主动声明了 `judgment`**(即 Part B 自规划出的新节点,或 Part A 上线后手工升级的固定链节点)才会走新的 `_LoopBackRequested` 回路路径。这保证 FR-A8 的向后兼容不是"理论上兼容"而是"代码路径都没变过"。 - -## 5. 断点续跑兼容性:新 attempt 后缀就是全部答案 - -不需要专门为回路设计新的续跑逻辑。原理:`_run_one_node()` 每次真正执行前,都先查询"这个 node_id 已经有几次真正的执行尝试"(`_count_prior_attempts`,扫 `work_node_execution` 里 `node_id` 匹配 `original_id` 或 `original_id-attemptN` 模式的行数),用下一个序号构造 `exec_node_id`。进程崩溃重启后重新调用 `run_graph()`: - -- 还没跑到的层:`outputs` 里没有对应 key,`_run_layer` 判断需要执行,正常走一遍——`_count_prior_attempts` 会看到之前留下的部分尝试记录(如果崩溃发生在某次 attempt 执行中途),用下一个序号继续,不会跟已存在的行冲突(`_ensure_node_row` 按 `exec_node_id` 去重,`exec_node_id` 本身按 attempt 序号递增,天然不冲突)。 -- 已经 `done` 的 attempt:`run_work_node()` 现有的"已完成短路"逻辑对每个 `exec_node_id` 各自生效,不需要改。 -- `attempt_counts`(内存字典,第 4.3 节):崩溃后是空的,但 `run_graph()` 一旦真的触发 `_LoopBackRequested` 时会先调用 `_loop_attempt_count_from_events(conn, run_id, key)`(第 6.3 节,从 `execution_event` 里查询真实发生过的次数),不依赖内存状态——重启后第一次遇到某个 loop_back key 时,从 DB 查出来的次数就是崩溃前的真实累计值,不会被内存清零重置为 0。 - -## 6. 循环上限配置(FR-A5) - -### 6.1 新表(`settings_config.py` 风格) - -```sql --- schema.py 新增 -CREATE TABLE work_graph_loop_limit_config ( - config_id TEXT PRIMARY KEY, -- "{org_node}:{node_type}" / "{org_node}:*" / "default" - max_loop_attempts INTEGER NOT NULL, - updated_by TEXT, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); -``` - -```python -# settings_config.py 新增,跟 get_/upsert_concurrency_config 完全同构 -def get_loop_limit_config(conn, config_id): - row = conn.execute( - "SELECT config_id, max_loop_attempts, updated_at FROM work_graph_loop_limit_config WHERE config_id = ?", - (config_id,), - ).fetchone() - return None if row is None else {"config_id": row[0], "max_loop_attempts": row[1], "updated_at": row[2]} - - -def upsert_loop_limit_config(conn, config_id, max_loop_attempts, updated_by): - if max_loop_attempts < 1: - raise InvalidParamError("max_loop_attempts must be >= 1, got {!r}".format(max_loop_attempts)) - exists = conn.execute( - "SELECT 1 FROM work_graph_loop_limit_config WHERE config_id = ?", (config_id,) - ).fetchone() is not None - if exists: - conn.execute( - "UPDATE work_graph_loop_limit_config SET max_loop_attempts = ?, updated_by = ?, " - "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", - (max_loop_attempts, updated_by, config_id), - ) - else: - conn.execute( - "INSERT INTO work_graph_loop_limit_config (config_id, max_loop_attempts, updated_by) VALUES (?, ?, ?)", - (config_id, max_loop_attempts, updated_by), - ) - conn.commit() - return get_loop_limit_config(conn, config_id) -``` - -### 6.2 查找顺序 + 硬编码兜底 - -```python -# work_graph_orchestrate.py -_DEFAULT_LOOP_LIMIT = 3 # 只在配置表连 "default" 行都没有时才会用到(比如全新数据库还没跑过 seed) - -def _resolve_loop_limit(conn, org_node, graph_manifest, target_node_id): - node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id) - node_level = (node.get("on_fail") or {}).get("max_attempts") - if node_level is not None: - return node_level # 节点自己声明的优先级最高(FR-A5) - node_type = node.get("artifact_type", target_node_id) - for config_id in ("{}:{}".format(org_node, node_type), "{}:*".format(org_node), "default"): - config = settings_config.get_loop_limit_config(conn, config_id) - if config: - return config["max_loop_attempts"] - return _DEFAULT_LOOP_LIMIT -``` - -初始 seed:`schema.create_schema()`/迁移里插入一条 `config_id="default", max_loop_attempts=3` 的行(跟 `org_loop_route._LOOP_LIMIT=3` 取值一致,作为一个"暂时先抄现有跨 Org loop 的经验值,后续按真实调用数据调整"的起点——呼应需求文档「7. 风险」第二条,这不是拍脑袋,是有意选择一个已经在生产验证过的数量级作为起点,且明确设计成之后可以不改代码调整)。 - -## 7. loop 历史持久化(FR-A4):`execution_event` 迁移 - -### 7.1 新增 `event_type`:`loop_back_triggered` - -`execution_event.event_type` 是 CHECK 约束枚举(`schema.py:262-266`),SQLite 无法直接 `ALTER`,沿用 `migrate_expand_artifact_types` 的 rebuild-copy-swap 手法: - -```python -# schema.py 新增 -def migrate_add_loop_back_event_type(conn): - """2026-08-08: 给 execution_event.event_type 的 CHECK 枚举加 - 'loop_back_triggered',用于记录 Work Graph 内部回路——见 - docs/todo/work-graph-self-planning-and-loop-back-design.md §7。 - 幂等,安全对已有数据库无条件调用(同 migrate_expand_artifact_types 的写法)。 - """ - row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='execution_event'").fetchone() - if row is None or "'loop_back_triggered'" in row[0]: - return - conn.executescript( - """ - CREATE TABLE execution_event_new ( - event_id TEXT PRIMARY KEY, - project_id TEXT NOT NULL REFERENCES project(project_id), - task_id TEXT REFERENCES task(task_id), - run_id TEXT REFERENCES work_graph_run(run_id), - node_exec_id TEXT REFERENCES work_node_execution(node_exec_id), - event_type TEXT NOT NULL CHECK (event_type IN ( - 'task_status_changed', 'node_status_changed', 'artifact_created', - 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated', - 'log_appended', 'quota_wait_started', 'loop_back_triggered' - )), - payload TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - INSERT INTO execution_event_new SELECT * FROM execution_event; - DROP TABLE execution_event; - ALTER TABLE execution_event_new RENAME TO execution_event; - CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at); - CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); - """ - ) - conn.commit() -``` - -`_ensure_schema()`(`backend_server.py:1020`)加一行无条件调用,跟 `migrate_expand_artifact_types`/`migrate_relax_org_node_constraints` 并列——这正是 2026-08-06 那次"忘记接进 `_ensure_schema`导致生产 500"的教训要求的(该函数自己的注释已经把这条约定写死)。 - -### 7.2 写入 + 查询 - -```python -def _record_loop_back_event(conn, run_id, sig, attempt_number): - task_id, project_id = conn.execute( - "SELECT task_id, project_id FROM work_graph_run wgr JOIN task t ON wgr.task_id = t.task_id " - "WHERE wgr.run_id = ?", (run_id,) - ).fetchone() - conn.execute( - "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " - "VALUES (?, ?, ?, ?, 'loop_back_triggered', ?)", - (str(uuid.uuid4()), project_id, task_id, run_id, json.dumps({ - "source_node_id": sig.source_node_id, "target_node_id": sig.target_node_id, - "attempt_number": attempt_number, "feedback": sig.feedback, - })), - ) - conn.commit() - - -def _loop_attempt_count_from_events(conn, run_id, key): - target_node_id, source_node_id = key - rows = conn.execute( - "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered'", (run_id,) - ).fetchall() - return sum( - 1 for (payload,) in rows - if json.loads(payload)["target_node_id"] == target_node_id - and json.loads(payload)["source_node_id"] == source_node_id - ) - - -def _loop_history_prompt_suffix(conn, run_id, node_id): - """target_node_id 重新执行时,把它过去每一轮被打回的完整历史(不只 - 最近一轮)拼进提示词——FR-A4。""" - rows = conn.execute( - "SELECT payload FROM execution_event WHERE run_id = ? AND event_type = 'loop_back_triggered' " - "ORDER BY created_at", (run_id,) - ).fetchall() - history = [json.loads(p) for (p,) in rows if json.loads(p)["target_node_id"] == node_id] - if not history: - return "" - lines = [ - "Attempt {}: rejected because: {}".format(h["attempt_number"], "; ".join(h["feedback"] or [])) - for h in history - ] - return "\n\nThis node was sent back for revision before, with this history:\n" + "\n".join(lines) -``` - -不新增表,`execution_event` 一张表回答"这个节点被打回几次、每次谁触发的、为什么、按什么顺序"——满足决策 #4,且天然带 `run_id`/`node_exec_id`/`created_at`,现有图可视化机制(`v_graph_visualizer`,若按 `run_id` 拉取 `execution_event` 渲染)不需要新的数据源就能把 `source_node_id → target_node_id` 画成一条回路边。 - -## 8. Part B:Planner 自规划节点集合 - -### 8.1 节点类型清单——数据,不是硬编码提示词字符串(FR-B2) - -```python -# work_graph_plan.py 新增 -_PLANNER_NODE_CATALOG = [ - {"artifact_type": "brd", "description": "商业需求文档...", "judgment": {"kind": "none"}}, - {"artifact_type": "prd", "description": "产品需求文档...", - "judgment": {"kind": "llm", "judge_prompt": _PRD_CONSISTENCY_JUDGE_PROMPT}}, - {"artifact_type": "sdd", "description": "详细设计文档...", "judgment": {"kind": "none"}}, - {"artifact_type": "benchmark", "description": "验收基准...", "judgment": {"kind": "none"}}, - {"artifact_type": "test_dataset", "description": "测试数据集(可选,goal 不需要时回复 N/A)...", - "judgment": {"kind": "none"}}, - {"artifact_type": "test_script", "description": "可执行验收脚本(可选,goal 不需要时回复 N/A)...", - "judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}}, -] -``` - -`_PRD_CONSISTENCY_JUDGE_PROMPT` 是现有 `_PRD_CONSISTENCY_PROMPT`(`e2e_pipeline.py:72`)改写成 `judge_prompt` 的通用形状(`{content}` 占位符替代原来的 `{prd_content}`),供 Planner 在自规划时"抄"进它给 prd 节点声明的 `judgment` 里——这份清单只是给 LLM 的**参考建议**,不是强制:Planner 完全可以给某个节点声明跟清单建议不同的 `judgment`/`on_fail`(FR-B4 已确认"全部自由规划"),清单只是降低它每次都要从零发明判断方式的成本。 - -### 8.2 新 `_PROMPT_TEMPLATE` - -```python -_PROMPT_TEMPLATE = ( - "Plan (do not execute) the Work Graph for this goal: {goal_text}\n" - "Available node types (use any subset, in any order that makes sense -- " - "this goal may not need all of them):\n{catalog_json}\n" - "For each node you include, decide: node_id, artifact_type, depends_on, " - "a judgment config (how to tell if this node's output is acceptable -- you " - "may reuse a catalog entry's suggested judgment, or write your own 'llm' " - "judge_prompt), and an on_fail config (what happens if judgment fails: " - "retry_self, loop_back, or hard_fail). If a node's on_fail.action is " - "'loop_back', you MUST also add a corresponding edge in the top-level " - "'edges' array with edge_type 'on_failure', from that node to the exact " - "earlier node_id it should return to -- the target must be a real " - "upstream dependency of this node (something that genuinely produced " - "input this node used), not an arbitrary node. This mirrors how the " - "success path already works: every node's 'yes, this succeeded, what's " - "next' is expressed as a normal dependency edge, and 'no, this failed, " - "go back to X' is expressed the same way, just with edge_type " - "'on_failure' instead. Every other edge needs edge_type: 'zero_token' " - "for pure aggregation/reduce steps, 'needs_judgment' for anything " - "requiring the agent to actually reason about content.\n" - "End your reply with the graph_manifest as a single fenced ```json code " - "block shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no " - "extra wrapper key." -).format(catalog_json=json.dumps(_PLANNER_NODE_CATALOG), goal_text="{goal_text}") -``` - -(`.format()` 两次是因为 `catalog_json` 在模块加载时就能确定、`goal_text` 要留到调用时才填——用 `{{`/`}}` 转义加上先格式化一次 `catalog_json`,再对外暴露带 `{goal_text}` 占位符的最终模板字符串,写法上等价于现有 `_GENERIC_PROMPT_TEMPLATE` 的处理方式,不是新模式。) - -### 8.3 `_normalize_graph_manifest` 的变化:删除,不是加固 - -`_CANONICAL_DOC_CHAIN` 常量、以及"当六个/五个规范类型全部出现时强制线性依赖顺序"那段逻辑(`work_graph_plan.py`,2026-08-07 已经因为禁用 test_script 缩成 5 元组)**整体删除**——FR-B4 已确认"全部自由规划,不保留任何代码级固定顺序兜底"。`_normalize_graph_manifest` 保留的部分: -- `id`/`node_id` 键名兼容(2026-08-01 真实教训)。 -- 从 `edges` 反推 `depends_on`(同上)。 - -新增的部分: -- **2026-08-08 修订(用户反馈)**:失败路径的目标节点不是节点身上一个跟 `edges` 脱节的独立字段,而是跟成功路径一样,由规划这个节点的时候一并定好的边决定——"yes 走哪个节点、no 走哪个节点,都是边定义好的"。所以校验对象变成:每个声明了 `on_fail.action="loop_back"` 的节点,必须**恰好有一条**从它出发、`edge_type="on_failure"` 的边(见第 4.2 节 `_on_failure_target`),且这条边指向的节点必须是该节点的(直接或间接)上游依赖——LLM 规划时可能"跳"到一个跟自己毫无依赖关系的节点(逻辑上没有意义:loop_back 的语义是"回到更早、真正影响了我的节点"),这种情况在 `_normalize_graph_manifest` 里直接拒绝并抛 `PlanningFailedError`(复用现有异常类),比让它在 `run_graph()` 执行到一半才报错更早暴露问题。 - -```python -def _validate_loop_back_targets(graph_manifest): - node_ids = {n["node_id"] for n in graph_manifest["nodes"]} - for node in graph_manifest["nodes"]: - on_fail = node.get("on_fail") or {} - if on_fail.get("action") != "loop_back": - continue - node_id = node["node_id"] - on_failure_edges = [ - e for e in graph_manifest.get("edges", []) - if e.get("from") == node_id and e.get("edge_type") == "on_failure" - ] - if len(on_failure_edges) != 1: - raise PlanningFailedError( - "node {} declares on_fail.action='loop_back' but has {} on_failure edge(s), expected exactly 1".format( - node_id, len(on_failure_edges) - ) - ) - target = on_failure_edges[0]["to"] - if target not in node_ids: - raise PlanningFailedError( - "node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format( - node_id, target - ) - ) - if target not in _transitive_ancestors(graph_manifest, node_id): - raise PlanningFailedError( - "node {}'s on_failure edge targets {!r}, which is not an upstream dependency -- " - "loop_back must target a node that genuinely influenced this one".format(node_id, target) - ) -``` - -(`_transitive_ancestors` 是 `_transitive_dependents`——第 4.2 节,`work_graph_orchestrate.py`——的反向版本;两边各自独立实现一份即可,`work_graph_plan.py` 不需要因此新引入对 `work_graph_orchestrate.py` 的依赖,两个模块现在也没有互相 import,保持这个边界。`_on_failure_target`——第 4.2 节——运行时可以放心假设"恰好一条"已经在这里校验过,不用再处理 0 条/多条的情况。) - -### 8.4 `plan_documents()` 调用点改动 - -`e2e_pipeline.py::plan_documents()` 不再需要 `_combined_planner_verify_fn`(PRD 一致性检查 + test_script 编译检查两个手写分支)——改成 `verify_fn=None`,因为自规划出的每个节点都自带 `judgment`,`run_graph()` 内部会自动解析。`mechanical_check_registry={"check_script_compiles": check_script_compiles}` 照常传入。旧的 `_combined_planner_verify_fn`/`_prd_consistency_verify_fn`/`_test_script_verify_fn` 函数**不删除**——`validate_implementation_with_own_graph()`(Validator 自己的硬编码 3/2 节点图,本轮不改,见需求文档非目标)继续用它们,且它们仍然是"外部 verify_fn 向后兼容路径"的真实调用方,删除会破坏 FR-A8。 - -## 9. 向后兼容性核对表(FR-A8) - -| 现有调用点 | 是否声明 `judgment`/`on_fail` | 改造后走的路径 | -|---|---|---| -| `implement_code()` 的合成单节点图 | 否 | `_resolve_node_verify_fn` 直接返回 `None`(该调用点本来就不传 `verify_fn`),零改动 | -| `validate_implementation_with_own_graph()`(`_VALIDATOR_MANIFEST`) | 否 | `_resolve_node_verify_fn` 返回外部 `_test_script_verify_fn(...)`,失败时走 `hard_fail`/现状 `_verify_and_maybe_regenerate`,逐字节不变 | -| `work_graph_fork.py`(`_GENERIC_PROMPT_TEMPLATE` 路径) | 否 | 同上,无 `verify_fn` 传入,无判断 | -| `plan_documents()`(Part B 改造后) | 是(每个节点自带) | 走新的 `judgment`/`on_fail` 解析路径 | - -## 10. 测试计划(对应需求文档验收标准 1-10) - -| 验收标准 | 测试方式 | -|---|---| -| 1-2. loop_back 累积历史 + 触发上限 | mock `execute_work_graph`,构造 A→B、B 声明 `on_fail=loop_back(target=A)`,B 连续失败 N 次;断言 A 被重跑 N 次、`execution_event` 里有 N 条 `loop_back_triggered`、A 第 N 次收到的 prompt 包含前 N-1 次的 feedback 文本 | -| 3. 崩溃重启后正确恢复轮次 | 跑到第 2 次 loop_back 时中断(mock 抛异常模拟进程退出),重新调用 `run_graph()`,断言 `_loop_attempt_count_from_events` 从 DB 读到 2,不是从 0 开始 | -| 4. 静态图校验不因 loop_back 报环 | 一个声明了 `on_fail.action="loop_back"` 且带一条指向上游节点的 `on_failure` 边的 `graph_manifest`,`topological_layers()` 正常返回分层,不抛 `ValueError`(因为 `on_failure` 边类型根本不参与 `topological_layers` 的依赖计算,只有 `depends_on`/普通数据依赖 edge 参与) | -| 5. 未声明字段的图逐字节不变 | 现有 `test_work_graph_orchestrate.py`/`test_e2e_pipeline.py` 全部既有用例不改断言即通过 | -| 6-7. Part B 自由规划 + 健壮性兜底 | 同需求文档,mock LLM 规划响应缺 test_dataset / 缺 depends_on,断言分别正确处理 | -| 8. test_script 可选 | mock 规划响应包含 test_script 节点,断言正常规划执行 | -| 9. 按 org_node/node_type 配置上限生效 | `settings_config.upsert_loop_limit_config` 写两条不同配置,构造两个 loop_back 场景,断言各自在配置的上限触发 | -| 10. loop 历史只在 `execution_event` | 触发若干轮 loop_back 后,只查 `execution_event`(不查任何新表)复原完整历史 | -| 新增:`_validate_loop_back_targets` 拒绝非法目标 | 分别构造"`on_failure` 边指向的节点不是上游依赖"、"声明 `loop_back` 但没有 `on_failure` 边"、"同一个节点有 2 条 `on_failure` 边"三种场景,断言 `plan_work_graph()` 都抛 `PlanningFailedError` | -| 新增:mechanical_check_registry 未注册报错 | `judgment.check_ref` 指向一个不存在的名字,断言 `run_graph()` 在执行任何节点前就报错,不是跑到那个节点才发现 | -| 新增:`topological_layers()` 正确忽略 `on_failure` 边(4.0 节修复) | 构造 A→B(普通依赖)+ B→A(`on_failure` 边)的 `graph_manifest`,断言 `topological_layers()` 返回 `[[A], [B]]`(不因为 B→A 那条边被误判成环);另跑一遍 G 既有的 `test_work_graph_orchestrate.py` 全部用例确认零回归 | - -## 11. 实施步骤建议 - -1. `schema.py` 两处迁移(`work_graph_loop_limit_config` 建表 + `migrate_add_loop_back_event_type`)+ `settings_config.py` 两个新函数 + `_ensure_schema()` 接入——纯数据层改动,先落地,不碰任何执行逻辑。 -2. `work_graph_orchestrate.py`:`_resolve_node_verify_fn`/机械检查注册表注入/`_llm_judgment_verify_fn`——先只做"judgment 解析",不做 loop_back(这一步验证:没有 `on_fail` 时行为完全不变,第 9 节核对表跑一遍)。 -3. `work_graph_orchestrate.py`:`_transitive_dependents`/`_LoopBackRequested`/`run_graph()` 主循环回退逻辑/attempt 后缀机制——loop_back 的核心,单独用 mock 场景验证第 10 节测试计划 1-5、9、10。 -4. `work_graph_plan.py`:删除 `_CANONICAL_DOC_CHAIN` 强制顺序逻辑、`_PLANNER_NODE_CATALOG`、新 `_PROMPT_TEMPLATE`、`_validate_loop_back_targets`——Part B,第 10 节测试计划 6-8、新增两项。 -5. `e2e_pipeline.py::plan_documents()` 切到 `verify_fn=None` + `mechanical_check_registry`,端到端跑一次 mock 场景确认整条链路。 -6. 真实场景验证(`RUN_REAL_AGENT_E2E=1`,小心控制次数/成本):观察一次真实 Planner 自规划调用产出的 `graph_manifest` 长什么样,是否合理、是否触发过 `PlanningFailedError`。 diff --git a/docs/todo/work-graph-self-planning-and-loop-back-requirements.md b/docs/todo/work-graph-self-planning-and-loop-back-requirements.md deleted file mode 100644 index 9d7375d..0000000 --- a/docs/todo/work-graph-self-planning-and-loop-back-requirements.md +++ /dev/null @@ -1,106 +0,0 @@ -# Planner 动态自规划 Work Graph(含节点判断与失败回路)——需求文档 - -**日期**: 2026-08-08 -**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应用户 2026-08-07/08-08 两轮讨论:先是"要不要让 Planner 自己判断要不要有 test_script 节点",深入后明确为一个更完整的诉求——Planner 第一步应该是自己规划 Work Graph(要不要 brd/prd/sdd/benchmark/test 的组合、未来还有更多 tool/skill 可组合调用),并为每个节点规划输入输出边约束、判断成功/失败的方式,失败时把结果送回更早的节点形成回路(loop),并把 loop 过程中的产出记录下来供后续轮次参考改进。详细设计(SDD 层面:graph_manifest 的具体 JSON 形状、`topological_layers()`/`run_graph()` 的具体改造点、循环历史的具体持久化方式)留到本文档确认后单独产出。 -**状态**: ✅ 已实现(2026-08-08)。原「8. 开放问题」的 7 个分叉点均已拍板,记录见「8. 已确认的决策」,正文相应章节已按决策更新。详细设计见 `docs/todo/work-graph-self-planning-and-loop-back-design.md`;Part A(判断+回路引擎)与 Part B(Planner 自规划)均已实现,`python3 -m unittest discover -s src`(692 tests)+ `discover -s supervisor -p "test_*.py"`(126 tests)全绿。尚未做真实 `RUN_REAL_AGENT_E2E=1` 端到端验证(需要真实 API 额度,等待用户决定是否现在跑)。 - -## 1. 背景 - -### 1.1 现状:三条独立的、能力有限的机制 - -- **Planner 自己的文档链**(`work_graph_plan.py::_PROMPT_TEMPLATE`):过一层真实的 LLM 规划调用,但提示词硬编码要求"必须为 brd/prd/sdd/benchmark/test_dataset 各出一个节点"(test_script 已于 2026-08-07 从这个固定清单里移除,见 `docs/todo/validator-test-script-narrative-output-issue.md`)——节点集合本质是固定的,规划这一步只是在确认依赖关系,不是真的自由决策"要不要有某个节点"。这是 2026-08-06 的一次明确设计决定(`docs/done/dynamic-work-graph-design-skill-design.md` §3 原话:"不是让 Claude 完全自由决定 Planner 要生成哪些节点——Planner 那条链是'架构常量'")。 -- **`_GENERIC_PROMPT_TEMPLATE`**(同文件,服务 Coder/Validator 的子任务图):真正让 LLM 自由规划 `{node_id, depends_on, task_prompt}`,但目前只有 `work_graph_fork.py`(人工发起的 time-travel 调试功能)在用,没有接入任何生产路径的自动执行;且和 Planner 的固定链一样,只是一张纯 DAG,没有判断/回路概念。 -- **`work_graph_orchestrate.py::run_graph()` 的验证-重试机制**:`_verify_and_maybe_regenerate()` 只支持"某节点验证失败 → 原地重新生成同一个节点一次 → 还失败就整体 `ConsistencyCheckFailedError`"。这不是回路——失败结果不会被送回更早的节点,历史反馈也只保留最近一轮(`"; ".join(feedback)` 直接拼进 reflect prompt,不会累积多轮)。 - -### 1.2 两个此前未被充分利用的既有事实 - -- **`edge_type`(`zero_token`/`needs_judgment`)字段已经存在于 `GRAPH_MANIFEST_SCHEMA`,但从未被任何执行逻辑读取**——`topological_layers()` 只看 `depends_on`/`from-to` 决定分层,完全不管 `edge_type` 的值,是纯文档字段。 -- **`topological_layers()` 对任何环直接 `raise ValueError`**——现在的执行引擎从数据结构到算法都是严格 DAG,没有"跳回更早节点"这种控制流的容身之处。`docs/sdd/01-M-模型设计.md:60` 里 `work_graph_run.graph_manifest` 字段的文档说明其实从一开始就写了"概念上应涵盖 fan-out/fan-in 分组、loop 规则",只是从未真正实现过。 - -### 1.3 一个必须正面回应的架构决定 - -`docs/graphlemon_architecture.md`"方案 A"明确设想过:Work Graph 内部的 fan-out/fan-in/loop/verifier 应该直接用 Claude Code 原生 Dynamic Workflow 原语(`agent()`/`parallel()`/`pipeline()`,类似 loop-until-dry)编排,而不是手撸 Python 引擎。但 `work_graph_orchestrate.py` 的实际实现故意背离了这个设想(一个节点一个独立 `claude -p` 子进程 + Python 拓扑引擎逐节点持久化状态),原因写在该模块自己的 docstring 里:REQ-06 断点续跑——原生原语跑在一次不透明的 `claude -p` 调用里,中途崩溃就会丢失全部循环历史和已完成节点状态;现在的设计换来的是"进程重启后能从任意已完成节点继续,不重跑"。 - -本需求延续这个既有背离,在 Python 引擎里新增回路能力,而不是回退去用原生原语——因为 REQ-06 是已经在生产里被真实依赖的能力(self-hosting 崩溃恢复、worktree resume),原生原语给不了这个保证。 - -## 2. 目标 - -1. `graph_manifest` 的节点能声明式地表达"怎么判断这个节点成功/失败"(机械检查引用,或另一次 LLM 判断),不再要求调用方在 `e2e_pipeline.py` 里为每种 artifact_type 手写专门的 `verify_fn` 分支。 -2. `graph_manifest` 的节点能声明式地表达"失败之后怎么办":原地重试(现状)、跳回更早的某个指定节点重新执行(新增)、或直接判定整图失败(现状的显式化)。 -3. 触发"跳回更早节点"时,被跳回节点到失败节点之间的整条下游链要被重新执行(下游数据是基于坏输入产出的,不能继续用);同时,这个节点每一轮被拒绝的历史(不只是最近一轮)要被完整地喂给它自己的下一次重新生成,让它能看到"过去几次分别为什么被拒绝",而不是每次都从零开始猜。 -4. 这套回路能力必须有可配置的上限,且在进程中途崩溃重启后能正确恢复"当前是第几轮",不能死循环也不能重置归零。 -5. 在回路执行引擎具备之后,让 Planner 自己的规划调用能真正决定"这个 goal 需要哪些节点",而不是像现在这样被提示词硬编码死一份固定清单——这份清单本身也应该是可扩展的数据,为未来接入更多 tool/skill 节点类型留出空间(这轮不要求真的新增新类型,只要求机制本身可扩展)。 -6. **长期方向(本轮不实施,仅作为设计约束)**:用户明确的最终目标是"Planner 改造成功后,Coder、Validator、甚至未来新增的 org graph node,都需要具备同样的能力——先 plan 自己的 work graph,再按照 work graph 执行"。本轮的 Part A(judgment/on_fail 机制)设计必须是 org-agent 无关的通用能力,不能是 Planner 专属的特殊逻辑,即使这轮只把它接到 Planner 一个 org node 上(见「3. 非目标」第 1 条、「8. 已确认的决策」第 6 条)。 - -## 3. 非目标(本轮明确不做) - -1. **本轮实施范围仍只覆盖 Planner,不把 Coder/Validator 也接上自规划**。`_VALIDATOR_MANIFEST` 现在完全是硬编码的 Python 字面量,连"过一层 LLM 规划"都没有;Coder 现在是合成单节点图。用户已确认长期方向是所有 org agent(含未来新增的)都要具备同样能力(见「2. 目标」第 6 条),但落地顺序是"Planner 改造验证成功后,再依次推广"——不是本轮的实施范围,本轮只是设计时不能把机制设计成 Planner 专属。 -2. **不回退到 Claude Code 原生 Dynamic Workflow 原语**(见 1.3)。继续在 Python 层的 `work_graph_orchestrate.py` 里扩展执行引擎,保住 REQ-06 断点续跑。 -3. **不追求完全通用的图执行框架**。回路的触发方式限定为"某节点判断失败 → 跳回自己声明的 `on_fail.target_node_id`",不支持任意节点间的条件分支、多目标跳转、或图结构在运行时被重新规划——这些是更大的能力,不在这轮范围内。 -4. **不重新设计跨 Org 的既有 loop 机制**(`org_level_loop.py`/`org_loop_route.py`:Validator FAIL/INCONCLUSIVE → 判根因 → 路由回 Planner/Coder,`loop_round` 封顶 3 轮)。本需求的回路是"同一个 Org Agent 自己的 Work Graph 内部"这一层,比跨 Org 的 loop 粒度更细,两者独立计数、不共享额度、不互相替代——内层耗尽自己的上限后照旧抛 `ConsistencyCheckFailedError`,外层机制不变地接管。 -5. **不在本需求里解决 test_script/validator_test_script 节点"叙述文字而非代码"的结构性根因**。这个节点已于 2026-08-07 因该问题禁用(`docs/todo/validator-test-script-narrative-output-issue.md`),根因是"探索后收尾习惯",跟本需求要解决的问题(节点集合是否可以自由规划、失败能否形成回路)完全正交。用户已确认(见「8. 已确认的决策」第 7 条):Part B 上线后,test_script 可以直接作为可选节点提供给 Planner 自由规划选择,不需要等这个结构性问题修复;该结构性问题的真正修复安排在另一份文档里,跟"给 Coder agent 加入 test_script 模块"一起解决——本需求只负责不阻塞它,不负责修它。 -6. **不修改现有 `_GENERIC_PROMPT_TEMPLATE` 路径本身**(Coder/Validator 子任务图、`work_graph_fork.py` 用的那条)。如果这轮的 judgment/on_fail 字段设计得足够通用,未来可以顺带让这条路径也受益,但不是本轮的验收范围。 - -## 4. 功能需求 - -分两部分:Part A 是执行引擎扩展(判断 + 回路能力),Part B 是 Planner 自规划节点集合。**Part B 依赖 Part A 先完成**——自由规划出的图如果没有真正的失败回路可用,只是重新引入"LLM 自由吐图结构"的脆弱性(2026-08-03 那次真实故障:LLM 漏给 `depends_on`/`edges`,`benchmark` 排到 `sdd` 前面执行,直接 `KeyError` 崩溃),却拿不到新能力。 - -### Part A:节点判断 + 失败回路执行引擎(`work_graph_orchestrate.py`) - -| # | 需求描述 | 优先级 | -|---|---|---| -| FR-A1 | `graph_manifest` 的节点应支持声明式的 `judgment` 配置(机械检查引用,或触发一次额外 LLM 判断调用),替代现在"调用方在 Python 里为每种 artifact_type 手写 `verify_fn` 分支"的模式。 | 必须 | -| FR-A2 | `graph_manifest` 的节点应支持声明式的 `on_fail` 配置,至少支持三种行为:`retry_self`(现状:原地重新生成同一节点一次)、`loop_back`(跳转到指定的更早节点重新执行)、`hard_fail`(不重试,直接判定整图失败——现状默认行为的显式化)。 | 必须 | -| FR-A3 | 触发 `loop_back` 时,不只重跑 `target_node_id` 本身,还要重跑它到触发失败的节点之间的整条下游链,因为这条链上的产出都是基于(即将被修正的)过期输入生成的。 | 必须 | -| FR-A4 | `loop_back` 的每一轮反馈(这一轮产出了什么、为什么被判定失败)必须可完整还原,且 `target_node_id` 重新执行时,累积的完整历史(不只是最近一轮)要被喂进它的提示词。**不新增专门的历史表**——复用现有 `execution_event` 日志机制(`node_start`/`node_completed` 等既有事件),每次 `loop_back` 触发/重新执行时在事件里记录 predecessor/successor 节点关联,让"这个节点被打回过几次、每次因为什么"可以直接从现有事件流重建,同时让现有的图可视化机制(`v_graph_visualizer`)能把回路自然地呈现成图上的一条边,不需要另外维护一份历史、也不需要事后做双份数据的一致性对账。 | 必须 | -| FR-A5 | 每一对 `(loop_back 目标节点, 触发回路的失败节点)` 需要独立计数,并有上限;超限时整个 `run_graph()` 调用判定失败(复用/扩展现有 `ConsistencyCheckFailedError` 语义),不允许无限循环。**上限不是写死的全局常量**——复用 `settings_config.py`(现有 `org_concurrency_config` 的同类模式:DB 存储、`get_/upsert_` 读写接口)新增一张按 `(org_node, node 类型或 node_id 模式)` 维度可配置的上限表,允许后续根据真实调用经验(哪些节点类型容易被反复打回、上限设多少更合理)动态调整,不需要改代码重新部署;节点自身也可以在 `on_fail.max_attempts` 里覆盖这个默认值。 | 必须 | -| FR-A6 | 循环计数与历史必须能扛得住 REQ-06 式的断点续跑:进程在循环中途崩溃重启后,应正确恢复"当前是第几轮、历史反馈有哪些",不能从零重置,也不能因为状态丢失而死循环。 | 必须 | -| FR-A7 | `topological_layers()` 的静态分层只应基于"数据依赖边"(`depends_on`/`consumes`);节点声明的 `on_fail.target_node_id`(运行时才可能触发的控制流跳转)不参与静态拓扑排序,不应被当成图里的环而报错。 | 必须 | -| FR-A8 | 不声明 `judgment`/`on_fail` 字段的既有 `graph_manifest`(例如 Coder 现在的合成单节点图)行为必须完全不变——这两个字段可选,缺省等价于现状(`judgment=none` 直接通过、`on_fail=hard_fail` 即现在的"验证失败重试一次后硬失败")。 | 必须 | -| FR-A9 | `judgment`/`on_fail` 机制的实现(`graph_manifest` schema、`run_graph()` 的执行逻辑、FR-A5 的上限配置表)必须是 org-agent 无关的通用能力——不能依赖任何 `org_node == "planner"` 的特判。这轮只有 Planner 会真正产出带这些字段的 `graph_manifest`,但 Coder/Validator 未来接入时,不应该需要改 `work_graph_orchestrate.py` 本身,只需要它们自己的规划提示词开始产出这些字段(呼应「2. 目标」第 6 条的长期方向)。 | 必须 | - -### Part B:Planner 自规划节点集合(依赖 Part A) - -| # | 需求描述 | 优先级 | -|---|---|---| -| FR-B1 | Planner 的规划提示词从"必须产出固定清单里的每一个节点"改为:给定一份可用节点类型清单,由 LLM 自己判断这个 goal 需要哪些节点、它们的依赖关系,以及(复用 Part A)每个节点的 `judgment`/`on_fail` 配置。 | 必须 | -| FR-B2 | 可用节点类型清单本身应是可扩展的数据(而不是硬编码在提示词字符串里的固定枚举),为未来接入更多 tool/skill 节点类型留出空间;本轮不要求真的新增除现有文档类型之外的新类型。 | 应该 | -| FR-B3 | 自由规划出的 `graph_manifest` 必须经过与现有 `_normalize_graph_manifest` 同等严格的健壮性校验/兜底,防止 2026-08-03 那次真实故障重演(LLM 输出遗漏 `depends_on`/`edges`,导致执行顺序错误甚至崩溃)——节点集合变自由不代表可以降低这层鲁棒性要求。 | 必须 | -| FR-B4 | **brd/prd/sdd/benchmark/test_dataset(以及 test_script,一旦被规划出来)全部由 LLM 自由规划,不保留任何代码级的固定顺序/固定节点集合兜底**——已确认(「8. 已确认的决策」第 1 条):完全推翻 `docs/done/dynamic-work-graph-design-skill-design.md` §3"Planner 链是架构常量"的决定。FR-B3 的健壮性校验兜底(防止 LLM 漏给 `depends_on`/`edges`)因此变得更重要:没有任何硬编码的顺序可以兜底,健壮性完全依赖 FR-B3。 | 必须 | - -## 5. 验收标准 - -1. 构造一个 `graph_manifest`:节点 A 产出,节点 B 依赖 A、声明 `judgment` 机械检查、`on_fail=loop_back` 指向 A;用 mock 让 B 连续两轮判断失败、第三轮通过。断言:A 被重新执行了两次(不是原地重试 B),且 A 第二次、第三次重新执行时收到的提示词里包含前面所有轮次的失败反馈(不只是最近一轮)。 -2. 同上场景,把 B 的失败改成"每一轮都失败",断言在达到可配置上限后,`run_graph()` 判定整图失败并明确抛出(而不是无限循环挂起),且失败前 A 已经产出的内容仍按现有"部分成功节点落盘"机制(`_write_completed_node_artifacts`)被正确保留。 -3. 模拟"回路进行到第 2 轮时进程中断重启",断言恢复执行后能正确识别"已经是第 2 轮"并继续,而不是把计数器归零重新从第 1 轮开始,也不是把已经持久化的历史反馈丢掉。 -4. 一个声明了 `on_fail.target_node_id` 指向更早节点的 `graph_manifest`,`topological_layers()`/图校验逻辑不应把它当作静态环拒绝(不抛 `ValueError`)。 -5. 一个完全不使用 `judgment`/`on_fail` 字段的既有 `graph_manifest`(例如 Coder 当前用的合成单节点图),跑一遍 `run_graph()`,行为与改造前逐字节一致——现有 `src/test_work_graph_orchestrate.py`/`src/test_e2e_pipeline.py` 里未显式测试新字段的用例,不修改断言即可继续通过。 -6. (Part B)给 Planner 一个明显不需要 test_dataset 的 goal(例如纯配置项调整),断言自规划产出的 `graph_manifest` 里确实没有 test_dataset 节点,且整条链路(Coder 拿到的 `test_dataset_content` 走 `"N/A: not provided"` 兜底)不因为节点缺失而报错——复用现有的 `"test_dataset" in artifact_ids` 容错逻辑,不需要新代码。 -7. (Part B)人为构造一个自由规划回复"遗漏 `depends_on`"的场景(模拟 2026-08-03 那次真实故障的输入),断言健壮性兜底仍能纠正执行顺序,不崩溃。 -8. (Part B)给 Planner 一个明显需要机械化验收脚本的 goal,可用节点类型清单里包含 test_script,断言 Planner 能够(不是必须,是"能够")自由规划出一个 test_script 节点——确认 test_script 已按「8. 已确认的决策」第 7 条重新开放为可选节点,不再被清单排除。 -9. (FR-A5)验证上限配置确实按 `(org_node, node 类型)` 生效:给同一个 org_node 下两种不同 node 类型分别配置不同上限,构造两个各自持续失败的 loop_back 场景,断言各自在自己配置的上限触发失败,不是共用同一个全局值;再验证节点自身 `on_fail.max_attempts` 能覆盖配置表里的默认值。 -10. (FR-A4)验证 loop_back 历史确实通过 `execution_event` 而非新表重建:触发一次多轮 loop_back 后,仅查询 `execution_event` 表(不查询任何新增表)即可还原"这个节点被打回几次、每次的 predecessor/successor 是谁、每次的反馈内容"。 - -## 6. 约束与假设 - -- 复用 REQ-06 已经建立的"`work_node_execution.output_payload` 是节点成功后的权威持久化数据"这一既有约定;回路历史**不新增专门的表**,完整复用 `execution_event` 现有机制(见 FR-A4),必须遵循"重启后可完整恢复"这一约束,不引入新的、易丢失的临时状态。 -- 复用 `org_loop_route.py::_LOOP_LIMIT` 的既有模式("计数 + 达到上限即转 `blocked_pending_human`")作为 Part A 回路上限触发后果的参照,但两者是独立的计数器,不共享同一个 `task.loop_round` 字段;上限的具体数值来源改为复用 `settings_config.py` 的 DB 配置模式(见 FR-A5),不是抄一个固定常量。 -- `judgment.kind="llm"` 复用 `_check_prd_consistency` 现有模式:额外发起一次 `agent()` 调用(`agentType='planner'`),self-target 场景下走独立 worktree 隔离,避免重新设计一套判断机制。 -- Part A 完成后,Coder 现在的合成单节点图、`_GENERIC_PROMPT_TEMPLATE` 路径都不强制迁移使用新字段——新字段是可选能力,不是所有 `graph_manifest` 生产者都必须立刻采用;但机制本身必须 org-agent 无关(见 FR-A9),为后续推广铺路。 - -## 7. 风险与已知权衡 - -- **用户已知情并接受:Part B 上线后 test_script 类节点可能被自由规划回来,narrative-output 问题可能重现**。`docs/todo/validator-test-script-narrative-output-issue.md` 记录的根因是"探索后收尾习惯",跟本需求解决的问题(节点集合能不能自由规划、失败能否形成回路)完全正交,本需求不修它。用户决策(「8. 已确认的决策」第 7 条):不等结构性修复,真正的修复安排在"给 Coder agent 加入 test_script 模块"那份文档里一起解决——本需求上线后,如果自举/真实任务里又复现了叙述文字问题,这是已知的、被接受的风险,不是本需求的回归。 -- **回路能力本身可能被滥用成隐藏的高成本重试**:一个设计不当的 `judgment`(例如过于严格的 LLM 判断标准)配合 `loop_back`,可能导致某个节点反复被打回、消耗大量 token 才勉强通过上限。FR-A5 的按 `(org_node, node 类型)` 可配置上限缓解了"一刀切"的问题,但初始默认值仍需要设计阶段给出具体理由,不能只是"抄一个数字";上线后应该根据真实调用数据回头调整(这正是选择"DB 配置表"而不是"硬编码常量"的原因)。 -- **Part B 是对已有架构决定的正式推翻**,不是渐进式增强。`docs/done/dynamic-work-graph-design-skill-design.md` §3 需要在 Part B 实施后同步更新,避免文档和代码再次出现本 session 里多次遇到的"文档记录的意图与实际代码不一致"的问题。 -- **Part A + Part B 一并实施(已确认,见「8. 已确认的决策」第 5 条),没有中间的"仅 Part A 先跑一段时间验证稳定性"的缓冲阶段**——意味着 Part A 的回路引擎第一次被真实使用,就是在"节点集合本身也是自由规划、不受固定链条约束"这个更不可预测的场景下,两个新变量同时引入,出问题时定位"是回路引擎的 bug 还是自由规划出了一个奇怪的图结构"会比分阶段更难。这是用户已知情的权衡,不是本需求遗漏。 - -## 8. 已确认的决策(2026-08-08) - -1. **Part B 的推翻力度**:全部自由规划。brd/prd/sdd/benchmark/test_dataset(以及重新开放的 test_script)不保留任何代码级固定顺序/固定节点集合兜底,完全推翻 `docs/done/dynamic-work-graph-design-skill-design.md` §3 的既有决定(对应 FR-B4)。健壮性完全依赖 FR-B3 的校验兜底。 -2. **回路上限**:不设单一全局默认值,按 `(org_node, node 类型)` 可配置,复用 `settings_config.py` 的 DB 配置模式,根据实际调用经验动态调整(对应 FR-A5)。 -3. **`judgment.kind="llm"` 的实现**:复用现有 `_check_prd_consistency` 模式(额外发起一次 `agent()` 调用,`agentType='planner'`,self-target 场景走独立 worktree)。 -4. **回路历史持久化**:不新增专门的表,完整复用 `execution_event` 现有日志机制,记录 predecessor/successor 节点关联,天然可被现有图可视化机制呈现为有向图上的边(对应 FR-A4)。 -5. **落地顺序**:Part A、Part B 一起做,不分阶段(见「7. 风险」最后一条,这是已知权衡,不是遗漏)。 -6. **Validator/Coder 是否一起做**:本轮仍只落地 Planner,但 Part A 的机制设计必须 org-agent 无关(FR-A9)——用户明确的长期方向是 Planner 验证成功后,Coder、Validator、乃至未来新增的 org graph node 都要具备"先 plan 自己的 work graph,再按图执行"的同一能力(见「2. 目标」第 6 条)。 -7. **test_script 是否要等结构性修复**:不需要等。Part B 上线后,test_script 直接作为可选节点开放给 Planner 自由规划;narrative-output 问题的真正结构性修复安排在"给 Coder agent 加入 test_script 模块"的另一份文档里一起解决,不是本需求的阻塞项(见「7. 风险」第一条)。 diff --git a/src/schema.py b/src/schema.py index f87d6dc..0a93bc9 100644 --- a/src/schema.py +++ b/src/schema.py @@ -274,7 +274,7 @@ CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at); -- 1.11.1 work_graph_loop_limit_config (Work Graph self-planning + loop-back, --- docs/todo/work-graph-self-planning-and-loop-back-design.md §6) +-- docs/done/work-graph-self-planning-and-loop-back-design.md §6) CREATE TABLE work_graph_loop_limit_config ( config_id TEXT PRIMARY KEY, max_loop_attempts INTEGER NOT NULL, @@ -596,7 +596,7 @@ def migrate_relax_org_node_constraints(conn): def migrate_add_work_graph_loop_limit_config(conn): """2026-08-08 (Work Graph self-planning + loop-back, - docs/todo/work-graph-self-planning-and-loop-back-design.md §6): adds + docs/done/work-graph-self-planning-and-loop-back-design.md §6): adds work_graph_loop_limit_config on an ALREADY-POPULATED database. Plain `CREATE TABLE IF NOT EXISTS` -- a brand-new table, no CHECK constraint to rebuild around, unlike migrate_expand_artifact_types. Idempotent and @@ -612,7 +612,7 @@ def migrate_add_work_graph_loop_limit_config(conn): def migrate_add_loop_back_event_type(conn): """2026-08-08 (Work Graph self-planning + loop-back, - docs/todo/work-graph-self-planning-and-loop-back-design.md §7): adds + docs/done/work-graph-self-planning-and-loop-back-design.md §7): adds 'loop_back_triggered' to execution_event.event_type's CHECK enum on an ALREADY-POPULATED database -- same create-copy-drop-rename dance as migrate_expand_artifact_types (SQLite has no ALTER-based constraint diff --git a/src/task_query.py b/src/task_query.py index 9635f4d..a4374c7 100644 --- a/src/task_query.py +++ b/src/task_query.py @@ -174,7 +174,7 @@ def get_task(conn, task_id): # on_failure edge / a retry_self node's "sent back Nx" badge -- # neither is derivable from graph_manifest/work_node_execution # alone (loop_back_triggered is execution_event-only by design, - # see docs/todo/work-graph-self-planning-and-loop-back-design.md §7). + # see docs/done/work-graph-self-planning-and-loop-back-design.md §7). "loop_back_events": _loop_back_events_for_run(conn, w[0]), } for w in runs diff --git a/src/test_schema.py b/src/test_schema.py index f6bf877..7129293 100644 --- a/src/test_schema.py +++ b/src/test_schema.py @@ -748,7 +748,7 @@ class MigrateAddWorkGraphLoopLimitConfigTest(unittest.TestCase): self.conn = schema.connect(":memory:") old_sql = schema.SCHEMA_SQL.replace( "-- 1.11.1 work_graph_loop_limit_config (Work Graph self-planning + loop-back,\n" - "-- docs/todo/work-graph-self-planning-and-loop-back-design.md §6)\n" + "-- docs/done/work-graph-self-planning-and-loop-back-design.md §6)\n" "CREATE TABLE work_graph_loop_limit_config (\n" " config_id TEXT PRIMARY KEY,\n" " max_loop_attempts INTEGER NOT NULL,\n" diff --git a/src/work_graph_plan.py b/src/work_graph_plan.py index 409e2e4..3f26150 100644 --- a/src/work_graph_plan.py +++ b/src/work_graph_plan.py @@ -308,7 +308,7 @@ def _normalize_graph_manifest(manifest): hardcoded canonical chain to fall back on if an LLM response omits dependency info, so a real reproduction of the 2026-08-03 incident shape is a genuine, currently-unmitigated risk this design accepted - (see docs/todo/work-graph-self-planning-and-loop-back-requirements.md + (see docs/done/work-graph-self-planning-and-loop-back-requirements.md §7's risk list) -- FR-B3's own acceptance criterion only covers the "wrong key names" failure mode this backfill already handles, not a complete absence of dependency info with no canonical chain to guess from. -- libgit2 0.21.4