From c736778b3fc927226722f8a35c49b656a81d227a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:58:13 +0000 Subject: [PATCH] fix: drop active heartbeat followups from queue (#25610, thanks @mcaxtr) Co-authored-by: Marcus Castro --- CHANGELOG.md | 1 + .../reply/agent-runner.runreplyagent.test.ts | 47 +++++++++++++++++-- src/auto-reply/reply/agent-runner.ts | 5 ++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4622e46ea..0e93bc017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. - Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. - Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) +- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. - Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. - Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 3590a624c..52d1e4550 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -8,7 +8,7 @@ import type { TypingMode } from "../../config/types.js"; import { withStateDirEnv } from "../../test-helpers/state-dir-env.js"; import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; +import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; type AgentRunParams = { @@ -86,6 +86,7 @@ beforeAll(async () => { beforeEach(() => { state.runEmbeddedPiAgentMock.mockClear(); state.runCliAgentMock.mockClear(); + vi.mocked(enqueueFollowupRun).mockClear(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); }); @@ -98,6 +99,9 @@ function createMinimalRun(params?: { storePath?: string; typingMode?: TypingMode; blockStreamingEnabled?: boolean; + isActive?: boolean; + shouldFollowup?: boolean; + resolvedQueueMode?: string; runOverrides?: Partial; }) { const typing = createMockTypingController(); @@ -106,7 +110,9 @@ function createMinimalRun(params?: { Provider: "whatsapp", MessageSid: "msg", } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const resolvedQueue = { + mode: params?.resolvedQueueMode ?? "interrupt", + } as unknown as QueueSettings; const sessionKey = params?.sessionKey ?? "main"; const followupRun = { prompt: "hello", @@ -147,8 +153,8 @@ function createMinimalRun(params?: { queueKey: "main", resolvedQueue, shouldSteer: false, - shouldFollowup: false, - isActive: false, + shouldFollowup: params?.shouldFollowup ?? false, + isActive: params?.isActive ?? false, isStreaming: false, opts, typing, @@ -274,6 +280,39 @@ async function runReplyAgentWithBase(params: { }); } +describe("runReplyAgent heartbeat followup guard", () => { + it("drops heartbeat runs when another run is active", async () => { + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true }, + isActive: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled(); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + expect(typing.cleanup).toHaveBeenCalledTimes(1); + }); + + it("still enqueues non-heartbeat runs when another run is active", async () => { + const { run } = createMinimalRun({ + opts: { isHeartbeat: false }, + isActive: true, + shouldFollowup: true, + resolvedQueueMode: "collect", + }); + + const result = await run(); + + expect(result).toBeUndefined(); + expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); +}); + describe("runReplyAgent typing (heartbeat)", () => { async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { return await withStateDirEnv( diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 33e4c0f7a..49e740835 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -235,6 +235,11 @@ export async function runReplyAgent(params: { } } + if (isHeartbeat && isActive) { + typing.cleanup(); + return undefined; + } + if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) { enqueueFollowupRun(queueKey, followupRun, resolvedQueue); await touchActiveSessionEntry();