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:
- Scan for any heartbeat configuration (per-agent OR defaults)
- If none found β return false for all agents
- 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.
π Related Errors
- 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.