agent-runtime-state.test.js 6.47 KB
/**
 * 27-P3B §四 W13 —— agent 运行态文件不再进 git;模板使用计数不再改写人工清单。
 *
 * ── 这个测试锁的是哪个回归 ────────────────────────────────────────────────
 * system agent 的 Claude CLI workDir **就是它自己的源码目录** `system/agents/<id>/`。
 * 它每跑一次就重写自己的 todo.md / plan.md,Claude CLI 还会改
 * `.claude/scheduled_tasks.json`。这些文件过去是被 git 跟踪的,于是:
 *
 *   ① 每次含 hired_from_base 的招聘都会让整仓 `git status` 冒出几条改动;
 *   ② graph-engine 的边界守卫扫的就是整仓 git status,把这些改动归属到
 *      「当时正在跑的那个 work 节点」头上 —— 一个真正成功的节点被判越界 fail。
 *
 * `graph-engine.splitUnattributable()` 是**缓解**(把这类路径摘出去不判罚),
 * 本轮做的是**根治**:把它们移出 git 索引 + 写进 .gitignore。
 * 两道防线都留着,所以这里锁根治那道 —— 只要有人把 todo.md 重新 `git add -f`
 * 回来,或者把 .gitignore 那几行删掉,这个测试就红。
 *
 * 同类问题还有 `system/templates/_registry.json`:那是人工维护的模板清单,
 * 但 `company-service._updateTemplateUsedCount()` 每次 hire 都原地 +1 改写它,
 * 于是任何一次真跑都会留一条 `M _registry.json`。现在计数搬到
 * `data/template-usage.json`(已 gitignore),registry 只留冻结的人工基线。
 */

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

const __dirname_ = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname_, '../../..');
const { CompanyService } = createRequire(import.meta.url)('../../services/company-service');

/** 这些路径下的文件是 agent 自己写的运行态,绝不能再被 git 跟踪 */
const RUNTIME_GLOBS = [
  'system/agents/*/todo.md',
  'system/agents/*/plan.md',
  'system/agents/*/.claude/scheduled_tasks.json',
  'system/agents/*/tasks/*.json',
];

function git(args) {
  return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
}

describe('W13 · agent 运行态文件已移出 git 跟踪', () => {
  it('git ls-files 里不再有 todo.md / plan.md / scheduled_tasks.json / tasks/', () => {
    const tracked = git(['ls-files', '--', ...RUNTIME_GLOBS]).split('\n').filter(Boolean);
    expect(tracked).toEqual([]);
  });

  it('.gitignore 真的挡住这些路径(不是只 rm --cached 了事)', () => {
    // check-ignore 不要求文件存在,所以拿一个固定的假 agent 名探路即可。
    const probes = [
      'system/agents/probe-agent/todo.md',
      'system/agents/probe-agent/plan.md',
      'system/agents/probe-agent/.claude/scheduled_tasks.json',
      'system/agents/probe-agent/tasks/task_x.json',
      'data/template-usage.json',
    ];
    for (const p of probes) {
      let ignored = true;
      try {
        git(['check-ignore', '-q', '--', p]);
      } catch {
        ignored = false; // exit code 1 = 没被忽略
      }
      expect(ignored, `${p} 应被 .gitignore 忽略`).toBe(true);
    }
  });

  it('模板本体(system/templates/*/todo.md)仍然被跟踪 —— 那是 hire 的拷贝源,是源码', () => {
    const tracked = git(['ls-files', '--', 'system/templates/*/todo.md']).split('\n').filter(Boolean);
    expect(tracked.length).toBeGreaterThan(0);
  });

  it('读侧对缺失容忍:loadFile 缺 todo.md 只返回空串,不抛', () => {
    // agent-chat.js:loadFile 的等价语义(readFileSync 包 try/catch)。这里直接验真实文件系统:
    // 一个不存在的 todo.md 读出来必须是「文件不存在」而不是别的异常形态。
    const missing = path.join(REPO_ROOT, 'system/agents/__no_such_agent__/todo.md');
    expect(fs.existsSync(missing)).toBe(false);
    let code = null;
    try { fs.readFileSync(missing, 'utf-8'); } catch (e) { code = e.code; }
    expect(code).toBe('ENOENT');
  });
});

describe('W13 · 模板 usedCount 搬出 _registry.json(产生→落库→读回)', () => {
  const REGISTRY_PATH = path.join(REPO_ROOT, 'system/templates/_registry.json');
  const USAGE_PATH = path.join(REPO_ROOT, 'data/template-usage.json');

  const usageSnapshot = fs.existsSync(USAGE_PATH) ? fs.readFileSync(USAGE_PATH, 'utf-8') : null;
  afterAll(() => {
    if (usageSnapshot === null) fs.rmSync(USAGE_PATH, { force: true });
    else fs.writeFileSync(USAGE_PATH, usageSnapshot, 'utf-8');
  });

  it('计数 +1 只写运行态文件,_registry.json 一个字节都不变', () => {
    const svc = new CompanyService();
    const before = fs.readFileSync(REGISTRY_PATH, 'utf-8');
    const baseline = JSON.parse(before).find((t) => t.id === 'base')?.usedCount || 0;

    const readBack0 = svc.listTemplates().find((t) => t.id === 'base');
    expect(readBack0).toBeTruthy();
    const shown0 = readBack0.usedCount;

    svc._updateTemplateUsedCount('base');   // ① 产生(hire() 命中模板分支时调的就是这个)
    svc._updateTemplateUsedCount('base');

    // ② 落库:落在 data/ 下的运行态文件,不是人工清单
    expect(fs.readFileSync(REGISTRY_PATH, 'utf-8')).toBe(before);
    expect(JSON.parse(fs.readFileSync(USAGE_PATH, 'utf-8')).base).toBe(
      (JSON.parse(usageSnapshot || '{}').base || 0) + 2,
    );

    // ③ 读回:listTemplates = 人工基线 + 运行态增量,前端 badge 的数字不回退
    const readBack = svc.listTemplates().find((t) => t.id === 'base');
    expect(readBack.usedCount).toBe(shown0 + 2);
    expect(readBack.usedCount).toBeGreaterThanOrEqual(baseline);
  });

  it('运行态文件缺失 / 损坏时退化成只用 registry 基线,不崩', () => {
    const svc = new CompanyService();
    fs.writeFileSync(USAGE_PATH, '{ this is not json', 'utf-8');
    expect(svc._readTemplateUsage()).toEqual({});
    const baseline = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8')).find((t) => t.id === 'base')?.usedCount || 0;
    expect(svc.listTemplates().find((t) => t.id === 'base').usedCount).toBe(baseline);

    fs.rmSync(USAGE_PATH, { force: true });
    expect(svc._readTemplateUsage()).toEqual({});
    expect(svc.listTemplates().find((t) => t.id === 'base').usedCount).toBe(baseline);
  });
});