From 44e912eccd0976ec31ac56d4144a0c470b913dda Mon Sep 17 00:00:00 2001 From: create Date: Fri, 20 Mar 2026 18:23:34 +0800 Subject: [PATCH] fix(cron): keep repaired schedule error state after force-run reload --- ...external-reload-schedule-recompute.test.ts | 89 +++++++++++++++++++ src/cron/service/ops.ts | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/cron/service.external-reload-schedule-recompute.test.ts b/src/cron/service.external-reload-schedule-recompute.test.ts index d8378dab51d..88e3b0dd821 100644 --- a/src/cron/service.external-reload-schedule-recompute.test.ts +++ b/src/cron/service.external-reload-schedule-recompute.test.ts @@ -397,6 +397,95 @@ describe("forceReload repairs externally changed cron schedules", () => { expect(persistedJob?.state?.lastStatus).toBe("ok"); }); + it("keeps scheduleErrorCount cleared when external reload fixes schedule during force-run", async () => { + const store = await makeStorePath(); + let nowMs = Date.parse("2026-03-19T01:44:00.000Z"); + const jobId = "manual-run-reload-clears-schedule-error-count"; + const staleNextRunAtMs = Date.parse("2026-03-19T23:30:00.000Z"); + + const createJob = (params: { + expr: string; + scheduleErrorCount?: number; + lastError?: string; + nextRunAtMs?: number; + }): CronJob => ({ + id: jobId, + name: "manual run reload clears schedule error count", + enabled: true, + createdAtMs: Date.parse("2026-03-18T00:30:00.000Z"), + updatedAtMs: Date.parse("2026-03-19T01:44:00.000Z"), + schedule: { kind: "cron", expr: params.expr, tz: "Asia/Shanghai", staggerMs: 0 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "tick" }, + state: { + nextRunAtMs: params.nextRunAtMs, + scheduleErrorCount: params.scheduleErrorCount, + lastError: params.lastError, + }, + }); + + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [ + createJob({ + expr: "30 23 * * *", + nextRunAtMs: staleNextRunAtMs, + scheduleErrorCount: 2, + lastError: "cron: invalid expression", + }), + ], + }); + + const runIsolatedAgentJob = vi.fn(async () => { + await writeCronStoreSnapshot({ + storePath: store.storePath, + jobs: [ + createJob({ + expr: "30 8 * * *", + nextRunAtMs: staleNextRunAtMs, + scheduleErrorCount: 2, + lastError: "cron: invalid expression", + }), + ], + }); + nowMs += 500; + return { status: "ok" as const, summary: "done" }; + }); + + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => nowMs, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + }); + + const result = await run(state, jobId, "force"); + expect(result).toEqual({ ok: true, ran: true }); + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + + const merged = state.store?.jobs.find((job) => job.id === jobId); + expect(merged?.schedule).toEqual({ + kind: "cron", + expr: "30 8 * * *", + tz: "Asia/Shanghai", + staggerMs: 0, + }); + expect(merged?.state.scheduleErrorCount).toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(store.storePath, "utf8")) as { + jobs?: Array<{ + id: string; + state?: { scheduleErrorCount?: number }; + }>; + }; + const persistedJob = persisted.jobs?.find((job) => job.id === jobId); + expect(persistedJob?.state?.scheduleErrorCount).toBeUndefined(); + }); + it("keeps one-shot terminal disable state when manual force-run reloads unchanged store", async () => { const store = await makeStorePath(); let nowMs = Date.parse("2026-03-19T01:44:00.000Z"); diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index a59dd2c2251..bc120b8258d 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -99,8 +99,8 @@ function mergeManualRunSnapshotAfterReload(params: { if (externalScheduleOrEnabledChanged) { reloaded.enabled = preservedEnabled; reloaded.state.nextRunAtMs = preservedNextRunAtMs; + reloaded.state.scheduleErrorCount = preservedScheduleErrorCount; if (preservedScheduleErrorCount !== undefined) { - reloaded.state.scheduleErrorCount = preservedScheduleErrorCount; reloaded.state.lastError = preservedScheduleErrorText; } } else {