openclaw - 💡(How to fix) Fix Heartbeat `HEARTBEAT_OK` leaks to user channel during active turns; pending user response dropped

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When a heartbeat poll fires while the agent is processing a user-initiated turn, the heartbeat's HEARTBEAT_OK response is routed to the same outbound channel as the user response, causing it to be delivered to the user as a spurious message. This happens because heartbeats and user responses share a single outbound delivery queue without turn-level isolation.

Discovered and patched locally on 2026-05-09 after a 3-hour debugging session — full diagnostic logs available on request.


Root Cause

Summary

When a heartbeat poll fires while the agent is processing a user-initiated turn, the heartbeat's HEARTBEAT_OK response is routed to the same outbound channel as the user response, causing it to be delivered to the user as a spurious message. This happens because heartbeats and user responses share a single outbound delivery queue without turn-level isolation.

Fix Action

Fix / Workaround

Discovered and patched locally on 2026-05-09 after a 3-hour debugging session — full diagnostic logs available on request.

Verification method: Enable HEARTBEAT_TRACE=/tmp/heartbeat-routing-trace.log (added in the patch). Logs show type=heartbeat_to_system_ack or type=locked_to_system_ack with suppressed: true. No HEARTBEAT_OK should appear in the user's Telegram channel.

Workaround Note

Code Example

[ROUTING] 2026-05-09T17:39:00Z type=heartbeat_to_system_ack is_heartbeat=true locked=true target=telegram session=agent:main:telegram:direct:REDACTED payload="HEARTBEAT_OK"
[ROUTING] 2026-05-09T17:39:02Z type=user_response is_heartbeat=false locked=true target=telegram session=agent:main:telegram:direct:REDACTED payload="Here are the results..."
[TURN_LOCK] ACQUIRED session=agent:main:telegram:direct:REDACTED at=2026-05-09T17:38:55Z
[TURN_LOCK] RELEASED session=agent:main:telegram:direct:REDACTED at=2026-05-09T17:39:15Z

---

// In emitHeartbeatEvent / buildHeartbeatPayload
Meta: {
    is_heartbeat: true
}

// In the payload delivered to the agent loop
payloads: [{
    text: resolveHeartbeatOkText(),
    meta: { is_heartbeat: true, turn_type: "heartbeat_ack" }
}]

---

// At turn start (in runPreparedReply wrapper)
const hbRouter = globalThis.__heartbeatRouter;
if (!isHeartbeat && sessionKey && hbRouter) {
    hbRouter.acquireTurnLock(sessionKey, "user");
}

// At turn end (in finally block)
const releaseTurnLockIfHeld = () => {
    if (!isHeartbeat && sessionKey && hbRouter?.isTurnLocked(sessionKey)) {
        hbRouter.releaseTurnLock(sessionKey);
    }
};

// Wrap the original runPreparedReply
const originalRunPreparedReply = runPreparedReply;
runPreparedReply = async function(params) {
    try {
        return await originalRunPreparedReply(params);
    } finally {
        releaseTurnLockIfHeld();
    }
};

---

async function deliverOutboundPayloadsCore(params) {
    const { cfg, channel, to, payloads } = params;
    const sessionKey = params.session?.key ?? "unknown";

    // If any payload is a heartbeat, route entire batch to system_ack
    const hasHeartbeatPayload = payloads.some(p => p?.meta?.is_heartbeat === true);
    if (hasHeartbeatPayload) {
        return [{ status: "system_ack", channel, to, suppressed: true, reason: "heartbeat" }];
    }

    // If session is locked for a user turn and this is NOT a user response, drop to system_ack
    const locked = isTurnLocked(sessionKey);
    const hasUserResponse = payloads.some(p => 
        p?.meta?.turn_type === "user_response" || !p?.meta?.is_heartbeat
    );
    if (locked && !hasUserResponse) {
        return [{ status: "system_ack", channel, to, suppressed: true, reason: "turn_locked" }];
    }

    // ... existing delivery logic
}

---

heartbeat:
    mode: polling
    intervalMinutes: 30
RAW_BUFFERClick to expand / collapse

GitHub Issue Draft — OpenClaw Heartbeat Routing Fix

Repo: https://github.com/openclaw/openclaw
Status: Draft — pending approval before posting.


Title

Heartbeat HEARTBEAT_OK leaks to user channel during active turns; pending user response dropped


Summary

When a heartbeat poll fires while the agent is processing a user-initiated turn, the heartbeat's HEARTBEAT_OK response is routed to the same outbound channel as the user response, causing it to be delivered to the user as a spurious message. This happens because heartbeats and user responses share a single outbound delivery queue without turn-level isolation.

Discovered and patched locally on 2026-05-09 after a 3-hour debugging session — full diagnostic logs available on request.


Reproduction

  1. Start a user turn that triggers a long-running operation (>30s, e.g. multiple tool calls or extended LLM generation)
  2. While the turn is in flight, wait for the heartbeat interval to fire (config: intervalMinutes: 30)
  3. Observe the user's outbound channel

Expected: Only the user's actual response is delivered after the turn completes. Heartbeat acks remain internal.

Actual: HEARTBEAT_OK is delivered to the user channel. The actual response from the user turn is either dropped or arrives late, requiring the user to re-prompt to recover state.

Environment:

  • OpenClaw version: 2026.3.23-2 (also reproduced in 2026.5.7 — no related fixes in changelog as of May 12 2026)
  • Telegram gateway, single-user, long-turn workload
  • Reproduced consistently with turns involving 15+ tool calls

Illustrative debug traces (reconstructed from anonymized logs):

[ROUTING] 2026-05-09T17:39:00Z type=heartbeat_to_system_ack is_heartbeat=true locked=true target=telegram session=agent:main:telegram:direct:REDACTED payload="HEARTBEAT_OK"
[ROUTING] 2026-05-09T17:39:02Z type=user_response is_heartbeat=false locked=true target=telegram session=agent:main:telegram:direct:REDACTED payload="Here are the results..."
[TURN_LOCK] ACQUIRED session=agent:main:telegram:direct:REDACTED at=2026-05-09T17:38:55Z
[TURN_LOCK] RELEASED session=agent:main:telegram:direct:REDACTED at=2026-05-09T17:39:15Z

Impact: Users see "HEARTBEAT_OK" or empty heartbeat responses in their chat, breaking the conversational illusion. In multi-session setups, one session's heartbeat can appear in another session's channel.


Root Cause Diagnosis

The heartbeat system and user-turn system share the same outbound delivery path:

  1. heartbeat-runner polls the agent loop via runPreparedReply({ isHeartbeat: true })
  2. The agent generates a reply — often HEARTBEAT_OK or a brief status summary
  3. This reply flows into deliverOutboundPayloadsCore() alongside any other pending payloads
  4. The delivery logic routes all payloads for a session to the session's configured channel
  5. There is no distinction between "heartbeat acknowledgment" and "user-facing response" at the delivery layer

Additionally:

  • Heartbeat payloads do not carry a marker identifying them as system/internal
  • The delivery queue is session-scoped, not turn-scoped — concurrent or overlapping turns interleave
  • The showAlerts/showOk heartbeat config only suppresses the generation of the heartbeat event, not the routing of an already-generated reply

Proposed Fix

A three-file change that introduces (a) heartbeat tagging at source, (b) per-session turn locking, and (c) delivery-layer routing to system_ack:

File 1: heartbeat-runner.js — tag heartbeats at source

Add meta: { is_heartbeat: true } to both the event envelope and the generated payload:

// In emitHeartbeatEvent / buildHeartbeatPayload
Meta: {
    is_heartbeat: true
}

// In the payload delivered to the agent loop
payloads: [{
    text: resolveHeartbeatOkText(),
    meta: { is_heartbeat: true, turn_type: "heartbeat_ack" }
}]

This allows downstream delivery logic to distinguish heartbeat-generated content from user-turn content.

File 2: get-reply.js — per-session turn lock

Acquire a turn lock when a non-heartbeat turn starts; release it when the turn completes:

// At turn start (in runPreparedReply wrapper)
const hbRouter = globalThis.__heartbeatRouter;
if (!isHeartbeat && sessionKey && hbRouter) {
    hbRouter.acquireTurnLock(sessionKey, "user");
}

// At turn end (in finally block)
const releaseTurnLockIfHeld = () => {
    if (!isHeartbeat && sessionKey && hbRouter?.isTurnLocked(sessionKey)) {
        hbRouter.releaseTurnLock(sessionKey);
    }
};

// Wrap the original runPreparedReply
const originalRunPreparedReply = runPreparedReply;
runPreparedReply = async function(params) {
    try {
        return await originalRunPreparedReply(params);
    } finally {
        releaseTurnLockIfHeld();
    }
};

The lock is stored in a module-level Map<sessionKey, { lockedAt, turnType }>.

File 3: deliver.js — route heartbeats to system_ack

At the top of deliverOutboundPayloadsCore, check payload metadata and session lock state:

async function deliverOutboundPayloadsCore(params) {
    const { cfg, channel, to, payloads } = params;
    const sessionKey = params.session?.key ?? "unknown";

    // If any payload is a heartbeat, route entire batch to system_ack
    const hasHeartbeatPayload = payloads.some(p => p?.meta?.is_heartbeat === true);
    if (hasHeartbeatPayload) {
        return [{ status: "system_ack", channel, to, suppressed: true, reason: "heartbeat" }];
    }

    // If session is locked for a user turn and this is NOT a user response, drop to system_ack
    const locked = isTurnLocked(sessionKey);
    const hasUserResponse = payloads.some(p => 
        p?.meta?.turn_type === "user_response" || !p?.meta?.is_heartbeat
    );
    if (locked && !hasUserResponse) {
        return [{ status: "system_ack", channel, to, suppressed: true, reason: "turn_locked" }];
    }

    // ... existing delivery logic
}

Why this works: Heartbeats are tagged at creation, so delivery can identify them even if they share a batch with other payloads. The turn lock ensures that any stray heartbeat that arrives during an active user turn is silently suppressed rather than leaking through.


Test Cases

CaseScenarioExpected Behavior
ALong user turn (>30s) + heartbeat fires mid-turnHeartbeat is suppressed to system_ack; user only sees the real reply
BIdle heartbeat (no active user turn) → heartbeat ack routes to system_ack, keepalive runner remains functional (messagesHandled increments). User channel sees nothing.messagesHandled counter increments; no user-visible message
CBack-to-back user turns with no gap; heartbeat between turnsHeartbeat between turns routes to system_ack; does not appear in chat
DTwo concurrent sessions (Session X and Session Y); Session X has active turn, heartbeat fires for Session YSession Y heartbeat is independent; Session X lock does not affect Session Y delivery

Verification method: Enable HEARTBEAT_TRACE=/tmp/heartbeat-routing-trace.log (added in the patch). Logs show type=heartbeat_to_system_ack or type=locked_to_system_ack with suppressed: true. No HEARTBEAT_OK should appear in the user's Telegram channel.


Workaround Note

I currently have a local patch applied directly to the compiled JS bundles in ~/.npm-global/lib/node_modules/openclaw/dist/:

  • heartbeat-runner-DpQCcYf2.js
  • get-reply-462JLlw-.js
  • deliver-ByxJCZw_.js

This is not sustainable — any npm update or openclaw doctor --fix will silently overwrite these files and reintroduce the bug. Hence filing this upstream.


Environment

  • OpenClaw version: 2026.3.23-2 (and likely earlier)
  • Node.js: v22.22.0
  • OS: Ubuntu 24.04 (WSL2) — but issue is platform-agnostic
  • Channels affected: Verified on Telegram. Architecture (single outbound queue in deliverOutboundPayloadsCore) suggests other channels are affected, but not directly tested.
  • Heartbeat config:
    heartbeat:
      mode: polling
      intervalMinutes: 30

Labels

bug, heartbeat, routing, upstream


Ready for review. Do not post without approval.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix Heartbeat `HEARTBEAT_OK` leaks to user channel during active turns; pending user response dropped