Repository navigation
fix(mcp): a request JSON can't write reads not-sent, an HTTP failure is reported once, and an unreaped launch is held first - #566
Merged
nissa-seru merged 1 commit intoOct 19, 2026
Conversation
…is reported once, and an unreaped launch is held first Anarchid's review of anima-research#540 found four gaps in the two MCP connection classes (anima-research#540 (review)). anima-research#556 holds the two it set aside; the review itself gave R1-1 and R1-2. anima-research#556's first: a call whose arguments JSON can't write (a BigInt, a cyclic object) put nothing on the wire, but the modern engine read it as no-response, "whether it took effect is unknown". ObservedTransport.send marked the request handed off before the inner send, and undid the mark only for NotWrittenError, while the serializer's TypeError came from inside the inner send. It now writes an observed request's JSON before the mark, so a call whose first leg JSON can't write reads not-sent over stdio and over HTTP, and the transport is still handed the caller's message. The legacy engine already refused such a request as not sent, but said "Try again later"; sendRequest now serializes it before it keeps anything, and refuses it in the modern engine's words: "If calling it again fails, ask whoever runs this host." anima-research#556's second: the SDK's HTTP send reports a failure through the transport's onerror and then throws the same object. So a failed request reached whoever sent it, and also raised the connection's 'error', which the framework traces as mcpl:server-error, or with no 'error' listener logged a second line. While a request's or a response's send is in flight, ObservedTransport now holds what that send's transport reports, and once the send settles it forwards all of it but the error the send threw, which its sender reports: a call through its outcome, the connect through its failure, the SDK through its own report. A notification holds nothing, since its sender may swallow its failure, as the SDK's listen teardown does. The scope is per send and names its transport, and a settled send's scope lets go of it. R1-1: when a stdio child's pipe failed and the child outlived SIGKILL, the modern engine held the launch only once the reap's close had failed, after that close had ended the pending calls. So they, and a call made as the connection closed, read "this host is reconnecting to it" one tick before reconnecting halted for good. The transport's 'unreaped' now passes the reap's failure, and handleLost records the hold once it has started the reap and before it emits 'close', as the legacy engine holds it first. It reports the hold after 'close', where the other route reports one too, in a finally. The permanent reconnect-failed comes from the reap's continuation, as on the route where the reap finds the child alive, and not when the host has closed the connection meanwhile, whose close() then reports the failure itself. A listener that throws can't skip the reap, the hold, 'close', the hold's report or the permanent failure. R1-2: a legacy connection redacted each error answer's log line with a redactor built for that line, and for a stdio server the framework launched, the configuration's env getter reads the Discord awareness ledger from disk. The connection now keeps one redactor, made at its first error answer; serverTextRedactor builds again only after a build that failed, which withholds that line, or once a dial notes something new. Also: each legacy refusal as not sent, for these arguments, for a failed pipe and for a write the transport refuses at once, rejects before it logs, so a host logger that throws can't replace the refusal. holdsUnreapedChild's doc says it may not read true yet while a reap runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryThis PR fixes MCP request outcomes, duplicate HTTP error reports, and cleanup ordering.
No actionable issues were found. Acknowledged limits from
Reviews (1) · Last reviewed commit: "fix(mcp): a request JSON can't write rea..." · Reviewed by Greptile |
This was referenced Oct 19, 2026
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 19, 2026
main brings anima-research#463's edit planner and edit_many, anima-research#350, anima-research#562, anima-research#563 and anima-research#566. anima-research#463 and this change both add declarations at the end of src/modules/workspace/index.ts, and both stand. Read along this change's paths, anima-research#463's edit_many parses each target with parseFilePath and refuses a read-only mount before it reads the store, so a direct mount refuses it as it refuses write, edit and delete; a test now pins all four. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s
added a commit
that referenced
this pull request
Oct 19, 2026
…ge entry for bounded jobs, the batched-wake hold What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round speech), #250 (surgery marks opt-in), #463 (the workspace edit planner), #168 (non-conversational marker tags: a reaction, edit or deletion never retargets the publish locus, apart from an edit the coalescer marks as the agent's first view), #558 (every ephemeral agent's usage in one shared entry), #570 (the batched-wake tests hold the gate's batch), #569, #565, #566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs. Conflicts in five files, resolved by composition: - src/agent.ts: the request strips the framework's token-estimate stamp (main) and then presents the agent's own turns under its name (ours). - src/framework.ts: `ephemeralCandidates` keeps this branch's record (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only (main's second argument is unread) with main's `readsDeferredWrite` spread; the re-pin rule's doc is main's with the `speech.locus` sentence; the turn-start flush is main's `flushForStart`, which gains this branch's `releaseLandedMail` so held mail whose message lands is released. - src/mcpl/channel-registry.ts: the three Dendrite options (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but records no publish locus — admission does, through `noteDelivered`, and the framework's three `noteDelivered` sites now apply main's marker rule (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is never the latest delivered inbound either; `channel_publish`'s default comment says the same. - test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps its shape and gains main's `holdBatches`, reading the rig's framework; every test that pairs two sends holds the batch, the two ambient-only tests included. - test/present-while-acting.test.ts: `createFramework` takes `speech`, `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests (traces and typing after a re-pin, the budget restart, the inbound-only arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs to, beside this branch's default-rule test. Three seams the suite found, all at the registry's reply anchor, which main sets where it accepts a message and this branch sets where admission delivers one: - #168's marker rule now lives in `noteDelivered` (tags and the coalescer's first-view mark are passed by the framework's three delivery sites), so the registry's unit tests exercise it as the framework calls it. - `buildChannelContext` reported no `incoming` when the speech fallback was null, and no context at all when nothing was open and no outgoing channel resolved — under `speech.locus: 'delivery'` that hid the reply anchor whenever the latest inbound wasn't a speech fallback. The anchor now depends only on `takesGlobalFallback` (an ephemeral run has none), and a context with an anchor is returned whatever else it lacks. - A coalesced acceptance's anchor is committed when the acceptance is acknowledged, not when the coalescer delivers: an acceptance whose commit fails after its delivery moves no anchor, and the retry the receipt answers commits the one the first attempt parked (`parkedAnchors`, keyed by server and event id, bounded). #157's tests pinned both halves. Test adaptations beyond the conflicts: #157's two re-pin tests and outgoing-stream's re-pin test run under the legacy locus rule, as the others do; two compiled tests main deleted (`mcpl-awareness-barrier`, `prose-stream-router`) were still in `dist/test` and failing from there — removed, since tsc doesn't clean outputs whose sources are gone. Two lines git doesn't show, named by nissa-seru's review of #546: - #558 reads `config.subconscious.name` for the shared usage entry's exemptions; a forks reader has no agent of its own, so the name is read only when `reader !== 'forks'` (the union has none otherwise). - resumeAgent makes its agent with `new Agent`, so it adds the agent to `ephemeralAgents` itself: a resumed job's usage counts in the shared entry, not under its own name. Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Problem
Anarchid's review of #540 (review) found four gaps, all in the two MCP connection classes. Each one was reproduced on
mainbefore this change (see Tests).no-response: "whether it took effect is unknown".ObservedTransport.sendmarked the request handed off before the inner send, and undid that mark only forNotWrittenError. The serializer'sTypeErrorcame from inside the inner send: stdio'swriteLine(JSON.stringify(…)), or the SDK's HTTP_send. The legacy engine already refused such a request as not sent, but said "Try again later", which fails the same way. This is Modern MCP engine: unserializable arguments read "effect unknown", and a failed HTTP request is also reported as a server error #556's first finding.onerrorand then throws the same error (index.mjs:5817-5819, SDK 2.3.1).ObservedTransportforwarded everyonerrorto the client, which the connection emits as'error', which the framework traces asmcpl:server-error. So each failed request reached its caller as its outcome, and also appeared as an error outside any request: one more trace per failure under the framework, and a second log line from a connection with no'error'listener. This is Modern MCP engine: unserializable arguments read "effect unknown", and a failed HTTP request is also reported as a server error #556's second finding.envis a getter that reads the Discord awareness ledger. So every error answer read and parsed the ledger from disk, since fix(mcp): the modern engine's failed requests, and a connect in flight, in #506's words #540 keeps the configuration on every connection. This is the review's R1-2.Changes
ObservedTransport.sendwrites an observed request's JSON before marking it handed off. A request JSON can't write is refused there, before anything is marked, so a call whose first leg it is readsnot-sent, over stdio and over HTTP, with the serializer's words in the host's log line. A later leg keeps what the earlier legs established. The transport is handed the message as before.ObservedTransportholds what its transport reports, until that send settles. Then it forwards all of it except the error the send threw, which whoever sent it reports: a call through its outcome, the connect through its failure, the SDK through its own report. A notification's send holds nothing, since its sender may swallow its failure, as the SDK's listen teardown does. A report made outside any send, after its send has settled, or by another transport is forwarded at once. The held reports live in anAsyncLocalStoragescope per send that names its transport, the same waywireScopealready keeps the request a send belongs to.'unreaped'passes the reap's failure, andhandleLostholds the launch before it emits'close', once it has started and recorded the reap. It reports the hold after'close'. The permanentreconnect-failedcomes from the reap's continuation, as on the route where the reap finds the child alive, and only if the host hasn't closed the connection meanwhile.sendRequestserializes a request before it keeps anything for it, and refuses one JSON can't write as not sent, in the modern engine's words: "If calling it again fails, ask whoever runs this host." Each of its refusals as not sent, this one, a failed pipe's and a write the transport refuses at once, now rejects before it logs, so a host logger that throws can't replace the refusal.serverTextRedactorbuilds again only after a build that failed, which withholds that line, or once a dial notes something new.What each part is for
Mcp-Nameheader is base64-encoded when it needs to be, so it can't fail. A bearer header fails only for a token a header can't carry, which an access provider could hand over at a refresh; such a call still readsno-response, as fix(mcp): the modern engine's failed requests, and a connect in flight, in #506's words #540 reads any HTTP send that throws. Marking where each transport writes would cover that too: beforewriteLine, and in afetchwrapper given to the SDK. That's a mechanism in both transports for a token no provider should hand out. The cost here is a secondJSON.stringifyof eachtools/callandtools/listrequest.JSON.rawJSONnumbers into JavaScript numbers, andJSON.rawJSONis how a BigInt is often sent, so it would change what goes out.onerror, afinally, and a scope for a notification that holds nothing). It tells a send's own failure from anything else by the error object's identity, since the SDK reports and throws the same object. In SDK 2.3.1, every request's and response's sender reports its failure: a request's rejection reaches its caller, and the SDK reports a failed response itself. A notification holds nothing, because the one this connection sends over HTTP, the listen teardown'snotifications/cancelled, is sent by a caller that swallows what it throws, so the transport's report is its only one. The connection sends no other notification over HTTP: it pins the modern revision, whose connect sends nonotifications/initialized, and it cancels an HTTP request by aborting its stream. Over stdio, the transport never reports the error a send throws, so a notification's failure there is reported once, by whatever sent it. A notification whose sender also reported its failure would be reported twice over HTTP, as onmain, but this connection sends none. A report made during a send waits until it settles, and every other report keeps its timing. One cost: when the host closes the connection while a send is in flight, a report made during that send is dropped at settle, since a closed connection emits no more'error', where onmainit went out at once. The scope is per send, so overlapping or nested sends neither release nor hold each other's reports, and a settled send's scope lets go of its transport. It names its transport because a host callback can run in a send's async context while the send is in flight: the SDK delivers a JSON response's messages from inside its send. A report another transport makes there is that transport's._startOrAuthSse's catch and again by its caller's.catch. This host passes no resumption token, and nothing here tests that path.'close'(one line, plus the reap's failure passed through, the hold's report split from its record, and afinally). This is Anarchid's fix. The hold is recorded after the reap starts and before'close', and reported after'close', which is where the other route reports a hold too, in afinally. So no listener that throws can skip the reap, the hold,'close', the hold's report or the permanent failure. It leaves a window: a child that exits between'unreaped'and the reap's re-check stays held untilclose(), where before the reap would have found it gone and reconnected. Legacy has the same window. A child that exits after the re-check stays held untilclose()in both engines, as before.'close'listener that throws can't skip it, and aclose()the host makes meanwhile reports the failure itself.try). It's for the words: the outcome was already right.envanyway.Tests
main76623a39, before the change, run outside the suite on Linux with Node 24.22.0:no-response, "whether it took effect is unknown", with notools/callon the wire.tools/callPOST, and one connection'error'each.tools/callanswered with 500 endedno-responseand raised one'error'. Under the framework, that'error'was onemcpl:server-errortrace beside the host's log line. With no'error'listener, it was two log lines. A refused connection (fetch failed) did the same.'close'listener said "…This host is reconnecting to it; try again later." The hold's'error'and the permanentreconnect-failedcame after both.envonce per error answer: 1, 2 and 3 reads for three answers. Through the framework, each error answer was oneDiscordAwarenessOutbox.batches()call.1ca803df):mcp-modern-outcomespasses 53 of 53 on Node 24.22.0. On Node 20.20.2 it passes 52, and itsJSON.rawJSONtest skips itself, since Node 20 has noJSON.rawJSON.mcp-modern-httppasses 11 of 11,mcpl-agent-words20 of 20 andmcp-recorded-text14 of 14, on both.sendToolsCall,protocol: 'modern',unreapedormcpl:server-errorpass on Node 24, 729 tests in all.main6345d27d: the typecheck and build pass, and all 243 test files pass, each in a process of its own: on Node 24.22.0, 4,744 tests pass and 27 are skipped; on Node 20.20.2, 4,743 pass and 28 are skipped, theJSON.rawJSONtest among them. CI'sstop-with-work-in-flightandconversation-routingpair passes on both.main4fd2bfb9: the four Build & Test legs (Ubuntu and macOS, Node 20 and 24) and the packed install pass in run 39231803388, and the changelog check in run 39231803565.main'ssrcand this change's tests, 13 tests fail, each one this change adds or changes, and the rest of those four files passes.mcp-modern-outcomes, 7: "an argument JSON cannot write is refused before the wire…", "a send's own failure is its call's to report…", "a request or a response that isn't a call's leaves what its send threw to its sender too…", "each send holds only what its own transport reported during it…", "a server that closes its stdin and outlives SIGKILL too is held…", and the two "an unreaped launch's loss with a … listener that throws…" tests.mcp-modern-http, 2: "an argument JSON cannot write…" and "a request the server answers with HTTP 500 is reported once…".mcpl-agent-words, 3: "a request JSON can't write is refused…", "a request refused as not sent is refused through its promise when the host's logger throws", and "error answers' log lines read their connection's configuration until a read succeeds…".mcp-recorded-text, 1: "a real discover-then-tools/list 404 is redacted in the host's log line…".maintoo. Each pins somethingmainalready does that a wrong fix could break: a notification's report, a call'sJSON.rawJSONarguments, another connection's report, and a host closing the connection as an unreaped launch's loss closes it.JSON.rawJSONtest.'close': both throwing-listener tests and the extended unreaped test.'close', or not at all: both throwing-listener tests.finally: the throwing-'close'test.close(): the host-close test.'close': the host-close test and both throwing-listener tests.try: the legacy refusal test.Not verified
AsyncLocalStoragereaching the SDK'sonerrorcall inside itssend, aswireScopealready relies on it reachingsend.Out of scope
fetch failed) still readsno-response, "whether it took effect is unknown". A failed fetch doesn't tell a connection that was refused from one that broke after part of the request went out, and fix(mcp): the modern engine's failed requests, and a connect in flight, in #506's words #540 settled on reading any throwing HTTP send as handed off.envgetter (framework.ts:22368), so nothing there reads the ledger.lossReaped()returns, as onmain.changelog.d/mcp-540-review-follow-ups.fixed.md.Fixes #556.
Written by Crispin-2406 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls, and Nigel-2654 (Claude Opus 5.5) reviewed it. Three rounds of our internal review read 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.