fix(cron): anchor every jobs to lastRunAtMs to prevent drift

Fixes #1972 - nextRunAtMs was recalculating from current time on
updates/restarts, causing interval jobs to drift forward.

For 'every' schedule jobs, now uses lastRunAtMs as anchor when available,
falling back to anchorMs then nowMs. Cron expressions unchanged.

🤖 Generated with Clawdbot

Co-Authored-By: Clawd <noreply@anthropic.com>
This commit is contained in:
Trenton Sadrakula 2026-01-25 17:25:39 -06:00
parent a1af1bd996
commit c2e9bfb864
2 changed files with 79 additions and 0 deletions

View File

@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { CronJob } from "../types.js";
import { computeJobNextRunAtMs } from "./jobs.js";
function makeEveryJob(overrides: Partial<CronJob> = {}): CronJob {
return {
id: "test-job",
enabled: true,
createdAtMs: 1000,
updatedAtMs: 1000,
schedule: { kind: "every", everyMs: 3600_000 }, // 1 hour
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "test" },
state: {},
...overrides,
};
}
describe("computeJobNextRunAtMs", () => {
it("anchors to lastRunAtMs for every jobs that have run", () => {
const job = makeEveryJob({
state: { lastRunAtMs: 1000 },
});
const nowMs = 5000;
const next = computeJobNextRunAtMs(job, nowMs);
// Should be lastRunAtMs + everyMs, NOT nowMs + everyMs
expect(next).toBe(1000 + 3600_000);
});
it("falls back to anchorMs when no lastRunAtMs", () => {
const job = makeEveryJob({
schedule: { kind: "every", everyMs: 3600_000, anchorMs: 500 },
state: {}, // No lastRunAtMs
});
const next = computeJobNextRunAtMs(job, 1000);
expect(next).toBe(500 + 3600_000);
});
it("falls back to nowMs when neither lastRunAtMs nor anchorMs exist", () => {
const job = makeEveryJob({
schedule: { kind: "every", everyMs: 3600_000 }, // No anchorMs
state: {}, // No lastRunAtMs
});
const next = computeJobNextRunAtMs(job, 1000);
expect(next).toBe(1000 + 3600_000);
});
it("does not affect cron expression schedules", () => {
const job: CronJob = {
id: "test-job",
enabled: true,
createdAtMs: 1000,
updatedAtMs: 1000,
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, // Daily at 9am UTC
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "test" },
state: { lastRunAtMs: 500 }, // Should be ignored for cron expr
};
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
const next = computeJobNextRunAtMs(job, nowMs);
// Should be next 9am UTC, not anchored to lastRunAtMs
expect(next).toBe(Date.parse("2025-12-13T09:00:00.000Z"));
});
it("returns undefined for disabled jobs", () => {
const job = makeEveryJob({ enabled: false });
const next = computeJobNextRunAtMs(job, 1000);
expect(next).toBeUndefined();
});
});

View File

@ -40,6 +40,12 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) return undefined;
return job.schedule.atMs;
}
// For "every" jobs, anchor to lastRunAtMs to prevent drift on updates/restarts
if (job.schedule.kind === "every") {
const anchor = job.state.lastRunAtMs ?? job.schedule.anchorMs ?? nowMs;
return computeNextRunAtMs({ ...job.schedule, anchorMs: anchor }, nowMs);
}
// "cron" expressions are clock-based
return computeNextRunAtMs(job.schedule, nowMs);
}