mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -813,6 +813,7 @@ export async function runPreparedReply(
|
||||
sessionKey,
|
||||
isMainSession,
|
||||
isNewSession,
|
||||
suppressHeartbeatOwnedEvents: isHeartbeat,
|
||||
});
|
||||
if (eventsBlock) {
|
||||
drainedSystemEventBlocks.push(eventsBlock);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user