Commit 2b77f8c9faaa1da7fac61a3398979430823febc9
1 parent
1ea9efa6
fix(p4): 产物 kind 不再只靠文件名;任务级共享按 kind 去重
第五次真 CLI 冒烟(gapClosed=true)暴露的两条,都是「这轮成立是侥幸」型。
1) indexNodeOutputs 靠文件名定 kind
真跑成立纯属 brd 节点恰好把文件命名成 `brd.md`。换写 `业务需求文档.md`
就落成 kind=artifact → 下游入边声明的 brd 查不到实例 → 提示词退化成
「⚠️ 索引里查不到实例」,即刚关掉的断点换个形式重现。agent 给产物起中文名
是完全正常的行为,不该由它决定索引对不对。
改成两遍:先按文件名认领,再在**一对一无歧义**时(还差一个 kind 没人产、
正好还剩一个认不出的文件)按声明认领。有歧义不猜 —— 猜错比留 artifact 更坏,
错的 kind 让下游读到一份不是它要的文件,artifact 只是查不到、会如实报 ⚠️。
2) describeInputs 任务级共享不去重
真跑里 brd 与 prd 两个节点各写了一份 contract.json,都是 kind=contract,
于是往后每个节点的提示词里「contract(任务级共享)」逐个累加。节点一多是噪声,
且给 agent 两个同类路径等于把一个我们自己都没定的选择推给它。
按 kind 保留最新那份,与沿边那一半的 byKind.set 同口径。
ponytail 标了根因(节点私有目录的文件也会按文件名落成 task-shared 的 kind)
与真正的修法(任务级产物只认 tasks/{taskId}/),后者要动 R-9.3 现有语义,
归到那条线一起定。
测试 829 全绿(+3),既有断言未改。4 处变异精确变红后逐字还原。
含 verify agent 回填的 §4.6「第五次真 CLI 冒烟」小节。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
3 changed files
with
172 additions
and
8 deletions
docs/todo/system-design/27-p4-parallel-and-artifacts.md
| ... | ... | @@ -345,6 +345,34 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts ( |
| 345 | 345 | **尚未接通的一环**:`system/work-graph-templates/*.json` 四张骨架的边**还没有** `produces` 声明, |
| 346 | 346 | 所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。骨架补声明是下一步。 |
| 347 | 347 | |
| 348 | +#### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码) | |
| 349 | + | |
| 350 | +**跑法**:主仓库 vite(5173)+ worktree `wf_55ba803e-5ac-6` 的真 CLI 服务(3010,`data/` 独立, | |
| 351 | +`LEMONBOSS_DISABLE_WATCHDOG=1`)。公司 `r19smoke`,手动模式,预算 `total_agent_calls` 实跑 **3/15**。 | |
| 352 | +**被测版本 = `925d9db` + 当时工作树里未提交的 R-19.A 改动**(这批改动随后进了 `1ea9efa`); | |
| 353 | +`1ea9efa` 里新加的 role 列与控制台三字段**不在**本轮被测范围内。 | |
| 354 | + | |
| 355 | +| 验收点 | 结果 | | |
| 356 | +|---|---| | |
| 357 | +| 建公司 → `orggraph-planning` 真跑落图自雇 | ✅ 未被打破。`org-analyst` / `org-designer` / `org-validator` 三节点 + 正向边 + 两条 backward fail 边;规划本体没混进 org 节点 | | |
| 358 | +| **R19-A:真实规划 agent 会不会在边上声明契约** | ✅ **成立**。约束九全文(`### 约束九` / kind 取值域 / backward 禁令 / 自查 5bis)确实进了它的提示词(28597 字,未被 2000 字截断)。它自己写出 `{"from":"org-analyst-brd","to":"org-analyst-prd","condition":"success","produces":["brd"]}` 与 `{...,"to":"org-analyst-validator","produces":["prd"],"needs":["brd"]}`,两条 backward 边干净。落库 `graph_edge_contracts` **3 行**(`edge_layer='work'`)—— `declareEdgeContract` 第一次有真实调用方写出真实行 | | |
| 359 | +| **落库侧:节点产出进不进索引** | ✅ 成立。`org-analyst-brd` success 后 `graph_files` 两行:`brd.md`→`kind=brd`(**按出边声明定的**)、`contract.json`→`kind=contract`(按文件名兜底) | | |
| 360 | +| **读回侧:下游提示词里有没有上游产物的真实绝对路径**(本轮核心) | ✅ **成立,断点关闭**。`org-analyst-prd`(`prd-planning`)的真实提示词里出现:<br>`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`<br>`- brd:…/runs/run_fc7e9b8a29d5/nodes/rn_2e8dbb1c6ed7/brd.md`<br>`- contract(任务级共享):…/rn_2e8dbb1c6ed7/contract.json`<br>对照第四次真跑同一个本体的提示词:`[上游输入` **一次都没出现过** | | |
| 361 | +| **下游还会不会说「拿不到输入」** | ✅ 不会了。prd 节点第一个动作就是 `Read` 那条绝对路径,随后自评「BRD 完整(5 条 in_scope / 7 条 out_of_scope / 5 条可判定度量),无需打回」,`attempt-0.json` 记 `verdict:"produced"` + `brd_in_scope_covered:"5/5"`。节点 **success**,`fail_reason=null` | | |
| 362 | +| 四环(产生→传递→落库→读回) | ✅ 真 CLI 下逐环有物证:agent 写 JSON → 引擎 `createWorkGraph` → `graph_edge_contracts` 3 行 + `graph_files` 4 行 → 提示词里的绝对路径 | | |
| 363 | + | |
| 364 | +**同轮暴露的两条不对称**(都不是本轮改动引入的): | |
| 365 | + | |
| 366 | +1. **落库通道走的是降级路径,不是 REST 端点**。`POST /api/company/:id/work-graphs` | |
| 367 | + **实际不存在**(真跑实测 404,全仓库无该路由)。规划 agent 按 `agent.md` 的降级约定把图 JSON | |
| 368 | + 写到 `workspace/workgraph-<taskId>.json`、由引擎接手落库 —— 契约在这条路上**没丢** | |
| 369 | + (`normalizeContracts` 在 `work-graph-db.normalizeEdges` 里,与入口无关)。 | |
| 370 | + 但模板里那段 curl 是**必然 404** 的死路径,每次真跑都要浪费 agent 一轮试探。 | |
| 371 | +2. **org 层的声明侧实际零产出**。`writeGraph → declareContracts` 代码在,但 | |
| 372 | + `orggraph-planning/agent.md` **一个字都没提**数据契约(`grep -c 'produces\|数据契约'` = 0), | |
| 373 | + 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。 | |
| 374 | + org 层这一半仍是「实现在、无真实调用方」。 | |
| 375 | + | |
| 348 | 376 | #### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地) |
| 349 | 377 | |
| 350 | 378 | 此前 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 第 2 个 tab 把 R-4 落成了 |
| ... | ... | @@ -359,7 +387,7 @@ R-19 之后这两件事有实体(`graph_edge_contracts` × `graph_files`), |
| 359 | 387 | | **出边契约** · 我的产出给了谁 | 每条 forward 出边一组 kind 药丸,三态:我产的 ✓ / 索引里有但不是我产的(标「由 X 产出」,合法,引擎按 task+kind 检索)/ 整个 task 都没有实例 ⚠️(= R-19.4 那类跑之前就能看出来的图结构错误) | ✅ 同上 | |
| 360 | 388 | | **画布上的边** | 只有**声明过契约**的边多画一枚小药丸(必需的那个 kind 占位、其余折 `+N`、全量与 `?`=可选进 `<title>`);`GAP_X` 只有 52px,第二个标签就会压线,所以克制到一枚 | ✅ `WorkGraphCanvas.vue` | |
| 361 | 389 | | **tab 徽标** | 必需输入解析不到的条数标成「输入输出 ⚠️N」,不用点进去才发现 | ✅ | |
| 362 | -| **前置:路由字段** | `GET /api/graph-runs/:id` 要补三个字段:`companyRoot`(= `companyRootDir(run.company_id)`,库里只存相对路径)、`taskFiles`(= `findFiles({companyId, taskId})`)、`edges[].contracts`(= `edgeContracts(layer, e.id)`,`readDefGraph` 已回 `edge.id`)。三者都是现成导出函数 | ❌ **未接**:缺字段时上述三块**整块降级**并点名缺哪一个,不做任何推断(推断出来的数据流正是第四次真跑栽的坑) | | |
| 390 | +| **前置:路由字段** | `GET /api/graph-runs/:id` 要补三个字段:`companyRoot`(= `companyRootDir(run.company_id)`,库里只存相对路径)、`taskFiles`(= `findFiles({companyId, taskId})`)、`edges[].contracts`(= `edgeContracts(layer, e.id)`,`readDefGraph` 已回 `edge.id`)。三者都是现成导出函数 | ✅ **已接**(`1ea9efa`)。缺字段时的降级行为保留:三块**整块降级**并点名缺哪一个,不做任何推断(推断出来的数据流正是第四次真跑栽的坑)。⚠️ **降级分支已被真跑实测过、接通后的正常分支还没有**:第五次真跑用的是 `1ea9efa` 之前的构建,那时节点详情「输入输出」tab 只显示降级横幅「产物索引未随响应下发…`GET /api/graph-runs/:id` 还没返回 `companyRoot` / `taskFiles` / `edges[].contracts`」,画布 forward 边上也没有契约药丸——即当时 R-19 的数据通路**引擎侧已通、控制台侧看不见**。接上之后界面能否真的显示解析出的绝对路径,**留待下一次真跑复验** | | |
| 363 | 391 | | `graph_edge_contracts.role` | `produces` / `needs` 的非对称在 `declareEdgeContract` 处被丢掉(表里没有 role 列),落库后两者不可区分 | ❌ 未落库:所以「这条边声明的 kind 有没有实例」只能看索引本身,而不能按 role 判定谁该产它 | |
| 364 | 392 | |
| 365 | 393 | --- | ... | ... |
server/services/graph-engine.js
| ... | ... | @@ -3084,20 +3084,43 @@ function indexNodeOutputs(run, def, nodeRun) { |
| 3084 | 3084 | } catch { /* 契约查不到就只靠文件名 */ } |
| 3085 | 3085 | |
| 3086 | 3086 | const companyRoot = companyRootDir(run.company_id); |
| 3087 | - for (const f of files) { | |
| 3088 | - // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物 | |
| 3089 | - if (/^attempt-\d+\.json$/.test(f)) continue; | |
| 3090 | - const abs = path.join(dir, f); | |
| 3087 | + | |
| 3088 | + // ── 第一遍:按文件名认领 kind | |
| 3089 | + // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物 | |
| 3090 | + const candidates = files.filter((f) => !/^attempt-\d+\.json$/.test(f)); | |
| 3091 | + const assigned = candidates.map((f) => { | |
| 3091 | 3092 | const base = f.replace(/\.[^.]+$/, '').toLowerCase(); |
| 3092 | 3093 | // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd |
| 3093 | 3094 | const kind = declared.find((k) => base === k || base.includes(k)) |
| 3094 | - || (KNOWN_FILE_KINDS.includes(base) ? base : 'artifact'); | |
| 3095 | + || (KNOWN_FILE_KINDS.includes(base) ? base : null); | |
| 3096 | + return { file: f, kind }; | |
| 3097 | + }); | |
| 3098 | + | |
| 3099 | + // ── 第二遍:文件名认不出来时,靠**声明**认领 | |
| 3100 | + // | |
| 3101 | + // 第五次真跑里这一步成立纯属侥幸:brd 节点恰好把文件命名成 `brd.md`。同一个本体 | |
| 3102 | + // 换写 `业务需求文档.md` 就会落成 kind=artifact,下游入边声明的 brd 查不到实例, | |
| 3103 | + // 提示词退化成「⚠️ 索引里查不到实例」—— 刚关掉的那个断点换个形式重现。 | |
| 3104 | + // agent 给文件起中文名 / 加前缀是完全正常的行为,不该由它决定索引对不对。 | |
| 3105 | + // | |
| 3106 | + // 只在**一对一无歧义**时认领:还差一个 kind 没人产、正好还剩一个认不出的文件。 | |
| 3107 | + // ponytail: 多个未认领 kind 配多个未认领文件时不猜(猜错比留 artifact 更坏 —— | |
| 3108 | + // 错的 kind 会让下游读到一份不是它要的文件,而 artifact 只是查不到)。 | |
| 3109 | + // 升级路径:让 agent 在 attempt-*.json 里自报 {kind: 文件名} 的映射,产出侧不再靠推断。 | |
| 3110 | + const unmatchedKinds = declared.filter((k) => !assigned.some((a) => a.kind === k)); | |
| 3111 | + const unclaimed = assigned.filter((a) => a.kind === null); | |
| 3112 | + if (unmatchedKinds.length === 1 && unclaimed.length === 1) { | |
| 3113 | + unclaimed[0].kind = unmatchedKinds[0]; | |
| 3114 | + } | |
| 3115 | + | |
| 3116 | + for (const { file: f, kind } of assigned) { | |
| 3117 | + const abs = path.join(dir, f); | |
| 3095 | 3118 | try { |
| 3096 | 3119 | recordFile({ |
| 3097 | 3120 | companyId: run.company_id, |
| 3098 | 3121 | taskId: run.task_id, |
| 3099 | 3122 | relPath: path.relative(companyRoot, abs), |
| 3100 | - kind, | |
| 3123 | + kind: kind || 'artifact', | |
| 3101 | 3124 | runId: run.id, |
| 3102 | 3125 | nodeRunId: nodeRun.id, |
| 3103 | 3126 | }); |
| ... | ... | @@ -3149,9 +3172,24 @@ function describeInputs(ctx, def) { |
| 3149 | 3172 | else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`); |
| 3150 | 3173 | } |
| 3151 | 3174 | } |
| 3175 | + // 任务级共享按 kind 去重,只列最新的那一份(findFiles 是 created_at 升序,所以后写的覆盖前写的 | |
| 3176 | + // —— 与上面沿边那一半的 byKind.set 同一个口径)。 | |
| 3177 | + // | |
| 3178 | + // 不去重的后果在第五次真跑里已经出现:brd 与 prd 两个节点各写了一份 contract.json, | |
| 3179 | + // 都是 kind=contract,于是往后每个节点的提示词里「contract(任务级共享)」逐个累加。 | |
| 3180 | + // 节点一多就是噪声,而且 agent 无从判断该信哪一份 —— 给它两个同名同类的路径, | |
| 3181 | + // 等于把一个我们自己都没定的选择推给它。 | |
| 3182 | + // ponytail: 「最新的赢」是约定而非事实推导。根因是节点私有目录里的文件也会按文件名 | |
| 3183 | + // 落成 task-shared 的 kind;真正的修法是任务级产物只认 tasks/{taskId}/ 下的那份 | |
| 3184 | + // (R-19.3 的产物区),但那会改掉「节点私有目录写 acceptance 也算共享」这条现有语义, | |
| 3185 | + // 归属 R-9.3 非相邻依赖那条线一起定。 | |
| 3186 | + const sharedByKind = new Map(); | |
| 3152 | 3187 | for (const f of shared) { |
| 3153 | 3188 | if (declared.includes(f.kind)) continue; // 已在上面列过 |
| 3154 | - lines.push(`- ${f.kind}(任务级共享):${path.join(companyRootDir(ctx.companyId), f.rel_path)}`); | |
| 3189 | + sharedByKind.set(f.kind, f); | |
| 3190 | + } | |
| 3191 | + for (const [kind, f] of sharedByKind) { | |
| 3192 | + lines.push(`- ${kind}(任务级共享):${path.join(companyRootDir(ctx.companyId), f.rel_path)}`); | |
| 3155 | 3193 | } |
| 3156 | 3194 | |
| 3157 | 3195 | if (!lines.length) return ''; | ... | ... |
server/tests/unit/graph-files-index.test.js
| ... | ... | @@ -329,4 +329,102 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { |
| 329 | 329 | expect(rebuilt[0].kind).toBe('artifact'); // 重建恢复不了语义,只恢复位置 |
| 330 | 330 | expect(rebuilt[0].rel_path).toBe(wdb.getRunState(b.runId).nodes[0].outputRef); |
| 331 | 331 | }); |
| 332 | + | |
| 333 | + // ── ⑦ 文件名认不出 kind 时靠声明认领 ──────────────────────── | |
| 334 | + // | |
| 335 | + // 第五次真 CLI 冒烟里这一环成立**纯属侥幸**:brd 节点恰好把文件命名成 `brd.md`。 | |
| 336 | + // agent 给产物起中文名是完全正常的行为,不该由文件名决定索引对不对。 | |
| 337 | + | |
| 338 | + it('⑦ 文件名认不出 kind 时按出边声明认领(一对一无歧义),下游照样拿到路径', async () => { | |
| 339 | + const companyId = 'co-files-claim'; | |
| 340 | + const taskId = 'task-claim'; | |
| 341 | + const b = buildRun({ | |
| 342 | + companyId, taskId, | |
| 343 | + nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }], | |
| 344 | + edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }], | |
| 345 | + }); | |
| 346 | + wdb.declareEdgeContract({ | |
| 347 | + edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd', | |
| 348 | + }); | |
| 349 | + | |
| 350 | + const tasks = await advance(b.runId, 2, { | |
| 351 | + writes: { | |
| 352 | + 'brd-writer': { | |
| 353 | + // 文件名里一个 "brd" 字都没有 —— 老逻辑会落成 kind=artifact | |
| 354 | + '业务需求文档.md': '# 业务需求', | |
| 355 | + // 同目录还有一个能按文件名认出来的(真跑里就是这个形状), | |
| 356 | + // 它必须先被 contract 认领掉,否则「还剩一个认不出的文件」不成立 | |
| 357 | + 'contract.json': JSON.stringify({ outputs: [] }), | |
| 358 | + 'attempt-1.json': JSON.stringify({ fail_reason: null }), | |
| 359 | + }, | |
| 360 | + }, | |
| 361 | + }); | |
| 362 | + | |
| 363 | + const rows = allFiles(companyId); | |
| 364 | + const byKind = new Map(rows.map((r) => [r.kind, r])); | |
| 365 | + expect([...byKind.keys()].sort()).toEqual(['brd', 'contract']); | |
| 366 | + expect(path.basename(byKind.get('brd').rel_path)).toBe('业务需求文档.md'); | |
| 367 | + | |
| 368 | + // 真正要防的是下游拿不到输入 —— 落成 artifact 时这一行会变成 ⚠️ | |
| 369 | + const prompt = tasks.get('prd-writer'); | |
| 370 | + expect(prompt).toContain(`- brd:${path.join(companyRoot(companyId), byKind.get('brd').rel_path)}`); | |
| 371 | + expect(prompt).not.toContain('⚠️ 上游声明会产出'); | |
| 372 | + }); | |
| 373 | + | |
| 374 | + it('⑦ 有歧义时不猜:两个认不出的文件配一个声明 → 都落 artifact', async () => { | |
| 375 | + const companyId = 'co-files-ambiguous'; | |
| 376 | + const taskId = 'task-ambiguous'; | |
| 377 | + const b = buildRun({ | |
| 378 | + companyId, taskId, | |
| 379 | + nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }], | |
| 380 | + edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }], | |
| 381 | + }); | |
| 382 | + wdb.declareEdgeContract({ | |
| 383 | + edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd', | |
| 384 | + }); | |
| 385 | + | |
| 386 | + const tasks = await advance(b.runId, 2, { | |
| 387 | + writes: { 'brd-writer': { '文档一.md': 'a', '文档二.md': 'b' } }, | |
| 388 | + }); | |
| 389 | + | |
| 390 | + // 猜错比留 artifact 更坏:错的 kind 会让下游读到一份不是它要的文件, | |
| 391 | + // 而 artifact 只是查不到 —— 后者会如实报 ⚠️,前者是静默的错。 | |
| 392 | + const rows = allFiles(companyId); | |
| 393 | + expect(rows.map((r) => r.kind)).toEqual(['artifact', 'artifact']); | |
| 394 | + expect(tasks.get('prd-writer')).toContain('- brd:⚠️'); | |
| 395 | + }); | |
| 396 | + | |
| 397 | + // ── ⑧ 任务级共享按 kind 去重 ──────────────────────────────── | |
| 398 | + | |
| 399 | + it('⑧ 两个节点各写一份同 kind 的任务级产物:提示词里只列最新那一份,不逐个累加', async () => { | |
| 400 | + const companyId = 'co-files-dedupe'; | |
| 401 | + const taskId = 'task-dedupe'; | |
| 402 | + const b = buildRun({ | |
| 403 | + companyId, taskId, | |
| 404 | + nodes: [{ nodeName: 'first' }, { nodeName: 'second' }, { nodeName: 'third' }], | |
| 405 | + edges: [ | |
| 406 | + { from: 'first', to: 'second', condition: 'success' }, | |
| 407 | + { from: 'second', to: 'third', condition: 'success' }, | |
| 408 | + ], | |
| 409 | + }); | |
| 410 | + | |
| 411 | + // 真跑里的形状:brd 与 prd 两个节点各写了一份 contract.json,都是 kind=contract | |
| 412 | + const tasks = await advance(b.runId, 3, { | |
| 413 | + writes: { | |
| 414 | + first: { 'contract.json': JSON.stringify({ v: 1 }) }, | |
| 415 | + second: { 'contract.json': JSON.stringify({ v: 2 }) }, | |
| 416 | + }, | |
| 417 | + }); | |
| 418 | + | |
| 419 | + // 两份都在索引里(各自路径不同,都是当前版本)——去重只发生在拼提示词那一步 | |
| 420 | + const rows = allFiles(companyId).filter((r) => r.kind === 'contract'); | |
| 421 | + expect(rows).toHaveLength(2); | |
| 422 | + | |
| 423 | + const prompt = tasks.get('third'); | |
| 424 | + const listed = prompt.split('\n').filter((l) => l.includes('(任务级共享)')); | |
| 425 | + expect(listed).toHaveLength(1); | |
| 426 | + // 最新的赢(findFiles 是 created_at 升序,与沿边那一半的 byKind.set 同口径) | |
| 427 | + expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path)); | |
| 428 | + expect(listed[0]).not.toContain(rows[0].rel_path); | |
| 429 | + }); | |
| 332 | 430 | }); | ... | ... |