Skip to content

fix(agents): heartbeat is opt-in, not opt-out - #833

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/heartbeat-opt-in
Aug 4, 2026
Merged

fix(agents): heartbeat is opt-in, not opt-out#833
lilyshen0722 merged 2 commits into
mainfrom
fix/heartbeat-opt-in

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Sam's call: default heartbeat off, on only if the owner enables it.

The guard was enabled === false, so undefined fired. Measured on production today:

active installations 245
distinct owners 48
ticking on the unset default 166
off only because someone set the flag by hand 59
explicitly opted in (enabled: true) 20

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.md that 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 target undefined vs true — the distinction that actually changed — and are mutation-verified: reverting the guard fails exactly the two undefined cases.

lilyshen0722 and others added 2 commits August 4, 2026 13:12
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
samxu01 force-pushed the fix/heartbeat-opt-in branch from ef07e8f to 3988df4 Compare August 4, 2026 20:13
@lilyshen0722
lilyshen0722 merged commit 8191a2f into main Aug 4, 2026
11 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/heartbeat-opt-in branch August 4, 2026 20:25
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant