From 9acbbd805cceccf834efb052f86e4e8011201dc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 10:28:17 -0700 Subject: [PATCH] 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 --- src/cron/schedule.test.ts | 278 +++++++++++++++++- src/cron/schedule.ts | 248 ++++++++++++++-- .../service.restart-catchup-subsecond.test.ts | 51 ++++ 3 files changed, 558 insertions(+), 19 deletions(-) diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 3fa20a07edbe..1576c95b2da1 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -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(() => diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 34357102548b..74412b0dc8eb 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -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(); +const cronTimezoneFormatters = new WeakMap(); 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. */ diff --git a/src/cron/service.restart-catchup-subsecond.test.ts b/src/cron/service.restart-catchup-subsecond.test.ts index 0c95024f4efd..35bd21370952 100644 --- a/src/cron/service.restart-catchup-subsecond.test.ts +++ b/src/cron/service.restart-catchup-subsecond.test.ts @@ -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(); + } + }); });