task-state-cleanup.js
7.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
* 任务态清理(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 };