From e1fe71872c33760bd7e82634e2d78c6bbfc7c3d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Apr 2026 01:05:15 +0100 Subject: [PATCH] test: share unresolved cron next-run fixtures --- ...ce.issue-66019-unresolved-next-run.test.ts | 133 ++++++++++-------- 1 file changed, 73 insertions(+), 60 deletions(-) diff --git a/src/cron/service.issue-66019-unresolved-next-run.test.ts b/src/cron/service.issue-66019-unresolved-next-run.test.ts index 933e1c3cad7..6927e47e373 100644 --- a/src/cron/service.issue-66019-unresolved-next-run.test.ts +++ b/src/cron/service.issue-66019-unresolved-next-run.test.ts @@ -12,52 +12,89 @@ import { onTimer } from "./service/timer.js"; const issue66019Fixtures = setupCronRegressionFixtures({ prefix: "cron-66019-" }); +function createIssue66019Job(params: { id: string; scheduledAt: number }) { + return createIsolatedRegressionJob({ + id: params.id, + name: params.id, + scheduledAt: params.scheduledAt, + schedule: { kind: "cron", expr: "0 7 * * *", tz: "Asia/Shanghai" }, + payload: { kind: "agentTurn", message: "ping" }, + state: { nextRunAtMs: params.scheduledAt - 1_000 }, + }); +} + +function createIssue66019State(params: { + storePath: string; + nowMs: () => number; + runIsolatedAgentJob: Parameters[0]["runIsolatedAgentJob"]; +}) { + return createCronServiceState({ + cronEnabled: true, + storePath: params.storePath, + log: noopLogger, + nowMs: params.nowMs, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: params.runIsolatedAgentJob, + }); +} + +function clearCronTimer(state: ReturnType) { + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } +} + +async function expectJobDoesNotRefireWhenNextRunIsUnresolved(params: { + state: ReturnType; + runIsolatedAgentJob: unknown; + advanceNow: () => void; +}) { + await onTimer(params.state); + expect(params.runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(params.state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined(); + + params.advanceNow(); + await onTimer(params.state); + + expect(params.runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(params.state.store?.jobs[0]?.state.nextRunAtMs).toBeUndefined(); +} + 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({ + const cronJob = createIssue66019Job({ 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, + const state = createIssue66019State({ 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(); + await expectJobDoesNotRefireWhenNextRunIsUnresolved({ + state, + runIsolatedAgentJob, + advanceNow: () => { + now = scheduledAt + 2_001; + }, + }); } finally { nextRunSpy.mockRestore(); - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } + clearCronTimer(state); } }); @@ -66,13 +103,9 @@ describe("#66019 unresolved next-run repro", () => { const scheduledAt = Date.parse("2026-04-13T15:45:00.000Z"); let now = scheduledAt; - const cronJob = createIsolatedRegressionJob({ + const cronJob = createIssue66019Job({ 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]); @@ -81,34 +114,25 @@ describe("#66019 unresolved next-run repro", () => { error: "synthetic failure", }); const nextRunSpy = vi.spyOn(schedule, "computeNextRunAtMs").mockReturnValue(undefined); - const state = createCronServiceState({ - cronEnabled: true, + const state = createIssue66019State({ 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(); + await expectJobDoesNotRefireWhenNextRunIsUnresolved({ + state, + runIsolatedAgentJob, + advanceNow: () => { + now = scheduledAt + 30_001; + }, + }); } finally { nextRunSpy.mockRestore(); - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } + clearCronTimer(state); } }); @@ -117,13 +141,9 @@ describe("#66019 unresolved next-run repro", () => { const scheduledAt = Date.parse("2026-04-13T15:50:00.000Z"); let now = scheduledAt; - const cronJob = createIsolatedRegressionJob({ + const cronJob = createIssue66019Job({ id: "cron-66019-error-backoff-floor", - name: "cron-66019-error-backoff-floor", 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]); @@ -138,13 +158,9 @@ describe("#66019 unresolved next-run repro", () => { .mockReturnValueOnce(undefined) .mockReturnValueOnce(undefined) .mockReturnValue(naturalNext); - const state = createCronServiceState({ - cronEnabled: true, + const state = createIssue66019State({ storePath: store.storePath, - log: noopLogger, nowMs: () => now, - enqueueSystemEvent: vi.fn(), - requestHeartbeatNow: vi.fn(), runIsolatedAgentJob, }); @@ -162,10 +178,7 @@ describe("#66019 unresolved next-run repro", () => { expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2); } finally { nextRunSpy.mockRestore(); - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } + clearCronTimer(state); } }); });