Skip to content

[Bug] sleep 排程:计时器撞上 sleepMinIntervalHours 后不重排,整轮被静默丢弃 #187

Closed

Description

@Philia-FY

环境、复现、观察均来自真实运行中的实例;六项要素经用户本人逐项核实。
报告由用户环境实测 + AI 协助整理措辞。

1. dsh-mneme 版本

@modusensus/[email protected]

2. DSH 版本与 profile

dsh CLI 0.1.5-rc.2 | profile: web

3. 操作系统 / 平台

Windows 11 专业版 build 26200 (x64) | Node v24.19.0

4. 复现步骤(从新会话开始,每步可照做)

配置上只需把冷却调短便于观察(默认 sleepMinIntervalHours: 8 同样可复现,只是窗口更长):

sleepModeEnabled: true(默认)
sleepIdleMinutes: 5(默认)
sleepMinIntervalHours: 1(默认 8;调小只为缩短观察时间)
  1. 先触发一轮种子 sleep:写入一条记忆(mneme_save)→ 保持静默 → 观察 dream_runs 出现一行 run_type='sleep'。
  2. 等这轮结束,在 1 小时内再写入一条记忆 → 保持静默满 sleepIdleMinutes。
  3. 此时静默计时器会到点,但 CD 未满 → 该轮被丢弃(可重复第 2 步复现多次)。
  4. 关键一步:此后不再写入,一直等到 CD 到期(种子轮时间 + 1 小时)之后。
  5. 预期:CD 到期时应当自行起一轮(库里有它该清理的新写入)。
    实际:不会起 —— 表已被丢弃,没有任何东西在等 CD 到期。必须再写入一次才会重新挂表。

5. 实际行为 vs 预期行为

实际行为

sleepIdleMinutes 静默计时器到点后,若 sleepMinIntervalHours(CD)尚未满足:

  • shouldRun() 返回 false;
  • maybeSchedule() 直接 return false,而 idleTimer 已在回调开头被置为 null;
  • 没有任何地方重新排表 → 这一轮永久丢失;
  • 于是"CD 到期"这个时刻没有任何东西在监听 → 要等下一次记忆写入触发 noteWrite() 才会重新挂表。

预期行为

CD 未满时不应丢掉整轮 —— 应至少按剩余 CD 重排一次并在到点时起跑,而不是静默丢弃、依赖"用户恰好又写了一次"。

本机真实时间线(取自 dream_runs / memories / llm_audit_logs;时间为 UTC,括号内为本地时间 UTC+8)

20:07:50~52 (本地)  autoSummarize 落 3 条记忆          → 这是一次"写入",挂表
2026-09-15T12:07:53.126Z   sleep ok   applied=79       ← 种子轮(lastRunAt 落在此刻)
2026-09-15T12:08:32.656Z   写入 #1(mneme_save)
        ↑ 应于 +5 分钟到点
~2026-09-15T12:13:33Z     到点 → CD 未满 → 丢弃 ①
2026-09-15T12:15:45.648Z   写入 #2(mneme_save)        → 重新挂表
~2026-09-15T12:20:46Z     到点 → CD 未满 → 丢弃 ②
2026-09-15T12:51:34.596Z   autoSummarize success(12:51:39 落 1 行)→ 又一次写入,挂表
~2026-09-15T12:56:39Z     到点 → CD 未满 → 丢弃 ③
2026-09-15T13:07:53.126Z   CD 到期 —— 此时没有任何表在等 → 什么都不发生   ★ 本 issue 核心
2026-09-15T13:16:08.857Z   写入 #3(mneme_save)        → 重新挂表
2026-09-15T13:22:23.444Z   sleep ok   applied=83       ← 被"下一次写入"救起

★ 12:56:39 → 13:16:08 之间没有任何写入,所以 13:07:53 的 CD 到点是一个完全没有监听者的时刻:库里有待清理的新记忆、CD 已满足,却什么都没发生,一直空等到 13:16 有人再写一次。

三次丢弃对应三次写入 —— 说明这不是偶发时序,而是只要写入落在 CD 窗口内就必然发生的常规路径(CD 越长越容易命中;默认 8h 时几乎每次写入都会命中)。

6. 相关 console / log 片段(token 打码;本机无 token 输出)

console 无报错 —— 这是静默丢失:logger.warn 只在 run 抛异常时打印,本路径走不到。

-- ~/.dsh/memory/memory.db
SELECT created_at, status, run_type, applied FROM dream_runs
 WHERE run_type='sleep' ORDER BY created_at DESC LIMIT 5;
2026-09-15T13:22:23.444Z  ok        sleep  83      ← 被 13:16 的写入救起
2026-09-15T12:07:53.126Z  ok        sleep  79      ← 种子轮
2026-09-15T08:31:50.053Z  ok        sleep  63
2026-09-15T07:22:36.494Z  degraded  sleep  73
2026-09-15T05:19:40.534Z  ok        sleep  83

注意 12:07:53 → 13:22:23 之间的空档长达 74 分钟,而其间 CD 只要求间隔 1 小时;这 14 分钟的额外延迟正是被丢弃的那几轮。

关联现象(同一机制,供一并评估,非本 issue 主体):armIdleTimer() 只在 noteWrite() 里被调用,构造时不排任何表。因此重启后只要没发生过写入,sleep 就永远没有时钟 —— 安静多久都不会触发。若一并处理,建议初始化时也排一次(lastWriteAt 已在构造时被设为当前时刻,shouldRun 的静默条件会自然生效)。

根因(代码级)

src/dream/sleep.js(发布产物对应 lib/dream/sleep.js:573-593 与 :617-618):

function armIdleTimer() {
  ...
  idleTimer = setTimeoutFn(async () => {
    idleTimer = null;
    await maybeSchedule();          // ← 返回值被丢弃,无人据它重排
  }, delay);
  idleTimer.unref?.();
}

async function maybeSchedule() {
  if (!shouldRun()) return false;   // ← CD 未满时在这里返回,而闹钟已经丢了
  ...
}

function shouldRun(at = now()) {
  ...
  if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
  return true;
}

noteWrite() 上方注释已明确描述了这个后果:

a stale timer armed against the old idle window would otherwise fire early, fail shouldRun, and leave nothing armed for the next window (a missed trigger until the next write)

但它只解释了"为什么每次写入要清旧表并重挂",对"CD 未满被丢弃"这一支没有做任何补救。

最小复现测试(node:test,符合本仓库测试约定,可直接跑)

用注入时钟把整段时序压到毫秒级,不依赖真实等待(createSleepScheduler 已支持注入 now / setTimeoutFn):

// test/sleep-cd-rearm.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSleepScheduler } from '../src/dream/sleep.js';

test('sleep:撞 CD 后应重排,而不是丢掉整轮', async () => {
  let nowMs = 0, id = 1, runs = 0;
  const timers = [];
  const sched = createSleepScheduler({
    service: { enqueue: (fn) => fn() },
    config: { sleepModeEnabled: true, sleepIdleMinutes: 5, sleepMinIntervalHours: 1 },
    onRun: async () => { runs++; return { ok: true }; },
    now: () => nowMs,
    setTimeoutFn: (fn, delay) => { const t = { id: id++, at: nowMs + delay, fn }; timers.push(t); return t.id; },
    clearTimeoutFn: (tid) => { const i = timers.findIndex((t) => t.id === tid); if (i >= 0) timers.splice(i, 1); },
  });
  const fire = async () => { timers.sort((a, b) => a.at - b.at); const t = timers.shift(); nowMs = t.at; await t.fn(); };

  sched.noteWrite(); await fire();
  assert.equal(runs, 1, '第一轮应正常跑');

  nowMs += 2000;
  sched.noteWrite(); await fire();                       // 这次会被 CD 拦下
  assert.ok(timers.length > 0, 'CD 未满时应重排一张表(0.8.0 上此处失败)');
  await fire();
  assert.equal(runs, 2, 'CD 到点后应自行跑到第二轮');
});

实测:0.8.0 上 FAIL(AssertionError: CD 未满时应重排一张表);打上下面的本地补丁后 PASS。

我们本地的兜底补丁(仅供参考,不强求照此实现)

思路:被 CD 拦下时,按「剩余 CD / 剩余静默」里更晚的那个重排一张表,而不是丢掉整轮。核心 3 行:

idleTimer = setTimeoutFn(async () => {
  idleTimer = null;
  const ran = await maybeSchedule();
  if (!ran) armCdWaitTimer();   // 新增:没跑成就按剩余量再排一次
}, delay);

armCdWaitTimer() 要点:disposed / idleTimer 已存在 / running 时直接返回;lastRunAt <= 0 时返回;delay = max(剩余 CD, 剩余静默) + 1000;回调里重复同样判断,因此不会空转。

同一份用例:打补丁前 FAIL、打补丁后 PASS;重排延迟 54m58s(= 剩余 CD),第二轮起跑 = 上一轮 + 1h + 1s。

Activity

  1. added
    bug功能异常、崩溃、数据错误(agent 诊断 → 人工确认)
    on Sep 15, 2026
  2. moved this from 📋 Backlog to 🎯 Ready in dsh-mneme 任务看板on Sep 15, 2026
  3. moved this from 🎯 Ready to 🔨 In Progress in dsh-mneme 任务看板on Sep 15, 2026
  4. modusensus commented on Sep 15, 2026

    @modusensus
    Member

    复验确认,报告的机制链完全属实:

    1. armIdleTimer 回调把 maybeSchedule() 的返回值丢弃(src/dream/sleep.js 调度器段),而 idleTimer 已在回调开头置 null——撞 CD 后确实没有任何东西在等 CD 到期;
    2. armIdleTimer 全文件只有 noteWrite() 一个调用点,构造时不挂表——重启后零写入就永远没有闹钟,关联现象同样成立。

    报告附的最小复现测试(注入 now/setTimeoutFn)与时线(dream_runs 实表取数)质量很高,修复语义也采纳了你们兜底补丁的思路:

    • 撞 CD / run 失败 → 重排不丢轮:按「剩余 CD / 剩余静默」里更晚的到点时刻(+1s 缓冲)重排一次,回调里重复同样判断所以不会空转。与补丁唯一的小差异:running 时不跳过重排——睡眠长跑中途的外部写入依赖这条路径自愈,实测无空转风险;
    • 跑成了就不重排:安静的库没有要处理的新东西,触发权交还下一次写入的 noteWrite,避免纯 noop 的 LLM 轮次;
    • 构造即挂表一并修了:重启后静默满 sleepIdleMinutes 即可触达第一轮,不再依赖「恰好有人写入」。

    实现见 #192,回归测试按报告的注入时钟方案复现了报告时序(修复前撞 CD 后 0 张表 → 修复后第二轮恰在上一轮 + CD + 1s 自行起跑)。感谢 @Philia-FY —— 这份报告从根因到补丁到测试是完整闭环,帮我们把一个静默丢失了两周量级维护窗口的缺陷钉死了。

  5. moved this from 🔨 In Progress to ✅ Done in dsh-mneme 任务看板on Sep 15, 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