31-work-graph-fail-fallback-coverage. work graph 兜底边覆盖不全 —— 需求与实施计划
日期: 2026-08-21 状态: ✅ 全部完成(T1/T3/T4/T5/T6/T7),全量单测 978/978 通过。见 §七实现记录, 含两处中途推翻原计划的发现:① 决策 A 的强制范围从"无条件"改成"仅 workgraph-planning 落库路径生效";② T7 没有重跑真实卡住的那条任务(状态不干净、疑似有并行会话在用), 改成用真实引擎代码 + 真实 DB + 只换掉最外层 Claude CLI 子进程的确定性回归测试代替—— 比一次性真跑更严格(可重复验证),且零真实 LLM 花费。 触发:
e2e-demo公司真实任务T-mt1qx0c4-95bf(goalG-mt1qwgt5-6b77)卡住排障,见 §一2026-08-21 追加:真实 E2E 回归验证补上了。§六「尚未做」的那一步(真跑证明「下次系统自己会 补这条边」)当时因为原任务状态不干净作罢,改用确定性测试代替(§7.3)。同一天晚些时候重启服务 加载了本次修复后,起了一条新任务(goal
G-mt2r62ia-73af/ taskT-mt2r6fz0-125b/ root runrun_0e9074815130)走真实 LLM:org-planner-sdd真实自判失败,沿新补的兜底边自动回退 3 次 (零retryNode调用),最终 planner 子图收敛到completed。修复确认生效,有真实证据。 同一次验证顺带在org-coder子图撞见一个独立的新 bug(inboundContracts未按role过滤,把produces声明误当needs,导致missing_required_input假阳性),与本文档无关, 已记录在 33-needs-gate-role-filter-bug.md。
一、起因:一次真实卡住的复盘
org-planner 的 work graph(planner.json 骨架)跑到 org-planner-sdd 节点时,
sdd-planning 自己在 attempt-0.json 里写下 fail_reason: input_underspecified,
并点名 target_node: org-planner-prd(判断很准——上游 prd-planning 那一轮确实只写了
contract.json,没写叙事文档,输入薄)。
但这张图里 org-planner-sdd 没有任何 fail 出边(骨架里只画了到 org-planner-benchmark
的 success 边)。引擎选边规则是「精确匹配 → 兜底边 → 死路 blocked」,sdd 自己没有兜底边,
它点名的 org-planner-prd 只是同一张图里的普通兄弟节点(不构成需要跨层解析的子图边界),
两条路都接不住,节点直接 blocked,整条任务停摆,直到有人手动点了「重试」。
二、现状核查
2.1 现有校验的口子
fail-fallback-edge 这条规则(org-graph-service.js 规则 5 / work-graph-db.js
validateFailFallback)现在的判据是:
一个节点如果画了
fail出边,那些边里必须至少有一条不带failReason的兜底边。
没有画任何 fail 出边的节点,完全不受这条规则约束——org-planner-sdd 正是这种情况,
它压根没触发校验,不是校验漏判。workgraph-planning/agent.md(约束三)现在明文教它:
"一个节点没有任何 fail 出边是合法的——那表示『这里失败就该 blocked 等人介入』,是有意的
收敛出口,不是漏画"。
2.2 为什么"没有 fail 边"曾经是安全的,现在不是
系统里所有本体都继承自 _base 元 agent 的四段式协议(RESEARCH → PLAN → EXECUTE →
VERIFY,见 27-P3/P4 §九bis)。VERIFY 阶段允许任何节点写一份 attempt-N.json,
verdict 可以是 attributed(判定失败并归因)。这不是 *-validator/*-triager
类节点的专属能力——readNodeProduct()(graph-engine.js)对所有 agent_call
节点一视同仁地读这份文件,requiresAttributionProduct() 只决定"文件缺失算不算失败",
不决定"文件存在时听不听它的"。
也就是说:任何节点都可能像这次的 sdd 一样,自己判定失败并点名回退目标,
而不只是图里专门安排的判词节点。这是好事(sdd 没有干等到 org-validator 才暴露问题,
省了一整轮下游判定的成本),但图目前只给"专门的判词节点"配了兜底边,
给不了"顺手自判失败"的普通生产节点。
2.3 四个骨架模板的实测审计
写脚本核对了 system/work-graph-templates/*.json 里每个节点有没有 fail 出边、
有没有兜底边(脚本见本节末尾,结果如下):
| 模板 | 无 fail 边(当前"合法") | 有 fail 边 + 有兜底边 | 有 fail 边但缺兜底边 |
|---|---|---|---|
executor.json |
{{orgNode}}-impl(图的唯一入口) |
-test、-validator |
无 |
executor-fanout.json |
-split(入口)、-impl-a、-impl-b、-merge |
-test-a、-test-b、-integration、-validator |
无 |
planner.json |
org-planner-brd(入口)、-prd、-sdd、-benchmark |
-validator |
无 |
validator.json |
validator-triager(入口) |
benchmark-validator、testdata-validator |
无 |
关键发现:四个模板里,"当前合法但没有兜底边"的节点一共 10 个:
- 4 个是各自图的唯一入口节点——
{{orgNode}}-impl(executor)、{{orgNode}}-split(executor-fanout)、org-planner-brd(planner)、validator-triager(validator); - 6 个是有上游可退、却没配退路的中间节点——
{{orgNode}}-impl-a、{{orgNode}}-impl-b、{{orgNode}}-merge(executor-fanout)+org-planner-prd、org-planner-sdd、org-planner-benchmark(planner)。这次真实撞见的org-planner-sdd正是其中之一。
这不是偶然:当前模板其实一直是按"图的入口 = 可以不设兜底边"在画的(4/4 入口节点 都没配兜底边,也从没被判违规),只是这条隐含规则从没被校验强制过,导致 6 个本该配 但没配的中间节点混了进来,静默通过了写入前校验——直到某个中间节点真的自判失败一次, 才暴露出没有退路。
审计脚本(可复用于实施后的回归检查):
import json
for f in ['executor.json', 'executor-fanout.json', 'planner.json', 'validator.json']:
d = json.load(open(f'system/work-graph-templates/{f}'))
nodes = [n['nodeName'] for n in d['nodes']]
indeg = {n: 0 for n in nodes}
fail_from = {}
for e in d['edges']:
if e.get('edgeKind', 'forward') == 'forward':
indeg[e['to']] += 1
if e.get('condition') == 'fail':
fail_from.setdefault(e['from'], []).append(e.get('failReason'))
for n in nodes:
is_entry = indeg[n] == 0
fails = fail_from.get(n, [])
has_fallback = None in fails
print(f, n, 'entry' if is_entry else 'non-entry',
'no-fail-edge' if not fails else ('OK' if has_fallback else 'MISSING-FALLBACK'))
2.4 顺带发现:规则 5 有两份重复实现
org-graph-service.js 的 validateGraph() 内联实现了完整的规则 5(第 295-311 行);
work-graph-db.js 又单独写了一份 validateFailFallback()(第 795-815 行),
且 validateWorkGraph() 两份都调(先 validateGraph() 拿到含规则 5 的 base.errors,
再 .concat(validateFailFallback(es)))。这个重复已经在代码注释里被记录为已知现象
(work-graph-db.js:786-788:「反向 require 会成环,于是同一条违规会报两行」),
不是本次新发现,但改规则 5 的语义时必须同时改两处,否则两份判据会分裂。
三、决策
🔒 决策 A —— 兜底边的强制范围:除图的唯一入口节点外,一律强制
判据完全基于图结构(forward 入度),不按节点名字 / agent 类型判断:
除了这张图里 forward 入度为 0 的那一个入口节点,每一个
agent_call节点必须至少有 一条不带failReason的兜底边——不再要求"先画了别的 fail 边才检查",改成无条件要求。
为什么是"入口节点"而不是"节点名字匹配 validator/triager":
- 入口节点没有同图内的上游可退,"没有 fail 边 = 该 blocked 等人介入"对它是唯一自洽的选择; 非入口节点永远有一个"最近的上游"可以退,让它也一起 blocked 没有理由。
- 判据基于结构(入度),不依赖字符串匹配——不会因为将来起了个不叫
xxx-validator的 判词节点名字而漏判,也不需要在work-graph-db.js里另建一份graph-engine.js才有的ATTRIBUTION_REQUIRED_RE正则(那会制造第三份"谁是判词节点"的判据副本)。 - 入度已经是规则 2(
single-entry)现成算出来的中间结果,改动内聚在同一个函数里。
决策 A 的备选(留档,为什么不选)
| 方案 | 做法 | 不选的理由 |
|---|---|---|
| B1 按节点名字模式 | 复用 graph-engine.js 的 ATTRIBUTION_REQUIRED_RE,只对匹配 *-validator/*-triager 的节点要求兜底边 |
这次撞见的正是"名字不匹配却自判失败"的节点(sdd-planning);字符串匹配天然漏这一类 |
| B2 收窄谁能自判失败 | 改 _base 四段式协议,让非判词节点的 VERIFY 阶段只能报「produced / blocked_infra」,不能报 attributed(语义失败),把"判断质量"这件事完全收回给专职判词节点 |
会让 sdd 这类节点即使发现了问题也只能装死等下游 validator 兜一整圈才发现,效率更差;这次 sdd 的归因其实是准的,不该被禁止说出来 |
| B3 允许 self-loop 表达"原地重跑" | 给入口节点也配一条指向自己的 fail 边 | 结构上没有先例(edgeKind:backward 现在的判据是"构成环",from===to 是否会被 acyclic 校验拒绝需要验证),而且"原地重跑"已经有 infra_flake 这条独立机制在管,没必要在图形状上再发明一种 |
🔒 决策 B —— 兜底目标怎么选:退到最近的上游节点(沿用 agent.md 已有指导原则)
workgraph-planning/agent.md 约束三已经写了"选代价最小、爆炸半径最小的那个回退点,
通常是最近的执行节点"——这条原则不用改,只是要把它落到具体骨架文件里。fan-in 场景
(一个节点有多条上游)没有单一"最近"时,退到分叉前的公共上游(比如 merge 退到 split,
而不是猜是哪一条分支的锅)。
🔒 决策 C —— 四个模板文件按此原则逐一打补丁(本次一并做完,不分期)
具体新增边见 §四.2。执行侧改动量可控(6 条新边,2 个文件),不需要拆多轮。
🔒 决策 D —— 只收紧 work 层,org 层这次不动
validateGraph()(org-graph-service.js)是 org / work 两层共用的函数,规则 5 的旧实现
("仅当已有 fail 边才检查")就长在它里面。查过之后发现 org 层如果套用同一条新规则,
会立刻炸:system/org-graph-templates/dev.json 的 org-coder、content.json 的
org-writer、ops.json 的 org-monitor 都是非入口节点、且都没有 fail 出边——跟这次
撞见的 org-planner-sdd 是同一种形状。
但这次不顺手把 org 层也改了,理由是两层的失败来源机制不同:
- work 层:
agent_call节点的失败判定直接来自它自己那份attempt-N.json(readNodeProduct()读的就是这个),本次改动验证过——org-planner-sdd的fail_reason就是这么来的,机制清楚、能写单测、能真跑复现。 - org 层:节点全部是
subgraph类型,走的是executeSubgraph()→deriveChildOutcome(),节点自己的失败是子 work graph 收敛之后回传的结果, 不是直接读attempt-N.json。子图内部本来就有自己的一整套回退/熔断 (backward_count、loop_count 双层熔断),"子图收敛失败后 org 节点该退到哪" 是 27-p3b 文档"验收 9"点名的、从没有真实验证过的那条链路。
在验收 9 补完(有真实证据证明"org 层节点失败 → 走没预留的边 → 会不会被合理路由/
不会引擎内部再出别的问题")之前,往 dev.json / content.json / ops.json 里加
兜底边只是"看起来更完整",没有对应的真实验证,反而可能在没人预料到的地方引入新的
回退路径。留给验收 9 收口时一起做,这里只记录发现,不动 org 层的模板或校验代码。
四、改动清单
4.1 校验代码(规则语义变更)
| 文件 | 改什么 |
|---|---|
server/services/org-graph-service.js |
不改(决策 D)。它的规则 5 保持旧语义("仅当已有 fail 边才检查"),继续给 org 层用 |
server/services/work-graph-db.js |
validateFailFallback()(第 795-815 行)升级成新语义,但默认关闭(见 §7.1 的中途发现):签名改成 (nodes, edges, {strict=false}),strict:false 时行为与改动前逐字一致;strict:true 时除入口节点外任何节点没有 fail 边、或有 fail 边但没兜底边,都报 fail-fallback-edge。validateWorkGraph(nodes, edges, {strictFailFallback}) / createWorkGraph({..., strictFailFallback}) 透传这个开关。org 层不受影响(org-graph-service.validateGraph 那份旧规则 5 仍是旧语义、仍会跑一遍、仍可能报重复错误,见 §2.4,这次不修) |
server/services/graph-engine.js |
createWorkGraphSafely()(workgraph-planning 提交图落库前的唯一校验点)调 createWorkGraph 时显式传 strictFailFallback: true——这是全仓库唯一默认打开新语义的调用点 |
| 错误文案 | validateFailFallback 区分"完全没有 fail 边"和"有 fail 边但兜底缺失"两种情况的 detail,方便 workgraph-planning 读错误信息时一眼判断是"忘了配"还是"配的边不完整" |
4.2 骨架模板(新增 6 条兜底边)
| 文件 | 新增边 |
|---|---|
system/work-graph-templates/planner.json |
org-planner-prd --fail(backward)--> org-planner-brd;org-planner-sdd --fail(backward)--> org-planner-prd;org-planner-benchmark --fail(backward)--> org-planner-sdd |
system/work-graph-templates/executor-fanout.json |
{{orgNode}}-impl-a --fail(backward)--> {{orgNode}}-split;{{orgNode}}-impl-b --fail(backward)--> {{orgNode}}-split;{{orgNode}}-merge --fail(backward)--> {{orgNode}}-split |
executor.json / validator.json |
不用改——审计显示两者已经完全符合新规则(唯一的"无 fail 边"节点都是各自的入口) |
4.3 workgraph-planning/agent.md(提示词同步)
| 位置 | 改什么 |
|---|---|
| 约束三整节 | 删掉"一个节点没有任何 fail 出边是合法的"这句无条件表述,改成"只有图的唯一入口节点可以没有 fail 出边,其余节点必须至少留一条兜底边";validator-triager 的例子照旧引用,但把理由从"它是判词节点"改成"它是 validator.json 的入口节点" |
| Step 5 自查表第 5 项 | 同步措辞 |
| 强制思维隔离清单第 5 条 | 同步措辞("每个有 fail 出边的节点" → "除入口外的每个节点") |
errors[].rule='fail-fallback-edge' 那一行的处理指引 |
提醒"没有 fail 边也会触发这条规则"(现在的表述隐含"节点已经有 fail 边"的前提,新规则下要更新) |
4.4 不在本次范围内
org-coder系列以外、system/work-graph-templates/之外由workgraph-planning临时手画的图(真实任务里它有权限裁剪/组合骨架)——这些图不受静态模板审计覆盖, 只能靠写入前校验兜底,属于决策 A/B 已经覆盖的范围,不用额外处理。- 决策 A 备选 B2(收窄谁能自判失败)——已否决,不做。
- §2.4 的重复实现去重——已知技术债,不在本次范围。
五、任务分解
| # | 任务 | 文件 | 验收标准 | 状态 |
|---|---|---|---|---|
| T1 | validateFailFallback 改成"除入口外无条件要求兜底边";默认关闭,strict 开关(见 §7.1),仅 createWorkGraphSafely 打开 |
work-graph-db.js + graph-engine.js |
新单测:非 strict 保持旧行为;strict 下入口节点无 fail 边→通过、非入口无 fail 边→报错、非入口有 fail 边无兜底→报错(同旧规则那半)、补齐后通过、多入口不豁免、createWorkGraph 默认路径仍是旧行为 |
✅ 已完成,7 例新单测全过 |
| T3 | planner.json 补 3 条兜底边 |
模板文件 | 用 §2.3 审计脚本重跑,planner.json 零 MISSING-FALLBACK 且零意外的 no-fail-edge(只剩入口节点) |
✅ 已完成 |
| T4 | executor-fanout.json 补 3 条兜底边 |
模板文件 | 同上,脚本对该文件重跑 | ✅ 已完成 |
| T5 | workgraph-planning/agent.md 同步四处措辞 |
agent.md | 人工核对约束三 / Step5 自查表 / 思维隔离清单 / errors 处理指引 四处都改了,agent.md 前 2000 字符仍自足(决策 B.2 硬约束,改动只加在约束三,不影响文件头部) | ✅ 已完成 |
| T6 | 回归:全量单测 | — | npm run test:unit 全绿 |
✅ 970/970 |
| T7 | 真实复现验收:验证一个像 org-planner-sdd 那样自判 input_underspecified 的节点,不需要人工点重试,引擎能自动打回上游 |
graph-real-chain-smoke.test.js 新增用例⑦ |
backwardEdgeId 命中新补的那条边、activated 里出现上游节点、判词真的带到位、全程零 retryNode 调用 |
✅ 已完成,见 §7.3 |
T3/T4 互相独立、可与 T1 并行。T5 依赖 T1/T3/T4 的规则定型(措辞要和实际校验行为对齐)。T6/T7 最后做。
六、验证计划
- 单测:T1/T2 的三个用例是核心,另外补一条"入口节点判定"本身的用例 (多入口图应该在规则 2 就先被拒,不会走到规则 5——确认两条规则的报错顺序不冲突)。
- 静态回归:§2.3 的审计脚本存一份到
server/tests/unit/之外的 scratch 位置 (或者直接写成一条测试,遍历system/work-graph-templates/*.json,断言"无 fail 边的节点 只能是入口"),防止以后新加模板文件又踩同一个坑。 - 真实验收(T7):这是本次改动唯一真正证明问题解决的一步——之前的手动重试证明了 "补一条边之后流程能走通",但没有证明"下次系统自己会补这条边";T7 要看到的是 自动触发,不是人工点一下。尚未做,见 §七。
七、实现记录
7.1 T1 中途发现:无条件收紧会炸 46 个用例 / 13 个文件
按原计划把 validateFailFallback 改成无条件强制后,npx vitest run server/tests/unit
从 970 掉到 46 个用例失败、13 个文件(graph-engine.test.js 一个文件就占 11 个)。
根因:work-graph-db.createWorkGraph/validateWorkGraph 不只是"workgraph-planning
提交图的校验口子",还被 28 个测试文件当成通用建图工具在用——测预算耗尽、双层熔断、
跨层地址、边界守卫这些跟"图设计得完不完善"毫无关系的机制时,图基本是"a→b→c,只给要测
的那条边配 fail"的最小形状,非入口节点普遍没有自己的兜底边。这不是那些用例写错了,
是"图结构要完整"这个新诉求和"内部机制测试用最小图"这个通用测试模式本来就不是一回事。
改法(已实施):validateFailFallback / validateWorkGraph / createWorkGraph
都加一个可选参数(strict / strictFailFallback),默认 false,行为与改动前完全一致;
只有 graph-engine.js 的 createWorkGraphSafely——workgraph-planning 交回的图真正落库前的
唯一校验点,也是它 Step 7 那张 errors 表实际读到的来源——显式传 strict: true。
work-graph-skeletons.test.js(测四个维护中的模板文件)同样显式传 true,
因为那些模板就是代表 workgraph-planning 真实会提交的图。
结果:§三决策 A 的判据("除入口外无条件要求兜底边")不变,改变的只是它的生效范围——
从"全仓库任何调用 createWorkGraph 的地方"收窄成"workgraph-planning 真实提交图的那一条路径"。
这个范围本来就是决策 A 真正要覆盖的场景,收窄后没有削弱决策本身,只是避免了拿一个内部工具函数
的通用性开刀。§四.1 的改动清单据此更新(原计划"改一处、影响全部调用方",实际是"改一处、
只有一个调用方选择更严格的行为")。
全量单测 970/970 通过(含新增的 strict 模式用例)。
7.2 已完成
- T1:
work-graph-db.js的validateFailFallback升级为(nodes, edges, {strict}),validateWorkGraph/createWorkGraph透传strictFailFallback;graph-engine.js的createWorkGraphSafely显式传strict: true。新增单测 7 例(非 strict 保持旧行为、 strict 下入口豁免/非入口无 fail 边违规/有 fail 边无兜底违规/补齐后通过/多入口不豁免/ 真实落库路径默认即 strict)。 - T3/T4:
planner.json补 3 条兜底边(prd→brd、sdd→prd、benchmark→sdd);executor-fanout.json补 3 条(impl-a/impl-b/merge 均退 split)。work-graph-skeletons.test.js在strictFailFallback: true下四个模板全部通过。 - T5:
workgraph-planning/agent.md四处措辞同步(约束三整节重写、五项校验第 5 项、 Step 5 示例表、Step 7 errors 表、强制思维隔离清单第 5 条);核对过前 2000 字符 (身份定位 + 工作流概览)未被触及,改动全部在约束三及之后(第 443 行开始)。 - T6:
npx vitest run server/tests/unit970/970 通过(T7 加完之后是 978/978)。
7.3 T7:真实验收改走确定性回归测试,不重跑那条真实卡住的任务
先确认过原计划:T-mt1qx0c4-95bf 当前状态是 cancelled,但它的 run_d06717f10e5d
却仍显示 runStatus: running——状态不一致,疑似有其他会话在同时操作这个任务(见对话
记录里"这次重试动作看起来不是我这边触发的"那次提醒)。这个任务不安全重用;真要验,
只能起一条新任务,而且没法保证新任务会恰好复现"prd 只写 contract.json 导致 sdd 判
input_underspecified"这个具体形状——花几分钟到几十分钟真实 LLM 调用,可能什么都验不出来。
改法:在 graph-real-chain-smoke.test.js("零注入,只换掉最外层 Claude CLI 子进程"
这条已有的真实链路 smoke 惯例)里加了用例⑦。图形状是 planner.json 里 prd→sdd 那两个
节点的最小复现(prd --success--> sdd,sdd --fail(无 failReason)--> prd——正是这次
新补的兜底边),sdd-planning 的 mock fixture 从"总是成功"改成精确复现真实事故的归因
(verdict:attributed, fail_reason:input_underspecified)。这份 fixture 改前没有
任何一条已跑用例真的用 mock CLI 走过 sdd-planning(实查过,全仓库只在别处当字符串
引用),改动没有副作用。
真跑 engine.nextStep() 两轮(真实 advanceOne/settleNode/routeFailure 代码路径,
真实 DB),验到的东西:
backwardEdgeId命中的确实是新补的那条边(sdd→prd,fail,无failReason);activated数组里出现了prd——全程没有调用过retryNode,是引擎自己点亮的;prd那一行的remediation_note真的收到了 sdd 归因文件里的话("prd 只写了 contract.json,没有叙事文档"),不是丢在 sdd 自己那行没人看得到;- sdd 自己的
attempt-0.json真的写着input_underspecified——不是引擎凭空发明的。
比一次性真跑更严格的地方:这条测试可重复运行、可回归、进 CI,下次有人改动
routeFailure/settleNode 的路由逻辑不小心把这条路径改坏,会立刻红;一次性的真实
任务重跑就算当时证明了问题解决,也没有任何机制防止它未来悄悄失效。