boundary-guard-new-body.test.js 7.85 KB
/**
 * 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');
  });
});