Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion forge-engine/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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<string, unknown> = { "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);
});
});
64 changes: 62 additions & 2 deletions forge-engine/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -276,6 +276,7 @@ async function fire(entry: ResolvedEntry, server: Server): Promise<void> {

appendLog(entry, content);
updateState(sender);
saveState(FIRES_MODULE, markFired(loadState(FIRES_MODULE), entry));

// Auto-delete one_shot
if (entry.one_shot) {
Expand Down Expand Up @@ -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<ResolvedEntry, "hour" | "minute" | "second" | "sender"> &
Partial<Pick<ResolvedEntry, "label" | "origin">>,
): 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<string, unknown>,
entry: Parameters<typeof fireKey>[0],
today: string = dateStr(),
): boolean {
return state[fireKey(entry)] === today;
}

/** 记下该条目今天已触发,并顺手清掉非今天的键(状态文件不随时间增长)。 */
export function markFired(
state: Record<string, unknown>,
entry: Parameters<typeof fireKey>[0],
today: string = dateStr(),
): Record<string, unknown> {
const next: Record<string, unknown> = {};
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<typeof setTimeout>[] = [];
let count = 0;
const missed: { label: string; time: string }[] = [];
Expand All @@ -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),
Expand Down
Loading