#22340: fix(heartbeat): drain system events after event-driven heartbeat run
agents
size: M
Cluster:
HEARTBEAT_OK Suppression Fixes
## What changed
- In `runHeartbeatOnce`, drain queued system events when an event-driven heartbeat run is processed (`exec-event`, `cron`, `wake`, `hook`, or tagged cron), instead of only peeking.
- Add a regression test to ensure an exec completion event is consumed on the first event-driven heartbeat and does not re-assert on the next event wake.
## Why this fixes the issue
`issue #22320` reports duplicate heartbeats shortly after tool completion. Heartbeat wakeups were using `peekSystemEventEntries` and did not clear consumed events, so repeated wake triggers could reprocess the same completion context and produce clustered follow-up heartbeats. Draining the queue for event-driven runs makes each tool-completion event one-shot.
## Tests
- `pnpm vitest run --config vitest.unit.config.ts src/infra/heartbeat-runner.returns-default-unset.test.ts src/infra/heartbeat-runner.ghost-reminder.test.ts`
- `pnpm test -- src/infra/heartbeat-runner.returns-default-unset.test.ts`
- `pnpm vitest run --config vitest.unit.config.ts src/infra/heartbeat-wake.test.ts src/infra/heartbeat-runner.scheduler.test.ts`
## Edge cases
- Non-event-driven heartbeats (interval) are unaffected.
- Event-driven failures continue to be retried per existing wake/scheduling behavior.
- `exec-event` events are now one-shot and consumed only after processing.
## Known infra flake / blockers
- None observed in local test runs.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Fixes duplicate heartbeat issue by draining system event queue after event-driven runs and adds Google Gemini 3.1 Pro Preview model fallback.
**Heartbeat fix (commit bb97948a):**
- Changed from `peekSystemEventEntries` to `drainSystemEventEntries` for event-driven heartbeats (`exec-event`, `cron`, `wake`, `hook`)
- Added `withDrainedPendingEvents` wrapper to ensure events are cleared only for event-driven runs
- Added regression test verifying exec completion events are consumed once
**Model catalog fix (commit c9038111):**
- Added `applyGoogle31ProPreviewFallback` to inject `gemini-3.1-pro-preview` when missing from catalog
- Updated test from `toEqual` to `toContainEqual` to handle injected fallback models
**Issues found:**
- Line 878 in `heartbeat-runner.ts` missing `withDrainedPendingEvents` wrapper (one return path after LLM call doesn't drain events)
<h3>Confidence Score: 3/5</h3>
- PR addresses reported issue but has one missed drain path that could reintroduce bug in edge case
- Core logic correctly switches from peek to drain and wraps most return paths, but line 878 (`alerts-disabled` after LLM call) bypasses the drain wrapper, potentially leaving events queued in that edge case. Model catalog changes are straightforward and properly tested.
- src/infra/heartbeat-runner.ts line 878 needs drain wrapper added
<sub>Last reviewed commit: bb97948</sub>
<!-- greptile_other_comments_section -->
<!-- /greptile_comment -->
Most Similar PRs
#19270: fix: retry event-driven heartbeats blocked by requests-in-flight
by deggertsen · 2026-02-17
80.4%
#12786: fix: drop heartbeat runs that arrive while another run is active
by mcaxtr · 2026-02-09
80.1%
#15575: fix(heartbeat): suppress prefixed HEARTBEAT_OK ack replies (#15505)
by TsekaLuk · 2026-02-13
77.7%
#14241: fix(heartbeat): propagate originating session key for exec event qu...
by aldoeliacim · 2026-02-11
77.4%
#19745: fix(heartbeat): enforce interval check regardless of trigger source
by misterdas · 2026-02-18
76.9%
#15422: fix(auto-reply): keep cron systemEvent payloads that start with 'Re...
by liuxiaopai-ai · 2026-02-13
76.3%
#3335: Fixes cron jobs
by hkirat · 2026-01-28
76.1%
#19387: Fix #19302: Filter isError payloads before heartbeat selection
by cedillarack · 2026-02-17
75.9%
#17801: fix(heartbeat): enforce interval guard for non-interval wake reasons
by aldoeliacim · 2026-02-16
75.6%
#12365: test(heartbeat): don't skip empty HEARTBEAT.md for cron wake events
by tyclaudius-ai · 2026-02-09
75.6%