diff --git a/src/cron/service.does-not-wake-on-next-heartbeat-main-job.test.ts b/src/cron/service.does-not-wake-on-next-heartbeat-main-job.test.ts new file mode 100644 index 000000000..39bac27db --- /dev/null +++ b/src/cron/service.does-not-wake-on-next-heartbeat-main-job.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CronService } from "./service.js"; + +const noopLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +async function makeStorePath() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-cron-")); + return { + storePath: path.join(dir, "cron", "jobs.json"), + cleanup: async () => { + await fs.rm(dir, { recursive: true, force: true }); + }, + }; +} + +describe("CronService", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); + noopLogger.debug.mockClear(); + noopLogger.info.mockClear(); + noopLogger.warn.mockClear(); + noopLogger.error.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not requestHeartbeatNow for main jobs when wakeMode is next-heartbeat", async () => { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + }); + + await cron.start(); + const atMs = Date.parse("2025-12-13T00:00:02.000Z"); + await cron.add({ + name: "next-heartbeat main job", + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + + vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z")); + await vi.runOnlyPendingTimersAsync(); + + expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", { agentId: undefined }); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + + cron.stop(); + await store.cleanup(); + }); +}); diff --git a/src/cron/service.prevents-duplicate-timers.test.ts b/src/cron/service.prevents-duplicate-timers.test.ts index 9f22450a2..966f2f22b 100644 --- a/src/cron/service.prevents-duplicate-timers.test.ts +++ b/src/cron/service.prevents-duplicate-timers.test.ts @@ -80,7 +80,8 @@ describe("CronService", () => { await cronB.status(); expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); - expect(requestHeartbeatNow).toHaveBeenCalledTimes(1); + // wakeMode=next-heartbeat should not force a heartbeat. + expect(requestHeartbeatNow).toHaveBeenCalledTimes(0); cronA.stop(); cronB.stop(); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 370f5d116..78ec1045f 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -183,8 +183,11 @@ export async function executeJob( await finish("error", heartbeatResult.reason, text); } } else { - // wakeMode is "next-heartbeat" or runHeartbeatOnce not available - state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` }); + // If wakeMode is "now" but runHeartbeatOnce isn't available, request a heartbeat. + // If wakeMode is "next-heartbeat", do NOT force a heartbeat: that's the whole point. + if (job.wakeMode === "now") { + state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` }); + } await finish("ok", undefined, text); } return;