Skip to content

Commit 47571dd

Browse files
authored
Merge pull request #291 from slow-stack/fix/89-lastrunat-persist
fix(dream): 节流与冷却时刻跨重启持久化——审计表恢复 lastRunAt 种子(#89 连发根因)
2 parents 7412400 + ab68562 commit 47571dd

10 files changed

Lines changed: 190 additions & 8 deletions

File tree

‎dsh-mneme/CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
## 🐛 修复
66

7+
- **autoDream / sleep 的节流与冷却时刻跨重启持久化(issue #89 连发根因)**:两个调度器的
8+
`lastRunAt` 只活在内存里,进程重启即归零——最小间隔 / 冷却闸对新实例放行,重启后立刻连发
9+
(#89 Sample A/C 实测:横跨重启边界的 8.7 / 23.1 分钟间隔连发)。修复走审计表:`dream_runs`
10+
本来就逐 run 落库(failed/degraded 也算 run),新增 `store.lastDreamRunAt(runType)` 读回最近
11+
一次开跑时刻,构造 dream 调度器(`run_type='auto'`)与 sleep 调度器(`run_type='sleep'`)时
12+
注入种子——零 schema 迁移、零新配置键。审计行 `created_at` 同时改为记录**开跑时刻**而非完成
13+
时刻(run 耗时不应计入下一轮间隔窗口)。回归测试 +5(审计读回与 run_type 过滤、dream/sleep
14+
重启闸、index.js 接线源码锁 ×2)。
15+
716
- **本地嵌入对 BGE 系用错池化(静默偏差,不报错)**:`LocalEmbedder.embed()` 对所有本地模型硬编码
817
`pooling: "mean"`,但 BGE 系(含默认的 `Xenova/bge-small-zh-v1.5`)是按 **CLS** 训练的——模型自带的
918
`1_Pooling/config.json` 明确写着 `pooling_mode_cls_token: true` / `pooling_mode_mean_tokens: false`,

‎dsh-mneme/lib/dream.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,15 +744,17 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
744744
if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
745745
}
746746

747-
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
747+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0 }) {
748748
let pendingTimer = null;
749749
let running = false;
750750
let disposed = false;
751751
let baseline = { count: 0, chars: 0 };
752752
let inFlight = null;
753753
// Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
754754
// 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
755-
let lastRunAt = 0;
755+
// lastRunAtSeed:调用方从 dream_runs 审计表读出的上次开跑时刻——
756+
// 内存变量进程重启即归零,闸门对新实例放行 → 重启后立刻连发(#89 根因)。
757+
let lastRunAt = lastRunAtSeed;
756758

757759
function shouldTrigger(service) {
758760
const memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
@@ -820,6 +822,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
820822
const logger = ctx.logger;
821823
let memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
822824
if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
825+
// Issue #89:开跑时刻既喂给审计行(created_at),也是调度器闸门的时间基准。
826+
const dreamStartedAt = Date.now();
823827
// v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
824828
// 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
825829
// 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
@@ -886,6 +890,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
886890
try {
887891
service.saveDreamRun({
888892
id: runId,
893+
// Issue #89:审计行记开跑时刻而非完成时刻——lastDreamRunAt 以它做
894+
// 重启后的间隔种子,落完成时刻会让 run 耗时白白计入下一轮最小间隔。
895+
created_at: new Date(dreamStartedAt).toISOString(),
889896
status,
890897
error: result.error,
891898
provider: route?.provider,

‎dsh-mneme/lib/dream/sleep.js‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,9 @@ function deriveStatus(phases) {
611611
*/
612612
export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
613613
const runId = randomUUID();
614+
// Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
615+
// (LLM 各 phase)不应计入重启后的冷却窗口。
616+
const sleepStartedAt = Date.now();
614617
const phases = {};
615618
const attempt = async (name, fn) => {
616619
if (signal?.aborted) return; // user resumed activity — stop before next phase
@@ -647,6 +650,9 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
647650
try {
648651
service.saveDreamRun({
649652
id: runId,
653+
// Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
654+
// 不该计入重启后的 sleep 冷却种子。
655+
created_at: new Date(sleepStartedAt).toISOString(),
650656
status,
651657
provider: route?.provider,
652658
model: route?.model,
@@ -686,10 +692,14 @@ export function createSleepScheduler({
686692
onRun = null,
687693
now = () => Date.now(),
688694
setTimeoutFn = setTimeout,
689-
clearTimeoutFn = clearTimeout
695+
clearTimeoutFn = clearTimeout,
696+
// Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
697+
// 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
698+
// 测试注入 0(默认)即保持旧行为。
699+
lastRunAtSeed = 0
690700
}) {
691701
let lastWriteAt = now();
692-
let lastRunAt = 0;
702+
let lastRunAt = lastRunAtSeed;
693703
let running = false;
694704
let disposed = false;
695705
let idleTimer = null;

‎dsh-mneme/lib/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ export const apply = (ctx, config) => {
459459
minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
460460
logger: ctx.logger,
461461
semantic: { embedder, vectorIndex },
462+
lastRunAtSeed: store.lastDreamRunAt("auto"),
462463
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
463464
});
464465
service.setDreamHook(() => dream.maybeSchedule(service));
@@ -475,6 +476,7 @@ export const apply = (ctx, config) => {
475476
service,
476477
config: cfg,
477478
logger: ctx.logger,
479+
lastRunAtSeed: store.lastDreamRunAt("sleep"),
478480
onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
479481
});
480482
service.setSleepHook(() => sleep.noteWrite());

‎dsh-mneme/lib/store.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,22 @@ export function createStore(path) {
15321532
return rows.map(toDreamRun);
15331533
}
15341534

1535+
// Issue #89:上次实际开跑时刻(epoch ms),从审计表恢复——调度器的 lastRunAt
1536+
// 只活在内存里,进程重启即归零,闸门对新实例放行 → 重启后立刻连发。审计表
1537+
// 本来就逐 run 落库(failed/degraded 也算 run),直接读它就是事实源,零迁移。
1538+
function lastDreamRunAt(runType = null) {
1539+
const row = runType
1540+
? db.prepare(
1541+
"SELECT created_at FROM dream_runs WHERE run_type = ? ORDER BY created_at DESC, id LIMIT 1"
1542+
).get(runType)
1543+
: db.prepare(
1544+
"SELECT created_at FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
1545+
).get();
1546+
if (!row?.created_at) return 0;
1547+
const ms = Date.parse(row.created_at);
1548+
return Number.isFinite(ms) ? ms : 0;
1549+
}
1550+
15351551
/**
15361552
* Latest ruling-rule version seen on the audit trail. policy_epoch is a config
15371553
* value stamped onto each run by the caller; reading the newest row's epoch
@@ -2486,6 +2502,7 @@ export function createStore(path) {
24862502
needsEmbedding,
24872503
searchVector,
24882504
saveDreamRun,
2505+
lastDreamRunAt,
24892506
getDreamRun,
24902507
listDreamRuns,
24912508
getLatestPolicyEpoch,

‎dsh-mneme/src/dream.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,15 +744,17 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
744744
if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
745745
}
746746

747-
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
747+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0 }) {
748748
let pendingTimer = null;
749749
let running = false;
750750
let disposed = false;
751751
let baseline = { count: 0, chars: 0 };
752752
let inFlight = null;
753753
// Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
754754
// 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
755-
let lastRunAt = 0;
755+
// lastRunAtSeed:调用方从 dream_runs 审计表读出的上次开跑时刻——
756+
// 内存变量进程重启即归零,闸门对新实例放行 → 重启后立刻连发(#89 根因)。
757+
let lastRunAt = lastRunAtSeed;
756758

757759
function shouldTrigger(service) {
758760
const memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
@@ -820,6 +822,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
820822
const logger = ctx.logger;
821823
let memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
822824
if (memories.length === 0) return { ok: true, applied: 0, skipped: true, summary: false };
825+
// Issue #89:开跑时刻既喂给审计行(created_at),也是调度器闸门的时间基准。
826+
const dreamStartedAt = Date.now();
823827
// v0.4.4 滑动窗口:只 consolidation 最近 dreamMaxSnapshotSize 条记忆,
824828
// 窗口外的旧记忆不进 snapshot(大记忆量下全量快照会撑爆 LLM 输入,配合
825829
// 隐式 keep 让 run 始终可收敛)。按 updated_at 倒序取前 maxSize 条。
@@ -886,6 +890,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
886890
try {
887891
service.saveDreamRun({
888892
id: runId,
893+
// Issue #89:审计行记开跑时刻而非完成时刻——lastDreamRunAt 以它做
894+
// 重启后的间隔种子,落完成时刻会让 run 耗时白白计入下一轮最小间隔。
895+
created_at: new Date(dreamStartedAt).toISOString(),
889896
status,
890897
error: result.error,
891898
provider: route?.provider,

‎dsh-mneme/src/dream/sleep.js‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,9 @@ function deriveStatus(phases) {
611611
*/
612612
export async function runSleep(ctx, service, config, logger, semantic = null, signal = null) {
613613
const runId = randomUUID();
614+
// Issue #89:开跑时刻——审计行 created_at 的基准(= 冷却种子),run 耗时
615+
// (LLM 各 phase)不应计入重启后的冷却窗口。
616+
const sleepStartedAt = Date.now();
614617
const phases = {};
615618
const attempt = async (name, fn) => {
616619
if (signal?.aborted) return; // user resumed activity — stop before next phase
@@ -647,6 +650,9 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
647650
try {
648651
service.saveDreamRun({
649652
id: runId,
653+
// Issue #89:审计行记开跑时刻而非完成时刻——run 耗时(含 LLM 各 phase)
654+
// 不该计入重启后的 sleep 冷却种子。
655+
created_at: new Date(sleepStartedAt).toISOString(),
650656
status,
651657
provider: route?.provider,
652658
model: route?.model,
@@ -686,10 +692,14 @@ export function createSleepScheduler({
686692
onRun = null,
687693
now = () => Date.now(),
688694
setTimeoutFn = setTimeout,
689-
clearTimeoutFn = clearTimeout
695+
clearTimeoutFn = clearTimeout,
696+
// Issue #89 同族:冷却时刻由调用方从 dream_runs 审计表恢复(run_type='sleep')——
697+
// 内存变量进程重启即归零,冷却闸对新实例放行 → 重启后立即进入 sleep 冷却盲区。
698+
// 测试注入 0(默认)即保持旧行为。
699+
lastRunAtSeed = 0
690700
}) {
691701
let lastWriteAt = now();
692-
let lastRunAt = 0;
702+
let lastRunAt = lastRunAtSeed;
693703
let running = false;
694704
let disposed = false;
695705
let idleTimer = null;

‎dsh-mneme/src/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ export const apply = (ctx, config) => {
459459
minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
460460
logger: ctx.logger,
461461
semantic: { embedder, vectorIndex },
462+
lastRunAtSeed: store.lastDreamRunAt("auto"),
462463
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
463464
});
464465
service.setDreamHook(() => dream.maybeSchedule(service));
@@ -475,6 +476,7 @@ export const apply = (ctx, config) => {
475476
service,
476477
config: cfg,
477478
logger: ctx.logger,
479+
lastRunAtSeed: store.lastDreamRunAt("sleep"),
478480
onRun: (signal) => (sleep ? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex }, signal) : Promise.resolve({ ok: true, skipped: true }))
479481
});
480482
service.setSleepHook(() => sleep.noteWrite());

‎dsh-mneme/src/store.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,22 @@ export function createStore(path) {
15321532
return rows.map(toDreamRun);
15331533
}
15341534

1535+
// Issue #89:上次实际开跑时刻(epoch ms),从审计表恢复——调度器的 lastRunAt
1536+
// 只活在内存里,进程重启即归零,闸门对新实例放行 → 重启后立刻连发。审计表
1537+
// 本来就逐 run 落库(failed/degraded 也算 run),直接读它就是事实源,零迁移。
1538+
function lastDreamRunAt(runType = null) {
1539+
const row = runType
1540+
? db.prepare(
1541+
"SELECT created_at FROM dream_runs WHERE run_type = ? ORDER BY created_at DESC, id LIMIT 1"
1542+
).get(runType)
1543+
: db.prepare(
1544+
"SELECT created_at FROM dream_runs ORDER BY created_at DESC, id LIMIT 1"
1545+
).get();
1546+
if (!row?.created_at) return 0;
1547+
const ms = Date.parse(row.created_at);
1548+
return Number.isFinite(ms) ? ms : 0;
1549+
}
1550+
15351551
/**
15361552
* Latest ruling-rule version seen on the audit trail. policy_epoch is a config
15371553
* value stamped onto each run by the caller; reading the newest row's epoch
@@ -2486,6 +2502,7 @@ export function createStore(path) {
24862502
needsEmbedding,
24872503
searchVector,
24882504
saveDreamRun,
2505+
lastDreamRunAt,
24892506
getDreamRun,
24902507
listDreamRuns,
24912508
getLatestPolicyEpoch,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { createStore } from "../src/store.js";
4+
import { createService } from "../src/service.js";
5+
import { createDreamScheduler } from "../src/dream.js";
6+
import { createSleepScheduler } from "../src/dream/sleep.js";
7+
8+
// 回归(issue #89):调度器的 lastRunAt 只活在内存里,进程重启即归零——
9+
// 最小间隔/冷却闸对新实例放行,重启后 autoDream/sleep 立即连发(Sample A/C 的
10+
// 根因)。修复 = 构造调度器时从 dream_runs 审计表恢复种子(store.lastDreamRunAt,
11+
// failed/degraded 也算 run,审计表本来就是逐 run 落库)。本文件锁三点:
12+
// 1. lastDreamRunAt 读回最近一次 run 的开跑时刻(含 run_type 过滤);
13+
// 2. dream 调度器带种子构造后,重启场景下最小间隔闸门立即生效;
14+
// 3. sleep 调度器带种子构造后,冷却期内不起跑、且按剩余窗口重排不丢触发权。
15+
16+
test("issue#89: store.lastDreamRunAt recovers the last run timestamp (with run_type filter)", () => {
17+
const store = createStore(":memory:");
18+
assert.equal(store.lastDreamRunAt(), 0, "empty trail → 0 (never ran)");
19+
assert.equal(store.lastDreamRunAt("sleep"), 0);
20+
store.saveDreamRun({ status: "ok", snapshot_hash: "h1", input_count: 1, receipt: "r", created_at: "2026-09-10T01:05:23.640Z", run_type: "auto" });
21+
store.saveDreamRun({ status: "ok", snapshot_hash: "h2", input_count: 1, receipt: "r", created_at: "2026-09-09T20:00:00.000Z", run_type: "sleep" });
22+
assert.equal(store.lastDreamRunAt(), Date.parse("2026-09-10T01:05:23.640Z"), "latest run regardless of type");
23+
assert.equal(store.lastDreamRunAt("sleep"), Date.parse("2026-09-09T20:00:00.000Z"), "filtered by run_type");
24+
assert.equal(store.lastDreamRunAt("auto"), Date.parse("2026-09-10T01:05:23.640Z"), "auto filter ignores sleep rows");
25+
store.close();
26+
});
27+
28+
test("issue#89: dream scheduler seeded from the audit trail — restart no longer bypasses the min interval", async () => {
29+
const store = createStore(":memory:");
30+
const service = createService({ store, mirror: null, config: {} });
31+
// 模拟上一进程刚跑过一轮(10ms 前开跑)并留下审计行,然后「重启」:
32+
// minIntervalMs 取 1min——断言窗口远宽于用例执行时间,真实时钟抖动(调度、
33+
// 慢盘)不会把 10ms 偏移推出闸门外造成间歇性假失败。
34+
store.saveDreamRun({
35+
status: "ok", snapshot_hash: "h", input_count: 1, receipt: "r",
36+
created_at: new Date(Date.now() - 10).toISOString(), run_type: "auto"
37+
});
38+
const dream = createDreamScheduler({
39+
onRun: async () => ({ ok: false, error: "llm failed" }),
40+
thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 60000,
41+
lastRunAtSeed: store.lastDreamRunAt("auto"),
42+
logger: { warn: () => {} }
43+
});
44+
service.saveWithDedupe({ type: "project", title: "a", content: "x" });
45+
// 无种子时这里会返回 true(旧缺陷:重启后立刻连发);带种子必须被闸门拦下。
46+
assert.equal(dream.maybeSchedule(service), false, "seeded gate blocks immediately after 'restart'");
47+
store.close();
48+
});
49+
50+
test("issue#89: sleep scheduler seeded — cooldown survives a restart", async () => {
51+
let nowMs = 1_000_000;
52+
const timers = [];
53+
let seq = 1;
54+
let runs = 0;
55+
const lastRunAt = nowMs - 10 * 60_000; // 上一进程 10 分钟前跑过,CD 1h 还剩 50min
56+
const sched = createSleepScheduler({
57+
service: { enqueue: (fn) => fn() },
58+
config: { sleepModeEnabled: true, sleepIdleMinutes: 5, sleepMinIntervalHours: 1 },
59+
logger: { warn: () => {} },
60+
onRun: async () => { runs++; return { ok: true }; },
61+
now: () => nowMs,
62+
lastRunAtSeed: lastRunAt,
63+
setTimeoutFn: (fn, delay) => { const t = { id: seq++, at: nowMs + delay, fn }; timers.push(t); return t.id; },
64+
clearTimeoutFn: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); }
65+
});
66+
nowMs += 1000;
67+
sched.noteWrite(); // 挂 idle 闹钟
68+
const idleTimer = timers[0];
69+
assert.ok(idleTimer, "idle timer armed on write");
70+
nowMs = idleTimer.at;
71+
timers.length = 0;
72+
await idleTimer.fn();
73+
assert.equal(runs, 0, "restored cooldown blocks the run");
74+
const rearm = timers[0];
75+
assert.ok(rearm, "re-armed for the remaining cooldown window (not dropped, #187 semantics)");
76+
assert.equal(rearm.at, lastRunAt + 3_600_000 + 1000, "re-arms at CD expiry (+1s guard)");
77+
});
78+
79+
// --- CodeRabbit #291 findings 5/6:index.js 的 seed 注入是单行无分支接线,仓库
80+
// 没有插件级挂载 harness(apply 需要完整宿主 ctx),起全量挂载只为准两条断言
81+
// 不成比例——改为源码级锁:删掉 index.js 的对应注入行即红。import 用绝对路径,
82+
// 不依赖测试进程 CWD。
83+
import { readFileSync } from "node:fs";
84+
import { fileURLToPath } from "node:url";
85+
const indexSrc = readFileSync(fileURLToPath(new URL("../src/index.js", import.meta.url)), "utf8");
86+
87+
test("issue#89: index.js wires the dream scheduler seed from the audit trail", () => {
88+
assert.match(
89+
indexSrc,
90+
/lastRunAtSeed:\s*store\.lastDreamRunAt\("auto"\)/,
91+
"dream scheduler must seed its min-interval gate from dream_runs (run_type='auto') — remove this line and restart-bypass returns"
92+
);
93+
});
94+
95+
test("issue#89: index.js wires the sleep scheduler seed from the audit trail", () => {
96+
assert.match(
97+
indexSrc,
98+
/lastRunAtSeed:\s*store\.lastDreamRunAt\("sleep"\)/,
99+
"sleep scheduler must seed its cooldown gate from dream_runs (run_type='sleep') — remove this line and restart-bypass returns"
100+
);
101+
});

0 commit comments

Comments
 (0)