Commit ab4631c2516e2f2a62bd57ae191867ee2905d4eb

Authored by yibo
1 parent 9cab41fd

fix(work-graph): executor 模板的 produces:log 声明永不可满足 —— 改成 required:false(33号)

org-coder-validator 在真实回归任务里被 missing_required_input 反复误判 blocked:
executor.json/executor-fanout.json 的 test/integration→validator 边声明
produces:["log"](required 默认 true),但 test 类节点从不写出能被 indexNodeOutputs
认作 kind=log 的文件(coder.log 实际属于 impl,test 自己的 _plan.md/_research.md
认不出 kind 也没有一对一无歧义可猜),这个 kind 因此永远不会被索引到,
requiredInputsMissing 每次都判缺失——不是时序问题,是结构性、每次重试都复现的硬伤。

改成 { kind: 'log', required: false },对齐 validator.json 已有的写法(它的
needs:artifact 早就标了 required:false)。真正必需的 needs:artifact 不变,
仍受硬闸门保护。

中途走了一段弯路:最初以为该改 inboundContracts() 按 role 过滤只留 needs,
改完单测炸了 9 个用例——大多数边只声明 produces(无配对 needs),这条链路
正是 T-mt25ecbd 产出闸门依赖的数据源,按 role 过滤会静默削弱那次修复。
已撤回(work-graph-db.js 这次只加注释,没有逻辑改动),细节记在 33 号文档。

新增回归用例 graph-files-index.test.js ⑪:produces required:false 且该 kind
从未被索引到时不拦下游成功,同边 needs:artifact(required:true) 依旧受保护。
全量单测 998/998 通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/README.md
@@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
49 | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | 49 | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) |
50 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据 | 50 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据 |
51 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | 51 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 |
52 -| [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | 🔴 **待修**。`inboundContracts`/`requiredInputsMissing` 不按 `role` 过滤,把同一条边上的 `produces` 声明也当成下游节点的必需输入——发现于上面 31 号文档的真实回归验证过程中,`org-coder-validator` 被 `missing_required_input` 反复误判 blocked,与兜底边问题无关,是独立新 bug | 52 +| [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 |
53 53
54 ## todo/future/(暂缓,非当前重心) 54 ## todo/future/(暂缓,非当前重心)
55 55
docs/todo/system-design/33-needs-gate-role-filter-bug.md
1 -# 33-needs-gate-role-filter-bug. `requiredInputsMissing` 未按 role 过滤 —— produces 声明被误判成 needs 1 +# 33-needs-gate-role-filter-bug. `produces:["log"]` 声明了一个从不会被索引到的 kind —— validator 被 missing_required_input 误判
2 2
3 -> **日期**: 2026-08-21  
4 -> **状态**: 🔴 **待修**(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug, 3 +> **日期**: 2026-08-21 发现,2026-08-22 修复
  4 +> **状态**: ✅ **已修复**(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug,
5 > 与 31 号文档的兜底边问题无关) 5 > 与 31 号文档的兜底边问题无关)
6 > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`, 6 > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`,
7 > root run `run_0e9074815130`)——为验证 [31 号文档](./31-work-graph-fail-fallback-coverage.md) 7 > root run `run_0e9074815130`)——为验证 [31 号文档](./31-work-graph-fail-fallback-coverage.md)
8 > 的兜底边修复而起的新任务,链路走到 `org-coder-validator` 时意外反复 blocked 8 > 的兜底边修复而起的新任务,链路走到 `org-coder-validator` 时意外反复 blocked
  9 +>
  10 +> **2026-08-22 修订**:§2.3 最初写的根因假设(`inboundContracts` 该按 `role` 过滤只留
  11 +> `needs`)**是错的**,动手改了之后跑单测直接炸了 9 个用例——`impl→test`、`brd→prd`
  12 +> 这类只声明 `produces`(没有配对 `needs`)的边占绝大多数,`inboundContracts` 把它们
  13 +> 也算进「下游需要的输入」正是**设计如此**,还是 T-mt25ecbd(commit `aecf5c9`)那次
  14 +> 产出闸门的直接依赖。已把那次改动撤回,真正的根因和修法见 §2.3(修订版)与 §四。
9 15
10 --- 16 ---
11 17
@@ -51,7 +57,7 @@ @@ -51,7 +57,7 @@
51 `org-coder-validator` 的 attempt 2 在 10:42:48 才跑,**比这些产物晚了半小时以上**, 57 `org-coder-validator` 的 attempt 2 在 10:42:48 才跑,**比这些产物晚了半小时以上**,
52 不是时序竞争,是稳定复现。 58 不是时序竞争,是稳定复现。
53 59
54 -### 2.3 根因:`inboundContracts` 不按 `role` 过滤,把 `produces` 声明也当成 `needs` 60 +### 2.3 根因(修订版):`produces:["log"]` 声明的是一个永远认不出来的 kind
55 61
56 `executor.json` 里 `test → validator` 这条边同时声明了两条契约(`work-graph-db.js` 62 `executor.json` 里 `test → validator` 这条边同时声明了两条契约(`work-graph-db.js`
57 `declareEdgeContract`): 63 `declareEdgeContract`):
@@ -61,70 +67,87 @@ @@ -61,70 +67,87 @@
61 "produces": ["log"], "needs": ["artifact"] } 67 "produces": ["log"], "needs": ["artifact"] }
62 ``` 68 ```
63 69
64 -即:`kind=log, role=produces`(test 自己产出的是 log)+ `kind=artifact, role=needs`  
65 -(validator 要吃的是 artifact)。这两条都落进同一张 `graph_edge_contracts` 表,  
66 -`required=1`。  
67 -  
68 -问题出在 `work-graph-db.js:2542` 的 `inboundContracts()`:  
69 -  
70 -```js  
71 -function inboundContracts(edgeLayer, edges, defNodeId) {  
72 - const inbound = (edges || []).filter(  
73 - (e) => String(e.to) === String(defNodeId) && (e.edgeKind || 'forward') !== 'backward'  
74 - );  
75 - const out = [];  
76 - for (const e of inbound) {  
77 - for (const c of edgeContracts(edgeLayer, e.id)) out.push({ ...c, fromNodeId: e.from });  
78 - }  
79 - return out;  
80 -} 70 +即:`kind=log, role=produces`(test 自己产出的是 log,required 默认 true)+
  71 +`kind=artifact, role=needs`(validator 真正要吃的是 artifact,中继声明,产出方是
  72 +更早的 impl,非相邻依赖,R-19.3 ②)。`executor.json` 头部 `dataContractNotes`
  73 +写明了设计意图——「validator 既要看测试记录(紧邻上游 produces log)也要看被验的
  74 +交付物本身」,即:`produces` 与 `needs` **都该算进 validator 的输入需求**,这不是
  75 +误用,`inboundContracts()` 把两者都吐出来是**故意的、正确的**(详见下方「走过的
  76 +弯路」)。
  77 +
  78 +真正的根因在**产出侧**:`test` 类节点(`org-coder-test`、`org-coder-test-a/b`、
  79 +`org-coder-integration`)从来不会写出一个能被认作 `kind=log` 的文件。
  80 +`indexNodeOutputs()`(`graph-engine.js:3835`)给文件定 kind 的规则是「文件名匹配
  81 +声明的 kind → 已知文件名 → 唯一无歧义兜底」三级,而这几类节点实际落盘的是
  82 +`_plan.md` / `_research.md` / `coder.log` 这类文件——`coder.log` 恰好属于 **impl**
  83 +节点(不是 test),test 自己写的两份文件文件名里都没有 "log",且同时有两个认不出
  84 +kind 的文件,触发不了「一对一无歧义」的兜底认领。于是 `graph_files` 里对这个
  85 +task **永远**不会出现一行 `kind='log'`——`unsatisfiedRequiredProduces()`(出边产出
  86 +闸门,`graph-engine.js:3801`)本身对此是宽容的:它只检查节点私有目录里有没有
  87 +**任何**候选文件,不检查文件是否精确匹配声明的 kind,所以 `test` 自己能顺利
  88 +`success`;但 `requiredInputsMissing()`(`graph-engine.js:3945`)在 `validator`
  89 +结算时是严格的:`declared kind - 索引里查到的 kind` 非空就判 `missing_required_input`,
  90 +而 `log` 这个 kind 因为上面那条产出侧的缺口从未出现在索引里——**这不是一次性的
  91 +时序问题,是这条边只要还这么声明就永远无法满足的硬伤,每次重试都复现**。
  92 +
  93 +对照 `validator.json`(同一批模板):它的 `needs` 声明本来就写成
  94 +`{ "kind": "artifact", "required": false }`——同一份代码库里已经有先例,
  95 +知道这类「声明了但未必查得到实例」的依赖要标 `required:false`,只是
  96 +`executor.json` / `executor-fanout.json` 里的 `produces:["log"]` 用的是裸数组
  97 +写法(`normalizeContracts` 对裸字符串项默认 `required:true`),漏了这一条。
  98 +
  99 +### 2.4 走过的弯路:为什么「按 role 过滤 inboundContracts」是错的
  100 +
  101 +最初的假设是「`inboundContracts()` 不该把 `produces` 也当成 `needs`,只留
  102 +`role==='needs'` 的行」。改完发现 `work-graph-skeletons.test.js` /
  103 +`org-layer-contracts.test.js` 等 9 个用例炸了:`org-planner-sdd`(入边
  104 +`prd→sdd: produces:["prd"]`,无配对 `needs`)、`org-coder-test`(入边
  105 +`impl→test: produces:["artifact"]`,无配对 `needs`)等一大批节点的入边**只声明
  106 +了 `produces`,从不显式写 `needs`**——这本来就是绝大多数模板边的写法(`declareEdgeContract`
  107 +注释原话:「出边声明产出是压倒性多数,needs 必须显式写」)。按 role 过滤会让这些
  108 +节点的 `inboundContracts()` 变成空数组,`requiredInputsMissing()` 也就不再校验
  109 +它们——而这条链路正是 T-mt25ecbd(commit `aecf5c9`)刚加固的产出闸门本身
  110 +依赖的数据源(`org-planner-benchmark` 靠它校验 `needs:[brd,prd,sdd]` 真的都被
  111 +索引到)。按 role 过滤表面上修好了 `log` 这一个 kind,实际上会**静默削弱**
  112 +T-mt25ecbd 那次修复要守住的保护——好在跑单测立刻炸了,没有带着这个错误方向走
  113 +下去。**结论**:`inboundContracts()` 本身没有 bug,别再按这个方向改它。
  114 +
  115 +### 2.5 实际修法
  116 +
  117 +在**声明**这一侧把 `log` 标成 `required:false`,与 `validator.json` 的既有写法
  118 +对齐——既保留「validator 能看到 test 的 log(如果将来真的被索引到)」这条文档
  119 +价值,又不再把一个永远查不到实例的 kind 当硬闸门:
  120 +
  121 +```diff
  122 +- { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success",
  123 +- "produces": ["log"], "needs": ["artifact"] },
  124 ++ { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success",
  125 ++ "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] },
81 ``` 126 ```
82 127
83 -它把入边上**所有**契约行原样吐出来,不管 `role` 是 `produces` 还是 `needs`。  
84 -下游 `graph-engine.js:3945` 的 `requiredInputsMissing()` 接着只按 `required` 过滤:  
85 -  
86 -```js  
87 -const requiredKinds = [...new Set(contracts.filter((c) => c.required).map((c) => c.kind))];  
88 -```  
89 -  
90 -于是 `validator` 的「必须在索引里找到实例」的 kind 集合变成了 `{log, artifact}`,  
91 -而不是本该只有的 `{artifact}`——`log` 是 test 声明**自己产出**的 kind(描述 test 的  
92 -输出形态),不是 validator 要读的输入。这个 task 的 `graph_files` 里从来没有任何  
93 -`kind='log'` 的行(`org-coder-test` 实际写的是 `coder.log` 但被索引成  
94 -`kind='artifact'`,不是 `kind='log'`),于是 `requiredKinds - haveKinds = ['log']`,  
95 -`missing.length > 0`,success 被强改成 fail。  
96 -  
97 -**对照**:同一份代码里至少三处其他函数在读同一张 `graph_edge_contracts` 表时都记得  
98 -按 `role` 过滤(`(c.role || 'produces') !== 'needs'` 或 `=== 'produces'`,  
99 -见 `graph-engine.js:968` / `:3811` / `:3866`),唯独 `inboundContracts` /  
100 -`requiredInputsMissing` 这一条链路漏了。判据不一致,不是设计如此。  
101 -  
102 -### 2.4 影响面推测(未逐一核实,留给修的时候一并查)  
103 -  
104 -- 任何在同一条边上**同时声明了 `produces` 和 `needs`** 的节点,只要 `produces`  
105 - 的那个 kind 从未被单独索引过(大多数「描述性」kind,比如这里的 `log`,本来就不  
106 - 一定会被单独记录成同名 kind 的产物行),下游节点的 success 就会被这条误判的  
107 - `missing_required_input` 拦下来,且**每次重试都会复现**(不是一次性的)。  
108 -- `executor.json` / `executor-fanout.json` / `validator.json` 里凡是 `test→validator`  
109 - 这种「同边混写 produces+needs」的写法都要复查一遍(§一 T3/T4 审计脚本可以扩展  
110 - 一个「edge 同时有 produces 与 needs 时,produces 的 kind 是否真的会被索引」的检查项)。  
111 -  
112 -### 2.5 建议修法(留给实施时定案,这里只记方向)  
113 -  
114 -`inboundContracts()` 应该只返回 `role='needs'`(含历史 NULL 按 `produces` 处理的  
115 -存量行需要保留原有兼容语义,但 NULL 不该被当成 `needs`)的契约行,或者  
116 -`requiredInputsMissing()` 在 `contracts.filter(...)` 那一步同时按  
117 -`(c.role || 'produces') === 'needs'` 过滤——两者选一,不建议两处都改(避免出现  
118 -「谁的语义才是权威」的第二份判据)。改完要跑一遍 §一 提到的真实回归任务,确认  
119 -`org-coder-validator` 不再假阳性 blocked。 128 +改了 4 处(`executor.json` 1 处,`executor-fanout.json` 3 处:`test-a→merge`、
  129 +`test-b→merge`、`integration→validator`——同一种「produces:log 但 test/integration
  130 +类节点从不产出可识别的 log 文件」的形状)。真正必需的 `needs:artifact`
  131 +(`required:true`)保持不变,继续受硬闸门保护。
120 132
121 --- 133 ---
122 134
123 -## 三、下一步  
124 -  
125 -- [ ] 定案 §2.5 的修法,改 `inboundContracts` 或 `requiredInputsMissing` 之一  
126 -- [ ] 新增单测:`test→validator` 同边声明 `produces:log` + `needs:artifact`,  
127 - 只有 `artifact` 有索引实例、`log` 没有,validator 的 success 判定不应被拦  
128 -- [ ] 跑一遍 `graph-real-chain-smoke.test.js` 同款「真实引擎代码 + 真实 DB + 换掉最外层  
129 - CLI 子进程」的确定性回归,复现本次撞见的具体场景  
130 -- [ ] 复查 `system/work-graph-templates/*.json` 里其他「同边混写 produces+needs」的边 135 +## 三、已完成
  136 +
  137 +- [x] 撤回 §2.4 那次错误方向的引擎改动(`work-graph-db.js` 的 `inboundContracts`
  138 + 恢复原状,加注释记录这条弯路,防止以后有人重新踩一遍)
  139 +- [x] 按 §2.5 改 `executor.json` / `executor-fanout.json` 的 4 处 `produces:["log"]`
  140 + 声明为 `required:false`
  141 +- [x] 新增单测 `graph-files-index.test.js` ⑪:`produces` 声明 `required:false`
  142 + 且该 kind 从未被索引到时,不拦下游节点 success;同一条边上 `required:true`
  143 + 的 `needs:artifact` 依旧受硬闸门保护(未被这次改动削弱)
  144 +- [x] 全量单测 `npx vitest run server/tests/unit`:**998/998 通过**(含新增用例,
  145 + 零回归)
  146 +
  147 +## 四、留待下次
  148 +
  149 +- [ ] 用真实回归任务(同 31 号文档 §一的路数,起一条新 e2e-demo 任务)验证
  150 + `org-coder-validator` 不再假阳性 blocked——这次改动只有单测覆盖,还没有
  151 + 真实 LLM 跑过的证据
  152 +- [ ] `validator.json` 的两条 `needs:[{kind:'artifact',required:false}]` 是否也该
  153 + 核对一遍「这个 kind 到底会不会被索引到」,避免同一类问题在别的模板里潜伏
server/services/work-graph-db.js
@@ -2538,6 +2538,17 @@ function edgeContracts(edgeLayer, edgeId) { @@ -2538,6 +2538,17 @@ function edgeContracts(edgeLayer, edgeId) {
2538 /** 2538 /**
2539 * 某个节点的**入边**上声明了哪些 kind —— 这是「这个节点需要什么输入」的答案。 2539 * 某个节点的**入边**上声明了哪些 kind —— 这是「这个节点需要什么输入」的答案。
2540 * 只看 forward 入边:backward 入边是回退路径,不是数据来源。 2540 * 只看 forward 入边:backward 入边是回退路径,不是数据来源。
  2541 + *
  2542 + * **produces 与 needs 都收**——两者对下游而言都是「要吃的输入」,只是来源不同:
  2543 + * produces 是紧邻上游自己的产出(R-19.3 ①「沿边传递」,如 `impl→test: produces:[artifact]`),
  2544 + * needs 是中继声明的非相邻依赖(R-19.3 ②,如 `test→validator: needs:[artifact]`,
  2545 + * 实际产出方是更早的 impl)。33 号文档最初以为这里该按 role 过滤只留 needs,
  2546 + * 但那会把单纯声明 produces(没有配对 needs)的绝大多数边(`impl→test`、`brd→prd`……)
  2547 + * 的下游需求判空,且会削弱 T-mt25ecbd 那次刚加固的产出闸门(`org-planner-sdd`/
  2548 + * `org-planner-benchmark` 的 `produces:[sdd]`/`produces:[acceptance]` 就是靠这条链路
  2549 + * 被 requiredInputsMissing 强制校验的)——按 role 过滤是错的,真正的根因和修法在
  2550 + * `system/work-graph-templates/*.json` 那几条 `produces:["log"]` 声明本身
  2551 + * (见 33 号文档修订版)。
2541 */ 2552 */
2542 function inboundContracts(edgeLayer, edges, defNodeId) { 2553 function inboundContracts(edgeLayer, edges, defNodeId) {
2543 const inbound = (edges || []).filter( 2554 const inbound = (edges || []).filter(
server/tests/unit/graph-files-index.test.js
@@ -542,4 +542,50 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; { @@ -542,4 +542,50 @@ describe(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
542 const rows = allFiles(companyId); 542 const rows = allFiles(companyId);
543 expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']); 543 expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']);
544 }); 544 });
  545 +
  546 + // ── ⑪ 33 号文档:produces 声明 required:false 时不拦下游 ─────────
  547 + //
  548 + // 复现 executor.json 里 test→validator 那条边的真实形状:test 出边同时声明
  549 + // produces:[{kind:'log', required:false}](自己产出的记录,写作文档但不强制)
  550 + // 与 needs:[{kind:'artifact', required:true}](中继声明,产出方是更早的 impl)。
  551 + // test 实际写的文件(_plan.md/_research.md 之类)从来认不出 kind=log ——
  552 + // 这在真实回归任务 T-mt2r6fz0-125b 里复现过:validator 被 missing_required_input
  553 + // 反复误判 blocked,而它自己判定 passed。required:false 让这条声明只留文档价值,
  554 + // 不再是硬闸门;真正必需的 needs:artifact 依旧受硬闸门保护(不因这次修复被削弱)。
  555 +
  556 + it('⑪ produces 声明 required:false 时,即使这个 kind 从未被索引到,也不拦下游成功', async () => {
  557 + const companyId = 'co-files-optional-produces';
  558 + const taskId = 'task-optional-produces';
  559 + const b = buildRun({
  560 + companyId, taskId,
  561 + nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-test' }, { nodeName: 'w-validator' }],
  562 + edges: [
  563 + { from: 'w-impl', to: 'w-test', condition: 'success' },
  564 + { from: 'w-test', to: 'w-validator', condition: 'success' },
  565 + ],
  566 + });
  567 + wdb.declareEdgeContract({
  568 + edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-test'), kind: 'artifact', role: 'produces',
  569 + });
  570 + const e2 = b.edgeId('w-test', 'w-validator');
  571 + // 与 executor.json 修复后的形状逐字对应
  572 + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'log', role: 'produces', required: false });
  573 + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'artifact', role: 'needs', required: true });
  574 +
  575 + await advance(b.runId, 3, {
  576 + writes: {
  577 + 'w-impl': { 'impl.md': '# 实现产物' },
  578 + // test 写的文件跟 "log" 毫无关联、彼此也无歧义可猜(同 ④b 的口径),
  579 + // 这个 kind 永远不会被索引到——正是修复要覆盖的那种情形
  580 + 'w-test': { '_plan.md': '计划', '_research.md': '调研' },
  581 + },
  582 + });
  583 +
  584 + const state = wdb.getRunState(b.runId);
  585 + const validator = state.nodes.find((n) => n.name === 'w-validator');
  586 + // 关键断言:log 从未被索引,但它是 required:false,不该拦下游
  587 + expect(allFiles(companyId).some((f) => f.kind === 'log')).toBe(false);
  588 + expect(validator.status).toBe('success');
  589 + expect(validator.failReason).toBeFalsy();
  590 + });
545 }); 591 });
system/work-graph-templates/executor-fanout.json
@@ -19,10 +19,10 @@ @@ -19,10 +19,10 @@
19 { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success", "produces": ["contract"] }, 19 { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success", "produces": ["contract"] },
20 { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success", "produces": ["artifact"] }, 20 { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success", "produces": ["artifact"] },
21 { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success", "produces": ["artifact"] }, 21 { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success", "produces": ["artifact"] },
22 - { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] },  
23 - { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, 22 + { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] },
  23 + { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] },
24 { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success", "produces": ["artifact"] }, 24 { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success", "produces": ["artifact"] },
25 - { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, 25 + { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] },
26 { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, 26 { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" },
27 { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, 27 { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" },
28 { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, 28 { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" },
system/work-graph-templates/executor.json
@@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
11 ], 11 ],
12 "edges": [ 12 "edges": [
13 { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success", "produces": ["artifact"] }, 13 { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success", "produces": ["artifact"] },
14 - { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, 14 + { "from": "{{orgNode}}-test", "to": "{{orgNode}}-validator", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] },
15 { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }, 15 { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" },
16 { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" } 16 { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }
17 ] 17 ]