# Phase 2 — 全员自规划 + 合图 + 节点级精确 loop_back——详细设计(SDD) **日期**: 2026-08-08 **性质**: 详细设计,对应 `docs/done/org-graph-unification-and-universal-self-planning-requirements.md` 的 Phase 2(FR-2/FR-3/FR-4),按 §4/§4.1 已拍板的合图(方案 A)路径设计。 **依赖**: 硬依赖 Phase 1(`docs/done/org-graph-unification-phase1-review-gate-design.md`)——已实现(commit `ea19c7d`,2026-08-08)。§9 接口核对清单已于 2026-08-12 逐条核对完成:三项全部吻合(`plan_and_review_documents()` 经 `_review_planner_output_with_retries()` 透传 `plan_documents()` 的 `{"run_id", "artifact_ids"}` 返回形状,含 `run_id`;`task.status` 新值确认是 `reviewing_planner_output`;`roles/planner_reviewer/role.json` 目前 `skills: ["planner-output-review", "unattended-agent-operations"]`,未预留 §7.1 新 skill 位置,Phase 2 实施时新增一行即可,不是不兼容)。 **状态**: Phase 2a(commit `86fc0bb`)+ Phase 2b(commit `7c46a2f`)已实现,2026-08-12。Phase 2c 的 Validator 自规划改造(§6 核心部分)已实现,2026-08-12:`validate_implementation_with_own_graph()` 改为 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 追加进 Planner/Coder 共享的同一个 `work_graph_run`,终审 verdict 变成图内一个真实节点(`node_type='final_verdict'`,`judgment.kind='mechanical'`、`check_ref='verdict_says_pass'`,`on_fail.action='dynamic_loop_back'`——Validator 图里唯一允许用这个 action 的节点),硬门槛(脚本真实非零退出码强制 FAIL,不听 LLM 辩解)通过 `_verdict_node_build_prompt`/`_verdict_says_pass_check` 的共享可变状态闭包保留,Python 强制而非只是 prompt 里说说。`_VALIDATOR_MANIFEST`/`_VALIDATOR_DOC_PROMPTS`/`_VALIDATOR_VERDICT_PROMPT`/`_validator_prompt_builder` 保留但不再使用(同 `_CODER_PROMPT` 先例)。§7.2 的收口验证已完成分析(见 §10 Phase 2c 小节末尾:一旦 verdict 节点自己的 `dynamic_loop_back` 内部消化重试,`validate_implementation_with_own_graph()` 正常返回时 verdict 必为 PASS,`org_level_loop.py` 的旧粗粒度路由对这条新路径自然不可达,不需要显式新旧流程判断——决定保留旧分支代码不删,已在 `scheduler.py::finish_task_lifecycle` 加注释说明)。 **2026-08-13 §2.1/§2.2 已实施**:`work_graph_run.org_node` 列彻底删除,`ux_work_graph_run_active` 收紧为 `UNIQUE(task_id) WHERE status NOT IN ('done', 'failed')`('blocked' 故意算作活跃——恢复一个 blocked 的 run 现在总是走 `append_to_run_id` 追加进同一行,不会再 INSERT 新行)。新迁移函数 `schema.migrate_drop_work_graph_run_org_node()`(内部先幂等调用 `migrate_relax_org_node_constraints()`,再做 create-copy-drop-rename,`PRAGMA foreign_keys=OFF` 全程包裹,理由同 `migrate_add_reviewing_planner_output_status` 的真实重启事故记录——`work_graph_run` 是被多张表 REFERENCES 的父表)已接入 `backend_server.py::_ensure_schema()`。下游改动:`work_graph_plan.py`/`work_graph_fork.py`(含 `create_fork()`/`_reconstruct_build_prompt()`/`execute_fork()`,`fork_info["org_node"]` 改名 `fork_info["subagent_type"]`,源节点自己的执行身份)/`root_cause_trace.py` 的三处 INSERT 全部去掉 `org_node` 列;`work_graph_run_query.py`(`list_work_graph_runs`/`get_work_graph_run`)与 `task_query.py::get_task` 的返回形状从单值 `"org_node"` 改成 `"org_nodes_involved"`(从 `work_node_execution.subagent_type` 去重派生的列表,`org_node=` 查询参数语义从等值匹配改成 EXISTS 子查询),前端 `frontend/app.js` 对应改造(`getLatestRunForOrgNode`/`orgNodesLabel`/`renderOrgGraphTopology` 等消费点全部改用列表语义)。发现一处设计文档本节 §2.1.3 没预料到的遗漏点:`task_query.py::get_task` 也有同样的单值 `org_node` 读取,一并修完。`root_cause_trace.py::judge_root_cause_via_work_graph` 的 INSERT 有一个已知、记录在代码注释里的边界情况:索引收紧后,若这个函数被调到时对应 task 已有一个仍处于活跃/blocked 状态的共享 run,会撞索引——目前不是真实风险(这个函数唯一的调用方 `org_level_loop.run_fix_loop` 只在一个已经不可达的分支里被调用,且它自己的测试夹具里对应 task 从未预先插入过 work_graph_run 行),但如果未来重新接上这条路径需要重新评估。`python3 -m unittest discover -s src`(768 tests)+ `discover -s supervisor`(126 tests)+ 4 个 frontend smoke test 全绿。 **2026-08-13 §7.1 已实施**:新增 `skills/work-graph-node-review/SKILL.md`——判断"自规划 Work Graph 节点(Coder/Validator/planner_reviewer 自己规划出的、声明 `judgment.kind="llm"` 的节点)的产出是不是真的、结构化的",不是叙述文字冒充产出;吸收了 `docs/done/validator-test-script-narrative-output-issue.md` §6 的根因分析和 `_NARRATION_RETRY_HINT`(条件句而非断言句)的措辞教训,并明确跟 `planner-output-review`(Planner 固定文档链、任务级、Coder 开始前把关)、`qa-task-validation`(任务级整体验收)、`skill-defect-remediation`(改 Skill 文件本身)三者的边界——判断粒度是"这一个自规划节点",不是文档链/整任务/Skill 文件。已挂到 `roles/planner_reviewer`、`roles/coder`、`roles/validator` 三个 `role.json` 的 `skills` 列表(各追加一行,如 §9 核对清单当时预判)。未修改 `.claude/agents/*.md`——那是 `agent_profile.deploy_agent_definitions()` 从 `role.json` 编译出的部署产物,由 Deploy Supervisor 在真实 cutover 时重新生成,不是手动跟 `role.json` 同步维护的(观察到现有 `.claude/agents/coder.md` 已经缺了更早前 Phase 2a 加的 `design-dynamic-work-graph`,印证这个文件本来就不是手动保持同步的)。`python3 -m unittest discover -s src`(768 tests,含 `test_agent_profile.py`)全绿。 **Phase 2c 至此全部完成**(§2.1/§2.2/§6 Validator 自规划改造/§7.1 均已实施;§7.2 收口分析已完成,决定不改代码,见上文)。**2026-08-08 修订**:写作过程中,需求文档 §7 的四个开放问题已被用户直接拍板(详见该文档 §7 对应条目),其中两条推翻了本文档最初的设计判断——Coder/Validator 自规划改为"建议节点清单"而非完全自由(§3),`work_graph_run.org_node` 改为彻底移除而非保留(§2.1)——本版本已按拍板结果改写,不是原始草稿。**2026-08-08 追加**(同日,见需求文档新增 §3.1):FR-4 的目标被明确为"失败即进化"完整方法论(判断失败 → Reflection-review 动态诊断 → 系统性缺陷则先 Fix-Skill → 跳回执行),当时 §5 设计的 `dynamic_loop_back` 只覆盖第 1/2/4 步,第 3 步(Fix-Skill)是已知缺口。**2026-08-12 补齐**:§5 已扩写为完整四步设计(§5.4 Reflection-review 现在同时判断目标节点和缺陷性质,§5.5 新增 Fix-Skill,真实调用既有但从未真正投入使用的 `skill_update.py::update_skill()`),过程中发现两个既有事实(`skills:` frontmatter 只按名字动态加载内容、不需要重新部署;`org_level_loop.py` 的 `_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key,`update_skill()` 在这个代码库里投产以来从未被真正调用过)——详见 §5 开头。由哪个身份执笔改 Skill 已通过 `AskUserQuestion` 确认(2026-08-12):统一用 Validator,见 §5.5。 --- ## 1. 一个必须先说清楚的修正:这比需求文档原来设想的更大 `org-graph-unification-and-universal-self-planning-requirements.md` 里 FR-2 原话是"函数本身已通用,只需替换调用点"——**这个判断经本次设计调研核实是不准确的**,具体差距见下面三点。本文档按修正后的真实范围设计,不是简单的调用点替换。 ### 1.1 `_GENERIC_PROMPT_TEMPLATE` 从未征集 `judgment`/`on_fail` `work_graph_plan.py:171-187` 的 `_GENERIC_PROMPT_TEMPLATE`(Coder/Validator 走 `plan_work_graph()` 时会用到的模板)只要求 LLM 给每个节点 `node_id`/`depends_on`/`task_prompt`,**完全没有要求 `judgment`/`on_fail` 字段**——跟 Planner 专用的 `_PROMPT_TEMPLATE`(`work_graph_plan.py:136-159`,明确征集 judgment/on_fail/on_failure 边)不是同一份提示词。如果只是把 `implement_code()`/`validate_implementation_with_own_graph()` 的硬编码 manifest 换成调用 `plan_work_graph(conn, "coder", ...)`,产出的图**不会带任何判断/回路能力**——FR-4 想要的"每个节点验证+精确 loop_back"根本无从谈起。**必须先扩展 `_GENERIC_PROMPT_TEMPLATE`**,见 §3。 ### 1.2 `run_graph()`/`work_node_execution` 的 `subagent_type`/`skill_ref` 是 run 级常量,不是节点级 `work_node_execution` 表(`schema.py:156-176`)已经有 `subagent_type`/`skill_ref` 两列——但 `_run_one_node()`(`work_graph_orchestrate.py:837-903`)调用 `_ensure_node_row()` 时,传的是 `run_graph()` 整个调用唯一的 `org_node`/`skill_id` 参数(`work_graph_orchestrate.py:856-857`),对图里**每一个节点都相同**,不是节点自己声明的值。FR-3 要的"每个节点声明用哪个 subagent + skill 实现"这个字段今天在数据库层已经有位置放,但执行链路上从来没有真正做到"节点级差异化"。**这是需要真正扩展的执行引擎逻辑**,不是数据已经在只是没读——见 §4。 ### 1.3 现有 `on_fail.action="loop_back"` 是静态预声明的单一目标,不是动态根因判断 这是本次调研发现的**最关键的架构缺口**:今天的 loop_back 机制要求节点在**规划阶段**就通过 `edge_type="on_failure"` 边声明"我失败了就跳回哪一个节点"(`work_graph_orchestrate.py:315-326` `_on_failure_target`,`work_graph_plan.py:247-283` `_validate_loop_back_targets` 在规划完成时就校验这条边指向合法上游节点)——**目标节点是写死的,不是失败发生时才动态判断的**。 但 FR-4 想要的是"终审 Validator 判断失败后,根因判断具体是哪个更早的节点出问题,精确跳回那个节点"——终审失败时,真正的根因可能是 SDD 节点、Benchmark 节点、或某个 Coder 实现节点中的任意一个,**这个"该跳回哪"的判断只能在失败发生的那一刻、看到具体的失败症状后才能做出,不可能在规划阶段预先声明一条固定的边**。现有机制完全没有覆盖这种"下游失败,动态诊断该回退到众多上游候选节点里的哪一个"的场景——它只覆盖"这个节点自己的产出不合格,固定退回同一个预先约定的地方"。 **这意味着 FR-4 需要给引擎新增一种 `on_fail.action`(本文档称之为 `dynamic_loop_back`),不是简单复用现有 `loop_back`**——见 §5。这确实修改了 `work_graph_orchestrate.py` 的判断/回路逻辑本身,跟 Phase 1 需求文档非目标 §5.3"不重写 judgment/on_fail/loop_back 引擎"的表述有出入;本文档认为这是必要的、有边界的**扩展**(新增一个 action 分支,不改动任何现有 action 的行为),不是"重写",Phase 1 那条非目标应理解为"不推倒重来"而非"一行都不能动",但这个偏差需要明确记录,不能悄悄做。 --- ## 2. Schema 变更 ### 2.1 `work_graph_run.org_node` 彻底移除(用户拍板,2026-08-08) 本节最初的设计判断是"不需要改,保留作'run 由谁发起'的元数据"——**已被用户推翻**:合图后一个 task 全程只有一个 `work_graph_run` 行,节点分属不同 org_node(`planner`/`planner_reviewer`/`coder`/`validator`),继续在 run 级保留单值 `org_node` 列既不准确(一个 run 里真实混着 4 种 org_node 的节点)也没有实际用途(真正的归属信息在 §1.2 已经确认属于 `work_node_execution.subagent_type`)——决策是彻底删除这一列,不是留作冗余字段。 **Schema 迁移**:`work_graph_run` 表删除 `org_node` 列(SQLite 无 `DROP COLUMN` 的老版本需要 create-copy-drop-rename,跟本文档 §2.2 同一个迁移函数里一起做,或者用现代 SQLite 的 `ALTER TABLE ... DROP COLUMN` 直接支持——取决于实施时 SQLite 版本,若支持则用后者,更简单)。 **下游改造点**(决策本身点名的两处,本节给出具体定义): #### 2.1.1 `work_graph_run_query.py:54-67`(`?org_node=` 过滤 + 结果字段) ```python query = ( "SELECT wgr.run_id, wgr.task_id, wgr.status, wgr.started_at, wgr.completed_at, " "(SELECT COUNT(*) FROM work_node_execution wne WHERE wne.run_id = wgr.run_id) AS node_count, " "(SELECT GROUP_CONCAT(DISTINCT wne.subagent_type) FROM work_node_execution wne " " WHERE wne.run_id = wgr.run_id) AS org_nodes_involved, " "wgr.parent_run_id, wgr.fork_of_node_exec_id " "FROM work_graph_run wgr JOIN task t ON t.task_id = wgr.task_id WHERE t.project_id = ?" ) params = [project_id] if org_node: query += ( " AND EXISTS (SELECT 1 FROM work_node_execution wne " "WHERE wne.run_id = wgr.run_id AND wne.subagent_type = ?)" ) params.append(org_node) ``` 返回形状里原来的单值 `"org_node": r[2]` 换成 `"org_nodes_involved": r[...].split(",") if r[...] else []`(GROUP_CONCAT 返回逗号拼接字符串或 NULL,app 层拆成列表)——调用方(`task_query.py`/frontend)原来读单值 `org_node` 字段的地方,改成读这个列表;Phase 3 的 Org 级元图(FR-5a)本来就需要知道"这个 run 横跨了哪几个 org_node 阶段",这个字段直接服务那个需求,不是额外负担。 #### 2.1.2 `work_graph_fork.py` 的 `org_node` 用法(三处,逐一替换) 1. **`create_fork()` 第 125-137 行**:SELECT 语句去掉 `wgr.org_node`;原来解出的 `org_node` 变量直接用同一行已经在读的 `wne.subagent_type`(源节点自己的执行身份)替代——这个值本来就在查询结果里,不需要新增查询。 2. **`_reconstruct_build_prompt(conn, task_id, org_node, graph_manifest)`(第 94-113 行)**:**去掉 `org_node` 参数**,dispatch 逻辑从"整张图统一按 `org_node=='planner'` 且全部节点有 `artifact_type`"改成**逐节点判断**——这本来就是合图后必须解决的问题(一张合并图会同时包含 Planner 的 `artifact_type` 节点和 Coder/Validator 的 `task_prompt` 节点,§2.1 决策只是让这个改动提前到现在做,不是额外增加的范围): ```python def _reconstruct_build_prompt(conn, task_id, graph_manifest): goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] node_artifact_types = { n["node_id"]: n["artifact_type"] for n in graph_manifest["nodes"] if "artifact_type" in n } planner_builder = e2e_pipeline._planner_prompt_builder(goal_text, node_artifact_types) def build_prompt(node, outputs): if "artifact_type" in node: return planner_builder(node, outputs) if "task_prompt" in node: return work_graph_plan.generic_node_prompt_builder(node, outputs) raise ForkGraphShapeUnsupportedError( "node {} has neither 'artifact_type' nor 'task_prompt'".format(node["node_id"]) ) return build_prompt ``` 3. **`INSERT INTO work_graph_run (...)`(第 164-168 行)**、**`fork_info`/`execute_fork()` 里的 `org_node`(第 189/208 行)**:INSERT 语句去掉 `org_node` 列;`execute_fork()` 调用 `run_graph()` 时原来传 `fork_info["org_node"]` 的位置,改传 `fork_info["subagent_type"]`(源节点自己的执行身份,作为 `run_graph()` 新签名里"没有节点级覆盖时的默认值",见 §4.1)。 #### 2.1.3 `root_cause_trace.py::judge_root_cause_via_work_graph`(第三处,设计 §5 Fix-Skill 时新发现,§2.1 原稿遗漏的一处) `judge_root_cause_via_work_graph()`(`root_cause_trace.py:222-226`)自己也有一次 `INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) VALUES (...)`——跟 Planner/Coder/Validator 一样直接写了 `org_node` 列,同样要删掉这个列。这个函数本身不受合图影响(它总是为陪审团判断新建一张独立的小图,不追加进跨阶段大图),但既然 `work_graph_run` 表结构变了,这里的 INSERT 语句必须跟着改,不能漏掉——本文档 §5.4 的 `_judge_root_cause_node` 直接复用这个函数的骨架,实施时会经过这段代码,顺手一起改。 ### 2.2 `ux_work_graph_run_active` 语义变化(需要迁移) `schema.py:151-152`:`CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)`(覆盖非终态 status)——语义是"同一 task 同一 org_node 同一时刻只有一个活跃 run"。合图后一个 task 全程只有一个活跃 run(不再按 org_node 切分),这条唯一索引的粒度要从 `(task_id, org_node)` 收紧为 `(task_id)`: ```sql DROP INDEX ux_work_graph_run_active; CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id) WHERE status NOT IN ('done', 'failed'); ``` (SQLite 的部分索引语法用 `WHERE` 子句表达"仅非终态",比原来在应用层散落判断更直接——可以借这次迁移顺手把语义从"应用层约定"变成"数据库约束",原索引其实也是全表扫描等价的部分索引,这里是等价改写,不是新引入部分索引这个概念。) 新增迁移函数 `migrate_narrow_work_graph_run_active_index(conn)`,照抄 §2.3 的思路:先检查现有索引定义,已经是新语义就跳过(幂等)。 ### 2.3 `artifact.artifact_type` CHECK 枚举 vs 自规划节点的任意 `node_id` **决策**:自规划出的 Coder/Validator 节点**不是每一个都要落成 `artifact` 行**。沿用 `implement_code()` 今天的既有设计——只有"最终交付物"(`code_diff`/`test_report`)才落 `artifact`,中间探索/推理节点的产出只落 `work_node_execution.output_payload`(已经是持久化的,断点续跑不受影响),不经过 `_write_completed_node_artifacts()` 那条路径。 具体做法:`_GENERIC_PROMPT_TEMPLATE` 扩展后(§3)要求节点声明一个新的可选字段 `promotes_to_artifact`(`{"artifact_type": "code_diff", ...}` 形状,缺省表示"这个节点的产出不落 artifact,只是图内部的中间步骤");`_write_completed_node_artifacts()`(Planner/Validator 现在共用的落盘函数)改成只处理声明了 `promotes_to_artifact` 的节点,不再对图里每个节点都无脑落一行——这本来就是 Planner 自己的 `_PLANNER_NODE_CATALOG` 節点隐含的假设(每个 Planner 节点都是"最终交付物"),Coder/Validator 的自由节点不能沿用同一假设。 `artifact.artifact_type` 的 CHECK 枚举**不需要扩表**——`promotes_to_artifact.artifact_type` 只允许落在已有的枚举值里(`code_diff`/`test_report`/`validator_benchmark`/`validator_test_dataset`/`validator_test_script` 等),由 Coder/Validator 的 prompt 明确约束"你的最终交付物只能声明这几种 artifact_type 之一",不开放任意值——避免把这个本来就有明确边界的表变成"什么都能塞"的自由字段。 --- ## 3. `_GENERIC_PROMPT_TEMPLATE` 扩展(对应 §1.1)+ Coder/Validator 建议节点清单(用户拍板,2026-08-08) 原设计判断"Coder/Validator 保持完全自由规划,不设清单"——**已被推翻**:`work_graph_plan.py:161-170` 2026-08-06 的注释原话"Coder/Validator's sub-tasks have no such fixed small vocabulary of node types"被用户拍板否决,Phase 2 要求给 Coder/Validator 各自定义一份类似 `_PLANNER_NODE_CATALOG` 的"建议节点类型清单"——建议不强制(跟 Planner 目录同样的措辞和约束力度,不是 Planner 文档链那种固定顺序的强约束),每项可以带 `suggested_judgment`。 ### 3.1 新增两份建议目录 ```python _CODER_NODE_CATALOG = [ {"node_type": "impl", "description": "Actual code changes implementing the SDD."}, { "node_type": "unit_test", "description": "New/updated unit tests covering the change -- same repo convention as existing tests.", "suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT}, # 见 §7.1 新 skill }, { "node_type": "integration_check", "description": "Actually runs the real test suite/benchmark script against the change and reports pass/fail -- not a description of doing so.", "suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT}, }, ] _VALIDATOR_NODE_CATALOG = [ { "node_type": "independent_benchmark", "description": "Validator's own held-out acceptance scenarios -- NOT reused from Planner's/Coder's benchmark, a genuine second opinion.", }, {"node_type": "test_dataset", "description": "Concrete test data, if this goal has a natural one."}, { "node_type": "test_script", "description": "A real, runnable script mechanically checking the independent benchmark's scenarios.", "suggested_judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"}, }, ] _GENERIC_NODE_CATALOGS = {"coder": _CODER_NODE_CATALOG, "validator": _VALIDATOR_NODE_CATALOG} ``` `node_type` 是目录里的建议标签,不是节点 schema 的新必填字段——Coder/Validator 的节点形状仍然是 `node_id`/`depends_on`/`task_prompt`(跟今天一样,`artifact_type` 是 Planner 专属的字段,不引入到这条路径),目录只是喂给 LLM 参考"这类目标通常需要哪几种节点",最终 `node_id`/`task_prompt` 怎么写仍由 LLM 自己决定,不强制照抄目录里的 `node_type` 字面值。 ### 3.2 `_GENERIC_PROMPT_TEMPLATE` 改为按 `org_node` 参数化,注入对应目录 原模板是一段固定字符串,只用 `.format(goal_text=goal_text)` 填一个占位符(`work_graph_plan.py:351` `plan_work_graph()`里的调用)。现在要注入目录 JSON(自带花括号),复用 `_PROMPT_TEMPLATE` 已经验证过的"拼接 + `.replace()`"写法,不用 `.format()`(同一个转义坑,`_normalize_graph_manifest` 的 docstring 已经记录过一次真实事故,不要在这里重蹈): ```python def _generic_prompt_template(org_node): catalog = _GENERIC_NODE_CATALOGS.get(org_node, []) catalog_section = ( "Suggested node types for this goal (use any subset, in any order, invent your own if " "none fit -- these are starting points, not requirements):\n" + json.dumps(catalog) + "\n" if catalog else "" ) return ( "Plan (do not execute) a Work Graph for this goal: {goal_text}\n" + catalog_section + "Follow the design-dynamic-work-graph Skill's methodology: only add a depends_on edge " "when a node's task_prompt genuinely needs another node's output content ...\n" "Output a graph_manifest whose nodes each have: node_id, depends_on, task_prompt, plus:\n" " - judgment: how to tell if this node's output is acceptable -- {\"kind\": \"none\"} if " " nothing needs checking, {\"kind\": \"llm\", \"judge_prompt\": \"...{content}...\"} for " " your own judgment prompt with a {content} placeholder.\n" " - on_fail (only if judgment.kind != \"none\"): {\"action\": \"retry_self\"}, " " {\"action\": \"hard_fail\"}, or {\"action\": \"loop_back\"} with a matching on_failure " " edge to a genuine upstream dependency.\n" " - promotes_to_artifact (optional): {\"artifact_type\": \"code_diff\"|\"test_report\"|...} " " ONLY for nodes producing this task's actual final deliverable.\n" "Every 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 ..." ) ``` `plan_work_graph()` 里 `else` 分支(`work_graph_plan.py:350-351`)从 `prompt = _GENERIC_PROMPT_TEMPLATE.format(goal_text=goal_text)` 改成 `prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text)`。 `GRAPH_MANIFEST_SCHEMA`(`work_graph_plan.py:33-86`)同步补充 `promotes_to_artifact` 的文档性字段(跟 `judgment`/`on_fail` 一样,仅供文档参考,不做 jsonschema 强校验,原因见模块自己的 `--agent`+`--json-schema` 限制说明)。 `_normalize_graph_manifest()`(`work_graph_plan.py:286-333`)不需要改——它的 `_validate_loop_back_targets` 校验逻辑已经是通用的,不区分 Planner 的固定链还是 Coder/Validator 的目录建议图。 --- ## 4. 节点级 `subagent_type`/`skill_ref`/`tools` 绑定(对应 §1.2,FR-3 的"用什么实现") ### 4.1 `run_graph()`/`_run_one_node()` 签名扩展 `run_graph(conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, ...)` 的 `org_node`/`skill_id` 两个参数保留(作为"没有节点级覆盖时的默认值"),`_run_one_node()` 内部改成: ```python def _run_one_node(conn, run_id, org_node, skill_id, target_folder_path, node, outputs, graph_manifest, ...): node_org_node = node.get("subagent_type", org_node) # 节点自己声明的执行身份,缺省用图级默认 node_skill_id = node.get("skill_ref", skill_id) ... node_exec_id = _ensure_node_row( conn, run_id, exec_node_id_str, node_org_node, node_skill_id, ... ) ... run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, node_org_node, prompt, output_schema, timeout) ``` 这不是把 `run_graph()` 整个重写——只是把原来"直接用闭包里的 `org_node`/`skill_id`"改成"先看节点自己有没有声明,没有就退回闭包默认值",两行改动,向后兼容(没有声明 `subagent_type` 的节点行为完全不变,Planner 的固定链、Coder 现有的合成单节点图都不受影响)。 `_resolve_loop_limit()`(`work_graph_orchestrate.py:512-528`)、`_llm_judgment_verify_fn()`(`work_graph_orchestrate.py:420-467`)里同样用到的 `org_node` 参数,同步改成接收 `node_org_node`(调用方在 `_run_one_node` 里已经解析好,直接传下去,不需要这两个函数自己再判断一次)。 ### 4.2 "tools" 绑定:复用 `docs/done/work-graph-node-execution-deblackbox-requirements.md` 的 `trigger_org_node_structured()` 节点声明 `tools`(如 `{"disallowed_tools": ["Bash"]}`,只做限制、不做放宽——不能让一个自规划节点给自己开出角色本身 `role.json` 没有的权限)时,去黑盒化文档设计的 `trigger_org_node_structured(org_node, prompt, target_folder_path, output_schema, model=None, timeout=300)` 需要再加一个可选的 `disallowed_tools` 参数,透传成底层 `claude` 调用的 `--disallowedTools` CLI 参数(05 §5.4.1.2 已经确认 CLI 顶层参数支持按工具/子命令限定,跟 subagent frontmatter 不支持是两条不同的解析路径)。 节点声明的 `disallowed_tools` 与角色 `role.json` 的 `permissions.disallowed_tools` 取并集(只加限制不减限制),在 `_run_one_node` 里合并后传给 `trigger_org_node_structured`。 **"data" 绑定**:即"这个节点的输入来自哪些上游节点",`depends_on` + `build_prompt(node, outputs)` 已经完整覆盖这件事(`outputs` 字典就是已完成上游节点的产出),不需要新机制——用户诉求里的"data"就是这一层,只是没有用现有术语描述。 --- ## 5. 新增 `on_fail.action = "dynamic_loop_back"` + 完整四步链路(Reflection-review → Fix-Skill → Re-loop) 需求文档 §3.1("失败即进化")把 FR-4 定为四步:①判断失败 ②Reflection-review 动态诊断(一次性内容错误 vs 系统性 Skill 缺陷)③如果是系统性缺陷,先修 Skill 本身 ④跳回执行。本节 2026-08-08 首版只设计了①②④,第 3 步(Fix-Skill)本次补齐——本次设计过程中读代码发现了两个直接决定 Fix-Skill 该怎么接的既有事实,先说明: **既有事实 A(关键,决定了"改 Skill 会不会真的生效")**:`.claude/agents/.md` 的 `skills:` frontmatter 只引用 Skill **名字**(`agent_profile.py::_frontmatter_yaml`),真正的 `skills//SKILL.md` 内容是 Claude Code 自己在每次 `claude --agent ` 调用时按名字动态加载的——**改完 Skill 文件、提交,下一次调用同一个 org_node 就会用上新内容,不需要重新部署 `.claude/agents/*.md`**。这意味着"改 Skill → 跳回执行"这个链路不需要额外一步"重新部署",直接可用。 **既有事实 B(说明这条能力在这个代码库里目前完全是摆设)**:`org_level_loop.py::run_fix_loop()` 的 `_ROUTE_FOR_CATEGORY = {"coder": "fast_lane", "planning": "full_replan"}`(`org_level_loop.py:10`)**没有 `skill_defect` 这个 key**——`root_cause_trace.py` 的三视角判断早就能产出 `skill_defect` 这个类别(`_PERSPECTIVE_FRAMINGS["skill_defect"]`,`root_cause_trace.py:112-116`),`bug_record.create_bug_record()` 也接受这个类别,但 `route = _ROUTE_FOR_CATEGORY.get(category, "full_replan")` 对 `skill_defect` 直接落到 `full_replan` 这个兜底值——**`skill_update.py::update_skill()` 在这个代码库里投产以来从未被任何生产代码路径真正调用过**,只有测试在用。Fix-Skill 是第一次把这条早就设计好、但从没接线的能力真正用起来。(这个 dead-code 缺口本身跟 Phase 2 无关,独立可修,见 §8 第 6 条——但不是本次设计的阻塞项,Fix-Skill 走的是全新的合图内部路径,不依赖修好这个旧 bug。) ### 5.1 语义 跟现有 `loop_back`(跳回一个规划阶段就写死的固定目标)不同,`dynamic_loop_back` 表示"这个节点失败时,由一次专门的根因判断调用(Reflection-review),从这个节点的全部上游祖先节点里动态选出真正该回退的目标,并判断这是一次性内容错误还是系统性 Skill 缺陷;后者先修 Skill(Fix-Skill)再回退"——只用于合适放在**图末端的终审性判断节点**(比如 Phase 1 引入的终审 Validator 节点),不要求每个节点都用这个更贵的机制。 ### 5.2 graph_manifest 字段(不变) ```jsonc { "node_id": "final_review", "judgment": {"kind": "llm", "judge_prompt": "..."}, "on_fail": {"action": "dynamic_loop_back", "max_attempts": 3} // 不需要对应的 on_failure 边——目标不是静态声明的 } ``` ### 5.3 执行逻辑总览(`work_graph_orchestrate.py` 新增分支) ```python if action == "dynamic_loop_back": ancestors = sorted(_transitive_ancestors(graph_manifest, node_id) - {node_id}) diagnosis = _judge_root_cause_node( # 见 §5.4,Reflection-review conn, run_id, node_id, feedback, ancestors, graph_manifest, build_prompt, outputs, org_node, target_folder_path, ) target = diagnosis["target_node_id"] if target not in ancestors: # 运行时防御,见 §5.7 raise ValueError( "dynamic_loop_back judge picked {!r}, not a real ancestor of {}".format(target, node_id) ) if diagnosis["defect_kind"] == "skill_defect": _fix_skill_for_node( # 见 §5.5,Fix-Skill——先修完 Skill 再触发回退 conn, run_id, target, graph_manifest, build_prompt, outputs, feedback, target_folder_path, ) raise _LoopBackRequested(source_node_id=node_id, target_node_id=target, feedback=feedback) ``` `_LoopBackRequested`/`run_graph()` 主循环(`_run_layers` 里 `loop_back_signal` 的处理,`work_graph_orchestrate.py:811-830`)**完全不需要改**——它已经是"给一个 `(source_node_id, target_node_id)` 就重新执行 target 及其全部下游"的通用机制,不关心这个 target 是静态声明来的还是动态判断来的,也不关心跳回之前有没有发生过 Fix-Skill 这一步。`target` 节点重新执行时会真的用上刚提交的新 Skill 内容(既有事实 A)——**这就是第 4 步"跳回执行、用新 Skill"的完整实现,不需要额外的"通知节点使用新 Skill"机制**,因为 Skill 加载本来就是运行时按名字动态发生的。 ### 5.4 Reflection-review:`_judge_root_cause_node`——判断目标节点 + 判断缺陷性质 复用 `root_cause_trace.judge_root_cause_via_work_graph` 的"多视角 Work Graph fan-out + `_tally_perspectives` 多数票仲裁"骨架,但这次要求陪审团在给出目标节点的同时,**再判断一次这是一次性错误还是系统性缺陷**——这是 Reflection-review 全链路复盘的核心职责,不能只做"选节点"这一半。 ```python _REFLECTION_OUTPUT_SCHEMA = { "type": "object", "required": ["target_node_id", "defect_kind", "detail"], "properties": { "target_node_id": {"type": "string"}, "defect_kind": {"type": "string", "enum": ["content_error", "skill_defect"]}, "detail": {"type": "string"}, }, } def _judge_root_cause_node( conn, run_id, failed_node_id, feedback, candidate_node_ids, graph_manifest, build_prompt, outputs, org_node, target_folder_path, timeout=180, ): """三个视角,每个是一次独立 Work Node fan-out 调用(跟 judge_root_cause_via_work_graph 同一套机制),但答案是 {target_node_id, defect_kind, detail} 而不是自由文本;target_node_id 必须是 candidate_node_ids 之一。多数票仲裁维度是 target_node_id(哪个 节点最多票就是哪个);defect_kind 取该目标节点获票的判断里的多数值 (同一票里 target 和 defect_kind 是绑定的一对答案,不是分开投票)。 < 2/3 一致时不升级人工(跟 root_cause_trace 不同,见 §5.7 第 2 点)。 """ ``` 三个视角的具体提问: 1. **内容视角**:"逐个看 `candidate_node_ids` 每个节点在这次 `outputs` 里的真实产出内容(`build_prompt(candidate_node, outputs)` 重建它当初被要求做什么 + `outputs[candidate_node_id]` 是它实际给出的什么),哪一个的内容本身站不住脚?是这次内容恰好没写好(`content_error`),还是这类节点反复容易出这类问题、看起来是方法论/prompt 本身有系统性偏差(`skill_defect`)?" —— 直接把 `validator-test-script-narrative-output-issue.md` §6 的根因分析("先探索再产出"这类节点收尾话术天然倾向叙述文字)作为 `skill_defect` 的具体识别范式喂给这个视角,不是让它凭空判断。 2. **衔接视角**:"如果每个候选节点单独看都没有实质内容缺陷,是不是节点之间的依赖关系/输入衔接本身有问题(比如一个节点该依赖另一个节点的输出却没声明 `depends_on`)?" —— 这种情况通常判 `content_error`(属于这次规划的偶然疏漏,不是 Skill 方法论错误)。 3. **历史视角**:"这个候选节点对应的 org_node/skill_ref 组合,在 `execution_event`(`loop_back_triggered`/`skill_iterated` 两种类型)里过去是否已经因为同一类问题被跳回或修过 Skill?如果是重复出现的同一个坑,倾向 `skill_defect`;如果是第一次见,倾向 `content_error`。" —— 这是三个视角里唯一利用跨任务/跨轮次历史数据的一个,需要真的查一次 `execution_event`,不是只看这一次的 `outputs`。 ### 5.5 Fix-Skill:真实修 Skill,不是修这一次的输出 **触发条件**:`_judge_root_cause_node` 判定 `defect_kind == "skill_defect"`。**要修哪个 Skill**:直接查 `graph_manifest` 里 `target_node_id` 节点自己声明的 `skill_ref`(§4 节点级绑定字段,缺省退回图级默认 `skill_id`)——不查 `work_node_execution` 表(那张表的 `node_id` 可能带 `-attemptN` 后缀,从 `graph_manifest` 原始节点定义读更直接、无歧义)。 **由谁来修——已用户拍板(2026-08-12):统一用 Validator 身份**(`org_node="validator"`),不管出问题的节点自己是哪个 org_node,都不让它自己改自己的 Skill。理由:`skill_update.py` 模块自己的 docstring 原话是"a **Validator**-triggered rewrite"——这是 03-C 时期就定下的既有设计意图,本设计只是第一次真正把它接起来,不是新发明;更本质的理由是,如果一个 org_node 的 Skill 本身有系统性偏差,让它用同一个(有偏差的)判断力去修自己的 Skill,大概率修不到点子上(这次踩的坑,恰恰是它自己看不出来的盲区)——独立的 Validator 身份更接近"找另一双眼睛"这个 Reflection-review 一直在做的事。 **真实调用链**: ```python def _fix_skill_for_node(conn, run_id, target_node_id, graph_manifest, build_prompt, outputs, feedback, target_folder_path, timeout=300): target_node = next(n for n in graph_manifest["nodes"] if n["node_id"] == target_node_id) skill_id = target_node.get("skill_ref", "unknown") # 图级默认由调用方 run_graph() 的 skill_id 参数兜底 current_content = _read_skill_content(skill_id) # skills//SKILL.md, skill_query._REPO_ROOT task_id = conn.execute("SELECT task_id FROM work_graph_run WHERE run_id = ?", (run_id,)).fetchone()[0] goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] rewrite_result = org_node_trigger.trigger_org_node_structured( # 见 work-graph-node-execution-deblackbox-requirements.md "validator", _SKILL_REWRITE_PROMPT.format( goal_text=goal_text, skill_id=skill_id, current_content=current_content, what_was_asked=build_prompt(target_node, outputs), what_it_produced=outputs[target_node_id].get("content", ""), failure_feedback="; ".join(feedback or []), loop_history=_loop_history_prompt_suffix(conn, run_id, target_node_id), # 复用既有函数 ), target_folder_path, _SKILL_REWRITE_SCHEMA, timeout=timeout, ) bug_result = bug_record.create_bug_record( conn, task_id=task_id, description="Skill {} produced repeated defective output at node {}".format(skill_id, target_node_id), root_cause_category="skill_defect", root_cause_detail=rewrite_result["change_summary"], suggested_route="full_replan", # 沿用既有枚举值填充要求,本路径不真的按它路由 ) iteration = skill_update.update_skill( conn, skill_id, bug_result["bug_id"], rewrite_result["new_content"], rewrite_result["change_summary"], repo_path=skill_query._REPO_ROOT, # 见既有事实 A 上方的先例 ) _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration["iteration_id"]) # 见下方 return iteration ``` `_SKILL_REWRITE_PROMPT`/`_SKILL_REWRITE_SCHEMA`(`{"new_content": str, "change_summary": str}`)是新增内容,方法论("怎么在不破坏这个 Skill 现有正确行为的前提下,精确修正导致这次失败的那一小部分")沉淀成新 skill `skills/skill-defect-remediation/SKILL.md`(见 §7.1 更新),挂到 `roles/validator/role.json`——这是 Validator 身份专属的新能力,不是 §7.1 `work-graph-node-review` 的职责范围(那个判断"一个节点的输出好不好",这个是"怎么改一份 Skill 文档本身",性质不同)。 **记录一次 `skill_iterated` execution_event(发现:这个 event_type 已经在 CHECK 枚举里存在,从 U-001 起就有,但从未被任何代码写过——不需要新 schema 迁移)**: ```python def _log_skill_iterated_event(conn, run_id, target_node_id, skill_id, iteration_id): row = conn.execute("SELECT wgr.task_id, t.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 (?, ?, ?, ?, 'skill_iterated', ?)", (str(uuid.uuid4()), row[1], row[0], run_id, json.dumps({ "node_id": target_node_id, "skill_id": skill_id, "iteration_id": iteration_id, })), ) conn.commit() ``` 这条 event 是 Phase 3 前端(FR-5b 历史回放)能把"这一轮为什么跟上一轮不一样"讲清楚的关键数据——不只是"跳回了",而是"跳回之前,Skill 被真的改过一次,改了什么"。 ### 5.6 Re-loop:不需要新机制 `_fix_skill_for_node` 返回后,§5.3 紧接着照常 `raise _LoopBackRequested(...)`——跟"一次性内容错误"分支走的是同一行代码。两条路径唯一的区别就是要不要先经过 §5.5,跳回执行本身完全复用现有机制(§5.3 已经说明为什么不需要改 `run_graph()` 主循环)。 ### 5.7 安全边界(含新增的 Fix-Skill 专属风险) 1. **运行时校验目标必须是真实祖先**(§5.3 已展示)——即使判断调用本身犯错选了一个不存在或非祖先的 node_id,直接报错而不是静默产生一次无意义的回路。 2. **判断未达成 2/3 一致时的退化策略**:不像 `root_cause_trace` 那样升级人工(那是跨 Org Agent 边界的终局判断,这里只是图内部一次重试的一部分),退化为"回退到 `candidate_node_ids` 里离失败节点最近的直接上游 + `defect_kind` 视为 `content_error`"(保守默认——不确定是不是系统性问题时,不touch Skill,只是普通回退)。 3. **`_resolve_loop_limit` 的计数维度不变**:`_loop_attempt_count_from_events` 是按 `(target_node_id, source_node_id)` 这一对计数的(`work_graph_orchestrate.py:560-567`),`dynamic_loop_back` 每次可能选出不同的 `target_node_id`,意味着"同一个 source 连续 3 次动态判断到 3 个不同的 target"不会被现有计数逻辑拦住——额外加一个"同一个 source_node_id 累计触发 dynamic_loop_back 的总次数"上限(配置沿用 `work_graph_loop_limit_config` 同一张表,`config_id` 用 `"{org_node}:{source_node_id}:total"` 这个新的 key 形状),防止绕过限流、无限循环。 4. ~~由 Validator 身份执笔改 Skill 这个设计判断需要用户确认~~——已通过 `AskUserQuestion` 确认(2026-08-12),采用 Validator 身份,见 §5.5。 5. **同一个 Skill 在一次 run 内被反复重写的风险**:`_fix_skill_for_node` 本身没有"这个 skill_id 这次 run 已经改过一次"的去重检查——如果 Reflection-review 连续几轮都判定同一个 `skill_ref` 是系统性缺陷(比如第一次改得不够准,问题仍然复现),会对同一个 Skill 文件连续产生多次真实 Git commit。这不算错误(每次改动理论上都在收敛),但值得在 §5.3 的分支里加一条"同一个 `(run_id, skill_id)` 组合本次 run 内的 Fix-Skill 触发次数"计数,超过一个小上限(比如 2 次)后即使判定是 `skill_defect` 也退化成普通 `content_error` 处理,避免一次任务反复重写同一份 Skill——具体阈值留给实施阶段。 6. **self-target 场景:Skill 库commit 和任务自己的代码改动共享同一个物理仓库**——`skill_query._REPO_ROOT` 默认等于 `paths.repo_root()`;当 `target_folder_path` 恰好也是 graphlemon 自己(`paths.is_self_target(target_folder_path)` 为真)时,两者是**同一个仓库**。`update_skill()` 直接对 `skill_query._REPO_ROOT` 做 `git add`+`commit`,不经过 05 §5.8 那套 worktree 隔离——这是**有意为之,不是遗漏**:`skill_update.py` 模块 docstring 早就说明"a Validator-triggered rewrite takes effect immediately",Skill 库改动本来就设计成直接落主仓库,不像任务自己的代码改动需要隔离后再合并。两者物理上不冲突(分别只碰 `skills//` 和任务自己改的路径,如 `src/`),但没有专门测试过"self-target 任务执行期间,Fix-Skill 的直接 commit 和该任务自己的 worktree 合并操作,在 git 层面是否有任何可观测的交互"——建议 Phase 2c 实施验收时至少构造一次这个组合场景跑一遍,不要假设"理论上不冲突"就等于"实测也没事"(这个代码库里 worktree 相关的坑,历史上从来都是实测才发现的,见 CLAUDE.md 的 Isolation 章节)。 --- ## 6. Coder/Validator 接入自规划:`implement_code()`/`validate_implementation_with_own_graph()` 改造 ### 6.1 增量追加接口(用户拍板,2026-08-08:`plan_work_graph()` 新增参数,不是新函数) 原设计判断是新增一个独立函数 `plan_work_graph_append()`——**已被推翻**:用户拍板要求合并/冲突检测逻辑集中在 `plan_work_graph()` 一个函数里,新增一个可选参数 `append_to_run_id`,不是平行的第二个函数。理由(用户原话):`work_graph_plan.py` 本来就是 `_normalize_graph_manifest`/`_validate_loop_back_targets` 等 `graph_manifest` 结构性操作的归属地,避免 Coder、Validator 两个调用点各自维护一份合并逻辑、未来出现两处不一致的风险。 ```python def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300, append_to_run_id=None): """append_to_run_id=None(默认):跟今天完全一样,INSERT 一个新 work_graph_run 行。append_to_run_id=<既有 run_id>:不 INSERT 新行, 改为读出该 run_id 现有的 graph_manifest,把这次新规划出的节点/边 合并进去(node_id 冲突时报错,不静默覆盖或改名),UPDATE 回同一行。 追加模式下会把已有节点的 node_id 清单(不含内容)注入 prompt 上下文, 让新规划的节点能声明 depends_on 指向已有(可能是别的 org_node 产出的) 节点。返回形状不变:{"run_id", "graph_manifest"}(追加模式下 run_id 就是传入的 append_to_run_id,graph_manifest 是合并后的完整图)。 """ if org_node == "planner": prompt = _PROMPT_TEMPLATE.replace("{goal_text}", goal_text) else: prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text) if append_to_run_id: existing = conn.execute( "SELECT graph_manifest FROM work_graph_run WHERE run_id = ?", (append_to_run_id,) ).fetchone() existing_manifest = json.loads(existing[0]) existing_ids = [n["node_id"] for n in existing_manifest["nodes"]] prompt = prompt.replace( "Plan (do not execute)", "This graph already has these nodes (from earlier stages): {}. Plan (do not execute) " "ADDITIONAL nodes only -- do not redeclare or duplicate the ones above.".format(existing_ids), ) # ... 真实 subprocess 调用 + _extract_graph_manifest + _normalize_graph_manifest,跟今天一样 ... new_manifest = _normalize_graph_manifest(_extract_graph_manifest(response_text)) if append_to_run_id: collision = set(n["node_id"] for n in new_manifest["nodes"]) & set(existing_ids) if collision: raise PlanningFailedError("append_to_run_id={} node_id collision: {}".format(append_to_run_id, collision)) merged = { "nodes": existing_manifest["nodes"] + new_manifest["nodes"], "edges": existing_manifest.get("edges", []) + new_manifest.get("edges", []), } conn.execute("UPDATE work_graph_run SET graph_manifest = ? WHERE run_id = ?", (json.dumps(merged), append_to_run_id)) conn.commit() return {"run_id": append_to_run_id, "graph_manifest": merged} run_id = str(uuid.uuid4()) conn.execute("INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, ?, 'planning')", (run_id, task_id, json.dumps(new_manifest))) conn.commit() return {"run_id": run_id, "graph_manifest": new_manifest} ``` (`INSERT INTO work_graph_run` 不再写 `org_node` 列,见 §2.1 的删除决策。) 调用方(`e2e_pipeline.implement_code()`)改造: ```python def implement_code(conn, task_id, run_id, sdd_content, goal_text, target_folder_path, ...): # run_id 现在由调用方(run_task_lifecycle)传入 -- Phase 1 建立的那个 # 跨阶段 work_graph_run,不再自己创建新 run graph_manifest = work_graph_plan.plan_work_graph( conn, "coder", task_id, goal_text, target_folder_path, append_to_run_id=run_id, )["graph_manifest"] outputs = work_graph_orchestrate.run_graph( conn, run_id, "coder", "coder-implementation", target_folder_path, graph_manifest, work_graph_plan.generic_node_prompt_builder, _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, needs_worktree=True, timeout=timeout, ) ... ``` `run_graph()` 的 `graph_manifest` 参数现在是**合并后的完整图**(既有节点 + 新增节点),但 `run_work_node()` 的既有断点续跑短路(已 `done` 的节点直接返回持久化输出)保证已有节点不会被重新执行——这跟 §5.3 提到的"再次调用 `run_graph()` 时自然跳过已完成节点"是同一个机制,不需要额外处理。 不再用 `{"nodes": [{"node_id": "implement", "depends_on": []}], "edges": []}` 这个合成单节点 manifest(`e2e_pipeline.py:637` 现状)——真正让 Coder 自己规划实现步骤。`validate_implementation_with_own_graph()` 同理,`_VALIDATOR_MANIFEST` 硬编码字面量被 `plan_work_graph(conn, "validator", ..., append_to_run_id=run_id)` 取代。 ### 6.2 `run_id` 的传递链路 Phase 1 SDD 设计的 `plan_and_review_documents()`(新增函数,Planner 阶段用)已经创建了这次任务唯一的 `work_graph_run` 行;Phase 2 要求这个 `run_id` 一路透传给 `implement_code()`/`validate_implementation_with_own_graph()`,不能让它们各自继续 `INSERT INTO work_graph_run`。`run_task_lifecycle()`/`run_or_resume_task_lifecycle()`(Phase 1 已经改造过的两个函数)需要把 `plan_and_review_documents()` 返回值里的 `run_id` 一路带下去——这是 Phase 2 对 Phase 1 产出的一个新依赖点,Phase 1 落地时如果 `plan_and_review_documents()` 的返回形状没有 `run_id`,需要回头补一个字段(轻量修改,不影响 Phase 1 已完成的核心逻辑)。 --- ## 7. 新增节点级验证 skill + 退休 `org_level_loop.py` 粗粒度路由 ### 7.1 新 skill:`work-graph-node-review` 新增 `skills/work-graph-node-review/SKILL.md`,`roles/planner_reviewer`(Phase 1 新增)、`roles/coder`、`roles/validator` 的 `role.json` 都引用它——org-agent-无关,是 `judgment.kind="llm"` 时 `judge_prompt` 的方法论参考(不是新的 Python 机制,`judgment.kind="llm"` 本身已经是引擎特性,这个 skill 提供的是"怎么写一个好的 judge_prompt/怎么识别叙述文字冒充结构化产出"的方法论沉淀,直接把 `validator-test-script-narrative-output-issue.md` §6 的根因分析和 `_NARRATION_RETRY_HINT` 的措辞模式吸收进去,成为所有自规划节点复用的判断标准,不是每个角色各写一份)。这份 skill 的方法论提炼出一个可复用的 `_NODE_REVIEW_JUDGE_PROMPT` 常量(放在 `work_graph_plan.py`,跟 §3.1 的建议目录同一个模块,因为目录本身就要引用它作为 `suggested_judgment.judge_prompt` 的默认值),不是每个目录条目各写一段相似的 judge_prompt。 不改造 `qa-task-validation`——那个 skill 判断的是"整体实现是否验收通过"(任务级),职责不同,两者并存,不合并。 ### 7.1.1 新 skill:`skill-defect-remediation`(Fix-Skill 专属,§5.5 新增) 新增 `skills/skill-defect-remediation/SKILL.md`,只挂到 `roles/validator/role.json`(不是 org-agent-无关的,只有执笔改 Skill 的身份需要它——见 §5.5 的"由谁来修"决策)。方法论核心:怎么在不破坏一份 Skill 现有正确行为的前提下,精确定位并只修正导致这次真实失败的那一小部分——跟 `work-graph-node-review`(判断"一个节点的输出好不好")职责完全不同,这个 skill 判断的是"怎么改一份方法论文档本身",不合并进同一份 skill。 ### 7.2 `org_level_loop.py`/`org_loop_route.py` 的粗粒度路由退休 Phase 1 阶段"复核失败先用现有 `org_level_loop.py` 粗粒度路由兜底"(Phase 1 SDD 的过渡方案)在 Phase 2 落地后被替换:终审 Validator 节点声明 `on_fail.action="dynamic_loop_back"`(§5),失败时直接在**同一个** `work_graph_run` 内精确回退,不再需要 `route_org_loop()` 那种"改 `task.status`、换 `assigned_org_node`、期待调用方重新触发另一个函数"的跨阶段路由。 `org_level_loop.py::run_fix_loop`/`org_loop_route.py::route_org_loop`/`_NEW_STATUS`/`_NEW_ORG_NODE`/`task.loop_round` 字段——**不删除**(`root_cause_trace.judge_root_cause`/`trace_root_cause_human_reported` 等其他调用方,以及 bug_record 驱动的人工报告场景仍然用得到这套粗粒度分类),但新的合图任务流程不再经过它们;`scheduler.py::finish_task_lifecycle` 需要判断"这是走新合图流程的任务,还是走旧三段式流程的任务"(新流程的失败已经在 `run_graph()` 内部处理完,不会再走到 `finish_task_lifecycle` 的 FAIL/INCONCLUSIVE 分支去调用 `run_fix_loop`)——具体判断依据留给实施阶段(比如看 `work_graph_run.graph_manifest` 里节点是不是都带 `judgment`/`on_fail` 字段,或者更简单,直接看 task 是不是 Phase 1/2 上线之后创建的)。 --- ## 8. 明确的不确定性(比 Phase 1 更大,如实标注) 1. **`_judge_root_cause_node`(§5.4)的判断质量未经真实验证**——三视角判断"哪个上游节点该负责 + 是不是系统性缺陷"是全新设计,没有 A/B 数据支撑,上线后大概率需要根据真实误判案例调整 prompt/视角设计,不是一次写对。`defect_kind` 这个二选一判断尤其新——误判方向有两种,且代价不对称:把系统性问题误判成一次性(漏改 Skill,同类错误还会再犯,但代价只是"没有更好")比把一次性问题误判成系统性(多余地真实重写了一次 Skill 文件,代价是留下一次不必要但无害的 Git 提交)风险更值得关注,值得在真实运行中专门跟踪这两类误判各自的发生率。 2. **`promotes_to_artifact`(§2.3)依赖 Coder/Validator 老实遵守"只有最终交付物才声明"这条约束**——是 prompt 层面的软约束,不是机械强制,理论上 LLM 可能给每个节点都声明 `promotes_to_artifact`(虽然 artifact_type 枚举本身会挡住不合法值,但不会挡住"过度声明"这种滥用),需要在真实运行中观察是否需要加机械检查。 3. **`dynamic_loop_back` 的"同一 source 总次数上限"(§5.7 第 3 点)需要新的 `work_graph_loop_limit_config` key 形状**——`"{org_node}:{source_node_id}:total"` 这个具体格式没有先例,实施时需要重新过一遍 `settings_config.py` 的查询/写入接口是否兼容这种三段式 key,还是需要专门扩展。 4. **`org_level_loop.py` 新旧流程共存期的判断依据**(§7.2 结尾)还没有定论,是留给实施阶段解决的开放问题,不是本文档的疏漏——但这个问题不解决,Phase 2 上线当天就会有"旧任务卡在新代码路径里"的真实风险,实施顺序上应该早排。 5. ~~Fix-Skill 由谁执笔改 Skill 需要用户确认~~——已确认(2026-08-12),统一用 Validator 身份,理由见 §5.5。 6. **发现的独立 dead-code 缺口,不阻塞 Phase 2 但值得单独一提**:`org_level_loop.py::_ROUTE_FOR_CATEGORY` 从来没有 `skill_defect` 这个 key(§5 开头"既有事实 B"),意味着这条旧的粗粒度路径下 `skill_update.update_skill()` 从来没被真正调用过。Phase 2 的 Fix-Skill 走的是全新的合图内部路径,不依赖修好这个旧 bug,但如果 §7.2 决定新旧流程要共存一段时间,这个旧路径本身的这个缺口在共存期内仍然是活的——是否值得顺手在 `_ROUTE_FOR_CATEGORY` 里补一条 `"skill_defect": "full_replan"`(不真的接 `update_skill()`,只是让路由不再是意外的默认值 fallback,语义更诚实),是一个独立、廉价、可以随时做的小修复,不必等 Phase 2c。 --- ## 9. 跟 Phase 1 的接口核对清单——✅ 全部核对完成(2026-08-12) - [x] `plan_and_review_documents()` 的返回形状是否包含 `run_id`——**是**:`plan_and_review_documents()` → `_review_planner_output_with_retries()` → 透传拿到 PASS 那一轮 `plan_documents()` 的原始返回值 `{"run_id", "artifact_ids"}`,`run_id` 齐全,§6.2 的假设成立,`implement_code()`/`validate_implementation_with_own_graph()` 可以直接拿这个 `run_id` 用作 `append_to_run_id`。 - [x] Phase 1 实际引入的新 `task.status` 值是不是 `reviewing_planner_output`——**是**,`src/schema.py`/`src/org_node_lifecycle.py` 均按此实现,与本文档假设完全一致。 - [x] `roles/planner_reviewer/role.json` 的 `skills` 列表是否已经预留位置给 §7.1 新增的 `work-graph-node-review`——**没有预留**,当前是 `["planner-output-review", "unattended-agent-operations"]`。不是问题:Phase 2 实施 §7.1 时直接追加一行 `"work-graph-node-review"` 即可,`roles/coder`/`roles/validator` 的 `role.json` 同理需要各追加一行——这是 Phase 2c 实施清单里的一步,不是需要提前协调的接口不兼容。 --- ## 10. 实施顺序(拆成三个可独立验收的子阶段) ### Phase 2a — 引擎扩展(地基,不改任何 org 流程) ✅ 已实现(commit `86fc0bb`,2026-08-12) - §4(节点级 subagent/skill/tools 绑定)+ §5(`dynamic_loop_back` 完整链路:Reflection-review + Fix-Skill)+ 对应单测。 - **§2.2(`ux_work_graph_run_active` 迁移)实施时被推迟,没有跟这批一起做**——设计时发现 `plan_documents()`/`implement_code()` 产出的 `work_graph_run` 行在 happy path 上从来不会被标记 `'done'`(只有 Validator 自己的图会),如果现在就把索引收紧成 `UNIQUE(task_id)`,会在 Phase 2b 的追加机制真正接上**之前**就先把现有生产流程弄崩——Coder 一插自己的行就会跟还没结束的 Planner 行撞车。已重新确认要推迟到 Phase 2c(Validator 也接入追加机制、三段真正共享一个 run 之后)才安全,见 Phase 2b 状态行的第 4 条。 - 验收:手写的合成 graph_manifest(不经过真实 LLM 规划),节点声明不同的 `subagent_type`,运行后 `work_node_execution.subagent_type` 确实按节点区分;另一个手写 manifest 触发 `dynamic_loop_back`,验证真的能跳到候选节点之一且计数正确——均已有对应单测(`src/test_work_graph_orchestrate.py`:`NodeLevelSubagentSkillTest`/`DynamicLoopBackContentErrorTest`/`DynamicLoopBackSkillDefectTest`/`TallyReflectionTest`)。 - 实施过程中发现并修复的真实 bug:节点级 `skill_ref` 没有走 `ensure_placeholder_skill`,第一次用到新 skill_id 会撞外键约束——已在 `_run_one_node` 里补上。 - `python3 -m unittest discover -s src`(741 tests)+ `discover -s supervisor`(126 tests)全绿。 ### Phase 2b — Coder 接入自规划 ✅ 已实现(commit `7c46a2f`,2026-08-12) - §3(`_generic_prompt_template` 按 org_node 扩展 + 建议目录)+ §2.3(`promotes_to_artifact`)+ §6(`implement_code()` 改造为 `plan_work_graph(..., append_to_run_id=run_id)`)。 - 实施过程中定位并修复的、SDD 原稿没预见到的问题: 1. **`_review_planner_output_with_retries()` 复用已有 artifact 分支硬编码 `run_id=None`**——每个真实 artifact 行本来就有真实的 `produced_by_run_id`,改成从 `sdd` artifact 读取真实 run_id,不再是 `None`(否则 Coder 在这条 resume 路径上完全没有 run 可追加)。 2. **`implement_code()` 从来没有真的传过 `needs_worktree=True` 给 `run_graph()`**——单节点合成图时代无害(图里只有一个节点,没有并发碰撞的可能),但自规划之后 Coder 图可能真的有并行写代码的节点,03-C §3.6 的隔离要求这次才第一次真正生效。 3. **`work_graph_fork.py::_reconstruct_build_prompt` 必须改成逐节点 dispatch**(原计划就在 Phase 2b 范围内,§2.1.2 已预见),否则合并图一旦真的出现 Coder 节点,fork 功能会直接报 `ForkGraphShapeUnsupportedError`。 4. **§2.2 索引收紧比 Phase 2a 判断的还要晚**:Phase 2a 曾判断"等 Phase 2b 落地后就能收紧",实施 Phase 2b 时发现不对——Validator 在 Phase 2c 之前仍然会给自己开一个独立的 `work_graph_run`,收紧到 `UNIQUE(task_id)` 会让 Validator 那次 INSERT 直接跟还没终结的 Planner+Coder 合并行冲突。§2.1/§2.2 已重新确认推迟到 Phase 2c(Validator 也接入追加机制之后)才安全。 - 新发现的测试基础设施缺口(不是设计问题,是既有测试夹具没预料到并发层):合并图里"已完成的旧节点"和"刚追加的新节点"如果互不依赖,会被 `topological_layers()` 分进同一层,触发 `run_graph()` 的多节点并发路径——`test_task_execution.py`/`test_e2e_pipeline.py` 的测试连接原来没开 `check_same_thread=False`,从没触发过这条路径,现已修正(生产连接本来就是对的,`scheduler.py`/`task_execution.py` 早就用 `check_same_thread=False`,只是测试夹具没跟上)。 - `python3 -m unittest discover -s src`(752 tests)+ `discover -s supervisor`(126 tests)全绿。 ### Phase 2c — Validator 接入自规划 + 精确 loop_back 收口 - Validator 侧的 §6 改造 + §7(新 skill + 退休粗粒度路由)+ §9 的核对清单。 - 验收:真实构造一次"终审失败,根因在 SDD 节点"的场景,验证 `dynamic_loop_back` 精确跳回 SDD 节点重新执行,SDD 的下游(Benchmark/Coder 实现/…)全部正确重跑,不需要人工介入,也不需要经过 `org_level_loop.py`。