fix(connector-claude-code): reconcile wakes lost before channel activation#226
Open
caffeinum wants to merge 1 commit into
Open
fix(connector-claude-code): reconcile wakes lost before channel activation#226caffeinum wants to merge 1 commit into
caffeinum wants to merge 1 commit into
Conversation
…ation A session spawned with a message already pending in its durable consumer became permanently deaf: the DM arrived (and emitted its one "incoming") milliseconds after the consumer bound, before the MCP handshake flipped channelActive — so the nudge no-op'd — and even once flipped, the client registers its notifications/claude/channel handler asynchronously (observed ~1.4s later), so a push in that window is discarded client-side. JetStream redelivery never re-emits: ingest's pending-duplicate branch only refreshes the ack handle. Net: the message sat buffered and unacked forever while the session idled, until an unrelated message re-fired the wake. Two-part fix, both derived from state rather than trusting any single notification to land: - post-activation reconcile: fire the nudge once right after channelActive flips if wake-pending messages are already buffered; - idle reconciler: every 30s, if the session is idle and pendingWake() > 0, re-nudge — healing every lost-wake class, not just this boot race. Uses the same mode-and-channel-aware predicate as the Stop→idle flush (held quiet/dnd ambient never wakes), and a delivered wake drains the inbox on its UserPromptSubmit, so re-nudges stop themselves.
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.
Symptom
An agent spawned with a DM already pending in its JetStream durable consumer becomes a permanent zombie: presence is fine, the consumer pulls fine, but it never takes a turn. Observed live on a real space (spawn-on-dm, 2026-07-12):
12:43:53session spawned; NATS connect + durable consumerdm_<id>bound at:55.825ingest()buffered it and emitted its one"incoming"→nudge():55.868(channelActiveflips there), but the client registered itsnotifications/claude/channelhandler only at:57.245("Channel notifications registered") — the nudge landed inside that ~1.4s window and was silently discardedingest(), which only refreshes the ack handle and returns without re-emitting — so no re-nudge, everConfirmed by sending a second DM: it took the new-item path, re-nudged (channel live by then), and that one turn drained both messages.
Mechanism (at upstream/main)
extensions/connector-claude-code/src/mcp.ts:212-214—nudge()is gated on the mutablechannelActive,falseuntil post-handshakemcp.ts:133—agent.start()kicks off the mesh connect in the background, so a pending message can arrive and emit"incoming"(mcp.ts:235-240) beforeawait server.connect(transport)(mcp.ts:252) flips the flag (mcp.ts:257-261)claude/channelcapability at the handshake but registers its notification handler asynchronously (~1.4s later in the observed trace), and a notification has no ack — a push in that window vanishesextensions/connector-core/src/agent.ts:264-268— a redelivered duplicate of a still-pending item only refreshes the ack handle (correctly, to keep one entry and never downgrade a durable ack) and returns without emitting, so redelivery can never heal a lost wakemcp.ts:204-211assumed a buffered message "is drained at the next UserPromptSubmit, so nothing is lost" — a freshly spawned agent that never gets a human turn has no next UserPromptSubmitFix
Two parts, both re-deriving the wake from state instead of trusting any single notification to land:
channelActiveflips, ifpendingWake() > 0, fire the nudge once. Covers everything that emitted"incoming"while the gate was closed.setInterval(unref'd): if the channel is active, the session is idle, andpendingWake() > 0, re-nudge. This is the self-healing net for the whole lost-wake class (handler-not-yet-registered window, a dropped notification, any future race), not just this boot ordering.pendingWake()is the same mode-and-channel-aware predicate the Stop→idle flush already uses, so held quiet/dnd ambient still never wakes a turn. No storm risk: a delivered wake drains the inbox on its UserPromptSubmit, which zeroes the predicate; the gate onstatus === "idle"keeps it silent mid-turn and while blocked on a permission. I considered also re-emitting fromingest()'s duplicate branch, but the reconciler subsumes it without touching the shared dedup logic, so I left that branch alone.Verification
pnpm typecheckclean across the workspace;pnpm testpasses;pnpm buildof the connector bundles the new paths intodist/mcp.cjsmcp.ts'smain()with a real MCP client, so the reconcile paths have no hermetic test; happy to add one if you can point me at a preferred harness shape