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 <noreply@anthropic.com>
This commit is contained in:
scoootscooob
2026-03-02 11:27:41 -08:00
committed by Peter Steinberger
parent 9a3800d8e6
commit a3c5d21b4d
2 changed files with 117 additions and 0 deletions

View File

@@ -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 }),
);
});
});

View File

@@ -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 &&