Commit ab57a0afb3030c9d70defe95161d570ddcf0b012
1 parent
e9f380a5
feat: 决策 D(引用真伪自动查)+ 任务态清理(28 §5.6)
## 决策 D:能自动查的自动查,不能自动查的把回查成本降到几秒 「这条结论对不对」机器判不了,需要理解内容 —— **不假装有自动判据**。 能自动做的只有两件,两件都做了: ① **引用真伪可以自动查**:提炼产物的 sources 必须都出自这一轮真喂进去的原料, 编一个出处直接拒(前置闸门,连去敏判定都不用调)。 编了出处的经验是「无法验证」最危险的形态 —— 人抽查时顺着一个不存在的指针放弃, 然后**默认它是真的**。 ② **auditDistilled()**:把每条结论与它引用的原料原文并排列出来, 指针悬空标 null(原料被清理过 / 出处是编的)。 真跑那 4 条实测:**8 条引用全部取得回原文**,模型没编出处。 ⚠️ auditDistilled 的输出**不是质量分**,别当成那个用。 ## 任务态清理(28 §5.6) 四道判据全过才删:① 公司在 company_agents 名册里;② 目录名在**任务态白名单**里; ③ mtime 早于 N 天(默认 14);④ run_* 对应的 run 已到终态。默认 dry-run。 **第 ② 条是踩出来的**:第一版写成黑名单(「不在保护名单里的都算任务态」), dry-run 直接选中了 trend-scout-global/idearun/venv 和 youtube-crawler/idearun/data —— 一个 Python 虚拟环境、一个数据目录,都在**真公司**底下,都不是任务态。 **删错不可逆,留着最多占磁盘 —— 判据必须是「认得出才删」,不是「不认识就删」。** 读不到名册时一家公司都不认(fail-closed),守卫里单独钉了这条。 公司态(memory/ conversations/)永远走「不许清」那条分支 —— 那是 dream 的唯一原料, 删掉不可再生(28 §5.6b)。 实跑 30 天窗口:待删 0,跳过 955(556 还新 / 74 不在名册 / 60 公司态 / 11 白名单外)。 全量:145 文件 / 1338 测试全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
7 changed files
with
407 additions
and
14 deletions
docs/todo/system-design/28-followups.md
| @@ -93,9 +93,19 @@ origin 全部按映射回填正确),但「读一下状态」不该顺手改 | @@ -93,9 +93,19 @@ origin 全部按映射回填正确),但「读一下状态」不该顺手改 | ||
| 93 | `assertNotProdDbInTests` 那道闸门**只挡测试进程**,故意不挡普通脚本(挡了服务自己也起不来)。 | 93 | `assertNotProdDbInTests` 那道闸门**只挡测试进程**,故意不挡普通脚本(挡了服务自己也起不来)。 |
| 94 | 要只读地看一眼,用 `new Database('data/graph.sqlite', {readonly: true})`,别走 `getDb()`。 | 94 | 要只读地看一眼,用 `new Database('data/graph.sqlite', {readonly: true})`,别走 `getDb()`。 |
| 95 | 95 | ||
| 96 | -### 3.2 任务态清理策略(§5.6) | ||
| 97 | -只有设计,没有实现。任务目录 `system/agents/{aid}/{cid}/{taskId}/` 在源码树里无限增长。 | ||
| 98 | -**实现前必须先读主文档 §5.6b** —— 公司态是 dream 的原料,不是缓存,只能清任务态。 | 96 | +### 3.2 ~~任务态清理策略(§5.6)~~ ✅ 已实现(2026-08-20) |
| 97 | + | ||
| 98 | +`server/services/task-state-cleanup.js` + `scripts/cleanup-task-state.js`,**默认 dry-run**。 | ||
| 99 | +四道判据全过才删:① 公司在 `company_agents` 名册里(`train/` `workspace/` 与测试残留天然排除); | ||
| 100 | +② 目录名在**任务态白名单**里;③ mtime 早于 N 天(默认 14);④ `run_*` 对应的 run 已到终态。 | ||
| 101 | + | ||
| 102 | +> **第 ② 条是踩出来的**:第一版写成黑名单(「不在保护名单里的都算任务态」), | ||
| 103 | +> dry-run 直接选中了 `trend-scout-global/idearun/venv` 和 `youtube-crawler/idearun/data` | ||
| 104 | +> —— 一个 Python 虚拟环境、一个数据目录,都在**真公司**底下,都不是任务态。 | ||
| 105 | +> **删错不可逆,留着最多占磁盘 —— 判据必须是「认得出才删」,不是「不认识就删」。** | ||
| 106 | + | ||
| 107 | +实跑(30 天窗口):待删 0,跳过 955(556 还新 / 74 不在名册 / 60 公司态 / 11 白名单外)。 | ||
| 108 | +公司态永远走「不许清」那条分支,守卫里有一条专门盯它。 | ||
| 99 | 109 | ||
| 100 | ### 3.3 ~~`agent-chat.js` 是心跳落点 bug 的第二个入口~~ ✅ 已修(2026-08-20,`edeff2e`) | 110 | ### 3.3 ~~`agent-chat.js` 是心跳落点 bug 的第二个入口~~ ✅ 已修(2026-08-20,`edeff2e`) |
| 101 | `PUT /api/agent/:agentId/heartbeat`(前端 Agent 详情页走的就是它)用 | 111 | `PUT /api/agent/:agentId/heartbeat`(前端 Agent 详情页走的就是它)用 |
docs/todo/system-design/29-dream-design.md
| @@ -197,9 +197,24 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | @@ -197,9 +197,24 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | ||
| 197 | | **C2 按量**(某 agent 新增 N 条 session 摘要后触发) | 与实际产出量对齐;需要一个计数落点 | | 197 | | **C2 按量**(某 agent 新增 N 条 session 摘要后触发) | 与实际产出量对齐;需要一个计数落点 | |
| 198 | | **C3 手动**(控制台一个按钮 + API) | 先手动跑一段时间看提炼质量,稳定了再自动 | | 198 | | **C3 手动**(控制台一个按钮 + API) | 先手动跑一段时间看提炼质量,稳定了再自动 | |
| 199 | 199 | ||
| 200 | -### ❓ 决策 D —— 提炼质量怎么评 / 失败怎么办 | 200 | +### 🔒 决策 D —— 能自动查的自动查,不能自动查的把回查成本降到几秒(2026-08-20 落地) |
| 201 | 201 | ||
| 202 | -这一条**可以先不定**,但要知道它没定: | 202 | +**「这条结论对不对」机器判不了**,需要理解内容。不假装有自动判据。 |
| 203 | +能自动做的只有两件,两件都做了: | ||
| 204 | + | ||
| 205 | +1. **引用真伪可以自动查**。提炼产物的 `sources` 必须都出自这一轮真喂进去的原料, | ||
| 206 | + 编一个出处直接拒(连去敏判定都不用调,省一次)。 | ||
| 207 | + > 编了出处的经验是「无法验证」这件事最危险的形态:人抽查时顺着一个不存在的指针 | ||
| 208 | + > 放弃,然后**默认它是真的**。 | ||
| 209 | +2. **回查成本降到几秒**:`auditDistilled(agentId)` 把每条结论与它引用的**原料原文** | ||
| 210 | + 并排列出来,指针悬空标 `null`(原料被清理过 / 出处是编的)。 | ||
| 211 | + | ||
| 212 | +真跑那 4 条实测:**8 条引用全部取得回原文**,模型没编出处。 | ||
| 213 | + | ||
| 214 | +> 仍然没有的是「结论对不对」的判据。`auditDistilled` 的输出**不是质量分**, | ||
| 215 | +> 别当成那个用 —— 它只是把回查这件事变便宜了。 | ||
| 216 | + | ||
| 217 | +下面是原始的选项留档。 | ||
| 203 | 218 | ||
| 204 | - 没有评价手段时,dream 的产出**无法验证对错**——它和 FDE 那边 | 219 | - 没有评价手段时,dream 的产出**无法验证对错**——它和 FDE 那边 |
| 205 | 「LLM 自己汇报分数」的问题是同一个(见 [architecture_todo_list](../future/architecture_todo_list.md) §1)。 | 220 | 「LLM 自己汇报分数」的问题是同一个(见 [architecture_todo_list](../future/architecture_todo_list.md) §1)。 |
| @@ -210,8 +225,8 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | @@ -210,8 +225,8 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`; | ||
| 210 | 225 | ||
| 211 | ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) | 226 | ## 四、推进顺序 🔒 **上游与 dream 一起做**(2026-08-20 拍板) |
| 212 | 227 | ||
| 213 | -> **进度**:步骤 1、2、3、4、5 全部实现,**并已真 CLI 跑通一次**(§5.5)。 | ||
| 214 | -> 未做的是:定时挂载(决策 C)、写定义文件(`SKILL.md` / `soul.md` / `agent.md`)—— 见 §5.4。 | 228 | +> **进度**:五步全部实现、真 CLI 跑通一次(§5.5),定时挂载与定义文件写入也已落地。 |
| 229 | +> 决策 D 落到「引用真伪自动查 + 回查成本降到几秒」。**未做的只剩「结论对不对」的判据** —— 那一条只能靠人。 | ||
| 215 | 230 | ||
| 216 | 用户定的是两件一起做,不分先后。拆成可验收的步骤: | 231 | 用户定的是两件一起做,不分先后。拆成可验收的步骤: |
| 217 | 232 | ||
| @@ -311,8 +326,7 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md | @@ -311,8 +326,7 @@ mtime 会被任何一次无关写入推动(例如别处重写整份 `memory.md | ||
| 311 | (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义 | 326 | (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义 |
| 312 | 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」, | 327 | 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」, |
| 313 | 闸门(§B.3)也已就位,缺的是「往里写什么、写成什么形状」这半边设计。 | 328 | 闸门(§B.3)也已就位,缺的是「往里写什么、写成什么形状」这半边设计。 |
| 314 | -3. **决策 D(提炼质量怎么评)仍未定**。可追溯(`sources`)是它的便宜起步,不是替代品。 | ||
| 315 | - §5.5 那次抽查是**人工**顺着 sources 回查的,不是自动的。 | 329 | +3. ~~决策 D~~ ✅ 已落地(见上)—— 但「结论对不对」仍然只能靠人抽查。 |
| 316 | 330 | ||
| 317 | --- | 331 | --- |
| 318 | 332 |
scripts/cleanup-task-state.js
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +/** | ||
| 3 | + * 任务态清理的命令行入口(28 §5.6)。 | ||
| 4 | + * | ||
| 5 | + * node scripts/cleanup-task-state.js # dry-run,14 天窗口 | ||
| 6 | + * node scripts/cleanup-task-state.js --days 30 # 换窗口 | ||
| 7 | + * node scripts/cleanup-task-state.js --apply # 真删 | ||
| 8 | + * node scripts/cleanup-task-state.js --why # 连「为什么没清」一起打 | ||
| 9 | + * | ||
| 10 | + * **公司态永不自动清理**(`memory/` / `conversations/`)—— 那是 dream 的唯一原料, | ||
| 11 | + * 删掉不可再生。判据与保护名单都在 server/services/task-state-cleanup.js,改之前先读文件头。 | ||
| 12 | + */ | ||
| 13 | +const cleanup = require('../server/services/task-state-cleanup'); | ||
| 14 | + | ||
| 15 | +const argv = process.argv.slice(2); | ||
| 16 | +const has = (f) => argv.includes(f); | ||
| 17 | +const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? Number(argv[i + 1]) : d; }; | ||
| 18 | + | ||
| 19 | +const apply = has('--apply'); | ||
| 20 | +const maxAgeDays = val('--days', cleanup.DEFAULT_MAX_AGE_DAYS); | ||
| 21 | + | ||
| 22 | +const r = cleanup.sweep({ maxAgeDays, apply }); | ||
| 23 | + | ||
| 24 | +for (const c of r.candidates) console.log(`${apply ? '删' : '待删'} ${c}`); | ||
| 25 | +if (has('--why')) { | ||
| 26 | + const by = new Map(); | ||
| 27 | + for (const s of r.skipped) by.set(s.reason, (by.get(s.reason) || 0) + 1); | ||
| 28 | + console.log('\n跳过统计:'); | ||
| 29 | + for (const [reason, n] of [...by].sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(4)} ${reason}`); | ||
| 30 | +} | ||
| 31 | +console.log(`\n窗口 ${maxAgeDays} 天:${apply ? `已删 ${r.removed.length}` : `待删 ${r.candidates.length}`} 个,跳过 ${r.skipped.length} 个`); | ||
| 32 | +if (!apply && r.candidates.length) console.log('要真删:加 --apply'); |
server/services/dream-service.js
| @@ -132,7 +132,23 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { | @@ -132,7 +132,23 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { | ||
| 132 | const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh }); | 132 | const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh }); |
| 133 | const accepted = []; | 133 | const accepted = []; |
| 134 | const rejected = []; | 134 | const rejected = []; |
| 135 | + // 决策 D 的可做部分:**「结论对不对」机器判不了,但「引用是不是编的」可以。** | ||
| 136 | + // 提炼产物的 sources 必须都出自这一轮真喂进去的原料 —— 编一个出处是 | ||
| 137 | + // 机器可检的谎,而它恰恰是「无法验证」这件事最危险的形态: | ||
| 138 | + // 一条编了出处的经验,人抽查时会顺着一个不存在的指针放弃,然后默认它是真的。 | ||
| 139 | + const knownSources = new Set(fresh.map((e) => e.source)); | ||
| 135 | for (const c of candidates || []) { | 140 | for (const c of candidates || []) { |
| 141 | + const cited = Array.isArray(c.sources) ? c.sources : []; | ||
| 142 | + const dangling = cited.filter((x) => !knownSources.has(x)); | ||
| 143 | + if (!cited.length || dangling.length) { | ||
| 144 | + rejected.push({ | ||
| 145 | + ...c, | ||
| 146 | + reason: cited.length | ||
| 147 | + ? `引用了本轮不存在的出处:${dangling.join('、')}(可追溯是唯一的质量抓手,编出处等于把它拆掉)` | ||
| 148 | + : '没有给出 sources —— 无法回查,按拒绝处理', | ||
| 149 | + }); | ||
| 150 | + continue; | ||
| 151 | + } | ||
| 136 | // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed) | 152 | // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed) |
| 137 | let verdict; | 153 | let verdict; |
| 138 | try { | 154 | try { |
| @@ -168,6 +184,43 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { | @@ -168,6 +184,43 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { | ||
| 168 | } | 184 | } |
| 169 | 185 | ||
| 170 | /** | 186 | /** |
| 187 | + * 抽查用:把已沉淀的每条经验和它引用的**原料原文**并排列出来。 | ||
| 188 | + * | ||
| 189 | + * 决策 D(提炼质量怎么评)目前没有自动判据,也不该假装有 —— 「这条结论对不对」 | ||
| 190 | + * 需要理解内容。能自动做的只有两件事,两件都做了: | ||
| 191 | + * · 写入时校验 `sources` 都出自本轮真喂进去的原料(编出处直接拒,见 dreamOnce); | ||
| 192 | + * · 这里把结论与原文并排摆出来,让人抽查的成本降到几秒。 | ||
| 193 | + * | ||
| 194 | + * **不要把这个函数的输出当成「质量分」**。它只是把回查这件事变便宜了。 | ||
| 195 | + */ | ||
| 196 | +function auditDistilled(agentId) { | ||
| 197 | + const file = path.join(locator.bodyDir(agentId), CROSS_COMPANY_MEMORY, 'experience.md'); | ||
| 198 | + let text; | ||
| 199 | + try { text = fs.readFileSync(file, 'utf-8'); } catch { return []; } | ||
| 200 | + | ||
| 201 | + // 原料按 source 建索引(跨公司都要,因为一条经验可能引了不同公司的记录) | ||
| 202 | + const index = new Map(); | ||
| 203 | + for (const cid of companiesOf(agentId)) { | ||
| 204 | + for (const e of readRawEntries(agentId, cid)) index.set(e.source, e.text); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + const out = []; | ||
| 208 | + for (const block of text.split(/\n### /).slice(1)) { | ||
| 209 | + const title = block.split('\n')[0].trim(); | ||
| 210 | + const m = /原料:(.+)$/m.exec(block); | ||
| 211 | + const cited = m ? m[1].split(/[、,]/).map((x) => x.trim()).filter(Boolean) : []; | ||
| 212 | + out.push({ | ||
| 213 | + title, | ||
| 214 | + sources: cited.map((src) => ({ | ||
| 215 | + source: src, | ||
| 216 | + text: index.has(src) ? index.get(src) : null, // null = 指针悬空(原料被清理过 / 出处是编的) | ||
| 217 | + })), | ||
| 218 | + }); | ||
| 219 | + } | ||
| 220 | + return out; | ||
| 221 | +} | ||
| 222 | + | ||
| 223 | +/** | ||
| 171 | * 落盘(决策 B)。 | 224 | * 落盘(决策 B)。 |
| 172 | * | 225 | * |
| 173 | * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。 | 226 | * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。 |
| @@ -505,7 +558,7 @@ module.exports = { | @@ -505,7 +558,7 @@ module.exports = { | ||
| 505 | collectMaterial, companiesOf, readRawEntries, | 558 | collectMaterial, companiesOf, readRawEntries, |
| 506 | defaultDistiller, defaultScreener, extractJson, | 559 | defaultDistiller, defaultScreener, extractJson, |
| 507 | DISTILLER_AGENT, SCREENER_AGENT, | 560 | DISTILLER_AGENT, SCREENER_AGENT, |
| 508 | - writeDistilled, writeDefinitionFiles, definitionWritability, readState, writeState, | 561 | + writeDistilled, writeDefinitionFiles, definitionWritability, auditDistilled, readState, writeState, |
| 509 | DREAM_BEGIN, DREAM_END, | 562 | DREAM_BEGIN, DREAM_END, |
| 510 | setDistiller, setScreener, setDreamAgentCaller, | 563 | setDistiller, setScreener, setDreamAgentCaller, |
| 511 | RAW_SECTION, CROSS_COMPANY_MEMORY, | 564 | RAW_SECTION, CROSS_COMPANY_MEMORY, |
server/services/task-state-cleanup.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 任务态清理(28 §5.6)。 | ||
| 3 | + * | ||
| 4 | + * `system/agents/{aid}/{cid}/{taskId}/` 在源码树里无限增长 —— 实查 1124 个三级目录、 | ||
| 5 | + * `system/agents` 共 228 MB。这套逻辑负责清它,**并且只清它**。 | ||
| 6 | + * | ||
| 7 | + * ── 不许碰的东西(28 §5.6b,硬约束)───────────────────────────────── | ||
| 8 | + * **公司态永不自动清理**:`{aid}/{cid}/` 下的 `memory/` 与 `conversations/` | ||
| 9 | + * 是 dream 的**唯一原料**,删掉损失的不是缓存而是这个 agent 全部的经验历史,**不可再生**。 | ||
| 10 | + * 所以下面的 PROTECTED 不是「优化」,是这套逻辑存在的前提。 | ||
| 11 | + * | ||
| 12 | + * ── 判据(四道,全过才删)─────────────────────────────────────────── | ||
| 13 | + * ① **只在真公司下面清**:公司清单查 `company_agents` 表(外加保留段 `_global`)。 | ||
| 14 | + * `system/agents/{aid}/{cid}/` 是约定不是校验 —— 二级目录里混着 `train/`、`workspace/` | ||
| 15 | + * 这些 ArenaSK 时代的运行态,和带时间戳的测试残留。它们**不在表里**,因此天然不被碰。 | ||
| 16 | + * (`train/hunt|digest|evolve` 正是这么被排除的:那是 FDE 的训练布局,不是任务态。) | ||
| 17 | + * ② **只删认得出来的**(白名单,不是黑名单)。任务态目录的名字有固定几种形状: | ||
| 18 | + * `run_<id>` / `heartbeat-<id>` / `callback-<id>` / `task-<id>` / `T-<id>` / | ||
| 19 | + * `conv-<id>` / `tpl-*` / `_no-task`。**除此之外一概不动。** | ||
| 20 | + * | ||
| 21 | + * ⚠️ 这条判据一开始写反了:第一版是「不在保护名单里的都算任务态」, | ||
| 22 | + * dry-run 直接选中了 `trend-scout-global/idearun/venv` 和 `youtube-crawler/idearun/data` | ||
| 23 | + * —— 一个 Python 虚拟环境和一个数据目录,都在真公司底下,都不是任务态。 | ||
| 24 | + * **删错不可逆,留着最多占磁盘 —— 判据必须是「认得出才删」。** | ||
| 25 | + * `memory` / `conversations` / `.claude` 仍然显式列在保护名单里(双保险)。 | ||
| 26 | + * ③ **年龄**:mtime 早于 N 天(默认 14)。 | ||
| 27 | + * ④ **run 还活着就不删**:目录名形如 `run_xxx` 且 `graph_runs` 里那条**不在终态**时跳过 —— | ||
| 28 | + * 正在跑的 run 的产物目录被删掉,症状是节点读不到上游产物,而且不报错。 | ||
| 29 | + * | ||
| 30 | + * ── 失效方向 ────────────────────────────────────────────────────────── | ||
| 31 | + * 判不出来一律**不删**。删错不可逆,留着最多占磁盘。 | ||
| 32 | + * 默认 dry-run,`apply: true` 才真删。 | ||
| 33 | + */ | ||
| 34 | + | ||
| 35 | +const fs = require('fs'); | ||
| 36 | +const path = require('path'); | ||
| 37 | +const locator = require('./agent-locator'); | ||
| 38 | + | ||
| 39 | +/** 公司态的组成部分,永不自动清理(28 §5.6b)。白名单之外本来就不删,这里是双保险。 */ | ||
| 40 | +const PROTECTED = new Set(['memory', 'conversations', '.claude']); | ||
| 41 | + | ||
| 42 | +/** | ||
| 43 | + * 认得出来的任务态目录名。**白名单,不是黑名单** —— 见文件头 §② 那条踩过的坑。 | ||
| 44 | + * 加新形状之前先想清楚:它真的是「跑完就能扔」的任务态吗? | ||
| 45 | + */ | ||
| 46 | +const TASK_DIR_PATTERNS = [ | ||
| 47 | + /^run_[0-9a-z]{6,}$/i, // 图执行的 run 产物目录 | ||
| 48 | + /^heartbeat-[0-9a-z]+$/i, // 心跳会话(每 5 分钟一个,量最大的一类) | ||
| 49 | + /^callback-[0-9a-zA-Z-]+$/, // 汇报回调 | ||
| 50 | + /^task-[0-9a-zA-Z-]+$/, // 派单任务 | ||
| 51 | + /^T-[0-9a-zA-Z-]+$/, // 任务 id 直接当目录名的那种 | ||
| 52 | + /^conv-[0-9a-zA-Z-]+$/, // 一次性对话 | ||
| 53 | + /^tpl-[0-9a-zA-Z-]+$/, // 模板训练会话 | ||
| 54 | + /^_no-task$/, // 没有任务 id 时的兜底落点 | ||
| 55 | +]; | ||
| 56 | +const looksLikeTaskDir = (name) => TASK_DIR_PATTERNS.some((re) => re.test(name)); | ||
| 57 | +const DEFAULT_MAX_AGE_DAYS = 14; | ||
| 58 | +/** graph_runs 的终态 —— 只有终态 run 的产物目录才可以清 */ | ||
| 59 | +const TERMINAL = new Set(['completed', 'failed', 'stopped']); | ||
| 60 | + | ||
| 61 | +/** 这家公司在册吗?`_global` 是保留段,也算。 */ | ||
| 62 | +function realCompanies() { | ||
| 63 | + const set = new Set(['_global']); | ||
| 64 | + try { | ||
| 65 | + const { listCompanyAgents } = require('./company-service'); | ||
| 66 | + for (const row of listCompanyAgents()) set.add(row.company_id); | ||
| 67 | + } catch (err) { | ||
| 68 | + // 读不到名册就**一家都不认**,等于这一轮什么都不清 —— fail-closed | ||
| 69 | + console.warn(`[TaskCleanup] ⚠️ 读 company_agents 失败,本轮不清任何目录: ${err.message}`); | ||
| 70 | + return new Set(); | ||
| 71 | + } | ||
| 72 | + return set; | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +/** 这条 run 还活着吗?查不到(已被清理 / 不是 run 目录)按「不活着」算 */ | ||
| 76 | +function runStillActive(dirName) { | ||
| 77 | + if (!/^run_[0-9a-f]{6,}$/i.test(dirName)) return false; | ||
| 78 | + try { | ||
| 79 | + const wdb = require('./work-graph-db'); | ||
| 80 | + const row = wdb.getRun(dirName); | ||
| 81 | + if (!row) return false; | ||
| 82 | + return !TERMINAL.has(row.run_status); | ||
| 83 | + } catch { | ||
| 84 | + // 库读不动时按**活着**处理:宁可少删 | ||
| 85 | + return true; | ||
| 86 | + } | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/** | ||
| 90 | + * @param {{maxAgeDays?: number, apply?: boolean, now?: number}} opts | ||
| 91 | + * @returns {{candidates: Array, removed: Array, skipped: Array}} | ||
| 92 | + * skipped 带 reason —— 「为什么没清」和「清了什么」一样值得看得见。 | ||
| 93 | + */ | ||
| 94 | +function sweep({ maxAgeDays = DEFAULT_MAX_AGE_DAYS, apply = false, now = Date.now() } = {}) { | ||
| 95 | + const companies = realCompanies(); | ||
| 96 | + const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000; | ||
| 97 | + const candidates = []; const removed = []; const skipped = []; | ||
| 98 | + | ||
| 99 | + let agents; | ||
| 100 | + try { | ||
| 101 | + agents = fs.readdirSync(locator.AGENTS_ROOT, { withFileTypes: true }) | ||
| 102 | + .filter((d) => d.isDirectory() && !d.name.startsWith('.')) | ||
| 103 | + .map((d) => d.name); | ||
| 104 | + } catch { return { candidates, removed, skipped }; } | ||
| 105 | + | ||
| 106 | + for (const aid of agents) { | ||
| 107 | + for (const cid of safeDirs(path.join(locator.AGENTS_ROOT, aid))) { | ||
| 108 | + if (!companies.has(cid)) { | ||
| 109 | + skipped.push({ path: `${aid}/${cid}`, reason: '不在 company_agents 名册里(train/ workspace/ 测试残留都走这条)' }); | ||
| 110 | + continue; | ||
| 111 | + } | ||
| 112 | + for (const task of safeDirs(path.join(locator.AGENTS_ROOT, aid, cid))) { | ||
| 113 | + const rel = `${aid}/${cid}/${task}`; | ||
| 114 | + const abs = path.join(locator.AGENTS_ROOT, aid, cid, task); | ||
| 115 | + if (PROTECTED.has(task)) { | ||
| 116 | + skipped.push({ path: rel, reason: '公司态,永不自动清理(28 §5.6b:dream 的唯一原料)' }); | ||
| 117 | + continue; | ||
| 118 | + } | ||
| 119 | + if (!looksLikeTaskDir(task)) { | ||
| 120 | + skipped.push({ path: rel, reason: '不是认得出来的任务态目录名(白名单之外一概不动)' }); | ||
| 121 | + continue; | ||
| 122 | + } | ||
| 123 | + let stat; | ||
| 124 | + try { stat = fs.statSync(abs); } catch { continue; } | ||
| 125 | + if (stat.mtimeMs > cutoff) { | ||
| 126 | + skipped.push({ path: rel, reason: `还新(${Math.round((now - stat.mtimeMs) / 86400000)} 天 < ${maxAgeDays} 天)` }); | ||
| 127 | + continue; | ||
| 128 | + } | ||
| 129 | + if (runStillActive(task)) { | ||
| 130 | + skipped.push({ path: rel, reason: 'run 还没到终态,产物目录不能删' }); | ||
| 131 | + continue; | ||
| 132 | + } | ||
| 133 | + candidates.push(rel); | ||
| 134 | + if (apply) { | ||
| 135 | + try { fs.rmSync(abs, { recursive: true, force: true }); removed.push(rel); } catch (err) { | ||
| 136 | + skipped.push({ path: rel, reason: `删除失败:${err.message}` }); | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + } | ||
| 142 | + return { candidates, removed, skipped }; | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +function safeDirs(dir) { | ||
| 146 | + try { | ||
| 147 | + return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); | ||
| 148 | + } catch { return []; } | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +module.exports = { sweep, PROTECTED, DEFAULT_MAX_AGE_DAYS, looksLikeTaskDir }; |
server/tests/unit/dream-service.test.js
| @@ -106,10 +106,11 @@ describe('dream · 骨架', () => { | @@ -106,10 +106,11 @@ describe('dream · 骨架', () => { | ||
| 106 | it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => { | 106 | it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => { |
| 107 | hire(CO_A); | 107 | hire(CO_A); |
| 108 | seedMemory(CO_A, ['一条经验']); | 108 | seedMemory(CO_A, ['一条经验']); |
| 109 | + const SRC = `${CO_A}:memory.md#1`; // 出处必须是本轮真喂进去的,编的会被前置闸门直接拒 | ||
| 109 | dream.setDistiller(async () => [ | 110 | dream.setDistiller(async () => [ |
| 110 | - { title: '通过的', body: '抽象经验', sources: ['a'] }, | ||
| 111 | - { title: '被拒的', body: '含公司名', sources: ['b'] }, | ||
| 112 | - { title: '判定炸了的', body: '???', sources: ['c'] }, | 111 | + { title: '通过的', body: '抽象经验', sources: [SRC] }, |
| 112 | + { title: '被拒的', body: '含公司名', sources: [SRC] }, | ||
| 113 | + { title: '判定炸了的', body: '???', sources: [SRC] }, | ||
| 113 | ]); | 114 | ]); |
| 114 | dream.setScreener(async ({ candidate }) => { | 115 | dream.setScreener(async ({ candidate }) => { |
| 115 | if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了'); | 116 | if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了'); |
| @@ -128,6 +129,48 @@ describe('dream · 骨架', () => { | @@ -128,6 +129,48 @@ describe('dream · 骨架', () => { | ||
| 128 | expect(exp).not.toContain('被拒的'); | 129 | expect(exp).not.toContain('被拒的'); |
| 129 | }); | 130 | }); |
| 130 | 131 | ||
| 132 | + it('编出处 / 不给出处的产物直接拒 —— 可追溯是唯一的质量抓手(决策 D)', async () => { | ||
| 133 | + // 「这条结论对不对」机器判不了,但「引用是不是编的」可以。 | ||
| 134 | + // 编了出处的经验最危险:人抽查时顺着一个不存在的指针放弃,然后默认它是真的。 | ||
| 135 | + hire(CO_A); | ||
| 136 | + seedMemory(CO_A, ['真素材']); | ||
| 137 | + dream.setDistiller(async () => [ | ||
| 138 | + { title: '编出处的', body: 'x', sources: ['不存在的公司:memory.md#99'] }, | ||
| 139 | + { title: '不给出处的', body: 'y' }, | ||
| 140 | + { title: '规矩的', body: 'z', sources: [`${CO_A}:memory.md#1`] }, | ||
| 141 | + ]); | ||
| 142 | + dream.setScreener(async () => ({ shareable: true })); | ||
| 143 | + | ||
| 144 | + const r = await dream.dreamOnce(AGENT); | ||
| 145 | + expect(r.accepted).toBe(1); | ||
| 146 | + expect(r.rejected).toHaveLength(2); | ||
| 147 | + expect(r.rejected.find((x) => x.title === '编出处的').reason).toContain('本轮不存在的出处'); | ||
| 148 | + expect(r.rejected.find((x) => x.title === '不给出处的').reason).toContain('没有给出 sources'); | ||
| 149 | + // 编出处那条**没被喂给去敏判定**(前置闸门,省一次调用) | ||
| 150 | + const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8'); | ||
| 151 | + expect(exp).toContain('规矩的'); | ||
| 152 | + expect(exp).not.toContain('编出处的'); | ||
| 153 | + }); | ||
| 154 | + | ||
| 155 | + it('auditDistilled 把结论与原料原文并排摆出来,悬空指针标 null', async () => { | ||
| 156 | + hire(CO_A); | ||
| 157 | + seedMemory(CO_A, ['原料甲的正文']); | ||
| 158 | + dream.setDistiller(async ({ entries }) => [ | ||
| 159 | + { title: '经验甲', body: '正文', sources: entries.map((e) => e.source) }, | ||
| 160 | + ]); | ||
| 161 | + dream.setScreener(async () => ({ shareable: true })); | ||
| 162 | + await dream.dreamOnce(AGENT); | ||
| 163 | + | ||
| 164 | + const audit = dream.auditDistilled(AGENT); | ||
| 165 | + expect(audit).toHaveLength(1); | ||
| 166 | + expect(audit[0].title).toBe('经验甲'); | ||
| 167 | + expect(audit[0].sources[0].text).toBe('原料甲的正文'); // 顺着指针取回了原文 | ||
| 168 | + | ||
| 169 | + // 原料被清掉之后,指针悬空要显示成 null 而不是假装还在 | ||
| 170 | + seedMemory(CO_A, []); | ||
| 171 | + expect(dream.auditDistilled(AGENT)[0].sources[0].text).toBeNull(); | ||
| 172 | + }); | ||
| 173 | + | ||
| 131 | it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => { | 174 | it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => { |
| 132 | hire(CO_A); | 175 | hire(CO_A); |
| 133 | seedMemory(CO_A, ['素材一']); | 176 | seedMemory(CO_A, ['素材一']); |
| @@ -160,7 +203,7 @@ describe('dream · 骨架', () => { | @@ -160,7 +203,7 @@ describe('dream · 骨架', () => { | ||
| 160 | dream.setDreamAgentCaller(async (agentName, prompt) => { | 203 | dream.setDreamAgentCaller(async (agentName, prompt) => { |
| 161 | calls.push({ agentName, prompt }); | 204 | calls.push({ agentName, prompt }); |
| 162 | if (agentName === dream.DISTILLER_AGENT) { | 205 | if (agentName === dream.DISTILLER_AGENT) { |
| 163 | - return '好的。\n```json\n[{"title":"结论一","body":"做法一","sources":["x"]}]\n```'; | 206 | + return `好的。\n\`\`\`json\n[{"title":"结论一","body":"做法一","sources":["${CO_A}:memory.md#1"]}]\n\`\`\``; |
| 164 | } | 207 | } |
| 165 | return '```json\n{"shareable":true,"reason":"没有可识别主体"}\n```'; | 208 | return '```json\n{"shareable":true,"reason":"没有可识别主体"}\n```'; |
| 166 | }); | 209 | }); |
server/tests/unit/task-state-cleanup.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 任务态清理(28 §5.6)—— **只清任务态,永不碰公司态**。 | ||
| 3 | + * | ||
| 4 | + * ── 为什么这条测试比一般的清理逻辑更要紧 ──────────────────────────── | ||
| 5 | + * 删错不可逆。而且被删错的那一半(公司态 `memory/` / `conversations/`) | ||
| 6 | + * 是 dream 的**唯一原料**:删掉损失的不是缓存,是这个 agent 全部的经验历史, | ||
| 7 | + * 且**不可再生**(28 §5.6b)。 | ||
| 8 | + * | ||
| 9 | + * ── 四道判据 ────────────────────────────────────────────────────────── | ||
| 10 | + * ① 只在 `company_agents` 名册里的公司下面清(`train/` `workspace/` 和测试残留天然被排除); | ||
| 11 | + * ② **白名单**:只删认得出来的任务态目录名,其余一概不动; | ||
| 12 | + * ③ 年龄早于 N 天; | ||
| 13 | + * ④ `run_*` 目录对应的 run 还没到终态就不删。 | ||
| 14 | + * | ||
| 15 | + * ── 第 ② 条是踩出来的 ───────────────────────────────────────────────── | ||
| 16 | + * 第一版写成黑名单(「不在保护名单里的都算任务态」),dry-run 直接选中了 | ||
| 17 | + * `trend-scout-global/idearun/venv` 和 `youtube-crawler/idearun/data` —— | ||
| 18 | + * 一个 Python 虚拟环境、一个数据目录,都在**真公司**底下,都不是任务态。 | ||
| 19 | + * 判据必须是「认得出才删」,不是「不认识就删」。 | ||
| 20 | + */ | ||
| 21 | +import { describe, it, expect } from 'vitest'; | ||
| 22 | +import fs from 'fs'; | ||
| 23 | +import os from 'os'; | ||
| 24 | +import path from 'path'; | ||
| 25 | + | ||
| 26 | +// 隔离库:不设的话 graph-db 的闸门会拦下测试进程,realCompanies() 走 catch 分支 | ||
| 27 | +// 返回空集合 —— 那时**一家公司都不认**,整套逻辑什么都不清。 | ||
| 28 | +// 那个 fail-closed 行为本身是对的(见下面单独一条),但会让其它用例全部空跑。 | ||
| 29 | +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-cleanup-')); | ||
| 30 | +process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); | ||
| 31 | + | ||
| 32 | +const cleanup = require('../../services/task-state-cleanup'); | ||
| 33 | + | ||
| 34 | +describe('任务态清理 · 判据', () => { | ||
| 35 | + it('② 白名单认得出真实存在的那几种任务态目录名', () => { | ||
| 36 | + // 这些形状都是实查 system/agents 得到的,不是想出来的 | ||
| 37 | + for (const name of [ | ||
| 38 | + 'run_9168bd24f46c', 'heartbeat-msy9yfdd', 'callback-T-msyu6u9b-939c', | ||
| 39 | + 'task-T-abc123', 'T-msyu6u9b-939c', 'conv-abc', 'tpl-train-x', '_no-task', | ||
| 40 | + ]) { | ||
| 41 | + expect(cleanup.looksLikeTaskDir(name)).toBe(true); | ||
| 42 | + } | ||
| 43 | + }); | ||
| 44 | + | ||
| 45 | + it('② 白名单**不**认这些 —— 第一版正是在这里判错的', () => { | ||
| 46 | + // venv / data 是真公司目录下的非任务态目录,实测被第一版黑名单判据选中过 | ||
| 47 | + for (const name of ['venv', 'data', 'memory', 'conversations', '.claude', | ||
| 48 | + 'workspace', 'node_modules', 'artifacts', 'projects']) { | ||
| 49 | + expect(cleanup.looksLikeTaskDir(name)).toBe(false); | ||
| 50 | + } | ||
| 51 | + }); | ||
| 52 | + | ||
| 53 | + it('公司态永远在保护名单里(这一条红了 = dream 的原料要被删)', () => { | ||
| 54 | + expect(cleanup.PROTECTED.has('memory')).toBe(true); | ||
| 55 | + expect(cleanup.PROTECTED.has('conversations')).toBe(true); | ||
| 56 | + }); | ||
| 57 | + | ||
| 58 | + it('读不到名册时一家公司都不认(fail-closed:宁可不清,也不赌)', () => { | ||
| 59 | + const orig = process.env.GRAPH_DB_PATH; | ||
| 60 | + delete process.env.GRAPH_DB_PATH; // 触发闸门 → listCompanyAgents 抛 | ||
| 61 | + try { | ||
| 62 | + const r = cleanup.sweep({ maxAgeDays: 0 }); | ||
| 63 | + expect(r.candidates).toEqual([]); | ||
| 64 | + expect(r.removed).toEqual([]); | ||
| 65 | + } finally { process.env.GRAPH_DB_PATH = orig; } | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + it('真扫一遍:默认 14 天窗口下不删任何东西,且跳过原因说得清', () => { | ||
| 69 | + const r = cleanup.sweep({ maxAgeDays: 14 }); | ||
| 70 | + expect(r.removed).toEqual([]); // dry-run 不删 | ||
| 71 | + expect(r.skipped.length).toBeGreaterThan(50); // 样本非空:真的扫到了东西 | ||
| 72 | + // 每条跳过都要有能读的原因 —— 「为什么没清」和「清了什么」一样值得看得见 | ||
| 73 | + for (const s of r.skipped.slice(0, 50)) expect(String(s.reason).length).toBeGreaterThan(4); | ||
| 74 | + // 公司态一定出现在跳过里,且理由点名 §5.6b | ||
| 75 | + const mem = r.skipped.find((s) => s.path.endsWith('/memory')); | ||
| 76 | + expect(mem).toBeTruthy(); | ||
| 77 | + expect(mem.reason).toContain('公司态'); | ||
| 78 | + }); | ||
| 79 | + | ||
| 80 | + it('把窗口放到 0 天时确实能选出任务态目录,且**一条公司态都没混进来**', () => { | ||
| 81 | + const r = cleanup.sweep({ maxAgeDays: 0 }); | ||
| 82 | + expect(r.candidates.length).toBeGreaterThan(0); // 样本非空,否则下面的负向断言是空跑 | ||
| 83 | + for (const c of r.candidates) { | ||
| 84 | + const last = c.split('/').pop(); | ||
| 85 | + expect(cleanup.PROTECTED.has(last)).toBe(false); | ||
| 86 | + expect(cleanup.looksLikeTaskDir(last)).toBe(true); | ||
| 87 | + } | ||
| 88 | + expect(r.removed).toEqual([]); // 仍然是 dry-run | ||
| 89 | + }); | ||
| 90 | +}); |