From bef510b0cefad0dc8115a75f3d5abe99926e18b7 Mon Sep 17 00:00:00 2001 From: AmberCXX Date: Sat, 22 Aug 2026 00:51:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(engine):=20=E9=94=99=E8=BF=87=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=A3=80=E6=B5=8B=E6=BC=8F=E6=9F=A5=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=8C=E4=B8=94=2000:00=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=B0=B8=E8=BF=9C=E8=A7=A6=E5=8F=91=E4=B8=8D=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scheduleOrigin 建定时器时只过 canScheduleToday(仅查 start_date/end_date), 未过 shouldFire,导致两个问题: 1. weekdays/days/months 不参与判定——「每周日」的任务会在周六被 报成「今天错过」 2. delay <= 0 一律落进 missed 分支——午夜重排本身发生在 00:00, 重排那一刻就把 00:00 的格子算成已过去,该时段任务永远跑不到 修法: - 新增 FIRE_GRACE_MS(90s)宽限支,刚过点的任务立即补触发而非报错过 - missed 与宽限两支都补 shouldFire(entry) shouldFire 改为 export 以便测试,并补 3 个回归测试。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016fd3qWDATPyaLxwM8s1Ag9 --- forge-engine/scheduler.test.ts | 24 +++++++++++++++++++++++- forge-engine/scheduler.ts | 14 ++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/forge-engine/scheduler.test.ts b/forge-engine/scheduler.test.ts index 7094723..4305496 100644 --- a/forge-engine/scheduler.test.ts +++ b/forge-engine/scheduler.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { expandRandom, removeScheduleEntryFromFile } from "./scheduler.js"; +import { expandRandom, removeScheduleEntryFromFile, shouldFire } from "./scheduler.js"; const tempDirs: string[] = []; @@ -92,3 +92,25 @@ 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); + }); +}); diff --git a/forge-engine/scheduler.ts b/forge-engine/scheduler.ts index e80e257..d2dbda3 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; @@ -346,6 +346,9 @@ function updateState(sender: string): void { // ── 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(); @@ -363,7 +366,14 @@ 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)) { + // shouldFire 必须在这里再查一次:canScheduleToday 只看 start_date/end_date, + // 不看 weekdays/days/months,否则"每周日"的任务会在周六被报成今天错过。 missed.push({ label: entry.label ?? entry.sender, time: timeStr(entry.hour, entry.minute), From 7f63c1a48e2fbe43dcf3913479d9bcc4c287eff1 Mon Sep 17 00:00:00 2001 From: AmberCXX Date: Sun, 30 Aug 2026 00:28:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(engine):=20=E5=B7=B2=E8=B7=91=E5=AE=8C?= =?UTF-8?q?=E7=9A=84=E4=BB=BB=E5=8A=A1=E8=A2=AB=E9=87=8D=E6=8A=A5=E6=88=90?= =?UTF-8?q?=E3=80=8C=E9=94=99=E8=BF=87=E3=80=8D=E2=80=94=E2=80=94=E9=94=99?= =?UTF-8?q?=E8=BF=87=E5=88=A4=E5=AE=9A=E5=8A=A0=E4=B8=80=E9=81=93=E5=B7=B2?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 错过判定原本只看两样:delay 落在 MISSED_WINDOW_MS(2h) 内,以及 shouldFire 的排期规则。它从不查该条目今天是否已经触发过——fire() 只写 global.json 的 last_fire / last_sender / today_count 这类全局标量,回答不了「这一条今天跑没跑」。 于是任务时刻之后 2 小时内的任何一次重排,都会把已经跑完的任务重新报一遍: 启动、配置热加载的部分重排,以及午夜重排在边界上提前几毫秒跑到前一天时 (此时 22:00 的 delay 约为 -1h59m,正落在窗口里)。2026-08-22 / 08-23 / 08-28 / 08-29 连续复发,每次都要人工判真假,判错的代价是重复推送或漏跑。 改动:新增 per-entry 的已触发记录(forge-state/fires.json) - fireKey(entry) 来源文件 + 时刻 + 名字(缺省回退 sender)作稳定标识 - hasFiredToday(state, entry, today) 纯函数,state 由调用方注入 - markFired(state, entry, today) 返回新 state,并清掉非当天的键,文件不增长 - fire() 成功推送后落一条记录;scheduleOrigin 每次重排只读一次状态文件 - 错过分支追加 !hasFiredToday(...) 真实的漏跑不受影响:engine 当时没跑就没有记录,照常上报。 验证:forge-engine 28 tests 全过(新增 6 条覆盖键稳定性、label 缺省回退、 跨日失效、清理旧键、同日幂等);bunx tsc --noEmit 无错; bun hub-test-harness/harness.ts 8/8;fh hub self-test 8/8。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EWhZ8wD8Ne5yiYaCdL6cjf --- forge-engine/scheduler.test.ts | 54 +++++++++++++++++++++++++++++++++- forge-engine/scheduler.ts | 52 +++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/forge-engine/scheduler.test.ts b/forge-engine/scheduler.test.ts index 4305496..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, shouldFire } from "./scheduler.js"; +import { + expandRandom, + fireKey, + hasFiredToday, + markFired, + removeScheduleEntryFromFile, + shouldFire, +} from "./scheduler.js"; const tempDirs: string[] = []; @@ -114,3 +121,48 @@ describe("shouldFire 时间规则", () => { 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 d2dbda3..fc9e8a2 100644 --- a/forge-engine/scheduler.ts +++ b/forge-engine/scheduler.ts @@ -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,6 +344,47 @@ 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; @@ -353,6 +395,7 @@ 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 }[] = []; @@ -371,9 +414,16 @@ function scheduleOrigin(origin: string, entries: ResolvedEntry[], server: Server // 没有这一支时,排在 00:00 的任务会被午夜重排自己判成已过去,永远跑不到。 timers.push(setTimeout(() => fire(entry, server), 1000)); count++; - } else if (delay > -MISSED_WINDOW_MS && !entry.one_shot && shouldFire(entry)) { + } 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),