base-graduation-gate.test.js 9.04 KB
/**
 * Unit Tests — `_base` 骨架不算「定义已填满」(28 二轮修复:哨兵判据)
 *
 * 锁的是两条修复互相抵消出来的一个 blocker,实测复现过:
 *   ① 二轮修复给 `_base` 补齐了 soul.md + main_skill/SKILL.md(对的,不该回退);
 *   ② 闸门 `missingBodyDefinition` 的判据是「文件存在且非空」。
 * 于是 ①+② = 骨架一拷完三份文件就都非空 → `promoteDraftIfComplete` 当场转正 →
 * 新角色**刚复制完通用模板就上岗**,身上只有 `_base` 的通用 Base Agent 文本、零角色特化;
 * 第二家公司再 hire 同一个 id 时它已经是 active,会被当成一份已就绪的可复用本体直接雇走。
 * 这就是 §2.4 的 org-coder 失败形态被原样搬回来,且污染面从 1 家公司放大成全系统。
 *
 * 修法是把判据从「空 → 算缺」扩成「空 **或仍带哨兵** → 算缺」,
 * 哨兵 `BASE_SKELETON_SENTINEL` 埋在 `_base` 的三份定义文件里,agent-creator 写真内容时
 * 自然覆盖掉。本文件三条用例分别锁判据、锁转正动作、锁端到端的招聘结果。
 *
 * 隔离手法与 agent-body-draft-gate.test.js 同源:GRAPH_DB_PATH 指一次性 sqlite,
 * 公司目录在 afterAll 删掉;**本体留下**(28 号裁决:测试建出来的 agent 是正式注册表条目,
 * 不删、进版本库)。id 因此必须固定 —— 随机后缀 + 不删 = 每跑一次多一个僵尸本体。
 *
 * 本体常驻带来两处必须显式处理的事,否则第二次跑就变成空跑或误红:
 *   ① 骨架态要复位:`fs.cpSync(_base → SKELETON_ROLE)` 每次覆写一遍,把上一次
 *      「填成真内容」的改动抹掉,哨兵回到原位;
 *   ② 库行要补种:一次性 sqlite 每次都是空的,而 HIRE_ROLE 的本体上一次跑就毕业出来了。
 *      生产里这一行由 seedAgents() 在启动时按 config.json 补上,这里照做(见 seedRowFromDisk)。
 *      不补的话闸门拿到 status='unknown',整道闸门不参与判断。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

describe('_base 骨架不算定义已填满(哨兵闸门)', () => {
  const REPO_ROOT = path.resolve(__dirname, '../../..');
  const AGENTS_DIR = path.join(REPO_ROOT, 'system/agents');
  const COMPANIES_DIR = path.join(REPO_ROOT, 'data/companies');
  const TEST_DB_PATH = '/tmp/test-base-graduation-gate.sqlite';

  const SKELETON_ROLE = 'zz-basegrad-copy'; // 手工拷贝的骨架,测判据本身
  const HIRE_ROLE = 'zz-basegrad-hire';     // 端到端招聘出来的新本体
  const CO = 'zz-basegrad-co';

  const DEFINITION_FILES = [
    'agent.md',
    'soul.md',
    path.join('.claude', 'skills', 'main_skill', 'SKILL.md'),
  ];

  let locator;
  let graphDb;
  let graphService;
  let companyService;

  const bodyOf = (id) => path.join(AGENTS_DIR, id);

  /**
   * 把盘上已有的本体按 `config.json.status` 补一行进一次性库 —— 生产里 seedAgents()
   * 每次进程启动都这么做。本体常驻之后,第二次跑起 HIRE_ROLE 已经在盘上,
   * 没有这一行 hire() 会拿到 status='unknown',闸门整个不参与判断(用例空跑)。
   */
  const seedRowFromDisk = (id) => {
    if (!fs.existsSync(path.join(bodyOf(id), 'config.json'))) return;
    const cfg = JSON.parse(fs.readFileSync(path.join(bodyOf(id), 'config.json'), '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);
  };
  const agentRow = (id) =>
    graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(id);

  /** 把三份定义写成「agent-creator 真的按角色填过」的样子 —— 哨兵随之消失 */
  const fillWithRealContent = (id) => {
    for (const rel of DEFINITION_FILES) {
      const p = path.join(bodyOf(id), rel);
      fs.mkdirSync(path.dirname(p), { recursive: true });
      fs.writeFileSync(p, `# ${id} 的真实定义\n这份 ${rel} 是按角色填过的,不再是通用骨架。\n`, 'utf-8');
    }
  };

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

    // 前提:`_base` 三份文件在,且都带着哨兵。缺了的话下面的断言会退化成
    // 「因为文件不存在所以算缺」的另一种通过 —— 那就白测了。
    for (const rel of DEFINITION_FILES) {
      const text = fs.readFileSync(path.join(AGENTS_DIR, '_base', rel), 'utf-8');
      expect(text.includes(locator.BASE_SKELETON_SENTINEL), `_base/${rel} 没有哨兵`).toBe(true);
      expect(text.trim().length, `_base/${rel} 是空的`).toBeGreaterThan(200);
    }
    // 骨架复位:cpSync 覆写,把上一次跑「填成真内容」的三份定义打回带哨兵的 `_base` 骨架
    fs.cpSync(path.join(AGENTS_DIR, '_base'), bodyOf(SKELETON_ROLE), { recursive: true, force: true });
    seedRowFromDisk(HIRE_ROLE);
    // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
    // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
    fs.rmSync(path.join(COMPANIES_DIR, CO), { recursive: true, force: true });
    companyService.create({ id: CO, name: '哨兵闸门公司', description: '28 二轮修复测试' });
  });

  afterAll(() => {
    // 本体不删(正式注册表条目);删的是公司侧目录 + 本体下的公司态子树
    for (const id of [SKELETON_ROLE, HIRE_ROLE]) {
      try { fs.rmSync(path.join(bodyOf(id), CO), { recursive: true, force: true }); } catch {}
    }
    try { fs.rmSync(path.join(COMPANIES_DIR, CO), { 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('刚从 _base 拷出来的本体:三份定义全部算缺(非空骗不过闸门)', () => {
    // 三份文件都存在、都远不止 200 字节 —— 「非空即齐全」的旧判据会全部放行
    for (const rel of DEFINITION_FILES) {
      expect(fs.readFileSync(path.join(bodyOf(SKELETON_ROLE), rel), 'utf-8').trim().length)
        .toBeGreaterThan(200);
    }
    expect(locator.missingBodyDefinition(SKELETON_ROLE)).toEqual(DEFINITION_FILES);
  });

  it('骨架本体不许转正;三份填成真内容之后才转正', () => {
    graphService.ensureSystemAgentRow(CO, SKELETON_ROLE, SKELETON_ROLE, { status: 'draft' });

    const blocked = graphService.promoteDraftIfComplete(SKELETON_ROLE);
    expect(blocked.promoted).toBe(false);
    expect(blocked.status).toBe('draft');
    expect(blocked.missing).toEqual(DEFINITION_FILES);

    fillWithRealContent(SKELETON_ROLE);

    const promoted = graphService.promoteDraftIfComplete(SKELETON_ROLE);
    expect(promoted.promoted).toBe(true);
    expect(promoted.status).toBe('active');
    expect(agentRow(SKELETON_ROLE).status).toBe('active');
  });

  it('端到端:雇一个只有骨架的角色,人不能当场上岗(库里仍是未毕业状态)', () => {
    // 这条直接对应实测证据:修复前返回的是 {status:'hired_from_base', needsTraining:false}
    // 且库里 status='active' —— 复制完通用骨架就"毕业"。
    //
    // 两条分支同一个不变式(本体常驻,第一次跑走前者、以后走后者):
    //   首次   本体不存在 → 从 `_base` 毕业 → hired_from_base + 库里 draft
    //   再跑   本体已在盘上(仍是骨架、哨兵还在)→ 闸门拦下 → body_not_ready + 库里 training
    // 不变的是「缺哪三份说得出来 + 不许当场上岗」,这才是闸门要保证的东西。
    const result = companyService.hire(CO, HIRE_ROLE, { type: 'skill', name: HIRE_ROLE });

    expect(['hired_from_base', 'body_not_ready']).toContain(result.status);
    expect(result.needsTraining).toBe(true);
    expect(result.missing).toEqual(DEFINITION_FILES);
    expect(graphService.isUngraduatedStatus(agentRow(HIRE_ROLE).status)).toBe(true);
    // 骨架是真拷过去了 —— 停在 draft 不是因为文件没生成
    for (const rel of DEFINITION_FILES) {
      expect(fs.existsSync(path.join(bodyOf(HIRE_ROLE), rel)), rel).toBe(true);
    }
  });
});