mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
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:
committed by
Peter Steinberger
parent
9a3800d8e6
commit
a3c5d21b4d
111
src/cron/service.heartbeat-ok-summary-suppressed.test.ts
Normal file
111
src/cron/service.heartbeat-ok-summary-suppressed.test.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user