Commit e9f380a5018a3d7fb6943e99bb8be220f3daefae
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>
Showing
3 changed files
with
296 additions
and
4 deletions
server/index.js
| ... | ... | @@ -175,6 +175,10 @@ async function start() { |
| 175 | 175 | // 公司级心跳 (Pipeline 自动驱动) |
| 176 | 176 | const { companyHeartbeatService } = require('./services/company-heartbeat-service'); |
| 177 | 177 | companyHeartbeatService.start(); |
| 178 | + // 29-DREAM 决策 C:经验汇流,每日一次。挂在这个早退之后 —— | |
| 179 | + // 它会起 agent(提炼 + 去敏判定各一次),归 DISABLE_BACKGROUND_AGENTS 管。 | |
| 180 | + // 只挂定时**不立刻跑一轮**:启动那一刻跑没有额外收益,反而让每次重启都烧一遍。 | |
| 181 | + require('./services/dream-service').startDaily(); | |
| 178 | 182 | }, 5000); |
| 179 | 183 | } catch (error) { |
| 180 | 184 | console.error('Failed to start server:', error); | ... | ... |
server/services/dream-service.js
| ... | ... | @@ -145,7 +145,12 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { |
| 145 | 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 | 154 | if (!dryRun) { |
| 150 | 155 | writeState(agentId, { |
| 151 | 156 | ...readState(agentId), |
| ... | ... | @@ -155,7 +160,11 @@ async function dreamOnce(agentId, { dryRun = false } = {}) { |
| 155 | 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 | 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 | 303 | function definitionWritability(agentId) { |
| 192 | 304 | const body = locator.bodyDir(agentId); |
| ... | ... | @@ -318,11 +430,83 @@ async function defaultScreener({ candidate }) { |
| 318 | 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 | 503 | module.exports = { |
| 322 | - dreamOnce, collectMaterial, companiesOf, readRawEntries, | |
| 504 | + dreamOnce, dreamSweep, startDaily, stopDaily, listDreamableAgents, | |
| 505 | + collectMaterial, companiesOf, readRawEntries, | |
| 323 | 506 | defaultDistiller, defaultScreener, extractJson, |
| 324 | 507 | DISTILLER_AGENT, SCREENER_AGENT, |
| 325 | - writeDistilled, definitionWritability, readState, writeState, | |
| 508 | + writeDistilled, writeDefinitionFiles, definitionWritability, readState, writeState, | |
| 509 | + DREAM_BEGIN, DREAM_END, | |
| 326 | 510 | setDistiller, setScreener, setDreamAgentCaller, |
| 327 | 511 | RAW_SECTION, CROSS_COMPANY_MEMORY, |
| 328 | 512 | }; | ... | ... |
server/tests/unit/dream-service.test.js
| ... | ... | @@ -254,3 +254,107 @@ describe('dream · 默认实现的解析与失效方向', () => { |
| 254 | 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 | +}); | ... | ... |