fix(heartbeat): skip heartbeat when main session is stale to allow daily reset#53014
fix(heartbeat): skip heartbeat when main session is stale to allow daily reset#53014daniel-knox wants to merge 1 commit into
Conversation
Greptile SummaryThis PR fixes the heartbeat vector of the daily-reset staleness bug by adding a session-freshness preflight check in One issue to address:
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/infra/heartbeat-runner.ts
Line: 457-481
Comment:
**Stale check fires before `shouldBypassFileGates`, silently dropping exec/cron/wake events**
The freshness guard is evaluated before the `if (shouldBypassFileGates) { return basePreflight; }` branch at line 483. That means non-isolated heartbeats triggered by an `exec-event`, `cron`, or `wake`/`hook` reason are also skipped when the session is stale — even though `shouldBypassFileGates` was introduced precisely to ensure those event-driven runs bypass all file-level gates.
Concrete scenario: a background task finishes after 4 AM. The exec-completion heartbeat fires, resolves a stale session, and returns `skipReason: "session-stale"`. The pending exec event stays in the queue until the next user message triggers the session reset — which could be hours later.
The scope described in the PR ("skip heartbeats that would touch the session, preventing the daily reset") only applies to ordinary scheduled heartbeats, not to event-driven ones. The guard should also check `!shouldBypassFileGates` so that exec, cron, and wake heartbeats remain unaffected:
```
if (!params.heartbeat?.isolatedSession && session.entry && !shouldBypassFileGates) {
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(heartbeat): skip heartbeat when main..." | Re-trigger Greptile |
| if (!params.heartbeat?.isolatedSession && session.entry) { | ||
| const now = Date.now(); | ||
| const resetType = resolveSessionResetType({ sessionKey: session.sessionKey }); | ||
| const channelReset = resolveChannelResetConfig({ | ||
| sessionCfg: params.cfg.session, | ||
| channel: session.entry.lastChannel ?? session.entry.channel, | ||
| }); | ||
| const resetPolicy = resolveSessionResetPolicy({ | ||
| sessionCfg: params.cfg.session, | ||
| resetType, | ||
| resetOverride: channelReset, | ||
| }); | ||
| const freshness = evaluateSessionFreshness({ | ||
| updatedAt: session.entry.updatedAt, | ||
| now, | ||
| policy: resetPolicy, | ||
| }); | ||
|
|
||
| if (!freshness.fresh) { | ||
| return { | ||
| ...basePreflight, | ||
| skipReason: "session-stale", | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Stale check fires before
shouldBypassFileGates, silently dropping exec/cron/wake events
The freshness guard is evaluated before the if (shouldBypassFileGates) { return basePreflight; } branch at line 483. That means non-isolated heartbeats triggered by an exec-event, cron, or wake/hook reason are also skipped when the session is stale — even though shouldBypassFileGates was introduced precisely to ensure those event-driven runs bypass all file-level gates.
Concrete scenario: a background task finishes after 4 AM. The exec-completion heartbeat fires, resolves a stale session, and returns skipReason: "session-stale". The pending exec event stays in the queue until the next user message triggers the session reset — which could be hours later.
The scope described in the PR ("skip heartbeats that would touch the session, preventing the daily reset") only applies to ordinary scheduled heartbeats, not to event-driven ones. The guard should also check !shouldBypassFileGates so that exec, cron, and wake heartbeats remain unaffected:
if (!params.heartbeat?.isolatedSession && session.entry && !shouldBypassFileGates) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/heartbeat-runner.ts
Line: 457-481
Comment:
**Stale check fires before `shouldBypassFileGates`, silently dropping exec/cron/wake events**
The freshness guard is evaluated before the `if (shouldBypassFileGates) { return basePreflight; }` branch at line 483. That means non-isolated heartbeats triggered by an `exec-event`, `cron`, or `wake`/`hook` reason are also skipped when the session is stale — even though `shouldBypassFileGates` was introduced precisely to ensure those event-driven runs bypass all file-level gates.
Concrete scenario: a background task finishes after 4 AM. The exec-completion heartbeat fires, resolves a stale session, and returns `skipReason: "session-stale"`. The pending exec event stays in the queue until the next user message triggers the session reset — which could be hours later.
The scope described in the PR ("skip heartbeats that would touch the session, preventing the daily reset") only applies to ordinary scheduled heartbeats, not to event-driven ones. The guard should also check `!shouldBypassFileGates` so that exec, cron, and wake heartbeats remain unaffected:
```
if (!params.heartbeat?.isolatedSession && session.entry && !shouldBypassFileGates) {
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 433991dcae
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!freshness.fresh) { | ||
| return { | ||
| ...basePreflight, | ||
| skipReason: "session-stale", |
There was a problem hiding this comment.
Preserve cron/exec heartbeats when session is stale
If a non-isolated heartbeat is triggered by a queued cron event, exec completion, or wake after the daily reset boundary, this early session-stale return suppresses the run before the existing bypass path can handle it (src/infra/heartbeat-runner.ts:441-445, src/infra/heartbeat-runner.ts:548-552). In that case the reminder/result is no longer delivered promptly; it sits in the system-event queue until some later turn drains it (src/auto-reply/reply/session-system-events.ts:82), so overnight reminders and exec completions can be delayed until the next user message or surface in the wrong conversation turn.
Useful? React with 👍 / 👎.
| const freshness = evaluateSessionFreshness({ | ||
| updatedAt: session.entry.updatedAt, | ||
| now, | ||
| policy: resetPolicy, | ||
| }); |
There was a problem hiding this comment.
Restrict the stale-session skip to daily resets
This uses evaluateSessionFreshness() with the full reset policy, not just the daily-reset case described in the commit. resolveSessionResetPolicy() includes idleMinutes and legacy session.idleMinutes in that policy (src/config/sessions/reset.ts:84-119), so any deployment using idle-based session expiry will now return session-stale for every non-isolated heartbeat once the user has been quiet longer than the idle window. Before this change, the heartbeat path could still roll over to a fresh session via resolveSession() when fresh was false (src/agents/command/session.ts:140-146); now heartbeat-driven reminders/check-ins stop entirely until the next inbound message.
Useful? React with 👍 / 👎.
…ily reset - Add session freshness check to resolveHeartbeatPreflight() - Skip heartbeat if main session is past daily reset window - Only applies to non-isolated heartbeats - Preserves session state for proper reset on next user message - Prevents heartbeat from blocking daily session resets Fixes openclaw#51000
433991d to
2ab2d85
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ab2d85e51
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // If the main session is stale (past the daily reset window), skip the heartbeat | ||
| // so the session can reset naturally when the user sends their first real message. | ||
| // Event-driven heartbeats (exec, cron, wake) bypass this via shouldBypassFileGates. | ||
| if (!params.heartbeat?.isolatedSession && session.entry && !shouldBypassFileGates) { |
There was a problem hiding this comment.
Bypass stale-session skips for nonstandard heartbeat wakes
requestHeartbeatNow() callers such as src/gateway/server-node-events.ts:487-492 (notifications-event) and src/agents/cli-runner.ts:357-366 (cli:watchdog:stall) queue system events specifically so the next heartbeat can flush them immediately. Those reasons are classified as other by src/infra/heartbeat-reason.ts:20-46, so they do not hit the existing bypass path; on a stale session this new early return now exits before the reply pipeline can drain the queued event (src/auto-reply/reply/session-system-events.ts:82-93). In practice, notification/stall notices can sit in memory until some unrelated later turn instead of being delivered by the wake that requested them.
Useful? React with 👍 / 👎.
| if (!params.heartbeat?.isolatedSession && session.entry && !shouldBypassFileGates) { | ||
| const now = Date.now(); | ||
| const resetType = resolveSessionResetType({ sessionKey: session.sessionKey }); | ||
| const channelReset = resolveChannelResetConfig({ | ||
| sessionCfg: params.cfg.session, | ||
| channel: session.entry.lastChannel ?? session.entry.channel, | ||
| }); |
There was a problem hiding this comment.
Limit the stale-session guard to the default main session
resolveHeartbeatSession() above can return an explicit heartbeat.session / runHeartbeatOnce({ sessionKey }) override instead of the default main session (src/infra/heartbeat-runner.ts:199-256, src/config/types.agent-defaults.ts:237-238). For configs that pin heartbeats to a dedicated channel/thread session, this new check makes interval heartbeats stop as soon as that override crosses the reset window, and because the heartbeat is often the only writer to that session, it stays permanently session-stale until someone sends a real message in that exact conversation. That turns a supported session override into a one-day-only configuration even though the daily-reset bug described in the commit only needs protection on the default main-session path.
Useful? React with 👍 / 👎.
…t working (openclaw#63732) When a heartbeat, cron-event, or exec-event run saves the session entry, the code was unconditionally writing `updatedAt: Date.now()`. This caused evaluateSessionFreshness to always see the session as fresh (since updatedAt was refreshed on every heartbeat tick), effectively disabling the daily reset policy. Fix: system events (heartbeat/cron-event/exec-event) now preserve the existing updatedAt value from the base entry instead of advancing it to the current time. Only real user interactions should advance updatedAt and thus reset the freshness clock. Bisected to commit 6c3eea3 (v2026.4.1) by @martingarramon. Related prior reports: openclaw#51000, openclaw#46539. Open fix attempt: openclaw#53014. Fixes openclaw#63732
|
Thanks for isolating the heartbeat vector. I’m closing this as superseded by the broader maintainer fix in #71845. What changed in the replacement:
This keeps the useful intent from this PR while avoiding the review concern where stale checks could drop event-driven heartbeats/exec completions. Closing in favor of #71845. |
Fixes #51000 (heartbeat vector)
Problem
The heartbeat runner resolves the main session via its own
resolveHeartbeatSession()function, which does not callevaluateSessionFreshness(). When a heartbeat fires aftersession.reset.atHour(e.g., 4 AM) but before the user's first message, the heartbeat touches the session entry, preventing the lazy daily reset from ever firing.This is the same root cause as #51000 (cron jobs overwriting
updatedAt), but through the heartbeat code path. The cron fix (unique session keys) addressed one vector; this PR addresses the remaining one.Fix
Added a session freshness check in
resolveHeartbeatPreflight()that mirrors the logic inresolveSession():evaluateSessionFreshness()skipReason: "session-stale"The session's
updatedAtremains untouched, so the next real user message triggers the reset naturally through the existingresolveSession()path.Scope
resolveSession()insrc/agents/command/session.tsChanges
src/infra/heartbeat-runner.ts- Added freshness check inresolveHeartbeatPreflight(), extendedHeartbeatSkipReasontype with"session-stale"Testing
oxfmt --check