diff --git a/src/cron/service.external-reload-schedule-recompute.test.ts b/src/cron/service.external-reload-schedule-recompute.test.ts index cc887dfc89b..95bed250642 100644 --- a/src/cron/service.external-reload-schedule-recompute.test.ts +++ b/src/cron/service.external-reload-schedule-recompute.test.ts @@ -247,4 +247,59 @@ describe("forceReload repairs externally changed cron schedules", () => { recomputeNextRunsForMaintenance(state); expect(state.store?.jobs[0]?.state.scheduleErrorCount).toBe(2); }); + + it("preserves the one-shot skip across a second forceReload before maintenance recompute", async () => { + const store = await makeStorePath(); + const nowMs = Date.parse("2026-03-19T01:44:00.000Z"); + const jobId = "external-invalid-schedule-second-reload"; + + const createJob = (expr: string): CronJob => ({ + id: jobId, + name: "external invalid schedule second reload", + enabled: true, + createdAtMs: Date.parse("2026-03-18T00:30:00.000Z"), + updatedAtMs: Date.parse("2026-03-19T01:44:00.000Z"), + schedule: { kind: "cron", expr, tz: "Asia/Shanghai", staggerMs: 0 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "tick" }, + state: { + nextRunAtMs: Date.parse("2026-03-20T00:30:00.000Z"), + }, + }); + + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [createJob("30 8 * * *")], + }); + + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => nowMs, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + await ensureLoaded(state, { skipRecompute: true }); + + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [createJob("not a valid cron")], + }); + + await ensureLoaded(state, { forceReload: true, skipRecompute: true }); + expect(state.store?.jobs[0]?.state.scheduleErrorCount).toBe(1); + + await ensureLoaded(state, { forceReload: true, skipRecompute: true }); + expect(state.store?.jobs[0]?.state.scheduleErrorCount).toBe(1); + + recomputeNextRunsForMaintenance(state); + expect(state.store?.jobs[0]?.state.scheduleErrorCount).toBe(1); + + recomputeNextRunsForMaintenance(state); + expect(state.store?.jobs[0]?.state.scheduleErrorCount).toBe(2); + }); }); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 640f2ae9516..470b7178522 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -40,10 +40,17 @@ function repairNextRunsAfterExternalReload(params: { }): boolean { const { state, previousJobs } = params; const skipRecomputeJobIds = getSkipNextReloadRepairRecomputeJobIds(state); - skipRecomputeJobIds.clear(); if (!state.store || !previousJobs?.length) { return false; } + if (skipRecomputeJobIds.size > 0) { + const currentJobIds = new Set(state.store.jobs.map((job) => job.id)); + for (const jobId of skipRecomputeJobIds) { + if (!currentJobIds.has(jobId)) { + skipRecomputeJobIds.delete(jobId); + } + } + } const previousById = new Map(previousJobs.map((job) => [job.id, job])); const now = state.deps.nowMs();