Skip to content

fix(heartbeat): deliver exec events with disabled heartbeat interval#67913

Closed
ToniDonDoni wants to merge 5 commits into
openclaw:mainfrom
ToniDonDoni:fix/62505-heartbeat-exec-event-wake
Closed

fix(heartbeat): deliver exec events with disabled heartbeat interval#67913
ToniDonDoni wants to merge 5 commits into
openclaw:mainfrom
ToniDonDoni:fix/62505-heartbeat-exec-event-wake

Conversation

@ToniDonDoni

@ToniDonDoni ToniDonDoni commented Apr 17, 2026

Copy link
Copy Markdown

Problem:
Background exec completions could be lost when the heartbeat interval was disabled with agents.defaults.heartbeat.every = "0m". The process completed successfully, but the user did not receive the completion output.

Scenario:
A user asks OpenClaw to run a long command. The command runs longer than the exec foreground yield window, so the tool returns a background/running result and the assistant turn ends. The command still finishes before the hard exec timeout, so it exits successfully.

Normally, background exec completion is delivered through the heartbeat/event-wake path: exec enqueues an exec-event wake with the original chat delivery context, and the heartbeat runner starts a follow-up agent turn to relay the command output back to the chat. When the heartbeat interval was disabled, the runner treated the wake as disabled before dispatching it, so the follow-up turn never happened.

Solution:
Allow exec-event wakes to bypass the normal per-agent heartbeat interval checks while preserving the global heartbeat enable switch. For targeted exec wakes, create a one-shot runner entry even when no interval-backed heartbeat agent is scheduled. For exec events without an explicit heartbeat target, default to target: "last" so the captured delivery context can be used to send the completion output to the original chat.

Added unit coverage for:

  • Session-scoped exec wakes when the heartbeat interval is disabled.
  • Exec completion relay with captured delivery context when the heartbeat interval is disabled.

Verification:

  • pnpm test src/infra/heartbeat-runner.scheduler.test.ts -t "disabled heartbeat interval"
  • pnpm test src/infra/heartbeat-runner.returns-default-unset.test.ts -t "disabled heartbeat interval"

Relates to #62505

@greptile-apps

greptile-apps Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where background exec completions were silently dropped when agents.defaults.heartbeat.every = "0m" disabled the heartbeat interval. The fix adds an isExecEventReason bypass in runHeartbeatOnce so exec-events skip the per-agent enable and interval checks (the global areHeartbeatsEnabled() gate is preserved), and extends the startHeartbeatRunner dispatcher to create a synthetic one-shot agent entry for exec-events even when no interval-backed agents are scheduled. A target: "last" default on effectiveHeartbeat ensures the captured delivery context is used to route exec-output replies. Two new unit tests cover both the scheduler dispatch and the end-to-end WhatsApp relay path.

Confidence Score: 5/5

Safe to merge; the only finding is a minor logging inconsistency that doesn't affect runtime behavior.

Fix is correct and well-scoped: the global heartbeat kill-switch is preserved, the synthetic one-shot agent never advances the schedule, and both the scheduler and delivery layers are tested. The single P2 comment (stale heartbeat?.target in two log lines) has no impact on correctness or delivery.

src/infra/heartbeat-runner.ts lines 795 and 800 (cosmetic log inconsistency only).

Comments Outside Diff (1)

  1. src/infra/heartbeat-runner.ts, line 793-801 (link)

    P2 Stale heartbeat?.target in log lines after effectiveHeartbeat override

    Both log entries still read heartbeat?.target ?? "none" (the original config object) instead of effectiveHeartbeat?.target ?? "none". For exec-events where effectiveHeartbeat silently sets target: "last", the log will say target: "none", which can mislead anyone debugging an unexpected delivery path.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/infra/heartbeat-runner.ts
    Line: 793-801
    
    Comment:
    **Stale `heartbeat?.target` in log lines after effectiveHeartbeat override**
    
    Both log entries still read `heartbeat?.target ?? "none"` (the original config object) instead of `effectiveHeartbeat?.target ?? "none"`. For exec-events where `effectiveHeartbeat` silently sets `target: "last"`, the log will say `target: "none"`, which can mislead anyone debugging an unexpected delivery path.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/infra/heartbeat-runner.ts
Line: 793-801

Comment:
**Stale `heartbeat?.target` in log lines after effectiveHeartbeat override**

Both log entries still read `heartbeat?.target ?? "none"` (the original config object) instead of `effectiveHeartbeat?.target ?? "none"`. For exec-events where `effectiveHeartbeat` silently sets `target: "last"`, the log will say `target: "none"`, which can mislead anyone debugging an unexpected delivery path.

```suggestion
  if (delivery.reason === "unknown-account") {
    log.warn("heartbeat: unknown accountId", {
      accountId: delivery.accountId ?? heartbeatAccountId ?? null,
      target: effectiveHeartbeat?.target ?? "none",
    });
  } else if (heartbeatAccountId) {
    log.info("heartbeat: using explicit accountId", {
      accountId: delivery.accountId ?? heartbeatAccountId,
      target: effectiveHeartbeat?.target ?? "none",
      channel: delivery.channel,
    });
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(heartbeat): deliver exec events with..." | Re-trigger Greptile

@ToniDonDoni

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex Review: Didn't find any major issues. What shall we delve into next?

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Author

@codex review

Copy link
Copy Markdown
Author

Addressed the Greptile review comment about stale heartbeat target logging.

Problem addressed:

  • src/infra/heartbeat-runner.ts now logs effectiveHeartbeat?.target ?? "none" in the account-related heartbeat log paths, so exec-event runs that default to target: "last" do not log a misleading target: "none".

CI note:

  • The previous failing checks-node-core-ui job failed during checkout/setup before tests ran: fatal: unable to read tree (...) followed by missing .github/actions/setup-node-env. A new push should rerun that lane against the updated head.

Verification run locally:

  • pnpm test src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/heartbeat-runner.scheduler.test.ts src/infra/heartbeat-events.test.ts src/infra/heartbeat-reason.test.ts src/infra/heartbeat-runner.subagent-session-guard.test.ts src/infra/heartbeat-visibility.test.ts src/infra/heartbeat-runner.skips-busy-session-lane.test.ts src/infra/heartbeat-wake.test.ts src/infra/heartbeat-runner.ghost-reminder.test.ts src/infra/heartbeat-active-hours.test.ts src/infra/heartbeat-schedule.test.ts src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts src/infra/heartbeat-runner.model-override.test.ts src/infra/heartbeat-runner.isolated-key-stability.test.ts src/infra/heartbeat-events-filter.test.ts src/infra/heartbeat-runner.transcript-prune.test.ts
    • vitest.unit-fast: 5 files passed, 83 tests passed.
    • vitest.infra: 12 files passed, 116 tests passed.
  • pnpm build passed.

Push:

  • Pushed fix/62505-heartbeat-exec-event-wake to ToniDonDoni/openclaw at 6c449137d1.

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex Review: Didn't find any major issues. Bravo.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Author

Addressed blocker 1 in f83a93f4a5.

What changed:

  • exec-event wakes now short-circuit with skipped/no-exec-completion when the inspected queue has no real exec completion.
  • This happens before any model turn and before inspected system events are consumed, so an empty/duplicate exec wake cannot accidentally run a normal heartbeat under every: "0m".
  • Added regression coverage that queues a non-exec system event under disabled heartbeat interval, fires an exec-event wake, verifies no reply/model call happens, and verifies the queued event remains queued.

Verification run locally:

  • pnpm test src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/heartbeat-runner.scheduler.test.ts -> passed, 2 files / 43 tests.
  • pnpm test src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/heartbeat-runner.scheduler.test.ts src/infra/heartbeat-events.test.ts src/infra/heartbeat-reason.test.ts src/infra/heartbeat-runner.subagent-session-guard.test.ts src/infra/heartbeat-visibility.test.ts src/infra/heartbeat-runner.skips-busy-session-lane.test.ts src/infra/heartbeat-wake.test.ts src/infra/heartbeat-runner.ghost-reminder.test.ts src/infra/heartbeat-active-hours.test.ts src/infra/heartbeat-schedule.test.ts src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts src/infra/heartbeat-runner.model-override.test.ts src/infra/heartbeat-runner.isolated-key-stability.test.ts src/infra/heartbeat-events-filter.test.ts src/infra/heartbeat-runner.transcript-prune.test.ts -> passed, 17 files / 200 tests.

Blocker 2 is intentionally deferred for separate investigation. I did not broaden this change to synthetic node-* fallback routing.

Copy link
Copy Markdown
Author

@codex review
Fixed blocker 1 now.

What changed:

  • exec-event wakes now short-circuit with skipped/no-exec-completion when the inspected queue does not contain a real exec completion event.
  • This prevents empty/stale/duplicate exec wakes from falling back into the normal heartbeat prompt path under heartbeat.every: "0m".
  • The skip path does not consume inspected system events, so unrelated queued events remain queued.

Regression coverage added:

  • runHeartbeatOnce({ reason: "exec-event" }) with disabled heartbeat interval and only an unrelated queued system event now:
    • does not call the model
    • does not send outbound user messages
    • leaves the unrelated system event queued

Blocker 2 is deferred for separate investigation: I did not change the synthetic node-* fallback/session-target behavior in this commit.

Verification run locally:

  • pnpm test src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/heartbeat-runner.scheduler.test.ts passed: 2 files, 43 tests.
  • pnpm test src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/heartbeat-runner.scheduler.test.ts src/infra/heartbeat-events.test.ts src/infra/heartbeat-reason.test.ts src/infra/heartbeat-runner.subagent-session-guard.test.ts src/infra/heartbeat-visibility.test.ts src/infra/heartbeat-runner.skips-busy-session-lane.test.ts src/infra/heartbeat-wake.test.ts src/infra/heartbeat-runner.ghost-reminder.test.ts src/infra/heartbeat-active-hours.test.ts src/infra/heartbeat-schedule.test.ts src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts src/infra/heartbeat-runner.model-override.test.ts src/infra/heartbeat-runner.isolated-key-stability.test.ts src/infra/heartbeat-events-filter.test.ts src/infra/heartbeat-runner.transcript-prune.test.ts passed: unit-fast 5 files/83 tests, infra 12 files/117 tests.
  • pnpm build passed on rerun. The first build attempt failed in generated postbuild staging because dist/extensions/speech-core/package.json was missing at that moment; rerunning after generated output existed passed.

Pushed commit: f83a93f4a55a9742c14f8b2f9d6d21d83da7bcc0 (fix(heartbeat): skip empty exec-event wakes).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f83a93f4a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

reason: "disabled",
} satisfies HeartbeatRunResult;
const reasonKind = resolveHeartbeatReasonKind(params?.reason);
if (reasonKind !== "exec-event" || (!params?.sessionKey && !params?.agentId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle unscoped exec-event wakes when heartbeat is disabled

When state.agents.size === 0, this guard only lets exec-event wakes run if they already include sessionKey or agentId, but node exec completions can intentionally enqueue unscoped wakes for synthetic node-* session fallbacks (via scopedHeartbeatWakeOptions). In that case the wake still returns skipped/disabled, so background node exec completions are not relayed under heartbeat.every: "0m", which leaves the original blocker unresolved for that execution path.

Useful? React with 👍 / 👎.

heartbeatFileContent: preflight.heartbeatFileContent,
});

if (preflight.isExecEventReason && !hasExecCompletion) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid skipping queued cron work on empty exec-event wakes

This early return skips the run whenever the wake reason is exec-event and no exec completion is present, even if cron-tagged events are queued in the same session. Because wake coalescing gives exec-event higher priority than cron:* for the same wake target, a stale/duplicate exec wake can mask a cron wake, and this branch then exits without consuming or re-waking cron work; with disabled interval scheduling, those cron events can remain stuck until another unrelated wake arrives.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b351829323

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +832 to +836
if (preflight.isExecEventReason && !hasExecCompletion) {
emitHeartbeatEvent({
status: "skipped",
reason: "no-exec-completion",
durationMs: Date.now() - startedAt,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Process exec-denied wakes instead of dropping them

This new guard skips all exec-event wakes that don't match isExecCompletionEvent, but src/gateway/server-node-events.ts emits reason: "exec-event" for exec.denied and exec.started too. Those wakes now return no-exec-completion before any reply run or event consumption, so denied node exec outcomes can be left unsurfaced (and queued) unless a later true completion in the same session happens to drain them. Previously these wakes still executed a heartbeat turn and could relay the failure context.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

@codex review
I disagree with the P1 as written for the current server-node-events.ts node exec path.

The reviewed concern assumes synthetic node-* fallback exec completions still produce an unscoped exec-event wake. Current code appears to canonicalize before enqueue/wake:

  • src/gateway/server-node-events.ts derives sessionKeyRaw = obj.sessionKey ?? node-${nodeId}``, then immediately calls loadSessionEntry(sessionKeyRaw) and uses `canonicalKey` as `sessionKey`.
  • loadSessionEntry() calls resolveSessionStoreKey({ cfg, sessionKey }).
  • resolveSessionStoreKey() canonicalizes non-agent keys into agent:<default-agent>:<key>.
  • requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" })) therefore receives an agent:* key for the fallback path, so scopedHeartbeatWakeOptions() preserves sessionKey.
  • src/gateway/server-node-events.test.ts already has coverage asserting fallback node-node-2 becomes agent:main:node-node-2 and the wake includes that scoped session key.

So I would not fix P1 blindly without a reproduction showing a different node exec completion path bypasses this canonicalization. The nearby comment about synthetic node-* keeping legacy unscoped behavior looks stale/misleading relative to the current code.

P2 still looks valid: the new exec-event && !hasExecCompletion early return ignores hasCronEvents, so if wake coalescing lets an exec-event reason win over a same-target cron:* wake, queued cron-tagged events can remain stuck under heartbeat.every: "0m" until another wake arrives.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b351829323

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

heartbeatFileContent: preflight.heartbeatFileContent,
});

if (preflight.isExecEventReason && !hasExecCompletion) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Process coalesced cron events before skipping exec wakes

This guard skips the run whenever the wake reason is exec-event and no exec completion is present, but it does not account for hasCronEvents. In the current wake queue, reasons are coalesced per target and exec-event has higher priority than cron:* (src/infra/heartbeat-wake.ts), so a same-target cron wake can be collapsed into an exec-event run that reaches this branch with hasCronEvents === true. With heartbeat.every: "0m", that early return means the queued cron events are never processed unless another wake happens later.

Useful? React with 👍 / 👎.

ToniDonDoni commented Apr 17, 2026

Copy link
Copy Markdown
Author

Closing this PR.

After re-checking the design, this is the wrong layer to keep patching. Exec completion delivery is not a heartbeat. It is a system event dispatch problem.

The current PR keeps adding exceptions inside heartbeat scheduling/runner code because exec completions are being routed through heartbeat wakes. Probably heartbeat.every should control periodic heartbeat work only, not whether queued system events like exec completion can be delivered.

Continuing to patch heartbeat edge cases here doesn’t appear to address the root issue. Exec completion would be better handled as a separate system event path, reusing shared lower-level agent-turn/delivery helpers if needed, instead of depending on heartbeat interval semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant