Phase 2 — 全员自规划 + 合图 + 节点级精确 loop_back——详细设计(SDD)
日期: 2026-08-08
性质: 详细设计,对应 docs/todo/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 先例)。python3 -m unittest discover -s src(756 tests)+ discover -s supervisor(126 tests)全绿。尚未实施:§2.1(org_node 列删除)、§2.2(ux_work_graph_run_active 收紧到 UNIQUE(task_id))、§7.1(新 skill work-graph-node-review + 三个 role.json 追加)、§7.2 的收口验证(见 §10 Phase 2c 小节末尾新增的分析:一旦 verdict 节点自己的 dynamic_loop_back 内部消化重试,validate_implementation_with_own_graph() 正常返回时 verdict 必为 PASS,org_level_loop.py 的旧粗粒度路由对这条新路径自然不可达,不需要显式新旧流程判断——决定保留旧分支代码不删,加注释说明,不做额外改动)。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= 过滤 + 结果字段)
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 用法(三处,逐一替换)
create_fork()第 125-137 行:SELECT 语句去掉wgr.org_node;原来解出的org_node变量直接用同一行已经在读的wne.subagent_type(源节点自己的执行身份)替代——这个值本来就在查询结果里,不需要新增查询。_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 决策只是让这个改动提前到现在做,不是额外增加的范围):
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
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):
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 新增两份建议目录
_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 已经记录过一次真实事故,不要在这里重蹈):
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() 内部改成:
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/todo/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/<org_node>.md 的 skills: frontmatter 只引用 Skill 名字(agent_profile.py::_frontmatter_yaml),真正的 skills/<skill_id>/SKILL.md 内容是 Claude Code 自己在每次 claude --agent <org_node> 调用时按名字动态加载的——改完 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 字段(不变)
{
"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 新增分支)
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 全链路复盘的核心职责,不能只做"选节点"这一半。
_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 点)。
"""
三个视角的具体提问:
- 内容视角:"逐个看
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的具体识别范式喂给这个视角,不是让它凭空判断。 - 衔接视角:"如果每个候选节点单独看都没有实质内容缺陷,是不是节点之间的依赖关系/输入衔接本身有问题(比如一个节点该依赖另一个节点的输出却没声明
depends_on)?" —— 这种情况通常判content_error(属于这次规划的偶然疏漏,不是 Skill 方法论错误)。 - 历史视角:"这个候选节点对应的 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 一直在做的事。
真实调用链:
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_id>/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 迁移):
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 专属风险)
- 运行时校验目标必须是真实祖先(§5.3 已展示)——即使判断调用本身犯错选了一个不存在或非祖先的 node_id,直接报错而不是静默产生一次无意义的回路。
- 判断未达成 2/3 一致时的退化策略:不像
root_cause_trace那样升级人工(那是跨 Org Agent 边界的终局判断,这里只是图内部一次重试的一部分),退化为"回退到candidate_node_ids里离失败节点最近的直接上游 +defect_kind视为content_error"(保守默认——不确定是不是系统性问题时,不touch Skill,只是普通回退)。 _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 形状),防止绕过限流、无限循环。由 Validator 身份执笔改 Skill 这个设计判断需要用户确认——已通过AskUserQuestion确认(2026-08-12),采用 Validator 身份,见 §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——具体阈值留给实施阶段。 - 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/<skill_id>/和任务自己改的路径,如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 两个调用点各自维护一份合并逻辑、未来出现两处不一致的风险。
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())改造:
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 更大,如实标注)
_judge_root_cause_node(§5.4)的判断质量未经真实验证——三视角判断"哪个上游节点该负责 + 是不是系统性缺陷"是全新设计,没有 A/B 数据支撑,上线后大概率需要根据真实误判案例调整 prompt/视角设计,不是一次写对。defect_kind这个二选一判断尤其新——误判方向有两种,且代价不对称:把系统性问题误判成一次性(漏改 Skill,同类错误还会再犯,但代价只是"没有更好")比把一次性问题误判成系统性(多余地真实重写了一次 Skill 文件,代价是留下一次不必要但无害的 Git 提交)风险更值得关注,值得在真实运行中专门跟踪这两类误判各自的发生率。promotes_to_artifact(§2.3)依赖 Coder/Validator 老实遵守"只有最终交付物才声明"这条约束——是 prompt 层面的软约束,不是机械强制,理论上 LLM 可能给每个节点都声明promotes_to_artifact(虽然 artifact_type 枚举本身会挡住不合法值,但不会挡住"过度声明"这种滥用),需要在真实运行中观察是否需要加机械检查。dynamic_loop_back的"同一 source 总次数上限"(§5.7 第 3 点)需要新的work_graph_loop_limit_configkey 形状——"{org_node}:{source_node_id}:total"这个具体格式没有先例,实施时需要重新过一遍settings_config.py的查询/写入接口是否兼容这种三段式 key,还是需要专门扩展。org_level_loop.py新旧流程共存期的判断依据(§7.2 结尾)还没有定论,是留给实施阶段解决的开放问题,不是本文档的疏漏——但这个问题不解决,Phase 2 上线当天就会有"旧任务卡在新代码路径里"的真实风险,实施顺序上应该早排。Fix-Skill 由谁执笔改 Skill 需要用户确认——已确认(2026-08-12),统一用 Validator 身份,理由见 §5.5。- 发现的独立 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. 实施顺序(拆成三个可独立验收的子阶段)
86fc0bb-2026-08-12">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)全绿。
7c46a2f-2026-08-12">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 原稿没预见到的问题:
_review_planner_output_with_retries()复用已有 artifact 分支硬编码run_id=None——每个真实 artifact 行本来就有真实的produced_by_run_id,改成从sddartifact 读取真实 run_id,不再是None(否则 Coder 在这条 resume 路径上完全没有 run 可追加)。implement_code()从来没有真的传过needs_worktree=True给run_graph()——单节点合成图时代无害(图里只有一个节点,没有并发碰撞的可能),但自规划之后 Coder 图可能真的有并行写代码的节点,03-C §3.6 的隔离要求这次才第一次真正生效。work_graph_fork.py::_reconstruct_build_prompt必须改成逐节点 dispatch(原计划就在 Phase 2b 范围内,§2.1.2 已预见),否则合并图一旦真的出现 Coder 节点,fork 功能会直接报ForkGraphShapeUnsupportedError。- §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。