/** * 任务态清理(28 §5.6)。 * * `system/agents/{aid}/{cid}/{taskId}/` 在源码树里无限增长 —— 实查 1124 个三级目录、 * `system/agents` 共 228 MB。这套逻辑负责清它,**并且只清它**。 * * ── 不许碰的东西(28 §5.6b,硬约束)───────────────────────────────── * **公司态永不自动清理**:`{aid}/{cid}/` 下的 `memory/` 与 `conversations/` * 是 dream 的**唯一原料**,删掉损失的不是缓存而是这个 agent 全部的经验历史,**不可再生**。 * 所以下面的 PROTECTED 不是「优化」,是这套逻辑存在的前提。 * * ── 判据(四道,全过才删)─────────────────────────────────────────── * ① **只在真公司下面清**:公司清单查 `company_agents` 表(外加保留段 `_global`)。 * `system/agents/{aid}/{cid}/` 是约定不是校验 —— 二级目录里混着 `train/`、`workspace/` * 这些 ArenaSK 时代的运行态,和带时间戳的测试残留。它们**不在表里**,因此天然不被碰。 * (`train/hunt|digest|evolve` 正是这么被排除的:那是 FDE 的训练布局,不是任务态。) * ② **只删认得出来的**(白名单,不是黑名单)。任务态目录的名字有固定几种形状: * `run_` / `heartbeat-` / `callback-` / `task-` / `T-` / * `conv-` / `tpl-*` / `_no-task`。**除此之外一概不动。** * * ⚠️ 这条判据一开始写反了:第一版是「不在保护名单里的都算任务态」, * dry-run 直接选中了 `trend-scout-global/idearun/venv` 和 `youtube-crawler/idearun/data` * —— 一个 Python 虚拟环境和一个数据目录,都在真公司底下,都不是任务态。 * **删错不可逆,留着最多占磁盘 —— 判据必须是「认得出才删」。** * `memory` / `conversations` / `.claude` 仍然显式列在保护名单里(双保险)。 * ③ **年龄**:mtime 早于 N 天(默认 14)。 * ④ **run 还活着就不删**:目录名形如 `run_xxx` 且 `graph_runs` 里那条**不在终态**时跳过 —— * 正在跑的 run 的产物目录被删掉,症状是节点读不到上游产物,而且不报错。 * * ── 失效方向 ────────────────────────────────────────────────────────── * 判不出来一律**不删**。删错不可逆,留着最多占磁盘。 * 默认 dry-run,`apply: true` 才真删。 */ const fs = require('fs'); const path = require('path'); const locator = require('./agent-locator'); /** 公司态的组成部分,永不自动清理(28 §5.6b)。白名单之外本来就不删,这里是双保险。 */ const PROTECTED = new Set(['memory', 'conversations', '.claude']); /** * 认得出来的任务态目录名。**白名单,不是黑名单** —— 见文件头 §② 那条踩过的坑。 * 加新形状之前先想清楚:它真的是「跑完就能扔」的任务态吗? */ const TASK_DIR_PATTERNS = [ /^run_[0-9a-z]{6,}$/i, // 图执行的 run 产物目录 /^heartbeat-[0-9a-z]+$/i, // 心跳会话(每 5 分钟一个,量最大的一类) /^callback-[0-9a-zA-Z-]+$/, // 汇报回调 /^task-[0-9a-zA-Z-]+$/, // 派单任务 /^T-[0-9a-zA-Z-]+$/, // 任务 id 直接当目录名的那种 /^conv-[0-9a-zA-Z-]+$/, // 一次性对话 /^tpl-[0-9a-zA-Z-]+$/, // 模板训练会话 /^_no-task$/, // 没有任务 id 时的兜底落点 ]; const looksLikeTaskDir = (name) => TASK_DIR_PATTERNS.some((re) => re.test(name)); const DEFAULT_MAX_AGE_DAYS = 14; /** graph_runs 的终态 —— 只有终态 run 的产物目录才可以清 */ const TERMINAL = new Set(['completed', 'failed', 'stopped']); /** 这家公司在册吗?`_global` 是保留段,也算。 */ function realCompanies() { const set = new Set(['_global']); try { const { listCompanyAgents } = require('./company-service'); for (const row of listCompanyAgents()) set.add(row.company_id); } catch (err) { // 读不到名册就**一家都不认**,等于这一轮什么都不清 —— fail-closed console.warn(`[TaskCleanup] ⚠️ 读 company_agents 失败,本轮不清任何目录: ${err.message}`); return new Set(); } return set; } /** 这条 run 还活着吗?查不到(已被清理 / 不是 run 目录)按「不活着」算 */ function runStillActive(dirName) { if (!/^run_[0-9a-f]{6,}$/i.test(dirName)) return false; try { const wdb = require('./work-graph-db'); const row = wdb.getRun(dirName); if (!row) return false; return !TERMINAL.has(row.run_status); } catch { // 库读不动时按**活着**处理:宁可少删 return true; } } /** * @param {{maxAgeDays?: number, apply?: boolean, now?: number}} opts * @returns {{candidates: Array, removed: Array, skipped: Array}} * skipped 带 reason —— 「为什么没清」和「清了什么」一样值得看得见。 */ function sweep({ maxAgeDays = DEFAULT_MAX_AGE_DAYS, apply = false, now = Date.now() } = {}) { const companies = realCompanies(); const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000; const candidates = []; const removed = []; const skipped = []; let agents; try { agents = fs.readdirSync(locator.AGENTS_ROOT, { withFileTypes: true }) .filter((d) => d.isDirectory() && !d.name.startsWith('.')) .map((d) => d.name); } catch { return { candidates, removed, skipped }; } for (const aid of agents) { for (const cid of safeDirs(path.join(locator.AGENTS_ROOT, aid))) { if (!companies.has(cid)) { skipped.push({ path: `${aid}/${cid}`, reason: '不在 company_agents 名册里(train/ workspace/ 测试残留都走这条)' }); continue; } for (const task of safeDirs(path.join(locator.AGENTS_ROOT, aid, cid))) { const rel = `${aid}/${cid}/${task}`; const abs = path.join(locator.AGENTS_ROOT, aid, cid, task); if (PROTECTED.has(task)) { skipped.push({ path: rel, reason: '公司态,永不自动清理(28 §5.6b:dream 的唯一原料)' }); continue; } if (!looksLikeTaskDir(task)) { skipped.push({ path: rel, reason: '不是认得出来的任务态目录名(白名单之外一概不动)' }); continue; } let stat; try { stat = fs.statSync(abs); } catch { continue; } if (stat.mtimeMs > cutoff) { skipped.push({ path: rel, reason: `还新(${Math.round((now - stat.mtimeMs) / 86400000)} 天 < ${maxAgeDays} 天)` }); continue; } if (runStillActive(task)) { skipped.push({ path: rel, reason: 'run 还没到终态,产物目录不能删' }); continue; } candidates.push(rel); if (apply) { try { fs.rmSync(abs, { recursive: true, force: true }); removed.push(rel); } catch (err) { skipped.push({ path: rel, reason: `删除失败:${err.message}` }); } } } } } return { candidates, removed, skipped }; } function safeDirs(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return []; } } module.exports = { sweep, PROTECTED, DEFAULT_MAX_AGE_DAYS, looksLikeTaskDir };