mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(cron): treat exact-second cron slots as valid in stale-future repair (#81731)
isStaggeredCronRunAtMs probed the cron library at runAtMs + 1 to decide whether a persisted nextRunAtMs was a real schedule slot. Croner-style second-granular schedules normalize that 1ms probe back to the candidate second, so previousRuns(1, runAtMs + 1) returns the slot before the candidate instead of the candidate itself. shouldRepairFutureCronNextRunAtMs then classified valid exact-second slots two-plus intervals out as stale and rebased them. Probe at runAtMs + 1_000 instead so the previous-run lookup lands past the candidate second, matching the +1s cursor step used elsewhere in this file. Fixes #81691 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -1286,6 +1286,45 @@ describe("recomputeNextRuns", () => {
|
||||
expect(job.state.nextRunAtMs).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves exact-second cron slots that fall multiple intervals into the future (#81691)", () => {
|
||||
// Regression for the stale-future repair path. `isStaggeredCronRunAtMs`
|
||||
// used to probe the cron library at `runAtMs + 1` to classify whether the
|
||||
// persisted timestamp was a real scheduled slot. Croner-style second-
|
||||
// granular schedules normalize that 1ms probe back to the candidate's
|
||||
// second, so `previousRuns(1, probe)` returns the slot before the
|
||||
// candidate rather than the slot itself. The slot then looks "stale" and
|
||||
// future-slot repair rebases it, even though it is a perfectly valid
|
||||
// schedule slot two-or-more intervals out.
|
||||
//
|
||||
// The bug only surfaces when nextRun lands two-plus intervals past
|
||||
// `naturalNext`, because the closer cases are already saved by the
|
||||
// `nextRun === naturalNext` / `followingNaturalNext` guards in
|
||||
// shouldRepairFutureCronNextRunAtMs.
|
||||
const now = Date.parse("2026-05-05T12:00:00.000Z");
|
||||
// "0 9 * * *" Pacific/Honolulu (UTC-10) → 19:00 UTC daily.
|
||||
// Honolulu has no DST, so the UTC offset is stable across the window.
|
||||
const exactFutureSlot = Date.parse("2026-05-08T19:00:00.000Z");
|
||||
const job: CronJob = {
|
||||
id: "honolulu-9am-future-slot",
|
||||
name: "honolulu 9am future slot",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-05-01T00:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-05-01T00:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Pacific/Honolulu", staggerMs: 0 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
state: { nextRunAtMs: exactFutureSlot },
|
||||
};
|
||||
const state = {
|
||||
...createMockState(now),
|
||||
store: { version: 1 as const, jobs: [job] },
|
||||
} as CronServiceState;
|
||||
|
||||
expect(recomputeNextRunsForMaintenance(state)).toBe(false);
|
||||
expect(job.state.nextRunAtMs).toBe(exactFutureSlot);
|
||||
});
|
||||
|
||||
it("keeps future nextRunAtMs while probing malformed cron schedules", () => {
|
||||
const now = Date.parse("2026-05-05T12:00:00.000Z");
|
||||
const future = Date.parse("2026-05-12T16:00:00.000Z");
|
||||
|
||||
@@ -179,7 +179,13 @@ function isStaggeredCronRunAtMs(job: CronJob, runAtMs: number): boolean {
|
||||
if (job.schedule.kind !== "cron" || !isFiniteTimestamp(runAtMs)) {
|
||||
return false;
|
||||
}
|
||||
const previous = computeStaggeredCronPreviousRunAtMs(job, runAtMs + 1);
|
||||
// Probe past the candidate second. Croner-style second-granular schedules
|
||||
// normalize a 1ms probe back to the candidate's second, so
|
||||
// `previousRuns(1, runAtMs + 1)` returns the slot before the candidate
|
||||
// rather than the candidate itself and exact-second slots get misclassified
|
||||
// as stale. A 1s probe lands past the candidate second, matching the cursor
|
||||
// step used elsewhere in this file (cf. #81691).
|
||||
const previous = computeStaggeredCronPreviousRunAtMs(job, runAtMs + 1_000);
|
||||
return previous === runAtMs;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user