agent-body-draft-gate.test.js 13.7 KB
/**
 * Unit Tests — 新造本体的 draft 闸门(28 号设计 §六 批次 5-22/23,验收 6)
 *
 * 锁的是一条因果链,链上每一环单独看都「没报错」,所以只能整条锁:
 *   雇一个仓库里没有的角色 → 从 `_base` 毕业出**本体**(不是公司私有副本)
 *   → 定义缺一份就停在 status='draft' → 别家公司雇不走它(body_not_ready)
 *   → 三个定义文件填满 → 下一个来问的人触发转正 → status='active'、可复用
 *   → 全程只有**一份**本体目录
 *
 * ⚠️ 28 二轮修复补齐 `_base` 之后**又**在三份定义里埋了哨兵:文件齐全 ≠ 定义填过。
 * 所以新本体一出生仍是 draft(第一条用例锁的就是这个 —— 「复制完通用模板就上岗」
 * 正是 §2.4 要根治的形态,且会经由第二家公司放大到全系统)。
 * 后面两条用例**显式**先把三份填成真内容、再清空 soul.md 打回 draft,
 * 模拟 agent-creator 那次 fire-and-forget 只填了一半的真实故障形态 ——
 * 这样 missing 恰好是 ['soul.md'],断的是「缺哪一份说得出来」,
 * 而不是顺带蹭 `_base` 骨架的缺失(那是另一条独立失效链,见 base-skeleton-complete.test.js)。
 *
 * 为什么闸门非有不可:填定义的 agent-creator 是一次 fire-and-forget 的 HTTP 自调用,
 * 填没填、填全没填全,改造前没有任何一处会问。§2.4 的 org-coder 就是这么变成空定义的
 * ——目录在、库里有行、跑起来只剩通用 Base Agent 文本,静默跑偏且不报错。
 *
 * 隔离手法与 company-hire-org-layer.test.js 同源:GRAPH_DB_PATH 指一次性 sqlite,
 * 公司目录与本体下的公司态子树在 afterAll 里删掉;**本体本身留着**
 * (28 号裁决:测试建出来的 agent 是正式注册表条目,不删、进版本库)。
 * id 因此必须固定,否则「不删」= 每跑一次多一个僵尸本体。
 *
 * 本体常驻之后,第一条用例的两条分支是同一件事的两种形态:
 *   首次   本体不存在 → hire() 路由到 `_base` 毕业分支 → hired_from_base + 库里 draft
 *   再跑   本体已在盘上(仍是骨架、哨兵还在)→ 闸门拦下 → body_not_ready + 库里 training
 * 断言落在两条分支共同的不变式上:**缺哪三份说得出来 + 绝不当场上岗**。
 * 「再跑」这条分支同时也是决策想要的形态 —— 本体被复用,而不是每次新造一份。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

describe('新造本体的 draft 闸门(28 批次 5)', () => {
  const REPO_ROOT = path.resolve(__dirname, '../../..');
  const TEST_DB_PATH = '/tmp/test-agent-body-draft-gate.sqlite';
  const AGENTS_DIR = path.join(REPO_ROOT, 'system/agents');
  const COMPANIES_DIR = path.join(REPO_ROOT, 'data/companies');

  const ROLE = 'zz-draft-role';
  const BLANK_ROLE = 'zz-draft-blankrole'; // 第 5 条用例的载体;名字不带 ROLE 前缀,免得互相误伤
  const CO_A = 'zz-draft-co-a';
  const CO_B = 'zz-draft-co-b';
  const BODY = path.join(AGENTS_DIR, ROLE);

  let graphDb;
  let graphService;
  let companyService;

  const agentRow = () =>
    graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(ROLE);
  const hiredCompanies = () =>
    graphDb.getDb()
      .prepare(`SELECT company_id FROM company_agents WHERE role_name = ? AND status = 'active'`)
      .all(ROLE).map((r) => r.company_id).sort();

  const DEF_FILES = ['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md'];

  /** agent-creator 按角色重写三份定义的效果:`_base` 哨兵随之消失 */
  const fillWithRealContent = (roleId = ROLE) => {
    for (const rel of DEF_FILES) {
      fs.writeFileSync(path.join(AGENTS_DIR, roleId, rel), `# ${roleId}\n按角色填过的 ${rel}。\n`, 'utf-8');
    }
  };

  /** 把本体打回「agent-creator 只填了一半」的形态:定义缺一份 + 库里退回 draft */
  const demoteToDraft = (roleId = ROLE) => {
    fillWithRealContent(roleId); // 先都填上,再单独挖掉 soul.md —— 缺的只有它一份
    fs.writeFileSync(path.join(AGENTS_DIR, roleId, 'soul.md'), '', 'utf-8');
    graphDb.getDb()
      .prepare(`UPDATE system_agents SET status = 'draft' WHERE id = ?`)
      .run(roleId);
  };

  /**
   * 把本体的三份定义打回 `_base` 骨架(哨兵回位)。
   *
   * 本用例的方法论就是**改本体的定义文件**(填满 / 挖空),而本体现在是常驻的
   * ——不复位的话第二次跑一开始就是「定义已填满」,第一条用例当场退化成
   * 「转正了所以通过」。只复位这三份:`config.json` 得留着上一次毕业写下的
   * status='training'(`_base` 那份是 'idle',盖过去等于把闸门的入口也一起抹了)。
   */
  const restoreSkeleton = (roleId) => {
    if (!fs.existsSync(path.join(AGENTS_DIR, roleId))) return;
    for (const rel of DEF_FILES) {
      fs.copyFileSync(path.join(AGENTS_DIR, '_base', rel), path.join(AGENTS_DIR, roleId, rel));
    }
  };

  /**
   * 把盘上已有的本体按 `config.json.status` 补一行进一次性库 —— 生产里 seedAgents()
   * 每次进程启动都这么做。本体常驻之后第二次跑起,ROLE 的本体已经在盘上,
   * 没有这一行 hire() 拿到的是 status='unknown':闸门整个不参与判断,用例空跑。
   */
  const seedRowFromDisk = (id) => {
    const cfgPath = path.join(AGENTS_DIR, id, 'config.json');
    if (!fs.existsSync(cfgPath)) return;
    const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
    const now = new Date().toISOString();
    graphDb.getDb().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', ?, ?, ?, ?)
    `).run(id, id, `system/agents/${id}`, cfg.status || 'active', now, now);
  };

  beforeAll(() => {
    // `_base` 骨架齐全是本测试的前提(缺文件时第一条用例的 active 断言会红,
    // 而不是悄悄退化成「因为缺文件所以 draft」的另一种通过)
    for (const f of ['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md']) {
      expect(fs.existsSync(path.join(AGENTS_DIR, '_base', f)), `_base/${f}`).toBe(true);
    }

    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of ['../../services/graph-db', '../../services/graph-service',
      '../../services/company-service']) {
      delete require.cache[require.resolve(m)];
    }
    graphDb = require('../../services/graph-db');
    graphService = require('../../services/graph-service');
    const { CompanyService } = require('../../services/company-service');
    companyService = new CompanyService();

    restoreSkeleton(ROLE);
    seedRowFromDisk(ROLE);
    // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
    // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
    for (const id of [CO_A, CO_B]) fs.rmSync(path.join(COMPANIES_DIR, id), { recursive: true, force: true });
    companyService.create({ id: CO_A, name: '闸门甲公司', description: '批次 5 闸门测试' });
    companyService.create({ id: CO_B, name: '闸门乙公司', description: '批次 5 闸门测试' });
  });

  afterAll(() => {
    // 本体不删(正式注册表条目);删的是公司侧目录 + 散在各本体下的公司态子树
    for (const id of [CO_A, CO_B]) {
      try { fs.rmSync(path.join(COMPANIES_DIR, id), { recursive: true, force: true }); } catch {}
      for (const body of fs.readdirSync(AGENTS_DIR, { withFileTypes: true })) {
        if (!body.isDirectory()) continue;
        try { fs.rmSync(path.join(AGENTS_DIR, body.name, id), { recursive: true, force: true }); } catch {}
      }
    }
    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;
  });

  it('骨架 ≠ 定义:本体三份文件齐全也不许上岗,缺哪份说得出来(验收 6)', () => {
    const result = companyService.hire(CO_A, ROLE, { type: 'skill', name: ROLE });

    // 首次跑是「刚毕业」,之后是「闸门拦下已有骨架」——两条都必须给出同一个结论
    expect(['hired_from_base', 'body_not_ready']).toContain(result.status);
    // 三份文件都在盘上,但内容还是 `_base` 骨架(带哨兵)→ 一份都不算填过,
    // 必须等 agent-creator 按角色重写才能上岗
    expect(result.missing).toEqual(DEF_FILES);
    expect(result.needsTraining).toBe(true);
    for (const f of DEF_FILES) {
      expect(fs.existsSync(path.join(BODY, f)), f).toBe(true);
    }

    const row = agentRow();
    expect(graphService.isUngraduatedStatus(row.status)).toBe(true);
    // origin 只在「本次真的造了它」时才是这条链路的证据:再跑时这一行是 seedRowFromDisk
    // (= 生产的 seedAgents)按盘上目录补的,origin='builtin',说的是另一件事。
    if (result.status === 'hired_from_base') {
      expect(row.origin).toBe('created');
      expect(row.status).toBe('draft');
    }
  });

  it('draft 本体不许被第二家公司雇走 —— 污染面锁死在发起方那一家(§九)', () => {
    demoteToDraft(); // agent-creator 只填了一半的形态

    const result = companyService.hire(CO_B, ROLE, {});

    expect(result.status).toBe('body_not_ready');
    expect(result.needsTraining).toBe(true);
    // 缺什么要说得出来,否则调用方只知道「不行」不知道「差哪」
    expect(result.missing).toEqual(['soul.md']);
    // 乙公司没有落下雇佣关系(甲有没有落,取决于上一条走的是毕业还是拦下,与本条无关)
    expect(hiredCompanies()).not.toContain(CO_B);
  });

  it('三个定义文件填满后自动转正,第二家公司复用同一份本体(验收 6)', () => {
    fs.writeFileSync(path.join(BODY, 'soul.md'), '# 灵魂\n认真干活。\n', 'utf-8');

    const result = companyService.hire(CO_B, ROLE, {});

    expect(result.status).toBe('hired_from_template');
    expect(agentRow().status).toBe('active');
    expect(hiredCompanies()).toContain(CO_B);
    // 复用 = 一份本体 + 两个公司态目录,而不是第二份本体。
    // 判据取「哪些本体下面挂着本用例这两家公司的公司态目录」——只准有 ROLE 这一份;
    // 数目录名的老写法会被 ROLE 的同前缀邻居(以及别的常驻 zz-* 本体)带偏。
    const hosting = fs.readdirSync(AGENTS_DIR, { withFileTypes: true })
      .filter((d) => d.isDirectory())
      .filter((d) => [CO_A, CO_B].some((cid) => fs.existsSync(path.join(AGENTS_DIR, d.name, cid))))
      .map((d) => d.name);
    expect(hosting).toEqual([ROLE]);
    expect(fs.existsSync(path.join(BODY, CO_B, 'company-context.md'))).toBe(true);
  });

  it('闸门只挡 draft/training —— enabled/idle/ready/trained 是合法在岗状态,不许被误判', () => {
    // `system_agents.status` 不是封闭取值域:seedAgents() 把 `config.json.status` 原样
    // upsert 进库,生产库里 56 个 builtin 本体有 36 个是 enabled / idle / ready / trained
    // (含 workgraph-planning / master / agent-creator / qa-judge)。
    // 闸门一旦写成「!== 'active' 就拦」,这 36 个全部 hire 不动,而且报的理由是
    // 「定义还没填满」+ missing 为空数组 —— 理由错、现象也不像 bug。本用例锁死这条。
    // 用真实存在的本体 qa-judge(三件套齐全),只改库里的 status。
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    for (const status of ['enabled', 'idle', 'ready', 'trained', 'active']) {
      const id = `qa-judge`;
      db.prepare(`INSERT OR REPLACE INTO system_agents
        (id,name,scope,owner_company_id,source_kind,origin,legacy_path,status,benchmark_score,created_at,updated_at)
        VALUES (?,?,'public',NULL,'global_agent','builtin',?, ?,NULL,?,?)`)
        .run(id, id, `system/agents/${id}`, status, now, now);
      db.prepare(`DELETE FROM company_agents WHERE role_name = ?`).run(id);

      const r = companyService.hire(CO_A, id, {});
      expect([status, r.status]).toEqual([status, 'hired_from_template']);
    }
    // 收尾:把这次借用 qa-judge 留下的公司态目录删掉,别在源码树里留运行态
    for (const cid of [CO_A]) {
      try { fs.rmSync(path.join(AGENTS_DIR, 'qa-judge', cid), { recursive: true, force: true }); } catch {}
    }
  });

  it('空白文件不算填过:SKILL.md 只剩空白时闸门必须重新落下', () => {
    const other = BLANK_ROLE;
    const otherBody = path.join(AGENTS_DIR, other);
    // 本体常驻,所以这里不靠「刚建出来」,而是每次显式把它摆成要测的形态:
    // 三份定义按角色填满 → 只把 SKILL.md 抹成空白 → 库里打回 draft。
    companyService.hire(CO_A, other, {});
    fillWithRealContent(other); // agent.md / soul.md 都按角色填过了,只留 SKILL.md 出问题
    fs.writeFileSync(path.join(otherBody, '.claude/skills/main_skill/SKILL.md'), '   \n\n', 'utf-8');
    graphDb.getDb().prepare(`UPDATE system_agents SET status = 'draft' WHERE id = ?`).run(other);

    // 三个文件都「存在」了,但 SKILL.md 是空白 —— 存在性判据会在这里放行,非空判据不会
    const blocked = companyService.hire(CO_B, other, {});
    expect(blocked.status).toBe('body_not_ready');
    expect(blocked.missing).toEqual(['.claude/skills/main_skill/SKILL.md']);
  });
});