diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad95bfcc..674821df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. - Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins. - Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr. +- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn. - Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason. - Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar. - Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28. diff --git a/src/infra/heartbeat-runner.cron-system-event-filter.test.ts b/src/infra/heartbeat-runner.cron-system-event-filter.test.ts index d83e04d98..dfe4c2c18 100644 --- a/src/infra/heartbeat-runner.cron-system-event-filter.test.ts +++ b/src/infra/heartbeat-runner.cron-system-event-filter.test.ts @@ -11,6 +11,8 @@ describe("isCronSystemEvent", () => { expect(isCronSystemEvent("HEARTBEAT_OK")).toBe(false); expect(isCronSystemEvent("HEARTBEAT_OK 🦞")).toBe(false); expect(isCronSystemEvent("heartbeat_ok")).toBe(false); + expect(isCronSystemEvent("HEARTBEAT_OK:")).toBe(false); + expect(isCronSystemEvent("HEARTBEAT_OK, continue")).toBe(false); }); it("returns false for heartbeat poll and wake noise", () => { diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 54a3d0bdb..76bcaf22f 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -135,6 +135,9 @@ describe("Ghost reminder bug (issue #13317)", () => { const calledCtx = getReplySpy.mock.calls[0]?.[0]; expect(calledCtx?.Provider).toBe("cron-event"); expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); + expect(calledCtx?.Body).toContain("Reminder: Check Base Scout results"); + expect(calledCtx?.Body).not.toContain("HEARTBEAT_OK"); + expect(calledCtx?.Body).not.toContain("heartbeat poll"); expect(sendTelegram).toHaveBeenCalled(); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); @@ -170,6 +173,9 @@ describe("Ghost reminder bug (issue #13317)", () => { const calledCtx = getReplySpy.mock.calls[0]?.[0]; expect(calledCtx?.Provider).toBe("cron-event"); expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); + expect(calledCtx?.Body).toContain("Reminder: Check Base Scout results"); + expect(calledCtx?.Body).not.toContain("HEARTBEAT_OK"); + expect(calledCtx?.Body).not.toContain("heartbeat poll"); expect(sendTelegram).toHaveBeenCalled(); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index af4caf071..cec770f24 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -114,26 +114,47 @@ function buildCronEventPrompt(pendingEvents: string[]): string { ); } -// Returns true when a system event should be treated as real cron reminder content. -export function isCronSystemEvent(evt: string) { +const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase(); + +// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events. +function isHeartbeatAckEvent(evt: string): boolean { const trimmed = evt.trim(); if (!trimmed) { return false; } - const lower = trimmed.toLowerCase(); - const heartbeatOk = HEARTBEAT_TOKEN.toLowerCase(); - if (lower === heartbeatOk || lower.startsWith(`${heartbeatOk} `)) { + if (!lower.startsWith(HEARTBEAT_OK_PREFIX)) { return false; } - if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) { - return false; - } - if (lower.includes("exec finished")) { - return false; + const suffix = lower.slice(HEARTBEAT_OK_PREFIX.length); + if (suffix.length === 0) { + return true; } + return !/[a-z0-9_]/.test(suffix[0]); +} - return true; +function isHeartbeatNoiseEvent(evt: string): boolean { + const lower = evt.trim().toLowerCase(); + if (!lower) { + return false; + } + return ( + isHeartbeatAckEvent(lower) || + lower.includes("heartbeat poll") || + lower.includes("heartbeat wake") + ); +} + +function isExecCompletionEvent(evt: string): boolean { + return evt.toLowerCase().includes("exec finished"); +} + +// Returns true when a system event should be treated as real cron reminder content. +export function isCronSystemEvent(evt: string) { + if (!evt.trim()) { + return false; + } + return !isHeartbeatNoiseEvent(evt) && !isExecCompletionEvent(evt); } type HeartbeatAgentState = { @@ -521,12 +542,13 @@ export async function runHeartbeatOnce(opts: { const isExecEvent = opts.reason === "exec-event"; const isCronEvent = Boolean(opts.reason?.startsWith("cron:")); const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : []; - const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished")); - const hasCronEvents = isCronEvent && pendingEvents.some((evt) => isCronSystemEvent(evt)); + const cronEvents = pendingEvents.filter((evt) => isCronSystemEvent(evt)); + const hasExecCompletion = pendingEvents.some(isExecCompletionEvent); + const hasCronEvents = isCronEvent && cronEvents.length > 0; const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : hasCronEvents - ? buildCronEventPrompt(pendingEvents) + ? buildCronEventPrompt(cronEvents) : resolveHeartbeatPrompt(cfg, heartbeat); const ctx = { Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),