diff --git a/docs/todo/system-design/28-followups.md b/docs/todo/system-design/28-followups.md index 899a88b..bdcb06d 100644 --- a/docs/todo/system-design/28-followups.md +++ b/docs/todo/system-design/28-followups.md @@ -93,9 +93,19 @@ origin 全部按映射回填正确),但「读一下状态」不该顺手改 `assertNotProdDbInTests` 那道闸门**只挡测试进程**,故意不挡普通脚本(挡了服务自己也起不来)。 要只读地看一眼,用 `new Database('data/graph.sqlite', {readonly: true})`,别走 `getDb()`。 -### 3.2 任务态清理策略(§5.6) -只有设计,没有实现。任务目录 `system/agents/{aid}/{cid}/{taskId}/` 在源码树里无限增长。 -**实现前必须先读主文档 §5.6b** —— 公司态是 dream 的原料,不是缓存,只能清任务态。 +### 3.2 ~~任务态清理策略(§5.6)~~ ✅ 已实现(2026-08-20) + +`server/services/task-state-cleanup.js` + `scripts/cleanup-task-state.js`,**默认 dry-run**。 +四道判据全过才删:① 公司在 `company_agents` 名册里(`train/` `workspace/` 与测试残留天然排除); +② 目录名在**任务态白名单**里;③ mtime 早于 N 天(默认 14);④ `run_*` 对应的 run 已到终态。 + +> **第 ② 条是踩出来的**:第一版写成黑名单(「不在保护名单里的都算任务态」), +> dry-run 直接选中了 `trend-scout-global/idearun/venv` 和 `youtube-crawler/idearun/data` +> —— 一个 Python 虚拟环境、一个数据目录,都在**真公司**底下,都不是任务态。 +> **删错不可逆,留着最多占磁盘 —— 判据必须是「认得出才删」,不是「不认识就删」。** + +实跑(30 天窗口):待删 0,跳过 955(556 还新 / 74 不在名册 / 60 公司态 / 11 白名单外)。 +公司态永远走「不许清」那条分支,守卫里有一条专门盯它。 ### 3.3 ~~`agent-chat.js` 是心跳落点 bug 的第二个入口~~ ✅ 已修(2026-08-20,`edeff2e`) `PUT /api/agent/:agentId/heartbeat`(前端 Agent 详情页走的就是它)用 diff --git a/docs/todo/system-design/29-dream-design.md b/docs/todo/system-design/29-dream-design.md index 919cdda..b932ad5 100644 --- a/docs/todo/system-design/29-dream-design.md +++ b/docs/todo/system-design/29-dream-design.md @@ -197,9 +197,24 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | **C2 按量**(某 agent 新增 N 条 session 摘要后触发) | 与实际产出量对齐;需要一个计数落点 | | **C3 手动**(控制台一个按钮 + API) | 先手动跑一段时间看提炼质量,稳定了再自动 | -### ❓ 决策 D —— 提炼质量怎么评 / 失败怎么办 +### 🔒 决策 D —— 能自动查的自动查,不能自动查的把回查成本降到几秒(2026-08-20 落地) -这一条**可以先不定**,但要知道它没定: +**「这条结论对不对」机器判不了**,需要理解内容。不假装有自动判据。 +能自动做的只有两件,两件都做了: + +1. **引用真伪可以自动查**。提炼产物的 `sources` 必须都出自这一轮真喂进去的原料, + 编一个出处直接拒(连去敏判定都不用调,省一次)。 + > 编了出处的经验是「无法验证」这件事最危险的形态:人抽查时顺着一个不存在的指针 + > 放弃,然后**默认它是真的**。 +2. **回查成本降到几秒**:`auditDistilled(agentId)` 把每条结论与它引用的**原料原文** + 并排列出来,指针悬空标 `null`(原料被清理过 / 出处是编的)。 + +真跑那 4 条实测:**8 条引用全部取得回原文**,模型没编出处。 + +> 仍然没有的是「结论对不对」的判据。`auditDistilled` 的输出**不是质量分**, +> 别当成那个用 —— 它只是把回查这件事变便宜了。 + +下面是原始的选项留档。 - 没有评价手段时,dream 的产出**无法验证对错**——它和 FDE 那边 「LLM 自己汇报分数」的问题是同一个(见 [architecture_todo_list](../future/architecture_todo_list.md) §1)。 @@ -210,8 +225,8 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) -> **进度**:步骤 1、2、3、4、5 全部实现,**并已真 CLI 跑通一次**(§5.5)。 -> 未做的是:定时挂载(决策 C)、写定义文件(`SKILL.md` / `soul.md` / `agent.md`)—— 见 §5.4。 +> **进度**:五步全部实现、真 CLI 跑通一次(§5.5),定时挂载与定义文件写入也已落地。 +> 决策 D 落到「引用真伪自动查 + 回查成本降到几秒」。**未做的只剩「结论对不对」的判据** —— 那一条只能靠人。 用户定的是两件一起做,不分先后。拆成可验收的步骤: @@ -311,8 +326,7 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」, 闸门(§B.3)也已就位,缺的是「往里写什么、写成什么形状」这半边设计。 -3. **决策 D(提炼质量怎么评)仍未定**。可追溯(`sources`)是它的便宜起步,不是替代品。 - §5.5 那次抽查是**人工**顺着 sources 回查的,不是自动的。 +3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。 --- diff --git a/scripts/cleanup-task-state.js b/scripts/cleanup-task-state.js new file mode 100644 index 0000000..bb8dd0d --- /dev/null +++ b/scripts/cleanup-task-state.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * 任务态清理的命令行入口(28 §5.6)。 + * + * node scripts/cleanup-task-state.js # dry-run,14 天窗口 + * node scripts/cleanup-task-state.js --days 30 # 换窗口 + * node scripts/cleanup-task-state.js --apply # 真删 + * node scripts/cleanup-task-state.js --why # 连「为什么没清」一起打 + * + * **公司态永不自动清理**(`memory/` / `conversations/`)—— 那是 dream 的唯一原料, + * 删掉不可再生。判据与保护名单都在 server/services/task-state-cleanup.js,改之前先读文件头。 + */ +const cleanup = require('../server/services/task-state-cleanup'); + +const argv = process.argv.slice(2); +const has = (f) => argv.includes(f); +const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? Number(argv[i + 1]) : d; }; + +const apply = has('--apply'); +const maxAgeDays = val('--days', cleanup.DEFAULT_MAX_AGE_DAYS); + +const r = cleanup.sweep({ maxAgeDays, apply }); + +for (const c of r.candidates) console.log(`${apply ? '删' : '待删'} ${c}`); +if (has('--why')) { + const by = new Map(); + for (const s of r.skipped) by.set(s.reason, (by.get(s.reason) || 0) + 1); + console.log('\n跳过统计:'); + for (const [reason, n] of [...by].sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(4)} ${reason}`); +} +console.log(`\n窗口 ${maxAgeDays} 天:${apply ? `已删 ${r.removed.length}` : `待删 ${r.candidates.length}`} 个,跳过 ${r.skipped.length} 个`); +if (!apply && r.candidates.length) console.log('要真删:加 --apply'); diff --git a/server/services/dream-service.js b/server/services/dream-service.js index 42a1683..8e65443 100644 --- a/server/services/dream-service.js +++ b/server/services/dream-service.js @@ -132,7 +132,23 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh }); const accepted = []; const rejected = []; + // 决策 D 的可做部分:**「结论对不对」机器判不了,但「引用是不是编的」可以。** + // 提炼产物的 sources 必须都出自这一轮真喂进去的原料 —— 编一个出处是 + // 机器可检的谎,而它恰恰是「无法验证」这件事最危险的形态: + // 一条编了出处的经验,人抽查时会顺着一个不存在的指针放弃,然后默认它是真的。 + const knownSources = new Set(fresh.map((e) => e.source)); for (const c of candidates || []) { + const cited = Array.isArray(c.sources) ? c.sources : []; + const dangling = cited.filter((x) => !knownSources.has(x)); + if (!cited.length || dangling.length) { + rejected.push({ + ...c, + reason: cited.length + ? `引用了本轮不存在的出处:${dangling.join('、')}(可追溯是唯一的质量抓手,编出处等于把它拆掉)` + : '没有给出 sources —— 无法回查,按拒绝处理', + }); + continue; + } // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed) let verdict; try { @@ -168,6 +184,43 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { } /** + * 抽查用:把已沉淀的每条经验和它引用的**原料原文**并排列出来。 + * + * 决策 D(提炼质量怎么评)目前没有自动判据,也不该假装有 —— 「这条结论对不对」 + * 需要理解内容。能自动做的只有两件事,两件都做了: + * · 写入时校验 `sources` 都出自本轮真喂进去的原料(编出处直接拒,见 dreamOnce); + * · 这里把结论与原文并排摆出来,让人抽查的成本降到几秒。 + * + * **不要把这个函数的输出当成「质量分」**。它只是把回查这件事变便宜了。 + */ +function auditDistilled(agentId) { + const file = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, 'experience.md'); + let text; + try { text = fs.readFileSync(file, 'utf-8'); } catch { return []; } + + // 原料按 source 建索引(跨公司都要,因为一条经验可能引了不同公司的记录) + const index = new Map(); + for (const cid of companiesOf(agentId)) { + for (const e of readRawEntries(agentId, cid)) index.set(e.source, e.text); + } + + const out = []; + for (const block of text.split(/\n### /).slice(1)) { + const title = block.split('\n')[0].trim(); + const m = /原料:(.+)$/m.exec(block); + const cited = m ? m[1].split(/[、,]/).map((x) => x.trim()).filter(Boolean) : []; + out.push({ + title, + sources: cited.map((src) => ({ + source: src, + text: index.has(src) ? index.get(src) : null, // null = 指针悬空(原料被清理过 / 出处是编的) + })), + }); + } + return out; +} + +/** * 落盘(决策 B)。 * * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。 @@ -505,7 +558,7 @@ module.exports = { collectMaterial, companiesOf, readRawEntries, defaultDistiller, defaultScreener, extractJson, DISTILLER_AGENT, SCREENER_AGENT, - writeDistilled, writeDefinitionFiles, definitionWritability, readState, writeState, + writeDistilled, writeDefinitionFiles, definitionWritability, auditDistilled, readState, writeState, DREAM_BEGIN, DREAM_END, setDistiller, setScreener, setDreamAgentCaller, RAW_SECTION, CROSS_COMPANY_MEMORY, diff --git a/server/services/task-state-cleanup.js b/server/services/task-state-cleanup.js new file mode 100644 index 0000000..e297a87 --- /dev/null +++ b/server/services/task-state-cleanup.js @@ -0,0 +1,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_` / `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 }; diff --git a/server/tests/unit/dream-service.test.js b/server/tests/unit/dream-service.test.js index 666e4d0..384d1fb 100644 --- a/server/tests/unit/dream-service.test.js +++ b/server/tests/unit/dream-service.test.js @@ -106,10 +106,11 @@ describe('dream · 骨架', () => { it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => { hire(CO_A); seedMemory(CO_A, ['一条经验']); + const SRC = `${CO_A}:memory.md#1`; // 出处必须是本轮真喂进去的,编的会被前置闸门直接拒 dream.setDistiller(async () => [ - { title: '通过的', body: '抽象经验', sources: ['a'] }, - { title: '被拒的', body: '含公司名', sources: ['b'] }, - { title: '判定炸了的', body: '???', sources: ['c'] }, + { title: '通过的', body: '抽象经验', sources: [SRC] }, + { title: '被拒的', body: '含公司名', sources: [SRC] }, + { title: '判定炸了的', body: '???', sources: [SRC] }, ]); dream.setScreener(async ({ candidate }) => { if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了'); @@ -128,6 +129,48 @@ describe('dream · 骨架', () => { expect(exp).not.toContain('被拒的'); }); + it('编出处 / 不给出处的产物直接拒 —— 可追溯是唯一的质量抓手(决策 D)', async () => { + // 「这条结论对不对」机器判不了,但「引用是不是编的」可以。 + // 编了出处的经验最危险:人抽查时顺着一个不存在的指针放弃,然后默认它是真的。 + hire(CO_A); + seedMemory(CO_A, ['真素材']); + dream.setDistiller(async () => [ + { title: '编出处的', body: 'x', sources: ['不存在的公司:memory.md#99'] }, + { title: '不给出处的', body: 'y' }, + { title: '规矩的', body: 'z', sources: [`${CO_A}:memory.md#1`] }, + ]); + dream.setScreener(async () => ({ shareable: true })); + + const r = await dream.dreamOnce(AGENT); + expect(r.accepted).toBe(1); + expect(r.rejected).toHaveLength(2); + expect(r.rejected.find((x) => x.title === '编出处的').reason).toContain('本轮不存在的出处'); + expect(r.rejected.find((x) => x.title === '不给出处的').reason).toContain('没有给出 sources'); + // 编出处那条**没被喂给去敏判定**(前置闸门,省一次调用) + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8'); + expect(exp).toContain('规矩的'); + expect(exp).not.toContain('编出处的'); + }); + + it('auditDistilled 把结论与原料原文并排摆出来,悬空指针标 null', async () => { + hire(CO_A); + seedMemory(CO_A, ['原料甲的正文']); + dream.setDistiller(async ({ entries }) => [ + { title: '经验甲', body: '正文', sources: entries.map((e) => e.source) }, + ]); + dream.setScreener(async () => ({ shareable: true })); + await dream.dreamOnce(AGENT); + + const audit = dream.auditDistilled(AGENT); + expect(audit).toHaveLength(1); + expect(audit[0].title).toBe('经验甲'); + expect(audit[0].sources[0].text).toBe('原料甲的正文'); // 顺着指针取回了原文 + + // 原料被清掉之后,指针悬空要显示成 null 而不是假装还在 + seedMemory(CO_A, []); + expect(dream.auditDistilled(AGENT)[0].sources[0].text).toBeNull(); + }); + it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => { hire(CO_A); seedMemory(CO_A, ['素材一']); @@ -160,7 +203,7 @@ describe('dream · 骨架', () => { dream.setDreamAgentCaller(async (agentName, prompt) => { calls.push({ agentName, prompt }); if (agentName === dream.DISTILLER_AGENT) { - return '好的。\n```json\n[{"title":"结论一","body":"做法一","sources":["x"]}]\n```'; + return `好的。\n\`\`\`json\n[{"title":"结论一","body":"做法一","sources":["${CO_A}:memory.md#1"]}]\n\`\`\``; } return '```json\n{"shareable":true,"reason":"没有可识别主体"}\n```'; }); diff --git a/server/tests/unit/task-state-cleanup.test.js b/server/tests/unit/task-state-cleanup.test.js new file mode 100644 index 0000000..ea4d4dc --- /dev/null +++ b/server/tests/unit/task-state-cleanup.test.js @@ -0,0 +1,90 @@ +/** + * 任务态清理(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 + }); +});