Commit 2b77f8c9faaa1da7fac61a3398979430823febc9

Authored by yibo
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>
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(&#39;R-19 产物索引:graph_files / graph_edge_contracts&#39;, () =&gt; {
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 });
... ...