33-needs-gate-role-filter-bug.md 10.2 KB

33-needs-gate-role-filter-bug. produces:["log"] 声明了一个从不会被索引到的 kind —— validator 被 missing_required_input 误判

日期: 2026-08-21 发现,2026-08-22 修复 状态: ✅ 已修复(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug, 与 31 号文档的兜底边问题无关) 触发: e2e-demo 公司真实回归任务 T-mt2r6fz0-125b(goal G-mt2r62ia-73af, root run run_0e9074815130)——为验证 31 号文档 的兜底边修复而起的新任务,链路走到 org-coder-validator 时意外反复 blocked

2026-08-22 修订:§2.3 最初写的根因假设(inboundContracts 该按 role 过滤只留 needs)是错的,动手改了之后跑单测直接炸了 9 个用例——impl→test、brd→prd 这类只声明 produces(没有配对 needs)的边占绝大多数,inboundContracts 把它们 也算进「下游需要的输入」正是设计如此,还是 T-mt25ecbd(commit aecf5c9)那次 产出闸门的直接依赖。已把那次改动撤回,真正的根因和修法见 §2.3(修订版)与 §四。


一、先说 31 号文档的验证结果:修复生效,有真实证据

这条任务本身是为了拿到 31 号文档 §六「尚未做」的那一步真实验收证据(当时因为原任务 T-mt1qx0c4-95bf 状态不干净,改用确定性回归测试代替,见 31 号文档 §7.3)。这次补上了:

  • 新起的 org-planner 子图(run_38b3df8fd228)里,org-planner-sdd 真实自判失败 (下游信息不足),沿新补的兜底边(org-planner-sdd --fail--> org-planner-prd, edge wge_ce0f7f05f6dc)自动回退了 3 次(graph_run_loop_records: verdict=registered,loop_count 1→2→3),全程零次 retryNode 调用,最终 brd→prd→sdd→benchmark→validator 整条链路收敛到 run_status=completed。
  • 31 号文档的决策 A(除入口节点外强制兜底边)在真实 LLM 驱动下按设计工作,结论确认。

二、新发现:org-coder-validator 被 missing_required_input 反复误判

2.1 现象

同一条任务往下走到 org-coder(run_21fd814c2f56,用的是 executor.json 骨架)时, org-coder-validator 连续两轮都以 fail_reason=missing_required_input 收场:

  • 第一轮(attempt 1,10:01-10:11):沿兜底边 validator --fail--> impl 回退两次 (wge_1bdb181b14ef 一次、wge_aa5bad61fe9a 一次),第三次撞上 run 树级回退硬顶 (backward_count 全树累计 >5,LOOP_HARD_CAP_RUN=5),force_terminated, 节点 blocked。这一段是硬顶熔断本身在正常工作,不是这次要报的 bug。
  • 人工 POST /nodes/:id/retry 把 org-coder-validator 打回 ready 后, 第二轮(attempt 2,10:42:48-10:44:01)又判了一次 missing_required_input, 这次连新的 loop_record 都没写(回退硬顶已越顶,直接静默 blocked)。
  • 节点自己的 remediationNote 写的是「无需整改。……本轮重新独立跑了一遍测试脚本与 goal-contract,结果与 attempt-1 完全一致,判定 attempt-1 的 passed 结论有效」—— agent 自己认为通过了,是引擎的产出/输入契约闸门(T-mt25ecbd 那次加的机制, commit aecf5c9)把 success 强行改判成 fail。

2.2 复现证据:graph_files 里明明有 artifact 却仍报缺

查 T-mt2r6fz0-125b 的产物索引(data/graph.sqlite 的 graph_files 表), org-coder-impl(rn_e71be3641174,10:06:51 完成)与 org-coder-test (rn_cd3e3a1c51e2,10:08:52 完成)都成功登记了当前(superseded_by IS NULL) kind='artifact' 的产物行(_plan.md / _research.md / coder.log)。 org-coder-validator 的 attempt 2 在 10:42:48 才跑,比这些产物晚了半小时以上, 不是时序竞争,是稳定复现。

2.3 根因(修订版):produces:["log"] 声明的是一个永远认不出来的 kind

executor.json 里 test → validator 这条边同时声明了两条契约(work-graph-db.js declareEdgeContract):

{ "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success",
  "produces": ["log"], "needs": ["artifact"] }

即:kind=log, role=produces(test 自己产出的是 log,required 默认 true)+ kind=artifact, role=needs(validator 真正要吃的是 artifact,中继声明,产出方是 更早的 impl,非相邻依赖,R-19.3 ②)。executor.json 头部 dataContractNotes 写明了设计意图——「validator 既要看测试记录(紧邻上游 produces log)也要看被验的 交付物本身」,即:produces 与 needs 都该算进 validator 的输入需求,这不是 误用,inboundContracts() 把两者都吐出来是故意的、正确的(详见下方「走过的 弯路」)。

真正的根因在产出侧:test 类节点(org-coder-test、org-coder-test-a/b、 org-coder-integration)从来不会写出一个能被认作 kind=log 的文件。 indexNodeOutputs()(graph-engine.js:3835)给文件定 kind 的规则是「文件名匹配 声明的 kind → 已知文件名 → 唯一无歧义兜底」三级,而这几类节点实际落盘的是 _plan.md / _research.md / coder.log 这类文件——coder.log 恰好属于 impl 节点(不是 test),test 自己写的两份文件文件名里都没有 "log",且同时有两个认不出 kind 的文件,触发不了「一对一无歧义」的兜底认领。于是 graph_files 里对这个 task 永远不会出现一行 kind='log'——unsatisfiedRequiredProduces()(出边产出 闸门,graph-engine.js:3801)本身对此是宽容的:它只检查节点私有目录里有没有 任何候选文件,不检查文件是否精确匹配声明的 kind,所以 test 自己能顺利 success;但 requiredInputsMissing()(graph-engine.js:3945)在 validator 结算时是严格的:declared kind - 索引里查到的 kind 非空就判 missing_required_input, 而 log 这个 kind 因为上面那条产出侧的缺口从未出现在索引里——这不是一次性的 时序问题,是这条边只要还这么声明就永远无法满足的硬伤,每次重试都复现。

对照 validator.json(同一批模板):它的 needs 声明本来就写成 { "kind": "artifact", "required": false }——同一份代码库里已经有先例, 知道这类「声明了但未必查得到实例」的依赖要标 required:false,只是 executor.json / executor-fanout.json 里的 produces:["log"] 用的是裸数组 写法(normalizeContracts 对裸字符串项默认 required:true),漏了这一条。

2.4 走过的弯路:为什么「按 role 过滤 inboundContracts」是错的

最初的假设是「inboundContracts() 不该把 produces 也当成 needs,只留 role==='needs' 的行」。改完发现 work-graph-skeletons.test.js / org-layer-contracts.test.js 等 9 个用例炸了:org-planner-sdd(入边 prd→sdd: produces:["prd"],无配对 needs)、org-coder-test(入边 impl→test: produces:["artifact"],无配对 needs)等一大批节点的入边只声明 了 produces,从不显式写 needs——这本来就是绝大多数模板边的写法(declareEdgeContract 注释原话:「出边声明产出是压倒性多数,needs 必须显式写」)。按 role 过滤会让这些 节点的 inboundContracts() 变成空数组,requiredInputsMissing() 也就不再校验 它们——而这条链路正是 T-mt25ecbd(commit aecf5c9)刚加固的产出闸门本身 依赖的数据源(org-planner-benchmark 靠它校验 needs:[brd,prd,sdd] 真的都被 索引到)。按 role 过滤表面上修好了 log 这一个 kind,实际上会静默削弱 T-mt25ecbd 那次修复要守住的保护——好在跑单测立刻炸了,没有带着这个错误方向走 下去。结论:inboundContracts() 本身没有 bug,别再按这个方向改它。

2.5 实际修法

在声明这一侧把 log 标成 required:false,与 validator.json 的既有写法 对齐——既保留「validator 能看到 test 的 log(如果将来真的被索引到)」这条文档 价值,又不再把一个永远查不到实例的 kind 当硬闸门:

- { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success",
-   "produces": ["log"], "needs": ["artifact"] },
+ { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success",
+   "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] },

改了 4 处(executor.json 1 处,executor-fanout.json 3 处:test-a→merge、 test-b→merge、integration→validator——同一种「produces:log 但 test/integration 类节点从不产出可识别的 log 文件」的形状)。真正必需的 needs:artifact (required:true)保持不变,继续受硬闸门保护。


三、已完成

  • [x] 撤回 §2.4 那次错误方向的引擎改动(work-graph-db.js 的 inboundContracts 恢复原状,加注释记录这条弯路,防止以后有人重新踩一遍)
  • [x] 按 §2.5 改 executor.json / executor-fanout.json 的 4 处 produces:["log"] 声明为 required:false
  • [x] 新增单测 graph-files-index.test.js ⑪:produces 声明 required:false 且该 kind 从未被索引到时,不拦下游节点 success;同一条边上 required:true 的 needs:artifact 依旧受硬闸门保护(未被这次改动削弱)
  • [x] 全量单测 npx vitest run server/tests/unit:998/998 通过(含新增用例, 零回归)

四、留待下次

  • [ ] 用真实回归任务(同 31 号文档 §一的路数,起一条新 e2e-demo 任务)验证 org-coder-validator 不再假阳性 blocked——这次改动只有单测覆盖,还没有 真实 LLM 跑过的证据
  • [ ] validator.json 的两条 needs:[{kind:'artifact',required:false}] 是否也该 核对一遍「这个 kind 到底会不会被索引到」,避免同一类问题在别的模板里潜伏