diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 30b2b9be..3bba67f5 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -29,6 +29,8 @@ ## 🐛 修复 +- **#529 的「分批」还差最后一段:一轮只送 `sleepConflictMaxPairsPerRun`(默认 60)对,剩下的对要等满一个 `sleepMinIntervalHours`(默认 8h)才轮到,等于把「分批」变成「隔夜慢慢分批」(#529 续排,按 @modusensus 在 issue 里的三条修正实现)**:候选预算 / 裁决留痕 / 退避 / 审计补字段四项上一版落地之后,池子清空的速度仍只由冷却钟决定——449 对要 8 轮,就是 8 个冷却周期。这一版补的是**续排**:本轮被预算截断、且送出去的每一对都落地了,就把下一次的冷却按 0 处理、只留一个静默窗口,让剩下的对接着追。**判据不能用 `status === "ok"`,这是 issue 里最关键的一条修正**:`adjudicateBatch` 的 status 是 `applied > 0 ? (…) : failures.length ? "failed" : "noop"`——全判 `keep` 的轮次 `applied` 恒为 0、**永远到不了 `ok`**,而「判 keep 不落地」正是 #529 的核心症状,拿 status 当判据会在最该追的场景静默失效。所以相位层另给一个正交布尔 `clean`(`!interrupted && failedBatches === 0 && skipped.length === 0 && failures.length === 0 && conflicts.length === 0 && ledgerFailed === 0`),口径与台账那句「未落地的对不记账」完全一致——**其中 `ledgerFailed === 0` 是评审补上的一环**:`saveConflictReviewed` 抛错此前只进 `logger.warn`,那一对既没被记成「已审」、又被算成「送出去的全落地了」,下一轮它原样回到候选池、又被算成可续排,于是每个静默窗口重问一次同一个冲突、直到连击上限才停,而审计上每轮都是干净的成功轮;现在逐批把留痕失败数报回 `phaseConflicts` 累计,`ledgerFailed` 同时进 `clean` 与审计行(`clean=false` 时要能分清是写入失败、非法被跳、CAS 冲突还是台账没记上)。`runSleep` 再把它和「本轮被预算截断」(`perRunSkipped > 0`)合成 `catchup` 交给调度器。**只改回调里的 if 不够,三处必须同时改**:① 冷却项——`effectiveMinIntervalMs()` 在续排时返回 0,`shouldRun` 与 `armNextWindow` 本来就读同一个函数,所以判定与排表口径天然一致(这条规矩是上一版为退避立的,此处直接复用);② **回调必须自己补挂闹钟**——`armIdleTimer` / `armNextWindow` 的回调原本是 `if (!ran) armNextWindow()`,跑成了一轮就不重排,而续排时库是安静的、没有下一次写入来救它,不改这一行续排只追一轮就断;③ 续排下限钉在一个 `sleepIdleMinutes`,否则冷却项归零后静默项可能已经是负的,算出来只剩那 1 秒——从「隔夜一轮」矫枉成「秒级连发」;④ **运行结束要按续排结果重挂那只表**(评审补的第二环):运行期间用户写入会走 `noteWrite()`,它按**那一次写入**重算闹钟、那可能是静默窗口的尾声,而 `armNextWindow()` 开头有 `if (disposed || idleTimer) return;`——收尾时若直接调用会被这只既存表挡下。若不处理,这一轮跑得久一点(越过那只表的到点时刻)时下一批就是「几秒后开跑」,「让库安静一个静默窗口再追」形同虚设。现在 `finally` 里当 `catchupWanted` 为真时先 `clearTimeoutFn` 撤掉旧表再 `armNextWindow()`,从**收尾这一刻**重算整整一个静默窗口;不追的轮次则保留 `noteWrite` 挂的那只表(它按用户最后一次写入算,正是「写完安静满一个窗口再说」)。**两道刹车,缺一不可**:其一,**与连败退避互斥**——`consecutiveFailures > 0` 时根本不追(两个逻辑抢同一只闹钟最容易出「重排的是短的、判定的是长的」这种空转);其二,**进程内连击上限 `CATCHUP_STREAK_CAP = 24`**——全判 keep 的轮次不进任何「坏」分母(连败计数只认整轮 `status === "failed"`),叠加续排后,用户持续写入时池子进得比追得快,会变成永不落地的每五分钟一轮,而审计上每轮都「有产出」,肉眼很难发现。449 对按 60/轮只需 8 轮,24 留足余量。**刹车的落点必须收在 `catchupWanted` 这一个状态上,不能只写在 `effectiveMinIntervalMs` 里**:那里改成返回基数之后,`armNextWindow` 仍会按 `catchupWanted` 把闹钟钉在静默窗口上重排,于是每 5 分钟醒一次、每次被 `shouldRun` 否掉再重排——没有 LLM 调用,但定时器链永远不断,且此后再也不会真正跑起来。**不新增配置键**:续排下限直接用 `sleepIdleMinutes`(本轮的账本/更新写入会重置 `lastWriteAt`,天然节流),新键要连带 `settings.js` 白名单与 `test/api.test.js` 的旗标计数锁,不值得为一件事动三个文件。**回归测试 +10**(新增 `test/sleep-catchup.test.js`):clean + backlog 后闹钟钉在静默窗口(5min+1000ms)且**真的起跑第二轮**、不 clean 时不追且间隔回到最小间隔、失败轮不追(补的是退避长表而非续排短表)、连追 24 轮到顶后不再补挂、全判 keep 的轮次 `applied=0` / `status=noop` / `clean=true` / `catchup=true`、坏批让这一轮 `clean=false` 即使还有 backlog 也不追;另两条锁的是**刹车之后还能不能重新起算**——到顶收手后下一次正常触发要能把连击从 0 重新算(不清零就成了永久闸,那道闸此后挡住这个进程的每一轮),以及**一轮抛错也要把连击清零**(抛错回合属于 catch 路径,若只加计数不复位,刚被救回来的进程会在下一轮撞上上一轮留下的 23)。变异自证八例(判据改成 `status === "ok"` → 2 红;回调不补挂 → 2 红;去掉连击刹车 → 1 红;`clean` 忽略 `failures` → 1 红;连击到顶只置 false 不清零 → 1 红;抛错不清零 → 1 红;`clean` 忽略 `ledgerFailed` → 1 红;收尾不重挂(或重挂前不清旧表)→ 1 红),恢复后 10/10 全绿。全量 `npm test` **2619 / 2618 pass / 0 fail / 1 skipped**。**已知未覆盖**:续排是纯进程内状态(`catchupWanted` / `catchupStreak`),宿主重启即归零——跨重启的冷却仍由 `lastRunAtSeed`(#89/#291)负责,两者不重叠;「池子永远追不完」的库到顶后会回到正常间隔、等下一个静默窗口重新起算,不报警也不进审计。 + - **`differentiate` 是个正反馈闭环:两侧各追加同一段注记 → 向量同向靠拢 → 下一轮又被判成重复 → 再追加一段,实测单条堆 19 段、正文 150 字膨胀到 2200+ 字(#533,报告与实测由 @xueyue33 提供)**:`sleepActionSet: "full"` 会把跨类型也可能相似的记忆对补进候选(`src/dream/sleep.js`),而 full 档的裁决集里有一条 `differentiate`——它的本意是「两条都留,但让模型标出各自覆盖的不同侧面,好让后续别再判成重复」。旧实现的做法是**把同一段「差异注记」同时追加到两侧正文**,注释写着理由:「注记进正文 → 进注入与 embedding → 下一轮不会再被判成重复(这是本动作存在的意义)」。报告者的现场把这半句推翻了:37 轮 sleep 之后 175 条被动过,活跃的 110 条里 100 条被重复追加 ≥2 段,最狠的一条 19 段。**根因不在「注记写太长」,而在「两侧共享同一段文本」**——离线复现(`node:sqlite` 内存库 + 真实 `src/`,不联网不调 LLM)把注记从 200 字逐级截到 0 字,只留那个固定前缀:200/40/20 字 → 余弦 0.913→0.949→0.968→0.979→0.985;10 字 → 0.905→…→0.981;4 字 → 0.891→…→0.972;**0 字 → 0.876→0.903→0.928→0.947→0.960**。给一侧追加时余弦是往下走的(0.823→0.732→0.658→0.604→0.564),旁路记账不进正文时恒定 0.861。所以「把注记写短一点」这条路根本不存在——只要两侧共享同一段后缀,向量就同向靠拢,真模型只改幅度不改方向。第二个放大器是幂等判据:旧守卫是 `String(content).includes(marker)`,`marker` 是整段 `(差异注记:${notes.join(";")})` 的字面量,模型只要换一种说法(语序、加「的」、同义替换),`includes` 就失效,同一对再判一次、再追加一段——这正是单条堆到 19 段的成因。改法三条,**核心是「differentiate 不再改写正文」**:① `applyDifferentiate` 只落账本不动正文。新增表 `differentiated_pairs`(`memory_a` / `memory_b` 排序归一后唯一 + 两侧当时的 `content_hash` + 模型给的 `distinctions` + `run_id` + `created_at`),正文一个字节都不改;字面 `includes` 幂等随注记一起退场。② 幂等改成**结构化**三层:任一侧已归档 → `skipped`;两侧正文仍带旧注记块(存量库兼容)→ `skipped`;账本里有这一对且两侧 `content_hash` 与记账时一致 → `skipped`。任一侧被改写(hash 变了)就重新进候选——宁可再判一次,也不静音真实演化。③ 抑制做在**候选构造阶段**而不是裁决阶段:`phaseConflicts` 拿到对之后先查账本,已裁决且正文未变就从候选里压掉,**不送 LLM**;被压掉几对记在 `phase.suppressed`,与「一对都没检测到」(`no conflicts found`)分开报——不记的话 `pairs` 变小就分不清是没检测到还是已裁决。**几个刻意的取舍**:(a) **不新建动作、不加配置开关**——`differentiate` 的语义(两条都留、标出差异)不变,变的只是它的落点从正文挪到账本,所以校验器、提示词、`ACTIONS` 白名单、receipt 全部不用动;开关则是多一个键就要多一份白名单 + schema + 旗标计数锁,而默认关 = 默认留着那个闭环。(b) **不清理存量注记**:升级后老库里那些已带注记块的行按「已裁决」处理(第二层幂等),把注记从正文里抠掉是不可逆的内容改写,而它此刻只是历史痕迹、不再产生新副作用。(c) **`distinctions` 仍然必填**:它是「模型确实做过分类」的唯一凭据,空数组 = 模型偷懒只输出动作名,不该被记成已裁决。(d) **裸 service 路径降级成 `skipped` 而不是抛错**:人工确认路径(`src/service.js` 的 `applyService`)没有账本透传,那里没有可落账的地方,「正文不变」本身就是全部副作用,报 applied 会是假账。(e) **向量不再重嵌**:`maintainIndexAfterDream` 的 `differentiate` 分支清空——正文没变就没有要重算的东西,顺手省掉一轮 embed 预算。**回归测试 +11**(新增 `test/differentiate-feedback.test.js` 7 条 + `test/store.test.js` 1 条持久化 + 改写 `test/sleep-action-set.test.js` 3 条):五轮措辞漂移下正文逐字节不变、账本只有一行、正文不变时保留原 `distinctions`、一侧改写后重新 `applied=1` 且结论被刷新、存量注记行判已裁决、裸 service 不抛错、第二轮 sleep 的冲突阶段 `status: noop` + `suppressed: 1` + **零 LLM 调用**、被改写后第三次循环重新送模型、审计行把 `suppressed` 与 `no conflicts found` 分开报。`docs/SLEEP.md` 的 `conflict_resolution` 算法从 4 步扩到 5 步并补了一条「differentiate 不改正文」的说明(带上面那组余弦数字,免得后人再把注记写回正文)。 - **sleep 的冲突相位在输入侧没有任何预算:全部候选对一次塞进一个 prompt、判 `keep` 不留痕,单请求实测 163,054 token(整池 163,840),失败后按冷却重排同一输入(#529,报告与实测由 @chengxinshengglj-png 提供)**:报告者的现场是一台 `sleepActionSet` 默认档的库——`sleep_conflict` 审计 131 行(46 success / 85 error),`input_tokens` 峰值 163,054,而服务端 `--ctx-size` 是 163,840(**占 99.5%**),`pairs` 443–449 而单轮 `applied` 只有 3–7;10-26 12:55 到 10-29 11:11 连续 **23 轮 llm failed**,`conflict_pending` 堆到 841 行。三件事互相咬合,缺一条都治不好:**①** 输入侧根本没有预算——`samePairs` 是**全部**同 scope 相似对,`listText` 一次拼完,模型只能看到前几对就撞上下文;**②** 判 `keep` 是彻底的 no-op(`applyDecisions` 只往进程内 `committed` 里 push 一下,不写任何东西),于是下一轮候选池逐字节相同、同一对每个冷却周期被重新问一遍;**③** 失败轮不带 `pairs`——131 行审计里 85 条 error 全是「送了个寂寞」,事后连送了多少对都查不到。四处一起补:**A 候选预算**:新增 `sleepConflictMaxPairs`(每批上限,默认 12)与 `sleepConflictMaxPairsPerRun`(每轮总量上限,默认 60),两者都 0 = 不限、回退旧行为;分批之后每批独立一次 LLM 往返 + 独立 `validateDecisions`/`applyDecisions`,**坏批只让这一批失败并计入 `degraded`,其余批照常落地**,只有一批都没成功才整轮 `failed`——此前任何一处理由(撞上下文、输出截断、JSON 坏掉)都是整轮失败。**B 裁决留痕**:新增表 `conflict_reviewed`(`memory_a`/`memory_b`/`judged_at`/`outcome`/`run_id`,`PRIMARY KEY (memory_a, memory_b)`)记「这段输入被看过」,`pair` 按字典序归一化 + `ON CONFLICT DO UPDATE` 幂等;预筛按「两侧 `updated_at` 都不晚于 `judged_at`」剔出候选池,任一字段更新即重新入池(与 `conflict_pending` 复核项 4 同一条宽松代理)。记账位置在每批 `validateDecisions` 之后,对本批**全部送审对**统一记、不只记被裁决成冲突的那几对——判 `keep` 的正是下一轮重复送审的元凶。**刻意不复用 `conflict_pending`**:那张表是人工待裁决队列,面板徽标数就是 `countConflictPending()`(`resolved_at IS NULL` 的行数),机器每判一对写一行会把这个数字淹掉,人就再也看不出「有哪些待我裁决」;台账零界面,不进 `listConflictPending`。**记账也不写进 `applyDecisions`**:那个函数 dream 与 sleep 共用,写进去会污染日间路径。**C 续排与退避**:新增 opt-in 的 `sleepFailureBackoff`(默认关),有效间隔 = `sleepMinIntervalHours` × 2^连败数、成功清零、封顶 `max(sleepMinIntervalHours, 24h)`(不是硬 24h:基数允许配到 168 小时,退避只放大间隔、不压缩用户自己设的基数)。**原提案的 `snapshot_hash` 判据用不了**:`runSleep` 里那个 `snapshotHash` 是 `createHash("sha256").update(JSON.stringify(phases))`——跑完之后对**结果**算的哈希,不是输入快照,拿它判「同一份输入又失败」会把不同的结果认成不同的输入;改用连续失败计数,与 `autoDream` 的失败退避(#292)同构。判据看 `status === "failed"` 而不是 `!ok`:`noop`(LLM 通路正常、只是没活干)与用户活动打断都给 `ok:false`,当失败会让退避在自己工作良好的库里越退越长。`armNextWindow()` 排下一次闹钟必须与 `shouldRun()` 用同一个函数——否则退避期闹钟会在基数到点响、被否、再重排,白转一轮。**D 审计补字段**:失败轮同样返回 `pairs`/`batches`(另加 `ledgerSkipped`/`perRunSkipped` 两个读数),失败批的 stream 层原文(`describeStreamFailure` 的 abort/error)进 `logger.warn`——只写 `llm failed` 的话运维分不清是上下文超限、模型拒收还是用户活动打断。**顺带修掉一个同类的谎**:分批之后「批间被用户活动打断」原本会落进 `noop`,而 `noop` 的语义是"看过、没冲突"——被打断的那几批根本没送出去。相位层改用 `aborted`(与入口处"一开始就 aborted"同一个词),`deriveStatus` 也补认它,否则整轮层会把相位层的 `aborted` 静默折回 `noop`,审计行照样记成"跑过、没什么事"。**报告者本地打了三键之后的效果**:单请求 806–2,011 token、连续失败 23 轮 → 0 轮、每轮台账跳过 32–53 对。**刻意不加 `sleepMinIntervalMinutes`**:`sleepMinIntervalHours` 早就在(`z.natural().min(1).max(168).default(8)`),再做一个「传 0 则回退小时键」的分钟键,面板上会出现两个都叫「最小间隔」的调节项。**不在本批**:报告者那 79 条 `input_tokens = 0` 的审计行属 #104 / #250 的传输族(流以错误结束、根本没有用量),与本条的输入预算无关,另账。回归测试 +16(`test/sleep.test.js` 6 条:分批上限、坏批只 degraded、判 `keep` 留痕且不重送 + 改一侧后重送、每轮总上限、**批间被用户活动打断时报 `aborted` 而不是 `noop`**(`noop` 的语义是「看过、没冲突」,而被打断的批根本没送出去)、失败轮仍带 `pairs`/`batches`;`test/conflict-reviewed.test.js` 4 条:键归一化、重复记账是 upsert、**记账不进人工队列**、按 `judged_at` 倒序;`test/sleep-failure-backoff.test.js` 6 条:默认关不翻倍、连败指数推迟、成功清零、封顶 max(基数, 24h)、**noop 不算失败**、抛错也计入),每条都做过变异自证。**评审补三处(CodeRabbit)**:① **台账预筛原先排在去重之后**——先按相似度占位、再把已审对摘掉,于是一条已审的记忆会把自己的配对名额占住,而它真正的对手(另一条没审过的记忆)因此进不了这一轮;现在预筛前移到去重之前,跨 scope 对不筛(它们要停车进人工队列)、`conflictFreezeEnabled` 档不筛(那一档只停车不送 LLM),`ledgerSkipped` 一次数清不再二次过滤。② **`reflectionUpdateMaxPerRun` 分批后退化成每批上限**——校验器不跨批记忆,N 批就是 N 倍上限;改为额度由分批循环持有、每批只领剩余额度、按本批**通过校验的** `update` 数扣减(`validateDecisions` 已把非法条目就地 splice 掉,数到的都真会落地),额度耗尽的那批若还含 `update` 被整单拒绝记 `failed`,其余批继续。③ **台账会把没落地的裁决记成已审**——原实现对整批送审对无条件记账且 `judged_at` 取落地之后,于是 `failures`/`conflicts`(CAS)/`skipped` 里那些没生效的对、以及 LLM 往返期间被外部改动的对,下一轮都会被 `alreadyJudged` 判成已审而永久压制;现在 `judged_at` 取**发送本批之前**的时刻,且本批 `failures`/`conflicts`/`skipped` 里出现过的 id,其所在对不记账。回归测试 +5(去重前预筛、跨批 update 额度、未落地对不记账、`judged_at` 的本批时钟域、单批抛错不带走整轮),五条都做过变异自证。**④ `judged_at` 还必须落在 store 的时钟域里**:`updated_at` 出自 `src/store.js` 的单调 `nowIso`(同一毫秒内连续写入逐次 +1ms),可能领先墙钟几毫秒;直接取墙钟会记出一个早于被测行自身版本的 `judged_at`,`alreadyJudged` 于是把「刚判过」读成「判过之后又被改过」,判 `keep` 的死循环原样复活(CI 的 `test-host-legacy` 腿抓到过这次真 flake)。改为取 `max(墙钟, 本批两侧最新 updated_at)`——两列同源才可比。**⑤ 一批抛异常会带走整轮**:`adjudicateBatch` 里 LLM 通路自己会把失败变成返回值,但 `applyDecisions` 之外的环节(台账写入的 `saveConflictReviewed` 之外,还有 `maintainIndexAfterDream`、以及未来任何新加的读写)一旦抛,循环就直接退出,前面的批次汇总全丢、后面还没送的批次一个不送,整轮被 `runSleep` 覆盖成失败相位。现在分批循环里加 `try/catch`,只放行 `AbortError`(与 `src/dream.js` 的 `if (error?.name === "AbortError") throw error;` 同款中断语义),其余折成这一批的 `failed` 结果继续下一批。**⑤ 的变异自证踩过一个坑,值得记**:最初把桩打在 `service.update` 上,用例是绿的但删掉 `try/catch` 也还是绿的——`applyDecisions`(`src/dream/decisions.js:532-540`)本来就把 `service` 抛出的任何非 CAS 异常吞进 `failures` 返回,根本打不到循环那一层。真正的注入点必须在 `applyDecisions` 的上游:改成让 `ctx.llm.stream` 在第一次 `sleep-conflict` 调用时直接 `throw`,删掉 `try/catch` 后用例立刻红。**教训:给「异常被兜住」的守卫写回归,桩必须打在兜底之外的那一层**,否则用例是自证循环——它证明的只是「被兜住的异常被兜住了」。 diff --git a/dsh-mneme/lib/dream/sleep.js b/dsh-mneme/lib/dream/sleep.js index 5db9d9d1..0b82fc40 100644 --- a/dsh-mneme/lib/dream/sleep.js +++ b/dsh-mneme/lib/dream/sleep.js @@ -52,6 +52,12 @@ const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 }; // 间隔、不能收紧用户自己设的)。 const FAILURE_BACKOFF_CAP_MS = 24 * 3600000; +// Issue #529(续排):一轮内部最多连追多少轮。449 对的池子按每轮 60 对只需 8 轮, +// 24 留足余量;超过就退回正常最小间隔。防的是"池子在长、每轮都 clean 但永远追不完" +// ——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟一轮, +// 而审计上每轮都"有产出",肉眼很难发现。 +const CATCHUP_STREAK_CAP = 24; + function parseJsonArray(text) { if (typeof text !== "string") return undefined; const start = text.indexOf("["); @@ -472,6 +478,11 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu // 单批的完整往返:送 → 解析 → 校验 → 应用 → 记账。抽成函数是为了让失败只影响 // 自己这一批(下面的循环),而不是把整轮判死。 const adjudicateBatch = async (batch, updateBudget) => { + // Issue #529 review(CodeRabbit ①):留痕写入失败要能被上层看见。只打 warn 的话, + // 这一对既没记账、又被算成"送出去的全落地了",于是下一轮它原样回到候选池、又被 + // 算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而审计上每轮 + // 都是干净的成功轮。计数随返回值透出,由 phaseConflicts 折进 clean。 + let ledgerFailed = 0; // Issue #536 review(CodeRabbit ③):judged_at 是**发送时刻**,不是落地时刻。 // // 取 max(墙钟, 本批两侧里最新的 updated_at):`updated_at` 用的是 store 的 @@ -593,13 +604,18 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu judged_at: judgedAt }); } catch (error) { + // Issue #529 review(CodeRabbit):留痕失败必须让这一轮**不 clean**。只 warn 的 + // 话,这一对既没记账、又被算成"送出去的全落地了",下一轮它原样回到候选池、 + // 又被算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而 + // 审计上每一轮都是干净的成功轮。 + ledgerFailed += 1; logger?.warn?.(`dsh-mneme sleep: failed to record reviewed conflict ${p.a.id}/${p.b.id}: ${String(error)}`); } } } // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约 // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。 - return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed }; + return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed, ledgerFailed }; }; // 分批:每批独立往返,坏批只记 degraded 不中断。此前一次全送,任何一处理由 // (撞上下文、输出截断、JSON 坏掉)都是整轮 failed——而整轮 failed 又不带 pairs, @@ -613,6 +629,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu const conflicts = []; const skipped = []; let failedBatches = 0; + // Issue #529 review(CodeRabbit ①):台账写入失败的累计数(逐批由 adjudicateBatch 报回)。 + let ledgerFailed = 0; // 用户活动打断时**不能**落进下面那条 noop:noop 的语义是「看过,没冲突」,而被打断 // 的那几批根本没送出去。用与入口处(signal 一开始就是 aborted)同一个词汇,避免 // 同一件事两个状态名。 @@ -647,6 +665,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu continue; } applied += res.applied; + ledgerFailed += res.ledgerFailed ?? 0; updateLeft = Math.max(0, updateLeft - (res.updateUsed ?? 0)); failures.push(...(res.failures ?? [])); conflicts.push(...(res.conflicts ?? [])); @@ -670,8 +689,24 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu : failedBatches > 0 ? "degraded" : "noop"; + // Issue #529(续排):「这一轮有没有产出」和「送出去的每一对是不是都落地了」是两件 + // 正交的事,status 只回答前者。全判 keep 的轮次 applied 恒为 0 → status 是 noop + // (never ok),而它恰恰是 #529 里最该续排的场景;拿 status === "ok" 当判据会在 + // 这里把续排堵死。所以另给一个布尔,口径与下面台账的 unsettled 完全一致:failure + // (写入失败)/ conflicts(CAS 冲突)/ skipped(非法被跳)任一出现过,就说明有对没 + // 落地,它下一轮还会被重新配出来,不算 clean。 + // Issue #529 review(CodeRabbit ①):台账写入失败同样算"没落地"——那一对没被记成 + // 已审,下一轮会重新入池,与上面 unsettled 是同一个道理。 + const clean = + !interrupted && + failedBatches === 0 && + skipped.length === 0 && + failures.length === 0 && + conflicts.length === 0 && + ledgerFailed === 0; return { status, + clean, // Issue #529 D:失败轮也要带 pairs/batches——此前只有成功路径带 pairs,85 条 // 失败审计行全是"送了个寂寞",连送了多少对都查不到。 pairs: selected.length, @@ -682,6 +717,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu ledgerSkipped, perRunSkipped, applied, + // Issue #529 review(CodeRabbit ①):留痕失败的对数——clean 为 false 时要能分清是 + // 哪一类没落地(写入失败 / 非法被跳 / CAS 冲突 / 台账没记上)。 + ledgerFailed, failures, conflicts, // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断 @@ -1000,7 +1038,18 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si } catch (error) { logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`); } - return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt }; + // Issue #529(续排):给调度器的续排判据。两个条件缺一不可—— + // · 本轮**被预算截断**(conflict 相位报告还有没进这一轮的对):说明池子没清空; + // · 本轮**clean**:送出去的每一对都落地了,不是为了追一个永远失败的批次空转。 + // `clean` 缺失时(该相位被 attempt 吞成 failed、或配置让它早退)按"不干净"算, + // 宁可少追一轮,也不要在失败状态下把间隔压到静默窗口。 + const conflictPhase = phases.conflicts; + const backlog = Number(conflictPhase?.perRunSkipped ?? 0) > 0; + // clean 与 catchup 分开给:前者是"这一轮的活全落地了",后者是"池子没清空 + 前者"。 + // 合成一个字段会让"干净但没 backlog"看起来像出过问题,排查时反着读。 + const clean = conflictPhase?.clean === true; + const catchup = backlog && clean; + return { ok: status === "ok" || status === "degraded", status, clean, catchup, backlog, runId, phases, receipt }; } // ---------------------------------------------------------------- scheduler @@ -1038,11 +1087,26 @@ export function createSleepScheduler({ // 只是没活干,不该被当成失败拖着退避)。纯内存变量、宿主重启归零:跨重启的 // 冷却本来就由 lastRunAtSeed(#89/#291)负责,两者不重叠。 let consecutiveFailures = 0; + // Issue #529(续排):上一轮报告"还有对没进这一轮、且送出去的全落地了"——池子没清空, + // 该接着追而不是等满一个最小间隔。只由 maybeSchedule 按 onRun 的返回置位,跑完一轮就 + // 重新判:池子追干净或某一轮不 clean 时自动落回 false,不需要额外的复位路径。 + let catchupWanted = false; + // Issue #529(续排):进程内续排连击数。给"池子在长、每轮都 clean 但永远追不完"这类 + // 库留一道刹车——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟 + // 一轮。超过上限就退回正常间隔,等下一个静默窗口重新起算。 + let catchupStreak = 0; // Issue #529:有效最小间隔 = 基数 × 2^连续失败数。关闭或尚无失败时逐字节 // 返回基数(默认关 = 行为与现状一致)。封顶取 max(基数, 24h) 而不是硬 24h: // 指数再大也不会把用户自己配的大基数(允许到 168h)压小。 function effectiveMinIntervalMs() { + // Issue #529(续排):还有 backlog 时把冷却项按 0 处理,让表落在**静默窗口**而不是 + // 最小间隔上。不新增配置键:本轮的账本/更新写入会重置 lastWriteAt,下一轮自然至少 + // 等一个 sleepIdleMinutes,这本身就是节流;再开一个键要连带 settings.js 白名单与 + // test/api.test.js 的旗标计数锁,不值得为一件事动三个文件。 + // 与连败互斥(同 issue 的第三条):连续失败 > 0 时根本不追——两个逻辑抢同一只闹钟 + // 最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。 + if (catchupWanted && consecutiveFailures <= 0) return 0; const base = (config.sleepMinIntervalHours ?? 8) * 3600000; if (config.sleepFailureBackoff !== true || consecutiveFailures <= 0) return base; const cap = Math.max(base, FAILURE_BACKOFF_CAP_MS); @@ -1058,8 +1122,10 @@ export function createSleepScheduler({ idleTimer = null; // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期" // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。 + // Issue #529(续排):跑成了也未必收工——池子还有没进这一轮的对时要接着追, + // 而此时已经没有待触发的写入了,不在这里补挂就永远等不到下一轮。 const ran = await maybeSchedule(); - if (!ran) armNextWindow(); + if (!ran || catchupWanted) armNextWindow(); }, delay); idleTimer.unref?.(); } @@ -1076,15 +1142,22 @@ export function createSleepScheduler({ // Issue #529:这里必须和 shouldRun 用同一个有效间隔,否则退避期里重排的闹钟 // 会在基数到点就响、然后被 shouldRun 否掉再重排——空转一串没有意义的定时器。 const cdMs = effectiveMinIntervalMs(); - const delay = Math.max( + let delay = Math.max( lastRunAt + cdMs - now(), lastWriteAt + idleMs - now(), 0 ) + 1000; + // Issue #529(续排):冷却项按 0 处理之后,静默项可能已经是负的(这一轮跑得久、 + // 期间没有新写入),算出来只剩那 1 秒——追 backlog 不该变成秒级连发。续排下限钉在 + // 一个静默窗口上:追得动就每轮追一批,但每批之间仍然让库安静下来。 + // 有写入时静默项会自己变大(账本行落库会重置 lastWriteAt),这里只是保证下限。 + if (catchupWanted) delay = Math.max(delay, idleMs + 1000); idleTimer = setTimeoutFn(async () => { idleTimer = null; const ran = await maybeSchedule(); - if (!ran) armNextWindow(); + // Issue #529(续排):与 armIdleTimer 的回调同一条理由——追 backlog 的轮次 + // 跑完必须自己补挂,否则续排在第二张表就断了。 + if (!ran || catchupWanted) armNextWindow(); }, delay); idleTimer.unref?.(); } @@ -1141,15 +1214,52 @@ export function createSleepScheduler({ if (result) { const failed = typeof result.status === "string" ? result.status === "failed" : !result.ok; consecutiveFailures = failed ? consecutiveFailures + 1 : 0; + // Issue #529(续排):判据只认 runSleep 给的 catchup——它是"池子还有没进这一轮 + // 的对"且"送出去的每一对都落地了"二者的合取。**不要**在这里拿 status 反推: + // 全判 keep 的轮次 applied 恒为 0、status 是 noop,恰好在最该追的场景失效。 + // 连续失败时不追(与退避互斥,见 effectiveMinIntervalMs),并把连击计数清零—— + // 失败说明这轮的"追"没有意义,重新从正常节奏起算。 + // 连击到顶就收手,并把连击清零——不清零的话这个进程从此再也不会追(streak 只增 + // 不减,之后每一轮都被同一道闸挡下),"等下一个静默窗口重新起算"就成了空话。 + // 收手必须落在 catchupWanted 这个状态上,不能只写在 effectiveMinIntervalMs 里: + // 那里改成"返回基数"之后,armNextWindow 仍会按 catchupWanted 把闹钟钉在静默 + // 窗口上重排,于是每 5 分钟醒一次、每次都被 shouldRun 否掉再重排——没有 LLM + // 调用,但定时器链永远不断。 + const wantCatchup = result.catchup === true && consecutiveFailures <= 0; + if (!wantCatchup || catchupStreak + 1 >= CATCHUP_STREAK_CAP) { + catchupStreak = 0; + catchupWanted = false; + } else { + catchupStreak += 1; + catchupWanted = true; + } } return !!(result && result.ok); } catch (error) { logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`); consecutiveFailures += 1; // Issue #529:抛错同样计入连败 + // Issue #529(续排):抛错也算失败,续排意图一并清掉——否则上一轮置位的状态会跟着 + // 逃出 try 块,让下一次排表仍按静默窗口的空转节奏走。 + catchupWanted = false; + catchupStreak = 0; return false; } finally { sleepAbort = null; running = false; + // Issue #529 review(CodeRabbit ②):运行期间用户写入会走 noteWrite(),它把闹钟 + // 按**那一次写入**重算了一遍——那可能是静默窗口的尾声。若这一轮结束时报告 + // catchup=true,那只表可能几秒后就要响,下一批紧接着开跑,于是「让库安静一个 + // 静默窗口再追」变成「跑完立刻再跑」。这里把已挂着的表撤掉重挂,交给 + // armNextWindow 从**此刻**算出整整一个静默窗口。 + // 反过来(不追的轮次)保留 noteWrite 挂的那只表:它是按用户最后一次写入算的, + // 正是「用户写完了、安静满一个窗口再说」的意思。 + if (catchupWanted) { + if (idleTimer) { + clearTimeoutFn(idleTimer); + idleTimer = null; + } + armNextWindow(); + } } } diff --git a/dsh-mneme/src/dream/sleep.js b/dsh-mneme/src/dream/sleep.js index 5db9d9d1..0b82fc40 100644 --- a/dsh-mneme/src/dream/sleep.js +++ b/dsh-mneme/src/dream/sleep.js @@ -52,6 +52,12 @@ const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 }; // 间隔、不能收紧用户自己设的)。 const FAILURE_BACKOFF_CAP_MS = 24 * 3600000; +// Issue #529(续排):一轮内部最多连追多少轮。449 对的池子按每轮 60 对只需 8 轮, +// 24 留足余量;超过就退回正常最小间隔。防的是"池子在长、每轮都 clean 但永远追不完" +// ——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟一轮, +// 而审计上每轮都"有产出",肉眼很难发现。 +const CATCHUP_STREAK_CAP = 24; + function parseJsonArray(text) { if (typeof text !== "string") return undefined; const start = text.indexOf("["); @@ -472,6 +478,11 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu // 单批的完整往返:送 → 解析 → 校验 → 应用 → 记账。抽成函数是为了让失败只影响 // 自己这一批(下面的循环),而不是把整轮判死。 const adjudicateBatch = async (batch, updateBudget) => { + // Issue #529 review(CodeRabbit ①):留痕写入失败要能被上层看见。只打 warn 的话, + // 这一对既没记账、又被算成"送出去的全落地了",于是下一轮它原样回到候选池、又被 + // 算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而审计上每轮 + // 都是干净的成功轮。计数随返回值透出,由 phaseConflicts 折进 clean。 + let ledgerFailed = 0; // Issue #536 review(CodeRabbit ③):judged_at 是**发送时刻**,不是落地时刻。 // // 取 max(墙钟, 本批两侧里最新的 updated_at):`updated_at` 用的是 store 的 @@ -593,13 +604,18 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu judged_at: judgedAt }); } catch (error) { + // Issue #529 review(CodeRabbit):留痕失败必须让这一轮**不 clean**。只 warn 的 + // 话,这一对既没记账、又被算成"送出去的全落地了",下一轮它原样回到候选池、 + // 又被算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而 + // 审计上每一轮都是干净的成功轮。 + ledgerFailed += 1; logger?.warn?.(`dsh-mneme sleep: failed to record reviewed conflict ${p.a.id}/${p.b.id}: ${String(error)}`); } } } // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约 // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。 - return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed }; + return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed, ledgerFailed }; }; // 分批:每批独立往返,坏批只记 degraded 不中断。此前一次全送,任何一处理由 // (撞上下文、输出截断、JSON 坏掉)都是整轮 failed——而整轮 failed 又不带 pairs, @@ -613,6 +629,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu const conflicts = []; const skipped = []; let failedBatches = 0; + // Issue #529 review(CodeRabbit ①):台账写入失败的累计数(逐批由 adjudicateBatch 报回)。 + let ledgerFailed = 0; // 用户活动打断时**不能**落进下面那条 noop:noop 的语义是「看过,没冲突」,而被打断 // 的那几批根本没送出去。用与入口处(signal 一开始就是 aborted)同一个词汇,避免 // 同一件事两个状态名。 @@ -647,6 +665,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu continue; } applied += res.applied; + ledgerFailed += res.ledgerFailed ?? 0; updateLeft = Math.max(0, updateLeft - (res.updateUsed ?? 0)); failures.push(...(res.failures ?? [])); conflicts.push(...(res.conflicts ?? [])); @@ -670,8 +689,24 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu : failedBatches > 0 ? "degraded" : "noop"; + // Issue #529(续排):「这一轮有没有产出」和「送出去的每一对是不是都落地了」是两件 + // 正交的事,status 只回答前者。全判 keep 的轮次 applied 恒为 0 → status 是 noop + // (never ok),而它恰恰是 #529 里最该续排的场景;拿 status === "ok" 当判据会在 + // 这里把续排堵死。所以另给一个布尔,口径与下面台账的 unsettled 完全一致:failure + // (写入失败)/ conflicts(CAS 冲突)/ skipped(非法被跳)任一出现过,就说明有对没 + // 落地,它下一轮还会被重新配出来,不算 clean。 + // Issue #529 review(CodeRabbit ①):台账写入失败同样算"没落地"——那一对没被记成 + // 已审,下一轮会重新入池,与上面 unsettled 是同一个道理。 + const clean = + !interrupted && + failedBatches === 0 && + skipped.length === 0 && + failures.length === 0 && + conflicts.length === 0 && + ledgerFailed === 0; return { status, + clean, // Issue #529 D:失败轮也要带 pairs/batches——此前只有成功路径带 pairs,85 条 // 失败审计行全是"送了个寂寞",连送了多少对都查不到。 pairs: selected.length, @@ -682,6 +717,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu ledgerSkipped, perRunSkipped, applied, + // Issue #529 review(CodeRabbit ①):留痕失败的对数——clean 为 false 时要能分清是 + // 哪一类没落地(写入失败 / 非法被跳 / CAS 冲突 / 台账没记上)。 + ledgerFailed, failures, conflicts, // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断 @@ -1000,7 +1038,18 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si } catch (error) { logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`); } - return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt }; + // Issue #529(续排):给调度器的续排判据。两个条件缺一不可—— + // · 本轮**被预算截断**(conflict 相位报告还有没进这一轮的对):说明池子没清空; + // · 本轮**clean**:送出去的每一对都落地了,不是为了追一个永远失败的批次空转。 + // `clean` 缺失时(该相位被 attempt 吞成 failed、或配置让它早退)按"不干净"算, + // 宁可少追一轮,也不要在失败状态下把间隔压到静默窗口。 + const conflictPhase = phases.conflicts; + const backlog = Number(conflictPhase?.perRunSkipped ?? 0) > 0; + // clean 与 catchup 分开给:前者是"这一轮的活全落地了",后者是"池子没清空 + 前者"。 + // 合成一个字段会让"干净但没 backlog"看起来像出过问题,排查时反着读。 + const clean = conflictPhase?.clean === true; + const catchup = backlog && clean; + return { ok: status === "ok" || status === "degraded", status, clean, catchup, backlog, runId, phases, receipt }; } // ---------------------------------------------------------------- scheduler @@ -1038,11 +1087,26 @@ export function createSleepScheduler({ // 只是没活干,不该被当成失败拖着退避)。纯内存变量、宿主重启归零:跨重启的 // 冷却本来就由 lastRunAtSeed(#89/#291)负责,两者不重叠。 let consecutiveFailures = 0; + // Issue #529(续排):上一轮报告"还有对没进这一轮、且送出去的全落地了"——池子没清空, + // 该接着追而不是等满一个最小间隔。只由 maybeSchedule 按 onRun 的返回置位,跑完一轮就 + // 重新判:池子追干净或某一轮不 clean 时自动落回 false,不需要额外的复位路径。 + let catchupWanted = false; + // Issue #529(续排):进程内续排连击数。给"池子在长、每轮都 clean 但永远追不完"这类 + // 库留一道刹车——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟 + // 一轮。超过上限就退回正常间隔,等下一个静默窗口重新起算。 + let catchupStreak = 0; // Issue #529:有效最小间隔 = 基数 × 2^连续失败数。关闭或尚无失败时逐字节 // 返回基数(默认关 = 行为与现状一致)。封顶取 max(基数, 24h) 而不是硬 24h: // 指数再大也不会把用户自己配的大基数(允许到 168h)压小。 function effectiveMinIntervalMs() { + // Issue #529(续排):还有 backlog 时把冷却项按 0 处理,让表落在**静默窗口**而不是 + // 最小间隔上。不新增配置键:本轮的账本/更新写入会重置 lastWriteAt,下一轮自然至少 + // 等一个 sleepIdleMinutes,这本身就是节流;再开一个键要连带 settings.js 白名单与 + // test/api.test.js 的旗标计数锁,不值得为一件事动三个文件。 + // 与连败互斥(同 issue 的第三条):连续失败 > 0 时根本不追——两个逻辑抢同一只闹钟 + // 最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。 + if (catchupWanted && consecutiveFailures <= 0) return 0; const base = (config.sleepMinIntervalHours ?? 8) * 3600000; if (config.sleepFailureBackoff !== true || consecutiveFailures <= 0) return base; const cap = Math.max(base, FAILURE_BACKOFF_CAP_MS); @@ -1058,8 +1122,10 @@ export function createSleepScheduler({ idleTimer = null; // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期" // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。 + // Issue #529(续排):跑成了也未必收工——池子还有没进这一轮的对时要接着追, + // 而此时已经没有待触发的写入了,不在这里补挂就永远等不到下一轮。 const ran = await maybeSchedule(); - if (!ran) armNextWindow(); + if (!ran || catchupWanted) armNextWindow(); }, delay); idleTimer.unref?.(); } @@ -1076,15 +1142,22 @@ export function createSleepScheduler({ // Issue #529:这里必须和 shouldRun 用同一个有效间隔,否则退避期里重排的闹钟 // 会在基数到点就响、然后被 shouldRun 否掉再重排——空转一串没有意义的定时器。 const cdMs = effectiveMinIntervalMs(); - const delay = Math.max( + let delay = Math.max( lastRunAt + cdMs - now(), lastWriteAt + idleMs - now(), 0 ) + 1000; + // Issue #529(续排):冷却项按 0 处理之后,静默项可能已经是负的(这一轮跑得久、 + // 期间没有新写入),算出来只剩那 1 秒——追 backlog 不该变成秒级连发。续排下限钉在 + // 一个静默窗口上:追得动就每轮追一批,但每批之间仍然让库安静下来。 + // 有写入时静默项会自己变大(账本行落库会重置 lastWriteAt),这里只是保证下限。 + if (catchupWanted) delay = Math.max(delay, idleMs + 1000); idleTimer = setTimeoutFn(async () => { idleTimer = null; const ran = await maybeSchedule(); - if (!ran) armNextWindow(); + // Issue #529(续排):与 armIdleTimer 的回调同一条理由——追 backlog 的轮次 + // 跑完必须自己补挂,否则续排在第二张表就断了。 + if (!ran || catchupWanted) armNextWindow(); }, delay); idleTimer.unref?.(); } @@ -1141,15 +1214,52 @@ export function createSleepScheduler({ if (result) { const failed = typeof result.status === "string" ? result.status === "failed" : !result.ok; consecutiveFailures = failed ? consecutiveFailures + 1 : 0; + // Issue #529(续排):判据只认 runSleep 给的 catchup——它是"池子还有没进这一轮 + // 的对"且"送出去的每一对都落地了"二者的合取。**不要**在这里拿 status 反推: + // 全判 keep 的轮次 applied 恒为 0、status 是 noop,恰好在最该追的场景失效。 + // 连续失败时不追(与退避互斥,见 effectiveMinIntervalMs),并把连击计数清零—— + // 失败说明这轮的"追"没有意义,重新从正常节奏起算。 + // 连击到顶就收手,并把连击清零——不清零的话这个进程从此再也不会追(streak 只增 + // 不减,之后每一轮都被同一道闸挡下),"等下一个静默窗口重新起算"就成了空话。 + // 收手必须落在 catchupWanted 这个状态上,不能只写在 effectiveMinIntervalMs 里: + // 那里改成"返回基数"之后,armNextWindow 仍会按 catchupWanted 把闹钟钉在静默 + // 窗口上重排,于是每 5 分钟醒一次、每次都被 shouldRun 否掉再重排——没有 LLM + // 调用,但定时器链永远不断。 + const wantCatchup = result.catchup === true && consecutiveFailures <= 0; + if (!wantCatchup || catchupStreak + 1 >= CATCHUP_STREAK_CAP) { + catchupStreak = 0; + catchupWanted = false; + } else { + catchupStreak += 1; + catchupWanted = true; + } } return !!(result && result.ok); } catch (error) { logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`); consecutiveFailures += 1; // Issue #529:抛错同样计入连败 + // Issue #529(续排):抛错也算失败,续排意图一并清掉——否则上一轮置位的状态会跟着 + // 逃出 try 块,让下一次排表仍按静默窗口的空转节奏走。 + catchupWanted = false; + catchupStreak = 0; return false; } finally { sleepAbort = null; running = false; + // Issue #529 review(CodeRabbit ②):运行期间用户写入会走 noteWrite(),它把闹钟 + // 按**那一次写入**重算了一遍——那可能是静默窗口的尾声。若这一轮结束时报告 + // catchup=true,那只表可能几秒后就要响,下一批紧接着开跑,于是「让库安静一个 + // 静默窗口再追」变成「跑完立刻再跑」。这里把已挂着的表撤掉重挂,交给 + // armNextWindow 从**此刻**算出整整一个静默窗口。 + // 反过来(不追的轮次)保留 noteWrite 挂的那只表:它是按用户最后一次写入算的, + // 正是「用户写完了、安静满一个窗口再说」的意思。 + if (catchupWanted) { + if (idleTimer) { + clearTimeoutFn(idleTimer); + idleTimer = null; + } + armNextWindow(); + } } } diff --git a/dsh-mneme/test/sleep-catchup.test.js b/dsh-mneme/test/sleep-catchup.test.js new file mode 100644 index 00000000..fc4a3a6f --- /dev/null +++ b/dsh-mneme/test/sleep-catchup.test.js @@ -0,0 +1,315 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createVectorIndex } from "../src/vector-index.js"; +import { createSleepScheduler, runSleep } from "../src/dream/sleep.js"; + +// Issue #529(续排):本轮被预算截断(conflict 相位还有没进这一轮的对)且送出去的每一对 +// 都落地了时,调度器不该等满一个 sleepMinIntervalHours(默认 8h)才跑下一轮——池子没清空, +// 剩下的对只能等到下一次到点,等于把"分批"变成"隔夜慢慢分批"。 +// +// 这一组用例锁的是续排语义,防的回归是: +// ① 判据拿 status 反推 → 全判 keep 的轮次 applied 恒为 0、status 是 noop(永远到不了 ok), +// 续排在最该追的场景静默失效; +// ② 只把冷却项改成 0、不在回调里补挂闹钟 → 续排只追一轮就断,剩下的对又回到隔夜等; +// ③ 不限制连击 → 用户持续写入时池子进得比追得快,变成永不落地的每五分钟一轮, +// 而审计上每轮都"有产出",肉眼很难发现; +// ④ 失败轮也追 → 与连败退避抢同一只闹钟,退避被自己踩空。 + +const embedder = { + embedSingle: async () => [1, 0, 0], + embed: async () => [1, 0, 0], + schedule: () => {}, + modelHash: "mock#1", + dimension: 3 +}; + +function mockCtx(responder) { + let call = 0; + return { + logger: { warn: () => {}, info: () => {} }, + agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) }, + llm: { + async *stream(options) { + const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? ""; + let reply = "[]"; + if (options.purpose === "sleep-conflict") reply = responder(userText, ++call); + yield { type: "text-delta", index: 0, text: reply }; + yield { type: "finish", reason: { kind: "stop" } }; + } + } + }; +} + +function setup() { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + const vectorIndex = createVectorIndex({ store }); + service.setEmbedder(embedder); + service.setVectorIndex(vectorIndex); + return { store, service, vectorIndex }; +} + +// 全部推到同一个方向附近:任意两条的 cosine 都 ≈ 1,findPotentialConflicts 生成 C(n,2) 对, +// 去重阶段按相似度留下 n/2 对(每记忆至多一对)。 +function seedSimilarPairs(service, vectorIndex, n) { + for (let i = 0; i < n; i++) { + const m = service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 }).memory; + vectorIndex.saveEmbedding(m.id, [1, 0, 0]); + } +} + +function baseConfig(overrides = {}) { + return { + sleepModeEnabled: true, + sleepIdleMinutes: 5, + sleepMinIntervalHours: 8, + sleepConflictStrictness: "normal", + sleepArchiveDays: 30, + sleepCompressDays: 90, + sleepPatternMinMemories: 100, + sleepMaxPatternPerRun: 3, + ...overrides + }; +} + +// 与 test/sleep-failure-backoff.test.js 同款房型:时钟与定时器全部注入。 +function makeHarness(overrides = {}) { + let nowMs = 10_000_000; + const timers = []; + let seq = 1; + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + let runs = 0; + let outcome = () => ({ ok: true, status: "noop" }); + // 运行**期间**要发生的事(写入 / 耗时)——用来复现"跑完那一刻已挂着的表只剩几秒"。 + let duringRun = null; + const sched = createSleepScheduler({ + service, + config: { sleepModeEnabled: true, sleepIdleMinutes: 5, sleepMinIntervalHours: 1, ...overrides }, + logger: { warn: () => {} }, + onRun: async () => { + runs++; + if (duringRun) await duringRun(); + return outcome(); + }, + now: () => nowMs, + setTimeoutFn: (fn, delay) => { + const t = { id: seq++, at: nowMs + delay, fn }; + timers.push(t); + return t.id; + }, + clearTimeoutFn: (id) => { + const i = timers.findIndex((t) => t.id === id); + if (i >= 0) timers.splice(i, 1); + } + }); + const fireNext = async () => { + timers.sort((a, b) => a.at - b.at); + const t = timers.shift(); + if (!t) throw new Error("no armed timer to fire"); + nowMs = t.at; + await t.fn(); + }; + return { + sched, + store, + timers, + fireNext, + runs: () => runs, + now: () => nowMs, + setOutcome: (fn) => { outcome = fn; }, + setDuringRun: (fn) => { duringRun = fn; }, + write: () => { nowMs += 1000; sched.noteWrite(); }, + advance: (ms) => { nowMs += ms; } + }; +} + +const MIN = 60_000; +const HOUR = 3_600_000; + +test("issue#529 续排:clean + 还有没进这一轮的对 ⇒ 下一个闹钟钉在静默窗口,不等最小间隔", async () => { + const h = makeHarness(); + h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true })); + h.write(); + await h.fireNext(); + assert.equal(h.runs(), 1, "静默到点起跑一轮"); + assert.equal(h.timers.length, 1, "关键:追 backlog 的轮次跑完必须自己补挂闹钟(此前一条都不挂)"); + const delay = h.timers[0].at - h.now(); + assert.equal(delay, 5 * MIN + 1000, "下限是一个静默窗口,不是 1 秒也不是 1 小时"); + + await h.fireNext(); + assert.equal(h.runs(), 2, "关键:闹钟真的起跑了第二轮(只补挂不起跑等于没追)"); + h.store.close(); +}); + +test("issue#529 续排:不 clean 时不追,间隔回到最小间隔", async () => { + const h = makeHarness(); + h.setOutcome(() => ({ ok: true, status: "degraded", catchup: false, backlog: true })); + h.write(); + await h.fireNext(); + assert.equal(h.runs(), 1); + assert.equal(h.timers.length, 0, "有对没落地 ⇒ 不补挂短闹钟,等下一次写入重新起算"); + const t = h.now(); + assert.equal(h.sched.shouldRun(t + HOUR - 1), false, "冷却仍是基数间隔"); + assert.equal(h.sched.shouldRun(t + HOUR), true); + h.store.close(); +}); + +test("issue#529 续排:运行期间用户写入挂的表要在收尾时重挂,不能剩几秒就开下一批", async () => { + const h = makeHarness(); + h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true })); + h.write(); + // 运行一开始就按静默窗口挂好了表(at = 起跑时刻 + 5min + 1s)。 + const armedAt = h.timers[0].at; + h.setDuringRun(async () => { + // 运行早期用户又写了一次:noteWrite 撤表重挂,新表从**那一刻**起算。 + h.advance(MIN); + h.write(); + // 这一轮本身跑了很久(LLM 往返 + 落库),早就越过那只表的到点时刻。真实环境里 + // 这只表此刻已经可以立即触发——收尾若不重挂,下一批就是"几秒后开跑", + // 「让库安静一个静默窗口再追」形同虚设。 + h.advance(10 * MIN); + }); + await h.fireNext(); + assert.equal(h.runs(), 1); + assert.ok(h.now() > armedAt, "前置:这一轮确实跑过了原来那只表的到点时刻"); + assert.equal(h.timers.length, 1, "收尾必须只剩(重挂的)一只表"); + assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000, "关键:从**收尾这一刻**重算整一个静默窗口,不是沿用运行期挂的旧表"); + h.store.close(); +}); + +test("issue#529 续排:台账写入失败要让这一轮不 clean,不能每个静默窗口重问同一个冲突", async () => { + const { service, store, vectorIndex } = setup(); + seedSimilarPairs(service, vectorIndex, 8); + // 留痕写不进去:这一对既没被记成"已审",下一轮还会原样回到候选池。若仍判 clean, + // 续排会一个静默窗口问一次 LLM、直到连击上限,而审计上每轮都是干净的成功轮。 + service.saveConflictReviewed = () => { + throw new Error("ledger unavailable"); + }; + const ctx = mockCtx(() => "[]"); + const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 }); + const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null); + assert.ok(result.phases.conflicts.ledgerFailed > 0, "前置:确实有留痕写入失败"); + assert.equal(result.phases.conflicts.clean, false, "关键:没记上账就不算落地"); + assert.equal(result.catchup, false, "不 clean ⇒ 不追下一批,回到正常节奏"); + store.close(); +}); + +test("issue#529 续排:失败轮不追(与连败退避互斥)", async () => { + const h = makeHarness({ sleepFailureBackoff: true }); + h.setOutcome(() => ({ ok: false, status: "failed", catchup: true, backlog: true })); + h.write(); + await h.fireNext(); + // 失败本身仍然要补挂(#187:不补挂就永远等不到下一轮),但补的是**退避后的**长表, + // 不是续排那张静默窗口的短表——这才是"互斥"的可观测形态。 + assert.equal(h.timers.length, 1, "失败轮按 #187 仍补挂一张表"); + assert.equal(h.timers[0].at - h.now(), 2 * HOUR + 1000, "补的是 1h × 2^1 的退避表,不是 5 分钟的续排表"); + const t = h.now(); + assert.equal(h.sched.shouldRun(t + 2 * HOUR - 1), false, "退避仍然生效(1h 基数 × 2^1)"); + assert.equal(h.sched.shouldRun(t + 2 * HOUR), true); + h.store.close(); +}); + +test("issue#529 续排:连击到上限即收手,闹钟回到正常间隔", async () => { + const h = makeHarness(); + // 用 runSleep 的真实返回形状:全判 keep 的一轮 status 是 noop,而 ok 只在 + // ok/degraded 时为真——桩里写 ok:true 会让 maybeSchedule 走进与真实路径不同的分支 + // (!ran 那条),于是「到顶后没有定时器」测的其实是桩的偏差,不是实现的行为。 + h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true })); + h.write(); + await h.fireNext(); + // 24 是实现里的 CATCHUP_STREAK_CAP:每轮都 clean 但池子永远追不完时,必须自己刹住。 + for (let i = 2; i <= 24; i++) { + assert.equal(h.timers.length, 1, `第 ${i} 轮之前应当有一只已挂好的闹钟`); + await h.fireNext(); + } + assert.equal(h.runs(), 24, "连追 24 轮"); + const t = h.now(); + assert.equal(h.sched.shouldRun(t + 5 * MIN), false, "收手后回到最小间隔,不再按静默窗口放行"); + assert.equal(h.sched.shouldRun(t + HOUR), true); + // 收手后既不能是「一直按静默窗口重排」(空转链),也不能是「什么都不挂」 + // (#187:CD 到期成了没有监听者的时刻)。正确形态是回到正常的最小间隔。 + assert.equal(h.timers.length, 1, "按 #187 补挂一张表,但不能是续排的短表"); + assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "补的是正常最小间隔,不是 5 分钟的续排表"); + h.store.close(); +}); + +test("issue#529 续排:全判 keep 的一轮 status=noop 仍是 clean,续排判据不依赖 status", async () => { + const { service, store, vectorIndex } = setup(); + seedSimilarPairs(service, vectorIndex, 8); + const ctx = mockCtx(() => "[]"); + const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 }); + const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null); + assert.equal(result.phases.conflicts.applied, 0, "全判 keep ⇒ 没有任何落地"); + assert.equal(result.phases.conflicts.status, "noop", "关键:这一轮永远到不了 ok(拿 status 当判据就死在这里)"); + assert.ok(result.phases.conflicts.perRunSkipped > 0, "前置:本轮确实被预算截断"); + assert.equal(result.phases.conflicts.clean, true, "送出去的每一对都留了台账 ⇒ clean"); + assert.equal(result.catchup, true, "关键:noop 轮次也要能追"); + assert.equal(result.backlog, true); + store.close(); +}); + +test("issue#529 续排:坏批让这一轮不 clean,即使还有 backlog 也不追", async () => { + const { service, store, vectorIndex } = setup(); + seedSimilarPairs(service, vectorIndex, 8); + let call = 0; + // 第一批吐空(stream 正常结束但没有 JSON 数组 ⇒ 这批 failed),后面的批正常。 + const ctx = mockCtx(() => (++call === 1 ? "" : "[]")); + const config = baseConfig({ sleepConflictMaxPairs: 1, sleepConflictMaxPairsPerRun: 2 }); + const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null); + assert.equal(result.backlog, true, "前置:确实有对没进这一轮"); + assert.equal(result.phases.conflicts.clean, false, "有批没走通 ⇒ 不 clean"); + assert.equal(result.catchup, false, "关键:不 clean 时宁可少追一轮,也不要在失败状态里把间隔压到静默窗口"); + store.close(); +}); + +test("issue#529 续排:连击到顶收手之后,下一次正常触发要能重新起算(刹车不能变成永久闸)", async () => { + const h = makeHarness(); + h.setOutcome(() => ({ ok: false, status: "noop", catchup: true, backlog: true })); + h.write(); + await h.fireNext(); + for (let i = 2; i <= 24; i++) await h.fireNext(); + assert.equal(h.runs(), 24); + assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "前置:到顶后已收手,闹钟回到正常间隔"); + + // 收手若只把 catchupWanted 置 false、**不把连击清零**,这个进程此后每一轮都会被同一道闸 + // 挡下(streak 只增不减),"等下一个静默窗口重新起算"就成了一句空话。 + h.write(); + for (let i = 0; i < 5 && h.runs() < 25; i++) await h.fireNext(); + assert.equal(h.runs(), 25, "关键:收手之后仍能被正常触发救回来"); + assert.equal(h.timers.length, 1, "重新起算后又该补挂续排闹钟"); + assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000, "补的是静默窗口的短表,说明连击已从 0 重新算"); + h.store.close(); +}); + +test("issue#529 续排:一轮抛错要把连击一并清零,不能把上一轮的进度带进下一次起算", async () => { + const h = makeHarness(); + // 同上面两条:runSleep 在 status==="noop" 时给 ok:false,桩要照它写。 + const clean = () => ({ ok: false, status: "noop", catchup: true, backlog: true }); + h.setOutcome(clean); + h.write(); + await h.fireNext(); + // 先追到 23(上限 24 的前一格):此时连击若不被清掉,下一轮 clean 就会立刻撞上限。 + for (let i = 2; i <= 23; i++) await h.fireNext(); + assert.equal(h.runs(), 23); + + h.setOutcome(() => { + throw new Error("boom"); + }); + h.write(); + await h.fireNext(); + assert.equal(h.runs(), 24, "第 24 轮抛错"); + assert.equal(h.timers.length, 1, "抛错仍按 #187 补挂一张表(补的是退避长表)"); + assert.equal(h.timers[0].at - h.now(), HOUR + 1000, "抛错后回落到最小间隔,续排的短表不再挂"); + + // 关键:抛错回合若不把 catchupStreak 清零,接下来这一轮 clean 的连击会从 23 累到 24, + // 于是"刚被救回来"的进程立刻又被同一道闸挡下。 + h.setOutcome(clean); + await h.fireNext(); + assert.equal(h.runs(), 25, "抛错后的第一次干净轮次仍要真的跑"); + assert.equal(h.timers.length, 1, "关键:连击已清零,这一轮又能挂上续排闹钟"); + assert.equal(h.timers[0].at - h.now(), 5 * MIN + 1000); + h.store.close(); +});