graduation-criteria-reality.test.js 12.4 KB
/**
 * Unit Tests — 毕业闸门的判据必须与**仓库现实**对得上(28 三轮复验,问题 1)
 *
 * 闸门(agent-locator.missingBodyDefinition)原来要求三件齐全:
 * `agent.md` + `soul.md` + `.claude/skills/main_skill/SKILL.md`。而实测:
 * `system/agents/` 下带 agent.md 的本体里,带 `main_skill/SKILL.md` 的**只有个位数** ——
 * 也就是说这份文件从来不是本体的组成部分,判据把「绝大多数在库本体」判成了没资格上岗。
 *
 * 它没有立刻炸,是因为闸门只作用于 draft / training 两个状态,大部分本体侥幸落在别的状态上。
 * 但它**已经卡死了一个真本体**:`code-planner` 的 config.json 写着 status='training',
 * `seedAgents()` 原样 upsert 进库,于是 hire() 阶段 A 对它恒返回 body_not_ready ——
 * 现役的 p2-verify 只是靠 `company_agents` 已有行走 `already_exists` 分支绕过去了,
 * **任何新建的公司都再也雇不到它**。用户马上要跑的「新建公司」E2E 正撞在这条上。
 *
 * 三条断言的分工(都必须**先证明样本非空**,否则空遍历会让全部断言表演式通过):
 *   ① 语料规模 —— 少于 40 个本体说明扫描根找错了地方,后面两条一律不算数;
 *   ② 判据确实放宽了 —— 「判据下齐全、盘上却没有 main_skill/SKILL.md」的本体有几十个。
 *      旧判据下这个集合**恒为空**(齐全 ⊆ 带 SKILL.md),所以改回去必红。
 *   ③ 缺的一定是必需件 —— 独立重算一遍「哪些本体的 agent.md / soul.md 读不到、是空白、
 *      或仍带 `_base` 哨兵」,与闸门的输出逐个比对。只断言比例的话,判据放成
 *      「永远齐全」也能通过。
 *
 * ⚠️ 断言一律取**下界 / 集合相等**,不取「占总数的百分之几」:`system/agents/` 是会长的
 * ——单测自己就会往里毕业出常驻本体(28 号裁决:测试建的 agent 是正式注册表条目,不删)。
 * 比例式断言会随着本体变多而无缘无故变红,而它红的时候什么缺陷都没发生。
 *
 * 最后单独锁 `code-planner` 能不能被雇:走的是 hire() 真正用的那两个函数
 * (promoteDraftIfComplete + isUngraduatedStatus),DB 指向一次性 sqlite,不碰生产库。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

const REPO_ROOT = path.resolve(__dirname, '../../..');
const AGENTS_DIR = path.join(REPO_ROOT, 'system', 'agents');
const SKILL_REL = path.join('.claude', 'skills', 'main_skill', 'SKILL.md');

const locator = require('../../services/agent-locator');

/** 与 graph-seed-service.isAgentBodyDir 同一条判据,这里独立实现一遍(不 import,免得一起错) */
const bodyIds = fs
  .readdirSync(AGENTS_DIR, { withFileTypes: true })
  .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
  .filter((d) => fs.existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')))
  .map((d) => d.name);

/**
 * ⚠️ 本文件遍历的是**真实的、别的测试文件正在写的** `system/agents/`。
 *
 * 28 号裁决把本体钉成了常驻的正式注册表条目,于是 `agent-body-draft-gate.test.js`
 * 这类用例只能**就地改本体的定义文件**来摆出要测的形态(把 `zz-draft-role` 的三份定义
 * 填满 → 把 soul.md 抹空 → 再填回去)。vitest 默认按文件并行,那个文件与本文件跑在
 * 两个 worker 里,所以「判据说齐全」与「独立重算说缺」这两次读之间,同一个本体的字节
 * 完全可能被改掉 —— 实测 8 次全量跑里稳定复现 1 次:
 *   AssertionError: expected [ 'zz-draft-role' ] to deeply equal []
 * 红的时候判据一点毛病没有,纯粹是两次读看到了不同的磁盘。
 *
 * 「本体不再被删除、遍历型断言改成对增长不敏感」只治了**数量**这一维,治不了**内容**
 * 这一维:本用例比的是两个实现对同一份字节的结论,字节在两次读之间变了,比对就失去意义。
 *
 * 所以这里把每个本体的读做成一个可自证的窗口:**先快照 → 跑判据 → 再快照**,
 * 两次快照不一致 = 这份定义在窗口内被别的 worker 改过,本轮不参与比对(记进 churned)。
 * 这不是把断言放宽:字节没变过的本体一个都不放过,且两侧仍是各自独立的实现,
 * 只是都落在**同一份被证明没变过的内容**上。放过的仅限「证据本身在被改写」的那一小撮。
 */
const DEF_RELS = ['agent.md', 'soul.md', SKILL_REL];
/** 读不到用一个正文里不可能出现的哨兵表示,才能把「文件消失了」也算进字节变化 */
const ABSENT = '\u0000ABSENT';

const readDefs = (id) => DEF_RELS.map((rel) => {
  try { return fs.readFileSync(path.join(AGENTS_DIR, id, rel), 'utf-8'); } catch { return ABSENT; }
});

const CHURNED = [];
/** 每项 { id, missing, defs }:defs 是被证明在判据执行期间没变过的三份定义原文 */
const BODIES = [];
for (const id of bodyIds) {
  const before = readDefs(id).join('\u0001');
  const missing = locator.missingBodyDefinition(id);
  const defs = readDefs(id);
  if (before !== defs.join('\u0001')) { CHURNED.push(id); continue; }
  BODIES.push({ id, missing, defs });
}

/** SKILL.md 在不在盘上 —— 取自同一份快照,不再单独 existsSync(那又是一次新的读) */
const hasSkill = (b) => b.defs[2] !== ABSENT;
/** 必需件出问题:读不到 / 空白 / 仍带 `_base` 哨兵。与闸门各写各的,只共用被证明稳定的字节 */
const requiredBroken = (b) =>
  [0, 1].some((i) => b.defs[i] === ABSENT || b.defs[i].trim() === '' || b.defs[i].includes(locator.BASE_SKELETON_SENTINEL));

describe('毕业闸门 × 仓库实测现状', () => {
  it('① 语料非空:system/agents/ 下至少 40 个本体(否则下面两条是空跑)', () => {
    expect(bodyIds.length).toBeGreaterThanOrEqual(40);
    // 跳过的必须是极少数:并行写只碰得到几个 zz-* 测试本体。要是 churned 大到把语料吃空,
    // 下面两条会退化成空跑而照样绿 —— 那正是本文件开头警告过的失效形态。
    expect(BODIES.length).toBeGreaterThanOrEqual(40);
    expect(CHURNED.length).toBeLessThan(5);
  });

  it('② 判据下齐全、却没有 SKILL.md 的本体有几十个 —— 判据放宽的直接证据', () => {
    const withSkill = BODIES.filter(hasSkill);
    const complete = BODIES.filter((b) => b.missing.length === 0);
    const completeWithoutSkill = complete.filter((b) => !hasSkill(b));

    // 把 SKILL.md 当必需件时,这个集合必然是空的 —— 改回去当场红。
    // 取下界而不是比例:新增本体只会让它变大,永远不会因为「仓库长大了」而红。
    // 实测(2026-08-18):74 个本体里 61 个判据下齐全,其中 56 个盘上没有 SKILL.md。
    expect(completeWithoutSkill.length).toBeGreaterThanOrEqual(40);
    expect(complete.length).toBeGreaterThan(withSkill.length);
  });

  it('③ 判成「缺」的本体,没有一个是「只是盘上没有 SKILL.md」', () => {
    const incomplete = BODIES.filter((b) => b.missing.length > 0);

    // 被放宽掉的正是这一条:SKILL.md **不存在**不算缺。所以「缺的全部理由都是它、
    // 而它压根不在盘上」的本体必须一个都没有 —— 把 SKILL.md 算回必需件,
    // 上面那 56 个本体会一起落进这个集合。
    const blamedOnAbsentSkill = incomplete
      .filter((b) => !hasSkill(b) && b.missing.every((rel) => rel === SKILL_REL))
      .map((b) => b.id);
    expect(blamedOnAbsentSkill).toEqual([]);

    // 反向:必需件在盘上出问题(读不到 / 空白 / 仍带 `_base` 哨兵)的本体,闸门一个都不许放过。
    // 独立重算,不复用闸门的实现;哨兵这一支不能省 —— 从 `_base` 毕业出来、agent-creator
    // 还没填过的骨架本体,三份文件都在且远不止一行,只看「存不存在」会把它算成齐全。
    const missingRequiredOnDisk = BODIES.filter(requiredBroken).map((b) => b.id);
    const incompleteIds = incomplete.map((b) => b.id);
    expect(missingRequiredOnDisk.filter((id) => !incompleteIds.includes(id))).toEqual([]);

    // 样本非空:实测有一批本体缺 soul.md(6 个 devloop-* + org-coder / org-writer)
    // 或仍是骨架(单测毕业出来的)。这条要是变成 0,说明判据被放成了「永远齐全」,
    // 上面两条断言也就不作数了。这 8 个是没有任何测试会去改的常驻本体,
    // 所以它们绝不会被 churned 过滤掉 —— 样本非空这一条不依赖并发时序。
    expect(missingRequiredOnDisk.length).toBeGreaterThan(0);
  });
});

/**
 * `code-planner` 是本轮真正被卡死的那个本体(config.json: status='training',
 * 定义只有 agent.md + soul.md,没有 SKILL.md)。它是未入库的运行时产物(git 未跟踪),
 * 干净 clone 上不存在 —— 所以按存在与否跳过,而不是假装它一定在。
 * 上面 ② 的不等式在任何 clone 上都会红,不依赖这一条。
 */
const CODE_PLANNER_DIR = path.join(AGENTS_DIR, 'code-planner');

describe.skipIf(!fs.existsSync(CODE_PLANNER_DIR))('code-planner 能被新公司雇到', () => {
  const TEST_DB_PATH = '/tmp/test-graduation-criteria-reality.sqlite';
  const MODULES = ['../../services/graph-db', '../../services/graph-service', '../../services/graph-seed-service'];

  let graphService;
  let seed;

  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 MODULES) delete require.cache[require.resolve(m)];
    seed = require('../../services/graph-seed-service');
    graphService = require('../../services/graph-service');
    // 扫真实的 system/agents/(GRAPH_SEED_ROOT 不设)→ 一次性 sqlite。
    // 生产库不受影响,但入库的是真实的 config.json,包括 code-planner 那句 "training"。
    seed.runStartupSeed();
  });

  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;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('本体定义按新判据是齐全的(缺的只可能是 SKILL.md,而它压根不存在)', () => {
    expect(fs.existsSync(path.join(CODE_PLANNER_DIR, SKILL_REL))).toBe(false);
    expect(locator.missingBodyDefinition('code-planner')).toEqual([]);
  });

  it('hire() 用的那道闸门放行:status 不再停在 training', () => {
    // hire() 阶段 A 的两行原样复刻:先现场校验触发转正,再问「是不是未毕业状态」。
    const gate = graphService.promoteDraftIfComplete('code-planner');
    expect(gate.missing).toEqual([]);
    expect(graphService.isUngraduatedStatus(gate.status)).toBe(false);
  });

  it('存量对账(reconcile 的 E 分支)自己就能把它转正,且转正不会被 seedAgents 打回去', () => {
    const db = require('../../services/graph-db').getDb();
    const statusOf = () =>
      db.prepare(`SELECT status FROM system_agents WHERE id = 'code-planner'`).get().status;

    // 打回「上一轮遗留」的形态:库里 training、磁盘上定义齐全
    db.prepare(`UPDATE system_agents SET status = 'training' WHERE id = 'code-planner'`).run();
    expect(statusOf()).toBe('training');

    const r = seed.reconcileDanglingRows();
    expect(r.graduated).toContain('code-planner');
    expect(statusOf()).toBe('active');

    // seedAgents 会把 config.json 的 status 原样 upsert。「只写不降级」守的是「不许被
    // config.json 推回 draft/training」,**不**保证转正后的值原样不变——config.json 当前
    // 写的是什么已毕业状态("enabled" 也好、别的也好,只要不是 draft/training),upsert 就会
    // 用那个值覆盖,这是设计如此,不是本用例要测的东西(2026-08-30 复核:config.json 已经从
    // 写死的 "training" 改成了 "enabled",不再硬编码具体值,避免这条测试再次因为 config.json
    // 内容变化而假红)。
    seed.seedAgents();
    expect(graphService.isUngraduatedStatus(statusOf())).toBe(false);

    // 幂等:第二遍没有可转正的行
    expect(seed.reconcileDanglingRows().graduated).toEqual([]);
  });
});