fix(mentions): a member's first message in a pod now reaches an agent (#834) - #838
Merged
Merged
Conversation
…#834) `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.
Closes #834.
enqueueMentionsreturned before enqueueing anything when a message carried no@mentionand noreplyTo(agentMentionService.ts:721), and the implicit path from #703 is gated on replies (:999). 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. That promise only ever held for people who already knew to type
@commonly-support— which someone asking "what is this" definitionally does not. HQ also auto-joins at verify, so they had not chosen the room either.Scope, and why the name matters
This is a first-message welcome wake. It is not general routing of unaddressed messages. The member whose day-30 question goes unanswered is out of scope and stays out of scope until ADR-017 ships a policy layer.
Why this can ship before that layer when a general rule cannot — the distinction is edge-triggered vs level-triggered. The trigger here is a monotone state transition (never-spoken → spoken), so total fires are bounded by the state space (members × 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.
That gives a reusable test for future point-fixes here: bounded by a finite state space → can ship now; bounded only by traffic → waits for budgets.
The proxy-calcification worry is real but does not apply: any future wake policy strictly supersedes "a new member's first message is wake-worthy" — it absorbs this rule rather than ripping it out. It is a floor, not a ceiling.
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— a greeting arriving after the conversation already started.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 install, name contains "support" — makes the wake target drift silently as installs change. Same stance as #833's heartbeat flag, for the same reason.A first message with no designated greeter still claims the marker, and logs a warning. The silent no-op was the failure mode most likely to ship: feature live, marker claimed, nothing happening, and nothing anywhere saying why. It still claims — otherwise designating a greeter later would welcome every existing member on their next message.
The event is enqueued as
chat.mention, not a new type. The CLI wrapper'sextractPromptreturnsnullfor anything outsidePROMPT_EVENT_TYPES(chat.mention,message.posted,dm.message,first_contact), so a bespokepod.first_messagewould be silently dropped by every already-deployed wrapper — the #611 failure mode, and it would have looked like the feature simply did not work. An additivewelcomeWake: truepayload flag costs nothing and works on drivers shipped before this existed.Wiring
Sender resolution moves above the unrouted early-return so the wake can check
isBot === false; every existing use of it is downstream and unchanged. Bots never claim or wake — an agent's first post would otherwise seed a loop, the same reasoning that gates #703 onisBot === false.AgentProfilenow loads only on the routed path, holding the added cost of reaching that point to two queries rather than three.PodMemberFirstMessagegets its own collection rather than a flag onPod.members: membership rows are rewritten by join/leave and by the dual-DB sync, and a marker that can be reset replays onboarding at someone already welcomed — the same reasoning that gaveAgentFirstContactits own collection. It is deliberately notAgentFirstContact, which keys(userId, agentName, instanceId)and fires on install; a member auto-joined to a community pod installs nothing, which is exactly the gap.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 (indexed
findOne, upsert only on miss). I did not pre-optimize: the correct-and-simple version matchesfirstContactServiceexactly, and matching a reviewed pattern beat inventing a faster unreviewed one.This ships inert
Nothing is welcomed until an install sets
config.welcomeWake.enabled. Designatingcommonly-supportin HQ is a separate, explicit step — so merging this cannot surprise anyone, and equally, merging alone does not fix the reported incident.Verification
48ffcc16baseline on identicalnode_modules: failures identical at 53 suites / 35 tests, totals +1 suite / +24 tests — exactly what this adds. (The 53 are pre-existing local env failures, e.g.Cannot find module '@sentry/node'.)instrument.tserrors are pre-existing, confirmed identical with these changes stashedisRouted ? [] : findGreeters(...)guard that read as decisive and survived deletion untouched. Removed, and the missing positivewokeGreeter: trueassertion added; dropping the routed early-return and forcingwokeGreeterfalse are both now caught.Spec sharpenings courtesy of @fable-lead's review of the issue.