fix(agents): heartbeat is opt-in, not opt-out - #833
Merged
Conversation
The dispatch guard was `enabled === false`, so an installation that had never expressed an opinion about heartbeats still woke on a timer. Nobody chose that — it is what a default of "on" produces when the setting lives in a config field most owners never see. Measured on production 2026-08-04: of 245 active installations across 48 distinct owners, 166 were ticking on that default alone, and 59 were off only because someone had gone and set the flag by hand. Each tick spends the owner's own model quota, and for BYO agents that is a bill we send to someone who never asked for a heartbeat and has no product surface to stop one (#832). It also runs a heartbeat with nothing behind it: the prompt tells the agent to read a HEARTBEAT.md that nothing provisions on the CAP path (#800). The modal outcome instance-wide is an agent waking hourly to execute an instruction pointing at nothing. Both guards now require `enabled === true`. The migration is unusually clean: this turns off exactly the 166 that never opted in and preserves all 20 that set the flag explicitly, so no owner who chose a heartbeat loses one. Tests cover `undefined` vs `true` specifically, which is the distinction that changed — a test written only against `false` would have passed before and after. Mutation-verified: reverting the guard fails exactly the two undefined cases and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016s8YysVUNmmiNXw3cTrZFJ
#804's cue tests build an installation that never sets heartbeat.enabled, which was fine when the guard was `=== false`. Under opt-in it does not dispatch, so the suite failed at its own `expect(enqueue).toHaveBeenCalled()` precondition before reaching any content assertion. The fixture opts in. These tests are about what the cue says, not about the gate — the gate has its own file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016s8YysVUNmmiNXw3cTrZFJ
samxu01
force-pushed
the
fix/heartbeat-opt-in
branch
from
August 4, 2026 20:13
ef07e8f to
3988df4
Compare
This was referenced Aug 4, 2026
Deploy Dev reports failure on every successful deploy — and it has made helm rollback dangerous
#835
Closed
lilyshen0722
added a commit
that referenced
this pull request
Aug 4, 2026
…#834) (#838) `enqueueMentions` returned before enqueueing anything when a message carried no @mention and no replyTo, and the implicit path from #703 is gated on replies. So the one message most likely to need an answer — a newcomer's first — was the one guaranteed not to get one. Observed live in Commonly HQ on 2026-08-04: a new member asked a question and it sat fourteen hours, in a room whose pinned welcome promises a support agent answers within a minute. Scope, kept narrow deliberately: this is a first-message welcome wake, NOT general routing of unaddressed messages. The member whose day-30 question goes unanswered is out of scope and stays there until ADR-017 ships a policy layer. Why it can ship before that layer, when a general rule cannot: the trigger is a monotone state transition (never-spoken -> spoken), so fires are bounded by the state space — members x pods, amortized one per join — INDEPENDENT of traffic. Message-arrival and clock triggers are bounded only by traffic, and taming those needs the budget machinery ADR-017 and #832 have not shipped. #800's incident was a clock trigger: eleven wakes, zero edges. Four things the design turns on: - The marker is claimed for every first message, addressed or not. Claiming only unaddressed ones would welcome a member on their SECOND message when their opener was "@codex help". - Greeters are explicit opt-in (`config.welcomeWake.enabled === true`), never inferred. "The pod's support agent" is not a schema concept, and guessing one — sole install, oldest, name contains "support" — makes the target drift silently as installs change. Same stance as #833's heartbeat flag. - A first message with no designated greeter still claims the marker and logs a warning. Silent no-op was the failure mode most likely to ship: feature live, nothing happening, nothing saying why. - The event is enqueued as `chat.mention`, not a new type. The CLI wrapper's extractPrompt returns null for anything outside PROMPT_EVENT_TYPES, so a bespoke `pod.first_message` would be dropped by every already-deployed wrapper — the #611 failure mode. Sender resolution moves above the unrouted early-return so the wake can check `isBot === false`; every existing use of it is downstream and unchanged. AgentProfile now loads only on the routed path, holding the added cost of reaching that point to two queries. Known cost, stated rather than buried: one marker upsert per human message. Bounded and indexed, but a write where there was none. If profiling shows it, the fix is a read-first fast path — the correct-and-simple version matches firstContactService exactly, and matching the reviewed pattern beat inventing a faster unreviewed one. Ships inert: nothing is welcomed until an install opts in. Tests: 18 service + 6 wiring, plus 41 existing mention tests unchanged. Mutation-tested — which caught dead code in my own first draft, an `isRouted ? [] : ...` guard that read as decisive and survived deletion.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sam's call: default heartbeat off, on only if the owner enables it.
The guard was
enabled === false, soundefinedfired. Measured on production today:enabled: true)Each tick spends that owner's own model quota. For BYO agents it is a bill sent to someone who never asked for a heartbeat and has no product surface to stop one (#832) — to run a heartbeat whose prompt points at a
HEARTBEAT.mdthat nothing provisions on the CAP path (#800).Migration is clean: turns off exactly the 166 that never opted in, preserves all 20 that did.
Both guards now require
=== true. Tests targetundefinedvstrue— the distinction that actually changed — and are mutation-verified: reverting the guard fails exactly the two undefined cases.