openclaw - ✅(Solved) Fix Bug: agents.defaults.heartbeat is silently ignored when any agent has explicit heartbeat config [1 pull requests, 2 comments, 3 participants]

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…
GitHub stats
openclaw/openclaw#78713Fetched 2026-05-07 03:33:26
View on GitHub
Comments
2
Participants
3
Timeline
3
Reactions
2
Timeline (top)
commented ×2cross-referenced ×1

Error Message

  • Cron jobs targeting session: main show "error": "disabled" in heartbeat runner logs
  • No error message tells you the default is being ignored

Root Cause

Root cause (confirmed by reading heartbeat-summary.ts):

PR fix notes

PR #78718: fix(heartbeat): correct agent-level heartbeat fallback to respect defaults

Description (problem / solution / changelog)

Bug

isHeartbeatEnabledForAgent used an all-or-nothing check: if ANY agent in agents.list had an explicit heartbeat block, the function entered a mode where ONLY agents with their own heartbeat field were considered enabled. This silently ignored agents.defaults.heartbeat for agents without per-agent config.

Example config that triggers the bug:

{
  "agents": {
    "defaults": {
      "heartbeat": { "every": "60m", "target": "channel" }
    },
    "list": [
      { "id": "main" },
      { "id": "ops", "heartbeat": { "every": "0m" } }
    ]
  }
}

Expected: main inherits the 60m default; ops is explicitly disabled.

Actual: main heartbeat is silently disabled because ops has a heartbeat block, flipping the function into "explicit-only" mode.

Fix

Changes to standard override semantics:

  1. Per-agent heartbeat wins if present (including every: "0m" for opt-out)
  2. If no per-agent heartbeat, fall back to agents.defaults.heartbeat
  3. If neither exists, keep the legacy default-agent-only fallback

resolveHeartbeatAgents in heartbeat-runner.ts is updated to match, using isHeartbeatEnabledForAgent per-agent instead of the old hasExplicitHeartbeatAgents gate.

Tests

Updated existing assertions and added coverage for:

  • Mixed defaults + explicit: agents without per-agent config now correctly inherit defaults
  • Explicit disabled (every: "0m") honored even when defaults exist
  • Empty heartbeat object {} treated as absent (falls through to defaults)

Real behavior proof

Config used:

{
  "agents": {
    "defaults": { "heartbeat": { "every": "60m", "target": "channel" } },
    "list": [
      { "id": "main" },
      { "id": "ops", "heartbeat": { "every": "0m" } }
    ]
  }
}

Before fix:

isHeartbeatEnabledForAgent(cfg, "main") // → false (wrong: silently disabled)
isHeartbeatEnabledForAgent(cfg, "ops")  // → false (correct: explicit opt-out)

main has no per-agent heartbeat, so hasExplicitHeartbeatAgents() entering all-or-nothing mode means defaults are never consulted. Cron runner logs show "error": "disabled" for session: main.

After fix:

isHeartbeatEnabledForAgent(cfg, "main") // → true  (correct: inherits default)
isHeartbeatEnabledForAgent(cfg, "ops")  // → false (correct: explicit opt-out)

Per-agent config wins if present. If absent, falls back to agents.defaults.heartbeat. Standard override semantics.

Unit test verification (new cases):

✓ per-agent config wins, others fall back to defaults
✓ explicit disabled (every=0m) honored even when defaults exist
✓ empty heartbeat object {} treated as absent, falls through to defaults

pnpm test heartbeat-runner.returns-default-unset.test.ts — all pass.

Fixes #78713

Changed files

  • src/infra/heartbeat-runner.returns-default-unset.test.ts (modified, +24/-2)
  • src/infra/heartbeat-runner.ts (modified, +10/-24)
  • src/infra/heartbeat-summary.ts (modified, +8/-10)

Code Example

{
  "agents": {
    "defaults": {
      "heartbeat": { "every": "60m", "target": "channel" }
    },
    "list": [
      { "id": "main" },
      { "id": "ops", "heartbeat": { "every": "0m" } }
    ]
  }
}

---

function hasExplicitHeartbeatAgents(cfg) {
  const list = cfg.agents?.list ?? [];
  return list.some((entry) => Boolean(entry?.heartbeat));
}

---

if (hasExplicit) {
  return list.some(
    (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
  );
}
if (cfg.agents?.defaults?.heartbeat) {
  return true;  // never reached
}
RAW_BUFFERClick to expand / collapse

Bug Description

I was setting up heartbeat for a multi-agent config and found something that cost me about an hour to trace. Here's the repro:

Config:

{
  "agents": {
    "defaults": {
      "heartbeat": { "every": "60m", "target": "channel" }
    },
    "list": [
      { "id": "main" },
      { "id": "ops", "heartbeat": { "every": "0m" } }
    ]
  }
}

What I expected:

  • main runs heartbeat every 60m (inherits default)
  • ops explicitly disabled (every=0m means no heartbeat)

What actually happens:

  • ops is correctly disabled
  • main heartbeat is also disabled — silently
  • Cron jobs targeting session: main show "error": "disabled" in heartbeat runner logs

Root cause (confirmed by reading heartbeat-summary.ts):

hasExplicitHeartbeatAgents() returns true because ops has a heartbeat block. That flips isHeartbeatEnabledForAgent into an all-or-nothing mode: only agents with their own heartbeat field are considered enabled. The global agents.defaults.heartbeat is never consulted for agents that don't have explicit per-agent config.

The relevant code in src/infra/heartbeat-summary.ts (~line 30):

function hasExplicitHeartbeatAgents(cfg) {
  const list = cfg.agents?.list ?? [];
  return list.some((entry) => Boolean(entry?.heartbeat));
}

When this returns true, the fallback to agents.defaults.heartbeat is bypassed entirely:

if (hasExplicit) {
  return list.some(
    (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
  );
}
if (cfg.agents?.defaults?.heartbeat) {
  return true;  // never reached
}

Why this is bad UX:

  • No error message tells you the default is being ignored
  • The heartbeat guide says agents.defaults.heartbeat "sets global behavior" — which is true only when zero agents have explicit config
  • In practice this means you can never mix "most agents use default, one agent opt-out" patterns

Suggested fix: Change the logic so per-agent config overrides the default, rather than replacing it entirely:

  1. Look up the agent's own heartbeat config first
  2. If it exists (even if every=0m), use it (every=0m → disabled)
  3. If no per-agent heartbeat exists, fall back to agents.defaults.heartbeat
  4. Only if neither exists, use the old default-agent-only fallback

I can send a PR for this if the direction looks right. Let me know.

Environment:

  • OpenClaw version: 2026.5.5 (commit e28ad6a869)
  • Node: 22.22.2 via nvm
  • OS: Ubuntu 24.04

Additional note: I searched open issues for "heartbeat default" and "hasExplicitHeartbeatAgents" before filing this. Closest was #74250 but that PR was about defaults-only configs, not the mixed defaults+explicit case described here.

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 - ✅(Solved) Fix Bug: agents.defaults.heartbeat is silently ignored when any agent has explicit heartbeat config [1 pull requests, 2 comments, 3 participants]