Skip to content

[Bug]: Heartbeat sends multiple response branches due to followup-runner and delivery-mirror #8063

@aksheyw

Description

@aksheyw

Summary

Heartbeat responses are being sent multiple times to users, creating duplicate/repetitive messages. A single heartbeat check can result in 2-4 messages delivered to the user instead of just one HEARTBEAT_OK.

Steps to reproduce

  1. Configure heartbeat with any model (tested with ministral-3b and gpt-4o-mini)
  2. Enable heartbeat runner with default settings
  3. Observe that a single heartbeat check sends multiple messages to the channel

Expected behavior

A heartbeat check should send exactly ONE message to the user:

  • Either HEARTBEAT_OK if nothing needs attention
  • Or a brief status update if something needs attention

Actual behavior

Multiple messages are sent due to several architectural issues:

  1. Followup Runner creates additional branches: finalizeWithFollowup() in agent-runner.js spawns runFollowup() which creates a new agent run with a different parentId, generating additional response branches
  2. Delivery-Mirror echoes messages: The transcript system records both the original message and a "delivery-mirror" entry, both appearing as assistant messages
  3. stripHeartbeatToken only strips edges: The function uses regex that only removes HEARTBEAT_OK at the start/end of text, not when embedded in the middle
  4. Multiple payloads from assistantTexts: buildPiPayloads() can create multiple payloads from the assistantTexts array

Root Cause Analysis

Files involved (from source investigation):

  • src/auto-reply/reply/agent-runner.ts - calls finalizeWithFollowup() unconditionally
  • src/auto-reply/reply/followup-runner.ts - spawns additional agent runs
  • src/auto-reply/heartbeat.ts - stripHeartbeatToken() uses edge-only regex
  • src/config/sessions/transcript.ts - delivery-mirror mechanism
  • src/agents/pi-embedded-runner/run/payloads.ts - builds multiple payloads

Proposed Fix

Option 1: Skip followup for heartbeat runs (Recommended)

In agent-runner.ts, add a check before calling finalizeWithFollowup():

// Skip followup for heartbeat - single response only
if (runContext.trigger !== 'heartbeat') {
  await finalizeWithFollowup(...)
}

Option 2: Add responsePolicy config

Add a responsePolicy: 'single' | 'multi' option to heartbeat config that enforces single-payload delivery.

Option 3: Fix stripHeartbeatToken to handle embedded tokens

// Current (broken for middle occurrences):
text.replace(/^HEARTBEAT_OK\s*|\s*HEARTBEAT_OK$/gi, '')

// Proposed (handles all occurrences):
text.replace(/HEARTBEAT_OK/gi, '').trim()

Environment

  • Clawdbot version: latest (npm)
  • Node.js: 22+
  • Channel: Telegram (but affects all channels)
  • Model: Tested with ministral-3b-2512 and gpt-4o-mini

Workaround

Until fixed, users can mitigate by:

  1. Using a model with strong instruction-following (gpt-4o-mini recommended over smaller models)
  2. Simplifying HEARTBEAT.md to have strict, minimal instructions with explicit "do NOT" examples

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstaleMarked as stale due to inactivity

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions