From a3c5d21b4d4ec87c7d20a9ad30effe3d19801752 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Mon, 2 Mar 2026 11:27:41 -0800 Subject: [PATCH] fix(cron): suppress HEARTBEAT_OK summary from leaking into main session (#32013) When an isolated cron agent returns HEARTBEAT_OK (nothing to announce), the direct delivery is correctly skipped, but the fallback path in timer.ts still enqueues the summary as a system event to the main session. Filter out heartbeat-only summaries using isCronSystemEvent before enqueuing, so internal ack tokens never reach user conversations. Co-Authored-By: Claude Opus 4.6 --- ...ce.heartbeat-ok-summary-suppressed.test.ts | 111 ++++++++++++++++++ src/cron/service/timer.ts | 6 + 2 files changed, 117 insertions(+) create mode 100644 src/cron/service.heartbeat-ok-summary-suppressed.test.ts diff --git a/src/cron/service.heartbeat-ok-summary-suppressed.test.ts b/src/cron/service.heartbeat-ok-summary-suppressed.test.ts new file mode 100644 index 00000000000..7f0cdef19a7 --- /dev/null +++ b/src/cron/service.heartbeat-ok-summary-suppressed.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import { CronService } from "./service.js"; +import { setupCronServiceSuite, writeCronStoreSnapshot } from "./service.test-harness.js"; +import type { CronJob } from "./types.js"; + +const { logger, makeStorePath } = setupCronServiceSuite({ + prefix: "cron-heartbeat-ok-suppressed", +}); + +describe("cron isolated job HEARTBEAT_OK summary suppression (#32013)", () => { + it("does not enqueue HEARTBEAT_OK as a system event to the main session", async () => { + const { storePath } = await makeStorePath(); + const now = Date.now(); + + const job: CronJob = { + id: "heartbeat-only-job", + name: "heartbeat-only-job", + enabled: true, + createdAtMs: now - 10_000, + updatedAtMs: now - 10_000, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Check if anything is new" }, + delivery: { mode: "announce" }, + state: { nextRunAtMs: now - 1 }, + }; + + await writeCronStoreSnapshot({ storePath, jobs: [job] }); + + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + + const cron = new CronService({ + storePath, + cronEnabled: true, + log: logger, + enqueueSystemEvent, + requestHeartbeatNow, + runHeartbeatOnce: vi.fn(), + // Simulate the isolated agent returning HEARTBEAT_OK — nothing to + // announce. The delivery was intentionally skipped. + runIsolatedAgentJob: vi.fn(async () => ({ + status: "ok" as const, + summary: "HEARTBEAT_OK", + delivered: false, + deliveryAttempted: false, + })), + }); + + await cron.start(); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(1_000); + cron.stop(); + + // HEARTBEAT_OK should NOT leak into the main session as a system event. + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); + }); + + it("still enqueues real cron summaries as system events", async () => { + const { storePath } = await makeStorePath(); + const now = Date.now(); + + const job: CronJob = { + id: "real-summary-job", + name: "real-summary-job", + enabled: true, + createdAtMs: now - 10_000, + updatedAtMs: now - 10_000, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Check weather" }, + delivery: { mode: "announce" }, + state: { nextRunAtMs: now - 1 }, + }; + + await writeCronStoreSnapshot({ storePath, jobs: [job] }); + + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + + const cron = new CronService({ + storePath, + cronEnabled: true, + log: logger, + enqueueSystemEvent, + requestHeartbeatNow, + runHeartbeatOnce: vi.fn(), + // Simulate real content that should be forwarded. + runIsolatedAgentJob: vi.fn(async () => ({ + status: "ok" as const, + summary: "Weather update: sunny, 72°F", + delivered: false, + deliveryAttempted: false, + })), + }); + + await cron.start(); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(1_000); + cron.stop(); + + // Real summaries SHOULD be enqueued. + expect(enqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("Weather update"), + expect.objectContaining({ agentId: undefined }), + ); + }); +}); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 9d5e2e8becb..99f4ea7e72f 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,4 +1,5 @@ import type { CronConfig, CronRetryOn } from "../../config/types.cron.js"; +import { isCronSystemEvent } from "../../infra/heartbeat-events-filter.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; import { resolveCronDeliveryPlan } from "../delivery.js"; @@ -985,12 +986,17 @@ export async function executeJobCore( // ran. If delivery was attempted but final ack is uncertain, suppress the // main summary to avoid duplicate user-facing sends. // See: https://github.com/openclaw/openclaw/issues/15692 + // + // Also suppress heartbeat-only summaries (e.g. "HEARTBEAT_OK") — these + // are internal ack tokens that should never leak into user conversations. + // See: https://github.com/openclaw/openclaw/issues/32013 const summaryText = res.summary?.trim(); const deliveryPlan = resolveCronDeliveryPlan(job); const suppressMainSummary = res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested; if ( summaryText && + isCronSystemEvent(summaryText) && deliveryPlan.requested && !res.delivered && res.deliveryAttempted !== true &&