diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index 4854a058e4d..4a0e356fb2d 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -188,6 +188,9 @@ describe("CronService", () => { storePath: store.storePath, cronEnabled: true, log: noopLogger, + // Perf: avoid advancing fake timers by 2+ minutes for the busy-heartbeat fallback. + wakeNowHeartbeatBusyMaxWaitMs: 1, + wakeNowHeartbeatBusyRetryDelayMs: 2, enqueueSystemEvent, requestHeartbeatNow, runHeartbeatOnce, @@ -229,11 +232,20 @@ describe("CronService", () => { status: "skipped" as const, reason: "requests-in-flight", })); + let now = 0; + const nowMs = () => { + now += 10; + return now; + }; const cron = new CronService({ storePath: store.storePath, cronEnabled: true, log: noopLogger, + nowMs, + // Perf: avoid advancing fake timers by 2+ minutes for the busy-heartbeat fallback. + wakeNowHeartbeatBusyMaxWaitMs: 1, + wakeNowHeartbeatBusyRetryDelayMs: 2, enqueueSystemEvent, requestHeartbeatNow, runHeartbeatOnce, @@ -250,9 +262,7 @@ describe("CronService", () => { payload: { kind: "systemEvent", text: "hello" }, }); - const runPromise = cron.run(job.id, "force"); - await vi.advanceTimersByTimeAsync(125_000); - await runPromise; + await cron.run(job.id, "force"); expect(runHeartbeatOnce).toHaveBeenCalled(); expect(requestHeartbeatNow).toHaveBeenCalled(); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 4dc1fffdf0a..4faa9f277d8 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -38,6 +38,14 @@ export type CronServiceDeps = { enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void; requestHeartbeatNow: (opts?: { reason?: string }) => void; runHeartbeatOnce?: (opts?: { reason?: string; agentId?: string }) => Promise; + /** + * WakeMode=now: max time to wait for runHeartbeatOnce to stop returning + * { status:"skipped", reason:"requests-in-flight" } before falling back to + * requestHeartbeatNow. + */ + wakeNowHeartbeatBusyMaxWaitMs?: number; + /** WakeMode=now: delay between runHeartbeatOnce retries while busy. */ + wakeNowHeartbeatBusyRetryDelayMs?: number; runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{ status: "ok" | "error" | "skipped"; summary?: string; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 913165dcbba..e708a4ea39d 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -442,7 +442,8 @@ async function executeJobCore( if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) { const reason = `cron:${job.id}`; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const maxWaitMs = 2 * 60_000; + const maxWaitMs = state.deps.wakeNowHeartbeatBusyMaxWaitMs ?? 2 * 60_000; + const retryDelayMs = state.deps.wakeNowHeartbeatBusyRetryDelayMs ?? 250; const waitStartedAt = state.deps.nowMs(); let heartbeatResult: HeartbeatRunResult; @@ -458,7 +459,7 @@ async function executeJobCore( state.deps.requestHeartbeatNow({ reason }); return { status: "ok", summary: text }; } - await delay(250); + await delay(retryDelayMs); } if (heartbeatResult.status === "ran") {