From d1890f2b696c6882ba98d96160962e5cbd630dad Mon Sep 17 00:00:00 2001 From: mbelinky Date: Mon, 13 Apr 2026 19:42:53 +0200 Subject: [PATCH] test(cron): add unresolved next-run repro --- ...ce.issue-66019-unresolved-next-run.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/cron/service.issue-66019-unresolved-next-run.test.ts diff --git a/src/cron/service.issue-66019-unresolved-next-run.test.ts b/src/cron/service.issue-66019-unresolved-next-run.test.ts new file mode 100644 index 00000000000..18f447c7cbe --- /dev/null +++ b/src/cron/service.issue-66019-unresolved-next-run.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createDefaultIsolatedRunner, + createIsolatedRegressionJob, + noopLogger, + setupCronRegressionFixtures, + writeCronJobs, +} from "../../test/helpers/cron/service-regression-fixtures.js"; +import * as schedule from "./schedule.js"; +import { createCronServiceState } from "./service/state.js"; +import { onTimer } from "./service/timer.js"; + +const issue66019Fixtures = setupCronRegressionFixtures({ prefix: "cron-66019-" }); + +describe("#66019 unresolved next-run repro", () => { + it("does not refire a recurring cron job 2s later when next-run resolution returns undefined", async () => { + const store = issue66019Fixtures.makeStorePath(); + const scheduledAt = Date.parse("2026-04-13T15:40:00.000Z"); + let now = scheduledAt; + + const cronJob = createIsolatedRegressionJob({ + id: "cron-66019-minimal-success", + name: "cron-66019-minimal-success", + scheduledAt, + schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" }, + payload: { kind: "agentTurn", message: "ping" }, + state: { nextRunAtMs: scheduledAt - 1_000 }, + }); + await writeCronJobs(store.storePath, [cronJob]); + + const runIsolatedAgentJob = createDefaultIsolatedRunner(); + const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined); + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + }); + + try { + await onTimer(state); + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined(); + + // Before the fix, applyJobResult would synthesize endedAt + 2_000 here, + // so a second tick a couple seconds later would refire the same job. + now = scheduledAt + 2_001; + await onTimer(state); + + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined(); + } finally { + nextRunSpy.mockRestore(); + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + } + }); + + it("does not refire a recurring errored cron job after the first backoff window when next-run resolution returns undefined", async () => { + const store = issue66019Fixtures.makeStorePath(); + const scheduledAt = Date.parse("2026-04-13T15:45:00.000Z"); + let now = scheduledAt; + + const cronJob = createIsolatedRegressionJob({ + id: "cron-66019-minimal-error", + name: "cron-66019-minimal-error", + scheduledAt, + schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" }, + payload: { kind: "agentTurn", message: "ping" }, + state: { nextRunAtMs: scheduledAt - 1_000 }, + }); + await writeCronJobs(store.storePath, [cronJob]); + + const runIsolatedAgentJob = vi.fn().mockResolvedValue({ + status: "error", + error: "synthetic failure", + }); + const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined); + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + }); + + try { + await onTimer(state); + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined(); + + // Before the fix, the error branch would synthesize the first backoff + // retry (30s), so the next tick after that window would rerun the job. + now = scheduledAt + 30_001; + await onTimer(state); + + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined(); + } finally { + nextRunSpy.mockRestore(); + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + } + }); +});