31-work-graph-fail-fallback-coverage.md 23.8 KB

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(goal G-mt1qwgt5-6b77)卡住排障,见 §一

2026-08-21 追加:真实 E2E 回归验证补上了。§六「尚未做」的那一步(真跑证明「下次系统自己会 补这条边」)当时因为原任务状态不干净作罢,改用确定性测试代替(§7.3)。同一天晚些时候重启服务 加载了本次修复后,起了一条新任务(goal G-mt2r62ia-73af / task T-mt2r6fz0-125b / root run run_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":

  1. 入口节点没有同图内的上游可退,"没有 fail 边 = 该 blocked 等人介入"对它是唯一自洽的选择; 非入口节点永远有一个"最近的上游"可以退,让它也一起 blocked 没有理由。
  2. 判据基于结构(入度),不依赖字符串匹配——不会因为将来起了个不叫 xxx-validator 的 判词节点名字而漏判,也不需要在 work-graph-db.js 里另建一份 graph-engine.js 才有的 ATTRIBUTION_REQUIRED_RE 正则(那会制造第三份"谁是判词节点"的判据副本)。
  3. 入度已经是规则 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/unit 970/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),验到的东西:

  1. backwardEdgeId 命中的确实是新补的那条边(sdd→prd,fail,无 failReason);
  2. activated 数组里出现了 prd——全程没有调用过 retryNode,是引擎自己点亮的;
  3. prd 那一行的 remediation_note 真的收到了 sdd 归因文件里的话("prd 只写了 contract.json,没有叙事文档"),不是丢在 sdd 自己那行没人看得到;
  4. sdd 自己的 attempt-0.json 真的写着 input_underspecified——不是引擎凭空发明的。

比一次性真跑更严格的地方:这条测试可重复运行、可回归、进 CI,下次有人改动 routeFailure/settleNode 的路由逻辑不小心把这条路径改坏,会立刻红;一次性的真实 任务重跑就算当时证明了问题解决,也没有任何机制防止它未来悄悄失效。