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(goalG-mt2r62ia-73af, root runrun_0e9074815130)——为验证 31 号文档 的兜底边修复而起的新任务,链路走到org-coder-validator时意外反复 blocked2026-08-22 修订:§2.3 最初写的根因假设(
inboundContracts该按role过滤只留needs)是错的,动手改了之后跑单测直接炸了 9 个用例——impl→test、brd→prd这类只声明produces(没有配对needs)的边占绝大多数,inboundContracts把它们 也算进「下游需要的输入」正是设计如此,还是 T-mt25ecbd(commitaecf5c9)那次 产出闸门的直接依赖。已把那次改动撤回,真正的根因和修法见 §2.3(修订版)与 §四。
一、先说 31 号文档的验证结果:修复生效,有真实证据
这条任务本身是为了拿到 31 号文档 §六「尚未做」的那一步真实验收证据(当时因为原任务
T-mt1qx0c4-95bf 状态不干净,改用确定性回归测试代替,见 31 号文档 §7.3)。这次补上了:
- 新起的
org-planner子图(run_38b3df8fd228)里,org-planner-sdd真实自判失败 (下游信息不足),沿新补的兜底边(org-planner-sdd --fail--> org-planner-prd, edgewge_ce0f7f05f6dc)自动回退了 3 次(graph_run_loop_records:verdict=registered,loop_count1→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 那次加的机制, commitaecf5c9)把 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 到底会不会被索引到」,避免同一类问题在别的模板里潜伏