Commit a77934e726b749895ab7fa89e351e559811e5612
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
Showing
3 changed files
with
42 additions
and
2 deletions
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('R-19.A 边上的数据契约:声明 → 落库 → 读回', () => { |
| 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/<公司ID>/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` 数组** · 文件路径必须**绝对** · | ... | ... |