diff --git a/server/index.js b/server/index.js index b70db3d..b8d6759 100644 --- a/server/index.js +++ b/server/index.js @@ -175,6 +175,10 @@ async function start() { // 公司级心跳 (Pipeline 自动驱动) const { companyHeartbeatService } = require('./services/company-heartbeat-service'); companyHeartbeatService.start(); + // 29-DREAM 决策 C:经验汇流,每日一次。挂在这个早退之后 —— + // 它会起 agent(提炼 + 去敏判定各一次),归 DISABLE_BACKGROUND_AGENTS 管。 + // 只挂定时**不立刻跑一轮**:启动那一刻跑没有额外收益,反而让每次重启都烧一遍。 + require('./services/dream-service').startDaily(); }, 5000); } catch (error) { console.error('Failed to start server:', error); diff --git a/server/services/dream-service.js b/server/services/dream-service.js index f66e991..42a1683 100644 --- a/server/services/dream-service.js +++ b/server/services/dream-service.js @@ -145,7 +145,12 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { else rejected.push({ ...c, reason: (verdict && verdict.reason) || '判定未通过' }); } - if (!dryRun && accepted.length) writeDistilled(agentId, accepted); + let definitions = []; + if (accepted.length) { + if (!dryRun) writeDistilled(agentId, accepted); + // 决策 B:三个 git 跟踪的定义文件也写,但每一份都要过闸门(见 writeDefinitionFiles) + definitions = writeDefinitionFiles(agentId, accepted, { dryRun }); + } if (!dryRun) { writeState(agentId, { ...readState(agentId), @@ -155,7 +160,11 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { lastRejected: rejected.length, }); } - return { agentId, skipped: null, counts, distilled: (candidates || []).length, accepted: accepted.length, rejected }; + return { + agentId, skipped: null, counts, + distilled: (candidates || []).length, accepted: accepted.length, rejected, + definitions, // 逐个文件的「写了没有、没写是为什么」—— 不写不该是静默的 + }; } /** @@ -187,6 +196,109 @@ function writeDistilled(agentId, accepted) { return file; } +/** + * 把提炼产物写进**定义文件**(决策 B:`SKILL.md` / `soul.md` / `agent.md` 直接落盘)。 + * + * ── 形状:一个**受管区块**,不是自由追加 ────────────────────────────── + * dream 只动一对标记之间的内容: + * + * … + * + * 区块外的一个字节都不碰。理由是这三个文件是**人和 agent 共同维护**的定义, + * 自由追加会让「哪些是人写的、哪些是 dream 写的」在几轮之后再也分不开, + * 而分不开就没法分别回滚(决策 B.1 的四条硬约束之一)。 + * 区块整体重写还带来一个额外好处:**幂等** —— 同一批经验重复跑不会越写越长。 + * + * ── 三道闸门,缺一不写 ──────────────────────────────────────────────── + * ① `definition-write-gate`:这份文件的当前内容必须已经在 git 历史里 + * (拍板要求:别导致直接修改而没有 git 历史)。 + * ② **`agent.md` 的前 2000 字符必须仍然自足**:给规划 agent 看的「角色定义摘录」 + * 只取前 2000 字符(`graph-engine.js:915`)。dream 的区块因此**只准放在文件末尾**, + * 且写完要复核前 2000 字符没被顶掉。 + * ③ 写完之后毕业闸门仍要过(`missingBodyDefinition` 为空)—— + * 把 `agent.md` 写坏会让本体退回「定义没填满」,**雇不进任何公司**。 + * + * 任何一道不过:**不写,并把原因返回**。少沉淀一条经验 vs 改坏一个所有公司共用的本体, + * 两者代价不对称。 + */ +const DREAM_BEGIN = ''; +const DREAM_END = ''; +const AGENT_MD_PREFIX_BUDGET = 2000; + +function renderDreamBlock(accepted, { heading }) { + const now = new Date().toISOString().substring(0, 10); + return [ + DREAM_BEGIN, + `## ${heading}`, + '', + `_由 dream 于 ${now} 提炼自跨公司经验;本区块由机器维护,区块外的内容不会被改动。_`, + '', + ...accepted.map((c) => `- **${c.title}** —— ${String(c.body || '').replace(/\n+/g, ' ')}`), + '', + DREAM_END, + ].join('\n'); +} + +/** 把区块塞进(或替换掉)正文;区块外一个字节不动 */ +function upsertBlock(text, block) { + const b = text.indexOf(DREAM_BEGIN); + const e = text.indexOf(DREAM_END); + if (b >= 0 && e > b) return `${text.slice(0, b)}${block}${text.slice(e + DREAM_END.length)}`; + return `${text.replace(/\s*$/, '')}\n\n${block}\n`; +} + +/** + * @param {string} agentId + * @param {Array} accepted 已过去敏闸门的产物 + * @param {{files?: string[], dryRun?: boolean}} opts + * @returns {Array<{file, written, reason}>} 逐个文件报「写了没有、没写是为什么」 + */ +function writeDefinitionFiles(agentId, accepted, { files = ['SKILL.md', 'soul.md', 'agent.md'], dryRun = false } = {}) { + const body = locator.bodyDir(agentId); + const HEADINGS = { + 'SKILL.md': '跨公司经验(dream)', + 'soul.md': '从经验里长出来的倾向(dream)', + 'agent.md': '跨公司经验提醒(dream)', + }; + const out = []; + if (!accepted.length) return files.map((f) => ({ file: f, written: false, reason: '本轮没有通过闸门的产物' })); + + for (const f of files) { + const abs = path.join(body, f); + const gate = canOverwriteDefinition(abs); + if (!gate.allowed) { out.push({ file: f, written: false, reason: gate.reason }); continue; } + + let text; + try { text = fs.readFileSync(abs, 'utf-8'); } catch (err) { + out.push({ file: f, written: false, reason: `读不到:${err.message}` }); continue; + } + const next = upsertBlock(text, renderDreamBlock(accepted, { heading: HEADINGS[f] || '跨公司经验(dream)' })); + + // 闸门②:agent.md 的前 2000 字符不许被顶掉 + if (f === 'agent.md' && next.slice(0, AGENT_MD_PREFIX_BUDGET) !== text.slice(0, AGENT_MD_PREFIX_BUDGET)) { + out.push({ + file: f, written: false, + reason: `写入会改变 agent.md 的前 ${AGENT_MD_PREFIX_BUDGET} 字符 —— ` + + '规划 agent 只看这一段(graph-engine 的「角色定义摘录」),改了等于悄悄换掉它读到的画像', + }); + continue; + } + + if (!dryRun) { + fs.writeFileSync(abs, next, 'utf-8'); + // 闸门③:写完复核毕业闸门,不过就**立刻还原** + const missing = locator.missingBodyDefinition(agentId); + if (missing.length) { + fs.writeFileSync(abs, text, 'utf-8'); + out.push({ file: f, written: false, reason: `写完毕业闸门不过(缺 ${missing.join(' / ')}),已还原` }); + continue; + } + } + out.push({ file: f, written: true, reason: dryRun ? '(dry-run)' : `已更新 ${gate.rel} 的 dream 区块` }); + } + return out; +} + /** 定义文件能不能写 —— 给后续步骤用,也给控制台显示「为什么没改」 */ function definitionWritability(agentId) { const body = locator.bodyDir(agentId); @@ -318,11 +430,83 @@ async function defaultScreener({ candidate }) { return { shareable: obj.shareable === true, reason: obj.reason || '' }; } +// ── 定时(决策 C:每日一次)──────────────────────────────────────────── +const DAY_MS = 24 * 60 * 60 * 1000; +let sweepTimer = null; + +/** + * 有哪些本体值得跑一轮 dream? + * + * 判据是**本体目录存在 + 不是保留 id**,和 `heartbeat-service.start()` 扫的同一套。 + * 不查 `system_agents` 表:那张表里可能有磁盘上已经没有的悬空行, + * 而 dream 要读的是磁盘上的记忆文件 —— **以「读得到原料的那一侧」为准**。 + */ +function listDreamableAgents() { + try { + return fs.readdirSync(locator.AGENTS_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.')) + .map((d) => d.name); + } catch { return []; } +} + +/** + * 扫一遍所有本体,各跑一次 `dreamOnce`。 + * + * **串行**,不是并发:每一轮都可能起两次 LLM 调用,几十个本体并发等于同时拉起几十个 + * claude 子进程 —— 本仓库为这个付过代价(2h43m 漏出 82 个进程 / 17.5 GB, + * 记在 28-followups §五 第五条)。慢一点没有代价,dream 不赶时间。 + * + * 空转保护在 `dreamOnce` 里:没有新原料的本体连一次 agent 调用都不起, + * 所以「76 个本体全扫一遍」的实际成本是 76 次目录读。 + */ +async function dreamSweep({ onResult, agents = null } = {}) { + const results = []; + for (const agentId of agents || listDreamableAgents()) { + try { + const r = await dreamOnce(agentId); + results.push(r); + if (onResult) onResult(r); + } catch (err) { + // 一个本体炸了不该拖垮整轮 —— 但**必须留下是谁炸的**, + // 否则现场只看到「今天没沉淀」,查不出是没原料还是一直在抛 + console.error(`[Dream] ⚠️ ${agentId} 本轮失败: ${err.message}`); + results.push({ agentId, error: err.message }); + } + } + const ran = results.filter((r) => !r.skipped && !r.error); + console.log(`[Dream] 🌙 扫描完成:${results.length} 个本体,${ran.length} 个有新原料,` + + `沉淀 ${ran.reduce((n, r) => n + (r.accepted || 0), 0)} 条`); + return results; +} + +/** + * 挂上每日定时(决策 C)。 + * + * `unref()`:**绝不拖着进程不退出**。与 task-scheduler 的兜底定时器同一处理 —— + * 少了它,`npm test` 之类的短命进程会挂在这个 24 小时的 timer 上不肯结束。 + */ +function startDaily({ intervalMs = DAY_MS } = {}) { + if (sweepTimer) return sweepTimer; + sweepTimer = setInterval(() => { + dreamSweep().catch((err) => console.error(`[Dream] 定时扫描失败: ${err.message}`)); + }, intervalMs); + if (sweepTimer.unref) sweepTimer.unref(); + console.log(`[Dream] 🌙 已挂载每日经验汇流(间隔 ${Math.round(intervalMs / 3600_000)} 小时)`); + return sweepTimer; +} + +function stopDaily() { + if (sweepTimer) clearInterval(sweepTimer); + sweepTimer = null; +} + module.exports = { - dreamOnce, collectMaterial, companiesOf, readRawEntries, + dreamOnce, dreamSweep, startDaily, stopDaily, listDreamableAgents, + collectMaterial, companiesOf, readRawEntries, defaultDistiller, defaultScreener, extractJson, DISTILLER_AGENT, SCREENER_AGENT, - writeDistilled, definitionWritability, readState, writeState, + writeDistilled, writeDefinitionFiles, definitionWritability, readState, writeState, + DREAM_BEGIN, DREAM_END, setDistiller, setScreener, setDreamAgentCaller, RAW_SECTION, CROSS_COMPANY_MEMORY, }; diff --git a/server/tests/unit/dream-service.test.js b/server/tests/unit/dream-service.test.js index 67670ee..666e4d0 100644 --- a/server/tests/unit/dream-service.test.js +++ b/server/tests/unit/dream-service.test.js @@ -254,3 +254,107 @@ describe('dream · 默认实现的解析与失效方向', () => { expect(screener).toContain('看不到原料'); }); }); + +/** + * 定义文件写入(决策 B)与定时挂载(决策 C)。 + * + * 这两块的共同点:**失效方向都是静默的**。 + * 定义文件写坏 → 本体行为漂了 / 雇不进公司,都不报错; + * 定时挂载写错 → 要么每天空烧几十次 LLM,要么一次都不跑,两种都没人会发现。 + */ +describe('dream · 定义文件写入与定时', () => { + const d = require('../../services/dream-service'); + const locator = require('../../services/agent-locator'); + + const ACCEPTED = [{ title: '结论一', body: '做法一', sources: ['s1'] }]; + + it('未进 git 历史的文件不写,且逐个报出为什么(不写不许是静默的)', () => { + // zz-dream-body 是测试造的运行态本体,不在 git 里 —— 正好是闸门要拦的那种 + const r = d.writeDefinitionFiles(AGENT, ACCEPTED, { dryRun: true }); + expect(r).toHaveLength(3); + for (const x of r) { + expect(x.written).toBe(false); + expect(x.reason).toMatch(/没有被 git 跟踪|读不到/); + } + }); + + it('真写一次:区块外一个字节不动、幂等、写完毕业闸门仍过', () => { + // 挑一个**真被 git 跟踪且干净**的本体来走真路径 —— 那正是闸门放行的前提。 + // 写完在 finally 里还原,并断言确实还原干净(否则这条测试会把仓库弄脏)。 + const BODY = 'dream-screener'; + const abs = path.join(ROOT, 'system', 'agents', BODY, 'soul.md'); + const orig = fs.readFileSync(abs, 'utf-8'); + expect(orig).not.toContain(d.DREAM_BEGIN); // 样本非空:现在还没有区块 + try { + const r1 = d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['soul.md'] }); + expect(r1[0].written).toBe(true); + + const after1 = fs.readFileSync(abs, 'utf-8'); + expect(after1).toContain(d.DREAM_BEGIN); + expect(after1).toContain('结论一'); + // 区块外一个字节不动 + expect(after1.slice(0, after1.indexOf(d.DREAM_BEGIN)).trim()).toBe(orig.trim()); + // 写完毕业闸门仍过 + expect(locator.missingBodyDefinition(BODY)).toEqual([]); + + // 幂等:同一批经验再写一次,只有一个区块,且长度不涨 + d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['soul.md'] }); + const after2 = fs.readFileSync(abs, 'utf-8'); + expect(after2.match(new RegExp(d.DREAM_BEGIN, 'g'))).toHaveLength(1); + expect(after2.length).toBe(after1.length); + } finally { + fs.writeFileSync(abs, orig, 'utf-8'); + expect(fs.readFileSync(abs, 'utf-8')).toBe(orig); + } + }); + + it('agent.md 的前 2000 字符会被改动时就不写 —— 规划 agent 只看这一段', () => { + // dream-screener/agent.md 比 2000 字符短,追加区块必然落进前 2000 字符里, + // 所以真调下来必须是「不写 + 说清理由」。 + const BODY = 'dream-screener'; + const abs = path.join(ROOT, 'system', 'agents', BODY, 'agent.md'); + const orig = fs.readFileSync(abs, 'utf-8'); + const r = d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['agent.md'] }); + expect(r[0].written).toBe(false); + expect(r[0].reason).toContain('前 2000 字符'); + expect(fs.readFileSync(abs, 'utf-8')).toBe(orig); // 一个字节都没动 + }); + + it('没有通过闸门的产物时,三个文件都不写且理由一致', () => { + const r = d.writeDefinitionFiles(AGENT, [], { dryRun: true }); + expect(r.map((x) => x.written)).toEqual([false, false, false]); + expect(r[0].reason).toContain('没有通过闸门的产物'); + }); + + it('可跑 dream 的本体清单跳过保留 id(`_base` / `_registry.json` 不是 agent)', () => { + const ids = d.listDreamableAgents(); + expect(ids.length).toBeGreaterThan(10); // 样本非空 + expect(ids.some((x) => x.startsWith('_'))).toBe(false); + expect(ids).toContain('dream-distiller'); + }); + + it('定时器 unref 且幂等挂载(少了 unref,短命进程会被 24 小时的 timer 吊住不退出)', () => { + const t1 = d.startDaily({ intervalMs: 60_000 }); + const t2 = d.startDaily({ intervalMs: 60_000 }); + expect(t2).toBe(t1); // 重复挂载不产生第二个 + expect(typeof t1.unref).toBe('function'); + d.stopDaily(); + }); + + it('扫描时一个本体抛异常不拖垮整轮,且留下是谁抛的(真调 dreamSweep)', async () => { + hire(CO_A); + seedMemory(CO_A, ['会让提炼抛的原料']); + d.setDistiller(async () => { throw new Error('故意炸'); }); + try { + // agents 显式给,避免真扫 76 个本体 + const rs = await d.dreamSweep({ agents: [AGENT, 'dream-screener'] }); + expect(rs).toHaveLength(2); + const bad = rs.find((r) => r.agentId === AGENT); + expect(bad.error).toContain('故意炸'); // 是谁炸的、为什么,都留下了 + const other = rs.find((r) => r.agentId === 'dream-screener'); + expect(other.error).toBeUndefined(); // 没被拖垮 + } finally { + d.setDistiller(null); + } + }); +});