Skip to content

fix(heartbeat): skip heartbeat when main session is stale to allow daily reset#53014

Closed
daniel-knox wants to merge 1 commit into
openclaw:mainfrom
daniel-knox:fix/heartbeat-session-reset
Closed

fix(heartbeat): skip heartbeat when main session is stale to allow daily reset#53014
daniel-knox wants to merge 1 commit into
openclaw:mainfrom
daniel-knox:fix/heartbeat-session-reset

Conversation

@daniel-knox

Copy link
Copy Markdown

Fixes #51000 (heartbeat vector)

Problem

The heartbeat runner resolves the main session via its own resolveHeartbeatSession() function, which does not call evaluateSessionFreshness(). When a heartbeat fires after session.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 in resolveSession():

  1. Resolves reset policy (mode, atHour, channel overrides)
  2. Evaluates session freshness via evaluateSessionFreshness()
  3. If the session is stale (past the daily reset window), skips the heartbeat with skipReason: "session-stale"

The session's updatedAt remains untouched, so the next real user message triggers the reset naturally through the existing resolveSession() path.

Scope

  • Only affects non-isolated heartbeats (isolated heartbeats create their own session and don't interfere with the main session)
  • Only skips when there is an existing session entry to evaluate
  • Uses the exact same freshness evaluation chain as resolveSession() in src/agents/command/session.ts

Changes

  • src/infra/heartbeat-runner.ts - Added freshness check in resolveHeartbeatPreflight(), extended HeartbeatSkipReason type with "session-stale"

Testing

  • Manually confirmed: with this change, heartbeats after 4 AM on a stale session would be skipped, allowing the daily reset to fire on the next user message
  • Formatting verified with oxfmt --check

@greptile-apps

greptile-apps Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the heartbeat vector of the daily-reset staleness bug by adding a session-freshness preflight check in resolveHeartbeatPreflight(). The core logic — mirroring resolveSession() with resolveSessionResetTyperesolveChannelResetConfigresolveSessionResetPolicyevaluateSessionFreshness — is correct and consistent with the rest of the codebase.

One issue to address:

  • The stale-session guard is positioned before the shouldBypassFileGates return path (line 483), which means heartbeats triggered by exec-event, cron, or wake/hook reasons are also skipped for stale sessions. Since shouldBypassFileGates exists specifically to allow event-driven heartbeats through all file-level gates, exec completion events and similar time-sensitive events will now be silently dropped until the user's next message. Adding && !shouldBypassFileGates to the guard condition would preserve the existing event-driven behaviour while still preventing ordinary scheduled heartbeats from touching the stale session.

Confidence Score: 3/5

  • One targeted fix is needed before merging: the stale check must be gated on !shouldBypassFileGates to avoid silently dropping exec-completion and other event-driven heartbeats on stale sessions.
  • The approach and freshness-evaluation logic are correct, but the guard placement creates a regression for exec/cron/wake heartbeats that is likely to surface in production (any user whose background task completes overnight won't get notified until their next message). This warrants a 3 rather than 4 because the affected code path is a primary user-facing delivery mechanism.
  • src/infra/heartbeat-runner.ts — specifically the guard condition at line 457 of the new freshness block.
Prompt To Fix All 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.

Reviews (1): Last reviewed commit: "fix(heartbeat): skip heartbeat when main..." | Re-trigger Greptile

Comment thread src/infra/heartbeat-runner.ts Outdated
Comment on lines +457 to +481
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",
};
}
}

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 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.

@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: 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".

Comment on lines +475 to +478
if (!freshness.fresh) {
return {
...basePreflight,
skipReason: "session-stale",

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 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 👍 / 👎.

Comment on lines +469 to +473
const freshness = evaluateSessionFreshness({
updatedAt: session.entry.updatedAt,
now,
policy: resetPolicy,
});

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 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
@daniel-knox
daniel-knox force-pushed the fix/heartbeat-session-reset branch from 433991d to 2ab2d85 Compare March 23, 2026 16:55

@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: 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) {

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 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 👍 / 👎.

Comment on lines +458 to +464
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,
});

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 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 👍 / 👎.

lml2468 pushed a commit to lml2468/openclaw that referenced this pull request Apr 9, 2026
…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
@steipete steipete added duplicate This issue or pull request already exists close:duplicate Closed as duplicate labels Apr 26, 2026
@steipete

Copy link
Copy Markdown
Contributor

Thanks for isolating the heartbeat vector. I’m closing this as superseded by the broader maintainer fix in #71845.

What changed in the replacement:

  • instead of skipping stale heartbeat runs, reset freshness is decoupled from updatedAt
  • daily reset now uses sessionStartedAt
  • idle reset now uses lastInteractionAt
  • heartbeat/cron/exec/system-event writes can continue to run for delivery/context, but they no longer keep the target session fresh
  • the fix also covers auto-reply, agent command session-store writes, cron sessions, and gateway-agent sessions, not just heartbeat preflight
  • regression tests cover the heartbeat case that this PR was trying to protect

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.

@steipete steipete closed this Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

close:duplicate Closed as duplicate duplicate This issue or pull request already exists size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Isolated cron jobs with explicit sessionKey overwrite parent session's updatedAt, preventing daily reset

2 participants