company-create-mainline.test.js 12.7 KB
/**
 * 28 号改造的**终验闸门** —— 「新建公司」主线用**真本体**从头到尾走一遍。
 *
 * ── 为什么还要这一份(三条修复各自都已经有单测了)────────────────────────
 * 已有的那些单测一律用**临时合成本体**(`fs.writeFileSync(BODY_DIR/agent.md, '# 临时本体')`):
 * 隔离得干净,但也因此把「真本体长什么样」这一维整个抹掉了。三条修复恰好全都
 * 只在真本体上才显形:
 *   ① 毕业闸门(SKILL.md 改成可选)—— 合成本体想让它齐全就让它齐全,
 *      而真的卡死过的是 `code-planner`:config.json 写着 `status:'training'`、
 *      磁盘上有 agent.md + soul.md、**没有** `.claude/skills/main_skill/SKILL.md`。
 *      把 SKILL.md 算回必需件,这一条当场红,且症状正是 E2E 主线上那句
 *      「新公司再也雇不到 code-planner」。
 *   ② 边界守卫的豁免树 —— 合成本体也能造出 `?? system/agents/xxx/`,
 *      但只有走真的 `hireFromBaseMetaAgent` 才知道 porcelain 里冒出来的是
 *      **一整条目录项**(而不是五个文件名)。
 *   ③ 交付物归宿 —— 合成本体的正文是一行标题,**永远数不出第二条写入指令**。
 *      真本体(ceo)正文里带着一句对坐标声明的**引用**(「你的交付物必须写到」那一行),
 *      于是「指令」和「引用」必须分开数;而引用一旦指向一个提示词里不存在的段,
 *      agent 就是找不到落点且不报错 —— 这条悬空只有拿真本体拼真提示词才看得见。
 *
 * ── 隔离手法 ────────────────────────────────────────────────────────────
 * GRAPH_DB_PATH 指临时库(先设再 require),仓库 data/graph.sqlite 全程不被碰。
 * 公司目录与公司态目录用固定 id,afterAll 删干净;毕业出来的那份新本体**留着**
 * (28 号裁决:测试建出来的 agent 是正式注册表条目)——所以 id 不能带时间戳,
 * 否则每跑一次就往注册表里多堆一个没人认领的僵尸。
 * 读的既有本体是仓库里真的那几份,**只读不写**。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

const locator = require('../../services/agent-locator');
const AgentManager = require('../../src/core/agent-manager');
const { GRAPH_WRITE_BOUNDARY_HEADER } = require('../../src/core/prompt-builder');

const REPO = locator.REPO_ROOT;
const TEST_DB_PATH = '/tmp/test-company-create-mainline.sqlite';
const CID = 'zz-mainline-co';
/** 主线上必须能雇进来的真本体。code-planner 是 §2 那条 training 死锁的当事人。 */
const REAL_BODIES = ['ceo', 'code-planner'];
/** 第一次跑时注册表里没有的角色 → 走 create-agent 毕业路径,产物此后常驻 */
const NEW_ROLE = 'zz-mainline-role';

/**
 * 「你必须把交付物写到 X」这类**归宿指令**。两条链路各有各的措辞:
 * 前者是坐标声明(agent-chat 链路),后者是 describeWriteBoundary(graph 链路)。
 * 必须锚在行首的列表项上 —— 本体正文里那句「…里『你的交付物必须写到』那一行」是
 * **引用**不是指令,不锚行首就会把它一起数进来,把健康的提示词误判成两个归宿。
 */
const WRITE_MANDATE = /(?:^|\n)-?\s*(?:你的交付物必须写到:|一切代码 \/ 测试 \/ 配置 \/ 交付物都写在)/g;
/** 本体正文对坐标声明那一行的引用(带书名号的才是引用) */
const MANDATE_QUOTE = /「你的交付物必须写到」/g;
const countOf = (re, s) => (s.match(re) || []).length;

describe('28 终验 · 「新建公司」主线(真本体)', () => {
  let svc;
  let listCompanyAgents;
  let engine;
  let db;
  let manager;
  let hires;
  let newHire;

  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;
    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',
      '../../services/company-service',
    ]) {
      delete require.cache[require.resolve(m)];
    }
    ({ getDb: db } = require('../../services/graph-db'));
    engine = require('../../services/graph-engine');
    ({ CompanyService: svc, listCompanyAgents } = require('../../services/company-service'));
    svc = new svc();

    // 库里先照 `seedAgents()` 的做法把两个真本体的 config.json.status 原样入库 ——
    // 临时库是空的,不 seed 的话 promoteDraftIfComplete 返回 status='unknown',
    // 闸门根本不参与判断,①那条就等于没测(`code-planner` 的 'training' 正是闸门的入口)。
    const now = new Date().toISOString();
    const ins = db().prepare(`
      INSERT OR REPLACE INTO system_agents
        (id, name, scope, owner_company_id, source_kind, origin, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, 'global_agent', 'builtin', ?, ?, ?, ?)
    `);
    for (const id of REAL_BODIES) {
      const cfg = JSON.parse(fs.readFileSync(path.join(locator.bodyDir(id), 'config.json'), 'utf-8'));
      ins.run(id, id, `system/agents/${id}`, cfg.status || 'active', now, now);
    }

    // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
    // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
    fs.rmSync(path.join(REPO, 'data', 'companies', CID), { recursive: true, force: true });
    svc.create({ id: CID, name: '主线终验公司', goal: '验证建司主线', description: 'throwaway' });
    hires = Object.fromEntries(REAL_BODIES.map((r) => [r, svc.hire(CID, r)]));
    // 直接调毕业分支:本体常驻之后 hire() 第二次跑会(正确地)走复用分支,
    // 而②锁的是「刚拷完 `_base` 骨架的新本体仍然不许上岗」。毕业幂等(_copySkeleton 覆写)。
    newHire = svc.hireFromBaseMetaAgent(CID, NEW_ROLE, { type: 'skill', name: NEW_ROLE });
    manager = new AgentManager({}, REPO);
  });

  afterAll(() => {
    fs.rmSync(path.join(REPO, 'data', 'companies', CID), { recursive: true, force: true });
    // applyBoundaryGuard 里的 timelineNote 认不出这个捏造的 runId,按 graph-run-sse 的
    // 兜底把 timeline 落到 `data/companies/_unknown/runs/{runId}/`。留着的话它会出现在
    // 控制台的公司列表里(svc.list() 扫的就是 data/companies/ 下的目录)。
    fs.rmSync(path.join(REPO, 'data', 'companies', '_unknown', 'runs', 'mainline-run'), { recursive: true, force: true });
    // 本体不删(正式注册表条目),只清它下面这次跑写出来的公司态
    fs.rmSync(locator.companyDir(NEW_ROLE, CID), { recursive: true, force: true });
    for (const id of REAL_BODIES) fs.rmSync(locator.companyDir(id, CID), { recursive: true, force: true });
    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;
    for (const m of ['../../services/graph-run-sse', '../../services/graph-engine', '../../services/company-service']) {
      delete require.cache[require.resolve(m)];
    }
  });

  // ① 毕业闸门 —— 把 SKILL.md 算回必需件,这三条一起红
  it('真本体(含 config.json 写着 training 的 code-planner)都雇得进来', () => {
    // 前提坐实:code-planner 磁盘上确实没有 SKILL.md,所以这条不是「碰巧齐全」
    expect(fs.existsSync(path.join(locator.bodyDir('code-planner'), '.claude/skills/main_skill/SKILL.md'))).toBe(false);
    for (const r of REAL_BODIES) {
      expect(hires[r].status, `${r} 被 body_not_ready 挡住了:缺 ${JSON.stringify(hires[r].missing)}`)
        .not.toBe('body_not_ready');
    }
  });

  // ② 哨兵闸门不许被①顺手放宽掉:刚拷完 `_base` 骨架的新本体仍然不许上岗
  it('注册表里没有的角色走 create-agent 路径:骨架齐全但停在 draft', () => {
    const body = locator.bodyDir(NEW_ROLE);
    expect(fs.existsSync(path.join(body, 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(body, 'config.json'))).toBe(true);
    expect(newHire.needsTraining).toBe(true);
    expect(db().prepare('SELECT status FROM system_agents WHERE id = ?').get(NEW_ROLE).status).toBe('draft');
  });

  // ③ 边界守卫:招聘窗口内新建的整条本体目录不算越界
  it('新建本体的 porcelain 目录项进 unattributable,节点不被降级成 fail', () => {
    const before = '';
    const after = [
      `?? system/agents/${NEW_ROLE}/`,     // hireFromBaseMetaAgent 造的整条目录项
      `M system/agents/ceo/todo.md`,       // agent 改自己的运行态文件(修改类,两字符状态位长度不同)
    ].join('\n');
    const { escapes, unattributable } = engine.splitUnattributable(engine.diffRepoStatus(before, after));
    expect(escapes).toEqual([]);
    expect(unattributable).toHaveLength(2);

    const ctx = { nodeRunId: 'mainline-node', attempt: 1, projectRoot: null, runId: 'mainline-run' };
    const outcome = engine.applyBoundaryGuard(ctx, before, { status: 'success', output: 'ok' }, after);
    expect(outcome.status).toBe('success');
    expect(outcome.failReason).toBeUndefined();
  });

  it('落点:公司不持有 agent 实体,公司态在本体目录下', () => {
    expect(fs.existsSync(path.join(REPO, 'data', 'companies', CID, 'agents'))).toBe(false);
    const ceoState = locator.companyDir('ceo', CID);
    for (const f of ['company-context.md', 'user.md', 'memory']) {
      expect(fs.existsSync(path.join(ceoState, f)), `公司态缺 ${f}`).toBe(true);
    }
    expect(listCompanyAgents(CID).map((r) => r.system_agent_id).sort())
      .toEqual([...REAL_BODIES, NEW_ROLE].sort());
    // 引用制:除了新造的那一个,不给这家公司新增任何私有本体
    expect(db().prepare('SELECT id FROM system_agents WHERE owner_company_id = ?').all(CID).map((r) => r.id))
      .toEqual([NEW_ROLE]);
  });

  // ④ 交付物归宿:一段提示词里只准有一个家,且本体正文引用的那一行必须真的在
  it('agent-chat 链路:一条写入指令,本体正文的引用有着落', () => {
    const addr = `${CID}:ceo`;
    const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-conv-1' } });
    const prompt = manager.buildFullPrompt(addr, '给公司写一份第一周的启动计划', agent);

    expect(prompt).toContain('# 运行时坐标(绝对路径,以此为准)');
    expect(countOf(WRITE_MANDATE, prompt)).toBe(1);
    // ceo 的 agent.md 正文引用了坐标声明那一行;引用存在 = 那一行必须也存在,
    // 否则 agent 照着「去找那一行」找不到,写哪儿全凭它自己猜,且没有任何报错。
    expect(countOf(MANDATE_QUOTE, prompt)).toBeGreaterThan(0);
    expect(prompt).toMatch(/- 你的交付物必须写到:\/.+\/workspace\//);
    // 本体正文与公司语境都真的装进来了(少装一段不报错,只是 agent 变成通用 Base Agent)
    expect(prompt).toContain('# CEO Agent');
    expect(prompt).toContain(CID);
  });

  it('graph 节点链路:归宿由写入边界段说了算,不悬空引用 chat 链路那一行', () => {
    const addr = `${CID}:code-planner`;
    const task = `${GRAPH_WRITE_BOUNDARY_HEADER}\n一切代码 / 测试 / 配置 / 交付物都写在项目根 /tmp/fake-project 内。\n\n把登录接口实现了。`;
    const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-node-1' } });
    const prompt = manager.buildFullPrompt(addr, task, agent);

    expect(countOf(WRITE_MANDATE, prompt)).toBe(1);
    expect(prompt).toContain('本节不另给归宿');
    // graph 链路的坐标声明**不发**「你的交付物必须写到」那一行,所以本体正文里
    // 但凡留着对它的引用就是悬空的 —— 这正是 40 份本体改写时按链路分叉的理由。
    expect(countOf(MANDATE_QUOTE, prompt)).toBe(0);
  });

  it('运行态坐标:cwd 是任务态目录,本体在 ../..,产物不落 system/', () => {
    const addr = `${CID}:code-planner`;
    const { agent } = manager.getAgentConfig(addr, { metadata: { taskId: 'mainline-node-2' } });
    expect(agent.workDir).toBe(locator.taskDir('code-planner', CID, 'mainline-node-2'));
    expect(path.relative(agent.workDir, agent.bodyDir)).toBe(path.join('..', '..'));
  });
});