diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 2178cdc0e3dc..6be40e261f5b 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -41,6 +41,7 @@ describe("Ghost reminder bug (issue #13317)", () => { storePath: string; target?: "telegram" | "none"; isolatedSession?: boolean; + activeHours?: boolean; }): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const cfg: OpenClawConfig = { agents: { @@ -50,6 +51,9 @@ describe("Ghost reminder bug (issue #13317)", () => { every: "5m", target: params.target ?? "telegram", ...(params.isolatedSession === true ? { isolatedSession: true } : {}), + ...(params.activeHours === true + ? { activeHours: { start: "08:00", end: "24:00", timezone: "user" as const } } + : {}), }, }, }, @@ -196,6 +200,8 @@ describe("Ghost reminder bug (issue #13317)", () => { owningCronLaneTaskMarker?: CommandLaneTaskMarker; cronLaneDepth?: number; cronNestedLaneDepth?: number; + activeHours?: boolean; + nowMs?: number; }): Promise<{ result: Awaited>; sendTelegram: ReturnType; @@ -215,6 +221,7 @@ describe("Ghost reminder bug (issue #13317)", () => { storePath, target: params.target, isolatedSession: params.isolatedSession, + activeHours: params.activeHours, }); params.enqueue(sessionKey); const owningCronJobMarker = params.owningCronJobId @@ -244,6 +251,7 @@ describe("Ghost reminder bug (issue #13317)", () => { deps: { getReplyFromConfig: getReplySpy, telegram: sendTelegram, + nowMs: () => params.nowMs ?? Date.now(), ...(params.cronLaneDepth === undefined && params.cronNestedLaneDepth === undefined ? {} : { @@ -310,6 +318,30 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(sendTelegram).toHaveBeenCalled(); }); + it("runs the tagged cron payload outside heartbeat active hours", async () => { + const reminderText = "Reminder: Send the overnight report"; + const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase({ + tmpPrefix: "openclaw-cron-quiet-hours-", + replyText: "Overnight report sent", + reason: "cron:overnight-report", + source: "cron", + intent: "immediate", + activeHours: true, + nowMs: Date.UTC(2025, 0, 1, 7, 0, 0), + enqueue: (sessionKey) => { + enqueueSystemEvent(reminderText, { + sessionKey, + contextKey: "cron:overnight-report", + }); + }, + }); + + expect(result.status).toBe("ran"); + expect(replyCallCount).toBe(1); + expectCronEventPrompt(calledCtx, reminderText); + expect(sendTelegram).toHaveBeenCalled(); + }); + it("uses CRON_EVENT_PROMPT when cron events are mixed with heartbeat noise", async () => { const { result, sendTelegram, calledCtx } = await runCronReminderCase( "openclaw-cron-mixed-", diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index f6ca81faa253..25cc7f87c752 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1324,12 +1324,13 @@ export async function runHeartbeatOnce(opts: { const agentId = normalizeAgentId( explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg), ); + const wakeSource = opts.source ?? inferHeartbeatWakeSourceFromReason(opts.reason); const heartbeat = resolveHeartbeatForWake({ cfg, agentId, requestedHeartbeat: opts.heartbeat, - source: opts.source, - mergeRequestedHeartbeat: opts.source === "cron", + source: wakeSource, + mergeRequestedHeartbeat: wakeSource === "cron", }); const runScope = opts.runScope ?? "global"; const allowsUnscheduledTarget = @@ -1345,7 +1346,12 @@ export async function runHeartbeatOnce(opts: { } const startedAt = opts.deps?.nowMs?.() ?? Date.now(); - if (!allowsUnscheduledTarget && !isWithinActiveHours(cfg, heartbeat, startedAt)) { + // Cron uses the heartbeat runner as execution transport; heartbeat scheduling windows do not own it. + if ( + !allowsUnscheduledTarget && + wakeSource !== "cron" && + !isWithinActiveHours(cfg, heartbeat, startedAt) + ) { return { status: "skipped", reason: "quiet-hours" }; } @@ -1445,7 +1451,7 @@ export async function runHeartbeatOnce(opts: { heartbeat, runScope, forcedSessionKey: opts.sessionKey, - source: opts.source, + source: wakeSource, reason: opts.reason, nowMs: startedAt, }); @@ -1965,7 +1971,7 @@ export async function runHeartbeatOnce(opts: { runSessionKey, response: heartbeatToolResponse, taskNames: dueHeartbeatTasks.map((task) => task.name), - wakeSource: opts.source, + wakeSource, wakeReason: opts.reason, occurredAt: startedAt, });