Skip to content

Commit a7e4765

Browse files
SymbolStarsteipete
andauthored
fix(cron): run explicit wakes outside heartbeat hours (#105830)
Co-authored-by: Peter Steinberger <[email protected]>
1 parent ef7f4f2 commit a7e4765

2 files changed

Lines changed: 43 additions & 5 deletions

File tree

src/infra/heartbeat-runner.ghost-reminder.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
4141
storePath: string;
4242
target?: "telegram" | "none";
4343
isolatedSession?: boolean;
44+
activeHours?: boolean;
4445
}): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => {
4546
const cfg: OpenClawConfig = {
4647
agents: {
@@ -50,6 +51,9 @@ describe("Ghost reminder bug (issue #13317)", () => {
5051
every: "5m",
5152
target: params.target ?? "telegram",
5253
...(params.isolatedSession === true ? { isolatedSession: true } : {}),
54+
...(params.activeHours === true
55+
? { activeHours: { start: "08:00", end: "24:00", timezone: "user" as const } }
56+
: {}),
5357
},
5458
},
5559
},
@@ -196,6 +200,8 @@ describe("Ghost reminder bug (issue #13317)", () => {
196200
owningCronLaneTaskMarker?: CommandLaneTaskMarker;
197201
cronLaneDepth?: number;
198202
cronNestedLaneDepth?: number;
203+
activeHours?: boolean;
204+
nowMs?: number;
199205
}): Promise<{
200206
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
201207
sendTelegram: ReturnType<typeof vi.fn>;
@@ -215,6 +221,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
215221
storePath,
216222
target: params.target,
217223
isolatedSession: params.isolatedSession,
224+
activeHours: params.activeHours,
218225
});
219226
params.enqueue(sessionKey);
220227
const owningCronJobMarker = params.owningCronJobId
@@ -244,6 +251,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
244251
deps: {
245252
getReplyFromConfig: getReplySpy,
246253
telegram: sendTelegram,
254+
nowMs: () => params.nowMs ?? Date.now(),
247255
...(params.cronLaneDepth === undefined && params.cronNestedLaneDepth === undefined
248256
? {}
249257
: {
@@ -310,6 +318,30 @@ describe("Ghost reminder bug (issue #13317)", () => {
310318
expect(sendTelegram).toHaveBeenCalled();
311319
});
312320

321+
it("runs the tagged cron payload outside heartbeat active hours", async () => {
322+
const reminderText = "Reminder: Send the overnight report";
323+
const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase({
324+
tmpPrefix: "openclaw-cron-quiet-hours-",
325+
replyText: "Overnight report sent",
326+
reason: "cron:overnight-report",
327+
source: "cron",
328+
intent: "immediate",
329+
activeHours: true,
330+
nowMs: Date.UTC(2025, 0, 1, 7, 0, 0),
331+
enqueue: (sessionKey) => {
332+
enqueueSystemEvent(reminderText, {
333+
sessionKey,
334+
contextKey: "cron:overnight-report",
335+
});
336+
},
337+
});
338+
339+
expect(result.status).toBe("ran");
340+
expect(replyCallCount).toBe(1);
341+
expectCronEventPrompt(calledCtx, reminderText);
342+
expect(sendTelegram).toHaveBeenCalled();
343+
});
344+
313345
it("uses CRON_EVENT_PROMPT when cron events are mixed with heartbeat noise", async () => {
314346
const { result, sendTelegram, calledCtx } = await runCronReminderCase(
315347
"openclaw-cron-mixed-",

src/infra/heartbeat-runner.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,12 +1324,13 @@ export async function runHeartbeatOnce(opts: {
13241324
const agentId = normalizeAgentId(
13251325
explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg),
13261326
);
1327+
const wakeSource = opts.source ?? inferHeartbeatWakeSourceFromReason(opts.reason);
13271328
const heartbeat = resolveHeartbeatForWake({
13281329
cfg,
13291330
agentId,
13301331
requestedHeartbeat: opts.heartbeat,
1331-
source: opts.source,
1332-
mergeRequestedHeartbeat: opts.source === "cron",
1332+
source: wakeSource,
1333+
mergeRequestedHeartbeat: wakeSource === "cron",
13331334
});
13341335
const runScope = opts.runScope ?? "global";
13351336
const allowsUnscheduledTarget =
@@ -1345,7 +1346,12 @@ export async function runHeartbeatOnce(opts: {
13451346
}
13461347

13471348
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
1348-
if (!allowsUnscheduledTarget && !isWithinActiveHours(cfg, heartbeat, startedAt)) {
1349+
// Cron uses the heartbeat runner as execution transport; heartbeat scheduling windows do not own it.
1350+
if (
1351+
!allowsUnscheduledTarget &&
1352+
wakeSource !== "cron" &&
1353+
!isWithinActiveHours(cfg, heartbeat, startedAt)
1354+
) {
13491355
return { status: "skipped", reason: "quiet-hours" };
13501356
}
13511357

@@ -1445,7 +1451,7 @@ export async function runHeartbeatOnce(opts: {
14451451
heartbeat,
14461452
runScope,
14471453
forcedSessionKey: opts.sessionKey,
1448-
source: opts.source,
1454+
source: wakeSource,
14491455
reason: opts.reason,
14501456
nowMs: startedAt,
14511457
});
@@ -1965,7 +1971,7 @@ export async function runHeartbeatOnce(opts: {
19651971
runSessionKey,
19661972
response: heartbeatToolResponse,
19671973
taskNames: dueHeartbeatTasks.map((task) => task.name),
1968-
wakeSource: opts.source,
1974+
wakeSource,
19691975
wakeReason: opts.reason,
19701976
occurredAt: startedAt,
19711977
});

0 commit comments

Comments
 (0)