fix(cron): run explicit wakes outside heartbeat hours (#105830)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
SymbolStar
2026-07-19 04:10:27 +08:00
committed by GitHub
parent ef7f4f2a61
commit a7e47653fc
2 changed files with 43 additions and 5 deletions

View File

@@ -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<ReturnType<typeof runHeartbeatOnce>>;
sendTelegram: ReturnType<typeof vi.fn>;
@@ -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-",

View File

@@ -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,
});