fix(cron): de-duplicate main-session heartbeat events

Fixes #44922

Preserve heartbeat-owned cron reminders as a single model input during heartbeat runs while keeping normal-turn fallback delivery when a heartbeat is skipped.

Proof: focused local Vitest/oxlint/format, clean autoreview, Crabbox AWS run_67abc286250a, Crabbox AWS check:changed run_bddebf014d58, and exact-head GitHub CI green for 341e807d7a.
This commit is contained in:
ZengWen-DT
2026-06-13 22:49:48 +08:00
committed by GitHub
parent eb1b640854
commit c773d8cd8e
3 changed files with 72 additions and 3 deletions
+1
View File
@@ -813,6 +813,7 @@ export async function runPreparedReply(
sessionKey,
isMainSession,
isNewSession,
suppressHeartbeatOwnedEvents: isHeartbeat,
});
if (eventsBlock) {
drainedSystemEventBlocks.push(eventsBlock);
+21 -3
View File
@@ -18,8 +18,23 @@ import {
type SystemEvent,
} from "../../infra/system-events.js";
function selectGenericSystemEvents(events: readonly SystemEvent[]): SystemEvent[] {
return events.filter((event) => !isExecCompletionEvent(event.text));
function isCronContextSystemEvent(event: SystemEvent): boolean {
return event.contextKey?.startsWith("cron:") ?? false;
}
function selectGenericSystemEvents(
events: readonly SystemEvent[],
options?: { suppressHeartbeatOwnedEvents?: boolean },
): SystemEvent[] {
// Exec completions and tagged cron events own dedicated heartbeat prompts
// (buildExecEventPrompt / buildCronEventPrompt). During heartbeat runs, leave
// cron entries queued for that owner; ordinary turns still drain them as the
// fallback when a heartbeat was skipped before it could consume the event.
return events.filter(
(event) =>
!isExecCompletionEvent(event.text) &&
!(options?.suppressHeartbeatOwnedEvents === true && isCronContextSystemEvent(event)),
);
}
function compactSystemEvent(line: string): string | null {
@@ -90,6 +105,7 @@ export async function drainFormattedSystemEvents(params: {
sessionKey: string;
isMainSession: boolean;
isNewSession: boolean;
suppressHeartbeatOwnedEvents?: boolean;
}): Promise<string | undefined> {
const summaryLines: string[] = [];
const systemLines: string[] = [];
@@ -97,7 +113,9 @@ export async function drainFormattedSystemEvents(params: {
// so the heartbeat path can consume and deliver them.
const queued = consumeSelectedSystemEventEntries(
params.sessionKey,
selectGenericSystemEvents(peekSystemEventEntries(params.sessionKey)),
selectGenericSystemEvents(peekSystemEventEntries(params.sessionKey), {
suppressHeartbeatOwnedEvents: params.suppressHeartbeatOwnedEvents,
}),
);
for (const event of queued) {
const compacted = compactSystemEvent(event.text);
+50
View File
@@ -3386,6 +3386,56 @@ describe("drainFormattedSystemEvents", () => {
expect(line).toMatch(/^System:/);
}
});
it("leaves tagged cron events queued during heartbeat runs instead of re-rendering them (#44922)", async () => {
try {
// A `sessionTarget: "main"` cron systemEvent is enqueued tagged `cron:<jobId>`
// and is surfaced by the heartbeat's dedicated reminder prompt. The generic
// render must not also emit it as a raw `System:` line during that heartbeat
// run, or the model sees the same text twice.
enqueueSystemEvent("Reminder: rotate API keys", {
sessionKey: "agent:main:main",
contextKey: "cron:rotate-keys",
});
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
const result = await drainFormattedSystemEvents({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:main",
isMainSession: true,
isNewSession: false,
suppressHeartbeatOwnedEvents: true,
});
expect(result).toContain("Model switched.");
expect(result).not.toContain("rotate API keys");
// The cron event stays queued so the heartbeat path remains its single owner.
expect(peekSystemEvents("agent:main:main")).toEqual(["Reminder: rotate API keys"]);
} finally {
resetSystemEventsForTest();
}
});
it("renders tagged cron events on normal turns so skipped heartbeats still have a fallback", async () => {
try {
enqueueSystemEvent("Reminder: rotate API keys", {
sessionKey: "agent:main:main",
contextKey: "cron:rotate-keys",
});
const result = await drainFormattedSystemEvents({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:main",
isMainSession: true,
isNewSession: false,
});
expect(result).toContain("Reminder: rotate API keys");
expect(peekSystemEvents("agent:main:main")).toEqual([]);
} finally {
resetSystemEventsForTest();
}
});
});
describe("persistSessionUsageUpdate", () => {