task-state-cleanup.test.js 4.81 KB
/**
 * 任务态清理(28 §5.6)—— **只清任务态,永不碰公司态**。
 *
 * ── 为什么这条测试比一般的清理逻辑更要紧 ────────────────────────────
 * 删错不可逆。而且被删错的那一半(公司态 `memory/` / `conversations/`)
 * 是 dream 的**唯一原料**:删掉损失的不是缓存,是这个 agent 全部的经验历史,
 * 且**不可再生**(28 §5.6b)。
 *
 * ── 四道判据 ──────────────────────────────────────────────────────────
 * ① 只在 `company_agents` 名册里的公司下面清(`train/` `workspace/` 和测试残留天然被排除);
 * ② **白名单**:只删认得出来的任务态目录名,其余一概不动;
 * ③ 年龄早于 N 天;
 * ④ `run_*` 目录对应的 run 还没到终态就不删。
 *
 * ── 第 ② 条是踩出来的 ─────────────────────────────────────────────────
 * 第一版写成黑名单(「不在保护名单里的都算任务态」),dry-run 直接选中了
 * `trend-scout-global/idearun/venv` 和 `youtube-crawler/idearun/data` ——
 * 一个 Python 虚拟环境、一个数据目录,都在**真公司**底下,都不是任务态。
 * 判据必须是「认得出才删」,不是「不认识就删」。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

// 隔离库:不设的话 graph-db 的闸门会拦下测试进程,realCompanies() 走 catch 分支
// 返回空集合 —— 那时**一家公司都不认**,整套逻辑什么都不清。
// 那个 fail-closed 行为本身是对的(见下面单独一条),但会让其它用例全部空跑。
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-cleanup-'));
process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');

const cleanup = require('../../services/task-state-cleanup');

describe('任务态清理 · 判据', () => {
  it('② 白名单认得出真实存在的那几种任务态目录名', () => {
    // 这些形状都是实查 system/agents 得到的,不是想出来的
    for (const name of [
      'run_9168bd24f46c', 'heartbeat-msy9yfdd', 'callback-T-msyu6u9b-939c',
      'task-T-abc123', 'T-msyu6u9b-939c', 'conv-abc', 'tpl-train-x', '_no-task',
    ]) {
      expect(cleanup.looksLikeTaskDir(name)).toBe(true);
    }
  });

  it('② 白名单**不**认这些 —— 第一版正是在这里判错的', () => {
    // venv / data 是真公司目录下的非任务态目录,实测被第一版黑名单判据选中过
    for (const name of ['venv', 'data', 'memory', 'conversations', '.claude',
      'workspace', 'node_modules', 'artifacts', 'projects']) {
      expect(cleanup.looksLikeTaskDir(name)).toBe(false);
    }
  });

  it('公司态永远在保护名单里(这一条红了 = dream 的原料要被删)', () => {
    expect(cleanup.PROTECTED.has('memory')).toBe(true);
    expect(cleanup.PROTECTED.has('conversations')).toBe(true);
  });

  it('读不到名册时一家公司都不认(fail-closed:宁可不清,也不赌)', () => {
    const orig = process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_DB_PATH; // 触发闸门 → listCompanyAgents 抛
    try {
      const r = cleanup.sweep({ maxAgeDays: 0 });
      expect(r.candidates).toEqual([]);
      expect(r.removed).toEqual([]);
    } finally { process.env.GRAPH_DB_PATH = orig; }
  });

  it('真扫一遍:默认 14 天窗口下不删任何东西,且跳过原因说得清', () => {
    const r = cleanup.sweep({ maxAgeDays: 14 });
    expect(r.removed).toEqual([]);               // dry-run 不删
    expect(r.skipped.length).toBeGreaterThan(50); // 样本非空:真的扫到了东西
    // 每条跳过都要有能读的原因 —— 「为什么没清」和「清了什么」一样值得看得见
    for (const s of r.skipped.slice(0, 50)) expect(String(s.reason).length).toBeGreaterThan(4);
    // 公司态一定出现在跳过里,且理由点名 §5.6b
    const mem = r.skipped.find((s) => s.path.endsWith('/memory'));
    expect(mem).toBeTruthy();
    expect(mem.reason).toContain('公司态');
  });

  it('把窗口放到 0 天时确实能选出任务态目录,且**一条公司态都没混进来**', () => {
    const r = cleanup.sweep({ maxAgeDays: 0 });
    expect(r.candidates.length).toBeGreaterThan(0); // 样本非空,否则下面的负向断言是空跑
    for (const c of r.candidates) {
      const last = c.split('/').pop();
      expect(cleanup.PROTECTED.has(last)).toBe(false);
      expect(cleanup.looksLikeTaskDir(last)).toBe(true);
    }
    expect(r.removed).toEqual([]); // 仍然是 dry-run
  });
});