Commit ab57a0afb3030c9d70defe95161d570ddcf0b012

Authored by yibo
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>
docs/todo/system-design/28-followups.md
... ... @@ -93,9 +93,19 @@ origin 全部按映射回填正确),但「读一下状态」不该顺手改
93 93 `assertNotProdDbInTests` 那道闸门**只挡测试进程**,故意不挡普通脚本(挡了服务自己也起不来)。
94 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 110 ### 3.3 ~~`agent-chat.js` 是心跳落点 bug 的第二个入口~~ ✅ 已修(2026-08-20,`edeff2e`)
101 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 197 | **C2 按量**(某 agent 新增 N 条 session 摘要后触发) | 与实际产出量对齐;需要一个计数落点 |
198 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 219 - 没有评价手段时,dream 的产出**无法验证对错**——它和 FDE 那边
205 220 「LLM 自己汇报分数」的问题是同一个(见 [architecture_todo_list](../future/architecture_todo_list.md) §1)。
... ... @@ -210,8 +225,8 @@ const entry = `- [${timestamp}] ${taskOneLine} → ${resultOneLine}`;
210 225  
211 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 326 (纯经验积累,删掉无副作用)。`SKILL.md` / `soul.md` / `agent.md` 三个 git 跟踪的定义
312 327 留给下一步;`definitionWritability()` 已经能报出「这三份现在能不能写、不能写是为什么」,
313 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 132 const candidates = await (distiller || defaultDistiller)({ agentId, entries: fresh });
133 133 const accepted = [];
134 134 const rejected = [];
  135 + // 决策 D 的可做部分:**「结论对不对」机器判不了,但「引用是不是编的」可以。**
  136 + // 提炼产物的 sources 必须都出自这一轮真喂进去的原料 —— 编一个出处是
  137 + // 机器可检的谎,而它恰恰是「无法验证」这件事最危险的形态:
  138 + // 一条编了出处的经验,人抽查时会顺着一个不存在的指针放弃,然后默认它是真的。
  139 + const knownSources = new Set(fresh.map((e) => e.source));
135 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 152 // 决策 A:**独立**的判定,不与提炼共享上下文;判不准 = 拒绝(fail-closed)
137 153 let verdict;
138 154 try {
... ... @@ -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 224 * 落盘(决策 B)。
172 225 *
173 226 * 只写**本体根的 `memory/`**:那是纯经验积累,删掉即可,无副作用。
... ... @@ -505,7 +558,7 @@ module.exports = {
505 558 collectMaterial, companiesOf, readRawEntries,
506 559 defaultDistiller, defaultScreener, extractJson,
507 560 DISTILLER_AGENT, SCREENER_AGENT,
508   - writeDistilled, writeDefinitionFiles, definitionWritability, readState, writeState,
  561 + writeDistilled, writeDefinitionFiles, definitionWritability, auditDistilled, readState, writeState,
509 562 DREAM_BEGIN, DREAM_END,
510 563 setDistiller, setScreener, setDreamAgentCaller,
511 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(&#39;dream · 骨架&#39;, () =&gt; {
106 106 it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => {
107 107 hire(CO_A);
108 108 seedMemory(CO_A, ['一条经验']);
  109 + const SRC = `${CO_A}:memory.md#1`; // 出处必须是本轮真喂进去的,编的会被前置闸门直接拒
109 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 115 dream.setScreener(async ({ candidate }) => {
115 116 if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了');
... ... @@ -128,6 +129,48 @@ describe(&#39;dream · 骨架&#39;, () =&gt; {
128 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 174 it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => {
132 175 hire(CO_A);
133 176 seedMemory(CO_A, ['素材一']);
... ... @@ -160,7 +203,7 @@ describe(&#39;dream · 骨架&#39;, () =&gt; {
160 203 dream.setDreamAgentCaller(async (agentName, prompt) => {
161 204 calls.push({ agentName, prompt });
162 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 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 +});
... ...