Skip to content

fix(mentions): a member's first message in a pod now reaches an agent (#834) - #838

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/834-first-message-welcome-wake
Aug 4, 2026
Merged

fix(mentions): a member's first message in a pod now reaches an agent (#834)#838
lilyshen0722 merged 1 commit into
mainfrom
fix/834-first-message-welcome-wake

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Closes #834.

enqueueMentions returned before enqueueing anything when a message carried no @mention and no replyTo (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's extractPrompt returns null for anything outside PROMPT_EVENT_TYPES (chat.mention, message.posted, dm.message, first_contact), so a bespoke pod.first_message would 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 additive welcomeWake: true payload 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 on isBot === false. AgentProfile now loads only on the routed path, holding the added cost of reaching that point to two queries rather than three.

PodMemberFirstMessage gets its own collection rather than a flag on Pod.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 gave AgentFirstContact its own collection. It is deliberately not AgentFirstContact, 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 matches firstContactService exactly, and matching a reviewed pattern beat inventing a faster unreviewed one.

This ships inert

Nothing is welcomed until an install sets config.welcomeWake.enabled. Designating commonly-support in HQ is a separate, explicit step — so merging this cannot surprise anyone, and equally, merging alone does not fix the reported incident.

Verification

  • 18 service tests + 6 wiring tests; 41 existing mention tests unchanged and passing
  • Full backend suite against a 48ffcc16 baseline on identical node_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'.)
  • Typecheck clean; the two instrument.ts errors are pre-existing, confirmed identical with these changes stashed
  • No new lint debt — the untouched sibling test produces the same import-rule output
  • Mutation-tested, which caught dead code in my own first draft: an isRouted ? [] : findGreeters(...) guard that read as decisive and survived deletion untouched. Removed, and the missing positive wokeGreeter: true assertion added; dropping the routed early-return and forcing wokeGreeter false are both now caught.

Spec sharpenings courtesy of @fable-lead's review of the issue.

…#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.
@lilyshen0722
lilyshen0722 merged commit bc95d70 into main Aug 4, 2026
11 checks passed
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.

A first message in a pod reaches no agent — the mention gate returns before enqueue

1 participant