/** * Unit Tests — R-17.1 跨层地址「哪一半是必填的」(27-P3B §四 W10) * * 起因:W10 原文(以及 validator-triager/agent.md 顶部粒度块的旧措辞)说 * 「`target_org_node` + `target_node` **两半都写**才合法,只写一半会静默退化成整张图重跑」。 * 本文件把这句话拿到引擎上实跑一遍,结论是**反的**: * * - `normalizeNodeRef` 只在 `workNode` 为空时返回 null(`orgNode` 缺省合法,写死成 null); * - `resolveCrossLayerTarget` 的第 ④ 关是 `if (ref.orgNode && ref.orgNode !== orgNodeName)` * —— `orgNode` 缺省时这一关**根本不进**,改以回退边的目标为准。 * * 所以:**`target_node` 是唯一必填的那一半**,`target_org_node` 是可选的一致性校验。 * 把「两半都要写」写进模板会诱导 agent 去编 org node 名 —— 恰是 agent.md 自己那条红线。 * * 隔离手法照抄 graph-engine.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, * agent 调用与子图规划全部注入假实现,全程不起 Claude CLI。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('R-17.1 跨层地址 · 两半的必填性(W10 事实核实)', () => { let wdb; let engine; let runsDir; const TEST_DB_PATH = '/tmp/test-graph-cross-layer-address.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-xlayer-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; }); beforeEach(() => { const db = wdb.getDb(); db.pragma('foreign_keys = OFF'); db.exec(` DELETE FROM graph_run_loop_records; DELETE FROM graph_run_nodes; DELETE FROM graph_runs; DELETE FROM work_graph_edges; DELETE FROM work_graph_nodes; DELETE FROM work_graphs; DELETE FROM org_graph_edges; DELETE FROM org_graph_nodes; DELETE FROM system_agents; `); db.pragma('foreign_keys = ON'); engine.setAgentExecutor(null); }); let seq = 0; const nextCompany = () => `xl-${++seq}`; 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); } /** org 层形状:coder / validator 都是 subgraph,validator 判失败沿 backward 边退回 coder */ function buildTwoLayerRun(companyId) { ensureAgents(['coder', 'validator']); const g = wdb.createWorkGraph({ companyId, nodes: [ { nodeName: 'coder', nodeKind: 'subgraph' }, { nodeName: 'validator', nodeKind: 'subgraph' }, ], edges: [ { from: 'coder', to: 'validator' }, { from: 'validator', to: 'coder', condition: 'fail', edgeKind: 'backward' }, ], }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'auto', }); engine.markRunReady(run.id); engine.setPlanWorkGraph(async (ctx) => { ensureAgents(['impl-a', 'impl-b', 'validator-triager']); const sub = ctx.nodeName === 'coder' ? wdb.createWorkGraph({ companyId, nodes: ['impl-a', 'impl-b'], edges: [{ from: 'impl-a', to: 'impl-b' }], }) : wdb.createWorkGraph({ companyId, nodes: ['validator-triager'], edges: [] }); expect(sub.errors || []).toEqual([]); return { workGraphId: sub.id }; }); return run.id; } function fakeExecutor(behaviors) { const calls = []; engine.setAgentExecutor(async (ctx) => { calls.push({ node: ctx.nodeName, attempt: ctx.attempt }); return behaviors[ctx.nodeName] || { status: 'success' }; }); return calls; } const nodeByName = (runId, name) => wdb.getRunState(runId).nodes.find((n) => n.name === name); // ──────────────────────────────────────────────────────────── it('只写 target_node(不写 target_org_node)→ 跨层定点重置照样生效,org 那一半以回退边为准', async () => { const companyId = nextCompany(); const runId = buildTwoLayerRun(companyId); const calls = fakeExecutor({ 'impl-a': { status: 'success' }, 'impl-b': { status: 'success' }, // ← W10 的核心:**只有 workNode**,没有 orgNode 'validator-triager': { status: 'fail', failReason: 'impl_bug', failNodeRef: { workNode: 'impl-a' }, remediationNote: 'impl-a 少处理了空输入', }, }); await engine.startRun(runId, { mode: 'auto' }).driver; const coder = nodeByName(runId, 'coder'); const inner = Object.fromEntries( wdb.getRunState(coder.childRunId).nodes.map((n) => [n.name, n]) ); // ① 地址被判为合法并落库(orgNode 显式为 null,不是把整条地址丢掉) const triager = wdb.getRunState(coder.childRunId === null ? runId : runId).nodes; expect(triager).toBeTruthy(); expect(JSON.parse(nodeByName(runId, 'validator').failNodeRef || 'null')) .toEqual({ orgNode: null, workNode: 'impl-a' }); // ② 真的做了定点重置:只有 impl-a 重跑,contract 未变的 impl-b 一次都没重跑 expect(inner['impl-a'].attempt).toBeGreaterThan(0); expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1); expect(inner['impl-b'].attempt).toBe(0); expect(calls.filter((c) => c.node === 'impl-b')).toHaveLength(1); // ③ 账本上 target_node_ref 非空 = 这一次退的是「某一个节点」, // 且 orgNode 被引擎按回退边的目标补齐成 'coder'(agent 没写、引擎自己解析出来的) const records = wdb.listLoopRecords(runId); expect(records.length).toBeGreaterThan(0); expect(JSON.parse(records[0].target_node_ref)).toMatchObject({ orgNode: 'coder', workNode: 'impl-a', childRunId: coder.childRunId, nodeRunId: inner['impl-a'].id, }); }); it('只写 target_org_node(不写 target_node)→ 整条地址作废,退化成整张 work graph 重跑', async () => { const companyId = nextCompany(); const runId = buildTwoLayerRun(companyId); const calls = fakeExecutor({ 'impl-a': { status: 'success' }, 'impl-b': { status: 'success' }, // ← 反向:只有 orgNode,没有 workNode。normalizeNodeRef 返回 null 'validator-triager': { status: 'fail', failReason: 'impl_bug', failNodeRef: { orgNode: 'coder' }, }, }); await engine.startRun(runId, { mode: 'auto' }).driver; const coder = nodeByName(runId, 'coder'); // 地址整条没落库 expect(nodeByName(runId, 'validator').failNodeRef).toBeNull(); // 整张子图重跑:两个节点都重跑了;子 run 仍然复用同一张 expect(wdb.listRunTree(runId).filter((r) => r.parent_node_run_id === coder.id)).toHaveLength(1); expect(calls.filter((c) => c.node === 'impl-a').length).toBeGreaterThan(1); expect(calls.filter((c) => c.node === 'impl-b').length).toBeGreaterThan(1); expect(wdb.listLoopRecords(runId).every((r) => r.target_node_ref === null)).toBe(true); }); // ──────────────────────────────────────────────────────────── // 模板侧:键名逐字对齐(「差一个字母就是静默失效」的防线) // ──────────────────────────────────────────────────────────── it('validator-triager 模板里的地址键名与引擎读的逐字一致,且没有近似拼写的变体', () => { const root = path.resolve(__dirname, '../../../system/agents/validator-triager'); const text = ['agent.md', 'tools.md'].map((f) => fs.readFileSync(path.join(root, f), 'utf8')).join('\n'); // 引擎 readNodeProduct 读的就是这两个顶层键(graph-engine.js:doc.target_org_node / doc.target_node) expect(text).toContain('target_org_node'); expect(text).toContain('target_node'); // 近似拼写一律不许出现:这些写法引擎一个都读不到,且失效是静默的 for (const bad of ['targetNode', 'targetOrgNode', 'target_orgnode', 'org_node_target', 'target_work_node']) { expect(text).not.toContain(bad); } // 必填性表述:模板不许再说「两半都要写才生效」(引擎侧 orgNode 缺省合法,见本文件第一条用例) expect(text).not.toContain('两个都给全'); expect(text).not.toContain('两半都写'); }); });