From 22593a27237a1cbc26f59f643128ba1982a8d2bd Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 12 Feb 2026 15:50:27 -0800 Subject: [PATCH] fix: refine cron heartbeat event detection --- CHANGELOG.md | 4 + ...at-runner.cron-system-event-filter.test.ts | 29 +++ .../heartbeat-runner.ghost-reminder.test.ts | 223 +++++++++--------- src/infra/heartbeat-runner.ts | 35 ++- 4 files changed, 164 insertions(+), 127 deletions(-) create mode 100644 src/infra/heartbeat-runner.cron-system-event-filter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e776180731..070959dbdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,10 @@ Docs: https://docs.openclaw.ai - BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek. - Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de. - Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. +- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr. - Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins. - 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. - Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max. - Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8. @@ -67,6 +69,8 @@ Docs: https://docs.openclaw.ai - Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26. - Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002. - Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi. +- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8. +- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct. - Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale. - Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik. diff --git a/src/infra/heartbeat-runner.cron-system-event-filter.test.ts b/src/infra/heartbeat-runner.cron-system-event-filter.test.ts new file mode 100644 index 0000000000..d83e04d983 --- /dev/null +++ b/src/infra/heartbeat-runner.cron-system-event-filter.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isCronSystemEvent } from "./heartbeat-runner.js"; + +describe("isCronSystemEvent", () => { + it("returns false for empty entries", () => { + expect(isCronSystemEvent("")).toBe(false); + expect(isCronSystemEvent(" ")).toBe(false); + }); + + it("returns false for heartbeat ack markers", () => { + expect(isCronSystemEvent("HEARTBEAT_OK")).toBe(false); + expect(isCronSystemEvent("HEARTBEAT_OK 🦞")).toBe(false); + expect(isCronSystemEvent("heartbeat_ok")).toBe(false); + }); + + it("returns false for heartbeat poll and wake noise", () => { + expect(isCronSystemEvent("heartbeat poll: pending")).toBe(false); + expect(isCronSystemEvent("heartbeat wake complete")).toBe(false); + }); + + it("returns false for exec completion events", () => { + expect(isCronSystemEvent("Exec finished (gateway id=abc, code 0)")).toBe(false); + }); + + it("returns true for real cron reminder content", () => { + expect(isCronSystemEvent("Reminder: Check Base Scout results")).toBe(true); + expect(isCronSystemEvent("Send weekly status update to the team")).toBe(true); + }); +}); diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index d72bd3227f..54a3d0bdb2 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -5,12 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js"; +import * as replyModule from "../auto-reply/reply.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; -import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; import { runHeartbeatOnce } from "./heartbeat-runner.js"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); @@ -21,63 +22,68 @@ beforeEach(() => { setActivePluginRegistry( createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), ); - // Reset system events queue to avoid cross-test pollution resetSystemEventsForTest(); }); afterEach(() => { - // Clean up after each test resetSystemEventsForTest(); + vi.restoreAllMocks(); }); describe("Ghost reminder bug (issue #13317)", () => { - it("should NOT trigger CRON_EVENT_PROMPT when only HEARTBEAT_OK is in system events", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ghost-")); + const createConfig = async ( + tmpDir: string, + ): Promise<{ cfg: OpenClawConfig; sessionKey: string }> => { const storePath = path.join(tmpDir, "sessions.json"); - - try { - const cfg: OpenClawConfig = { - agents: { - defaults: { - workspace: tmpDir, - heartbeat: { - every: "5m", - target: "telegram", - }, + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "telegram", }, }, - channels: { telegram: { allowFrom: ["*"] } }, - session: { store: storePath }, - }; - - const sessionKey = resolveMainSessionKey(cfg); + }, + channels: { telegram: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "telegram", - lastProvider: "telegram", - lastTo: "155462274", - }, + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "telegram", + lastProvider: "telegram", + lastTo: "155462274", }, - null, - 2, - ), - ); + }, + null, + 2, + ), + ); - // Simulate leftover HEARTBEAT_OK from previous heartbeat - enqueueSystemEvent("HEARTBEAT_OK", { sessionKey }); + return { cfg, sessionKey }; + }; - const sendTelegram = vi.fn().mockResolvedValue({ - messageId: "m1", - chatId: "155462274", - }); + it("does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ghost-")); + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + chatId: "155462274", + }); + const getReplySpy = vi + .spyOn(replyModule, "getReplyFromConfig") + .mockResolvedValue({ text: "Heartbeat check-in" }); + + try { + const { cfg } = await createConfig(tmpDir); + enqueueSystemEvent("HEARTBEAT_OK", { sessionKey: resolveMainSessionKey(cfg) }); - // Run heartbeat with cron: reason (simulating cron job firing) const result = await runHeartbeatOnce({ cfg, agentId: "main", @@ -87,73 +93,32 @@ describe("Ghost reminder bug (issue #13317)", () => { }, }); - // Check that heartbeat ran successfully - expect(result.status).toBeDefined(); - - // The bug: sendTelegram would be called with a message containing - // "scheduled reminder" even though no actual reminder content exists. - // The fix: should use regular heartbeat prompt, NOT CRON_EVENT_PROMPT. - - // If a message was sent, verify it doesn't contain ghost reminder text - if (result.status === "sent") { - const calls = sendTelegram.mock.calls; - expect(calls.length).toBeGreaterThan(0); - const message = calls[0][0].message; - - // Should NOT contain the ghost reminder prompt - expect(message).not.toContain("scheduled reminder has been triggered"); - expect(message).not.toContain("relay this reminder"); - } - + expect(result.status).toBe("ran"); + expect(getReplySpy).toHaveBeenCalledTimes(1); + const calledCtx = getReplySpy.mock.calls[0]?.[0]; + expect(calledCtx?.Provider).toBe("heartbeat"); + expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered"); + expect(calledCtx?.Body).not.toContain("relay this reminder"); + expect(sendTelegram).toHaveBeenCalled(); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); - it("should trigger CRON_EVENT_PROMPT when actual cron message exists", async () => { + it("uses CRON_EVENT_PROMPT when an actionable cron event exists", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-")); - const storePath = path.join(tmpDir, "sessions.json"); - + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + chatId: "155462274", + }); + const getReplySpy = vi + .spyOn(replyModule, "getReplyFromConfig") + .mockResolvedValue({ text: "Relay this reminder now" }); + try { - const cfg: OpenClawConfig = { - agents: { - defaults: { - workspace: tmpDir, - heartbeat: { - every: "5m", - target: "telegram", - }, - }, - }, - channels: { telegram: { allowFrom: ["*"] } }, - session: { store: storePath }, - }; - - const sessionKey = resolveMainSessionKey(cfg); - - await fs.writeFile( - storePath, - JSON.stringify( - { - [sessionKey]: { - sessionId: "sid", - updatedAt: Date.now(), - lastChannel: "telegram", - lastProvider: "telegram", - lastTo: "155462274", - }, - }, - null, - 2, - ), - ); - - // Simulate real cron message (not HEARTBEAT_OK) - enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey }); - - const sendTelegram = vi.fn().mockResolvedValue({ - messageId: "m1", - chatId: "155462274", + const { cfg } = await createConfig(tmpDir); + enqueueSystemEvent("Reminder: Check Base Scout results", { + sessionKey: resolveMainSessionKey(cfg), }); const result = await runHeartbeatOnce({ @@ -165,19 +130,47 @@ describe("Ghost reminder bug (issue #13317)", () => { }, }); - // Check that heartbeat ran - expect(result.status).toBeDefined(); - - // If a message was sent, verify it DOES contain the cron reminder prompt - if (result.status === "sent") { - const calls = sendTelegram.mock.calls; - expect(calls.length).toBeGreaterThan(0); - const message = calls[0][0].message; - - // SHOULD contain the cron reminder prompt - expect(message).toContain("scheduled reminder has been triggered"); - } - + expect(result.status).toBe("ran"); + expect(getReplySpy).toHaveBeenCalledTimes(1); + const calledCtx = getReplySpy.mock.calls[0]?.[0]; + expect(calledCtx?.Provider).toBe("cron-event"); + expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); + expect(sendTelegram).toHaveBeenCalled(); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("uses CRON_EVENT_PROMPT when cron events are mixed with heartbeat noise", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-mixed-")); + const sendTelegram = vi.fn().mockResolvedValue({ + messageId: "m1", + chatId: "155462274", + }); + const getReplySpy = vi + .spyOn(replyModule, "getReplyFromConfig") + .mockResolvedValue({ text: "Relay this reminder now" }); + + try { + const { cfg, sessionKey } = await createConfig(tmpDir); + enqueueSystemEvent("HEARTBEAT_OK", { sessionKey }); + enqueueSystemEvent("Reminder: Check Base Scout results", { sessionKey }); + + const result = await runHeartbeatOnce({ + cfg, + agentId: "main", + reason: "cron:reminder-job", + deps: { + sendTelegram, + }, + }); + + expect(result.status).toBe("ran"); + expect(getReplySpy).toHaveBeenCalledTimes(1); + const calledCtx = getReplySpy.mock.calls[0]?.[0]; + expect(calledCtx?.Provider).toBe("cron-event"); + expect(calledCtx?.Body).toContain("scheduled reminder has been triggered"); + 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 b15ff03f22..af4caf071a 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -114,6 +114,28 @@ 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 trimmed = evt.trim(); + if (!trimmed) { + return false; + } + + const lower = trimmed.toLowerCase(); + const heartbeatOk = HEARTBEAT_TOKEN.toLowerCase(); + if (lower === heartbeatOk || lower.startsWith(`${heartbeatOk} `)) { + return false; + } + if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) { + return false; + } + if (lower.includes("exec finished")) { + return false; + } + + return true; +} + type HeartbeatAgentState = { agentId: string; heartbeat?: HeartbeatConfig; @@ -500,18 +522,7 @@ export async function runHeartbeatOnce(opts: { const isCronEvent = Boolean(opts.reason?.startsWith("cron:")); const pendingEvents = isExecEvent || isCronEvent ? peekSystemEvents(sessionKey) : []; const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished")); - - // Fix for #13317: Only treat as cron event if there are actual cron-related messages, - // not just any system events (which could be heartbeat acks, exec completions, etc.) - const hasCronEvents = isCronEvent && pendingEvents.some((evt) => { - const trimmed = evt.trim(); - // Exclude standard heartbeat acks and exec completion messages - return ( - trimmed.length > 0 && - !trimmed.includes("HEARTBEAT_OK") && - !trimmed.includes("Exec finished") - ); - }); + const hasCronEvents = isCronEvent && pendingEvents.some((evt) => isCronSystemEvent(evt)); const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : hasCronEvents