Commit e9f380a5018a3d7fb6943e99bb8be220f3daefae

Authored by yibo
1 parent 1d20af1b

feat(dream): 定时挂载(决策 C)+ 定义文件写入(决策 B)

## 定时:每日一次,挂在 DISABLE_BACKGROUND_AGENTS 早退之后

dreamSweep 串行扫所有本体,**不并发**:每轮可能起两次 LLM 调用,
几十个本体并发 = 同时拉起几十个 claude 子进程 —— 这个代价本仓库付过
(2h43m 漏出 82 个进程 / 17.5 GB,28-followups §五 第五条)。慢一点没有代价。

空转保护在 dreamOnce 里,所以「76 个本体全扫一遍」的实际成本是 76 次目录读。
定时器 unref —— 少了它,短命进程会被 24 小时的 timer 吊住不退出。
只挂定时**不立刻跑一轮**:启动那刻跑没有额外收益,反而让每次重启都烧一遍。

一个本体抛异常不拖垮整轮,但**必须留下是谁抛的**,否则现场只看到「今天没沉淀」,
查不出是没原料还是一直在抛。

## 定义文件:受管区块 + 三道闸门

形状是一对 `<!-- dream:begin/end -->` 之间的**受管区块**,区块外一个字节不碰。
自由追加会让「哪些是人写的、哪些是 dream 写的」几轮之后再也分不开,
而分不开就没法分别回滚。区块整体重写还顺带**幂等** —— 同一批经验重复跑不会越写越长。

三道闸门,缺一不写:
① definition-write-gate:当前内容必须已在 git 历史里(你定的前置条件);
② **agent.md 的前 2000 字符必须仍然自足** —— 规划 agent 只看这一段
   (graph-engine.js:915 的「角色定义摘录」),改了等于悄悄换掉它读到的画像;
③ 写完复核毕业闸门,不过就**立刻还原** —— agent.md 写坏会让本体雇不进任何公司。

任何一道不过:不写,并把原因逐个文件返回(`definitions[]`)。**不写不该是静默的。**

## 测试上又纠了自己一次

三条新用例第一版是「在测试里比对形状」而不是真调 —— 和上一轮同一个毛病。
改成对**真被 git 跟踪且干净**的本体(dream-screener)走真路径:
真写、验区块外字节不变 / 幂等 / 毕业闸门仍过,finally 还原并断言还原干净。
agent.md 那条同理真调,验「不写 + 理由点名前 2000 字符 + 一个字节都没动」。

dreamSweep 加 agents 参数,让错误隔离那条能真调而不是真扫 76 个本体。

全量:144 文件 / 1330 测试全绿,仓库干净。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/index.js
@@ -175,6 +175,10 @@ async function start() { @@ -175,6 +175,10 @@ async function start() {
175 // 公司级心跳 (Pipeline 自动驱动) 175 // 公司级心跳 (Pipeline 自动驱动)
176 const { companyHeartbeatService } = require('./services/company-heartbeat-service'); 176 const { companyHeartbeatService } = require('./services/company-heartbeat-service');
177 companyHeartbeatService.start(); 177 companyHeartbeatService.start();
  178 + // 29-DREAM 决策 C:经验汇流,每日一次。挂在这个早退之后 ——
  179 + // 它会起 agent(提炼 + 去敏判定各一次),归 DISABLE_BACKGROUND_AGENTS 管。
  180 + // 只挂定时**不立刻跑一轮**:启动那一刻跑没有额外收益,反而让每次重启都烧一遍。
  181 + require('./services/dream-service').startDaily();
178 }, 5000); 182 }, 5000);
179 } catch (error) { 183 } catch (error) {
180 console.error('Failed to start server:', error); 184 console.error('Failed to start server:', error);
server/services/dream-service.js
@@ -145,7 +145,12 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { @@ -145,7 +145,12 @@ async function dreamOnce(agentId, { dryRun = false } = {}) {
145 else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' }); 145 else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' });
146 } 146 }
147 147
148 - if (!dryRun && accepted.length) writeDistilled(agentId, accepted); 148 + let definitions = [];
  149 + if (accepted.length) {
  150 + if (!dryRun) writeDistilled(agentId, accepted);
  151 + // 决策 B:三个 git 跟踪的定义文件也写,但每一份都要过闸门(见 writeDefinitionFiles)
  152 + definitions = writeDefinitionFiles(agentId, accepted, { dryRun });
  153 + }
149 if (!dryRun) { 154 if (!dryRun) {
150 writeState(agentId, { 155 writeState(agentId, {
151 ...readState(agentId), 156 ...readState(agentId),
@@ -155,7 +160,11 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { @@ -155,7 +160,11 @@ async function dreamOnce(agentId, { dryRun = false } = {}) {
155 lastRejected: rejected.length, 160 lastRejected: rejected.length,
156 }); 161 });
157 } 162 }
158 - return { agentId, skipped: null, counts, distilled: (candidates || []).length, accepted: accepted.length, rejected }; 163 + return {
  164 + agentId, skipped: null, counts,
  165 + distilled: (candidates || []).length, accepted: accepted.length, rejected,
  166 + definitions, // 逐个文件的「写了没有、没写是为什么」—— 不写不该是静默的
  167 + };
159 } 168 }
160 169
161 /** 170 /**
@@ -187,6 +196,109 @@ function writeDistilled(agentId, accepted) { @@ -187,6 +196,109 @@ function writeDistilled(agentId, accepted) {
187 return file; 196 return file;
188 } 197 }
189 198
  199 +/**
  200 + * 把提炼产物写进**定义文件**(决策 B:`SKILL.md` / `soul.md` / `agent.md` 直接落盘)。
  201 + *
  202 + * ── 形状:一个**受管区块**,不是自由追加 ──────────────────────────────
  203 + * dream 只动一对标记之间的内容:
  204 + *
  205 + * <!-- dream:begin --> … <!-- dream:end -->
  206 + *
  207 + * 区块外的一个字节都不碰。理由是这三个文件是**人和 agent 共同维护**的定义,
  208 + * 自由追加会让「哪些是人写的、哪些是 dream 写的」在几轮之后再也分不开,
  209 + * 而分不开就没法分别回滚(决策 B.1 的四条硬约束之一)。
  210 + * 区块整体重写还带来一个额外好处:**幂等** —— 同一批经验重复跑不会越写越长。
  211 + *
  212 + * ── 三道闸门,缺一不写 ────────────────────────────────────────────────
  213 + * ① `definition-write-gate`:这份文件的当前内容必须已经在 git 历史里
  214 + * (拍板要求:别导致直接修改而没有 git 历史)。
  215 + * ② **`agent.md` 的前 2000 字符必须仍然自足**:给规划 agent 看的「角色定义摘录」
  216 + * 只取前 2000 字符(`graph-engine.js:915`)。dream 的区块因此**只准放在文件末尾**,
  217 + * 且写完要复核前 2000 字符没被顶掉。
  218 + * ③ 写完之后毕业闸门仍要过(`missingBodyDefinition` 为空)——
  219 + * 把 `agent.md` 写坏会让本体退回「定义没填满」,**雇不进任何公司**。
  220 + *
  221 + * 任何一道不过:**不写,并把原因返回**。少沉淀一条经验 vs 改坏一个所有公司共用的本体,
  222 + * 两者代价不对称。
  223 + */
  224 +const DREAM_BEGIN = '<!-- dream:begin -->';
  225 +const DREAM_END = '<!-- dream:end -->';
  226 +const AGENT_MD_PREFIX_BUDGET = 2000;
  227 +
  228 +function renderDreamBlock(accepted, { heading }) {
  229 + const now = new Date().toISOString().substring(0, 10);
  230 + return [
  231 + DREAM_BEGIN,
  232 + `## ${heading}`,
  233 + '',
  234 + `_由 dream 于 ${now} 提炼自跨公司经验;本区块由机器维护,区块外的内容不会被改动。_`,
  235 + '',
  236 + ...accepted.map((c) => `- **${c.title}** —— ${String(c.body || '').replace(/\n+/g, ' ')}`),
  237 + '',
  238 + DREAM_END,
  239 + ].join('\n');
  240 +}
  241 +
  242 +/** 把区块塞进(或替换掉)正文;区块外一个字节不动 */
  243 +function upsertBlock(text, block) {
  244 + const b = text.indexOf(DREAM_BEGIN);
  245 + const e = text.indexOf(DREAM_END);
  246 + if (b >= 0 && e > b) return `${text.slice(0, b)}${block}${text.slice(e + DREAM_END.length)}`;
  247 + return `${text.replace(/\s*$/, '')}\n\n${block}\n`;
  248 +}
  249 +
  250 +/**
  251 + * @param {string} agentId
  252 + * @param {Array} accepted 已过去敏闸门的产物
  253 + * @param {{files?: string[], dryRun?: boolean}} opts
  254 + * @returns {Array<{file, written, reason}>} 逐个文件报「写了没有、没写是为什么」
  255 + */
  256 +function writeDefinitionFiles(agentId, accepted, { files = ['SKILL.md', 'soul.md', 'agent.md'], dryRun = false } = {}) {
  257 + const body = locator.bodyDir(agentId);
  258 + const HEADINGS = {
  259 + 'SKILL.md': '跨公司经验(dream)',
  260 + 'soul.md': '从经验里长出来的倾向(dream)',
  261 + 'agent.md': '跨公司经验提醒(dream)',
  262 + };
  263 + const out = [];
  264 + if (!accepted.length) return files.map((f) => ({ file: f, written: false, reason: '本轮没有通过闸门的产物' }));
  265 +
  266 + for (const f of files) {
  267 + const abs = path.join(body, f);
  268 + const gate = canOverwriteDefinition(abs);
  269 + if (!gate.allowed) { out.push({ file: f, written: false, reason: gate.reason }); continue; }
  270 +
  271 + let text;
  272 + try { text = fs.readFileSync(abs, 'utf-8'); } catch (err) {
  273 + out.push({ file: f, written: false, reason: `读不到:${err.message}` }); continue;
  274 + }
  275 + const next = upsertBlock(text, renderDreamBlock(accepted, { heading: HEADINGS[f] || '跨公司经验(dream)' }));
  276 +
  277 + // 闸门②:agent.md 的前 2000 字符不许被顶掉
  278 + if (f === 'agent.md' && next.slice(0, AGENT_MD_PREFIX_BUDGET) !== text.slice(0, AGENT_MD_PREFIX_BUDGET)) {
  279 + out.push({
  280 + file: f, written: false,
  281 + reason: `写入会改变 agent.md 的前 ${AGENT_MD_PREFIX_BUDGET} 字符 —— `
  282 + + '规划 agent 只看这一段(graph-engine 的「角色定义摘录」),改了等于悄悄换掉它读到的画像',
  283 + });
  284 + continue;
  285 + }
  286 +
  287 + if (!dryRun) {
  288 + fs.writeFileSync(abs, next, 'utf-8');
  289 + // 闸门③:写完复核毕业闸门,不过就**立刻还原**
  290 + const missing = locator.missingBodyDefinition(agentId);
  291 + if (missing.length) {
  292 + fs.writeFileSync(abs, text, 'utf-8');
  293 + out.push({ file: f, written: false, reason: `写完毕业闸门不过(缺 ${missing.join(' / ')}),已还原` });
  294 + continue;
  295 + }
  296 + }
  297 + out.push({ file: f, written: true, reason: dryRun ? '(dry-run)' : `已更新 ${gate.rel} 的 dream 区块` });
  298 + }
  299 + return out;
  300 +}
  301 +
190 /** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */ 302 /** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */
191 function definitionWritability(agentId) { 303 function definitionWritability(agentId) {
192 const body = locator.bodyDir(agentId); 304 const body = locator.bodyDir(agentId);
@@ -318,11 +430,83 @@ async function defaultScreener({ candidate }) { @@ -318,11 +430,83 @@ async function defaultScreener({ candidate }) {
318 return { shareable: obj.shareable === true, reason: obj.reason || '' }; 430 return { shareable: obj.shareable === true, reason: obj.reason || '' };
319 } 431 }
320 432
  433 +// ── 定时(决策 C:每日一次)────────────────────────────────────────────
  434 +const DAY_MS = 24 * 60 * 60 * 1000;
  435 +let sweepTimer = null;
  436 +
  437 +/**
  438 + * 有哪些本体值得跑一轮 dream?
  439 + *
  440 + * 判据是**本体目录存在 + 不是保留 id**,和 `heartbeat-service.start()` 扫的同一套。
  441 + * 不查 `system_agents` 表:那张表里可能有磁盘上已经没有的悬空行,
  442 + * 而 dream 要读的是磁盘上的记忆文件 —— **以「读得到原料的那一侧」为准**。
  443 + */
  444 +function listDreamableAgents() {
  445 + try {
  446 + return fs.readdirSync(locator.AGENTS_ROOT, { withFileTypes: true })
  447 + .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
  448 + .map((d) => d.name);
  449 + } catch { return []; }
  450 +}
  451 +
  452 +/**
  453 + * 扫一遍所有本体,各跑一次 `dreamOnce`。
  454 + *
  455 + * **串行**,不是并发:每一轮都可能起两次 LLM 调用,几十个本体并发等于同时拉起几十个
  456 + * claude 子进程 —— 本仓库为这个付过代价(2h43m 漏出 82 个进程 / 17.5 GB,
  457 + * 记在 28-followups §五 第五条)。慢一点没有代价,dream 不赶时间。
  458 + *
  459 + * 空转保护在 `dreamOnce` 里:没有新原料的本体连一次 agent 调用都不起,
  460 + * 所以「76 个本体全扫一遍」的实际成本是 76 次目录读。
  461 + */
  462 +async function dreamSweep({ onResult, agents = null } = {}) {
  463 + const results = [];
  464 + for (const agentId of agents || listDreamableAgents()) {
  465 + try {
  466 + const r = await dreamOnce(agentId);
  467 + results.push(r);
  468 + if (onResult) onResult(r);
  469 + } catch (err) {
  470 + // 一个本体炸了不该拖垮整轮 —— 但**必须留下是谁炸的**,
  471 + // 否则现场只看到「今天没沉淀」,查不出是没原料还是一直在抛
  472 + console.error(`[Dream] ⚠️ ${agentId} 本轮失败: ${err.message}`);
  473 + results.push({ agentId, error: err.message });
  474 + }
  475 + }
  476 + const ran = results.filter((r) => !r.skipped && !r.error);
  477 + console.log(`[Dream] 🌙 扫描完成:${results.length} 个本体,${ran.length} 个有新原料,`
  478 + + `沉淀 ${ran.reduce((n, r) => n + (r.accepted || 0), 0)} 条`);
  479 + return results;
  480 +}
  481 +
  482 +/**
  483 + * 挂上每日定时(决策 C)。
  484 + *
  485 + * `unref()`:**绝不拖着进程不退出**。与 task-scheduler 的兜底定时器同一处理 ——
  486 + * 少了它,`npm test` 之类的短命进程会挂在这个 24 小时的 timer 上不肯结束。
  487 + */
  488 +function startDaily({ intervalMs = DAY_MS } = {}) {
  489 + if (sweepTimer) return sweepTimer;
  490 + sweepTimer = setInterval(() => {
  491 + dreamSweep().catch((err) => console.error(`[Dream] 定时扫描失败: ${err.message}`));
  492 + }, intervalMs);
  493 + if (sweepTimer.unref) sweepTimer.unref();
  494 + console.log(`[Dream] 🌙 已挂载每日经验汇流(间隔 ${Math.round(intervalMs / 3600_000)} 小时)`);
  495 + return sweepTimer;
  496 +}
  497 +
  498 +function stopDaily() {
  499 + if (sweepTimer) clearInterval(sweepTimer);
  500 + sweepTimer = null;
  501 +}
  502 +
321 module.exports = { 503 module.exports = {
322 - dreamOnce, collectMaterial, companiesOf, readRawEntries, 504 + dreamOnce, dreamSweep, startDaily, stopDaily, listDreamableAgents,
  505 + collectMaterial, companiesOf, readRawEntries,
323 defaultDistiller, defaultScreener, extractJson, 506 defaultDistiller, defaultScreener, extractJson,
324 DISTILLER_AGENT, SCREENER_AGENT, 507 DISTILLER_AGENT, SCREENER_AGENT,
325 - writeDistilled, definitionWritability, readState, writeState, 508 + writeDistilled, writeDefinitionFiles, definitionWritability, readState, writeState,
  509 + DREAM_BEGIN, DREAM_END,
326 setDistiller, setScreener, setDreamAgentCaller, 510 setDistiller, setScreener, setDreamAgentCaller,
327 RAW_SECTION, CROSS_COMPANY_MEMORY, 511 RAW_SECTION, CROSS_COMPANY_MEMORY,
328 }; 512 };
server/tests/unit/dream-service.test.js
@@ -254,3 +254,107 @@ describe(&#39;dream · 默认实现的解析与失效方向&#39;, () =&gt; { @@ -254,3 +254,107 @@ describe(&#39;dream · 默认实现的解析与失效方向&#39;, () =&gt; {
254 expect(screener).toContain('看不到原料'); 254 expect(screener).toContain('看不到原料');
255 }); 255 });
256 }); 256 });
  257 +
  258 +/**
  259 + * 定义文件写入(决策 B)与定时挂载(决策 C)。
  260 + *
  261 + * 这两块的共同点:**失效方向都是静默的**。
  262 + * 定义文件写坏 → 本体行为漂了 / 雇不进公司,都不报错;
  263 + * 定时挂载写错 → 要么每天空烧几十次 LLM,要么一次都不跑,两种都没人会发现。
  264 + */
  265 +describe('dream · 定义文件写入与定时', () => {
  266 + const d = require('../../services/dream-service');
  267 + const locator = require('../../services/agent-locator');
  268 +
  269 + const ACCEPTED = [{ title: '结论一', body: '做法一', sources: ['s1'] }];
  270 +
  271 + it('未进 git 历史的文件不写,且逐个报出为什么(不写不许是静默的)', () => {
  272 + // zz-dream-body 是测试造的运行态本体,不在 git 里 —— 正好是闸门要拦的那种
  273 + const r = d.writeDefinitionFiles(AGENT, ACCEPTED, { dryRun: true });
  274 + expect(r).toHaveLength(3);
  275 + for (const x of r) {
  276 + expect(x.written).toBe(false);
  277 + expect(x.reason).toMatch(/没有被 git 跟踪|读不到/);
  278 + }
  279 + });
  280 +
  281 + it('真写一次:区块外一个字节不动、幂等、写完毕业闸门仍过', () => {
  282 + // 挑一个**真被 git 跟踪且干净**的本体来走真路径 —— 那正是闸门放行的前提。
  283 + // 写完在 finally 里还原,并断言确实还原干净(否则这条测试会把仓库弄脏)。
  284 + const BODY = 'dream-screener';
  285 + const abs = path.join(ROOT, 'system', 'agents', BODY, 'soul.md');
  286 + const orig = fs.readFileSync(abs, 'utf-8');
  287 + expect(orig).not.toContain(d.DREAM_BEGIN); // 样本非空:现在还没有区块
  288 + try {
  289 + const r1 = d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['soul.md'] });
  290 + expect(r1[0].written).toBe(true);
  291 +
  292 + const after1 = fs.readFileSync(abs, 'utf-8');
  293 + expect(after1).toContain(d.DREAM_BEGIN);
  294 + expect(after1).toContain('结论一');
  295 + // 区块外一个字节不动
  296 + expect(after1.slice(0, after1.indexOf(d.DREAM_BEGIN)).trim()).toBe(orig.trim());
  297 + // 写完毕业闸门仍过
  298 + expect(locator.missingBodyDefinition(BODY)).toEqual([]);
  299 +
  300 + // 幂等:同一批经验再写一次,只有一个区块,且长度不涨
  301 + d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['soul.md'] });
  302 + const after2 = fs.readFileSync(abs, 'utf-8');
  303 + expect(after2.match(new RegExp(d.DREAM_BEGIN, 'g'))).toHaveLength(1);
  304 + expect(after2.length).toBe(after1.length);
  305 + } finally {
  306 + fs.writeFileSync(abs, orig, 'utf-8');
  307 + expect(fs.readFileSync(abs, 'utf-8')).toBe(orig);
  308 + }
  309 + });
  310 +
  311 + it('agent.md 的前 2000 字符会被改动时就不写 —— 规划 agent 只看这一段', () => {
  312 + // dream-screener/agent.md 比 2000 字符短,追加区块必然落进前 2000 字符里,
  313 + // 所以真调下来必须是「不写 + 说清理由」。
  314 + const BODY = 'dream-screener';
  315 + const abs = path.join(ROOT, 'system', 'agents', BODY, 'agent.md');
  316 + const orig = fs.readFileSync(abs, 'utf-8');
  317 + const r = d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['agent.md'] });
  318 + expect(r[0].written).toBe(false);
  319 + expect(r[0].reason).toContain('前 2000 字符');
  320 + expect(fs.readFileSync(abs, 'utf-8')).toBe(orig); // 一个字节都没动
  321 + });
  322 +
  323 + it('没有通过闸门的产物时,三个文件都不写且理由一致', () => {
  324 + const r = d.writeDefinitionFiles(AGENT, [], { dryRun: true });
  325 + expect(r.map((x) => x.written)).toEqual([false, false, false]);
  326 + expect(r[0].reason).toContain('没有通过闸门的产物');
  327 + });
  328 +
  329 + it('可跑 dream 的本体清单跳过保留 id(`_base` / `_registry.json` 不是 agent)', () => {
  330 + const ids = d.listDreamableAgents();
  331 + expect(ids.length).toBeGreaterThan(10); // 样本非空
  332 + expect(ids.some((x) => x.startsWith('_'))).toBe(false);
  333 + expect(ids).toContain('dream-distiller');
  334 + });
  335 +
  336 + it('定时器 unref 且幂等挂载(少了 unref,短命进程会被 24 小时的 timer 吊住不退出)', () => {
  337 + const t1 = d.startDaily({ intervalMs: 60_000 });
  338 + const t2 = d.startDaily({ intervalMs: 60_000 });
  339 + expect(t2).toBe(t1); // 重复挂载不产生第二个
  340 + expect(typeof t1.unref).toBe('function');
  341 + d.stopDaily();
  342 + });
  343 +
  344 + it('扫描时一个本体抛异常不拖垮整轮,且留下是谁抛的(真调 dreamSweep)', async () => {
  345 + hire(CO_A);
  346 + seedMemory(CO_A, ['会让提炼抛的原料']);
  347 + d.setDistiller(async () => { throw new Error('故意炸'); });
  348 + try {
  349 + // agents 显式给,避免真扫 76 个本体
  350 + const rs = await d.dreamSweep({ agents: [AGENT, 'dream-screener'] });
  351 + expect(rs).toHaveLength(2);
  352 + const bad = rs.find((r) => r.agentId === AGENT);
  353 + expect(bad.error).toContain('故意炸'); // 是谁炸的、为什么,都留下了
  354 + const other = rs.find((r) => r.agentId === 'dream-screener');
  355 + expect(other.error).toBeUndefined(); // 没被拖垮
  356 + } finally {
  357 + d.setDistiller(null);
  358 + }
  359 + });
  360 +});