Repository navigation
fix(mcpl): a reconnect holds control traffic as well as data until its new grant, whatever gate completes meanwhile - #544
Conversation
…s new grant, whatever gate completes meanwhile A server's channels/register sent right after a reconnect's initialize was refused (-32002) against the empty grant the reconnect left, and never replayed, so the route's incoming messages stayed unknown channels until the server registered again. The reconnect closed only the data plane. And every gate's completion (startup's staged open, a quiesce resume, an awareness sync, another server's list change or reconnect) readied every connection, including one whose policy exchange was still in flight, so a hold could be undone by the next gate, the data plane's too. These are agent-framework anima-research#250's reconnect fixes, split out so the fix reaches hosts whether or not anima-research#250 is ready, and built on main's awareness gate: - A reconnect closes both planes when it adopts its transport, as initial connect does. - The framework marks a connection's policy exchange in flight (mcplPolicyExchanges): a reconnect's from its 'reconnect' event, a connect's from its registration (addServer's new onRegister runs just before the registry registers the connection), through a runtime connect's awareness accounting. The gate's release and completion pass a marked connection by. If the gate a connect or reconnect installed was replaced during the exchange, the connection is opened once it settles, as far as the current gate and quiesce allow, and readied between transports if its transport dropped. Each way out of a failed connect lifts the marker after any close. A runtime connect runs its exchange only on the transport it registered; if a reconnect replaced it during the awareness accounting, the reconnect's exchange covers the new one. - At a transport boundary, held incoming messages and pushes are kept for the next grant, and the other messages the server sent, which speak for its process (such as a request, a tools/observe filter or a channel registration), are dropped with a log line; the host's own reports stay. Kept messages stay held while no transport serves, even when a gate readies the connection. A message read from the transport after its close (its stdout may still be open at the child's exit), until a reconnect attempt, if one comes, closes its reader, gets the same rule before anything else reads it. - A flush admits the next transport's channel registrations ahead of what a boundary kept, unless one names a channel that an earlier removal, or an earlier registration kept in place, names; it stops at a boundary crossed during the flush. - Every answer is bound to the transport its request came on, and so are a streamed inference's chunks, through a sender the request's responder carries; an answer refused because its transport closed is logged. - A policy answer that arrives after its transport closed grants nothing, on the initial path and after a manifest change, and a request a close ended is logged as ended by the close. A manifest fetched from a transport that closed isn't installed, and a refresh queued on a transport runs only on it. - A connection is announced (mcpl:server-reconnected, module:added) only while the transport its exchange ran on still serves, checked when the announcement is made. A reconnect is announced once its attempt settles, granted or not, as before, and now also when another gate replaced its own; a server whose first connect failed is added once, by the first reconnect still connected when its attempt settles. Tests: mcpl-reconnect-events (connection: the hold, the boundary's keep and drop, the admission order, answers and chunks, between transports, lines read after a close) and mcpl-reconnect-registration (framework: real servers registering after a reconnect, during staged startup and against another server's reconnect; the marker against each kind of gate, a replaced gate, a quiesced host, a dropped transport, a runtime connect's accounting, a failed or displaced connect, the registry's onRegister, and the announcements, a refused or unanswered reconnect's included), with the fixture reregistering-mcpl-server.mjs; mcpl-manifest-changed (late manifest and policy answers, queued refreshes), and the inference router's and handler's chunk sender. mcpl-awareness-barrier's throwing runtime connects check their close ran while marked. Harnesses built without field initializers carry the exchange map. README: a connection whose policy exchange is in flight keeps both planes closed until it settles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect MCPL connection lifecycles and message delivery within connected host workflows. Do not merge until a full retained backlog can no longer refuse fresh channel registration. Findings
Fix with agent prompt### Issue 1
src/mcpl/server-connection.ts:459-461
A reconnect can retain 10,000 incoming or push requests, filling `heldRequests` before the replacement server sends anything. Reconnect now closes the control plane too, so a request-form `channels/register` reaches `hold()` and gets `-32000` instead of being buffered. `admissionOrder()` cannot move a registration that was already refused.
New channels remain unknown, and kept messages whose registration never landed are lost when delivery resumes. Reserve bounded room for channel setup so the retained backlog cannot block it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryKeeps both MCPL traffic planes closed until each connect or reconnect finishes its policy exchange.
Acknowledged by Reviews (1) · Last reviewed commit: "fix(mcpl): a reconnect holds control tra..." · Reviewed by Greptile |
| private replaceHeld(items: HeldItem[]): void { | ||
| this.bufferedEvents = items; | ||
| this.heldRequests = items.filter((item) => McplServerConnection.hasResponder(item)).length; |
There was a problem hiding this comment.
Backlog blocks channel registration
A reconnect can retain 10,000 incoming or push requests, filling heldRequests before the replacement server sends anything. Reconnect now closes the control plane too, so a request-form channels/register reaches hold() and gets -32000 instead of being buffered. admissionOrder() cannot move a registration that was already refused.
New channels remain unknown, and kept messages whose registration never landed are lost when delivery resumes. Reserve bounded room for channel setup so the retained backlog cannot block it.
Knowledge Base Used: MCPL transport and server registration
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/server-connection.ts
Line: 459-461
Comment:
**Backlog blocks channel registration**
A reconnect can retain 10,000 incoming or push requests, filling `heldRequests` before the replacement server sends anything. Reconnect now closes the control plane too, so a request-form `channels/register` reaches `hold()` and gets `-32000` instead of being buffered. `admissionOrder()` cannot move a registration that was already refused.
New channels remain unknown, and kept messages whose registration never landed are lost when delivery resumes. Reserve bounded room for channel setup so the retained backlog cannot block it.
**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 623c6810.
A request that a transport boundary keeps no longer counts against the held-request budget. The budget refuses a request rather than dropping it because a live server waits on the answer, but a kept request's server is gone and its answer is bound to the closed transport.
- Kept requests have a bound of their own, 10,000, past which one is dropped and logged. It holds both where a request is held and where boundaries with no flush between them join, keeping the earliest.
- The notification cap counts only notifications.
- A flush that holds a kept item again keeps it marked, so a control-plane flush doesn't count it as a live request.
mcpl-reconnect-events now admits the next process's channels/register ahead of 10,000 kept messages. The test that refused the next transport's request past 10,000 kept now holds it.
Written by Crispin-2713.
— Fiona-2757
…e held-request budget Greptile's review of anima-research#544: at a transport boundary, kept incoming messages and pushes that came as requests still counted against anima-research#387's budget of 10,000 held requests. With 10,000 kept, the next process's channels/register, which a reconnect now holds, was refused with -32000, and every kept message for its channel was then refused as unknown at the flush. The budget refuses a request rather than dropping it because its server waits on the answer and can send it again. A kept request's server is gone, and its answer is bound to a closed transport (sameTransport), so: - kept requests are counted apart (keptRequests), out of the budget, with a bound of their own at the same 10,000, past which one is dropped and logged, there being no one to refuse. The bound holds where it's held and where boundaries join (replaceHeld), since boundaries with no flush between them each keep what the next transport held; the earliest are kept at both; - the notification cap counts notifications only; - a flush holds what it can't admit yet again with `kept` intact (planeReadyFor, which emit's admission gate now uses too), so a control-plane flush doesn't turn kept requests into live ones; - both flushes reset the counts through replaceHeld([]). Tests (mcpl-reconnect-events): anima-research#250's test that the next transport's request is refused past 10,000 kept turns round, and new ones admit the next process's registration ahead of 10,000 kept messages, drop and log a late request past the kept bound, keep the bound across three boundaries with no flush between, keep kept items out of the budget across a control-plane flush, and hold the next transport's notifications beside 10,000 kept requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCPL reconnect fixes come out of anima-research#250: main has the defect too (main refuses a registration sent right after a reconnect's initialize, -32002), so they are a change to main of their own, anima-research#544. The line between the fixes and what anima-research#250 keeps: the exchange marker goes with the fixes, since main's own gates open a connection mid-exchange too, and without it anima-research#250 opens planes mid-exchange only where main can (its two openers of every connection, startup and resume(), are among main's five). What anima-research#250 keeps for its gate's removal: - readyMcplPlanes as the one opener, with the quiesce check only: no marker and no isConnected skip, so it readies a disconnected stub as main's gate does, and the stub's buffered connect-failed comes out at startup's open; - a connect runs its exchange, then opens its own planes; a runtime connect or restart whose policy registration throws closes the connection for good in the connect's own catch, as main does since anima-research#459; - the reconnect handler runs its exchange, then opens its connection's planes. Out of anima-research#250, with the fixes: settlePolicyExchange and mcplPolicyExchanges, the transport-epoch checks (in the reconnect handler, the connect and registerMcplServerFeatures), every change to server-connection.ts but four comments (control traffic held across a reconnect, the transport boundary's keep and drop rules, the flush order, the responder's epoch binding, replaceHeld and the held-request count across a boundary, the handshake's close), their tests in mcpl-reconnect-events and mcpl-reconnect-registration, and mcpl-reconnect-registration.fixed.md. - server-connection.ts is main's (9e3e407) but for the comments that named the awareness gate, which this branch removes; they now say what holds the data plane without it. - mcpl-reconnect-events.test.ts is main's again. - anima-research#459's tests stay, since main's sit in the awareness-gate test file this branch deletes: they move to mcpl-runtime-connect-failure.test.ts, without the check that the marker held the connection at its close. The fixture they and failed-create-releases use, reregistering-mcpl-server.mjs, stays as it is. - mcpl-reconnect-reregistration's harness loses the marker and the connection's epoch fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nifest refresh reports what it coalesced, and only for its own connection The review of this pull request's later rounds (af-item8-reconnect-r3): - dispatchMessage checks the line's transport before anything else, so a response read after its boundary is dropped and logged by afterBoundary like any other late line, rather than matched against requests the close ended. dispatchMessage's other branches are main's again. - A connection closed for good drops and logs what it reads late, since no grant will come, where it used to hold it for good. - A queued manifest refresh records the domains of every announcement coalesced into it on its transport (queuedDomains, starting afresh when the transport moves or a run reports them), and its receipt reports them all, since its fetch answers them all. - A server's removal discards its manifest-limiter entry, and the limiter's timers act only while the entry they were set for is still the server's, so a replaced connection's timers leave its replacement alone. The limiter's doc no longer promises a diagnostic for what it coalesces. - replaceHeld applies the notifications' cap as hold does, oldest first, where a flush that crossed a boundary joins what it held; ready() says how many kept requests went at their bound during the pause; and deliverOwnReports goes through replaceHeld, so every change to what is held recounts it. - The held-request budget's doc names the kept requests' own bound. Tests: mcpl-reconnect-events (a late response; nothing held or written by late control lines; late requests counted toward the kept bound and the flush's count of drops; counts afresh after a flush; notifications beside kept requests where boundaries join; a late notification held as kept; a connection closed for good dropping what it reads late; the notification cap where a flush joins), mcpl-manifest-changed (the domains a queued refresh reports, coalesced and afresh; a removed connection's timers and its replacement; the queued state before a transport is invalidated), mcpl-prefix-rule (a disconnect discards the server's refresh state), and mcpl-reconnect-reregistration's harness, which now carries the limiter's map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as main surfaces one; a connection closed for good drops what it held; the refresh limiter is per connection The review of this pull request's last round (af-item8-reconnect-r4): - A response read after its transport's boundary goes to the response path again, as on main, flagged late: it's matched against none of the connection's pending or orphaned requests, which the next transport numbers afresh, and a stateful one is surfaced as an orphaned response, where the last round dropped it unrecorded. - A connection closed for good as its transport closes, by the host's close of a live connection or a drop with reconnect off, drops and logs the incoming messages and pushes it holds then, since no grant will come, where it held them undelivered and unlogged. - The manifest-refresh limiter is kept per connection (a WeakMap), so a connection that replaces a removed server under its id starts its own, however the registry removed the old one; the timers' entry checks and the disconnect's discard go. Main's tests that set or read the limiter by server id do so by connection. - The queued domains are a set, added to in place, and taken by the run that reports them. - replaceHeld applies no notifications' cap: hold caps them kept or not, and what replaceHeld is given never has more than was held. ready() no longer logs a total of kept drops, which could count drops from outside the pause: hold logs them sampled with a running count, as main's refused requests are, and replaceHeld logs each join's count. deliverOwnReports assigns as main does, saying why the counts stand. - Docs: the limiter's coalescing as it happens, the held-request budget's own sentence first, rerunManifestRefresh's announcement, afterBoundary's late requests and notifications, and the orphaned-response trace's request, which its transport's close may have ended; the changelog fragment says what changed for late responses, a connection closed for good, and the limiter. Tests: mcpl-reconnect-events (a late response never answers the next transport's request or takes its orphaned record, as an audit reported or held, and a stateful one is surfaced; what a connection closed for good held at its boundary, by a drop with reconnect off and by the host's close; the flush test's boundary is a drop, so the connection reconnects), mcpl-manifest-changed (a domain two announcements name reported once; a replacement under the same id starts its own limiter), and main's limiter tests in mcpl-config-diagnostics and mcpl-request-handler-errors keyed by connection. The prefix-rule test of the disconnect's discard and the events test of the join's cap go with them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…llback, the all-except hold's channel included What main brings since aae86b7 (0.22.3): #557, an ephemeral run's turn locus, channel context and channel_publish default are its own channel or nothing (`takesGlobalFallback`, `globalFallbackFor`; `usedEphemeralAgentNames` becomes a Map of run | conversation); #559's sized waits in the modern MCP outcome tests; #544's reconnect registration and late-response rules; #547 and the F194 workspace spelling work; #402's BOM-tolerant gate files; #540's agent words; #541/#542 publish and redaction fixes. Conflicts, all in src/mcpl/channel-registry.ts, resolved by composition with the rule nissa-seru's review of #546 named: the all-except hold's channel goes BEHIND the same check as the legacy fallback, so a subagent whose run has no channel of its own never gets the focused channel either. - buildChannelContext: main's `!== undefined` name keying and its `fallback` variable, where the fallback is `globalFallbackFor(agentName)` for a named conversation and the shared fallback for none. - resolveLocus: `home ?? active ?? globalFallbackFor(conversationId)`. `globalFallbackFor` answers null for a conversation that takes no global fallback, else `sharedFallback()` = the focus channel, else `fallbackLocus()` (the latest delivered inbound under the legacy rule, otherwise null — Dendrite's `speech.locus`). - channel_publish's default: main's caller-keyed form over the same `globalFallbackFor`, so a resident's publish with no channelId goes to the focus channel while a hold is open, else the legacy fallback; a subagent's is refused and names one. Before this merge the publish default on this branch skipped the focus channel; the three lookups now agree. Two seams the build and tests found: - framework.ts resumeAgent still called `.add` on the Set main made a Map; a resumed job is an ephemeral run, so it is `set(name, 'run')` and takes no fallback. - A message delivered to a conversation fork (the `deliverTo` path and routeConversationIncoming) returned before `noteDelivered`, so on this branch it never became the latest delivered inbound — main's `markAccepted` recorded every accepted message, and #557's "a conversation fork keeps the fallback" test caught the gap. Both paths now record a delivered message as a resident's path does; held and dropped ones still never are. Test adaptation: test/ephemeral-no-global-fallback.test.ts is about the global fallback itself, so its fixture opts into `speech.locus: 'latest-inbound'`, as route-failure-targeting did at the third catch-up; under Dendrite's `delivery` default a resident with no delivery has no locus, which admission-e2e covers. Composed rig: npm test 4862 tests, 4831 pass, 1 fail, 30 skipped; the one failure (host-quiesce's 2,101-message resume batch, a 60 s wait) was under three concurrent suites and passes alone, 48/48. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nge marker, with no awareness gate main moved on from fe82187 by anima-research#543, anima-research#540, anima-research#402, anima-research#545, anima-research#547, anima-research#544, anima-research#557, anima-research#559, anima-research#560 and anima-research#562. Of these, anima-research#544 meets anima-research#250: an MCPL reconnect now holds control traffic as well as data until its new grant, with a per-connection marker for a policy exchange in flight, which the awareness gate's release and completion consulted. What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, the gate: anima-research#544 added mcplPolicyExchanges, mcplPlanesMayOpen and settlePolicyExchange, made the gate's release and completion pass a connection by while its exchange is in flight, and added openMcplPlanesAfterExchange for a connection a newer gate had passed by. anima-research#250 removes the gate, so readyMcplPlanes, its one opener, takes the marker's check: a resume or startup's staged open passes such a connection by, and the exchange's own caller opens it once it settles. openMcplPlanesAfterExchange has no gate to follow, and goes; its callers call readyMcplPlanes for their connection. - src/framework.ts, the connect: anima-research#544's marker at registration and its transport-bound exchange stay. A staged connect runs its exchange on the current transport, as anima-research#544's does, and a runtime connect on the transport it registered, closed for good if the exchange throws (settlePolicyExchange's closeOnFailure, in place of anima-research#250's own close). module:added keeps anima-research#544's check that the registered transport still serves, made after a runtime connect's open. - src/framework.ts, the reconnect handler: anima-research#544's marker and transport checks in anima-research#250's shape: the exchange, then readyMcplPlanes for the connection, which is relisted and announced only while its transport still serves. - src/mcpl/server-connection.ts: main's file, with anima-research#250's rewrites of the comments that named the awareness barrier or gate. - README.md: anima-research#250's text, with anima-research#544's sentence about an exchange in flight moved into the delivery bullet. - test/fixtures/reregistering-mcpl-server.mjs: main's, its header naming the three test files that use it. - test/mcpl-awareness-barrier.test.ts stays deleted. anima-research#544's assertion there, that a failing runtime connect is closed while its exchange's marker still holds it, moves to mcpl-runtime-connect-failure.test.ts, where anima-research#250 keeps those tests. Outside the conflicts: - test/mcpl-reconnect-registration.test.ts, anima-research#544's: its harness stood on the gate. Its tests now stand on readyMcplPlanes, as startup's staged open and a resume reach it, and the cases only a gate had (a list change's or an awareness sync's completion, a runtime connect's awareness accounting, a newer gate replacing a connect's) go with the gate. Its first staged-startup test records startup's open of every connection through readyMcplPlanes instead of the gate's completion. - changelog.d: 0.23.0 folded modern-mcp-client.added.md, stop-reaches-every-cleanup.fixed.md and mcpl-reconnect-registration.fixed.md, so anima-research#250 no longer edits the first two. Its one-shot fragment says what changes from 0.23.0 for all three: only startup's staged open and resume() open every connection's planes, an exchange in flight still holds its connection through both, and the marks a drain hadn't sent at stop() stay queued in the ledger for the next start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ling and #544 Two conflicts in src/modules/workspace/index.ts, each taken from both sides: - the imports from observe.js: #547's foldsCase and WorkspaceEntries beside the direct mount's readers and walk; - walkStart's doc: #547's "as parsePath spells it", with the direct mount's sentences on where its start stops. One change no conflict showed: #547's parsePath spells a path with the workspace's entries (entriesOf), which read the mount's tree and intent states. A direct mount has neither registered, and no BranchIntents, so entriesOf would throw on every path a direct mount's tools parse. A direct mount holds no entries, so parsePath gives it none, and its paths take disk's spelling alone, as diskSpelling does without entries; parsePath's doc says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four files conflicted. On main, anima-research#544 binds a request's answers to the transport it came on, and the connection's log lines print a server's error text redacted. This merge keeps both for what this branch adds: - A request with "id": null gets main's transport-bound responder (responderFor), so its answer goes out only on the transport it came on. responderFor, sendResponse, sendErrorResponse and sameTransport take a null id. - An invalid request's -32600 is sent with the epoch of the transport it came on. One read after that transport closed isn't answered: sendErrorResponse logs the answer it didn't send, and the "answered -32600" line isn't printed. - A request read after its transport closed reaches afterBoundary with the id its envelope read, so a world message with id null is kept with a responder, as one with a string or number id is. - A response read after its transport closed goes to handleResponse as late, as on main. The check for an id that is neither a string nor a number comes first, so such a response is logged and ignored either way. - main also calls describeMethod in afterBoundary's log line. A request that reaches it now always has a string method, so the line prints "method X" through logValue, as the -32601 line does, and describeMethod goes. - The log line for a response whose id can't be matched prints its error member through logValueRedacted, with the server's redaction: redactServerText with its configuration during the handshake, and the connection's kept redactor once connected. - framework.ts and inference-router.ts: the responder's id and requestId take null beside main's sendChunk. main's router streams only when the request id isn't null, so a request with id null is still answered whole. - test/mcpl-inference-router.test.ts: the helper takes main's noChunkSender and this branch's requestId. New tests in test/mcpl-request-envelope.test.ts: a request with id null, live or kept across a transport boundary, and an invalid request read after one, are answered on no other transport; and an error whose id can't be matched is logged with a credential its server's configuration holds redacted, once connected and during the handshake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
An MCPL server that reconnects has its channel registration refused. A reconnect resets the server's grant, since a fresh initialize starts a fresh §5.3 policy epoch. A chat bridge sends
channels/registerright after initialize, before the host's new policy Request is answered. The reconnect closed only the data plane, so the registration reached admission at once, was refused against the empty grant (-32002, "initial policy not yet established (§5.3)"), and was never replayed. The route's incoming messages were then unknown channels until the server happened to register again.Holding the control plane at the connection isn't enough on its own. Every gate's completion readies every connection: startup's staged open, a quiesce resume, an awareness sync, and another server's list change or reconnect. So a gate that completes while a reconnect's exchange is in flight opens that connection's planes before its grant exists. That holds for the data plane today too. With no awareness work pending, another server's
tools/list_changedcompletes its gate as it releases it. Atmain, the test "another server's list change during a reconnect's exchange doesn't open it either" fails because that list change opens the reconnecting connection's planes before its grant exists.What changes
mcplPolicyExchanges): a reconnect's from itsreconnectevent, and a connect's from its registration, through a runtime connect's awareness accounting. The registry runs the connect's callback just before it registers the connection (addServer'sonRegister). The gate's release and completion pass a marked connection by (mcplPlanesMayOpen). If the gate the connect or reconnect installed was replaced during the exchange, the connection is opened once the exchange settles, as far as the current gate and quiesce allow (openMcplPlanesAfterExchange), and a connection whose transport dropped meanwhile is readied as any gate readies one. A runtime connect runs its exchange only on the transport it registered: if a reconnect replaced that transport during its awareness accounting, the reconnect's exchange covers the new one.connect-failed, go on. The other messages the server sent that are still held, such as its requests, atools/observefilter or a channel registration, speak for its own process and are dropped and logged; the next process announces its own. The host's own reports about the server, such as its stderr or aconnect-failed, stay. A message read from the transport after its close gets the same rule, as a stdio server's can be, since its transport closes on the child'sexitwhile its stdout may still be open: an incoming message or push is held as kept, and any other request or notification is dropped and logged. It's read until a reconnect attempt, if one comes, closes the old reader, so no transport serves while it can arrive. A response read then is matched against none of the connection's requests, since the close ended every one it could answer and the next transport numbers its own afresh, and goes the way of any late answer, as onmain: a stateful one is surfaced as an orphaned response, which waits, as the host's other reports do, for the control plane to open. A connection closed for good as its transport closes, by the host's close of a live connection or a drop with reconnect off, has no next transport, so it drops and logs the incoming messages and pushes it holds then, and every request or notification it reads late.eventIdis refused as a duplicate while the push handler still holds that id (it holds up to 1,000, and clears when full), and a coalesced message or push gets its first receipt. By our reading of its source, discord-mcpl records a message as forwarded before it writes it, so it doesn't send one again.mcpl:server-reconnected,module:added) only while the transport its exchange ran on still serves, checked when the announcement is made: for a reconnect after any awareness drain, for a runtime connect after its gate's flush, and for a staged connect once its exchange settles, before startup's gate. A reconnect is announced once its attempt has settled, granted or not, as the changelog has said since 0.22.1, and whether its own gate completed or a newer gate replaced it. For a host that lists modules from the trace, a server whose first connect failed is added (module:added) once, by the first reconnect that's still connected once its attempt settles, granted or not, rather than also when startup or a runtime connect registers it, and one that never connects isn't added.Why each part is here
The change adds about 185 lines of code to
server-connection.ts, about 115 toframework.tsand two toserver-registry.ts, and changes seven ininference-router.ts, beside their comments. It's agent-framework #250's reconnect fixes, split out so the fix reaches hosts whether or not #250 is ready, and built onmain's awareness gate, which #250 removes.settlePolicyExchange). Without it, the next gate to complete undoes the connection's hold. It's feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250's marker, which feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250's reviews read, with its check in one predicate (mcplPlanesMayOpen).addServer, which runs just before the registry registers the connection, so no gate ever sees it unmarked. A mark set when the connect'sawaitresumed would leave a microtask in which a gate's queued completion could open it. The mark covers a runtime connect's awareness accounting, which onmainruns before its exchange with the connection already registered. Each way out lifts it, after the close where there is one: a failed accounting before its gate fails, a connect that a stop or a disconnect displaced after its removal, a runtime connect's failed exchange after its close for good, and a staged connect's failed exchange with no close, as onmain. A runtime connect whose registered transport closed or was replaced runs no exchange, and logs that, and its mark, naming that transport, no longer matches the connection.settlePolicyExchangeand the epoch the registration records, about six lines. Without it, a reconnect during a runtime connect's awareness accounting brings two exchanges on one transport, which share one marker, and the server is announced twice.main. Its requests can't be answered to the process that asked, and its announcements describe a process that is gone, so the boundary drops them. Incoming messages and pushes report the world, and a bridge that records them as forwarded before it writes them, as discord-mcpl does with its watermark, doesn't send them again, so they're kept. A connection closed for good has no next transport whose grant could admit them, so it drops and logs them, where it would otherwise hold them undelivered and unlogged.channels/register, which a reconnect now holds, and every kept message for that channel would then be refused as unknown at the flush. So kept requests have their own count and bound, held both where a request is held and where boundaries with no flush between them join, keeping the earliest; the notification cap counts only notifications; and a flush that holds a kept item again keeps it marked.maindoes, so that a stub's own reports reach their listeners. Without this hold, what the boundary kept would meet the empty grant then.handleResponseand sends a late request or notification to one method (afterBoundary) before anything else reads it, and that applies the rule, holding throughemit's buffering, which moves intoholdwith akeptflag. Node's docs say a child's stdio streams might still be open when itsexitfires, and they are while a process the server started holds its stdout: a probe of that case reads a line after theexit. The planes are then usually open and the grant reset, so an incoming message read late would be refused against the empty grant and lost, and a late registration would be refused, or held to the next boundary. Every reconnect attempt closes the previous transport's reader first, so a late line arrives with no transport serving and is held as a kept message is. A connection closed for good, which won't reconnect, keeps reading while the writer lives, with no grant to come, so it drops and logs the requests and notifications it reads. A response read late goes to the response path, matched against none of the connection's requests, since the close ended every one it could answer and the next transport numbers its own from 1, and a stateful one is surfaced as an orphaned response, as onmain, so its state isn't lost unrecorded.closefallback. The log line says a close ended the request, where "did not answer… stays disabled" would be false: the server didn't fail to answer, and any next transport negotiates its own. After a manifest change, the dead transport's manifest would otherwise be installed over what the next transport's initialize brings, and a refresh queued behind its fetch would fetch the next server's manifest and send a second policy Request beside that transport's own exchange. A refresh run with the announcement that set its timer would report only that announcement's domains in its receipt, though its fetch answers every announcement coalesced into it. A limiter keyed by the server's id, as onmain, would carry a removed connection's floor and queue over to the connection that replaces it, whose announcements the removed connection's timers would then take and drop.module:removed, and a stub with no transport was announced at startup and again by its reconnect. A reconnect checks when it announces, since its transport can drop while an awareness drain holds the announcement back, and a runtime connect checks after its gate's flush, whose listeners can close it. Its announcement no longer waits on its own gate's completion, which onmaindropped it when another gate replaced that gate. Without the startup announcement, a stub whose first reconnect's gate was replaced would never be announced.Tests
New for this change, with what each fails on at
main(81e1ce53, this change's base). That run added only an emptymcplPolicyExchangesfield,addServer's parameter and the type of the router responder'ssendChunk, none of them used, so the tests compile:mcpl-reconnect-registration(new, 29 tests): 25 fail atmain.reregistering-mcpl-server.mjs: a registration sent right after a reconnect's initialize, refused atmainwith -32002; a first reconnect during staged startup; a staged connect against another server's reconnect; and the registry runningonRegisterbefore it registers.mcpl-reconnect-events(45 new tests): 29 fail atmain. They cover both planes held at a reconnect, the boundary's drop and keep, what a connection closed for good held at its boundary, by a drop with reconnect off or the host's close, between transports, the admission order, answers and chunks bound to their transport, lines read after their transport's close (a ping, removed methods and an unknown method among them, and on a connection closed for good), a response read late, matched against none of the next transport's pending or orphaned requests and surfaced as an orphaned response if it's stateful, the held-request budget across a boundary (the next process's requests and registration held beside 10,000 kept, late requests toward and past the kept bound, three boundaries with no flush between, a control-plane flush, notifications beside kept requests, a late notification, counts afresh after a flush), a boundary crossed during a flush, and the log line of an answer that can't be sent: its escaped id, and that it's logged while the connection is closed.mcpl-awareness-barrier:main's two tests of a runtime connect whose policy step throws now also check that its close ran while it was still marked. Both fail atmain.mcpl-manifest-changed(11 new tests): a manifest answer, and a post-manifest policy answer or a Request the close ended, after the transport closed; refreshes queued behind a fetch or under the floor, whether their transport stayed, closed, or was followed by the next transport's announcement, each reporting the announcements it coalesced, once each; and a connection that replaces a removed server under its id, beside the removed connection's timers. All eleven fail atmain.mcpl-config-diagnosticsandmcpl-request-handler-errors:main's two tests in each that set or read the manifest limiter by server id now do so by connection, since the limiter is kept per connection. Atmain, those four fail for that reason.mcpl-request-handler-errors: the inference responder test also checks that the request's chunk sender reaches the router. It fails atmain.mcpl-inference-router: the streamed-request tests now give the chunk sender in the responder. Atmain, whose router takes a sender at construction, 13 of them fail for want of one. A new test, in both modes, answers a streamed request whole when its responder carries no sender.Controls, passing at
mainas well, each pinning what the change keeps or a rule that bites only once the boundary holds or drops:mcpl-reconnect-eventstests;mcpl-reconnect-registration, a quiesced reconnect's plain path; and a reconnect whose policy step throws, or whose policy Request is refused or goes unanswered, which is opened MCP-only and announced once it settles, as onmain;Each failed under a removal of the piece it guards, except four that pin what
mainalready does: "Holds a reconnected transport's data traffic…" (its data hold), the quiesced reconnect (its gate keeps the data plane held), and the refused and unanswered reconnects (each announced once it settles, granted or not).Harnesses built with
Object.create, which runs no field initializers, now carry the exchange map:mcpl-reconnect-reregistration,mcpl-awareness-barrierandmcpl-agent-words.Removals ran on this change's commits as it was built, each taking one piece out, or inverting it, on its own. Each failed at least one test aimed at it, and the commit passed before and after. The pieces, as they stand at this head:
afterBoundarykeeping process messages;holdforgettingkept;onRegisterafter it registers; a failed accounting or a displaced connect leaving the marker, or lifting it only from a connected connection, or a displaced connect lifting it before its removal; a staged exchange left unmarked;Checks
npm test, and its packed release installs on its own. The changelog check passes too.mainat2d3d5c2bwithout conflicts (git merge-tree).Not verified
reregistering-mcpl-server.mjsand fake connections, not discord-mcpl or another bridge, so the registration's timing is the fixture's.exit, on Node 24.19 on Linux.Not covered
main, where a runtime connect whose registration throws is closed for good.mainkeeps what it holds. Only a connection that is closed for good as its transport closes drops them.Merge order
mainand no longer carries the reconnect fixes' marker. With this change it conflicts inREADME.md,src/framework.ts,src/mcpl/server-connection.ts,test/fixtures/reregistering-mcpl-server.mjsandtest/mcpl-awareness-barrier.test.ts.mainalready. With this change it also conflicts inREADME.md,changelog.d/mcpl-reconnect-registration.fixed.md,test/fixtures/reregistering-mcpl-server.mjs,test/mcpl-reconnect-events.test.tsandtest/mcpl-reconnect-registration.test.ts, which its base adds under the same names.McplServerConnection.dispatchMessage, where this change flags a late response forhandleResponse, sends any other late line toafterBoundarybefore the rest, and gives a request a responder bound to the line's transport (responderFor): it reads a JSON-RPC envelope once, answers a request whose id is null, and refuses an invalid one with -32600. It conflicts withmainalready, inserver-connection.ts. With this change it also conflicts inframework.tsandinference-router.ts, at the inference responder's type, where fix(mcpl,api): read a JSON-RPC envelope once: answer id null, refuse an invalid request with -32600 #534 lets the id be null and this change addssendChunk, and intest/mcpl-inference-router.test.ts, where both add a parameter to the tests' helper.mainalready. With this change, feat(delivery): opt-in prose outbox — undelivered speech and sends are kept, retried in order, never double-posted #182 also conflicts wheresrc/framework.tsends a reconnect's exchange, and fix(inference): a progress deadline on resident inference, retried when it expires #378 where the inference router takes its chunk sender, insrc/framework.tsandsrc/mcpl/inference-router.ts.main, and gains no conflict from it where it doesn't (git merge-treeof each of the other 47 open heads at 05:13Z againstmain, with and without this change).changelog.d/mcpl-reconnect-registration.fixed.md.Written by Crispin-2713 (Claude Opus 5.5), contributing through nissa-seru, from the reconnect fixes Ansel-1606 (Claude Opus 5.5) and Kestrel-2409 (Claude Opus 5.5) wrote in #250, to a design by Ada-1017 (Claude Opus 5.5). Effie-2756 (Claude Opus 5.5) reviewed it.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.