diff --git a/forge-engine/scheduler.test.ts b/forge-engine/scheduler.test.ts index 7094723..a19557d 100644 --- a/forge-engine/scheduler.test.ts +++ b/forge-engine/scheduler.test.ts @@ -3,7 +3,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { expandRandom, removeScheduleEntryFromFile } from "./scheduler.js"; +import { + expandRandom, + fireKey, + hasFiredToday, + markFired, + removeScheduleEntryFromFile, + shouldFire, +} from "./scheduler.js"; const tempDirs: string[] = []; @@ -92,3 +99,70 @@ describe("scheduler helpers", () => { expect(fs.existsSync(filePath)).toBe(false); }); }); + +describe("shouldFire 时间规则", () => { + // 回归:错过任务检测原本只走 canScheduleToday(仅查 start_date/end_date), + // 未过 shouldFire,导致「每周日」的任务在周六被报成「今天错过」。 + test("weekdays 不匹配当天时返回 false", () => { + const today = new Date().getDay(); + const otherDay = (today + 1) % 7; + expect(shouldFire({ hour: 22, minute: 0, second: 0, sender: "t", weekdays: [otherDay] } as never)).toBe(false); + expect(shouldFire({ hour: 22, minute: 0, second: 0, sender: "t", weekdays: [today] } as never)).toBe(true); + }); + + test("days 不匹配当天日期时返回 false", () => { + const d = new Date().getDate(); + const other = d === 1 ? 2 : 1; + expect(shouldFire({ hour: 9, minute: 0, second: 0, sender: "t", days: [other] } as never)).toBe(false); + expect(shouldFire({ hour: 9, minute: 0, second: 0, sender: "t", days: [d] } as never)).toBe(true); + }); + + test("无时间条件时默认可触发", () => { + expect(shouldFire({ hour: 0, minute: 0, second: 0, sender: "t" } as never)).toBe(true); + }); +}); + +describe("已触发记录(错过检测的第二道闸)", () => { + // 回归:错过判定原本只看「墙上时间 − 排期时刻」落在 2h 窗口内 + shouldFire, + // 从不查该条目今天是否已经触发过。于是任务时刻之后 2 小时内的任何一次重排 + // (启动 / 配置热加载 / 午夜重排在边界上提前几毫秒跑),都会把已跑完的任务 + // 重新报成「错过」。2026-08-22 / 08-23 / 08-28 / 08-29 连续复发。 + const entry = { + hour: 22, minute: 0, second: 0, + sender: "engine", label: "洗澡提醒", origin: "shower.json", + } as never; + + test("fireKey 对同一条目稳定,对不同时刻/来源不同", () => { + expect(fireKey(entry)).toBe(fireKey(entry)); + expect(fireKey({ ...(entry as object), minute: 30 } as never)).not.toBe(fireKey(entry)); + expect(fireKey({ ...(entry as object), origin: "other.json" } as never)).not.toBe(fireKey(entry)); + }); + + test("label 缺省时回退到 sender,不会把两个条目挤成同一个键", () => { + const a = { hour: 9, minute: 0, second: 0, sender: "briefing", origin: "x.json" } as never; + const b = { hour: 9, minute: 0, second: 0, sender: "redline", origin: "x.json" } as never; + expect(fireKey(a)).not.toBe(fireKey(b)); + }); + + test("没有记录时 hasFiredToday 为 false", () => { + expect(hasFiredToday({}, entry, "2026-08-29")).toBe(false); + }); + + test("markFired 之后同日为 true、次日为 false", () => { + const state = markFired({}, entry, "2026-08-29"); + expect(hasFiredToday(state, entry, "2026-08-29")).toBe(true); + expect(hasFiredToday(state, entry, "2026-08-30")).toBe(false); + }); + + test("markFired 清掉非当天的键,状态文件不会无限增长", () => { + let state: Record = { "stale.json|09:00:00|旧任务": "2026-01-01" }; + state = markFired(state, entry, "2026-08-29"); + expect(Object.keys(state)).toEqual([fireKey(entry)]); + }); + + test("同一天重复 markFired 不产生第二个键", () => { + let state = markFired({}, entry, "2026-08-29"); + state = markFired(state, entry, "2026-08-29"); + expect(Object.keys(state)).toHaveLength(1); + }); +}); diff --git a/forge-engine/scheduler.ts b/forge-engine/scheduler.ts index e80e257..fc9e8a2 100644 --- a/forge-engine/scheduler.ts +++ b/forge-engine/scheduler.ts @@ -147,7 +147,7 @@ function resolveAll(rawEntries: RawScheduleEntry[]): ResolvedEntry[] { /** * 统一时间规则检查。所有条件都满足才触发。 */ -function shouldFire(entry: ResolvedEntry): boolean { +export function shouldFire(entry: ResolvedEntry): boolean { const now = new Date(); if (entry.weekdays?.length && !entry.weekdays.includes(now.getDay())) return false; @@ -276,6 +276,7 @@ async function fire(entry: ResolvedEntry, server: Server): Promise { appendLog(entry, content); updateState(sender); + saveState(FIRES_MODULE, markFired(loadState(FIRES_MODULE), entry)); // Auto-delete one_shot if (entry.one_shot) { @@ -343,13 +344,58 @@ function updateState(sender: string): void { saveState("global", s); } +// ── 已触发记录 ────────────────────────────────────────────────────────────── +// 错过检测原本只看「墙上时间 − 排期时刻」是否落在 MISSED_WINDOW_MS 内(外加 +// shouldFire 的排期规则),从不查该条目今天是否已经触发过。于是任务时刻之后 +// 2 小时内的任何一次重排——启动、配置热加载、午夜重排在边界上提前几毫秒跑到 +// 前一天——都会把已经跑完的任务重新报成「错过」。global.json 只有 last_fire / +// today_count 这类全局标量,回答不了「这一条今天跑没跑」,所以另存一份 per-entry 记录。 + +const FIRES_MODULE = "fires"; + +/** 条目的稳定标识:来源文件 + 时刻 + 名字。名字缺省时回退到 sender。 */ +export function fireKey( + entry: Pick & + Partial>, +): string { + const time = `${pad2(entry.hour)}:${pad2(entry.minute)}:${pad2(entry.second)}`; + return `${entry.origin ?? ""}|${time}|${entry.label ?? entry.sender}`; +} + +/** 该条目今天是否已经触发过。state 由调用方传入,便于一次重排只读一次文件。 */ +export function hasFiredToday( + state: Record, + entry: Parameters[0], + today: string = dateStr(), +): boolean { + return state[fireKey(entry)] === today; +} + +/** 记下该条目今天已触发,并顺手清掉非今天的键(状态文件不随时间增长)。 */ +export function markFired( + state: Record, + entry: Parameters[0], + today: string = dateStr(), +): Record { + const next: Record = {}; + for (const [k, v] of Object.entries(state)) { + if (v === today) next[k] = v; + } + next[fireKey(entry)] = today; + return next; +} + // ── Schedule ──────────────────────────────────────────────────────────────── const MISSED_WINDOW_MS = 2 * 60 * 60 * 1000; +// 刚过点的宽限:排期时刻本身可能正好压在任务时刻上(典型:午夜重排在 00:00 跑, +// 把 00:00 的格子算成"已经过去"),导致该时段任务永远触发不到。 +const FIRE_GRACE_MS = 90 * 1000; function scheduleOrigin(origin: string, entries: ResolvedEntry[], server: Server): number { const now = Date.now(); const today = dateStr(); + const fires = loadState(FIRES_MODULE); const timers: ReturnType[] = []; let count = 0; const missed: { label: string; time: string }[] = []; @@ -363,7 +409,21 @@ function scheduleOrigin(origin: string, entries: ResolvedEntry[], server: Server if (delay > 0) { timers.push(setTimeout(() => fire(entry, server), delay)); count++; - } else if (delay > -MISSED_WINDOW_MS && !entry.one_shot) { + } else if (delay > -FIRE_GRACE_MS && !entry.one_shot && shouldFire(entry)) { + // 刚过点,还在宽限内 → 立即补触发,不算错过。 + // 没有这一支时,排在 00:00 的任务会被午夜重排自己判成已过去,永远跑不到。 + timers.push(setTimeout(() => fire(entry, server), 1000)); + count++; + } else if ( + delay > -MISSED_WINDOW_MS && + !entry.one_shot && + shouldFire(entry) && + !hasFiredToday(fires, entry, today) + ) { + // shouldFire 必须在这里再查一次:canScheduleToday 只看 start_date/end_date, + // 不看 weekdays/days/months,否则"每周日"的任务会在周六被报成今天错过。 + // hasFiredToday 是第二道闸:跑过就不是错过,否则任务时刻后 2h 内的任何一次 + // 重排都会把已完成的任务重报一遍(见上方「已触发记录」段的说明)。 missed.push({ label: entry.label ?? entry.sender, time: timeStr(entry.hour, entry.minute),