You need to sign in before continuing.
task-state-cleanup.js 7.43 KB
/**
 * 任务态清理(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_<id>` / `heartbeat-<id>` / `callback-<id>` / `task-<id>` / `T-<id>` /
 *    `conv-<id>` / `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 };