/** * Unit Tests — 边界守卫不再冤杀「运行期新建一份 agent 本体」的节点(28 号 §九 风险表第一行) * * ── 锁的是哪个回归 ──────────────────────────────────────────────────── * 28 号改造把新本体的落点从 `data/companies/{cid}/agents/`(整个目录已 gitignore) * 搬到了被 git 跟踪的 `system/agents/{id}/`。于是招聘一个模板库里没有的角色时: * hireFromBaseMetaAgent 从 `_base` 复制骨架 → 新建整个 `system/agents/{newId}/` * → 整仓 `git status --porcelain` 冒出 `?? system/agents/{newId}/` **一整条目录项** * → 边界守卫把它归属到「当时正在跑的那个 work 节点」头上 → 成功的节点被判 fail。 * 症状就是设计 §九 点名的那条:**agent 明明成功却 fail,且归因指向错误的节点**, * 挡在「新建公司」E2E 主线上。 * * 判据一旦退回「深度 1 的五个固定文件名」(旧 AGENT_RUNTIME_STATE_RE),下面第一条就红。 * * ── 为什么用注入的 after 快照 ──────────────────────────────────────── * 守卫只在 `agentExecutor === defaultAgentExecutor` 时才开,而单测一律注入假 executor, * 所以「越界 → 判 fail」这条分支真跑不到。`applyBoundaryGuard` 的第四个参数 * `injectedAfter` 就是为此留的测试缝(见它的 JSDoc):before/after 两张快照由用例给定, * 不起 git 子进程、不起 Claude CLI,而生产路径一个字符都没变。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; describe('边界守卫 · 新建 agent 本体不判越界(28 §九)', () => { let wdb; let engine; let runsDir; const TEST_DB_PATH = '/tmp/test-boundary-guard-new-body.sqlite'; beforeAll(() => { // 隔离手法同 graph-engine-fail-reason.test.js:先设环境变量再 require, // 仓库里的 data/graph.sqlite 与 data/companies/ 全程不被碰到。 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(), 'boundary-new-body-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; // 同一个进程里后续文件再 require 时要拿回没有 override 的那份 for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { delete require.cache[require.resolve(m)]; } }); 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 system_agents; `); db.pragma('foreign_keys = ON'); }); let seq = 0; /** 建一个只有一个 work 节点的 run,返回它的 ctx(守卫要的就这几个字段) */ function newNodeCtx() { const companyId = `bg-co-${++seq}`; const ts = new Date().toISOString(); wdb.getDb().prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) VALUES ('hr', 'hr', 'public', NULL, 'global_agent', 'system/agents/hr', 'active', ?, ?) `).run(ts, ts); const g = wdb.createWorkGraph({ companyId, nodes: ['hr'], edges: [] }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, executionMode: 'manual' }); engine.markRunReady(run.id); engine.startRun(run.id); const node = wdb.getRunState(run.id).nodes.find((n) => n.name === 'hr'); return { runId: run.id, run: wdb.getRun(run.id), nodeRunId: node.id, attempt: 0, projectRoot: '/tmp/p', }; } /** timeline = 落盘的 SSE 流;读它才算验到「证据没丢」这一环 */ const timeline = (runId) => fs.readFileSync(path.join(runsDir, runId, 'stream.log'), 'utf8') .split('\n\n').filter(Boolean) .map((block) => ({ event: (/^event: (.+)$/m.exec(block) || [])[1], data: JSON.parse(/^data: (.+)$/m.exec(block)[1]), })); it('`?? system/agents/{newId}/` 不降级成 fail,且证据进了 timeline 的 unattributable', () => { const ctx = newNodeCtx(); // 招聘走 hired_from_base 时 porcelain 里就是这一条:未跟踪的**整个目录**,带尾部斜杠。 const settled = engine.applyBoundaryGuard( ctx, '', { status: 'success', output: '已为你招到 brand-new-role' }, '?? system/agents/brand-new-role/\n', ); expect(settled.status).toBe('success'); expect(settled.failReason).toBeUndefined(); expect(settled.output).not.toContain('[边界守卫]'); const boundary = timeline(ctx.runId).filter((e) => e.event === 'node.boundary'); expect(boundary).toHaveLength(1); expect(boundary[0].data.violation).toBeNull(); // 不判罚 expect(boundary[0].data.unattributable).toEqual(['?? system/agents/brand-new-role/']); // 证据留着 }); it('agent-creator 往新本体里补的定义文件(深度 ≥1 的任意路径)同样不判罚', () => { const ctx = newNodeCtx(); const settled = engine.applyBoundaryGuard( ctx, '', { status: 'success' }, [ '?? system/agents/brand-new-role/agent.md', '?? system/agents/brand-new-role/.claude/skills/x/SKILL.md', // 深度 3,旧名单接不住 'M system/agents/agent-creator/todo.md', // 修改类(1 字符状态位) '?? "system/agents/新 角色/"', // 含空格 → git 会整条加引号 ].join('\n') + '\n', ); expect(settled.status).toBe('success'); const boundary = timeline(ctx.runId).filter((e) => e.event === 'node.boundary'); expect(boundary[0].data.unattributable).toHaveLength(4); }); it('反面:写进纯源码目录仍然判 fail(别把守卫改瞎了)', () => { const ctx = newNodeCtx(); const settled = engine.applyBoundaryGuard( ctx, '', { status: 'success', output: 'done' }, 'M server/index.js\n', ); expect(settled.status).toBe('fail'); expect(settled.failReason).toBe('boundary_escape'); expect(settled.output).toContain('[边界守卫]'); const boundary = timeline(ctx.runId).filter((e) => e.event === 'node.boundary'); expect(boundary).toHaveLength(1); expect(boundary[0].data.violation).toBe('boundary_escape'); expect(boundary[0].data.entries).toEqual(['M server/index.js']); }); it('反面:注册表旁边的同前缀目录不蹭豁免(system/agents-backup/ 仍判 fail)', () => { const ctx = newNodeCtx(); const settled = engine.applyBoundaryGuard( ctx, '', { status: 'success' }, '?? system/agents-backup/x.md\n', ); expect(settled.status).toBe('fail'); expect(settled.failReason).toBe('boundary_escape'); }); });