fix(cron): preserve scheduling across historical timezone transitions (#118712)

* fix(cron): normalize timezone transitions and historical DST schedules

* fix(cron): validate timezone transitions in scheduler retry paths
This commit is contained in:
Peter Steinberger
2026-08-03 10:28:17 -07:00
committed by GitHub
parent 206a784961
commit 9acbbd805c
3 changed files with 558 additions and 19 deletions
+277 -1
View File
@@ -1,5 +1,6 @@
// Cron schedule tests cover schedule parsing and next-run calculations.
import { beforeEach, describe, expect, it } from "vitest";
import { Cron } from "croner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
coerceFiniteScheduleNumber,
computeNextRunAtMs,
@@ -49,6 +50,281 @@ describe("cron schedule", () => {
expect(new Date(next ?? 0).getUTCFullYear()).toBe(2026);
});
describe("daylight-saving transitions", () => {
it.each([
{
label: "New York repeated hour retains the later same-day reminder",
timezone: "America/New_York",
expression: "30 1,3 * * *",
now: "2026-11-01T06:15:00.000Z",
next: "2026-11-01T08:30:00.000Z",
previous: "2026-11-01T05:30:00.000Z",
},
{
label: "New York repeated hour retains six-field reminders",
timezone: "America/New_York",
expression: "15 30 1,3 * * *",
now: "2026-11-01T06:15:00.000Z",
next: "2026-11-01T08:30:15.000Z",
previous: "2026-11-01T05:30:15.000Z",
},
{
label: "New York repeated hour skips duplicate per-minute occurrences",
timezone: "America/New_York",
expression: "* * * * *",
now: "2026-11-01T06:15:00.000Z",
next: "2026-11-01T07:00:00.000Z",
previous: "2026-11-01T05:59:00.000Z",
},
{
label: "New York repeated hour skips duplicate per-second occurrences",
timezone: "America/New_York",
expression: "* * * * * *",
now: "2026-11-01T06:15:00.000Z",
next: "2026-11-01T07:00:00.000Z",
previous: "2026-11-01T05:59:59.000Z",
},
{
label: "Oslo repeated hour retains the later same-day reminder",
timezone: "Europe/Oslo",
expression: "30 2,4 * * *",
now: "2026-10-25T01:15:00.000Z",
next: "2026-10-25T03:30:00.000Z",
previous: "2026-10-25T00:30:00.000Z",
},
{
label: "Lord Howe schedules the first occurrence of a half-hour fold",
timezone: "Australia/Lord_Howe",
expression: "45 1 * * *",
now: "2026-04-04T14:40:00.000Z",
next: "2026-04-04T14:45:00.000Z",
previous: "2026-04-03T14:45:00.000Z",
},
{
label: "Lord Howe repeated half-hour retains the later same-day reminder",
timezone: "Australia/Lord_Howe",
expression: "45 1,2 * * *",
now: "2026-04-04T15:10:00.000Z",
next: "2026-04-04T16:15:00.000Z",
previous: "2026-04-04T14:45:00.000Z",
},
{
label: "Lord Howe repeated half-hour skips duplicate per-second occurrences",
timezone: "Australia/Lord_Howe",
expression: "* * * * * *",
now: "2026-04-04T15:10:00.000Z",
next: "2026-04-04T15:30:00.000Z",
previous: "2026-04-04T14:59:59.000Z",
},
{
label: "New York skips a nonexistent spring-forward reminder",
timezone: "America/New_York",
expression: "30 2 * * *",
now: "2027-03-14T06:45:00.000Z",
next: "2027-03-15T06:30:00.000Z",
previous: "2027-03-13T07:30:00.000Z",
},
{
label: "New York previous occurrence never comes from its spring-forward gap",
timezone: "America/New_York",
expression: "30 2 * * *",
now: "2027-03-14T07:10:00.000Z",
next: "2027-03-15T06:30:00.000Z",
previous: "2027-03-13T07:30:00.000Z",
},
{
label: "New York preserves the final valid second before a spring-forward gap",
timezone: "America/New_York",
expression: "59 59 1,2 * * *",
now: "2027-03-14T07:05:00.000Z",
next: "2027-03-15T05:59:59.000Z",
previous: "2027-03-14T06:59:59.000Z",
},
{
label: "Oslo skips a nonexistent spring-forward reminder",
timezone: "Europe/Oslo",
expression: "30 2 * * *",
now: "2026-03-29T00:45:00.000Z",
next: "2026-03-30T00:30:00.000Z",
previous: "2026-03-28T01:30:00.000Z",
},
{
label: "Lord Howe keeps the valid boundary after its half-hour spring gap",
timezone: "Australia/Lord_Howe",
expression: "15,30 2 * * *",
now: "2026-10-03T15:20:00.000Z",
next: "2026-10-03T15:30:00.000Z",
previous: "2026-10-02T16:00:00.000Z",
},
{
label: "Lord Howe keeps a valid later occurrence after its half-hour spring gap",
timezone: "Australia/Lord_Howe",
expression: "15,35 2 * * *",
now: "2026-10-03T15:20:00.000Z",
next: "2026-10-03T15:35:00.000Z",
previous: "2026-10-02T16:05:00.000Z",
},
{
label: "Lord Howe previous occurrence never comes from its half-hour spring gap",
timezone: "Australia/Lord_Howe",
expression: "15 2 * * *",
now: "2026-10-03T15:32:00.000Z",
next: "2026-10-04T15:15:00.000Z",
previous: "2026-10-02T15:45:00.000Z",
},
{
label: "Antarctica schedules the first occurrence of a two-hour fold",
timezone: "Antarctica/Troll",
expression: "0 2 * * *",
now: "2026-10-24T23:30:00.000Z",
next: "2026-10-25T00:00:00.000Z",
previous: "2026-10-24T00:00:00.000Z",
},
{
label: "Antarctica skips every repeated occurrence in a two-hour fold",
timezone: "Antarctica/Troll",
expression: "0 1,2,4 * * *",
now: "2026-10-25T01:30:00.000Z",
next: "2026-10-25T04:00:00.000Z",
previous: "2026-10-25T00:00:00.000Z",
},
{
label: "Antarctica skips duplicate per-second occurrences in a two-hour fold",
timezone: "Antarctica/Troll",
expression: "* * * * * *",
now: "2026-10-25T01:30:00.250Z",
next: "2026-10-25T03:00:00.000Z",
previous: "2026-10-25T00:59:59.000Z",
},
{
label: "Antarctica keeps the valid reminder after its two-hour spring gap",
timezone: "Antarctica/Troll",
expression: "30 2,3 * * *",
now: "2026-03-29T00:30:00.000Z",
next: "2026-03-29T01:30:00.000Z",
previous: "2026-03-28T03:30:00.000Z",
},
{
label: "New York skips a nonexistent annual reminder after multiple offset changes",
timezone: "America/New_York",
expression: "30 2 14 3 *",
now: "2026-07-01T00:00:00.000Z",
next: "2028-03-14T06:30:00.000Z",
previous: "2026-03-14T06:30:00.000Z",
},
{
label: "New York skips a nonexistent historical annual reminder",
timezone: "America/New_York",
expression: "30 2 8 3 *",
now: "2026-07-01T00:00:00.000Z",
next: "2027-03-08T07:30:00.000Z",
previous: "2025-03-08T07:30:00.000Z",
},
{
label: "Moscow keeps valid annual reminders beyond multiple historical rule changes",
timezone: "Europe/Moscow",
expression: "30 2 * 3 SUN#L",
now: "2007-03-01T00:00:00.000Z",
next: "2012-03-24T22:30:00.000Z",
previous: "1991-03-30T23:30:00.000Z",
},
])("$label", ({ timezone, expression, now, next, previous }) => {
const schedule = { kind: "cron" as const, expr: expression, tz: timezone };
const nowMs = Date.parse(now);
expect(computeNextRunAtMs(schedule, nowMs)).toBe(Date.parse(next));
expect(computePreviousRunAtMs(schedule, nowMs)).toBe(Date.parse(previous));
});
it.each(["30 2 * 3 SUN#2", "0 30 2 * 3 SUN#2"])(
"skips nonexistent future occurrences but preserves historical timezone rules for %s",
(expression) => {
const schedule = { kind: "cron" as const, expr: expression, tz: "America/New_York" };
const nowMs = Date.parse("2026-01-01T00:00:00.000Z");
expect(computeNextRunAtMs(schedule, nowMs)).toBeUndefined();
expect(computePreviousRunAtMs(schedule, nowMs)).toBe(
Date.parse("2006-03-12T07:30:00.000Z"),
);
},
);
it("never spills a nonexistent year-limited reminder into another year", () => {
const schedule = {
kind: "cron" as const,
expr: "0 30 2 14 3 * 2027",
tz: "America/New_York",
};
const nowMs = Date.parse("2026-07-01T00:00:00.000Z");
expect(computeNextRunAtMs(schedule, nowMs)).toBeUndefined();
expect(computePreviousRunAtMs(schedule, nowMs)).toBeUndefined();
});
it("recovers the first occurrence of a year-limited reminder during its repeated hour", () => {
const schedule = {
kind: "cron" as const,
expr: "0 30 1 1 11 * 2026",
tz: "America/New_York",
};
const nowMs = Date.parse("2026-11-01T06:15:00.000Z");
expect(computeNextRunAtMs(schedule, nowMs)).toBeUndefined();
expect(computePreviousRunAtMs(schedule, nowMs)).toBe(Date.parse("2026-11-01T05:30:00.000Z"));
});
it.each([
{
label: "next-second retry rejects a real spring-forward gap",
timezone: "America/New_York",
expression: "30 2 * * *",
now: "2027-03-14T06:45:00.000Z",
prior: "2027-03-13T07:30:00.000Z",
forcedPastCalls: 1,
expected: "2027-03-15T06:30:00.000Z",
},
{
label: "next-second retry selects the first real half-hour fold",
timezone: "Australia/Lord_Howe",
expression: "45 1 * * *",
now: "2026-04-04T14:40:00.000Z",
prior: "2026-04-03T14:45:00.000Z",
forcedPastCalls: 1,
expected: "2026-04-04T14:45:00.000Z",
},
{
label: "tomorrow retry rejects a real spring-forward gap",
timezone: "America/New_York",
expression: "30 2 * * *",
now: "2027-03-13T23:45:00.000Z",
prior: "2027-03-13T07:30:00.000Z",
forcedPastCalls: 2,
expected: "2027-03-15T06:30:00.000Z",
},
{
label: "tomorrow retry selects the first real half-hour fold",
timezone: "Australia/Lord_Howe",
expression: "45 1 * * *",
now: "2026-04-03T23:45:00.000Z",
prior: "2026-04-03T14:45:00.000Z",
forcedPastCalls: 2,
expected: "2026-04-04T14:45:00.000Z",
},
])("$label", ({ timezone, expression, now, prior, forcedPastCalls, expected }) => {
const spy = vi.spyOn(Cron.prototype, "nextRun");
for (let count = 0; count < forcedPastCalls; count += 1) {
spy.mockImplementationOnce(() => new Date(prior));
}
try {
expect(
computeNextRunAtMs({ kind: "cron", expr: expression, tz: timezone }, Date.parse(now)),
).toBe(Date.parse(expected));
} finally {
spy.mockRestore();
}
});
});
it("throws a clear error when cron expr is missing at runtime", () => {
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
expect(() =>
+230 -18
View File
@@ -1,6 +1,7 @@
/** Computes at/every/cron schedule timestamps with bounded Croner caching. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { Cron } from "croner";
import { Cron, CronDate } from "croner";
import { parseOffsetlessIsoDateTimeInTimeZone } from "../infra/format-time/parse-offsetless-zoned-datetime.js";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
@@ -9,7 +10,9 @@ import type { CronSchedule } from "./types.js";
export { coerceFiniteScheduleNumber } from "./schedule-number.js";
const CRON_EVAL_CACHE_MAX = 512;
const DAY_MS = 86_400_000;
const cronEvalCache = new Map<string, Cron>();
const cronTimezoneFormatters = new WeakMap<Cron, Intl.DateTimeFormat>();
function resolveCronTimezone(tz?: string) {
const trimmed = normalizeOptionalString(tz) ?? "";
@@ -46,6 +49,166 @@ function resolveCronFromSchedule(schedule: { tz?: string; expr?: unknown }): Cro
return resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
}
function hasNearbyCronTimezoneTransition(
cron: Cron,
timezone: string,
nowMs: number,
candidateMs: number,
): boolean {
let formatter = cronTimezoneFormatters.get(cron);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
timeZoneName: "longOffset",
});
cronTimezoneFormatters.set(cron, formatter);
}
const resolvedFormatter = formatter;
const readOffset = (instantMs: number) =>
resolvedFormatter
.formatToParts(new Date(instantMs))
.find((part) => part.type === "timeZoneName")?.value;
const currentOffset = readOffset(nowMs);
const candidateOffset = readOffset(candidateMs);
return (
currentOffset !== candidateOffset ||
currentOffset !== readOffset(nowMs - DAY_MS) ||
candidateOffset !== readOffset(candidateMs - DAY_MS)
);
}
function resolveCronWallTimeMs(instantMs: number, timezone: string): number {
const local = new CronDate(new Date(instantMs), timezone);
return Date.UTC(
local.year,
local.month,
local.day,
local.hour,
local.minute,
local.second,
local.ms,
);
}
function resolveFirstCronOccurrenceMs(instantMs: number, timezone: string): number | undefined {
const wallTime = new Date(resolveCronWallTimeMs(instantMs, timezone)).toISOString().slice(0, -1);
const resolved = parseOffsetlessIsoDateTimeInTimeZone(wallTime, timezone);
return resolved === null ? undefined : Date.parse(resolved);
}
function matchesCronOccurrence(cron: Cron, instant: Date): boolean {
const matchesOccurrence = cron.match.bind(cron);
return matchesOccurrence(instant);
}
function findCronTimezoneTransitionMs(
firstMs: number,
lastMs: number,
timezone: string,
): number | undefined {
let beforeSeconds = Math.floor(Math.min(firstMs, lastMs) / 1_000);
let afterSeconds = Math.floor(Math.max(firstMs, lastMs) / 1_000);
const previousOffsetMs =
resolveCronWallTimeMs(beforeSeconds * 1_000, timezone) - beforeSeconds * 1_000;
const nextOffsetMs = resolveCronWallTimeMs(afterSeconds * 1_000, timezone) - afterSeconds * 1_000;
if (previousOffsetMs === nextOffsetMs) {
return undefined;
}
while (afterSeconds - beforeSeconds > 1) {
const middleSeconds = Math.floor((beforeSeconds + afterSeconds) / 2);
const middleOffsetMs =
resolveCronWallTimeMs(middleSeconds * 1_000, timezone) - middleSeconds * 1_000;
if (middleOffsetMs === previousOffsetMs) {
beforeSeconds = middleSeconds;
} else {
afterSeconds = middleSeconds;
}
}
return afterSeconds * 1_000;
}
function resolveCronRunAtTransitionMs(
cron: Cron,
transitionMs: number,
timezone: string,
): number | undefined {
let transition = new Date(transitionMs);
// Croner's finite year horizon bounds this search; historical timezone
// policies can make many consecutive annual occurrences nonexistent.
for (;;) {
const candidate = matchesCronOccurrence(cron, transition)
? transition
: cron.nextRun(transition);
if (!candidate) {
return undefined;
}
if (matchesCronOccurrence(cron, candidate)) {
return resolveFirstCronOccurrenceMs(candidate.getTime(), timezone);
}
const candidateMs = candidate.getTime();
const nextTransitionMs = findCronTimezoneTransitionMs(
candidateMs - DAY_MS,
candidateMs,
timezone,
);
if (nextTransitionMs === undefined || nextTransitionMs <= transition.getTime()) {
return undefined;
}
transition = new Date(nextTransitionMs);
}
}
function resolveNextCronOccurrenceMs(
cron: Cron,
nowMs: number,
nextMs: number,
timezone: string,
): number | undefined {
if (!matchesCronOccurrence(cron, new Date(nextMs))) {
// Croner invents an instant for a nonexistent local time; bracket the gap,
// not the request, so annual schedules crossing several transitions work.
const transitionMs = findCronTimezoneTransitionMs(nextMs - DAY_MS, nextMs, timezone);
return transitionMs === undefined
? undefined
: resolveCronRunAtTransitionMs(cron, transitionMs, timezone);
}
const firstOccurrenceMs = resolveFirstCronOccurrenceMs(nextMs, timezone);
if (firstOccurrenceMs === undefined || firstOccurrenceMs > nowMs) {
return firstOccurrenceMs;
}
const firstCurrentOccurrenceMs = resolveFirstCronOccurrenceMs(nowMs, timezone);
const inRepeatedInterval =
firstCurrentOccurrenceMs !== undefined && firstCurrentOccurrenceMs < nowMs;
const transitionStartMs = inRepeatedInterval ? firstCurrentOccurrenceMs : nowMs;
const transitionEndMs = inRepeatedInterval ? nowMs : nextMs;
const overlapMs = inRepeatedInterval
? nowMs - firstCurrentOccurrenceMs
: nextMs - firstOccurrenceMs;
const transitionMs = findCronTimezoneTransitionMs(transitionStartMs, transitionEndMs, timezone);
if (transitionMs === undefined) {
return undefined;
}
// Croner folds only a fixed hour. Use the actual offset change so 30-minute
// and two-hour repeated clocks run once, at their first occurrence.
return resolveCronRunAtTransitionMs(cron, transitionMs + overlapMs, timezone);
}
function resolveValidatedNextCronOccurrenceMs(
cron: Cron,
nowMs: number,
candidateMs: number,
timezone: string,
): number | undefined {
if (candidateMs > nowMs && !hasNearbyCronTimezoneTransition(cron, timezone, nowMs, candidateMs)) {
return candidateMs;
}
const normalizedMs = resolveNextCronOccurrenceMs(cron, nowMs, candidateMs, timezone);
return normalizedMs !== undefined && normalizedMs > nowMs ? normalizedMs : undefined;
}
/** Computes the next scheduled run timestamp after now for at/every/cron schedules. */
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
@@ -87,26 +250,33 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
return undefined;
}
const timezone = resolveCronTimezone(schedule.tz);
const normalizedNextMs = resolveValidatedNextCronOccurrenceMs(cron, nowMs, nextMs, timezone);
if (normalizedNextMs !== undefined) {
return normalizedNextMs;
}
if (nextMs > nowMs) {
return undefined;
}
// Workaround for croner year-rollback bug: some timezone/date combinations
// (e.g. Asia/Shanghai) cause nextRun to return a timestamp in a past year.
// Retry from a later reference point when the returned time is not in the
// future.
if (nextMs <= nowMs) {
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
const retryMs = cron.nextRun(new Date(nextSecondMs))?.getTime();
if (retryMs !== undefined && retryMs > nowMs) {
return retryMs;
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
const retryMs = cron.nextRun(new Date(nextSecondMs))?.getTime();
if (retryMs !== undefined) {
const normalizedRetryMs = resolveValidatedNextCronOccurrenceMs(cron, nowMs, retryMs, timezone);
if (normalizedRetryMs !== undefined) {
return normalizedRetryMs;
}
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
const retry2Ms = cron.nextRun(new Date(tomorrowMs))?.getTime();
if (retry2Ms !== undefined && retry2Ms > nowMs) {
return retry2Ms;
}
return undefined;
}
return nextMs;
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
const retry2Ms = cron.nextRun(new Date(tomorrowMs))?.getTime();
return retry2Ms !== undefined
? resolveValidatedNextCronOccurrenceMs(cron, nowMs, retry2Ms, timezone)
: undefined;
}
/** Computes the previous cron-expression run timestamp before now. */
@@ -118,11 +288,53 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n
if (!cron) {
return undefined;
}
const previousMs = cron.previousRuns(1, new Date(nowMs))[0]?.getTime();
if (previousMs === undefined || previousMs >= nowMs) {
let previousMs = cron.previousRuns(1, new Date(nowMs))[0]?.getTime();
const timezone = resolveCronTimezone(schedule.tz);
if (
previousMs !== undefined &&
previousMs < nowMs &&
!hasNearbyCronTimezoneTransition(cron, timezone, nowMs, previousMs)
) {
return previousMs;
}
const firstCurrentOccurrenceMs = resolveFirstCronOccurrenceMs(nowMs, timezone);
if (firstCurrentOccurrenceMs !== undefined && firstCurrentOccurrenceMs < nowMs) {
const transitionMs = findCronTimezoneTransitionMs(firstCurrentOccurrenceMs, nowMs, timezone);
if (transitionMs !== undefined) {
const overlapEndMs = transitionMs + nowMs - firstCurrentOccurrenceMs;
const candidateMs = cron.previousRuns(1, new Date(overlapEndMs))[0]?.getTime();
if (candidateMs !== undefined) {
previousMs = candidateMs;
}
}
}
if (previousMs === undefined) {
return undefined;
}
return previousMs;
// previousRuns ends at Croner's minimum supported year; never drop valid
// historical occurrences after an arbitrary number of changed DST rules.
while (!matchesCronOccurrence(cron, new Date(previousMs))) {
const transitionMs = findCronTimezoneTransitionMs(previousMs - DAY_MS, previousMs, timezone);
if (transitionMs === undefined) {
return undefined;
}
const beforeTransition = new Date(transitionMs - 1_000);
const candidate = matchesCronOccurrence(cron, beforeTransition)
? beforeTransition
: cron.previousRuns(1, beforeTransition)[0];
const candidateMs = candidate?.getTime();
if (candidateMs === undefined || candidateMs >= previousMs) {
return undefined;
}
previousMs = candidateMs;
}
const normalizedPreviousMs = resolveFirstCronOccurrenceMs(previousMs, timezone);
return normalizedPreviousMs !== undefined && normalizedPreviousMs < nowMs
? normalizedPreviousMs
: undefined;
}
/** Clears the Croner expression cache for deterministic tests. */
@@ -55,4 +55,55 @@ describe("CronService restart catch-up within a cron slot's first second", () =>
await store.cleanup();
}
});
it("replays the first daylight-saving fold after restarting in the repeated hour", async () => {
const restartAtMs = Date.parse("2026-11-01T06:05:00.000Z");
vi.setSystemTime(new Date(restartAtMs));
const store = await makeStorePath();
const runCommandJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
await writeCronStoreSnapshot({
storePath: store.storePath,
jobs: [
{
id: "restart-missed-first-daylight-saving-fold",
name: "first daylight-saving fold",
enabled: true,
createdAtMs: Date.parse("2026-10-01T00:00:00.000Z"),
updatedAtMs: Date.parse("2026-10-31T07:30:00.000Z"),
schedule: { kind: "cron", expr: "30 1,3 * * *", tz: "America/New_York" },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "command", argv: ["echo", "FIRED"] },
state: {
nextRunAtMs: Date.parse("2026-11-01T08:30:00.000Z"),
lastRunAtMs: Date.parse("2026-10-31T07:30:00.000Z"),
lastStatus: "ok",
},
},
],
});
const cron = new CronService({
storePath: store.storePath,
cronEnabled: true,
log: logger,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
runCommandJob: runCommandJob as never,
});
try {
await cron.start();
expect(runCommandJob).toHaveBeenCalledTimes(1);
expect(cron.getJob("restart-missed-first-daylight-saving-fold")?.state).toMatchObject({
lastRunAtMs: restartAtMs,
nextRunAtMs: Date.parse("2026-11-01T08:30:00.000Z"),
});
} finally {
cron.stop();
await store.cleanup();
}
});
});