Skip to content

[bug] sleep 冲突阶段的输入侧没有任何预算:全部候选对一次塞进一个 prompt、判 keep 不留痕,单请求实测 163,054 token(整池 163,840),失败后按冷却重排同一输入 #529

Closed

Description

@chengxinshengglj-png

摘要

sleep 的冲突阶段(phaseConflicts)把当前库里所有同 scope 相似对拼成一个 prompt 一次性送出,输入侧没有任何条数/字符/token 预算;而判 keep 的对不写任何东西(applyDecisions 里是明确的 no-op),相似度与 updated_at 都不变 ⇒ 下一轮同一批对重新配对、重新送审、再判一次 keep。

两侧都没有预算:输出侧的 2048 已经被 #257 / #260 修掉了,输入侧一直没有。库一大,单请求就贴满服务端窗口,然后进入「失败 → 按冷却重排 → 同一输入再失败」的确定性循环。

本机实测:

项 值
sleep_conflict 审计行 131 条(success 46 / error 85)
单请求 input_tokens 峰值 163,054(服务端 --ctx-size 163840 ⇒ 占整池 99.5%)
同型三行 157,257 / 162,897 / 163,054,全部 no json array in llm output,输出 8,192 / 943 / 783 token,耗时 36.8s / 286.4s / 284.6s
单轮候选对(pairs) 443 ~ 449
单轮 applied(整轮,含 demotion/patterns) 3 ~ 7(冲突阶段几乎不产生写入)
连续失败 10-26 12:55 → 10-29 11:11 连续 23 轮 conflicts: llm failed(中间没有一次成功),applied 恒为 3;全库累计 98 轮
conflict_pending 841 行(已裁决 822 / 未裁决 19)

环境

项 值
dsh-mneme 0.9.5(8b78cdeecdd3f1081fd74020218915cc282071b3,npm latest)
后端 本地 llama.cpp / llama-swap,Qwen3.8-27B-Coder390-Vision
服务端窗口 --parallel 3 --ctx-size 163840(unified,三槽共用一个 163,840 池)
记忆库 活跃 3,409 条(全局 2,663 / 带 scope 746),归档 4,375 条
行号来源 npm tarball 的 package/src/...(0.9.5 原文),不是本地 lib/(本地已打自用补丁)

现象

冲突阶段的送审对象是「去重后全部候选对」,没有上限:

// src/dream/sleep.js:227  候选源 = 全库
const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));

// :265
const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);

// :283-291  去重(每条记忆至多一对),但没有条数上限
pairs.sort((a, b) => b.similarity - a.similarity);
const selected = [];
for (const p of pairs) {
  if (used.has(p.a.id) || used.has(p.b.id)) continue;
  ...
  selected.push(p);
}

// :343-345  一次请求装完全部
const listText = samePairs.map((p) =>
  STR.candidateConflicts[language](p)
).join("\n\n");

本机这 3,409 条库里,全局(agent_scope/workspace_scope 均 NULL)占 2,663 条,同 scope 候选充足,pairs 稳定在 443~449。

至于那三条 157k~163k 的请求当时装了多少对——查不出来:pairs 只在成功路径的返回值里(:442-444),失败路径(:385 / :388 / :423)不带,审计行也不记。这正是下面的观测缺口之一,也是我只能报"峰值 163,054 token"而不能报"当时送了多少对"的原因。

为什么它不会自己好:keep 不留痕

// src/dream/decisions.js:504-509
// keep is a confirmed no-op: it commits nothing but still records the
// per-id disposition so the outcome covers every snapshot memory.
if (d.action === "keep") {
  committed.push({ action: "keep", ids: d.ids });
  continue;
}

committed 是进程内数组,随 run 结束消失。没有 updated_at 变化、没有落库标记 ⇒ 下一轮 findPotentialConflicts 会原样再算出这一对,再送一次。

分批之后(12 对/批)能直接看到这件事的规模:模型每批只对 1~6 对开口(审计里 all 1~6 decision(s) invalid 的 N 就是它实际回答的条数,其余按 dreamImplicitKeep 补 keep)。也就是说 443 对里真正需要裁决的是极少数,其余全是"再问一次还是 keep"的老对,每轮 token 照付、状态不变。

值得一提的是:conflict_pending 里已经有差不多一半的语义了——

// src/store.js:2837-2845
const reviewed = db.prepare("SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NOT NULL ORDER BY resolved_at DESC, rowid DESC LIMIT 1").get(a, b);
if (reviewed) {
  const touched = db.prepare("SELECT count(*) AS c FROM memories WHERE id IN (?, ?) AND updated_at > ?").get(a, b, reviewed.resolved_at).c;
  if (touched === 0) return undefined;   // 已裁决 + 两侧未改动 ⇒ 别再停车
}

但这道判据只用在停车(跨 scope 那条路),同 scope 的 LLM 裁决路径没有等价物。

失败之后不会停

// src/dream/sleep.js:862-875
async function maybeSchedule() {
  try {
    ...
    return !!(result && result.ok);       // 失败 ⇒ false
  } catch (error) { ... return false; }
}

// :825(armNextWindow 的回调里)
const ran = await maybeSchedule();
if (!ran) armNextWindow();                // ⇒ 按剩余冷却重排

armNextWindow 的注释把这件事写成了公理:

// :807-809
// 跑成了一轮就不重排:安静的库没有要处理的新东西,触发权交还给下一次写入的 noteWrite。

「还有 400+ 对没判」和「这一轮整个失败」都不在这个分支里。于是:失败 → 按 CD 重排 → 同一批输入 → 必然同样失败。本机 10-26 12:55 → 10-29 11:11 连续 23 轮 llm failed,applied 恒 3,pairs 全部为 null(:383-386 就在拿到 text 之前返回了);全库累计 98 轮。

另一点:--ctx-size 163840 是 unified 的整池,一条 163k 的请求会把整个 KV 池吃掉,同一时刻的主对话/其他插件请求一起排队。单请求能吃满池这件事,可能比它自己失败更值得管。

两个观测缺口(导致这类问题从外部无法自证)

  1. 85 条 error 里 79 条 input_tokens = 0:请求在拿到服务端 usage 之前就没了,error_message 只有 no json array in llm output 或空。到底是"服务端拒了"还是"模型不守格式",事后查不出来(与 autoDream 宽容路径补强(0.7.22 实测三发现):coverage 误伤 / archive 类型越界 / 失败审计缺明细 #104 / [Feature] 后台 LLM 开销可见性:实体抽取与 Sleep Mode 未计入审计表 #250 同族)。
  2. pairs 只在成功路径的返回值里(:442-444),失败路径(:385 / :388 / :423)不带 ⇒ 失败轮从 dream_runs 里看不出它当时要送多少对。

诉求

A. 候选预算:加 sleepConflictMaxPairs + sleepConflictMaxBatches(或直接按 token 预算切批),一批一批送。现在 skipInvalid 只管校验失败,管不了整请求失败——一批炸掉会带走整轮。

B. 记账:判过的对要留痕(两侧 updated_at 未变就跳过再审)。store.js 的复核项 4 可以直接复用:把同 scope 的裁决结果也写进 conflict_pending(带一个 sleep-batch 记账 之类的原因),下一轮预加载后按 untouchedSince 过滤。不需要新表。

C. 续排与退避:if (!ran) → if (!ran || 还有积压);同一 snapshot_hash 连续失败要拉长间隔,别按固定 CD 全额重付。

D. 审计补字段:失败轮也记 pairs/batches,error 里落流错误的原文。

参考实作

本地按 A/B/C 打了三个键(sleepConflictMaxPairs 默认 12 / sleepConflictMaxBatches 默认 3 / sleepMinIntervalMinutes 默认 0 = 回退上游小时键),只改 src/dream/sleep.js + src/config.js,没动提示词与校验器。同库同轮的效果:

打之前 打之后(10-30 全天 9 轮)
单请求输入 157k ~ 163k token 806 ~ 2,011 token
每轮送审 443 对,一次 3 批 × 12 对(batches: 3),余下前移到下一轮
llm failed 连续 23 轮(10-26~10-29) 0 轮(27 次调用全 success)
台账跳过 — 每轮 32 ~ 53 对(判过且两侧 updated_at 未变)
单批失败 一批炸掉 = 整轮 failed 9 轮里 7 轮有 12 批失败但整轮仍 degraded 并落 13 条裁决;只有 2 轮 3 批全败才记 failed

最后一行是分批真正的价值:整请求失败不再等于整轮归零。但也要说清楚——分批只是把「一次赌 443 对」换成「赌 36 对」,如果没有 B(记账),同样的 400+ 对下一轮还会在原地等着。

需要的话我可以把它整理成 PR(含 config schema、分批循环、台账预载与重排判据)。

Activity

  1. added
    bug功能异常、崩溃、数据错误(agent 诊断 → 人工确认)
    on Oct 30, 2026
  2. modusensus commented on Oct 30, 2026

    @modusensus
    Member

    先谢谢这份报告——这个细节密度(行号、审计读数、同型三行的 token/耗时、连续 23 轮的时间跨度)很少见,我们照着逐行回了源码,结论是全部属实,没有一处夸张。

    尤其是「keep 是 no-op ⇒ 下一轮原样回来」这条因果链,和第 2 条观测缺口(pairs 只在成功路径带出)之间的互相掩护,写得很准:正因为失败轮不带 pairs,所以从外面只能看到「又失败了」,看不到「同一批又送了一次」。

    四条诉求我们都收,但其中三处的落点想跟你商量一下,避免 PR 做出来返工。


    A. 候选预算 —— 收,但建议去掉 maxBatches

    你自己那张表里最有说服力的是最后一行:「9 轮里 7 轮有 1~2 批失败但整轮仍 degraded」。这说明价值不在「最多几批」,而在单批失败不中断整轮。如果加了 maxBatches 但循环体里仍然 return { status: "failed" },等于白加一个旋钮。

    建议缩成两个数:

    • sleepConflictMaxPairs(每批对数,默认 12)
    • sleepConflictMaxPairsPerRun(每轮总对数上限,余下前移到下一轮)

    外加两条不带配置的实现要求:

    1. 每批送完必须把该批从 samePairs 里 splice 掉(src/dream/sleep.js:302 那行之后),否则重试/退避会把同一批重复送;
    2. 失败批只记 degraded,不 return;只有「零批成功」才记整轮 failed。

    这两条比多一个配置键重要得多。

    B. 记账 —— 收,但不要写 conflict_pending

    方向对(复用「已裁决 + 两侧未改动就跳过」这条既有判据),落点我们建议换掉,理由是那张表已经被人工队列占着了:

    • countConflictPending()(src/store.js:2884-2888)数的是 resolved_at IS NULL,直接喂面板的待确认角标;
    • src/service.js:2035 那条 listConflictPending({ includeResolved: true, limit: 500 }) 会把库里最近 500 行捞出来做「已裁决」视图——机器记账行按每轮 12~36 对的速度灌进去,几天就能把这个窗口填满,人工真正该看的裁决被挤到 500 行之外;
    • 表注释(src/store.js:165-170)写的就是「conflict parked for manual review」,塞机器记账进去等于把两种生命周期混在一个字段上。

    替代:单开一张 conflict_reviewed(memory_a, memory_b, judged_at, outcome),两端 id 沿用 saveConflictPending 的 sort() 归一化(src/store.js:2832),主键 (memory_a, memory_b),INSERT OR REPLACE 天然幂等。预载时按 updated_at > judged_at 过滤——这就是你要的那条判据,一行不变。

    复杂度不比复用高,但「人看的队列」和「机器看的账」分开了,以后面板加筛选也不会互相污染。

    记账位置提醒一句:别写进 applyDecisions(src/dream/decisions.js:497)。那是 dream 和 sleep 共用的,写进去会顺带污染日间巩固路径。留在 phaseConflicts 里,在 validateDecisions 之后对本批送审的全部对统一记(被 archive 的也记——归档后不会再配对,多一行无害且能审计)。

    C. 退避 —— 收,但 snapshot_hash 这个判据用不了

    src/dream/sleep.js:725 的 snapshotHash 是 run 跑完之后对 phases 结果算的哈希,不是输入快照:

    • 失败轮 phases 全是 failed,哈希确实稳定,但它跟「这一轮送了哪些对」毫无关系;
    • 它在 runSleep 内部算完就丢了——maybeSchedule() 的 onRun 返回值(src/dream/sleep.js:869-872)只有 { ok, status, runId, phases, receipt },调度器拿不到,也存不下来。

    要做同一件事,建议退化成按连续失败次数指数退避:有效间隔 = sleepMinIntervalHours × 2^连续失败数,成功清零,封顶。这样判据是「我连着失败了几次」,不需要在两次 run 之间传递任何输入指纹,也不会因为 phases 里多一个字段就误判成「输入变了」。

    (顺带:仓库里已经有同款的 autoDreamFailureBackoff(src/settings.js:145-150 附近,Issue #292),判据一致,可以照抄。)

    D. 审计补字段 —— 收,但建议缩小范围

    失败轮补 pairs/batches 完全同意,就改 src/dream/sleep.js:385 / 388 / 423 三处 return,加上 conflictStreamFailure 的原文(这个闭包里已经有,:347)。

    但「79 条 input_tokens = 0」这条我们想单独拆出去:它和 #104 / #250 是同一个族——请求在拿到服务端 usage 之前就断了,这是传输层的问题,不是冲突阶段的问题,混在这个 PR 里会让 review 面从「睡眠预算」扩到「审计/流」两层。建议另开一个 issue,我们跟着修。


    如果开 PR,这几个闸门提前说一声

    1. 新配置键必须 src/config.js 的 schema 和 src/settings.js 的白名单成对注册,两边缺一边面板就读不到;
    2. test/api.test.js:757 有个硬编码计数 Object.keys(data.effective).length === 106(只数白名单键)。按上面缩之后的两个键,这个数要 106 → 108,并在上面几行照惯例写一句注释说明来源;
    3. sleepMinIntervalMinutes 这个键先不用加——sleepMinIntervalHours(src/config.js:588,z.natural().min(1))已经在那儿了,再加一个 0=回退的分钟键会让面板出现两个都叫「最小间隔」的旋钮。指数退避直接叠在小时键上就行;
    4. 改 src/ 之后跑一次 npm run sync(lib/ 是产物,check-sync 会拦未同步的手改);
    5. 回归测试建议锁两件事,都能做变异验证(改回旧行为必红):
      • 一批失败不影响其他批:mock 让第 2 批抛错,断言第 1、3 批的裁决落库、整轮 degraded 而非 failed;
      • 判过的对下一轮不再送:连跑两轮,断言第二轮送审对数不含第一轮判过的对;
    6. 提交署名按 CONTRIBUTING 来:不要有 AI 署名,也不要有任何 Co-Authored-By trailer(指向人也不行)。

    还有一条边界:这个改动不需要动 src/lang.js。你原文也写了「没动提示词与校验器」,我们确认这是对的、也请守住——那边现在有另一条在改,同期碰同一行会白忙一趟 rebase。


    按这个口径,A + B 是同一个 PR 比较合理(他自己也说了「没有 B,分批只是把一次赌 443 对换成赌 36 对」),C 可以随后单独一个。你要是愿意拆成两个 PR,我们这边排期更顺。

    再次感谢,这份报告省了我们很多现场取证。

  3. moved this from 📋 Backlog to 🔨 In Progress in dsh-mneme 任务看板on Oct 31, 2026
  4. modusensus commented on Oct 31, 2026

    @modusensus
    Member

    @chengxinshengglj-png 按你这份报告实现了,PR 在这里:#536。

    A / B / C / D 四条都做了,三处落点按上一条回帖的说明做了调整(C 的判据从 snapshot_hash 换成了连续失败计数——runSleep 里那个 hash 是跑完之后对 phases 结果算的,不是输入快照,用它判「同一份输入又在失败」不成立)。

    两处特别想听你的意见:

    • sleepFailureBackoff 的封顶 24h 是我拍的。你那边 449 对的池子,撞上下文之后模型侧通常多久能腾出容量?如果实际要更久,封顶应该抬。
    • sleepConflictMaxPairsPerRun 默认 60,估算依据是「60 对 ≈ 5 批 ≈ 10K 输入 token」。你手上有那台库的真实分布(443–449 对、单轮 applied 3–7),这个默认值大概率比我准。

    另外你原案里的 sleepMinIntervalMinutes(传 0 回退小时键)没有加,理由在 PR 的「三处刻意的取舍 (a)」——面板上会出现两个都叫「最小间隔」的调节项。指数退避直接基于 sleepMinIntervalHours 算。

    你本地已经打过 A/B/C 三键的那套,欢迎直接在这个 PR 上挑刺或接管细化。

  5. added 2 commits that reference this issue on Oct 31, 2026
  6. moved this from 🔨 In Progress to ✅ Done in dsh-mneme 任务看板on Nov 1, 2026
  7. chengxinshengglj-png commented on Nov 1, 2026

    @chengxinshengglj-png
    ContributorAuthor

    @modusensus 先说结论:三处落点调整我都同意。B 换表那条尤其对——我原案只想着「别新建表」,没算到 conflict_pending 的 limit: 500 窗口会被机器记账灌满、把面板角标淹掉,那个后果比多一张表严重得多。C 的 snapshot_hash 判据确实不成立,我认这个错。

    两个问题,用我这台库(3,400 条活跃记忆、冲突候选池 443~449 对)的实测回答。

    1. sleepFailureBackoff 封顶 24h:够,不用抬

    但要区分两种「撞上下文」,它们的恢复时间差三个数量级:

    类型 本机实测 恢复时间
    输入永久超池 644 对 = 193,037 token vs --ctx-size 163840;10-26 12:55 → 10-29 11:11 连续 23 轮 llm failed,跨 70 小时 永不自愈,只能等代码修
    临时撞池(主对话吃满 KV) 11-01 实测:主对话 135,471 + sleep 39,961 = 175,432 > 163,840 分钟级:那一轮主对话解码完就腾出来

    第一种正是 A 要治的(加了 perRun 预算就不会再出现),第二种根本不该退到小时级。所以 24h 对这两种都够——第一种退多久都没用,第二种 1h 就绰绰有余。max(base, 24h) 的写法也对,不会把用户配的 168h 压小。

    2. sleepConflictMaxPairsPerRun 默认 60:你的估算准,就用它

    本机 10-30 九轮、27 次 sleep_conflict 调用实测:

    • 单批 12 对 → 输入 806~2,011 token(折合单对 67168,和 config.js:676 注释里的 112168 一致)
    • 60 对 ≈ 5 批 ≈ 4.0k~10.1k 输入 token —— 你的「≈10K」是上限而不是均值,默认值没有偏大
    • 单批耗时 60120 s,一轮 510 分钟,相对 sleepMinIntervalHours 最小 1h 的窗口只占 8~17%

    一个附带观察:这组 60~120 s 是在 decode 55 t/s 下测的。同一天本机还踩到 decode 掉到 1.58 t/s(三槽统一池里另一个请求正在做 13 万 token 的冷 prefill,把整个 batch 吃光),同一批从 90 s 变成 30 分钟。这不是默认值的问题——恰恰说明批越小越安全,被大 prefill 插队的概率越低。所以「每批 12 / 每轮 60」这个切法我支持。

    3. 一个想补的缺口:A 造了 backlog,C 没接住

    合并后的 dsh-mneme/src/dream/sleep.js:1062 / :1087 仍然是:

    const ran = await maybeSchedule();
    if (!ran) armNextWindow();

    注释写的是「跑成了一轮就不重排:安静的库没有要处理的新东西」。这个前提在 A 之前成立,A 之后不成立了——跑成一轮恰恰意味着还有东西要处理:449 对的池子、perRun 60,一轮必然剩 389 对。剩下的只能等下一轮,而下一轮要等 sleepMinIntervalHours(min(1) / 默认 8)到点或新的写库触发,于是

    449 对 = 8 轮 = 8 ~ 64 小时才能消化完一轮候选池

    本地我打的是 backlogRemains()(跑完还有候选就自排,间隔取短值),同样的池子按 36 对/轮大约 2 小时走完。

    建议的最小改动,就是让「本轮因 perRun 上限被截断」也参与重排:

    if (!ran || truncatedByPerRun) armNextWindow();

    两个坑提前说:

    • 截断续排不能计入 consecutiveFailures,否则一个健康的库会因为「池子大」把自己退避到 24h;
    • 退避开启时,续排仍要受 effectiveMinIntervalMs() 约束,两个逻辑别互相打架(armNextWindow 里已经在用同一个函数,这点你已经处理好了)。

    4. 本地迁移

    这版落地后我就不需要自己那套补丁了。计划:保留 sleepConflictMaxPairs: 12、把自造的 sleepConflictMaxBatches 换成 sleepConflictMaxPairsPerRun、砍掉原案的 sleepMinIntervalMinutes(你说面板上会同时出现两个「最小间隔」——我在自己的配置里已经体会到了,确实该砍)。conflict_pending 里那 800 多行机器记账本来也该淘汰,conflict_reviewed 是干净的起法。

    如果第 3 点你认可,我可以直接在现在 main 上的代码开 PR。

  8. moved this from ✅ Done to 🎯 Ready in dsh-mneme 任务看板on Nov 1, 2026
  9. modusensus commented on Nov 1, 2026

    @modusensus
    Member

    @chengxinshengglj-png 第 3 点我认可,方向对:perRunSkipped > 0 确实说明这轮是被预算截断、不是没活干,"安静即无事"那个老前提从 A 落地那一刻起就失效了。

    但按你写的那个最小改动直接打,会把 #529 那个8 小时一轮的死循环换成 5 分钟一轮的版本,而且更难看出来。三条都是核代码时撞出来的,第 1 条尤其关键。

    1. 判据不能用 status === "ok" —— 全判 keep 的轮次永远不是 ok

    adjudicateBatch 的返回是(dsh-mneme/src/dream/sleep.js:602):

    return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", ... }

    applied === 0 到不了 "ok"。而 #529 的核心症状正是"判 keep 不落地"——keep 在 applyDecisions(dsh-mneme/src/dream/decisions.js:522-526)里是 confirmed no-op,applied 不涨。一整轮 60 对全判 keep,applied 是 0、status 是 noop。拿 status === "ok" 当"全落地"的信号,恰好在最该追的那个场景里把追堵死。

    建议别让调度器反推 status 的语义,在 phaseConflicts 的返回里加一个正交的布尔:

    clean: !interrupted && failedBatches === 0 && skipped.length === 0 && failures.length === 0,

    status 回答"这轮有没有产出",clean 回答"送出去的每一对是不是都落地了"。追 backlog 只该看后者。

    2. 只改回调里那个 if 不够,得喂给 armNextWindow 的 delay

    const delay = Math.max(
      lastRunAt + cdMs - now(),      // 冷却/退避项
      lastWriteAt + idleMs - now(),  // 静默窗口
      0
    ) + 1000;

    cdMs = effectiveMinIntervalMs(),默认 8h。回调里判了条件是没用的——表本身还挂在 8 小时后;绕过去还有 shouldRun(:1100)里同一道闸。所以是两处:delay 的冷却项 + shouldRun 的间隔判定。

    顺带一个实现细节:ran 是布尔(maybeSchedule 结尾 return !!(result && result.ok)),回调拿不到 result。要么在 maybeSchedule 里把本轮的 perRunSkipped/clean 存进闭包,要么让它返回结果对象、三处调用点改 .ok。

    我倾向不新增配置键,续排下限直接用 sleepIdleMinutes(backlog 时把冷却项按 0 处理)。一是天然节流——每轮的账本/更新写入会重置 lastWriteAt,下一轮至少等一个静默窗口,按你实测 5~10 min/轮 + 5 min 静默正好是你那个量级;二是新键要连带 settings.js 白名单和 dsh-mneme/test/api.test.js:763 的旗标计数锁(现在 111),不值得为一件事开三个文件。

    3. 更隐蔽的回环:全判 keep 的轮次也不进任何"坏"分母

    连败计数只认整轮 status === "failed"(:1141-1144),而有产出但有一批失败的轮次整轮状态是 degraded → ok: true → 计数清零。这本身没问题(degraded 确实丢了东西,但不该退避到 24h)。问题是叠加续排之后:一个永远有一批失败、永远截断的库,consecutiveFailures 恒为 0、perRunSkipped 恒大于 0,就会每 5 分钟跑一轮,每轮都丢两成,而审计上看每轮都"有产出"。

    所以除了 clean 那条合取,再加一道进程内的 catchupStreak 上限(建议 24——449 对按 60/轮只需 8 轮,留足余量),超过就退回正常间隔。给"池子在长、每轮都 clean 但永远追不完"留刹车。

    坑 1 你说得对,续排不该碰 consecutiveFailures。坑 2 我建议比"追但受约束"更干脆:consecutiveFailures > 0 时根本不追——两个逻辑抢同一只闹钟最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。

    测试

    dsh-mneme/test/sleep-scheduler-rearm.test.js 已有注入时钟与定时器的 harness,onRun 直接返回造好的形状即可。三条:

    1. clean + 有 backlog 之后,下一张表的到点接近静默窗口而非 8h,并且真的起跑;
    2. 同一形状但 clean: false(failedBatches > 0)之后,不许挂短表;
    3. 全判 keep 的一轮(applied === 0、status === "noop"、clean === true)仍然要追 —— 这条锁的就是第 1 条那个陷阱。

    第 2 条请做变异自证:把 clean 从判据里删掉,它必须红。我给 #536 写"单批抛错不带走整轮"时踩过一次——桩打在本来就兜底的层上(applyDecisions 自己会把 service 的异常吞进 failures),用例一直绿,删掉守卫还是绿。

    分支

    #536 已在 de5058d 合进 main,从 main 切就行,不会冲突。提 PR 之后我来 review。

    顺带一句无关的:conflict_pending 那 800 多行淘汰前先数一下 resolved_at IS NULL 还剩几条——台账不碰这张表,但那里可能混着真正的人工待裁决。

  10. modusensus commented on Nov 2, 2026

    @modusensus
    Member

    #545 于 2026-11-02 合入 main(5cf515a),续排补上了。reopen 的诉求已闭环,逐条对上你 11-01 列的三条陷阱:

    ① 判据不能用 status === "ok" ✅
    改了正交布尔 clean(src/dream/sleep.js:700-706):

    const clean =
      !interrupted && failedBatches === 0 &&
      skipped.length === 0 && failures.length === 0 &&
      conflicts.length === 0 && ledgerFailed === 0;

    续排判据是 catchup = backlog && clean(:1053),完全不碰 status。全判 keep 的轮次 applied === 0、status === "noop" 仍算 clean —— 这条单独有用例锁(test/sleep-catchup.test.js:12)。

    ② armNextWindow 的 delay 与 shouldRun 两处都改 ✅
    不是只改回调里的 if:

    • effectiveMinIntervalMs() 在 catchupWanted && consecutiveFailures <= 0 时返回 0(:1111)
    • armNextWindow 的 cdMs 走同一个函数(:1146),且 catchup 时把 delay 下限钉在静默窗口 idleMs + 1000(:1156),避免退化成秒级连发
    • shouldRun 的间隔判定同样走它(:1175)
    • 三处调用点都补了 if (!ran || catchupWanted) armNextWindow();(:1130 / :1162),否则续排在第二张表就断

    ③ 全判 keep 的轮次不进坏分母 → 加 catchupStreak 上限 ✅
    CATCHUP_STREAK_CAP = 24(:56),到顶就收手;consecutiveFailures > 0 时根本不追(与退避互斥,按你建议的「互斥最省事」)。收手落在 catchupWanted 状态上而不是只写在 effectiveMinIntervalMs 里——否则定时器链每 5 分钟醒一次空转(:1226-1229 的注释记了这条)。

    变异自证(你特别要求的那条)
    把 catchup 判据里的 clean 删掉再跑:

    ✖ issue#529 续排:坏批让这一轮不 clean,即使还有 backlog 也不追
      AssertionError: 关键:不 clean 时宁可少追一轮,也不要在失败状态里把间隔压到静默窗口
      true !== false
    

    恢复后 10/10 全绿,git diff 干净。

    另外 #536(de5058d)的 A/B/C/D 落点也都是按你 10-30 那条回帖调的(conflict_reviewed 单独开表、C 的判据换成连续失败计数、sleepConflictMaxPairs=12 / sleepConflictMaxPairsPerRun=60)。

    谢谢这份报告——行号、审计读数、连续 23 轮的时间跨度,直接省掉了现场取证。


  11. moved this from 🎯 Ready to ✅ Done in dsh-mneme 任务看板on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug功能异常、崩溃、数据错误(agent 诊断 → 人工确认)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions