diff --git a/docs/README.md b/docs/README.md index bdb2650..583ebd9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [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) | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据 | | [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 次,零人工介入),修复确认生效 | -| [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 | +| [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 号文档的真实回归验证过程中,与兜底边问题无关 | ## todo/future/(暂缓,非当前重心) diff --git a/docs/todo/system-design/33-needs-gate-role-filter-bug.md b/docs/todo/system-design/33-needs-gate-role-filter-bug.md index 918efc2..35d3b65 100644 --- a/docs/todo/system-design/33-needs-gate-role-filter-bug.md +++ b/docs/todo/system-design/33-needs-gate-role-filter-bug.md @@ -1,11 +1,17 @@ -# 33-needs-gate-role-filter-bug. `requiredInputsMissing` 未按 role 过滤 —— produces 声明被误判成 needs +# 33-needs-gate-role-filter-bug. `produces:["log"]` 声明了一个从不会被索引到的 kind —— validator 被 missing_required_input 误判 -> **日期**: 2026-08-21 -> **状态**: 🔴 **待修**(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug, +> **日期**: 2026-08-21 发现,2026-08-22 修复 +> **状态**: ✅ **已修复**(发现于 31 号文档修复的真实 E2E 回归验证过程中,是一个独立的新 bug, > 与 31 号文档的兜底边问题无关) > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`, > root run `run_0e9074815130`)——为验证 [31 号文档](./31-work-graph-fail-fallback-coverage.md) > 的兜底边修复而起的新任务,链路走到 `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(修订版)与 §四。 --- @@ -51,7 +57,7 @@ `org-coder-validator` 的 attempt 2 在 10:42:48 才跑,**比这些产物晚了半小时以上**, 不是时序竞争,是稳定复现。 -### 2.3 根因:`inboundContracts` 不按 `role` 过滤,把 `produces` 声明也当成 `needs` +### 2.3 根因(修订版):`produces:["log"]` 声明的是一个永远认不出来的 kind `executor.json` 里 `test → validator` 这条边同时声明了两条契约(`work-graph-db.js` `declareEdgeContract`): @@ -61,70 +67,87 @@ "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()`: - -```js -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; -} +即:`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 当硬闸门: + +```diff +- { "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"] }, ``` -它把入边上**所有**契约行原样吐出来,不管 `role` 是 `produces` 还是 `needs`。 -下游 `graph-engine.js:3945` 的 `requiredInputsMissing()` 接着只按 `required` 过滤: - -```js -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。 +改了 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`)保持不变,继续受硬闸门保护。 --- -## 三、下一步 - -- [ ] 定案 §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」的边 +## 三、已完成 + +- [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 到底会不会被索引到」,避免同一类问题在别的模板里潜伏 diff --git a/server/services/work-graph-db.js b/server/services/work-graph-db.js index c6870d1..11814b9 100644 --- a/server/services/work-graph-db.js +++ b/server/services/work-graph-db.js @@ -2538,6 +2538,17 @@ function edgeContracts(edgeLayer, edgeId) { /** * 某个节点的**入边**上声明了哪些 kind —— 这是「这个节点需要什么输入」的答案。 * 只看 forward 入边:backward 入边是回退路径,不是数据来源。 + * + * **produces 与 needs 都收**——两者对下游而言都是「要吃的输入」,只是来源不同: + * produces 是紧邻上游自己的产出(R-19.3 ①「沿边传递」,如 `impl→test: produces:[artifact]`), + * needs 是中继声明的非相邻依赖(R-19.3 ②,如 `test→validator: needs:[artifact]`, + * 实际产出方是更早的 impl)。33 号文档最初以为这里该按 role 过滤只留 needs, + * 但那会把单纯声明 produces(没有配对 needs)的绝大多数边(`impl→test`、`brd→prd`……) + * 的下游需求判空,且会削弱 T-mt25ecbd 那次刚加固的产出闸门(`org-planner-sdd`/ + * `org-planner-benchmark` 的 `produces:[sdd]`/`produces:[acceptance]` 就是靠这条链路 + * 被 requiredInputsMissing 强制校验的)——按 role 过滤是错的,真正的根因和修法在 + * `system/work-graph-templates/*.json` 那几条 `produces:["log"]` 声明本身 + * (见 33 号文档修订版)。 */ function inboundContracts(edgeLayer, edges, defNodeId) { const inbound = (edges || []).filter( diff --git a/server/tests/unit/graph-files-index.test.js b/server/tests/unit/graph-files-index.test.js index 4a5b086..e84db40 100644 --- a/server/tests/unit/graph-files-index.test.js +++ b/server/tests/unit/graph-files-index.test.js @@ -542,4 +542,50 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { const rows = allFiles(companyId); expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']); }); + + // ── ⑪ 33 号文档:produces 声明 required:false 时不拦下游 ───────── + // + // 复现 executor.json 里 test→validator 那条边的真实形状:test 出边同时声明 + // produces:[{kind:'log', required:false}](自己产出的记录,写作文档但不强制) + // 与 needs:[{kind:'artifact', required:true}](中继声明,产出方是更早的 impl)。 + // test 实际写的文件(_plan.md/_research.md 之类)从来认不出 kind=log —— + // 这在真实回归任务 T-mt2r6fz0-125b 里复现过:validator 被 missing_required_input + // 反复误判 blocked,而它自己判定 passed。required:false 让这条声明只留文档价值, + // 不再是硬闸门;真正必需的 needs:artifact 依旧受硬闸门保护(不因这次修复被削弱)。 + + it('⑪ produces 声明 required:false 时,即使这个 kind 从未被索引到,也不拦下游成功', async () => { + const companyId = 'co-files-optional-produces'; + const taskId = 'task-optional-produces'; + const b = buildRun({ + companyId, taskId, + nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-test' }, { nodeName: 'w-validator' }], + edges: [ + { from: 'w-impl', to: 'w-test', condition: 'success' }, + { from: 'w-test', to: 'w-validator', condition: 'success' }, + ], + }); + wdb.declareEdgeContract({ + edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-test'), kind: 'artifact', role: 'produces', + }); + const e2 = b.edgeId('w-test', 'w-validator'); + // 与 executor.json 修复后的形状逐字对应 + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'log', role: 'produces', required: false }); + wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'artifact', role: 'needs', required: true }); + + await advance(b.runId, 3, { + writes: { + 'w-impl': { 'impl.md': '# 实现产物' }, + // test 写的文件跟 "log" 毫无关联、彼此也无歧义可猜(同 ④b 的口径), + // 这个 kind 永远不会被索引到——正是修复要覆盖的那种情形 + 'w-test': { '_plan.md': '计划', '_research.md': '调研' }, + }, + }); + + const state = wdb.getRunState(b.runId); + const validator = state.nodes.find((n) => n.name === 'w-validator'); + // 关键断言:log 从未被索引,但它是 required:false,不该拦下游 + expect(allFiles(companyId).some((f) => f.kind === 'log')).toBe(false); + expect(validator.status).toBe('success'); + expect(validator.failReason).toBeFalsy(); + }); }); diff --git a/system/work-graph-templates/executor-fanout.json b/system/work-graph-templates/executor-fanout.json index d7631bf..8b66a38 100644 --- a/system/work-graph-templates/executor-fanout.json +++ b/system/work-graph-templates/executor-fanout.json @@ -19,10 +19,10 @@ { "from": "{{orgNode}}-split", "to": "{{orgNode}}-impl-b", "condition": "success", "produces": ["contract"] }, { "from": "{{orgNode}}-impl-a", "to": "{{orgNode}}-test-a", "condition": "success", "produces": ["artifact"] }, { "from": "{{orgNode}}-impl-b", "to": "{{orgNode}}-test-b", "condition": "success", "produces": ["artifact"] }, - { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, - { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, + { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-merge", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] }, + { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-merge", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] }, { "from": "{{orgNode}}-merge", "to": "{{orgNode}}-integration", "condition": "success", "produces": ["artifact"] }, - { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": ["log"], "needs": ["artifact"] }, + { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-validator", "condition": "success", "produces": [{ "kind": "log", "required": false }], "needs": ["artifact"] }, { "from": "{{orgNode}}-test-a", "to": "{{orgNode}}-impl-a", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-test-b", "to": "{{orgNode}}-impl-b", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-integration", "to": "{{orgNode}}-merge", "condition": "fail", "edgeKind": "backward" }, diff --git a/system/work-graph-templates/executor.json b/system/work-graph-templates/executor.json index 323b1d0..971e88b 100644 --- a/system/work-graph-templates/executor.json +++ b/system/work-graph-templates/executor.json @@ -11,7 +11,7 @@ ], "edges": [ { "from": "{{orgNode}}-impl", "to": "{{orgNode}}-test", "condition": "success", "produces": ["artifact"] }, - { "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"] }, { "from": "{{orgNode}}-test", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" }, { "from": "{{orgNode}}-validator", "to": "{{orgNode}}-impl", "condition": "fail", "edgeKind": "backward" } ]