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

33-needs-gate-role-filter-bug. requiredInputsMissing 未按 role 过滤 —— produces 声明被误判成 needs

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


一、先说 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 根因:inboundContracts 不按 role 过滤,把 produces 声明也当成 needs

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)+ kind=artifact, role=needs (validator 要吃的是 artifact)。这两条都落进同一张 graph_edge_contracts 表, required=1。

问题出在 work-graph-db.js:2542 的 inboundContracts():

function inboundContracts(edgeLayer, edges, defNodeId) {
  const inbound = (edges || []).filter(
    (e) => String(e.to) === String(defNodeId) && (e.edgeKind || 'forward') !== 'backward'
  );
  const out = [];
  for (const e of inbound) {
    for (const c of edgeContracts(edgeLayer, e.id)) out.push({ ...c, fromNodeId: e.from });
  }
  return out;
}

它把入边上所有契约行原样吐出来,不管 role 是 produces 还是 needs。 下游 graph-engine.js:3945 的 requiredInputsMissing() 接着只按 required 过滤:

const requiredKinds = [...new Set(contracts.filter((c) => c.required).map((c) => c.kind))];

于是 validator 的「必须在索引里找到实例」的 kind 集合变成了 {log, artifact}, 而不是本该只有的 {artifact}——log 是 test 声明自己产出的 kind(描述 test 的 输出形态),不是 validator 要读的输入。这个 task 的 graph_files 里从来没有任何 kind='log' 的行(org-coder-test 实际写的是 coder.log 但被索引成 kind='artifact',不是 kind='log'),于是 requiredKinds - haveKinds = ['log'], missing.length > 0,success 被强改成 fail。

对照:同一份代码里至少三处其他函数在读同一张 graph_edge_contracts 表时都记得 按 role 过滤((c.role || 'produces') !== 'needs' 或 === 'produces', 见 graph-engine.js:968 / :3811 / :3866),唯独 inboundContracts / requiredInputsMissing 这一条链路漏了。判据不一致,不是设计如此。

2.4 影响面推测(未逐一核实,留给修的时候一并查)

  • 任何在同一条边上同时声明了 produces 和 needs 的节点,只要 produces 的那个 kind 从未被单独索引过(大多数「描述性」kind,比如这里的 log,本来就不 一定会被单独记录成同名 kind 的产物行),下游节点的 success 就会被这条误判的 missing_required_input 拦下来,且每次重试都会复现(不是一次性的)。
  • executor.json / executor-fanout.json / validator.json 里凡是 test→validator 这种「同边混写 produces+needs」的写法都要复查一遍(§一 T3/T4 审计脚本可以扩展 一个「edge 同时有 produces 与 needs 时,produces 的 kind 是否真的会被索引」的检查项)。

2.5 建议修法(留给实施时定案,这里只记方向)

inboundContracts() 应该只返回 role='needs'(含历史 NULL 按 produces 处理的 存量行需要保留原有兼容语义,但 NULL 不该被当成 needs)的契约行,或者 requiredInputsMissing() 在 contracts.filter(...) 那一步同时按 (c.role || 'produces') === 'needs' 过滤——两者选一,不建议两处都改(避免出现 「谁的语义才是权威」的第二份判据)。改完要跑一遍 §一 提到的真实回归任务,确认 org-coder-validator 不再假阳性 blocked。


三、下一步

  • [ ] 定案 §2.5 的修法,改 inboundContracts 或 requiredInputsMissing 之一
  • [ ] 新增单测:test→validator 同边声明 produces:log + needs:artifact, 只有 artifact 有索引实例、log 没有,validator 的 success 判定不应被拦
  • [ ] 跑一遍 graph-real-chain-smoke.test.js 同款「真实引擎代码 + 真实 DB + 换掉最外层 CLI 子进程」的确定性回归,复现本次撞见的具体场景
  • [ ] 复查 system/work-graph-templates/*.json 里其他「同边混写 produces+needs」的边