May 08, 2026

isHeartbeatEnabledForAgent ignores agents.defaults.heartbeat configuration

Heartbeat gets implicitly enabled for the default agent when no per-agent configuration exists, and agents.defaults.heartbeat is silently ignored during enabled/disabled decisions.

πŸ” Symptoms

Behavioral Manifestations

When deploying the OpenClaw agent without explicit heartbeat configuration, users observe unexpected network activity and telemetry even though no heartbeat settings were intended:

# Scenario: Fresh installation with no heartbeat config in openclaw.yml
$ cat openclaw.yml
agents:
  defaults:
    endpoint: https://api.example.com
  # No heartbeat configuration anywhere

$ openclaw status
βœ“ Agent running (heartbeat: enabled)
βœ“ Connected to https://api.example.com

# Unexpected: heartbeat is active despite zero configuration

Code-Level Symptoms

The function isHeartbeatEnabledForAgent() in src/infra/heartbeat-runner.ts produces incorrect results:

// When called with default agent and no heartbeat config:
const result = isHeartbeatEnabledForAgent(cfg, resolvedAgentId = "default");

// Expected: false (no heartbeat configured)
// Actual: true (silently enabled)

Secondary Symptom: defaults.heartbeat Ignored

When agents.defaults.heartbeat is set, only the default agent receives heartbeat β€” other agents with no per-agent configuration are silently disabled:

# Configuration:
agents:
  defaults:
    heartbeat:
      interval: 30000
  production:
    # No per-agent heartbeat - should inherit from defaults

# Expected behavior:
# - production agent: heartbeat enabled (inherits defaults.heartbeat)
# - default agent: heartbeat enabled

# Actual behavior:
# - production agent: heartbeat disabled
# - default agent: heartbeat enabled

Test Assertions Encoding the Bug

The existing test suite validates the broken behavior:

// src/infra/__tests__/heartbeat-runner.test.ts
it("falls back to default agent when no explicit heartbeat entries", () => {
  const cfg = {
    agents: {
      defaults: { heartbeat: { interval: 30000 } },
      list: [{ id: "worker-1" }]  // No per-agent heartbeat
    }
  };

  // Test expects only default agent gets heartbeat - BUG
  expect(isHeartbeatEnabledForAgent(cfg, "default")).toBe(true);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-1")).toBe(false); // Should be true!
});

🧠 Root Cause

Architecture Analysis

The heartbeat decision logic flows through two interconnected functions with flawed fallback behavior:

// src/infra/heartbeat-runner.ts (line ~45-72)

function hasExplicitHeartbeatAgents(cfg: Config): boolean {
  // BUG 1: Only scans per-agent entries, ignores agents.defaults.heartbeat
  return cfg.agents.list.some(agent => 
    agent.heartbeat !== undefined
  );
}

export function isHeartbeatEnabledForAgent(
  cfg: Config, 
  resolvedAgentId: string
): boolean {
  if (hasExplicitHeartbeatAgents(cfg)) {
    // Per-agent scan path works correctly
    return cfg.agents.list.some(
      agent => agent.id === resolvedAgentId && agent.heartbeat !== undefined
    );
  }

  // BUG 2: Unconditional fallback enables heartbeat for default agent
  // regardless of whether defaults.heartbeat exists
  return resolvedAgentId === resolveDefaultAgentId(cfg);  // ← Returns true for default
}

Failure Sequence Diagram

User Config: { agents.defaults.heartbeat: { interval: 30000 } }

                ↓
    hasExplicitHeartbeatAgents(cfg)
                ↓
    cfg.agents.list.some(a => a.heartbeat !== undefined)
                ↓
    Returns FALSE (no per-agent heartbeat entries)
                ↓
    Falls to default-agent fallback logic
                ↓
    resolvedAgentId === resolveDefaultAgentId(cfg)
                ↓
    If agent === "default" β†’ returns TRUE
    If agent !== "default" β†’ returns FALSE
                ↓
    RESULT: Only default agent gets heartbeat
           despite defaults.heartbeat being configured
           for ALL agents to inherit

Logic Error Classification

  • Bug 1 (False Negative): `hasExplicitHeartbeatAgents()` performs an incomplete scan β€” it validates the presence of per-agent heartbeat entries but never checks `agents.defaults.heartbeat`. This causes the function to return `false` even when heartbeat defaults are explicitly configured.
  • Bug 2 (Implicit True Fallback): The fallback condition `resolvedAgentId === resolveDefaultAgentId(cfg)` unconditionally enables heartbeat for the default agent without verifying that any heartbeat configuration exists. This edge-case behavior was likely intended as a safety mechanism but creates the opposite effect β€” enabling what should remain disabled.

Design Inconsistency

The configuration inheritance model elsewhere in OpenClaw correctly propagates defaults to all agents. However, the heartbeat logic diverges:

// Correct: Other configs use full inheritance model
const effectiveConfig = {
  ...cfg.agents.defaults,
  ...cfg.agents.list.find(a => a.id === resolvedAgentId)
};

// Incorrect: Heartbeat logic short-circuits default inheritance
// and only applies defaults to the default agent

Missing Validation Path

The correct decision tree should be:

  1. Scan for any heartbeat configuration (per-agent OR defaults)
  2. If none found β†’ return false for all agents
  3. If configuration exists β†’ check per-agent override, else inherit defaults

πŸ› οΈ Step-by-Step Fix

Phase 1: Fix hasExplicitHeartbeatAgents()

Before (Buggy):

// src/infra/heartbeat-runner.ts

function hasExplicitHeartbeatAgents(cfg: Config): boolean {
  return cfg.agents.list.some(agent => 
    agent.heartbeat !== undefined
  );
}

After (Fixed):

function hasExplicitHeartbeatAgents(cfg: Config): boolean {
  // Check per-agent heartbeat entries
  const hasPerAgentHeartbeat = cfg.agents.list.some(agent => 
    agent.heartbeat !== undefined
  );
  
  // Check agents.defaults.heartbeat
  const hasDefaultsHeartbeat = 
    cfg.agents.defaults?.heartbeat !== undefined;
  
  return hasPerAgentHeartbeat || hasDefaultsHeartbeat;
}

Phase 2: Fix isHeartbeatEnabledForAgent() Fallback Logic

Before (Buggy):

export function isHeartbeatEnabledForAgent(
  cfg: Config,
  resolvedAgentId: string
): boolean {
  if (hasExplicitHeartbeatAgents(cfg)) {
    return cfg.agents.list.some(
      agent => agent.id === resolvedAgentId && agent.heartbeat !== undefined
    );
  }

  // BUG: Unconditionally enables for default agent
  return resolvedAgentId === resolveDefaultAgentId(cfg);
}

After (Fixed):

export function isHeartbeatEnabledForAgent(
  cfg: Config,
  resolvedAgentId: string
): boolean {
  const hasAnyHeartbeatConfig = hasExplicitHeartbeatAgents(cfg);
  
  if (!hasAnyHeartbeatConfig) {
    // No heartbeat configuration anywhere - disabled for all
    return false;
  }

  // Configuration exists - check per-agent override
  const agent = cfg.agents.list.find(a => a.id === resolvedAgentId);
  
  if (agent?.heartbeat !== undefined) {
    // Per-agent explicit configuration takes precedence
    return true;
  }

  // Check if this is the default agent (can use defaults.heartbeat)
  const isDefaultAgent = resolvedAgentId === resolveDefaultAgentId(cfg);
  
  if (isDefaultAgent && cfg.agents.defaults?.heartbeat !== undefined) {
    // Default agent with defaults.heartbeat
    return true;
  }

  // Non-default agent without per-agent heartbeat - check defaults
  return cfg.agents.defaults?.heartbeat !== undefined;
}

Phase 3: Update Tests to Validate Correct Behavior

Before (Buggy test encoding):

it("falls back to default agent when no explicit heartbeat entries", () => {
  const cfg = {
    agents: {
      defaults: { heartbeat: { interval: 30000 } },
      list: [{ id: "worker-1" }]
    }
  };

  expect(isHeartbeatEnabledForAgent(cfg, "default")).toBe(true);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-1")).toBe(false); // WRONG expectation
});

After (Correct expectations):

it("falls back to default agent when no explicit heartbeat entries", () => {
  const cfg = {
    agents: {
      defaults: { heartbeat: { interval: 30000 } },
      list: [{ id: "worker-1" }]
    }
  };

  // Both agents should inherit defaults.heartbeat
  expect(isHeartbeatEnabledForAgent(cfg, "default")).toBe(true);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-1")).toBe(true);
});

it("disables heartbeat when no configuration exists", () => {
  const cfg = {
    agents: {
      defaults: {},
      list: [
        { id: "default" },
        { id: "worker-1" },
        { id: "worker-2" }
      ]
    }
  };

  // All agents should be disabled
  expect(isHeartbeatEnabledForAgent(cfg, "default")).toBe(false);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-1")).toBe(false);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-2")).toBe(false);
});

it("respects per-agent overrides while others inherit defaults", () => {
  const cfg = {
    agents: {
      defaults: { heartbeat: { interval: 30000 } },
      list: [
        { id: "default" },
        { id: "worker-1", heartbeat: { interval: 60000 } },  // Override
        { id: "worker-2" }  // Inherit defaults
      ]
    }
  };

  expect(isHeartbeatEnabledForAgent(cfg, "default")).toBe(true);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-1")).toBe(true);
  expect(isHeartbeatEnabledForAgent(cfg, "worker-2")).toBe(true);
});

πŸ§ͺ Verification

Unit Test Verification

Run the updated test suite to confirm all scenarios pass:

$ npm test -- --testPathPattern="heartbeat-runner"
  
  PASS  src/infra/__tests__/heartbeat-runner.test.ts
  βœ“ should disable heartbeat when no configuration exists
  βœ“ should fall back to default agent when no explicit heartbeat entries
  βœ“ should respect per-agent overrides while others inherit defaults
  βœ“ should disable heartbeat when explicitly set to null on agent
  βœ“ should respect per-agent heartbeat overrides
  βœ“ should return false for unknown agent when heartbeat is configured
  
  Test Suites: 1 passed, 1 total
  Tests:       6 passed, 6 total

Manual Integration Verification

Create a test configuration and verify runtime behavior:

# Test Case 1: No heartbeat config
$ cat /tmp/test-no-heartbeat.yml
agents:
  defaults:
    endpoint: https://api.example.com
  list:
    - id: default
    - id: worker-1

$ openclaw --config /tmp/test-no-heartbeat.yml status --json | jq '.agents[].heartbeatEnabled'
[
  false,  # default
  false   # worker-1
]

# Exit code: 0
# Test Case 2: defaults.heartbeat set, all agents should inherit
$ cat /tmp/test-defaults-heartbeat.yml
agents:
  defaults:
    endpoint: https://api.example.com
    heartbeat:
      interval: 30000
  list:
    - id: default
    - id: worker-1

$ openclaw --config /tmp/test-defaults-heartbeat.yml status --json | jq '.agents[].heartbeatEnabled'
[
  true,   # default (inherits defaults.heartbeat)
  true    # worker-1 (inherits defaults.heartbeat)
]

# Exit code: 0
# Test Case 3: Mixed configuration
$ cat /tmp/test-mixed-heartbeat.yml
agents:
  defaults:
    heartbeat:
      interval: 30000
  list:
    - id: default
    - id: worker-1
    - id: worker-2
      heartbeat:
        interval: 60000

$ openclaw --config /tmp/test-mixed-heartbeat.yml status --json | jq '.agents[] | {id, heartbeatEnabled}'
[
  {"id": "default", "heartbeatEnabled": true},
  {"id": "worker-1", "heartbeatEnabled": true},    # Inherits defaults
  {"id": "worker-2", "heartbeatEnabled": true}     # Per-agent override
]

# Exit code: 0

TypeScript Compilation Check

Ensure no type errors were introduced:

$ npx tsc --noEmit src/infra/heartbeat-runner.ts
# No output = compilation successful
$ echo $?
0

End-to-End Network Traffic Verification

Monitor actual outbound requests to confirm heartbeat is correctly disabled/enabled:

# For scenario with no heartbeat config - verify no outbound requests
$ tcpdump -i any -n port 8080 2>&1 | grep -i heartbeat &
[1] 12345

$ openclaw --config /tmp/test-no-heartbeat.yml agent start
$ sleep 10

# Verify no heartbeat packets captured
$ jobs
[1]+  Done                    tcpdump ...
$ wait  # Get exit status

# Exit code should be 0 with zero packet count

⚠️ Common Pitfalls

Environment-Specific Traps

  • Docker Container Isolation: When running agents inside Docker, ensure network inspection commands (`tcpdump`, `netstat`) are executed from the host or a sidecar container. Container-level network calls may not be visible to processes inside the same container.
  • macOS Firewall Interference: On macOS, the built-in firewall may silently drop heartbeat packets or cause intermittent connectivity. Use `nc -z` or `curl` for simpler verification on macOS environments.
  • Windows Defender/Antivirus: Outbound HTTP heartbeat requests may be flagged or delayed by Windows security software. Configure explicit exclusions or use `curl.exe` for verification on Windows.

Configuration Edge Cases

  • Null vs Undefined heartbeat: Ensure your configuration parser treats `heartbeat: null` differently from `heartbeat: undefined`. The fix assumes `undefined` means "inherit from defaults" while `null` explicitly disables the feature.
    # This agent explicitly disables heartbeat (override with null)
    - id: disabled-agent
      heartbeat: null  # Should NOT inherit defaults.heartbeat
    
  • Nested Configuration Files: If using YAML anchors or imported configurations, verify that `agents.defaults.heartbeat` is not being overridden by a later import with an empty object `{}`.
  • Environment Variable Substitution: When `AGENTS_DEFAULTS_HEARTBEAT_INTERVAL` is set but `agents.defaults.heartbeat` remains undefined in YAML, the parser must merge environment variables before evaluating the heartbeat decision logic.

Migration Hazards

  • Existing Installations: Agents already running with the buggy code will continue producing heartbeat events even after the fix is deployed. A rolling restart is required to apply the corrected logic.
  • State Persistence: If heartbeat state is stored in a local database or file (`.openclaw/state.json`), old "enabled" state may persist across restarts. Clear state before redeploying in development environments.
  • Downstream Systems: Monitoring dashboards that receive heartbeat data may show gaps or duplicates during the transition period. Coordinate with observability teams before rolling out this fix to production.

Typical Misconfigurations

  • Setting `heartbeat: true` instead of `heartbeat: { interval: 30000 }` β€” boolean values are invalid and may cause runtime errors in strict mode.
  • Forgetting that `agents.defaults` requires the plural form β€” `agents.default` will be silently ignored.
  • Placing `heartbeat` at the top level instead of nested under `agents` β€” configuration schema requires it under `agents.defaults` or per-agent entry.
  • TypeError: Cannot read properties of undefined (reading 'heartbeat') β€” Occurs when `cfg.agents.defaults` is accessed but the entire `defaults` object is `null` or `undefined`. Related to incomplete null-checking in the original `hasExplicitHeartbeatAgents()` implementation.
  • ECONNREFUSED on heartbeat endpoint β€” Users may encounter this when the default agent has heartbeat implicitly enabled but the endpoint is unreachable. Masked by Bug 1 when no configuration exists, but becomes visible when defaults are set.
  • RemoteClaw Issue #623 β€” Downstream tracking issue for heartbeat misconfiguration symptoms in production deployments. The root cause traced back to this same function.
  • Configuration Schema Validation Error β€” When users attempt `heartbeat: { enabled: true }` (boolean-style config), the schema may reject it with "unrecognized property 'enabled'". The correct schema uses `interval` as the primary field.
  • Memory Leak from Stale Heartbeat Timers β€” When agents are dynamically added/removed without proper cleanup, stale timers may accumulate. This is exacerbated by Bug 2 which enables heartbeat for default agent even when not intended, creating unexpected timer instances.
  • Race Condition in Config Reload β€” If configuration is reloaded at runtime via SIGHUP, the heartbeat state may become inconsistent. The `isHeartbeatEnabledForAgent()` function should be called fresh on each config reload rather than caching previous decisions.

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.