/** * 任务态清理(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 }); });