/** * Unit Tests — R-19 产物索引(27-P4 §4.6) * * 锁的是第四次真 CLI 冒烟那个断点的正解。当时 org-planner-prd 的归因原话是 * 「不是上游没产出,是它的产出**结构上到不了我手上**」—— 边只承载控制流 * (condition / edge_kind / fail_reason),上游 brd.md 躺在节点私有目录, * 下游提示词里没有任何可读路径。 * * 每条用例都按「产生 → 传递 → 落库 → 读回」四环走真入口: * ① 版本链 同路径二次产出标 superseded_by,不删行(落库) * ② 部分唯一索引 「同一键只有一个活的 + 留历史」这个选择本身 * ③a 沿边传递 出边声明 kind → 引擎登记实例 → 下游提示词里是绝对路径(全四环) * ③b 任务级共享 acceptance 对**非相邻**节点也可见(R-9.3 planner→(coder)→validator) * ④ 缺输入告警 声明了却查不到实例时提示词里出现 ⚠️,不静默省略(R-19.4 读回端) * ⑤ 产出侧过滤 attempt-*.json 不登记(引擎自读的归因文件);节点 fail 不登记 * ⑥ 索引重建 删掉 graph_files 后能从 graph_run_nodes 重建 * * describeInputs / indexNodeOutputs / companyRootDir **故意不导出**(graph-engine.js * module.exports 那段注释写明了口径:不为测试新开公开面),所以这里全部通过 advanceOne * 这个真入口验 —— 输入侧读 executor 收到的 ctx.task,产出侧读 graph_files 落库行。 * * 隔离手法同 graph-boundary.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, * 仓库 data/ 全程不被碰;executor 全用注入的假货,不起 Claude CLI。 * (注入 executor 还顺带关掉了边界守卫 —— boundaryGuardApplicable 只在 * agentExecutor === defaultAgentExecutor 时成立,所以这些用例不扫宿主仓库。) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('R-19 产物索引:graph_files / graph_edge_contracts', () => { let wdb; let engine; let runsDir; const TEST_DB_PATH = '/tmp/test-graph-files-index.sqlite'; beforeAll(() => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-files-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', ]) { delete require.cache[require.resolve(m)]; } wdb = require('../../services/work-graph-db'); engine = require('../../services/graph-engine'); }); afterAll(() => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_RUNS_DIR; for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { delete require.cache[require.resolve(m)]; } }); // ── 测试脚手架 ──────────────────────────────────────────────── function ensureAgents(ids) { const db = wdb.getDb(); const ts = new Date().toISOString(); const ins = db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) `); for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); } /** 建图 + 建 run + 置 ready + start,并给出「按节点名拿 defNodeId / 边 id」的取值器 */ function buildRun({ companyId, taskId, nodes, edges = [] }) { ensureAgents(nodes.map((n) => n.systemAgentId || n.nodeName)); const g = wdb.createWorkGraph({ companyId, taskId, nodes, edges }); expect(g.errors || []).toEqual([]); const def = wdb.readDefGraph('work', g.id); const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId])); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId }); engine.markRunReady(run.id); engine.startRun(run.id, { mode: 'manual' }); return { def, runId: run.id, defNodeId: (name) => byName.get(name), edgeId: (from, to) => def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id, }; } /** 节点私有产物目录 —— 与 graph-engine.nodeArtifactDir 同源(GRAPH_RUNS_DIR 覆盖分支) */ const nodeDir = (rootRunId, nodeRunId) => path.join(runsDir, rootRunId, 'nodes', nodeRunId); /** 公司根 —— 与 graph-engine.companyRootDir 同源(同一个 override 分支) */ const companyRoot = (companyId) => path.join(runsDir, '_companies', companyId); /** * 跑 n 步,收集每个节点看到的提示词。writes[nodeName] 给出这个节点要落盘的文件, * 落在它自己的私有目录里、outputRef 指向该目录 —— 与 defaultAgentExecutor 的形状一致。 */ async function advance(runId, steps, { writes = {}, status = () => 'success' } = {}) { const tasks = new Map(); engine.setAgentExecutor(async (ctx) => { tasks.set(ctx.nodeName, ctx.task); const files = writes[ctx.nodeName]; if (!files) return { status: status(ctx.nodeName) }; const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); for (const [name, body] of Object.entries(files)) { fs.writeFileSync(path.join(dir, name), body); } return { status: status(ctx.nodeName), outputRef: dir }; }); try { for (let i = 0; i < steps; i++) await engine.advanceOne(runId); } finally { engine.setAgentExecutor(null); } return tasks; } const allFiles = (companyId) => wdb.getDb() .prepare(`SELECT * FROM graph_files WHERE company_id = ? ORDER BY created_at, id`) .all(companyId); // ── ① 版本链 ───────────────────────────────────────────────── it('① 同一 (company, task, relPath) 二次产出:旧行标 superseded_by 而不是被删,findFiles 只给最新那份', () => { const companyId = 'co-files-ver'; const taskId = 'task-ver'; const rel = 'tasks/task-ver/brd.md'; const v1 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' }); const v2 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' }); expect(v2).not.toBe(v1); const rows = allFiles(companyId); // 历史不被删:两行都在 expect(rows.map((r) => r.id).sort()).toEqual([v1, v2].sort()); // 旧行指向新行 expect(rows.find((r) => r.id === v1).superseded_by).toBe(v2); // 当前版本恰好一行 expect(rows.filter((r) => r.superseded_by === null).map((r) => r.id)).toEqual([v2]); // 读回:findFiles 只返回最新那份 const found = wdb.findFiles({ companyId, taskId, kinds: ['brd'] }); expect(found.map((f) => f.id)).toEqual([v2]); }); // ── ② 部分唯一索引 ─────────────────────────────────────────── it('② 部分唯一索引:两行同路径且都 superseded_by IS NULL 被拒;历史行同路径可共存', () => { const db = wdb.getDb(); const companyId = 'co-files-idx'; const rel = 'tasks/task-idx/prd.md'; const ins = db.prepare(` INSERT INTO graph_files (id, company_id, task_id, rel_path, kind, created_at, superseded_by) VALUES (?, ?, 'task-idx', ?, 'prd', ?, ?) `); const now = new Date().toISOString(); ins.run('gf-live-1', companyId, rel, now, null); // 第二个「当前版本」必须被索引挡住 —— 否则 findFiles 会同时返回两份, // 下游拿不准该读哪个(这条锁的是「用部分唯一索引」这个选择本身) expect(() => ins.run('gf-live-2', companyId, rel, now, null)).toThrow(/UNIQUE/i); // 而历史行(superseded_by 非空)同路径可以随便共存 —— 索引是**部分**的,不是全量的 expect(() => ins.run('gf-old-1', companyId, rel, now, 'gf-live-1')).not.toThrow(); expect(() => ins.run('gf-old-2', companyId, rel, now, 'gf-live-1')).not.toThrow(); expect(allFiles(companyId).filter((r) => r.superseded_by === null)).toHaveLength(1); }); // ── ③a 沿边传递(全四环)+ ⑤ attempt-*.json 不登记 ────────── it('③a 沿边传递:出边声明 brd → 引擎登记实例 → 下游提示词里是绝对路径;attempt-*.json 不登记', async () => { const companyId = 'co-files-edge'; const taskId = 'task-edge'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }], edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }], }); // 产生:规划时就在边上声明「这条边传一份 brd」 wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd', }); const tasks = await advance(b.runId, 2, { writes: { 'brd-writer': { 'brd.md': '# BRD', // 引擎自读的归因文件,不是给下游的产物 'attempt-1.json': JSON.stringify({ fail_reason: null }), }, }, }); // 落库:只登记 brd.md,kind 取出边声明的 brd(不是兜底的 artifact) const rows = allFiles(companyId); expect(rows).toHaveLength(1); expect(rows[0].kind).toBe('brd'); expect(path.basename(rows[0].rel_path)).toBe('brd.md'); // attempt-*.json 一行都不该有 expect(rows.filter((r) => /attempt-/.test(r.rel_path))).toEqual([]); // 传递:入边契约查得到 const inbound = wdb.inboundContracts('work', b.def.edges, b.defNodeId('prd-writer')); expect(inbound.map((c) => c.kind)).toEqual(['brd']); // 读回:下游提示词里是能直接 open 的绝对路径 const abs = path.join(companyRoot(companyId), rows[0].rel_path); expect(fs.existsSync(abs)).toBe(true); const prompt = tasks.get('prd-writer'); expect(prompt).toContain('[上游输入'); expect(prompt).toContain(`- brd:${abs}`); // 查得到实例就不该出现缺输入告警(段尾那句「标 ⚠️ 的表示…」是固定说明,不算告警) expect(prompt).not.toContain('⚠️ 上游声明会产出'); // 上游自己那一步没有入边,不该凭空多一段 expect(tasks.get('brd-writer')).not.toContain('[上游输入'); }); // ── ③b 任务级共享:非相邻可见 ──────────────────────────────── it('③b 任务级共享:planner 产的 acceptance,隔着 coder 的 validator 也查得到(R-9.3 的形状)', async () => { const companyId = 'co-files-shared'; const taskId = 'task-shared'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'planner' }, { nodeName: 'coder' }, { nodeName: 'validator' }], edges: [ { from: 'planner', to: 'coder', condition: 'success' }, { from: 'coder', to: 'validator', condition: 'success' }, ], }); // 刻意**不**声明任何边契约:planner→validator 根本没有边,纯边绑定表达不了这条依赖 const tasks = await advance(b.runId, 3, { writes: { planner: { 'acceptance.json': JSON.stringify({ checks: ['a'] }) } }, }); const rows = allFiles(companyId); expect(rows).toHaveLength(1); expect(rows[0].kind).toBe('acceptance'); // 无声明时按文件名定 kind const abs = path.join(companyRoot(companyId), rows[0].rel_path); // 非相邻的 validator 拿到了路径 —— 这才是「两类数据流都要」的理由 const prompt = tasks.get('validator'); expect(prompt).toContain('[上游输入'); expect(prompt).toContain(`- acceptance(任务级共享):${abs}`); // 相邻的 coder 同样看得到(任务级共享不受相邻性限制) expect(tasks.get('coder')).toContain(abs); // planner 跑的时候索引里还没有实例,不该有段落 expect(tasks.get('planner')).not.toContain('[上游输入'); }); // ── ④ 缺输入告警(R-19.4 读回端)──────────────────────────── it('④a 声明的 produces 一个候选文件都没落盘:节点本身按 fail 结算(produces_missing),不点亮下游', async () => { // T-mt25ecbd 复现原样:brd/prd/sdd 三个 planner 节点声明了 produces 却真的什么都没写, // executor 没抛异常就被引擎当 success——这道闸门堵在这里,不再等下游发现读不到东西。 const companyId = 'co-files-missing'; const taskId = 'task-missing'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }], edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true, }); // 上游什么都不写 → 声明在、连候选文件都没有 const tasks = await advance(b.runId, 2); expect(allFiles(companyId)).toEqual([]); const state = wdb.getRunState(b.runId); const sddWriter = state.nodes.find((n) => n.name === 'sdd-writer'); expect(sddWriter.status).toBe('blocked'); // fail → 没有 fail 边 → 终态 blocked(FSM) expect(sddWriter.failReason).toBe('produces_missing'); // 上游都没能成功,下游压根不会被点亮,更谈不上跑 expect(tasks.has('impl')).toBe(false); }); it('④b 声明了 required 输入却查不到实例:提示词里出现 ⚠️,agent 判自己成功也不被引擎采信(missing_required_input)', async () => { const companyId = 'co-files-missing-needs'; const taskId = 'task-missing-needs'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }], edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true, }); // 上游写了两份跟声明的 sdd 对不上、彼此也无歧义可猜的文件(同 ⑦ 的「有歧义时不猜」口径, // 两份都落成 kind=artifact)——产出闸门只看「有没有候选文件」,不判分类对不对, // 所以 sdd-writer 正常 success,impl 照样被点亮。但 graph_files 里查不到 kind=sdd 的 // 实例——这正是 R-19.4 要报的那种「声明了却查不到」。 const tasks = await advance(b.runId, 2, { writes: { 'sdd-writer': { '文档一.md': '跟 sdd 无关', '文档二.md': '跟 sdd 也无关' } }, }); const prompt = tasks.get('impl'); expect(prompt).toContain('[上游输入'); expect(prompt).toContain('- sdd:⚠️'); expect(prompt).toContain('索引里查不到实例'); // impl 自己的 executor 判了 success(advance 默认 status: () => 'success'), // 但引擎不采信——它自己也声明了要吃 required 的 sdd,查不到实例就不算数。 const state = wdb.getRunState(b.runId); const impl = state.nodes.find((n) => n.name === 'impl'); expect(impl.status).toBe('blocked'); expect(impl.failReason).toBe('missing_required_input'); }); it('④c T-mtratkfb:只落了阶段性私有笔记(_research.md)没有真交付物 → 仍判 produces_missing,' + '不被 hasCandidates 兜底放过', async () => { // 复现 run_fe5a50b4e836:org-coder 四段式生命周期第一阶段写完 _research.md 就直接 // 上报 success,产物目录里除了这份阶段性笔记什么都没有——闸门如果把 _research.md // 也算进候选文件,这个节点会被误判「已产出」放行,下游拿到的其实是空交付。 const companyId = 'co-files-private-note'; const taskId = 'task-private-note'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'coder' }, { nodeName: 'validate' }], edges: [{ from: 'coder', to: 'validate', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('coder', 'validate'), kind: 'artifact', required: true, }); const tasks = await advance(b.runId, 2, { writes: { coder: { '_research.md': '结论:可以直接进入 PLAN,无阻塞项' } }, }); const state = wdb.getRunState(b.runId); const coder = state.nodes.find((n) => n.name === 'coder'); expect(coder.status).toBe('blocked'); expect(coder.failReason).toBe('produces_missing'); expect(tasks.has('validate')).toBe(false); }); // ── ⑤ 节点 fail 不登记 ────────────────────────────────────── it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => { const companyId = 'co-files-fail'; const taskId = 'task-fail'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'flaky' }] }); await advance(b.runId, 1, { writes: { flaky: { 'brd.md': '# 半成品' } }, status: () => 'fail', }); // 文件真的落盘了(不是没写),但索引里一行都没有 const state = wdb.getRunState(b.runId); // 图上没有任何 fail 边,失败节点最终落 blocked(fail → blocked 是 FSM 的终态转换) expect(state.nodes[0].status).toBe('blocked'); expect(fs.existsSync(path.join(state.nodes[0].outputRef, 'brd.md'))).toBe(true); expect(allFiles(companyId)).toEqual([]); }); // ── ⑥ 索引重建 ───────────────────────────────────────────── it('⑥ graph_files 被清掉后能从 graph_run_nodes 重建(有损:kind 退成 artifact)', async () => { const companyId = 'co-files-rebuild'; const taskId = 'task-rebuild'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'brd-writer' }] }); await advance(b.runId, 1, { writes: { 'brd-writer': { 'brd.md': '# BRD' } } }); expect(allFiles(companyId)).toHaveLength(1); // 索引坏了 / 被清空 wdb.getDb().prepare(`DELETE FROM graph_files WHERE company_id = ?`).run(companyId); expect(wdb.findFiles({ companyId, taskId })).toEqual([]); // 节点行还在 → 能重建出「这个节点产出过哪个目录」 expect(wdb.rebuildFilesFromNodes(companyId)).toBe(1); const rebuilt = wdb.findFiles({ companyId, taskId }); expect(rebuilt).toHaveLength(1); 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); }); // ── ⑨ 出边上的 needs 不算本节点的产出 ────────────────────────── // // Chrome 走查里实测到的:p-prd 的出边写 produces['prd'] + needs['brd'](中继声明), // 收集「本节点产出哪些 kind」时若不按 role 过滤,未认领 kind 数就是 2 而不是 1, // ⑦ 那条一对一认领当场失效 —— 文件退成 artifact、下游拿不到。 it('⑨ 出边同时有 produces 与 needs:认领只看 produces,中继的 needs 不参与', async () => { const companyId = 'co-files-role'; const taskId = 'task-role'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'w-brd' }, { nodeName: 'w-prd' }, { nodeName: 'w-val' }], edges: [ { from: 'w-brd', to: 'w-prd', condition: 'success' }, { from: 'w-prd', to: 'w-val', condition: 'success' }, ], }); // w-prd 的出边:它产 prd;同时声明 w-val 还要吃 brd(产出方是 w-brd,不是紧邻上游) const e2 = b.edgeId('w-prd', 'w-val'); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'prd', role: 'produces' }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'brd', role: 'needs' }); await advance(b.runId, 2, { writes: { // 文件名认不出 kind → 只能靠声明认领;needs 混进来的话这里会退成 artifact 'w-prd': { '产品需求文档.md': '# 产品需求' }, }, }); const rows = allFiles(companyId); expect(rows).toHaveLength(1); expect(rows[0].kind).toBe('prd'); }); it('⑨ 出边只有 needs 时不认领(本节点什么都没声明要产)', async () => { const companyId = 'co-files-only-needs'; const taskId = 'task-only-needs'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'n-a' }, { nodeName: 'n-b' }], edges: [{ from: 'n-a', to: 'n-b', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('n-a', 'n-b'), kind: 'sdd', role: 'needs', }); await advance(b.runId, 1, { writes: { 'n-a': { '随便写的.md': 'x' } } }); // needs 不是产出声明,不该被拿来认领 —— 认领了就是把「下游要吃 sdd」 // 误读成「我产 sdd」,下游读到一份不是它要的文件 const rows = allFiles(companyId); expect(rows).toHaveLength(1); expect(rows[0].kind).toBe('artifact'); }); // ── ⑩ 引擎自己的簿记文件不进索引 ──────────────────────────────── it('⑩ contract.prev.json 不进索引(retryNode 的契约快照,不是节点交付物)', async () => { const companyId = 'co-files-snapshot'; const taskId = 'task-snapshot'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 's-brd' }] }); await advance(b.runId, 1, { writes: { 's-brd': { 'brd.md': '# BRD', // 引擎为算 contract_hash 留的上一轮快照。漏掉它的话, // 每次人工重试都往 graph_files 添一行 kind=artifact 的噪声 'contract.prev.json': JSON.stringify({ outputs: [] }), 'attempt-1.json': JSON.stringify({ fail_reason: null }), }, }, }); const rows = allFiles(companyId); expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']); }); // ── ⑪ 33 号文档:produces 声明 required:false 时不拦下游 ───────── // // 复现 executor.json 里 test→validator 那条边的真实形状:test 出边同时声明 // produces:[{kind:'log', required:false}](自己产出的记录,写作文档但不强制) // 与 needs:[{kind:'artifact', required:true}](中继声明,产出方是更早的 impl)。 // test 实际写的文件(_plan.md/_research.md 之类)从来认不出 kind=log —— // 这在真实回归任务 T-mt2r6fz0-125b 里复现过:validator 被 missing_required_input // 反复误判 blocked,而它自己判定 passed。required:false 让这条声明只留文档价值, // 不再是硬闸门;真正必需的 needs:artifact 依旧受硬闸门保护(不因这次修复被削弱)。 it('⑪ produces 声明 required:false 时,即使这个 kind 从未被索引到,也不拦下游成功', async () => { const companyId = 'co-files-optional-produces'; const taskId = 'task-optional-produces'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-test' }, { nodeName: 'w-validator' }], edges: [ { from: 'w-impl', to: 'w-test', condition: 'success' }, { from: 'w-test', to: 'w-validator', condition: 'success' }, ], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-test'), kind: 'artifact', role: 'produces', }); const e2 = b.edgeId('w-test', 'w-validator'); // 与 executor.json 修复后的形状逐字对应 wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'log', role: 'produces', required: false }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'artifact', role: 'needs', required: true }); await advance(b.runId, 3, { writes: { 'w-impl': { 'impl.md': '# 实现产物' }, // test 写的文件跟 "log" 毫无关联、彼此也无歧义可猜(同 ④b 的口径), // 这个 kind 永远不会被索引到——正是修复要覆盖的那种情形 'w-test': { '_plan.md': '计划', '_research.md': '调研' }, }, }); const state = wdb.getRunState(b.runId); const validator = state.nodes.find((n) => n.name === 'w-validator'); // 关键断言:log 从未被索引,但它是 required:false,不该拦下游 expect(allFiles(companyId).some((f) => f.kind === 'log')).toBe(false); expect(validator.status).toBe('success'); expect(validator.failReason).toBeFalsy(); }); // ── ⑫⑬ R-19 升级:contract.json 的 produces 字段(39 号文档 §7.2 缺陷 5 / §7.4 追记)── // // 覆盖「产出写在项目根,节点私有目录文件名扫描天生看不到」这个真实事故:agent 显式声明 // kind → 绝对路径,引擎信这份声明(校验过再信),不再只靠猜文件名。 it('⑫ 显式声明 produces:文件名完全猜不出 kind,靠 contract.json 的声明照样正确索引', async () => { const companyId = 'co-files-declared'; const taskId = 'task-declared'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-next' }], edges: [{ from: 'w-impl', to: 'w-next', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-next'), kind: 'artifact', required: true, }); engine.setAgentExecutor(async (ctx) => { if (ctx.nodeName !== 'w-impl') return { status: 'success' }; const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); // 文件名跟任何已知 kind 都不沾边,也没有任何可猜的痕迹 const weirdPath = path.join(dir, 'a-completely-unguessable-name.bin'); fs.writeFileSync(weirdPath, 'payload'); fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({ produces: { artifact: weirdPath }, })); return { status: 'success', outputRef: dir }; }); try { await engine.advanceOne(b.runId); await engine.advanceOne(b.runId); } finally { engine.setAgentExecutor(null); } const rows = allFiles(companyId).filter((r) => /a-completely-unguessable-name/.test(r.rel_path)); expect(rows).toHaveLength(1); expect(rows[0].kind).toBe('artifact'); const state = wdb.getRunState(b.runId); expect(state.nodes.find((n) => n.name === 'w-impl').status).toBe('success'); expect(state.nodes.find((n) => n.name === 'w-next').status).toBe('success'); }); it('⑬ 声明的 produces 路径越界(指向别处)或文件不存在:整个 kind 判「没产出」,' + '不退回文件名兜底扫描,节点按 produces_missing 结算', async () => { const companyId = 'co-files-declared-invalid'; const taskId = 'task-declared-invalid'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-next' }], edges: [{ from: 'w-impl', to: 'w-next', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-next'), kind: 'artifact', required: true, }); const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-files-outside-')); const outsidePath = path.join(outsideDir, 'not-allowed.md'); fs.writeFileSync(outsidePath, '# 越界文件,真实存在但不在允许写入的两处位置内'); engine.setAgentExecutor(async (ctx) => { if (ctx.nodeName !== 'w-impl') return { status: 'success' }; const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); // 声明本身合法存在,但指向的路径既不在本节点私有目录也不在项目根——越界 fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({ produces: { artifact: outsidePath }, })); return { status: 'success', outputRef: dir }; }); try { await engine.advanceOne(b.runId); } finally { engine.setAgentExecutor(null); } const state = wdb.getRunState(b.runId); const impl = state.nodes.find((n) => n.name === 'w-impl'); // 关键断言:不静默放过——声明校验不过就判 produces_missing,不是悄悄采信这份越界声明。 // 终态是 blocked 不是 fail:本用例的图只画了 success 边,没有 fail 边可路由, // 失败结算完找不到出边正是 blocked 的定义(同真实案例 org-hunt 的 failReason+blocked 组合)。 expect(impl.status).toBe('blocked'); expect(impl.failReason).toBe('produces_missing'); expect(allFiles(companyId).some((f) => f.rel_path.includes('not-allowed'))).toBe(false); try { fs.rmSync(outsideDir, { recursive: true, force: true }); } catch {} }); it('⑭ 44 号文档修复:声明的 produces 指向 root run 共享 artifacts/ 目录(不是节点私有目录/项目根)' + '——不再判越界,节点按 success 结算', async () => { const companyId = 'co-files-shared-artifacts'; const taskId = 'task-shared-artifacts'; const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'w-benchmark' }, { nodeName: 'w-validator' }], edges: [{ from: 'w-benchmark', to: 'w-validator', condition: 'success' }], }); wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: b.edgeId('w-benchmark', 'w-validator'), kind: 'acceptance', required: true, }); engine.setAgentExecutor(async (ctx) => { if (ctx.nodeName !== 'w-benchmark') return { status: 'success' }; // 产物写进共享区,不是节点私有目录——goal-benchmark 真实的写法 const sharedDir = path.join(runsDir, ctx.run.root_run_id, 'artifacts'); fs.mkdirSync(sharedDir, { recursive: true }); const sharedPath = path.join(sharedDir, 'goal-benchmark.json'); fs.writeFileSync(sharedPath, JSON.stringify({ dimensions: [] })); const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); // 声明指向共享区绝对路径——44 号文档修复前,这条声明会被判「越界」 fs.writeFileSync(path.join(dir, 'contract.json'), JSON.stringify({ produces: { acceptance: sharedPath }, })); return { status: 'success', outputRef: dir }; }); try { await engine.advanceOne(b.runId); await engine.advanceOne(b.runId); } finally { engine.setAgentExecutor(null); } const state = wdb.getRunState(b.runId); const benchmark = state.nodes.find((n) => n.name === 'w-benchmark'); expect(benchmark.failReason).toBeFalsy(); expect(benchmark.status).toBe('success'); expect(state.nodes.find((n) => n.name === 'w-validator').status).toBe('success'); }); });