@modusensus/dsh-mneme 0.9.5 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +10 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +76 -6
  9. package/lib/client.js +259 -11
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/graph/anchoring.js +7 -2
  17. package/lib/index.js +15 -6
  18. package/lib/inject.js +9 -4
  19. package/lib/lang.js +17 -12
  20. package/lib/pipeline-nudge-auto.js +3 -0
  21. package/lib/pipeline-nudge.js +41 -8
  22. package/lib/pipeline-runner.js +15 -0
  23. package/lib/pre-inject-gate.js +14 -0
  24. package/lib/profile-extract.js +4 -1
  25. package/lib/provenance-http.js +162 -37
  26. package/lib/service.js +99 -8
  27. package/lib/session-gate.js +91 -0
  28. package/lib/settings.js +13 -1
  29. package/lib/store.js +681 -18
  30. package/lib/summarize.js +152 -15
  31. package/lib/tools.js +26 -6
  32. package/package.json +4 -2
  33. package/src/api-standalone.js +11 -3
  34. package/src/api.js +76 -6
  35. package/src/config.js +33 -1
  36. package/src/continuity.js +11 -3
  37. package/src/dream/decisions.js +88 -27
  38. package/src/dream/sleep.js +467 -81
  39. package/src/dream.js +110 -13
  40. package/src/fence.js +24 -0
  41. package/src/graph/anchoring.js +7 -2
  42. package/src/index.js +15 -6
  43. package/src/inject.js +9 -4
  44. package/src/lang.js +17 -12
  45. package/src/pipeline-nudge-auto.js +3 -0
  46. package/src/pipeline-nudge.js +41 -8
  47. package/src/pipeline-runner.js +15 -0
  48. package/src/pre-inject-gate.js +14 -0
  49. package/src/profile-extract.js +4 -1
  50. package/src/provenance-http.js +162 -37
  51. package/src/service.js +99 -8
  52. package/src/session-gate.js +91 -0
  53. package/src/settings.js +13 -1
  54. package/src/store.js +681 -18
  55. package/src/summarize.js +152 -15
  56. package/src/tools.js +26 -6
  57. package/test/api.test.js +153 -4
  58. package/test/client.test.js +380 -0
  59. package/test/config.test.js +8 -0
  60. package/test/conflict-reviewed.test.js +79 -0
  61. package/test/continuity.test.js +29 -1
  62. package/test/dependency-overrides.test.js +78 -0
  63. package/test/differentiate-feedback.test.js +248 -0
  64. package/test/dream-decision-evidence.test.js +314 -0
  65. package/test/dream-fence.test.js +43 -0
  66. package/test/graph-boosted-relations.test.js +189 -0
  67. package/test/graph-multipath.test.js +269 -0
  68. package/test/inject-pools.test.js +2 -1
  69. package/test/mint-source.test.js +83 -0
  70. package/test/pipeline-nudge-auto.test.js +26 -2
  71. package/test/pipeline-nudge.test.js +87 -0
  72. package/test/pipeline-runner.test.js +26 -0
  73. package/test/prompt-stance.test.js +128 -0
  74. package/test/provenance-http.test.js +200 -17
  75. package/test/release-docs.test.js +7 -1
  76. package/test/security-doc.test.js +43 -4
  77. package/test/session-gate.test.js +264 -0
  78. package/test/settings.test.js +4 -0
  79. package/test/sleep-action-set.test.js +16 -11
  80. package/test/sleep-catchup.test.js +315 -0
  81. package/test/sleep-failure-backoff.test.js +156 -0
  82. package/test/sleep.test.js +293 -0
  83. package/test/standalone-api.test.js +50 -0
  84. package/test/store.test.js +220 -0
  85. package/test/summarize.test.js +172 -0
@@ -27,6 +27,8 @@ import { computeHeat } from "../heat.js";
27
27
  // demoteToSummary,phasePatterns 还会 mint 出新行,所以这条契约要在每处各挡一遍
28
28
  // (清单与静态锁见 test/share-imported-guard.test.js)。
29
29
  import { isImportedRow } from "../memory-source.js";
30
+ // Issue #533:查账本时要拿两侧当前的正文哈希,与 applyDifferentiate 记账时的口径同源。
31
+ import { contentHashOf } from "../content-hash.js";
30
32
 
31
33
  // Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
32
34
  // ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
@@ -40,6 +42,22 @@ const SUMMARY_MAX = 120;
40
42
  // aggressive low-confidence pairs too (0.75) — bloated stores
41
43
  const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
42
44
 
45
+ // Issue #529(C 项的替代实现):连续失败时最小间隔的封顶。基数默认 8 小时,
46
+ // 2^3 就到 64 小时——不封顶的话模型持续失败会让 sleep 事实上停摆好几天,而
47
+ // 用户完全看不出来。24 小时 = 「最多一天试一次」,与 #292 给 autoDream 的
48
+ // 30 分钟封顶同一意图(封顶值不同是因为两个调度的基数差一个数量级)。
49
+ //
50
+ // 实际封顶取 max(基数, 24h),不是硬 24h:`sleepMinIntervalHours` 允许 1–168,
51
+ // 用户配 48 小时就是明说"两天跑一次",退避不该把它压回 24 小时(封顶只能放宽
52
+ // 间隔、不能收紧用户自己设的)。
53
+ const FAILURE_BACKOFF_CAP_MS = 24 * 3600000;
54
+
55
+ // Issue #529(续排):一轮内部最多连追多少轮。449 对的池子按每轮 60 对只需 8 轮,
56
+ // 24 留足余量;超过就退回正常最小间隔。防的是"池子在长、每轮都 clean 但永远追不完"
57
+ // ——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟一轮,
58
+ // 而审计上每轮都"有产出",肉眼很难发现。
59
+ const CATCHUP_STREAK_CAP = 24;
60
+
43
61
  function parseJsonArray(text) {
44
62
  if (typeof text !== "string") return undefined;
45
63
  const start = text.indexOf("[");
@@ -281,9 +299,83 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
281
299
  // Dedupe: each memory participates in at most one pair, highest similarity
282
300
  // first — overlapping pairs would violate validateDecisions' "one claim".
283
301
  pairs.sort((a, b) => b.similarity - a.similarity);
302
+
303
+ // Issue #533:已经裁决过「互补、两条都留」的对不再重判。
304
+ //
305
+ // 过滤必须发生在 used 去重**之前**:若先占位再丢弃,一条已裁决的记忆会把
306
+ // 名额占住,而它真正的对手又是自己人,这一轮就白跑——抑制一对不该顺带压掉
307
+ // 另一对候选。判据与 applyDifferentiate 的幂等同源:账本里有这对、且两侧正文
308
+ // 的 content_hash 与记账时一致(任一侧改过 = 结论可能不再成立,重判)。
309
+ // 存量兼容走同一格:旧版本把注记写进正文却没账本行,两侧都还带着注记块也算
310
+ // 已裁决,免得升级后第一轮把所有老对重判一遍。
311
+ const alreadyDifferentiated = (p) => {
312
+ const a = service.getById(p.a.id);
313
+ const b = service.getById(p.b.id);
314
+ if (!a || !b) return false;
315
+ if (typeof service.findDifferentiatedPair === "function") {
316
+ const record = service.findDifferentiatedPair(a.id, b.id);
317
+ if (record) {
318
+ const [ha, hb] = a.id <= b.id
319
+ ? [contentHashOf(a), contentHashOf(b)]
320
+ : [contentHashOf(b), contentHashOf(a)];
321
+ // 账本行没记 hash(存量/异常写入)时不下结论,宁可多判一次。
322
+ return record.hash_a != null && record.hash_b != null
323
+ && record.hash_a === ha && record.hash_b === hb;
324
+ }
325
+ }
326
+ return STR.differentiatedNoteRe.test(String(a.content ?? ""))
327
+ && STR.differentiatedNoteRe.test(String(b.content ?? ""));
328
+ };
329
+ const fresh = [];
330
+ let suppressed = 0;
331
+ for (const p of pairs) {
332
+ if (alreadyDifferentiated(p)) {
333
+ suppressed += 1;
334
+ continue;
335
+ }
336
+ fresh.push(p);
337
+ }
338
+ if (fresh.length === 0) {
339
+ // 与 "no conflicts found" 分开报:一对都没检测到 vs 检测到但全都裁决过,
340
+ // 是完全不同的两件事,混成一句会让「sleep 是不是没在干活」读不出来。
341
+ return { status: "noop", reason: "all candidate pairs already differentiated", suppressed };
342
+ }
343
+
344
+ // Issue #529:输入侧预算。此前 samePairs 是**全部**同 scope 相似对,一次拼成一个
345
+ // prompt——实测单请求 163,054 输入 token(服务端 --ctx-size 163,840),撞墙后按冷却
346
+ // 重排,下一轮送进去的还是同一份输入,于是 23 轮连续失败。
347
+ //
348
+ // 预筛掉「已经判过且两侧都没动过」的对:判 keep 曾经是彻底的 no-op,既不留痕也不
349
+ // 影响下一轮的候选池,同一对每个冷却周期都被重新送审一遍。台账(conflict_reviewed)
350
+ // 记的是"这段输入被看过",两侧任一 updated_at 晚于 judged_at 就重新入池——与
351
+ // conflict_pending 复核项 4 同一条宽松代理:任何字段更新都算值得再看一眼。
352
+ //
353
+ // Issue #536 review(CodeRabbit ①):预筛必须发生在下面的 used 去重**之前**,理由与
354
+ // #533 那条一模一样——先占位再去掉,一条已审的记忆会把自己的配对名额占住,而它真正
355
+ // 的对手(另一条没审过的记忆)因此进不了这一轮。三条边界:
356
+ // · 只筛同 scope 对:跨 scope 对本来就要停车进人工队列,不能被台账吞掉;
357
+ // · freeze 模式不筛:那一档只停车不送 LLM,台账与它无关(ledgerSkipped 记 0);
358
+ // · ledgerSkipped 在这里一次数清,后面 candidates 直接取 samePairs,不再二次过滤。
359
+ const isCrossScopePair = (p) =>
360
+ scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
361
+ || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
362
+ const ledger = typeof service.loadConflictReviewed === "function"
363
+ ? (service.loadConflictReviewed() ?? new Map())
364
+ : new Map();
365
+ const pairKey = (p) => (p.a.id < p.b.id ? `${p.a.id}\u0000${p.b.id}` : `${p.b.id}\u0000${p.a.id}`);
366
+ const alreadyJudged = (p) => {
367
+ const rec = ledger.get(pairKey(p));
368
+ if (!rec?.judged_at) return false;
369
+ return (p.a.updated_at ?? "") <= rec.judged_at && (p.b.updated_at ?? "") <= rec.judged_at;
370
+ };
371
+ const selectionPairs = config.conflictFreezeEnabled === true
372
+ ? fresh
373
+ : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));
374
+ const ledgerSkipped = fresh.length - selectionPairs.length;
375
+
284
376
  const used = new Set();
285
377
  const selected = [];
286
- for (const p of pairs) {
378
+ for (const p of selectionPairs) {
287
379
  if (used.has(p.a.id) || used.has(p.b.id)) continue;
288
380
  used.add(p.a.id);
289
381
  used.add(p.b.id);
@@ -295,9 +387,6 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
295
387
  // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
296
388
  // 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
297
389
  // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
298
- const isCrossScopePair = (p) =>
299
- scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
300
- || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
301
390
  const crossPairs = selected.filter(isCrossScopePair);
302
391
  const samePairs = selected.filter((p) => !isCrossScopePair(p));
303
392
  const parkPair = (p, reason) => {
@@ -329,33 +418,42 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
329
418
  if (parkPair(p, scopeReason(p))) scopeParked++;
330
419
  }
331
420
  if (samePairs.length === 0) {
332
- return { status: scopeParked > 0 ? "ok" : "noop", frozen: 0, scopeCandidates: scopeParked, pairs: selected.length };
421
+ // 没有可送 LLM 的同 scope 对:可能一对都没检测到,也可能全被台账挡下了
422
+ // (ledgerSkipped)。两种都要如实报,否则「sleep 怎么什么都不干」读不出来。
423
+ return {
424
+ status: scopeParked > 0 ? "ok" : "noop",
425
+ frozen: 0,
426
+ scopeCandidates: scopeParked,
427
+ pairs: selected.length,
428
+ batches: 0,
429
+ ledgerSkipped
430
+ };
333
431
  }
334
432
 
335
433
  // LLM adjudication(仅同 scope 对)。
336
434
  const route = resolveSleepRoute(ctx, config, logger);
337
- if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length };
338
- const snapshot = new Map();
339
- for (const p of samePairs) {
340
- snapshot.set(p.a.id, p.a);
341
- snapshot.set(p.b.id, p.b);
342
- }
343
- const listText = samePairs.map((p) =>
344
- STR.candidateConflicts[language](p)
345
- ).join("\n\n");
435
+ if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length, batches: 0, ledgerSkipped };
436
+ // 每轮总上限:分批解决的是单请求撞墙,不是"一个 449 对的池子该在一轮里跑完"。
437
+ // 没进这一轮的对留在下一轮(它们既没被丢弃,也不在台账里)。
438
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
439
+ const pool = perRunCap > 0 ? samePairs.slice(0, perRunCap) : samePairs;
440
+ const perRunSkipped = samePairs.length - pool.length;
346
441
  const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
347
442
  let conflictStreamFailure = "";
348
443
  // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
349
444
  // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
350
445
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
351
- const runConflict = (withEffort, tokens) => {
446
+ const runConflict = (batch, withEffort, tokens) => {
352
447
  conflictStreamFailure = "";
448
+ const listText = batch.map((p) => STR.candidateConflicts[language](p)).join("\n\n");
353
449
  return auditedSleepLlm(ctx, service, config, logger, {
354
450
  triggerSource: "sleep",
355
451
  operationType: "sleep_conflict",
356
452
  modelId: `${route.provider}:${route.model}`,
357
453
  // 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
358
- relatedMemoryIds: samePairs.flatMap((p) => [p.a.id, p.b.id]),
454
+ // 分批后必须取**这一批**的 id:整轮的 60 对一次写进每一批的审计行,面板会把
455
+ // 同一个「沉淀条数」重复累加五遍。
456
+ relatedMemoryIds: batch.flatMap((p) => [p.a.id, p.b.id]),
359
457
  streamError: () => conflictStreamFailure,
360
458
  // 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
361
459
  // 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
@@ -377,72 +475,251 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
377
475
  ]
378
476
  }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
379
477
  };
380
- // Issue #478:睡眠侧同样接 max_tokens 降级——面板 hint 已承诺「超上限同样自动降级」,
381
- // 只挂 dream 三个点会让 sleepMaxTokens 的 131072 上限继续原样发。
382
- const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(true, tokens), () => runConflict(false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
383
- if (text === undefined) {
384
- if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
385
- return { status: "failed", error: "llm failed" };
386
- }
387
- const decisions = parseJsonArray(text);
388
- if (!decisions) return { status: "failed", error: "invalid decisions json" };
389
- // validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
390
- // 就会为未覆盖的 id 自动补 keep(dreamImplicitKeep 默认开启),这里保留显式
391
- // 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
392
- const covered = new Set();
393
- for (const d of decisions) {
394
- // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
395
- // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
396
- // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
397
- if (d?.action === "conflict" || d?.action === "supersede") {
398
- if (typeof d?.winner === "string") covered.add(d.winner);
399
- if (typeof d?.loser === "string") covered.add(d.loser);
400
- } else if (Array.isArray(d?.ids)) {
401
- for (const id of d.ids) covered.add(id);
478
+ // 单批的完整往返:送 → 解析 → 校验 → 应用 → 记账。抽成函数是为了让失败只影响
479
+ // 自己这一批(下面的循环),而不是把整轮判死。
480
+ const adjudicateBatch = async (batch, updateBudget) => {
481
+ // Issue #529 review(CodeRabbit ①):留痕写入失败要能被上层看见。只打 warn 的话,
482
+ // 这一对既没记账、又被算成"送出去的全落地了",于是下一轮它原样回到候选池、又被
483
+ // 算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而审计上每轮
484
+ // 都是干净的成功轮。计数随返回值透出,由 phaseConflicts 折进 clean。
485
+ let ledgerFailed = 0;
486
+ // Issue #536 review(CodeRabbit ③):judged_at 是**发送时刻**,不是落地时刻。
487
+ //
488
+ // 取 max(墙钟, 本批两侧里最新的 updated_at):`updated_at` 用的是 store 的
489
+ // **单调时钟**(src/store.js 的 nowIso:同一毫秒内的连续写入逐次 +1ms),它可能
490
+ // 比墙钟快几毫秒;直接用墙钟会记出一个早于被测行自身版本的 `judged_at`,于是
491
+ // 「这一对刚判过」在下一轮的 `alreadyJudged` 里读成「判过之后又被改过」,判 keep
492
+ // 的死循环原样复活(CI 的 test-host-legacy 腿抓到过一次真 flake)。两列同源才可比。
493
+ const judgedAt = batch.reduce(
494
+ (acc, p) => (p.a.updated_at > acc ? p.a.updated_at : (p.b.updated_at > acc ? p.b.updated_at : acc)),
495
+ new Date().toISOString()
496
+ );
497
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(batch, true, tokens), () => runConflict(batch, false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
498
+ if (text === undefined) {
499
+ if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
500
+ return { status: "failed", error: "llm failed", failure: conflictStreamFailure || undefined };
402
501
  }
403
- }
404
- for (const id of snapshot.keys()) {
405
- if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
406
- }
407
- // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
408
- // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
409
- // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
410
- // 模块对"什么算合法"的判据一致。
411
- const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
412
- maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
413
- minAgeHours: config.reflectionUpdateMinAgeHours,
414
- skipInvalid: config.dreamSkipInvalid !== false,
415
- allowCrossTypeMerge: config.allowCrossTypeMerge === true,
416
- // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
417
- // 与 dream 主链路同一判据。
418
- mergeGuard: config.dreamMergeGuard === true,
419
- // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
420
- // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
421
- allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
422
- });
423
- if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
424
- // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
425
- // LEGACY_ACTIONS 含 create,理论上可 mint)。
426
- const { applied, failures, conflicts } = applyDecisions(
427
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
428
- );
429
- // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
430
- // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
431
- // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
432
- if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
502
+ const decisions = parseJsonArray(text);
503
+ if (!decisions) return { status: "failed", error: "invalid decisions json" };
504
+ // 本批的 snapshot 只含本批的 id。validateDecisions 要求每个 snapshot id 恰好被
505
+ // claim 一次,拿整轮的 snapshot 校验单批输出会把其余 48 对判成"未裁决"。
506
+ const batchSnapshot = new Map();
507
+ for (const p of batch) {
508
+ batchSnapshot.set(p.a.id, p.a);
509
+ batchSnapshot.set(p.b.id, p.b);
510
+ }
511
+ // v0.4.4 起 validateDecisions 本身就会为未覆盖的 id 自动补 keep(dreamImplicitKeep
512
+ // 默认开启),这里保留显式预填作为防御性双保险——漏判读作"未裁决冲突"而非
513
+ // "冲突阶段整体失败"。
514
+ const covered = new Set();
515
+ for (const d of decisions) {
516
+ // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
517
+ // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
518
+ // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
519
+ if (d?.action === "conflict" || d?.action === "supersede") {
520
+ if (typeof d?.winner === "string") covered.add(d.winner);
521
+ if (typeof d?.loser === "string") covered.add(d.loser);
522
+ } else if (Array.isArray(d?.ids)) {
523
+ for (const id of d.ids) covered.add(id);
524
+ }
525
+ }
526
+ for (const id of batchSnapshot.keys()) {
527
+ if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
528
+ }
529
+ // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
530
+ // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
531
+ // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
532
+ // 模块对"什么算合法"的判据一致。
533
+ const { ok, errors, skipped } = validateDecisions(decisions, batchSnapshot, {
534
+ // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限,
535
+ // 分批后不能每批都拿整份额度——那样 N 批就是 N 倍上限。这里用的是本轮的剩余额度。
536
+ maxUpdatePerRun: updateBudget,
537
+ minAgeHours: config.reflectionUpdateMinAgeHours,
538
+ skipInvalid: config.dreamSkipInvalid !== false,
539
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true,
540
+ // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
541
+ // 与 dream 主链路同一判据。
542
+ mergeGuard: config.dreamMergeGuard === true,
543
+ // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
544
+ // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
545
+ allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
546
+ });
547
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
548
+ // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
549
+ // LEGACY_ACTIONS 含 create,理论上可 mint)。
550
+ const { applied, failures, conflicts } = applyDecisions(
551
+ // runId 让 differentiate 的账本行能追到是哪一轮 sleep 判的(issue #533)。
552
+ decisions, service, logger, batchSnapshot, { ...config, mintSource: "sleep", runId }
553
+ );
554
+ // Issue #536 review(CodeRabbit ②):本轮剩余的 update 额度按本批**通过校验的**
555
+ // update 数扣减——validateDecisions 已把非法条目从 decisions 就地 splice 掉,
556
+ // 所以数到的都是真的会落地的那些。落地时被 CAS 挡下的也算:额度宁可少发一次,
557
+ // 也不能让"多批各扣一次满额"把整轮上限架空。
558
+ const updateUsed = decisions.filter((d) => d?.action === "update").length;
559
+ // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
560
+ // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
561
+ // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
562
+ if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
563
+ try {
564
+ await maintainIndexAfterDream(decisions, service, semantic);
565
+ } catch (error) {
566
+ logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
567
+ }
568
+ }
569
+ // 台账:本批**全部送审对**都记一行,不只记被判成冲突的那几对。判 keep 的那一对
570
+ // 正是 #529 里"下一轮原样再送"的元凶,只记有动作的对等于没记。
571
+ if (typeof service.saveConflictReviewed === "function") {
572
+ // judged_at 取的是**发送之前**的时刻(见函数开头),不是落地之后:LLM 往返
573
+ // 期间若一侧被外部改动,它的 updated_at 会晚于 judged_at,下一轮自然重新入池;
574
+ // 模型看到的本来就是旧快照,按"已审"封盘等于用陈旧结论锁死这一对。
575
+ const outcomeOf = new Map();
576
+ for (const d of decisions) {
577
+ const action = d?.action ?? "keep";
578
+ if (action === "conflict" || action === "supersede") {
579
+ if (typeof d?.winner === "string") outcomeOf.set(d.winner, action);
580
+ if (typeof d?.loser === "string") outcomeOf.set(d.loser, action);
581
+ } else if (Array.isArray(d?.ids)) {
582
+ for (const id of d.ids) outcomeOf.set(id, action);
583
+ }
584
+ }
585
+ // Issue #536 review(CodeRabbit ③):未落地的对不记账。skipped(非法被跳)、
586
+ // failures(写入失败)、conflicts(CAS 冲突)里出现过的 id,说明那条决策没生效;
587
+ // 把它记成"已审"会让下一轮的 alreadyJudged 为真,这一对从此不再送审——
588
+ // 沉默地永久压制一个真实冲突,比多送一轮贵得多。
589
+ const unsettled = new Set();
590
+ for (const f of [...(failures ?? []), ...(conflicts ?? []), ...(skipped ?? [])]) {
591
+ for (const id of f?.ids ?? []) unsettled.add(id);
592
+ }
593
+ for (const p of batch) {
594
+ if (unsettled.has(p.a.id) || unsettled.has(p.b.id)) continue;
595
+ try {
596
+ service.saveConflictReviewed({
597
+ run_id: runId,
598
+ memory_a: p.a.id,
599
+ memory_b: p.b.id,
600
+ // 一对两端的动作可能不同(conflict 的胜/败、merge 的两端),取**非 keep
601
+ // 的那一侧**:keep/keep 才代表"看过、不冲突",这正是我们要留痕的结论。
602
+ outcome: [outcomeOf.get(p.a.id), outcomeOf.get(p.b.id)]
603
+ .find((x) => x && x !== "keep") ?? "keep",
604
+ judged_at: judgedAt
605
+ });
606
+ } catch (error) {
607
+ // Issue #529 review(CodeRabbit):留痕失败必须让这一轮**不 clean**。只 warn 的
608
+ // 话,这一对既没记账、又被算成"送出去的全落地了",下一轮它原样回到候选池、
609
+ // 又被算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而
610
+ // 审计上每一轮都是干净的成功轮。
611
+ ledgerFailed += 1;
612
+ logger?.warn?.(`dsh-mneme sleep: failed to record reviewed conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
613
+ }
614
+ }
615
+ }
616
+ // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
617
+ // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
618
+ return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed, ledgerFailed };
619
+ };
620
+ // 分批:每批独立往返,坏批只记 degraded 不中断。此前一次全送,任何一处理由
621
+ // (撞上下文、输出截断、JSON 坏掉)都是整轮 failed——而整轮 failed 又不带 pairs,
622
+ // 事后看不出它到底送了什么。
623
+ const batchSize = config.sleepConflictMaxPairs ?? 12;
624
+ const batches = batchSize > 0
625
+ ? Array.from({ length: Math.ceil(pool.length / batchSize) }, (_, i) => pool.slice(i * batchSize, (i + 1) * batchSize))
626
+ : [pool];
627
+ let applied = 0;
628
+ const failures = [];
629
+ const conflicts = [];
630
+ const skipped = [];
631
+ let failedBatches = 0;
632
+ // Issue #529 review(CodeRabbit ①):台账写入失败的累计数(逐批由 adjudicateBatch 报回)。
633
+ let ledgerFailed = 0;
634
+ // 用户活动打断时**不能**落进下面那条 noop:noop 的语义是「看过,没冲突」,而被打断
635
+ // 的那几批根本没送出去。用与入口处(signal 一开始就是 aborted)同一个词汇,避免
636
+ // 同一件事两个状态名。
637
+ let interrupted = false;
638
+ // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限。
639
+ // 校验器本身不跨批记忆,每批各拿一份满额就等于 N 倍上限,所以额度由这里持有:
640
+ // 每批只领剩余额度,校验通过后按**本批通过校验的** update 数扣减。额度用尽的
641
+ // 那几批若还含 update,会被校验器整单拒绝(记 failed),其余批照常继续——
642
+ // 与"一批坏掉不判死整轮"的既有契约一致。
643
+ let updateLeft = config.reflectionUpdateMaxPerRun ?? 2;
644
+ for (let i = 0; i < batches.length; i++) {
645
+ if (signal?.aborted) { interrupted = true; break; }
646
+ // Issue #536 review(CodeRabbit):单批抛错不能带走整轮。`adjudicateBatch` 里不只有
647
+ // "把失败变成返回值"的 LLM 通路,还有 `applyDecisions` 与台账写入——它们抛出的异常
648
+ // 此前会直接跳出循环,后面所有批都不跑、已汇总的 applied/failures 也一起丢掉。
649
+ // 只放行 AbortError(取消意图绝不能被降级重试吞掉,与 dream.js 的 withEffort 守卫同款),
650
+ // 其余一律折成这一批的 failed 结果,循环继续。
651
+ let res;
433
652
  try {
434
- await maintainIndexAfterDream(decisions, service, semantic);
653
+ res = await adjudicateBatch(batches[i], updateLeft);
435
654
  } catch (error) {
436
- logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
655
+ if (error?.name === "AbortError") { interrupted = true; break; }
656
+ res = { status: "failed", error: String(error?.message ?? error), failures: [], skipped: [] };
657
+ }
658
+ if (res.status === "failed") {
659
+ failedBatches++;
660
+ failures.push(...(res.failures ?? []));
661
+ skipped.push(...(res.skipped ?? []));
662
+ // 失败批的原始理由(stream 层的 abort/error 原文)要进审计:只写 "llm failed"
663
+ // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。
664
+ logger?.warn?.(`dsh-mneme sleep: conflict batch ${i + 1}/${batches.length} failed: ${res.error}${res.failure ? ` (${res.failure})` : ""}`);
665
+ continue;
437
666
  }
667
+ applied += res.applied;
668
+ ledgerFailed += res.ledgerFailed ?? 0;
669
+ updateLeft = Math.max(0, updateLeft - (res.updateUsed ?? 0));
670
+ failures.push(...(res.failures ?? []));
671
+ conflicts.push(...(res.conflicts ?? []));
672
+ skipped.push(...(res.skipped ?? []));
438
673
  }
439
- // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
440
- // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
441
- const degraded = skipped.length > 0;
674
+ const degraded = skipped.length > 0 || failedBatches > 0;
675
+ // 一批都没成功才算整轮失败;只要有一批落地,如实记 degraded——这跟 deriveStatus
676
+ // 的契约一致(degraded 也算有产出)。
677
+ const allFailed = failedBatches === batches.length;
678
+ // 有一批没走通时整轮**绝不能报 noop**:noop 的语义是「看过,没冲突」,而踩了
679
+ // llm failed 的那批其实没看过。这是 #529 里最误导人的一处——23 轮失败期间
680
+ // applied 恒为 0,若再叠上 noop,运维只会以为库里没冲突。
681
+ const status = interrupted
682
+ ? "aborted"
683
+ : allFailed
684
+ ? "failed"
685
+ : applied > 0
686
+ ? (degraded ? "degraded" : "ok")
687
+ : failures.length
688
+ ? "failed"
689
+ : failedBatches > 0
690
+ ? "degraded"
691
+ : "noop";
692
+ // Issue #529(续排):「这一轮有没有产出」和「送出去的每一对是不是都落地了」是两件
693
+ // 正交的事,status 只回答前者。全判 keep 的轮次 applied 恒为 0 → status 是 noop
694
+ // (never ok),而它恰恰是 #529 里最该续排的场景;拿 status === "ok" 当判据会在
695
+ // 这里把续排堵死。所以另给一个布尔,口径与下面台账的 unsettled 完全一致:failure
696
+ // (写入失败)/ conflicts(CAS 冲突)/ skipped(非法被跳)任一出现过,就说明有对没
697
+ // 落地,它下一轮还会被重新配出来,不算 clean。
698
+ // Issue #529 review(CodeRabbit ①):台账写入失败同样算"没落地"——那一对没被记成
699
+ // 已审,下一轮会重新入池,与上面 unsettled 是同一个道理。
700
+ const clean =
701
+ !interrupted &&
702
+ failedBatches === 0 &&
703
+ skipped.length === 0 &&
704
+ failures.length === 0 &&
705
+ conflicts.length === 0 &&
706
+ ledgerFailed === 0;
442
707
  return {
443
- status: applied > 0 ? (degraded ? "degraded" : "ok") : failures.length ? "failed" : "noop",
708
+ status,
709
+ clean,
710
+ // Issue #529 D:失败轮也要带 pairs/batches——此前只有成功路径带 pairs,85 条
711
+ // 失败审计行全是"送了个寂寞",连送了多少对都查不到。
444
712
  pairs: selected.length,
713
+ // #533:这一轮被账本压掉、没送 LLM 的候选对数——不记的话「pairs 变小了」
714
+ // 分不清是没检测到还是已经裁决过。
715
+ suppressed,
716
+ batches: batches.length,
717
+ ledgerSkipped,
718
+ perRunSkipped,
445
719
  applied,
720
+ // Issue #529 review(CodeRabbit ①):留痕失败的对数——clean 为 false 时要能分清是
721
+ // 哪一类没落地(写入失败 / 非法被跳 / CAS 冲突 / 台账没记上)。
722
+ ledgerFailed,
446
723
  failures,
447
724
  conflicts,
448
725
  // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
@@ -604,8 +881,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
604
881
  if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
605
882
  // 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
606
883
  // applyDecisions 的 @param config。
884
+ // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
885
+ // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
607
886
  const { applied, failures, conflicts } = applyDecisions(
608
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
887
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
609
888
  );
610
889
  return {
611
890
  status: applied > 0 ? "ok" : "noop",
@@ -681,10 +960,16 @@ function deriveStatus(phases) {
681
960
  // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
682
961
  const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
683
962
  const anyDegraded = list.some((p) => p.status === "degraded");
963
+ // Issue #529:被打断(用户恢复活动)既不是失败也不是"没活干"。相位层已经用
964
+ // aborted 区分了这两种,整轮层若回落到 noop,审计行就又把"没跑完"记成"跑过、
965
+ // 没什么事"——所以这里也给它一个自己的名字。只在没有任何产出时生效:真的落了
966
+ // 东西就是 ok/degraded,真的报错就是 failed,二者都比 aborted 值得优先播报。
967
+ const anyAborted = list.some((p) => p.status === "aborted");
684
968
  if (anyDegraded) return "degraded";
685
969
  if (anyWork && anyError) return "degraded";
686
970
  if (anyError) return "failed";
687
971
  if (anyWork) return "ok";
972
+ if (anyAborted) return "aborted";
688
973
  return "noop";
689
974
  }
690
975
 
@@ -755,7 +1040,18 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
755
1040
  } catch (error) {
756
1041
  logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
757
1042
  }
758
- return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt };
1043
+ // Issue #529(续排):给调度器的续排判据。两个条件缺一不可——
1044
+ // · 本轮**被预算截断**(conflict 相位报告还有没进这一轮的对):说明池子没清空;
1045
+ // · 本轮**clean**:送出去的每一对都落地了,不是为了追一个永远失败的批次空转。
1046
+ // `clean` 缺失时(该相位被 attempt 吞成 failed、或配置让它早退)按"不干净"算,
1047
+ // 宁可少追一轮,也不要在失败状态下把间隔压到静默窗口。
1048
+ const conflictPhase = phases.conflicts;
1049
+ const backlog = Number(conflictPhase?.perRunSkipped ?? 0) > 0;
1050
+ // clean 与 catchup 分开给:前者是"这一轮的活全落地了",后者是"池子没清空 + 前者"。
1051
+ // 合成一个字段会让"干净但没 backlog"看起来像出过问题,排查时反着读。
1052
+ const clean = conflictPhase?.clean === true;
1053
+ const catchup = backlog && clean;
1054
+ return { ok: status === "ok" || status === "degraded", status, clean, catchup, backlog, runId, phases, receipt };
759
1055
  }
760
1056
 
761
1057
  // ---------------------------------------------------------------- scheduler
@@ -788,6 +1084,36 @@ export function createSleepScheduler({
788
1084
  let disposed = false;
789
1085
  let idleTimer = null;
790
1086
  let sleepAbort = null;
1087
+ // Issue #529(C 项):同一进程内的连续失败计数。失败(run 报 status="failed"
1088
+ // 或抛错)+1,其余一律清零(ok/degraded/noop——noop 说明 LLM 通路是好的,
1089
+ // 只是没活干,不该被当成失败拖着退避)。纯内存变量、宿主重启归零:跨重启的
1090
+ // 冷却本来就由 lastRunAtSeed(#89/#291)负责,两者不重叠。
1091
+ let consecutiveFailures = 0;
1092
+ // Issue #529(续排):上一轮报告"还有对没进这一轮、且送出去的全落地了"——池子没清空,
1093
+ // 该接着追而不是等满一个最小间隔。只由 maybeSchedule 按 onRun 的返回置位,跑完一轮就
1094
+ // 重新判:池子追干净或某一轮不 clean 时自动落回 false,不需要额外的复位路径。
1095
+ let catchupWanted = false;
1096
+ // Issue #529(续排):进程内续排连击数。给"池子在长、每轮都 clean 但永远追不完"这类
1097
+ // 库留一道刹车——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟
1098
+ // 一轮。超过上限就退回正常间隔,等下一个静默窗口重新起算。
1099
+ let catchupStreak = 0;
1100
+
1101
+ // Issue #529:有效最小间隔 = 基数 × 2^连续失败数。关闭或尚无失败时逐字节
1102
+ // 返回基数(默认关 = 行为与现状一致)。封顶取 max(基数, 24h) 而不是硬 24h:
1103
+ // 指数再大也不会把用户自己配的大基数(允许到 168h)压小。
1104
+ function effectiveMinIntervalMs() {
1105
+ // Issue #529(续排):还有 backlog 时把冷却项按 0 处理,让表落在**静默窗口**而不是
1106
+ // 最小间隔上。不新增配置键:本轮的账本/更新写入会重置 lastWriteAt,下一轮自然至少
1107
+ // 等一个 sleepIdleMinutes,这本身就是节流;再开一个键要连带 settings.js 白名单与
1108
+ // test/api.test.js 的旗标计数锁,不值得为一件事动三个文件。
1109
+ // 与连败互斥(同 issue 的第三条):连续失败 > 0 时根本不追——两个逻辑抢同一只闹钟
1110
+ // 最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。
1111
+ if (catchupWanted && consecutiveFailures <= 0) return 0;
1112
+ const base = (config.sleepMinIntervalHours ?? 8) * 3600000;
1113
+ if (config.sleepFailureBackoff !== true || consecutiveFailures <= 0) return base;
1114
+ const cap = Math.max(base, FAILURE_BACKOFF_CAP_MS);
1115
+ return Math.min(base * 2 ** consecutiveFailures, cap);
1116
+ }
791
1117
 
792
1118
  function armIdleTimer() {
793
1119
  if (disposed || idleTimer) return;
@@ -798,8 +1124,10 @@ export function createSleepScheduler({
798
1124
  idleTimer = null;
799
1125
  // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
800
1126
  // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
1127
+ // Issue #529(续排):跑成了也未必收工——池子还有没进这一轮的对时要接着追,
1128
+ // 而此时已经没有待触发的写入了,不在这里补挂就永远等不到下一轮。
801
1129
  const ran = await maybeSchedule();
802
- if (!ran) armNextWindow();
1130
+ if (!ran || catchupWanted) armNextWindow();
803
1131
  }, delay);
804
1132
  idleTimer.unref?.();
805
1133
  }
@@ -813,16 +1141,25 @@ export function createSleepScheduler({
813
1141
  // lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
814
1142
  if (lastRunAt <= 0) return;
815
1143
  const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
816
- const cdMs = (config.sleepMinIntervalHours ?? 8) * 3600000;
817
- const delay = Math.max(
1144
+ // Issue #529:这里必须和 shouldRun 用同一个有效间隔,否则退避期里重排的闹钟
1145
+ // 会在基数到点就响、然后被 shouldRun 否掉再重排——空转一串没有意义的定时器。
1146
+ const cdMs = effectiveMinIntervalMs();
1147
+ let delay = Math.max(
818
1148
  lastRunAt + cdMs - now(),
819
1149
  lastWriteAt + idleMs - now(),
820
1150
  0
821
1151
  ) + 1000;
1152
+ // Issue #529(续排):冷却项按 0 处理之后,静默项可能已经是负的(这一轮跑得久、
1153
+ // 期间没有新写入),算出来只剩那 1 秒——追 backlog 不该变成秒级连发。续排下限钉在
1154
+ // 一个静默窗口上:追得动就每轮追一批,但每批之间仍然让库安静下来。
1155
+ // 有写入时静默项会自己变大(账本行落库会重置 lastWriteAt),这里只是保证下限。
1156
+ if (catchupWanted) delay = Math.max(delay, idleMs + 1000);
822
1157
  idleTimer = setTimeoutFn(async () => {
823
1158
  idleTimer = null;
824
1159
  const ran = await maybeSchedule();
825
- if (!ran) armNextWindow();
1160
+ // Issue #529(续排):与 armIdleTimer 的回调同一条理由——追 backlog 的轮次
1161
+ // 跑完必须自己补挂,否则续排在第二张表就断了。
1162
+ if (!ran || catchupWanted) armNextWindow();
826
1163
  }, delay);
827
1164
  idleTimer.unref?.();
828
1165
  }
@@ -833,7 +1170,9 @@ export function createSleepScheduler({
833
1170
  if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
834
1171
  // lastRunAt === 0 means never ran — the min-interval check must not block
835
1172
  // the very first cycle (a real run stamps a nonzero timestamp).
836
- if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
1173
+ // Issue #529:连败退避开启时改用指数放大的有效间隔——恒定失败的模型不再按
1174
+ // 固定节奏连发(#529 实测连续 23 轮 llm failed),只会越打越稀。
1175
+ if (lastRunAt > 0 && at - lastRunAt < effectiveMinIntervalMs()) return false;
837
1176
  return true;
838
1177
  }
839
1178
 
@@ -869,13 +1208,60 @@ export function createSleepScheduler({
869
1208
  const result = await service.enqueue(() =>
870
1209
  onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
871
1210
  );
1211
+ // Issue #529:连败计数只在这里加减。判据优先看 result.status——runSleep 的
1212
+ // ok 只在 ok/degraded 时为真,noop(LLM 通路正常、只是没活干)和用户活动
1213
+ // 打断都会给出 ok:false,把它当失败会让退避在自己工作良好的库里越退越长。
1214
+ // 回落到 !result.ok 只是为了兼容注入的 no-op 桩(没有 status 字段)。
1215
+ // result 为空(最小测试替身)=完成且无失败,不动计数,与 #292 口径一致。
1216
+ if (result) {
1217
+ const failed = typeof result.status === "string" ? result.status === "failed" : !result.ok;
1218
+ consecutiveFailures = failed ? consecutiveFailures + 1 : 0;
1219
+ // Issue #529(续排):判据只认 runSleep 给的 catchup——它是"池子还有没进这一轮
1220
+ // 的对"且"送出去的每一对都落地了"二者的合取。**不要**在这里拿 status 反推:
1221
+ // 全判 keep 的轮次 applied 恒为 0、status 是 noop,恰好在最该追的场景失效。
1222
+ // 连续失败时不追(与退避互斥,见 effectiveMinIntervalMs),并把连击计数清零——
1223
+ // 失败说明这轮的"追"没有意义,重新从正常节奏起算。
1224
+ // 连击到顶就收手,并把连击清零——不清零的话这个进程从此再也不会追(streak 只增
1225
+ // 不减,之后每一轮都被同一道闸挡下),"等下一个静默窗口重新起算"就成了空话。
1226
+ // 收手必须落在 catchupWanted 这个状态上,不能只写在 effectiveMinIntervalMs 里:
1227
+ // 那里改成"返回基数"之后,armNextWindow 仍会按 catchupWanted 把闹钟钉在静默
1228
+ // 窗口上重排,于是每 5 分钟醒一次、每次都被 shouldRun 否掉再重排——没有 LLM
1229
+ // 调用,但定时器链永远不断。
1230
+ const wantCatchup = result.catchup === true && consecutiveFailures <= 0;
1231
+ if (!wantCatchup || catchupStreak + 1 >= CATCHUP_STREAK_CAP) {
1232
+ catchupStreak = 0;
1233
+ catchupWanted = false;
1234
+ } else {
1235
+ catchupStreak += 1;
1236
+ catchupWanted = true;
1237
+ }
1238
+ }
872
1239
  return !!(result && result.ok);
873
1240
  } catch (error) {
874
1241
  logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
1242
+ consecutiveFailures += 1; // Issue #529:抛错同样计入连败
1243
+ // Issue #529(续排):抛错也算失败,续排意图一并清掉——否则上一轮置位的状态会跟着
1244
+ // 逃出 try 块,让下一次排表仍按静默窗口的空转节奏走。
1245
+ catchupWanted = false;
1246
+ catchupStreak = 0;
875
1247
  return false;
876
1248
  } finally {
877
1249
  sleepAbort = null;
878
1250
  running = false;
1251
+ // Issue #529 review(CodeRabbit ②):运行期间用户写入会走 noteWrite(),它把闹钟
1252
+ // 按**那一次写入**重算了一遍——那可能是静默窗口的尾声。若这一轮结束时报告
1253
+ // catchup=true,那只表可能几秒后就要响,下一批紧接着开跑,于是「让库安静一个
1254
+ // 静默窗口再追」变成「跑完立刻再跑」。这里把已挂着的表撤掉重挂,交给
1255
+ // armNextWindow 从**此刻**算出整整一个静默窗口。
1256
+ // 反过来(不追的轮次)保留 noteWrite 挂的那只表:它是按用户最后一次写入算的,
1257
+ // 正是「用户写完了、安静满一个窗口再说」的意思。
1258
+ if (catchupWanted) {
1259
+ if (idleTimer) {
1260
+ clearTimeoutFn(idleTimer);
1261
+ idleTimer = null;
1262
+ }
1263
+ armNextWindow();
1264
+ }
879
1265
  }
880
1266
  }
881
1267