Commit a77934e726b749895ab7fa89e351e559811e5612

Authored by yibo
1 parent 7aa2d0da

fix(work-graph): kind='log' 契约永远落成 required=false

test/integration 类节点从不产出一个能被 indexNodeOutputs() 认作
kind='log' 的文件(落盘的都是 coder.log/test.log,归入 kind='artifact'),
required 的 log 契约永远无法满足,把节点卡进 missing_required_input
死循环。33 号文档修过一次模板文件,但 workgraph-planning agent 实际
照抄的是 tools.md 里一份没同步更新的旧范例,同一个坑复现(snakegame-co
T-mtfh2kuf-7c31)。这次把底线收到 declareEdgeContract 唯一落库口,
不再依赖每个模板/每次 LLM 规划都记得写对;顺带同步 tools.md 范例。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7xYyvv3pzuV9diMVUH5QL
server/services/work-graph-db.js
... ... @@ -2558,10 +2558,23 @@ function findFiles({ companyId, taskId = null, kinds = null }) {
2558 2558 return rows.filter((r) => want.has(r.kind));
2559 2559 }
2560 2560  
  2561 +/**
  2562 + * kind='log' 永远不能被声明成 required:`indexNodeOutputs()` 的三级认领规则
  2563 + * (文件名匹配声明 kind → 已知文件名 → 唯一无歧义兜底)对 test/integration 类节点
  2564 + * 结构性地认不出一个 kind='log' 的文件——它们落盘的是 coder.log/test.log 之类,
  2565 + * 全部被归到 kind='artifact'。required:true 的 log 契约因此**永远无法满足**,
  2566 + * 触发 requiredInputsMissing() 把节点打成 missing_required_input 死循环
  2567 + * (33 号文档 + snakegame-co T-mtfh2kuf-7c31 复现两次:一次模板 executor.json 里
  2568 + * 手滑漏了 required:false,一次 workgraph-planning agent 的 tools.md 例句没跟着改)。
  2569 + * 与其指望每个模板作者 / 每次 LLM 规划都记得写对,不如把这条底线收在唯一的落库口。
  2570 + */
  2571 +const NEVER_REQUIRED_KINDS = new Set(['log']);
  2572 +
2561 2573 /** 在边上声明「这条边要传什么 kind」。规划时写,只有 kind 没有实例。 */
2562 2574 function declareEdgeContract({ edgeLayer, edgeId, kind, required = true, role = 'produces' }) {
2563 2575 if (!['org', 'work'].includes(edgeLayer)) throw new Error(`edgeLayer 非法: ${edgeLayer}`);
2564 2576 if (!edgeId || !kind) throw new Error('declareEdgeContract 需要 edgeId / kind');
  2577 + const effectiveRequired = required && !NEVER_REQUIRED_KINDS.has(kind);
2565 2578 // role 默认 produces:出边声明产出是压倒性多数,needs 必须显式写。
2566 2579 // 去重键仍是 (layer, edge, kind) 而不含 role —— 同一条边对同一个 kind 既声明 produces
2567 2580 // 又声明 needs 是自相矛盾的输入(「我产它」和「我要别人产它」),
... ... @@ -2574,7 +2587,7 @@ function declareEdgeContract({ edgeLayer, edgeId, kind, required = true, role =
2574 2587 ON CONFLICT(edge_layer, edge_id, kind) DO NOTHING
2575 2588 `)
2576 2589 .run(
2577   - newId('gec'), edgeLayer, String(edgeId), kind, required ? 1 : 0,
  2590 + newId('gec'), edgeLayer, String(edgeId), kind, effectiveRequired ? 1 : 0,
2578 2591 role === 'needs' ? 'needs' : 'produces', new Date().toISOString()
2579 2592 );
2580 2593 }
... ...
server/tests/unit/graph-edge-contracts.test.js
... ... @@ -156,6 +156,27 @@ describe(&#39;R-19.A 边上的数据契约:声明 → 落库 → 读回&#39;, () =&gt; {
156 156 expect(rows.map((r) => [r.kind, r.required])).toEqual([['acceptance', 0]]);
157 157 });
158 158  
  159 + it('① kind=log 永远落成 required=0,哪怕调用方(模板/LLM 规划)写的是 required:true(33 号文档 + snakegame-co T-mtfh2kuf-7c31 两次复现)', () => {
  160 + const companyId = 'co-contract-log-floor';
  161 + ensureAgents(['unit-tester', 'coder-validator']);
  162 + const g = wdb.createWorkGraph({
  163 + companyId,
  164 + taskId: 'T-log-floor',
  165 + nodes: [
  166 + { nodeName: 'l-test', systemAgentId: 'unit-tester' },
  167 + { nodeName: 'l-val', systemAgentId: 'coder-validator' },
  168 + ],
  169 + edges: [
  170 + // 裸数组写法 = normalizeContracts 默认 required:true,正是漂移复现的形状
  171 + { from: 'l-test', to: 'l-val', condition: 'success', produces: ['log'] },
  172 + ],
  173 + });
  174 + expect(g.errors || []).toEqual([]);
  175 + const def = wdb.readDefGraph('work', g.id);
  176 + const rows = wdb.edgeContracts('work', def.edges[0].id);
  177 + expect(rows.map((r) => [r.kind, r.required])).toEqual([['log', 0]]);
  178 + });
  179 +
159 180 it('① kind 大小写/空格被归一(" PRD " 与 "prd" 是同一个契约,不产生两行)', () => {
160 181 const companyId = 'co-contract-case';
161 182 ensureAgents(['prd-planning', 'sdd-planning']);
... ...
system/agents/workgraph-planning/tools.md
... ... @@ -126,11 +126,17 @@ ls data/companies/&lt;公司ID&gt;/agents/
126 126 (或写成一个 `.json` 文件、在回复里给出**绝对路径**),引擎自己取出来校验 + 落库。
127 127  
128 128 ```json
129   -{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":["log"],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]}
  129 +{"nodes":[{"nodeName":"org-coder-impl","systemAgentId":"coder"},{"nodeName":"org-coder-test","systemAgentId":"unit-tester"},{"nodeName":"org-coder-validator","systemAgentId":"coder-validator"}],"edges":[{"from":"org-coder-impl","to":"org-coder-test","condition":"success","produces":["artifact"]},{"from":"org-coder-test","to":"org-coder-validator","condition":"success","produces":[{"kind":"log","required":false}],"needs":["artifact"]},{"from":"org-coder-test","to":"org-coder-impl","condition":"fail","edgeKind":"backward"},{"from":"org-coder-validator","to":"org-coder-impl","condition":"fail","edgeKind":"backward"}]}
130 130 ```
131 131  
132 132 > `produces` / `needs` 就是 §6bis 那两个键。**forward 边上少了它们,下游节点的提示词里
133 133 > 就没有上游产物的路径** —— 先读 §6bis 再写这份 JSON。
  134 +>
  135 +> `"kind":"log"` 必须写成 `{"kind":"log","required":false}` 这个对象形式,不能简写成
  136 +> `["log"]`(33 号文档 + snakegame-co T-mtfh2kuf-7c31 两次真实事故):test/integration
  137 +> 类节点从不产出一个能被引擎认作 `kind='log'` 的文件(落盘的是 coder.log/test.log,
  138 +> 全被归到 `kind='artifact'`),required 的 log 契约会永远无法满足,把节点卡进
  139 +> missing_required_input 死循环。
134 140  
135 141 引擎取图的死规矩(完整版在 `agent.md` Step 7):**严格 JSON**(注释 / 尾逗号 / 省略号一律解析失败,
136 142 整块当没写)· 顶层要有 **`nodes` 数组** · 文件路径必须**绝对** ·
... ...