From f6c2e99f5d6e3f0e97f2c937597cb70b0df955b7 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:02:05 -0600 Subject: [PATCH] Cron: preserve due jobs after manual runs (#23994) --- src/cron/service.issue-regressions.test.ts | 40 ++++++++++++++++++++++ src/cron/service/ops.ts | 5 ++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index ed5d30e62c9..ba7e181db6a 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -561,6 +561,46 @@ describe("Cron issue regressions", () => { await runFinished.promise; // Barrier for final persistence before cleanup. await cron.list({ includeDisabled: true }); + cron.stop(); + }); + + it("does not advance unrelated due jobs after manual cron.run", async () => { + const store = await makeStorePath(); + const nowMs = Date.now(); + const dueNextRunAtMs = nowMs - 1_000; + + await writeCronJobs(store.storePath, [ + createIsolatedRegressionJob({ + id: "manual-target", + name: "manual target", + scheduledAt: nowMs, + schedule: { kind: "at", at: new Date(nowMs + 3_600_000).toISOString() }, + payload: { kind: "agentTurn", message: "manual target" }, + state: { nextRunAtMs: nowMs + 3_600_000 }, + }), + createIsolatedRegressionJob({ + id: "unrelated-due", + name: "unrelated due", + scheduledAt: nowMs, + schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" }, + payload: { kind: "agentTurn", message: "unrelated due" }, + state: { nextRunAtMs: dueNextRunAtMs }, + }), + ]); + + const cron = await startCronForStore({ + storePath: store.storePath, + cronEnabled: false, + runIsolatedAgentJob: createDefaultIsolatedRunner(), + }); + + const runResult = await cron.run("manual-target", "force"); + expect(runResult).toEqual({ ok: true, ran: true }); + + const jobs = await cron.list({ includeDisabled: true }); + const unrelated = jobs.find((entry) => entry.id === "unrelated-due"); + expect(unrelated).toBeDefined(); + expect(unrelated?.state.nextRunAtMs).toBe(dueNextRunAtMs); cron.stop(); }); diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 2fbfeeb3414..68789790207 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -297,7 +297,10 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f emit(state, { jobId: job.id, action: "removed" }); } - recomputeNextRuns(state); + // Manual runs should not advance other due jobs without executing them. + // Use maintenance-only recompute to repair missing values while + // preserving existing past-due nextRunAtMs entries for future timer ticks. + recomputeNextRunsForMaintenance(state); await persist(state); armTimer(state); });