diff --git a/docs/todo/system-design/27-p4-parallel-and-artifacts.md b/docs/todo/system-design/27-p4-parallel-and-artifacts.md
index fbfc807..5a638d9 100644
--- a/docs/todo/system-design/27-p4-parallel-and-artifacts.md
+++ b/docs/todo/system-design/27-p4-parallel-and-artifacts.md
@@ -345,6 +345,34 @@ CREATE TABLE IF NOT EXISTS graph_edge_contracts (
**尚未接通的一环**:`system/work-graph-templates/*.json` 四张骨架的边**还没有** `produces` 声明,
所以「照抄骨架」得到的图不传数据,全靠规划 agent 自己按约束九补。骨架补声明是下一步。
+#### 第五次真 CLI 冒烟:第四次那个断点复验通过(2026-08-17,未改一行代码)
+
+**跑法**:主仓库 vite(5173)+ worktree `wf_55ba803e-5ac-6` 的真 CLI 服务(3010,`data/` 独立,
+`LEMONBOSS_DISABLE_WATCHDOG=1`)。公司 `r19smoke`,手动模式,预算 `total_agent_calls` 实跑 **3/15**。
+**被测版本 = `925d9db` + 当时工作树里未提交的 R-19.A 改动**(这批改动随后进了 `1ea9efa`);
+`1ea9efa` 里新加的 role 列与控制台三字段**不在**本轮被测范围内。
+
+| 验收点 | 结果 |
+|---|---|
+| 建公司 → `orggraph-planning` 真跑落图自雇 | ✅ 未被打破。`org-analyst` / `org-designer` / `org-validator` 三节点 + 正向边 + 两条 backward fail 边;规划本体没混进 org 节点 |
+| **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` 第一次有真实调用方写出真实行 |
+| **落库侧:节点产出进不进索引** | ✅ 成立。`org-analyst-brd` success 后 `graph_files` 两行:`brd.md`→`kind=brd`(**按出边声明定的**)、`contract.json`→`kind=contract`(按文件名兜底) |
+| **读回侧:下游提示词里有没有上游产物的真实绝对路径**(本轮核心) | ✅ **成立,断点关闭**。`org-analyst-prd`(`prd-planning`)的真实提示词里出现:
`[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]`
`- brd:…/runs/run_fc7e9b8a29d5/nodes/rn_2e8dbb1c6ed7/brd.md`
`- contract(任务级共享):…/rn_2e8dbb1c6ed7/contract.json`
对照第四次真跑同一个本体的提示词:`[上游输入` **一次都没出现过** |
+| **下游还会不会说「拿不到输入」** | ✅ 不会了。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` |
+| 四环(产生→传递→落库→读回) | ✅ 真 CLI 下逐环有物证:agent 写 JSON → 引擎 `createWorkGraph` → `graph_edge_contracts` 3 行 + `graph_files` 4 行 → 提示词里的绝对路径 |
+
+**同轮暴露的两条不对称**(都不是本轮改动引入的):
+
+1. **落库通道走的是降级路径,不是 REST 端点**。`POST /api/company/:id/work-graphs`
+ **实际不存在**(真跑实测 404,全仓库无该路由)。规划 agent 按 `agent.md` 的降级约定把图 JSON
+ 写到 `workspace/workgraph-.json`、由引擎接手落库 —— 契约在这条路上**没丢**
+ (`normalizeContracts` 在 `work-graph-db.normalizeEdges` 里,与入口无关)。
+ 但模板里那段 curl 是**必然 404** 的死路径,每次真跑都要浪费 agent 一轮试探。
+2. **org 层的声明侧实际零产出**。`writeGraph → declareContracts` 代码在,但
+ `orggraph-planning/agent.md` **一个字都没提**数据契约(`grep -c 'produces\|数据契约'` = 0),
+ 所以真跑后 `graph_edge_contracts` 里 `edge_layer='org'` **0 行**。
+ org 层这一半仍是「实现在、无真实调用方」。
+
#### 控制台读回端(R19-C:R-4「边的输入输出」的字面落地)
此前 [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`),
| **出边契约** · 我的产出给了谁 | 每条 forward 出边一组 kind 药丸,三态:我产的 ✓ / 索引里有但不是我产的(标「由 X 产出」,合法,引擎按 task+kind 检索)/ 整个 task 都没有实例 ⚠️(= R-19.4 那类跑之前就能看出来的图结构错误) | ✅ 同上 |
| **画布上的边** | 只有**声明过契约**的边多画一枚小药丸(必需的那个 kind 占位、其余折 `+N`、全量与 `?`=可选进 ``);`GAP_X` 只有 52px,第二个标签就会压线,所以克制到一枚 | ✅ `WorkGraphCanvas.vue` |
| **tab 徽标** | 必需输入解析不到的条数标成「输入输出 ⚠️N」,不用点进去才发现 | ✅ |
-| **前置:路由字段** | `GET /api/graph-runs/:id` 要补三个字段:`companyRoot`(= `companyRootDir(run.company_id)`,库里只存相对路径)、`taskFiles`(= `findFiles({companyId, taskId})`)、`edges[].contracts`(= `edgeContracts(layer, e.id)`,`readDefGraph` 已回 `edge.id`)。三者都是现成导出函数 | ❌ **未接**:缺字段时上述三块**整块降级**并点名缺哪一个,不做任何推断(推断出来的数据流正是第四次真跑栽的坑) |
+| **前置:路由字段** | `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 的数据通路**引擎侧已通、控制台侧看不见**。接上之后界面能否真的显示解析出的绝对路径,**留待下一次真跑复验** |
| `graph_edge_contracts.role` | `produces` / `needs` 的非对称在 `declareEdgeContract` 处被丢掉(表里没有 role 列),落库后两者不可区分 | ❌ 未落库:所以「这条边声明的 kind 有没有实例」只能看索引本身,而不能按 role 判定谁该产它 |
---
diff --git a/server/services/graph-engine.js b/server/services/graph-engine.js
index a217a21..f7087cb 100644
--- a/server/services/graph-engine.js
+++ b/server/services/graph-engine.js
@@ -3084,20 +3084,43 @@ function indexNodeOutputs(run, def, nodeRun) {
} catch { /* 契约查不到就只靠文件名 */ }
const companyRoot = companyRootDir(run.company_id);
- for (const f of files) {
- // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物
- if (/^attempt-\d+\.json$/.test(f)) continue;
- const abs = path.join(dir, f);
+
+ // ── 第一遍:按文件名认领 kind
+ // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物
+ const candidates = files.filter((f) => !/^attempt-\d+\.json$/.test(f));
+ const assigned = candidates.map((f) => {
const base = f.replace(/\.[^.]+$/, '').toLowerCase();
// 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd
const kind = declared.find((k) => base === k || base.includes(k))
- || (KNOWN_FILE_KINDS.includes(base) ? base : 'artifact');
+ || (KNOWN_FILE_KINDS.includes(base) ? base : null);
+ return { file: f, kind };
+ });
+
+ // ── 第二遍:文件名认不出来时,靠**声明**认领
+ //
+ // 第五次真跑里这一步成立纯属侥幸:brd 节点恰好把文件命名成 `brd.md`。同一个本体
+ // 换写 `业务需求文档.md` 就会落成 kind=artifact,下游入边声明的 brd 查不到实例,
+ // 提示词退化成「⚠️ 索引里查不到实例」—— 刚关掉的那个断点换个形式重现。
+ // agent 给文件起中文名 / 加前缀是完全正常的行为,不该由它决定索引对不对。
+ //
+ // 只在**一对一无歧义**时认领:还差一个 kind 没人产、正好还剩一个认不出的文件。
+ // ponytail: 多个未认领 kind 配多个未认领文件时不猜(猜错比留 artifact 更坏 ——
+ // 错的 kind 会让下游读到一份不是它要的文件,而 artifact 只是查不到)。
+ // 升级路径:让 agent 在 attempt-*.json 里自报 {kind: 文件名} 的映射,产出侧不再靠推断。
+ const unmatchedKinds = declared.filter((k) => !assigned.some((a) => a.kind === k));
+ const unclaimed = assigned.filter((a) => a.kind === null);
+ if (unmatchedKinds.length === 1 && unclaimed.length === 1) {
+ unclaimed[0].kind = unmatchedKinds[0];
+ }
+
+ for (const { file: f, kind } of assigned) {
+ const abs = path.join(dir, f);
try {
recordFile({
companyId: run.company_id,
taskId: run.task_id,
relPath: path.relative(companyRoot, abs),
- kind,
+ kind: kind || 'artifact',
runId: run.id,
nodeRunId: nodeRun.id,
});
@@ -3149,9 +3172,24 @@ function describeInputs(ctx, def) {
else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`);
}
}
+ // 任务级共享按 kind 去重,只列最新的那一份(findFiles 是 created_at 升序,所以后写的覆盖前写的
+ // —— 与上面沿边那一半的 byKind.set 同一个口径)。
+ //
+ // 不去重的后果在第五次真跑里已经出现:brd 与 prd 两个节点各写了一份 contract.json,
+ // 都是 kind=contract,于是往后每个节点的提示词里「contract(任务级共享)」逐个累加。
+ // 节点一多就是噪声,而且 agent 无从判断该信哪一份 —— 给它两个同名同类的路径,
+ // 等于把一个我们自己都没定的选择推给它。
+ // ponytail: 「最新的赢」是约定而非事实推导。根因是节点私有目录里的文件也会按文件名
+ // 落成 task-shared 的 kind;真正的修法是任务级产物只认 tasks/{taskId}/ 下的那份
+ // (R-19.3 的产物区),但那会改掉「节点私有目录写 acceptance 也算共享」这条现有语义,
+ // 归属 R-9.3 非相邻依赖那条线一起定。
+ const sharedByKind = new Map();
for (const f of shared) {
if (declared.includes(f.kind)) continue; // 已在上面列过
- lines.push(`- ${f.kind}(任务级共享):${path.join(companyRootDir(ctx.companyId), f.rel_path)}`);
+ sharedByKind.set(f.kind, f);
+ }
+ for (const [kind, f] of sharedByKind) {
+ lines.push(`- ${kind}(任务级共享):${path.join(companyRootDir(ctx.companyId), f.rel_path)}`);
}
if (!lines.length) return '';
diff --git a/server/tests/unit/graph-files-index.test.js b/server/tests/unit/graph-files-index.test.js
index 498c6e5..5c6431c 100644
--- a/server/tests/unit/graph-files-index.test.js
+++ b/server/tests/unit/graph-files-index.test.js
@@ -329,4 +329,102 @@ describe('R-19 产物索引:graph_files / graph_edge_contracts', () => {
expect(rebuilt[0].kind).toBe('artifact'); // 重建恢复不了语义,只恢复位置
expect(rebuilt[0].rel_path).toBe(wdb.getRunState(b.runId).nodes[0].outputRef);
});
+
+ // ── ⑦ 文件名认不出 kind 时靠声明认领 ────────────────────────
+ //
+ // 第五次真 CLI 冒烟里这一环成立**纯属侥幸**:brd 节点恰好把文件命名成 `brd.md`。
+ // agent 给产物起中文名是完全正常的行为,不该由文件名决定索引对不对。
+
+ it('⑦ 文件名认不出 kind 时按出边声明认领(一对一无歧义),下游照样拿到路径', async () => {
+ const companyId = 'co-files-claim';
+ const taskId = 'task-claim';
+ const b = buildRun({
+ companyId, taskId,
+ nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
+ edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
+ });
+ wdb.declareEdgeContract({
+ edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
+ });
+
+ const tasks = await advance(b.runId, 2, {
+ writes: {
+ 'brd-writer': {
+ // 文件名里一个 "brd" 字都没有 —— 老逻辑会落成 kind=artifact
+ '业务需求文档.md': '# 业务需求',
+ // 同目录还有一个能按文件名认出来的(真跑里就是这个形状),
+ // 它必须先被 contract 认领掉,否则「还剩一个认不出的文件」不成立
+ 'contract.json': JSON.stringify({ outputs: [] }),
+ 'attempt-1.json': JSON.stringify({ fail_reason: null }),
+ },
+ },
+ });
+
+ const rows = allFiles(companyId);
+ const byKind = new Map(rows.map((r) => [r.kind, r]));
+ expect([...byKind.keys()].sort()).toEqual(['brd', 'contract']);
+ expect(path.basename(byKind.get('brd').rel_path)).toBe('业务需求文档.md');
+
+ // 真正要防的是下游拿不到输入 —— 落成 artifact 时这一行会变成 ⚠️
+ const prompt = tasks.get('prd-writer');
+ expect(prompt).toContain(`- brd:${path.join(companyRoot(companyId), byKind.get('brd').rel_path)}`);
+ expect(prompt).not.toContain('⚠️ 上游声明会产出');
+ });
+
+ it('⑦ 有歧义时不猜:两个认不出的文件配一个声明 → 都落 artifact', async () => {
+ const companyId = 'co-files-ambiguous';
+ const taskId = 'task-ambiguous';
+ const b = buildRun({
+ companyId, taskId,
+ nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
+ edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
+ });
+ wdb.declareEdgeContract({
+ edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
+ });
+
+ const tasks = await advance(b.runId, 2, {
+ writes: { 'brd-writer': { '文档一.md': 'a', '文档二.md': 'b' } },
+ });
+
+ // 猜错比留 artifact 更坏:错的 kind 会让下游读到一份不是它要的文件,
+ // 而 artifact 只是查不到 —— 后者会如实报 ⚠️,前者是静默的错。
+ const rows = allFiles(companyId);
+ expect(rows.map((r) => r.kind)).toEqual(['artifact', 'artifact']);
+ expect(tasks.get('prd-writer')).toContain('- brd:⚠️');
+ });
+
+ // ── ⑧ 任务级共享按 kind 去重 ────────────────────────────────
+
+ it('⑧ 两个节点各写一份同 kind 的任务级产物:提示词里只列最新那一份,不逐个累加', async () => {
+ const companyId = 'co-files-dedupe';
+ const taskId = 'task-dedupe';
+ const b = buildRun({
+ companyId, taskId,
+ nodes: [{ nodeName: 'first' }, { nodeName: 'second' }, { nodeName: 'third' }],
+ edges: [
+ { from: 'first', to: 'second', condition: 'success' },
+ { from: 'second', to: 'third', condition: 'success' },
+ ],
+ });
+
+ // 真跑里的形状:brd 与 prd 两个节点各写了一份 contract.json,都是 kind=contract
+ const tasks = await advance(b.runId, 3, {
+ writes: {
+ first: { 'contract.json': JSON.stringify({ v: 1 }) },
+ second: { 'contract.json': JSON.stringify({ v: 2 }) },
+ },
+ });
+
+ // 两份都在索引里(各自路径不同,都是当前版本)——去重只发生在拼提示词那一步
+ const rows = allFiles(companyId).filter((r) => r.kind === 'contract');
+ expect(rows).toHaveLength(2);
+
+ const prompt = tasks.get('third');
+ const listed = prompt.split('\n').filter((l) => l.includes('(任务级共享)'));
+ expect(listed).toHaveLength(1);
+ // 最新的赢(findFiles 是 created_at 升序,与沿边那一半的 byKind.set 同口径)
+ expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path));
+ expect(listed[0]).not.toContain(rows[0].rel_path);
+ });
});