From c773d8cd8ef8aa2075810cc6182f1b436bcdf294 Mon Sep 17 00:00:00 2001 From: ZengWen-DT Date: Sat, 13 Jun 2026 22:49:48 +0800 Subject: [PATCH] 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 341e807d7a30c5b15b3714f6292d06ac41e1a798. --- src/auto-reply/reply/get-reply-run.ts | 1 + src/auto-reply/reply/session-system-events.ts | 24 +++++++-- src/auto-reply/reply/session.test.ts | 50 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 7a6ff9b192e8..57901cb0ab1b 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -813,6 +813,7 @@ export async function runPreparedReply( sessionKey, isMainSession, isNewSession, + suppressHeartbeatOwnedEvents: isHeartbeat, }); if (eventsBlock) { drainedSystemEventBlocks.push(eventsBlock); diff --git a/src/auto-reply/reply/session-system-events.ts b/src/auto-reply/reply/session-system-events.ts index 028b9a0599f8..ac5ea9a700a4 100644 --- a/src/auto-reply/reply/session-system-events.ts +++ b/src/auto-reply/reply/session-system-events.ts @@ -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 { 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); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index b9e7adee99e2..79540f09f0d9 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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:` + // 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", () => {