|
| 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