Skip to content

Commit c773d8c

Browse files
authored
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 341e807.
1 parent eb1b640 commit c773d8c

3 files changed

Lines changed: 72 additions & 3 deletions

File tree

src/auto-reply/reply/get-reply-run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,7 @@ export async function runPreparedReply(
813813
sessionKey,
814814
isMainSession,
815815
isNewSession,
816+
suppressHeartbeatOwnedEvents: isHeartbeat,
816817
});
817818
if (eventsBlock) {
818819
drainedSystemEventBlocks.push(eventsBlock);

src/auto-reply/reply/session-system-events.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,23 @@ import {
1818
type SystemEvent,
1919
} from "../../infra/system-events.js";
2020

21-
function selectGenericSystemEvents(events: readonly SystemEvent[]): SystemEvent[] {
22-
return events.filter((event) => !isExecCompletionEvent(event.text));
21+
function isCronContextSystemEvent(event: SystemEvent): boolean {
22+
return event.contextKey?.startsWith("cron:") ?? false;
23+
}
24+
25+
function selectGenericSystemEvents(
26+
events: readonly SystemEvent[],
27+
options?: { suppressHeartbeatOwnedEvents?: boolean },
28+
): SystemEvent[] {
29+
// Exec completions and tagged cron events own dedicated heartbeat prompts
30+
// (buildExecEventPrompt / buildCronEventPrompt). During heartbeat runs, leave
31+
// cron entries queued for that owner; ordinary turns still drain them as the
32+
// fallback when a heartbeat was skipped before it could consume the event.
33+
return events.filter(
34+
(event) =>
35+
!isExecCompletionEvent(event.text) &&
36+
!(options?.suppressHeartbeatOwnedEvents === true && isCronContextSystemEvent(event)),
37+
);
2338
}
2439

2540
function compactSystemEvent(line: string): string | null {
@@ -90,14 +105,17 @@ export async function drainFormattedSystemEvents(params: {
90105
sessionKey: string;
91106
isMainSession: boolean;
92107
isNewSession: boolean;
108+
suppressHeartbeatOwnedEvents?: boolean;
93109
}): Promise<string | undefined> {
94110
const summaryLines: string[] = [];
95111
const systemLines: string[] = [];
96112
// Exec completions have a dedicated heartbeat prompt; leave those entries queued
97113
// so the heartbeat path can consume and deliver them.
98114
const queued = consumeSelectedSystemEventEntries(
99115
params.sessionKey,
100-
selectGenericSystemEvents(peekSystemEventEntries(params.sessionKey)),
116+
selectGenericSystemEvents(peekSystemEventEntries(params.sessionKey), {
117+
suppressHeartbeatOwnedEvents: params.suppressHeartbeatOwnedEvents,
118+
}),
101119
);
102120
for (const event of queued) {
103121
const compacted = compactSystemEvent(event.text);

src/auto-reply/reply/session.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3386,6 +3386,56 @@ describe("drainFormattedSystemEvents", () => {
33863386
expect(line).toMatch(/^System:/);
33873387
}
33883388
});
3389+
3390+
it("leaves tagged cron events queued during heartbeat runs instead of re-rendering them (#44922)", async () => {
3391+
try {
3392+
// A `sessionTarget: "main"` cron systemEvent is enqueued tagged `cron:<jobId>`
3393+
// and is surfaced by the heartbeat's dedicated reminder prompt. The generic
3394+
// render must not also emit it as a raw `System:` line during that heartbeat
3395+
// run, or the model sees the same text twice.
3396+
enqueueSystemEvent("Reminder: rotate API keys", {
3397+
sessionKey: "agent:main:main",
3398+
contextKey: "cron:rotate-keys",
3399+
});
3400+
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
3401+
3402+
const result = await drainFormattedSystemEvents({
3403+
cfg: {} as OpenClawConfig,
3404+
sessionKey: "agent:main:main",
3405+
isMainSession: true,
3406+
isNewSession: false,
3407+
suppressHeartbeatOwnedEvents: true,
3408+
});
3409+
3410+
expect(result).toContain("Model switched.");
3411+
expect(result).not.toContain("rotate API keys");
3412+
// The cron event stays queued so the heartbeat path remains its single owner.
3413+
expect(peekSystemEvents("agent:main:main")).toEqual(["Reminder: rotate API keys"]);
3414+
} finally {
3415+
resetSystemEventsForTest();
3416+
}
3417+
});
3418+
3419+
it("renders tagged cron events on normal turns so skipped heartbeats still have a fallback", async () => {
3420+
try {
3421+
enqueueSystemEvent("Reminder: rotate API keys", {
3422+
sessionKey: "agent:main:main",
3423+
contextKey: "cron:rotate-keys",
3424+
});
3425+
3426+
const result = await drainFormattedSystemEvents({
3427+
cfg: {} as OpenClawConfig,
3428+
sessionKey: "agent:main:main",
3429+
isMainSession: true,
3430+
isNewSession: false,
3431+
});
3432+
3433+
expect(result).toContain("Reminder: rotate API keys");
3434+
expect(peekSystemEvents("agent:main:main")).toEqual([]);
3435+
} finally {
3436+
resetSystemEventsForTest();
3437+
}
3438+
});
33893439
});
33903440

33913441
describe("persistSessionUsageUpdate", () => {

0 commit comments

Comments
 (0)