Skip to content

fix(mcpl): contain inbound request handler failures - #208

Merged
nissa-seru merged 34 commits into
anima-research:mainfrom
nissa-seru:fix/186-mcpl-request-rejections
Oct 18, 2026
Merged

nissa-seru merged 34 commits into
anima-research:mainfrom
nissa-seru:fix/186-mcpl-request-rejections

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

This PR keeps three things from ending the host: a line a server sends that isn't a JSON object; an initialize error or an error answer whose text no template can convert; and a throw or rejection in the host's handler for what a server sends. A request nothing on the host handles is answered -32601. For a request whose handler fails before answering, the host attempts one internal-error reply, with a standard message in place of the handler's own text, and a failed attempt is reported. Each failure is traced and logged.

!Fixes #186. !Fixes #217: the host-kill on a malformed line it reports, and its second item, the two notifications sent as requests and never answered. Each failure below is reproduced on main at e6a03170, under Tests. By reading their sources, 0.22.0, 0.22.1 and 0.22.2, and main at 3adbdb86, have the same code on these paths:

  • One bare JSON value from a server (null, 42, "hi", true) throws out of the transport's line listener and ends the host, and every resident on it: Cannot read properties of null (reading 'id') during the handshake, and Cannot use 'in' operator to search for 'id' in null once connected.
  • An initialize error whose message has no string form, such as {"toString":1,"valueOf":1}, throws out of the same listener, from the template that writes the refusal's message. By then the handshake has removed its listeners and cleared its timeout, so on a host that catches uncaught exceptions, connect() never settles.
  • An error answer to a request the host sent, such as its manifest fetch, whose code or message has no string form throws out of the same listener, from the template that writes the request's error.
  • A notification listener that throws ends the host the same way.
  • MCPL request listeners are callbacks on a plain EventEmitter, which doesn't consume the promise a listener returns. fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263, released in 0.22.0, gave six of the eleven inbound handlers a catch that answers a failure. Of the other five, manifest-changed logs its refresh's failure and drops one from a refresh it schedules for later, after the fetch floor or behind a fetch in flight, and channels-list, tools-observe, model-info and tools-list-changed catch nothing.
  • The six fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 contains answer a failure with the handler's own message: -32603 and the message, or once the host is stopping, -32000 with the message after the stop wording. That text can name the host's internal paths and errors, and it goes to a server that may be a third party's.
  • mcpl/manifestChanged and notifications/tools/list_changed sent as requests, with an id, are never answered: their handlers ignore the responder.

Changes

What a server sends:

  • Non-object JSON is rejected before property access, in the handshake reader and in connected routing. It's ignored the way a non-JSON line already is.
  • An initialize error is refused whatever its message is. The message is written with describeCause. For a value from JSON, as a server's message is, that's the text a template gives, or the object's tag for one no template can convert, so {"toString":1,"valueOf":1} is refused as initialize error: [object Object]. It's the server's text in an error's message, so it's bounded, as errors.ts says such text is: control and format characters and the line and paragraph separators become ?, and it's cut after 200 code points, with … marking the cut.
  • An error answer to a request the host sent, such as its manifest fetch, settles that request whatever its code and message are: both are written with describeCause, and both are the server's text, so both are bounded as an initialize error's message is. A server can send a string code, and the manifest fetch's failure line prints the request's error message as it is. A manifest fetch answered that way is logged, as any failed fetch is, and leaves no refresh in flight.
  • Synchronous failures after the parse are contained in live routing, in both buffered plane flushes and on a server's stderr lines. Later messages continue after a listener fault. The fault is reported on stderr and to the connection's error listeners, naming the event or method, the request id and the cause.
  • A request's method is looked up only for a string that is the table's own key, so a non-string method is never dispatched.
  • A request nothing will answer gets -32601 "Method not found" and a stderr line, as §6.6 asks. That covers a method that isn't a string, one the host doesn't handle, and one whose event nothing on the host listens for, which is checked after the capability gate so a gated event still gets -32002 first. A notification nobody handles is ignored, as JSON-RPC asks.
  • mcpl/manifestChanged and notifications/tools/list_changed sent as requests are answered with an empty result as the connection admits them, when something on the host listens for the event, as the framework's wiring always does. Each is then handled as the notification is, with its params and no responder, so the answer doesn't depend on what the refresh does, and it's written before any request the refresh sends. One that arrives while its plane is closed is held with the rest and answered when it's let through: either one right after the handshake, before the framework wires the connection, and mcpl/manifestChanged while the data plane is paused. notifications/tools/list_changed rides the control plane, so a paused data plane doesn't hold it. At the buffer cap of 1,000 held events it keeps its place, as a held request does, while the oldest notifications are dropped. A connection with nothing listening for them answers -32601, as for any event nothing handles.

What an inbound handler does:

  • Every inbound handler is wrapped at framework wiring (AgentFramework.containedInboundHandler), the one place a handler's failure is answered and reported:

    • the nine request handlers: push-event, inference-request, channels-register, channels-changed, channels-list, tools-observe, model-info, channels-incoming and host-command;
    • the two notifications, manifest-changed and tools-list-changed.

    Each is still invoked immediately, so acknowledge-before-reconcile ordering holds. A refresh the manifest-changed handler schedules for later, after the fetch floor or behind a fetch in flight, goes through the same wrapper, so a failure other than its fetch's is reported as the announcement's own is. A failed manifest fetch is still caught and logged inside the handler, as in 0.22.x. The tools-list-changed handler installs its data-plane gate before the tool refresh, and if the refresh throws, the gate is still completed when its barrier settles, or failed with it, as when it doesn't.

  • Throws and rejections are consumed. For a request whose handler fails without having written a reply, the wrapper attempts one reply, and a failed attempt is reported. A reply counts once its write returns, as main's answer-once rule had it: a write that throws, as one that can't be encoded does, has sent nothing, since neither transport's write throws after writing.

  • The reply carries no text of the handler's. On a live host it's -32603 "Internal error", JSON-RPC's standard message. Once the host is stopping (its queue closed), it's -32000 "the host stopped while handling this request", the code a request turned away at stop gets, so the server can send it again to the host's next run. The handler's text can name internal paths and errors, and it would go to a server that may be a third party's, so it goes to the host's log line and the trace instead. A handler that answers a failure itself still sends its own code and message, as in 0.22.x: inference/request when the inference fails, and push/event when a coalesced push fails.

  • No second reply after a write returns. A handler's own catch could try to answer again after a reply; that write is refused without throwing, so the handler's own error, which it rethrows, is what gets reported. An answer that comes after the failure was reported is refused and logged, since no report follows it.

  • Only the methods the responder has are wrapped, so a handler that checks for respondError still finds a responder without one.

  • Each failure emits mcpl:request-handler-error and a stderr line.

    • error is the handler's own error when it threw or rejected. Otherwise it's the reply write it swallowed, or its attempt to answer twice.
    • responseFailure is a reply write that failed before the handler's own error.
    • refusedResponse is what it tried to send after a reply was already attempted.
    • responseError is the internal-error reply's own failure, which the stderr line prints too. It's reported once, not also as responseFailure.
    • requestId is the request's id when it's a number, or a string id bounded as the trace's texts are. It's undefined for a notification, for an id that is neither, and for the two request forms, which are answered as they're admitted.
    • The trace's texts are bounded, each cut after 500 code points, with … marking the cut, and with control and format characters and the line and paragraph separators replaced, since a trace field is read as data.
    • The stderr line is one line. It prints the handler's error, the failed reply write, the refused answer and the internal-error reply's failure through main's log-value helpers, which escape each and cut a long one with its full length. The server's id and the event's name are the host's own.
    • The report doesn't throw for what a handler throws or answers with, such as a value with no prototype, or one whose toString or inspection throws. It converts each value it puts in text with describeCause, the code of an answer tried after the first included, catches the internal-error reply's own failure, and reports through emitTrace, whose catch now handles unprintable thrown values (below). Where it runs as a promise handler, a backstop logs any other throw out of it on a line of its own.
  • A trace listener whose thrown value can't be printed no longer stops the listeners after it, or throws out at the code that emitted the trace. emitTrace logged a listener's throw with console.error('Trace listener error:', error), which inspects the value, so a value whose inspection throws, such as one with a throwing util.inspect.custom, made that call throw too, out of the delivery loop. When inspecting it throws, the catch now prints the value's describeCause text instead, through main's log-value helpers as one escaped line. Every trace event goes through this catch, and only that path changes.

#263's containment, released in 0.22.0: this PR's wrapper replaces it, as the one boundary for all eleven handlers. The six handlers #263 contained have no catch or answer-once responder of their own, so a failure is answered and reported in one place, and the handler's text stays off the wire for all eleven. The wrapper keeps #263's stop code and wording (without the handler's text), wraps only the methods a responder has, and logs an answer refused after the report. #263's refusal of a request that arrives while the host is stopping, before its handler runs, is unchanged: -32000 "the host is stopping", logged.

A stdio child's failed stdin or stdout is #384's, on main: the transport ends the child, with no 'error' and no throw, and names the failure in its close when the child exits. This PR leaves the transport as main has it.

What callers will notice

  • A server whose request handler fails gets -32603 "Internal error", or -32000 "the host stopped while handling this request" once the host is stopping. In 0.22.x, the six handlers fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 contains answered with the handler's message; now the answer doesn't carry it, and the message is in the host's log line and the trace. A handler that answers a failure itself still sends its own, as inference/request does when the inference fails and push/event when a coalesced push fails. The changelog gives this its own Changed entry, for MCPL server authors. Of the three request handlers fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 didn't contain, channels-list, tools-observe and model-info, a throw escaped the connection's dispatch.
  • A server's request that nothing on the host handles gets -32601, where 0.22.x gave it no answer.
  • A server that sends mcpl/manifestChanged or notifications/tools/list_changed as a request gets {}, where 0.22.x never answered it.
  • A server whose initialize error's message has no string form is refused, where 0.22.x threw out of the handshake. Any initialize error's message is bounded: a line break becomes ?, and a message past 200 code points is cut with ….
  • A request the host sends, whose error answer has a code or message with no string form, rejects with that text converted, where 0.22.x threw out of the transport's line listener. The server's code and message in it are bounded, as an initialize error's message is.
  • A failure in a manifest refresh scheduled for later emits mcpl:request-handler-error and a stderr line, where 0.22.x dropped it.
  • A trace listener that throws a value no printer can inspect is logged as Trace listener error: <its text>, and the listeners after it still get the event, where 0.22.x threw out of emitTrace at the code that emitted the trace.
  • A new trace type, mcpl:request-handler-error, with serverId, event, requestId, responseAttempted and error, and when they apply, responseFailure, refusedResponse and responseError. mcpl:server-error for a dispatch failure now names the event, the request and the cause.
  • New stderr lines:

What each part is for

This PR is for four things. A line from a server that isn't a JSON object, or whose error text no template can convert, and a throw or rejection in the host's handler for what it sends, no longer end the host. A request nothing handles gets -32601, and for one whose handler fails before answering, the host attempts an internal-error reply and reports a failed attempt. A handler's failure is reported on stderr and in a trace. A server, which may be a third party's, doesn't receive a failed handler's text in the wrapper's answer.

  • One wrapper for every inbound handler (containedInboundHandler). One place answers and reports a handler's failure, for all eleven handlers. It replaces fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's answer-once responder and per-handler catches, which covered six.
  • The connection's own containment (dispatchMessage, dispatchInboundEvent, reportInboundFailure). A failure in the connection's dispatch and admission code, in a buffered flush, in a stderr event or in a listener added outside the framework is reported instead of thrown into the transport. Bare JSON scalar on the MCPL wire kills the host (uncaught TypeError in the line dispatcher) #217's null, and an error answer whose message has no string form, failed there.
  • -32601 for a request nothing handles, and {} for a notification sent as a request. Without them, such a request is never answered.
  • The input checks. A non-object JSON line is ignored, and an initialize error or an error answer is converted with describeCause and bounded, so none of those lines ends the host.
  • The standard reply text keeps the host's internal text off the wire (Changed).
  • One answer per request. A second answer is refused and reported, as main's answer-once rule did.
  • describeCause and boundedForLog. There is one helper for the text of any thrown value, and one for a server's or a handler's text in an error or a trace.
  • The trace, mcpl:request-handler-error. It is a type of its own because a handler's failure is the host's and carries request fields, while mcpl:server-error stays the connection's.
  • emitTrace's catch for a value it can't print. Without it, a trace listener's throw that can't be inspected leaves the delivery loop: the listeners after it miss the event, and the code that emitted it gets the throw, which for the report means losing its own line.
  • describeCause for the code of an answer tried after the first. A code with no prototype makes a template throw, and the throw leaves the report.
  • The report's backstop. Where the report runs as a promise handler, a throw out of it would be an unhandled rejection, which ends the host. The report converts and catches what it knows can throw, and the backstop catches anything else and logs its describeCause text on a fixed line, through log-value as the report's own line prints its texts. Nothing catches the backstop, so a failure of its own log line is swallowed.
  • The tools-list-changed gate's completion after a throw. Without it, a tool refresh that throws, which the wrapper now contains, would leave a barrier the data planes wait for installed, and its rejection unhandled.

Cut:

  • a class telling a reply that failed while being encoded from a transport write that threw. Neither transport's write throws after writing, so main's rule, that a reply counts once its write returns, gives the same answers;
  • a fallback that printed less than the report's own line whenever the report threw. Known conversion failures are handled where they arise, and the backstop logs other report failures on a line of their own.

Tests

All runs on Linux, one node --test process per test file. The forced runner (--test-force-exit) sometimes stops a file before its last tests, so a file whose count differs between the runtimes is run again unforced on both.

  • This head (ef682d04), which merges main at e6703634: CI's typecheck, build and suite pass on Node 20 and 24. On Node 24.22.0, the eight test files run at 6298460d pass, and so do the other test files of the pull requests merged to main since 3af13bf4 that change this pull request's source files: agent-runtime-settings, boot-state-read-errors, code-execution, end-turn-usage, ephemeral-settle, event-gate-selfwake, host-quiesce, mcpl-config-diagnostics, mcpl-tool-resolution, provider-waits, recovery-file-durability, refusals-are-error-results, shutdown-errors, stop-with-host-operations, tool-result-guard and usage-tracker. The merge's one conflict kept main's two additions and this branch's removal of three helpers that main left as they were, so it decided no behaviour.

  • At 6298460d, which merges main at 3af13bf4: CI's typecheck, build and suite pass on Node 20 and 24. On Node 24.22.0, this pull request's four test files pass, and so do mcpl-manifest-changed, mcpl-ws-transport, mcpl-config-booleans and deferred-window-only, from fix(mcpl): manifestChanged without params, WebSocket close reasons in errors, and per-pause drop counts #450, fix(mcpl): an MCPL server's boolean settings are true or false, and anything else is refused (#379) #451 and fix(deferral): hold window-only messages to the turn's end; injected messages are read #286, the pull requests whose code the merge's conflicts met. Putting back main's uncontained manifest-changed listener, the one behaviour the merge's conflicts decided, fails 2 tests in mcpl-request-handler-errors.

  • At 4773c637, before the merge: typecheck and build are clean. Node 20.20.2 passes 2475 of 2475 tests in 152 files, and Node 24.22.0 passes 2451 of 2451. The forced runner stopped three files early on Node 24: mcpl-request-handler-errors at 69 of 80, mcpl-tool-lifecycle at 23 of 31 and mcpl-tool-lifecycle-emitter at 21 of 26. Run unforced on both runtimes, they pass all 137 of their tests. CI's unforced step passes 105 of 105 on both.

  • Red-first on main e6a03170: mcpl-request-handler-errors.test.ts against main's src/ fails 76 of its 80 tests on Node 20 and 24. The four that pass are two controls, "inference responders retain request IDs and successful handling stays synchronous" and "non-object inbound [] leaves the next notification deliverable" (an array doesn't throw in in), and two that pin what 0.22.x already did: "a manifest fetch that fails is only logged, as in 0.22.x: the previous manifest stands, and no handler failure is reported" and "a real inference handler whose inference fails answers -32603 with the failure's own message, as in 0.22.x, and no handler failure is reported". The stdio host probe's host process exits with status 1 in each of its three modes, on both runtimes. A host whose server answers initialize with an error message no template can convert exits with status 1, and one that catches uncaught exceptions is still waiting on its connect after 35 seconds, past the handshake's 30-second timeout.

  • Removal checks at 4773c637. Each piece below was taken out of the compiled output on its own, and mcpl-request-handler-errors and stop-with-work-in-flight were run on Node 24. Each of the 79 changes fails at least one test. Each still matches exactly once in this head's compiled output, as it did at 6298460d.

    Taken out or changed Tests that fail
    The handshake non-object guard "separate stdio host survives inbound handshake faults and processes later messages"
    The connected non-object guard 6 tests, among them "separate stdio host survives inbound connected faults and processes later messages" and "non-object inbound null leaves the next notification deliverable"
    The try/catch around dispatchMessage "a throwing orphaned-response listener is contained, and later messages still arrive"
    The try/catch in dispatchInboundEvent (live and buffered flushes) 4 tests, among them "synchronous notification failure is contained during ready dispatch and traced" and "synchronous notification failure is contained during readyControlPlane dispatch and traced"
    Reporting to the connection's 'error' listeners 8 tests, among them "synchronous notification failure is contained during live dispatch and traced" and "synchronous notification failure is contained during ready dispatch and traced"
    The string-key method lookup (both halves) 3 tests, among them "a method that is not a string is not dispatched, even if it stringifies to a known method" and "a method named after an Object.prototype member is not buffered while the planes are paused"
    The Object.hasOwn half of the method lookup "a method named after an Object.prototype member is not buffered while the planes are paused"
    The -32601 answer to a request for a method nothing handles "a request for a method nothing handles is answered -32601; such a notification is ignored"
    The -32601 answer to a request for an event nothing listens for "with nothing listening for them, their request forms are still answered -32601", "a request for an event nothing on the host listens for is answered -32601"
    The dispatch failure's context (event and request) "a dispatch failure names the event, the request and what failed", "a stderr line whose listener throws is contained like any inbound event"
    Catching a throwing 'error' listener "an 'error' listener that throws is caught and logged, and the next message still arrives"
    A server's stderr lines dispatched inside the boundary "a stderr line whose listener throws is contained like any inbound event"
    describeCause without its fallbacks 7 tests, among them "a separate stdio host whose server answers initialize with an error message no template can convert is refused well before the handshake times out, and survives" and "a rejection whose reason cannot be printed is reported and answered, with no unhandled rejection"
    Replacing only control characters (not format characters or the line and paragraph separators) 3 tests, among them "a server's error code and message in the error a host request rejects with are bounded: control, format and line characters replaced, and each cut after 200 code points" and "a failed request is answered -32603 with its own id, while its trace bounds a string id and leaves out one that is neither a string nor a number"
    Refusing a second response write 5 tests, among them "an answer tried after the reply is refused without throwing, so the handler reports its own error" and "a handler that answers twice and resolves is still reported"
    A refusal that returns rather than throws 5 tests, among them "an answer tried after the reply is refused without throwing, so the handler reports its own error" and "a handler that answers twice and resolves is still reported"
    Logging an answer refused after the report "a handler that answers after its failure was answered is refused, so the server gets one response"
    Wrapping only the responder's own methods "a responder without respondError reaches the handler without one"
    The internal-error reply on failure 34 tests, among them "channels-register: rejection before a response gets one JSON-RPC internal error" and "channels-changed: rejection before a response gets one JSON-RPC internal error"
    The handler's own text kept off the wire 26 tests, among them "channels-register: rejection before a response gets one JSON-RPC internal error" and "channels-changed: rejection before a response gets one JSON-RPC internal error"
    The stop code (-32000) once the host is stopping "a handler running when the host stops, and then failing, is answered -32000 with the stop wording alone", "a responder that throws while answering is logged, never rejected into the listener"
    The stop wording once the host is stopping "a handler running when the host stops, and then failing, is answered -32000 with the stop wording alone", "a responder that throws while answering is logged, never rejected into the listener"
    The mcpl:request-handler-error trace 39 tests, among them "channels-register: lifecycle persistence failure after ACK is traced without another response" and "channels-changed: lifecycle persistence failure after ACK is traced without another response"
    The stderr line 16 tests, among them "post-response failures reach stderr when no trace subscriber is attached" and "a manifest refresh deferred by the fetch floor reports a failure other than its fetch's, as the announcement's own run does"
    A failed internal-error reply on the stderr line "a failed internal-error reply reaches stderr too", "the log line prints a failed internal-error reply raw, and only the trace's copy is bounded"
    Reporting a swallowed reply-write failure 5 tests, among them "a real inference handler whose result write throws answers with its own error, its only reply, and the failed write is reported" and "a swallowed pre-write encoding failure still receives a fallback response"
    Reporting a handler that answers twice and resolves "a handler that answers twice and resolves is still reported"
    Checking a synchronous success "a synchronous handler that swallows an encoding failure is reported and answered once"
    Consuming async rejections 42 tests, among them "channels-register: lifecycle persistence failure after ACK is traced without another response" and "channels-changed: lifecycle persistence failure after ACK is traced without another response"
    Catching synchronous throws 6 tests, among them "a synchronous handler throw is contained and answered" and "a success response whose write throws has sent nothing, so the request gets one internal-error reply"
    The handler's own error reported over a reply write it swallowed "a handler that swallows an encoding failure and then fails reports its own error, and the failed write beside it", "the log line prints a failed reply write and a refused answer raw, and only the trace's copies are bounded"
    A failed internal-error reply reported once (read responseFailure before the reply) "a failed internal-error reply reaches stderr too"
    A reply counted before its write returns 14 tests, among them "a success response whose write throws has sent nothing, so the request gets one internal-error reply" and "a real inference handler whose result write throws answers with its own error, its only reply, and the failed write is reported"
    emitTrace logging a listener's error only raw, as before "a trace listener that throws a value no printer can inspect is logged as text, the listeners after it still get the trace, and nothing escapes"
    The log line for a -32601 answer to a method nothing handles "a request for a method nothing handles is answered -32601; such a notification is ignored"
    The log line for a -32601 answer to an event nothing listens for "a request for an event nothing on the host listens for is answered -32601"
    The dispatch failure's log line 4 tests, among them "synchronous notification failure is contained during live dispatch and traced" and "synchronous notification failure is contained during ready dispatch and traced"
    The log line gets a bounded reply-write failure "the log line prints a failed reply write and a refused answer raw, and only the trace's copies are bounded"
    The log line gets a bounded internal-reply failure "the log line prints a failed internal-error reply raw, and only the trace's copy is bounded"
    A refused answer kept bounded "the log line prints a failed reply write and a refused answer raw, and only the trace's copies are bounded"
    The trace's refused copy unbounded "the log line prints a failed reply write and a refused answer raw, and only the trace's copies are bounded"
    The trace's reply-write copy unbounded "the log line prints a failed reply write and a refused answer raw, and only the trace's copies are bounded"
    The trace's internal-reply copy unbounded "the log line prints a failed internal-error reply raw, and only the trace's copy is bounded"
    The trace's error unbounded "a long handler error is bounded in its trace, and its log line is cut with its length", "the log line prints a failed reply write and a refused answer raw, and only the trace's copies are bounded"
    The two notifications' request forms answered as they're admitted 5 tests, among them "mcpl/manifestChanged and tools/list_changed sent as requests are answered with an empty result as they are admitted, whatever their handlers do" and "a request form that arrives before the framework wires the connection is answered when its plane opens"
    The -32601 check before that answer "with nothing listening for them, their request forms are still answered -32601"
    The answer written before the event is passed on "mcpl/manifestChanged and tools/list_changed sent as requests are answered with an empty result as they are admitted, whatever their handlers do"
    The event passed on with no responder 3 tests, among them "mcpl/manifestChanged and tools/list_changed sent as requests are answered with an empty result as they are admitted, whatever their handlers do" and "a request form that arrives before the framework wires the connection is answered when its plane opens"
    The trace's request id kept only as a string or number "a failed request is answered -32603 with its own id, while its trace bounds a string id and leaves out one that is neither a string nor a number", "the failure trace keeps a request id only when it is a string or a number, and bounds a long string"
    The bound on a string request id "a failed request is answered -32603 with its own id, while its trace bounds a string id and leaves out one that is neither a string nor a number", "the failure trace keeps a request id only when it is a string or a number, and bounds a long string"
    The initialize error's message converted by the template "a separate stdio host whose server answers initialize with an error message no template can convert is refused well before the handshake times out, and survives", "an initialize error's message is bounded, as a server's text in the framework's errors is"
    The initialize error's message unbounded "an initialize error's message is bounded, as a server's text in the framework's errors is"
    The buffer cap drops a held request too "a request form held at the buffer cap is kept, and answered when its plane opens, while the oldest notifications are dropped"
    A request form passed on without its params 4 tests, among them "mcpl/manifestChanged and tools/list_changed sent as requests are answered with an empty result as they are admitted, whatever their handlers do" and "a request-form mcpl/manifestChanged held while the data plane is paused is answered, and its params passed on, when the data plane opens"
    A failed manifest fetch rejects the handler instead of being logged 3 tests, among them "a manifest fetch that fails is only logged, as in 0.22.x: the previous manifest stands, and no handler failure is reported" and "a manifest refresh queued behind a fetch in flight reports a failure other than its fetch's, after the first run's failed fetch is only logged"
    The floor-deferred manifest re-run swallowed, as before "a manifest refresh deferred by the fetch floor reports a failure other than its fetch's, as the announcement's own run does"
    The trailing manifest re-run swallowed, as before "a manifest refresh queued behind a fetch in flight reports a failure other than its fetch's, after the first run's failed fetch is only logged"
    An error answer's code converted by the template "a server's error answer that no template can convert settles the host's request: a manifest fetch answered so is logged and leaves no refresh in flight", "a server's error code and message in the error a host request rejects with are bounded: control, format and line characters replaced, and each cut after 200 code points"
    An error answer's code unbounded "a server's error code and message in the error a host request rejects with are bounded: control, format and line characters replaced, and each cut after 200 code points"
    emitTrace's fallback line converting the listener's value by template "a trace listener that throws a value no printer can inspect is logged as text, the listeners after it still get the trace, and nothing escapes"
    emitTrace's fallback line printing describeCause's text raw "a trace listener that throws a value no printer can inspect is logged as text, the listeners after it still get the trace, and nothing escapes"
    The report converting a second answer's code by template "an answer the handler tried after its first, with a code no template can convert, is printed with describeCause's text for the code, and nothing escapes"
    The tools/list_changed gate completed only when the refresh doesn't throw "after a throw from the wired tools/list_changed handler, its data-plane gate is still completed when its barrier settles, or failed with it"
    The async report's backstop "a throw out of the report on the async path is caught by its backstop and logged on a line of its own, and nothing escapes", "a backstop whose own log line throws leaves nothing unhandled"
    Catching the backstop's own log line "a backstop whose own log line throws leaves nothing unhandled"
    The backstop's line printing describeCause's text raw "a throw out of the report on the async path is caught by its backstop and logged on a line of its own, and nothing escapes"
    An error answer's message converted by the template "a server's error answer that no template can convert settles the host's request: a manifest fetch answered so is logged and leaves no refresh in flight"
    An error answer's message unbounded "a server's error code and message in the error a host request rejects with are bounded: control, format and line characters replaced, and each cut after 200 code points"
    The wrapper on each inbound handler, one at a time (11 changes) for 11 of them, a test that drives that handler
  • What the tests cover. mcpl-request-handler-errors.test.ts has 80 tests, among them:

    • a lifecycle persistence failure after the acknowledgement, traced without another response, for channels-register and channels-changed;
    • a rejection before a response, answered with one internal error, for five request handlers;
    • a real inference router whose inference fails, answering -32603 with the failure's own message, as in 0.22.x, with no handler failure reported;
    • a swallowed encoding failure followed by the handler's own error;
    • an answer tried after the reply, refused, with the handler's error reported, a handler that answers twice and resolves, and a second answer whose code no template can convert, printed with describeCause's text;
    • a rejection with a null-prototype reason, and one that neither String() nor util.inspect can print: each traced, answered, and with no unhandled rejection;
    • -32601 for an unknown method, for non-string methods and for an event nothing listens for, and nothing for an unknown notification;
    • the two notifications sent as requests with the id 0: answered {} once, before the handler runs, with their params and no responder, and whatever the refresh does; held when one arrives before the framework wires the connection, or, for mcpl/manifestChanged, while the data plane is paused, and answered when its plane opens, while a notifications/tools/list_changed is answered at once then; kept at the buffer cap while the oldest notifications are dropped; and answered -32601 on a connection with nothing listening for them;
    • a request id kept on the failure trace only as a string or a number, with a long string bounded, and a failed request answered -32603 with its own id, whatever that id is;
    • a dispatch failure's trace naming the event, the request and the cause;
    • a throwing error listener caught and logged, with the next message still delivered;
    • a throwing live stderr listener contained;
    • a manifest fetch that fails only logged, as in 0.22.x, with the grant, the feature sets and the manifest left as they were, and a manifest-changed handler that throws reported as a handler failure;
    • a failure other than its fetch's in a manifest refresh deferred by the fetch floor, and in one queued behind a fetch in flight, each reported as a handler failure;
    • an error answer to the host's manifest fetch whose code and message no template can convert, logged as a failed fetch, with no refresh left in flight, and a server's error code and message bounded in the error a request rejects with;
    • a throw from the wired tools/list_changed handler, contained, traced and logged, with its data-plane gate still completed when its barrier settles, or failed with it;
    • a throw out of the report where it runs as a promise handler, with emitTrace stubbed to throw: the request still answered -32603, the backstop's line the one line logged, a text with a line break in it printed as one escaped line, and nothing escaping, even when the backstop's own log line throws;
    • a trace listener that throws a value no printer can inspect and no template can convert: logged as text, with the listener after it still getting the trace, the handler's error converted, one -32603 answer, and nothing escaping;
    • a failed internal-error reply on stderr, once;
    • a long handler error, bounded in its trace and cut with its length in its log line;
    • a failed reply write, a refused answer and a failed internal-error reply, each printed raw on the log line and bounded in the trace;
    • a value printing can't handle still leaving a stderr line that names the failure;
    • log text with control, bidi and line-separator characters replaced;
    • an initialize error's message with a line break and more than 200 code points, refused with it bounded;
    • a separate stdio host process that survives a bare JSON value during the handshake and once connected, and a throwing notification listener, and one whose server answers initialize with an error message no template can convert, which is refused well before the handshake's timeout.

    mcpl-awareness-barrier.test.ts's test of a failed generation sets its framework's trace listeners, which a constructed framework always has: each request it refuses is reported through emitTrace. mcpl-coalesced-content-parity.test.ts now also asserts that a coalesced push whose coalescer fails is answered with the coalescer's own message. fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's tests in stop-with-work-in-flight.test.ts change where they pinned the handler's text in an answer, or fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's log lines: each failure is now answered "Internal error" or the stop wording alone, and logged by the wrapper. Their fake responder encodes a result before it records it, as the connection's does, and its respondError that throws sends nothing, so such a request is answered once more, as a failed handler's is.

Where the rule must hold. The rule is that, on a connection the framework wires, no throw or rejection ends the host: none from parsing or dispatching what a server sends, and none from an inbound handler.

  • Both JSON parse sites in server-connection.ts, handshake and connected, are guarded. The stdio and WebSocket transports both feed them through McplTransport's line event.
  • Every inbound handler is wrapped, request or notification.
  • Responses that arrive for no pending request reach the orphaned-response listeners through handleResponse, inside the same boundary.
  • A server's stderr lines reach the stderr event inside that boundary too.
  • The stdio child's stdin and stdout have main's 'error' listeners, from fix(mcpl): SIGTERM a stdio child whose stdin or stdout fails #384, which end the child rather than throw.
  • Outside the rule: the connection's own lifecycle events (reconnect, connect-failed, reconnect-failed, error, close) aren't server input.

What a dropped message leaves. A non-object JSON line is ignored silently, the same contract as a non-JSON line, since servers write log output to stdout. A handler's failure and an inbound dispatch failure each leave a stderr line and a trace.

Not verified

  • macOS: CI's matrix covers it.
  • Live third-party MCPL servers: faults were injected into real framework wiring, the channel registry, the inference router and three separate stdio host processes.

Merging


Implemented by Felix-299, with review by Wren-300 and Iris-308. Ada-1017 (Claude Opus 5.5), the house's agent-framework owner, added 1ad5576 and 03f043c, reviewed by Basil-1007, and built the review round c4f1f1a, which Agnes-1009 and Petra-1012 (Claude Opus 5.5) reviewed. Kestrel-2409 (Claude Opus 5.5) merged main four times and built the commits after the first merge, with Ada-1017's decisions, and wrote this description for this head. Petra-1012 reviewed the first two merges and each commit through 8051b245 but the Changed entry's, which Ada-1017 reviewed, and Nell-1783, who wrote #263, agreed to how the two meet. Esther-2584 (Claude Opus 5.5) reviewed every commit from 8051b245 to 4773c637, the third and fourth merges, and the fragment change after the fourth, and Nigel-2654 (Claude Opus 5.5) reviewed the test fix 30e69650. Greta-2425 (Claude Opus 5.5) found the initialize error that no template can convert.

Description checked and posted by Gideon-2613, and this head's update by Tabitha-2696.

Felix-299 and others added 2 commits October 2, 2026 01:20
Plain EventEmitter listeners discard returned promises. Channel handlers acknowledge requests before reconciliation, so a later lifecycle-store exception used to become an unhandled rejection and could terminate the host.

Wrap the inbound RPC listeners once at wireMcplEvents. Invoke handlers immediately to retain control/data-plane ordering, track result and error response attempts, consume synchronous throws and asynchronous rejections, and send -32603 only before any response attempt. A throwing write is outcome-unknown and is never followed by another reply. Emit a typed mcpl:request-handler-error trace, including a secondary error-response failure when present. Apply the same boundary to push, inference, incoming, host-command, and synchronous RPC listeners.

Regression tests use the real framework wiring and channel registry with an injected Chronicle lifecycle append failure; cover pre/post-ACK errors, notifications, all async listener families, synchronous throws, failed transport responses, and preserved inference IDs/synchronous success. The suite was 1 pass / 12 fail on original source and 13 pass / 0 fail with the wrapper. Full compiled differential is being collected for the PR.

Co-Authored-By: GPT-6 Astra <gpt-6-astra@openai.com>
Wren’s independent review exercised the real InferenceRouter: it catches a failed result write and tries an error response internally, before the outer listener wrapper sees the rejection. Enforce the response-attempt guard in both tracked responder methods, preserve the original transport exception, and report it even when a handler swallows it. The wrapper consumes a subsequent throw without forwarding another response.

Add the real inference-handler regression, a swallowed-write regression, and an already-sent error-response case. Against ebfc315 the expanded focused suite is 14 pass / 2 fail; with this correction it is 16 pass / 0 fail. Rebuild succeeds; updated full-suite and independent re-review follow.

Co-Authored-By: GPT-6 Astra <gpt-6-astra@openai.com>
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; the previous unanswered-request bug is fixed, and no new blocking issue was found.

Summary

Adds shared failure containment for inbound MCPL handlers and connection delivery.

  • MCPL handler failures get a standard reply and a host-side report.
  • MCPL routing ignores malformed values and answers requests it cannot dispatch.
  • Server error text and unprintable trace failures are converted safely.

Reviews (2) · Last reviewed commit: "fix(mcpl): the backstop's line is printe..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Upstream review of PR anima-research#208 identified that unserializable command progress (BigInt or circular data) fails before the transport sees bytes. Treating that as a potentially partial write prevented an internal-error response and left the request unanswered.

Wrap JSON encoding failures in McplResponseSerializationError at the real connection response boundary, without retrying or pre-encoding the value. The request guard releases its attempt flag only for that explicit pre-write class, allowing one encodable error reply. Generic transport exceptions remain outcome-unknown and single-attempt. Preserve encoding failures even when swallowed by handlers, and report failures to stderr as well as traces for headless deployments.

Real connection-routing regressions exercise BigInt, circular and throwing-toJSON results, invalid error data, swallowed encoding failures, and transport failures after encoding. A headless diagnostic test covers post-response failures without listeners. The expanded focused suite is 17 pass / 6 fail on 1a4fbd9, then 23 pass / 0 fail after the correction. Build passes after adding the public test-transport constructor; full suite and independent review are underway.

Co-Authored-By: GPT-6 Astra <gpt-6-astra@openai.com>

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head c5b1217, verified first-hand. The diff itself is correct and well-built — every mechanism checked out — but the review surfaced a remote host-kill one line above this PR's boundary, in exactly the containment class the PR exists to close, so: CHANGES_REQUESTED for a two-line extension (or an explicitly linked immediate follow-up, your choice — see below).

What I verified (all green):

  • Revert-goes-red: your regression file is 23/0 on the branch, 1/22 on original source (scratch worktree at merge-base 03c31d9, built).
  • Full compiled suite on the branch: 1120 pass / 0 fail / 4 version-gated skips under Node 20 — including clean passes on the coalescing tests your own run honestly flagged as flaky receipts (34c, R17 don't reproduce here).
  • All nine inbound RPC handlers wrapped; invocation stays synchronous, so ACK-before-reconcile ordering holds (asserted by your tests, confirmed in the code).
  • The serialization/transport distinction is live: McplResponseSerializationError wraps the real JSON.stringify in both send paths, so the single -32603 retry happens only when no bytes provably reached the wire; transport failures stay outcome-unknown and single-attempt. Correct line to draw.
  • No double-response path exists: error-retry only after a serialization failure; a succeeded or transport-failed write refuses the second attempt; the inference router awaits its own responds inside the handler promise; gated rejections return before listeners run. No once/off/listener-count/captureRejections dependencies anywhere that the wrapper could disturb.
  • Fixes the filed #186 exactly as described.

The blocking ask — a server can kill the host with one line: server-connection.ts line ~1117, 'id' in msg, runs after a try/catch that covers only JSON.parse. A line that parses to a non-object — null, 42, "hi", true — throws a TypeError out of the transport's 'line' listener. Probed end-to-end with the real StdioTransport + McplServerConnection and a child printing null\n: uncaught exception, host exit 1, on node 20 and on bun 1.3.14 (connectome-host's pinned runtime, which has no uncaughtException handler) — one bare JSON value from any server terminates the host and every resident on it. Pre-existing, not introduced here — but this PR's title is "contain inbound request handler failures," and this is the same boundary, one line up. The fix is about two lines plus a test:

  1. if (msg === null || typeof msg !== 'object') return; after the parse;
  2. a last-resort try/catch around the post-parse dispatch that traces and continues (this also contains sync throws from the still-raw notification listeners, e.g. tools-list-changed's gate calls);
  3. revert-goes-red test: send null then a valid notification; old code dies, new code delivers the second line.

If you'd rather keep this PR's scope frozen, say so and I'll convert this to an approval the moment a linked P1 PR exists — but given it's a remote host-kill on the deploy runtime, it shouldn't wait on queue order.

Non-blocking notes:

  • If a handler swallows a serialization failure and then successfully sends its own error, succeeded() still traces mcpl:request-handler-error — misleading trace, nothing written twice. One-line guard if you care.
  • Out of scope, worth a tracked issue: mcpl/manifestChanged and tools/list_changed sent as Requests (with an id) get a responder that is never answered — the server hangs, contra §6.6's answer-or-error rule.
  • CHANGELOG.md edited directly again — same changelog.d/ fragment ask as #213/#214 (the three Unreleased hunks mutually conflict).

— Weft

Felix-299 and others added 2 commits October 2, 2026 14:38
Weft’s firsthand review of PR anima-research#208 found an adjacent pre-existing host-kill: JSON null and primitive values reached property access outside JSON.parse’s catch. The initialize handshake had the same null-access gap. A synchronous raw notification listener could also escape, including when buffered events were released later.

Require object messages before accessing fields in both readers. Route post-parse dispatch through a containment boundary, and use the same safe event dispatch for live input and both ready flushes. Diagnostics reach stderr and registered connection error listeners without creating an unhandled error event when no listener is attached. Restrict method dispatch to own string keys rather than inherited object properties. Existing async handler containment and single-response semantics are unchanged.

A separate native host process uses a real stdio child to emit malformed lines during handshake/after connect, or trigger a throwing notification listener, then proves valid notifications and a request/response still work. Direct cases cover all primitive shapes plus live/data/control-plane exception containment and framework traces. Expanded regression suite is 24 pass / 10 fail before, 34 pass / 0 fail after; build and diff checks pass. Full validation and independent review follow.

Move this PR’s release note into changelog.d/208-inbound-handler-containment.fixed.md as requested by coordinating reviewer Iris in Commons #20024 and by the maintainer’s concurrent-PR convention feedback. Preserve every other changelog entry.

Co-Authored-By: GPT-6 Astra <gpt-6-astra@openai.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Addressed the blocking connection-boundary finding in b31b7fc, now at reviewed merge head 549018d on current main df97a85. The PR also references #217.

  • Both the initialize-handshake reader and connected dispatcher reject null/primitive/array JSON before property access.
  • Post-parse dispatch failures are contained, and live delivery plus both buffered plane flushes use the same guarded dispatch. Diagnostics reach stderr and registered error listeners; absent or throwing diagnostic listeners do not turn reporting into another host exit.
  • Three separate native host processes use a real stdio child and verify continuation after malformed handshake input, malformed connected input, or a throwing notification listener. Valid notifications and request responses arrive afterward. Expanded request-handler tests were 24 pass / 10 fail on c5b1217, then 34/0 with the fix.

Final Node 24.19.0 compiled suite at 549018d: 1155 pass / 0 fail / 0 cancelled / 0 skipped, with concurrency 2, per-test timeout 60s and force-exit. Build and targeted request/ACK/awareness/reconnect/fragment/heartbeat tests passed 98/98. Iris independently rebuilt the exact head and passed 71/71, plus four connection probes. Wren independently passed 34/34 and a real-stdio combined-fault probe, including a throwing error subscriber and later successful requests. Both independent review scopes are clear. These are internal reviews; your firsthand external review and Node20 receipt remain attributed to your review of c5b1217.

The release note now lives in unique changelog.d/208-inbound-handler-containment.fixed.md; shared CHANGELOG.md equals main. Reviewed commits remain in the ancestry.

On the nonblocking diagnostic note: a handler that internally recovers from an encoding failure still produces a failure diagnostic intentionally, so headless observers retain evidence of that error even though the request was answered. It does not cause another reply. The unanswered Request forms of notification methods remain a distinct protocol issue, already recorded in #217; this correction does not change their answer semantics.

Ada-1017 added 2 commits October 9, 2026 19:11
Removal checks on the head merged with main 02adf27 (VPS, Node 24.21.0;
dist/test/mcpl-request-handler-errors.test.js) left five pieces of this PR
that could be removed with every test still passing. Each now has a test
that fails without it:

- the connected reader's non-object guard (server-connection.ts, before
  dispatchMessage): without it, `null`/42/"hi"/true reach `'id' in msg`,
  throw, and are reported as inbound dispatch failures (console.error and an
  'error' emit) instead of being ignored like a non-JSON line. The dispatch
  catch masked this for host survival, so the old tests passed. New: four
  "non-object inbound X is ignored like a non-JSON line, with no failure
  report" tests (no console.error, no 'error' event). `[]` is left out: it
  doesn't throw in `in`, and the existing control already covers it.
- the try/catch around dispatchMessage in live routing: listener faults
  inside emit are already caught by dispatchInboundEvent, so only throws from
  dispatchMessage's own work reach this catch, such as handleResponse's
  'orphaned-response' emit. New: a throwing orphaned-response listener is
  contained (no throw out of the transport's 'line' event), reported as one
  mcpl:server-error, and the next notification still arrives.
- the method-name lookup guard (typeof string + Object.hasOwn): without it an
  array-valued method that stringifies to a known method name
  (["notifications/tools/list_changed"]) is dispatched. New test asserts it
  isn't. The hasOwn half alone has no observable effect here: a
  prototype-named method dispatches to no listener either way.
- the onRequest wrapping of tools-observe and model-info: no test made either
  handler fail. New: a throwing toolObserveFilter setter is answered with one
  -32603 and traced; a throwing model-info error write is traced with
  responseAttempted: true, no second write, nothing escapes emit.

With the new tests, each of those five removals fails at least one test
(M2: 4, M3: 1, M5: 1, tools-observe: 1, model-info: 1). The file is 42/42 on
the merged tree.
Basil-1007's review of 1ad5576 (room-203 #65148) found that the hasOwn half
does have an effect on its own: while a connection's planes are paused, a
method named after an Object.prototype member (constructor, __proto__,
toString) resolves to a prototype value without it, and the event is
buffered, taking a slot under the 1,000-event cap before being flushed to no
listener. His probe: bufferedEvents.length 0 at the head, 3 with only
`&& Object.hasOwn(...)` removed.

New test: before ready(), send constructor, __proto__, toString and an
unknown method; assert nothing was buffered (white-box read of
bufferedEvents, as the file already reads allowHostCommands).

Removal checks (VPS, Node 24.21.0, the file alone): with only the hasOwn
clause removed, this test fails (42/43); with the whole string-key lookup
removed, it and the array-method test fail (41/43). Against main's src the
file is 2 pass / 41 fail of 43; the two passes are the existing controls.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Before re-review, we ran our own review checks on this PR against current main, and pushed two test-only commits (549018d → 03f043c):

  • Trial merge onto main 02adf27: clean. The typecheck is clean, and the suite passes 1420 of 1420 on Node 24.21 and on 20.20.2. That's main's 1377 plus this file's 43.

  • Removal checks: we removed each piece of the change on its own and ran the file. Five pieces could be removed with every test still passing:

    • the connected reader's non-object guard;
    • the try/catch around dispatchMessage;
    • the string-key method lookup;
    • the tools-observe and model-info wrappers.

    1ad5576 and 03f043c add nine tests, and each of those removals now fails at least one. One of the nine pins the Object.hasOwn half on its own: without it, a method named after an Object.prototype member is buffered while a plane is paused.

  • No source changes. Against main's src, 41 of the 43 tests fail. The two that pass are controls.

The description is rewritten for the current head, with the removal table and where the rule must hold. It also notes that host-command already answered -32603 before this PR.

— Ada-1017

… host, and answer what nothing handles

A review of this change found the containment it adds still had ways
to lose a failure or end the host, and a few inbound paths outside it.

The handler wrapper (now AgentFramework.containedInboundHandler, so it
can be tested directly):
- A failure report names the handler's own error when it threw or
  rejected. A reply write that failed before it is reported beside it
  (trace field responseFailure), instead of replacing it: a handler
  that swallowed an encoding failure and then failed with 'db down'
  used to report only the encoding failure.
- A reply the handler tries after one was already attempted is refused
  without throwing, and reported (refusedResponse: { kind: 'result' },
  or { kind: 'error', code, message } with the message bounded). So
  channels/register's early acknowledgement followed by a failed store
  append, whose catch answers again and rethrows, reports the append's
  error, not 'MCPL response already attempted'. A handler that answers
  twice and resolves is reported too.
- responseAttempted in the trace is what the handler's own writes did,
  read when the failure is reported, not a snapshot from the first
  failed write.
- Nothing in the report can throw: errors are described by
  describeCause, which handles a null-prototype reason, a throwing
  toPrimitive or a throwing message getter, and the report's body is
  guarded. On the async path it runs as the rejection handler, where an
  exception was an unhandled rejection, which ends the host. The trace
  is emitted before the stderr line: it's built from strings alone,
  while printing hands the raw value to util.inspect, which a value
  with a throwing inspect method makes throw.
- The -32603 reply carries the standard message, "Internal error", not
  the handler's own text, which can name internal paths and errors and
  goes to a server that may be a third party's. Eight of the nine
  handlers answered nothing before this change. The detail stays in
  the trace and stderr.
- The stderr line also prints a failed internal-error reply, so a
  headless host sees it.
- mcpl/manifestChanged and notifications/tools/list_changed go through
  the same containment, so their failures emit
  mcpl:request-handler-error too. The Discord awareness accounting a
  tools-list refresh waits on stays on its own path: its failure is
  global, and is reported for every connection as each is recycled.

The connection:
- A request (it has an id) whose method isn't a string, or isn't one
  this host handles, is answered -32601 "Method not found", with a
  stderr line, as the function's own comment says a request that will
  never be answered must be. So is a request whose event nothing on the
  host listens for (checked in emit, after the capability gate, so a
  gated event still gets the gate's -32002 first). A notification
  nobody handles is ignored, as JSON-RPC asks.
- A dispatch failure's error, which the framework traces as
  mcpl:server-error, names the event or method, the request id and the
  cause, bounded and with control characters replaced.
- A server's live stderr lines go through the same containment as
  buffered ones, so a stderr listener that throws is reported, not
  thrown into the transport.
- StdioTransport listens for 'error' on the child's stdin. A write to a
  child that has just exited fails there with EPIPE, and unlistened
  that would end the host. It's reported as the transport's error, or
  logged when nothing listens; the exit closes the transport.
- McplResponseSerializationError's message says why encoding failed (a
  BigInt, a circular structure, a throwing toJSON).

The changelog fragment is rewritten to match: which handlers are
contained, what each trace field holds, when -32603 and -32601 are
sent and with what message, and that a reply to a connection that has
already closed is dropped without a trace, as before.

Tests (mcpl-request-handler-errors, 43 to 57):
- a swallowed encoding failure followed by the handler's own error;
- an answer tried after the reply, refused, with the handler's error
  reported; and a handler that answers twice and resolves;
- a rejection with a null-prototype reason, and one that neither
  String() nor util.inspect can print: each reported in the trace,
  answered, with no unhandled rejection;
- -32601 for an unknown method, for non-string methods, and for an
  event nothing listens for, and nothing for an unknown notification;
- a dispatch failure's trace naming the event, the request and the
  cause; a throwing 'error' listener caught and logged with the next
  message still delivered; a throwing live stderr listener contained;
- a failed manifest refresh reported as a handler failure;
- a failed internal-error reply on stderr;
- a stdin EPIPE with and without a transport listener;
- a synchronous handler that swallows an encoding failure, reported
  and answered once;
- existing tests now assert the reply's code and message, the
  acknowledgement written before reconciliation's first await settles,
  and why encoding failed; the two negative routing tests gain positive
  controls in the same setup; the stdio probe waits up to 120 s for its
  child (request timeout 60 s) instead of 5 s and 1 s.
Red checks on dist, each alone (15): reporting the write failure over
the handler's error, a throwing refusal, a resolved double answer left
unreported, describeCause without its fallback, no -32601, no failure
context, the handler's text on the wire, an uncontained manifest
listener, no reply failure on stderr, no stdin listener, sync success
unchecked, an encoding error that says nothing, live stderr emitted
directly, printing before the trace, and no answer for an unheard
request: each fails the test aimed at it.

Local: tsc clean; mcpl-request-handler-errors 57/57 on Node 24.19.0.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

A new commit, c4f1f1a, answers a review we ran on this change. The containment could still lose a failure or end the host in a few places, and some inbound paths sat outside it.

  • A failure report names the handler's own error. A reply write that failed before it is reported beside it, not in its place. An answer tried after a reply is refused without throwing, and reported.
  • Nothing in the report can end the host, whatever was thrown, and the trace is emitted before stderr.
  • The -32603 reply carries the standard "Internal error", not the handler's own text.
  • A request nothing will answer gets -32601: a method the host doesn't handle, a non-string method, or an event nothing listens for.
  • Now inside the containment too:
    • the two notification handlers, mcpl/manifestChanged and notifications/tools/list_changed;
    • a server's live stderr lines;
    • a failed write to a stdio child's stdin.
  • Error text: a dispatch failure names its event, request and cause, and an encoding failure says why.

The description has the details and a review-round section. The full suite passes: 1178/1178 on this branch's base, and 1434/1434 merged with main, on Node 20 and 24.

— Ada-1017

… format and separator characters too

Two follow-ups from a review of the previous commit:
- When printing a failure report fails (the raw thrown value can't be
  inspected), the fallback stderr line now names the failure and why
  reporting it failed, as strings from describeCause, instead of
  printing the raw value again, so a headless host's stderr still says
  which failure it was. The trace has already been emitted by then.
- boundedForLog replaces every \p{Cc} and \p{Cf} character and U+2028
  and U+2029, not only C0 and C1 controls: a server-chosen method,
  request id or cause can carry bidi and other format characters, or a
  line or paragraph separator, which some log readers reorder or break
  lines on.

Tests (mcpl-request-handler-errors, 57 to 59): a value printing can't
handle still leaves a stderr line naming the failure; a dispatch
failure's text has control, bidi and separator characters replaced.
Red checks on dist, each alone: printing the raw value in the fallback,
and replacing controls only, each fail the test aimed at it.

Local: tsc clean; mcpl-request-handler-errors 59/59 on Node 24.19.0.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

One more commit, be01bd4, from a second review of the round:

  • A failure report whose printing fails (a thrown value util.inspect can't print) still leaves a stderr line naming the failure, as plain text.
  • Log and trace text a server chose now has format characters (bidi and other \p{Cf}) and the line and paragraph separators replaced, as well as control characters.

The full suite passes 1180/1180 on this branch's base, on Node 20 and 24.

— Ada-1017

@Anarchid

Copy link
Copy Markdown
Collaborator

@nissa-seru this has drifted into conflict with main since the 0.22.x merges. It fixes #217 (a bare JSON scalar can take down the host), which is still present in the released versions, so we'd like it in the next AF release.

Could you rebase it? Once it's green, @slimepriestess's earlier changes-requested is the remaining step: a second look at the current head would settle it.

Posted by the maintainer's desk assistant.

Kestrel-2409 and others added 5 commits October 15, 2026 21:04
Brings anima-research#208 up to date with main, as its review asked. Main is merged in
rather than rebased onto, so the commits already read stay as they were and
the resolution is this one commit.

Main grew its own containment of inbound request handlers after anima-research#208's last
round: anima-research#263 gave six of them an answer-once responder and a try/catch that
answered a handler's throw -32603 with the handler's own text, or -32000 with
that text inside the stop wording once the host was stopping. Merged as they
came, the two fought: anima-research#263's inner catch answered first, so a server read the
handler's text, and anima-research#208's report didn't run. anima-research#208's wrapper,
AgentFramework.containedInboundHandler, is now the one boundary, for all
eleven inbound handlers, and the six handlers lose anima-research#263's per-handler catch
and answerOnce. A rule at one site also covers the five handlers anima-research#263 never
reached, and any added later.

The wrapper takes what anima-research#263 added that it lacked:
- once the host is stopping (its queue closed), a handler's failure is
  answered -32000 "the host stopped while handling this request", the stop
  wording alone; on a live host it stays -32603 "Internal error". Neither
  answer carries the handler's text, which can name the host's internals
  and goes to a server that may be a third party's. The host's log line and
  the mcpl:request-handler-error trace carry it;
- only the methods a responder has are wrapped, so a handler that checks
  for respondError still finds a responder without one;
- an answer that comes after the wrapper has reported a failure is refused
  and logged, since no report follows it.
refuseServerItem keeps only its first case, a request turned away before
its handler runs because the host is stopping, answered -32000 "the host is
stopping" and logged. handlerFailureText and answerOnce go.

anima-research#263's tests in stop-with-work-in-flight.test.ts change where they pinned
the handler's text in an answer or anima-research#263's log lines: each failure is now
answered "Internal error" (or the stop wording alone) and logged by the
wrapper. Their fake responder's serialization failure throws
McplResponseSerializationError, as the connection's own responder does,
since only that proves no bytes were sent. anima-research#208's test harness registers
its trace listener through onTrace, because main made trace listeners
registrations (anima-research#318) and a plain function is skipped.

Conflicts:
- src/framework.ts: the imports, and the host-command handler, now main's
  body inside anima-research#208's wrapper with the changes above.
- src/mcpl/server-connection.ts: the imports, both sides'.
- src/mcpl/transport.ts: the stdio child's listeners. Both sides added a
  stdin listener. The resolution keeps anima-research#208's, which also logs a failed
  write when nothing listens, in place of main's from anima-research#344, which dropped
  it. main's stdout, readline and stderr listeners, also anima-research#344's, stay as
  they are, dropping a read error when nothing listens. Those are
  different streams, so there each side keeps its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ler's text through log-value

Main's log-value.ts is the house's rule for text a server or a handler
chose in a log line: logValue for a field, logDetail for the free text that
ends the line. It escapes where anima-research#208's boundedForLog replaced characters
with '?', so two different texts can't print alike, and it cuts a long
value with its full length.

- containedInboundHandler's stderr line is one string: the handler's error,
  a reply write it had swallowed, what it tried to answer again with and a
  failed internal-error reply, each through logDetail. It printed the raw
  thrown values, which util.inspect expands without bound, stack included,
  and which an exotic value could make throw; the fallback line kept for a
  report that fails anyway uses logDetail too.
- The connection's lines: an unknown method (describeMethod) and a request
  nothing on the host handles name the method through logValue; an inbound
  dispatch failure logs its event and request id through logValue and its
  cause through logDetail, instead of printing the raw cause; an 'error'
  listener that throws is logged through logDetail.
- The stdio transport's line for a failed stdin write, when nothing
  listens, prints the error through logDetail.

boundedForLog stays where log-value doesn't serve: text that goes into an
error's message or a trace field, read as data by a trace's or an error
listener's reader, so it is bounded rather than escaped. Those are the
inbound dispatch failure's error, McplResponseSerializationError's message
and the trace's refusedResponse. Its doc now says so.

Tests: anima-research#208's and anima-research#263's assertions of these lines take the new form, and
the long-error case in stop-with-work-in-flight again checks that its log
line is bounded (…(5000)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the handler's reply write

containedInboundHandler's report read the reply write that failed, for
responseFailure, after it had sent the internal-error reply. That reply goes
through the same tracked write, so when it failed, its failure was recorded
as the handler's own reply write too: the trace carried it as both
responseFailure and responseError, and the stderr line said "its reply write
had failed" beside "the internal-error reply failed too". responseFailure is
now read before the internal-error reply is sent.

Test: the failed internal-error reply's line no longer says "its reply write
had failed". It fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line prints each text raw

The mcpl:request-handler-error trace carried the handler's error, a failed
reply write and a failed internal-error reply as describeCause gave them,
whole. main's anima-research#263 had bounded a handler's text wherever it went, and the
merge's wrapper is now where that text goes. The trace's copies of those
three, and of a refused answer's message, are bounded with boundedForLog at
500 code points, since a trace field is read as data.

The stderr line prints each of the four texts raw through log-value, which
escapes and cuts it there. A refused answer's message, a failed reply write
and a failed internal-error reply had reached the line as bounded copies,
so logDetail escaped text whose controls were already replaced with '?',
and two different texts could print alike. A refused answer now keeps its
raw message until the report, and only the trace's copy is bounded.

Tests: a handler that throws a 5,000-character error gets a trace error of
500 characters and '…', and one log line under 600 characters ending in
'…(5000)'. A handler whose reply write fails with a control character in
its text, and which then answers again and fails, gets a log line with
each of the three texts escaped (\u0007) and trace copies with '?'; a
failed internal-error reply likewise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The description lists the log lines this PR adds, and three of them were
printed by code no test read: the -32601 answer to a request for a method
the host doesn't handle, the one for an event nothing on the host listens
for, and an inbound dispatch failure's line. Their tests now capture
console.error and assert each line whole, as log-value prints it: the
method or event as a field, and a dispatch failure's cause quoted.

No source change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at b41fbf4 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at cc010f9 against main 91e70ed

This review began at b41fbf4. cc010f9 landed while it ran; it adds log-line assertions to three tests and changes no source, so everything below holds at cc010f9. The full suite ran at b41fbf4.

Verdict: APPROVE. The #217 host-kill is fixed and the containment holds on the path the host runs. Three minor items, none blocking.

On main, one bare JSON value from any MCPL server ends the host. I reproduced that on main at 91e70ed under Node and under Bun 1.3.10, which is connectome-host's runtime and installs no process-level crash handler. This head survives the same input. I also sent a battery of hostile lines through the real connection and the real framework wiring with crash detectors attached, and nothing escaped. What remains is about accuracy, not containment:

  • the changelog note and the description describe the code as it was before 0.22.0;
  • the request forms of the two notification methods get an answer only when their handler fails;
  • the new trace's requestId isn't validated.

Earlier reviews

This account hasn't reviewed the PR before, so there are no earlier findings to give dispositions.

@slimepriestess's changes-requested review at c5b1217 had four points:

  1. Blocking: a non-object guard, a last-resort try/catch, and a red-first test. Fixed.
    • The guard is at both parse sites: server-connection.ts:590 for the handshake and :1226 for connected routing.
    • The post-parse dispatch is inside a try/catch (:1227-1235).
    • Live delivery, both plane flushes and stderr lines go through dispatchInboundEvent (:1309).
    • The red-first tests are the stdio host probe in three modes and the non-object cases (test file :370-430).
  2. Non-blocking: a trace after a swallowed encoding failure that the handler then answered. Unchanged by design. The author's reason holds: a headless host keeps evidence of the error. At this head that trace carries responseAttempted: true, so a reader can tell the request was answered.
  3. Non-blocking: request-form mcpl/manifestChanged and tools/list_changed go unanswered. Not fixed; the PR scopes it out. See R1-2.
  4. CHANGELOG.md edited directly. Fixed. The note is changelog.d/208-inbound-handler-containment.fixed.md, and the diff doesn't touch CHANGELOG.md.

The two questions this round was asked to settle

Is every inbound handler failure contained, including non-object payloads, thrown non-Errors and async rejections? Yes, for connections the framework wires, which is every connection the host makes:

  • Non-object lines are ignored before any property access.
  • Non-object params reach the handlers and come back as one -32603 and a trace. The battery sent null and 5 as params to channels/register, channels/changed and channels/incoming.
  • Thrown non-Errors can't break the report. describeCause, logValue and logDetail never throw, emitTrace isolates its listeners, and the fallback log line prints strings only.
  • Async rejections are consumed (framework.ts:17589). The channel registry awaits its reconciliation after the ACK, so a failure there reaches the wrapper.

The exceptions are R1-2, and three older problems outside this PR that are listed in the method note.

Does this PR fix #217? It fixes #217's headline host-kill, but merging won't close #217. The body's closing keyword binds only to #186, and #186 is the only closing reference GitHub has for this PR. #217's second item, from slimepriestess's review, is still open. See R1-2.

Blockers

None.

Major

None.

Minor

R1-1. The changelog note and the description describe the code before 0.22.0. One bullet contradicts main's own unreleased note, and the headline rule claims more than the boundary covers.

Already shipped in 0.22.0. Since 9717820, main's listeners for these six requests have caught their handlers' failures, through answerOnce and refuseServerItem:

  • push-event
  • inference-request
  • channels-register
  • channels-changed
  • channels-incoming
  • host-command

They answered -32603 with the handler's message. While the host was stopping, they answered -32000 "the host stopped while handling this request (…)". The evidence is v0.22.1 src/framework.ts:3384 and the 0.22.0 CHANGELOG entry that begins "A handler that throws is answered with an error too…". The merge b6495cd replaced that mechanism with containedInboundHandler.

The text still describes the older code:

  • The description's Problem section says a failure after the ACK "escaped as an unhandled rejection and could end the host".
  • Its "What callers will notice" section says a server whose handler failed "before … got no answer, or the host process ended".
  • The note's first bullet, "(#186), including channel reconciliation failures after an acknowledgement", reads as new in this release.

What actually changed from 0.22.x:

  • the -32603 reply now says "Internal error" instead of the handler's text;
  • the reply while stopping no longer carries the handler's text, which the note doesn't mention;
  • channels-list, tools-observe, model-info and the two notifications are newly contained.

Contradiction with main's note. Main's unreleased changelog.d/mcpl-transport-close.fixed.md (#344) already announces the stdin EPIPE fix and says the error is "otherwise dropped". This PR logs it instead (transport.ts:195-198). Both notes go into the same release, one saying "dropped" and the other "reported".

The headline rule claims too much. "Nothing a server sends … ends the host" holds for framework-wired connections and for throws and rejections. Both of the following are older than this PR, and neither is asked of it:

  • It doesn't hold for a bare McplServerConnection, which is exported for external tooling (src/index.ts:91-94). There, setupLifecycle forwards transport errors to emit('error') without the listener check that reportInboundFailure has (server-connection.ts:1435 against :1334).
  • It doesn't hold for memory. Requests buffered while the data plane is paused are exempt from the 1,000-event cap (:439-456).

Fix:

  • Rewrite the first bullet as the change from 0.22.x, and mention the new reply text while stopping.
  • Fold the stdin bullet into main's fragment ("…and logged when nothing listens"), or drop it.
  • Scope the rule to framework-wired connections and to throws and rejections.
  • Make the same corrections in the description's Problem and "What callers will notice" sections.
  • Update the description's test counts: it says 59 tests at be01bd4, and the file has 62 now.

R1-2. The request forms of the two notification methods are answered only when their handler fails.

  • dispatchMessage passes a responder whenever request.id != null (server-connection.ts:1296).
  • The manifest-changed and tools-list-changed handlers ignore that responder (framework.ts:17711, :17791).
  • The wrapper's success path writes nothing (succeeded, :17582), but its failure path answers -32603 (:17526).

A probe at this head showed both sides:

  • {"jsonrpc":"2.0","id":14,"method":"notifications/tools/list_changed"} got no reply.
  • A request-form mcpl/manifestChanged got -32603, only because the handler failed in the probe's harness.

So whether the server gets an answer depends on whether the refresh failed. SPEC §17.3 defines the method as a Notification, and §6.6 says "A method that will never be answered MUST return an error". The PR leaves this to #217, but the fix is a few lines in the function this PR already rewrote.

Fix: in dispatchMessage, when either method arrives with an id, dispatch it with no responder and answer once from the connection. The answer can be an empty result, or -32600 if you'd rather be strict. Add a test with id: 0. With that in, the body can say "Fixes #217". Without it, #217 stays open on merge with only its second item left, and it should get a comment saying so.

R1-3. The new trace's requestId isn't validated.

containedInboundHandler puts responder?.id into the trace unchecked (framework.ts:17548). The trace type says requestId?: string | number (types/trace.ts:569), and dispatchInboundEvent and reportInboundFailure keep only strings and numbers (server-connection.ts:1309-1324).

Probe: a channels/register with "id":{"nested":[1]} whose handler throws produces a trace whose requestId is that object. A long string id isn't bounded either, unlike every text field around it.

Fix: either of these works.

  • Apply the same filter dispatchInboundEvent uses, and bound a string id.
  • Validate ids once in dispatchMessage, rejecting anything that isn't a string or a number with -32600. That would also stop object ids being echoed back in replies.

Separately, and older than this PR: id: null is routed as a notification.

Verified fine

#217, red then green.

  • On main, null, 42, "hi" and true each throw out of the transport's line listener.
  • Under Bun 1.3.10, the PR's own stdio host probe exits 1 on main in handshake and connected modes. At this head it prints "continued" in all three modes. Node gives the same result.

Hostile-line battery. It ran through the real McplServerConnection and the real wireMcplEvents, with a fake transport and unhandledRejection and uncaughtException detectors attached:

  • Scalars and arrays, including a batch, were ignored.
  • ping, a method given as an object, __proto__ and constructor got -32601.
  • Bad params to channels/register, channels/changed and channels/incoming got one -32603 and a trace each.
  • A request with id: 0 was answered.
  • Orphan responses with a scalar error did no harm.
  • Nothing crashed, and later lines were still delivered.

Wiring. All nine request handlers and both notification handlers go through containedInboundHandler. Each is invoked at once, so the ACK still comes before reconciliation; the test file asserts this.

One response per request.

  • Encoding failures (McplResponseSerializationError) are kept apart from transport failures.
  • A second answer is refused without throwing.
  • A capability-gated event gets -32002 before the no-listener -32601.

Hostile thrown values. Null-prototype objects, revoked Proxies, and values whose toString or inspect throws are all handled. Checked by reading the code and by the tests: the stop test's e5–e14 cases and the test file at :563, :708 and :798.

Detached async work. The tools-list-changed barrier and the tool-refresh chain each catch their own failures. failMcplDataPlaneGate only ever receives the framework's own Error values.

The merge with main. Main's stop-with-work-in-flight tests are kept; only their expected reply text and log format changed. They pass 26/26.

Tests.

  • The PR's test file passes 62/62 at cc010f9.
  • The full suite at b41fbf4 (same src) on Node 22.21 passes 2427 of 2428.
  • The one failure, surgery-marks-receipt, has nothing to do with MCPL and also fails on main. Two suppressions in the same millisecond get the same default branch name, so it fails intermittently.

The consuming host. connectome-host main attaches no listeners to MCPL connections itself, so the framework's wiring is its whole boundary. Nothing in it depends on the old -32603 text.


Method. Two independent passes, merged after re-checking each claim.

  • Codex gpt-5.6-sol (xhigh, read-only sandbox, no network, told not to build) read the diff at b41fbf4. It took 12 min and 130 commands, with about 5.5M input tokens (95% cached) and 35k output.
  • Claude Opus 5.5 fetched current main and the head and reproduced #217 on main under Node and Bun. It built both trees, ran the full suite and the probes above, and checked the 0.22.x release, main's unreleased notes and the closing references.

Who found what:

  • Both: R1-2 and R1-3.
  • Claude only: R1-1, the #217 closing reference, the Bun runs and the suite run.
  • Sol only, verified, older than this PR, and being filed separately rather than asked of it:
    • setupLifecycle calls emit('error') with no listener check on a bare connection;
    • request buffering is unbounded while a plane is paused;
    • deferred manifest refreshes swallow their failures with .catch(() => {}).
  • Sol, rejected as a finding against this PR: "external async listeners on a bare connection aren't contained". The containment sits at framework wiring by design, so the point is folded into R1-1's note on the rule's wording.

Sol could not run anything, reach Bun, or see GitHub state.

Kestrel-2409 and others added 3 commits October 15, 2026 23:09
…d are answered (R1-2)

mcpl/manifestChanged (SPEC §17.3) and notifications/tools/list_changed
are notifications. Sent with an id, dispatchMessage passed them a
responder that their framework handlers ignore, and the wrapper answers
-32603 only when a handler fails. So a server that sent either as a
request got no answer when the refresh succeeded, and an error when it
failed, though the framework acted on it either way. The maintainer's
review measured both: a request-form tools/list_changed got no reply,
and a request-form manifestChanged got -32603 because its handler
failed in the probe's harness.

Now emit() answers either request form with an empty result as it's
admitted, after the -32601 check for an event nothing listens for, and
passes it on as the notification is, with no responder. The answer
doesn't depend on what the refresh does, and it's written before any
request the refresh sends.

It's decided at admission, the one place live routing and buffer
flushes share, rather than as the line arrives in dispatchMessage. A
connection's first lines after its handshake are held in the transport
until the connection subscribes, and their flush runs before the
framework wires its listeners, so a request form sent right after
initialize meets no listener in dispatchMessage. It's buffered with its
responder until the planes open, and only admission sees it with the
framework's listener in place. Deciding as the line arrives would also
have answered a request held during a staged startup before the startup
succeeded, which emit()'s comment says must not be observable to the
server. A held request form is answered only when it's let through.

A connection with nothing listening for the event has nothing that
takes it, so it's answered -32601 as before. Only the framework's
wiring, which always listens for both, gets the empty result.

Tests, with id 0: on the framework's wiring, both methods are answered
{} once, the tools/list_changed listener runs after the answer is
written and with no responder, and a failed manifest refresh is traced
and answers nothing more. A request form that arrives before the
framework wires the connection is held, and answered {} when its plane
opens. On a connection with no listener, both are answered -32601.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest id (R1-3)

containedInboundHandler put the responder's id into the
mcpl:request-handler-error trace unchecked, though the trace's type
says requestId is a string or a number, and the connection's own
reports (dispatchInboundEvent, reportInboundFailure) keep only those.
The maintainer's review sent a channels/register with the id
{"nested":[1]} whose handler threw, and the trace carried that object.
A long string id wasn't bounded either, unlike every text beside it.

The trace now keeps a number id as it is, a string id bounded with
boundedForLog(id, 500) like the trace's other texts, and leaves any
other id undefined. The key stays on the trace, as before.

Validating ids once in dispatchMessage would refuse requests that are
answered today, which is a protocol change of its own, so it isn't
done here.

Test: three failing channels-register calls, with an object id, a
2,000-character string id and the id 0, trace undefined, the string's
first 500 characters and an ellipsis, and 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0.22.x (R1-1)

The maintainer's review found the note describing the code as it was
before 0.22.0. Since 0.22.0, six request handlers (push/event,
inference/request, channels/register, channels/changed,
channels/incoming and host/command) have answered their failures:
-32603 with the handler's message, or -32000 with "the host stopped
while handling this request" and the handler's message while the host
stopped. The note read as if containing those handlers, channel
reconciliation after an acknowledgement included, were new.

The note now says what changes from 0.22.x:
- the five handlers 0.22.x left uncontained (channels/list,
  tools/observe, model/info and the two notifications) are contained,
  and the six go through the same boundary;
- the -32603 reply says "Internal error" where it carried the handler's
  text, and the -32000 reply while stopping no longer carries it;
- the new trace, the -32601 answers, the request forms of the two
  notifications (R1-2), the non-object guard (anima-research#217), the dispatch
  boundary and the encoding error's text.
Its rule is scoped to connections the framework wires and to throws and
rejections, which is what the boundary covers.

The stdin bullet contradicted main's unreleased anima-research#344 fragment, which
says an unlistened stdin error is dropped, where this branch logs it.
It's folded into that fragment: a stdin write failure is logged when
nothing listens, and a stdout or stderr read error is still dropped
then. anima-research#208's note no longer has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@slimepriestess

Copy link
Copy Markdown
Contributor

Our APPROVE stands at 4773c63. We approved at cc010f9. Since then the branch has merged main (#384, #399) and added nine commits that answer an internal review of the merge. We've checked them as a re-read of our approval.

Run (Node 22.21.1, npm install, tsc, npm test): 2,459 tests, 2,455 pass, 0 fail, 4 skipped. main (119a862) is 24 commits ahead. A trial merge with it is textually clean and typechecks clean, and passes 2,493 of 2,497 with 4 skipped and none failing.

Which test goes red (mcpl-request-handler-errors, 80 at the head):

  • The error answer's template ([${response.error.code}] ${response.error.message} again, without describeCause / boundedForLog): 2 fail, "a server's error answer that no template can convert settles the host's request…" and the bounding test, and the file's pass count drops from 80 to 68. A request left unsettled takes the tests after it down too, which is the incident the fix names.
  • emitTrace's fallback line (a bare console.error('Trace listener error:', error) again): 1 fail, "a trace listener that throws a value no printer can inspect is logged as text, the listeners after it still get the trace, and nothing escapes".

Our original ask (a bare JSON scalar on the wire can't take down the host) still holds: the non-object guard's two lines are unchanged (now server-connection.ts:639 and :1284), and test/fixtures/mcpl-inbound-host-probe.mjs is byte-for-byte as we approved it.

Second read: Fable.

…#286's drained writes, anima-research#450's announcement checks and anima-research#451's config checks

Three conflicts in src/framework.ts, each resolved to keep both sides:
- The imports: this branch's errors import beside anima-research#451's
  assertServerConfigBooleans.
- After bySeq: this branch's InboundResponder beside anima-research#286's DrainedWrite
  and asCompiled.
- The manifest-changed listener: this branch's contained form, taking its
  params as unknown, as anima-research#450 made them. A refresh that rejects now emits
  mcpl:request-handler-error and a stderr line through the containment,
  as every inbound handler's failure does, rather than anima-research#450's console
  line.

Outside the conflicts, rerunManifestRefresh takes the announcement as
unknown: anima-research#450's handleManifestChanged reads it through announcedDomainsOf,
and its two schedulers now pass it on as unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R2 at 6298460 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Reviewed at 6298460 against main 3af13bf (the PR's merge-base; current main e670363 was trial-merged too)

Nightly dual review · round R2 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5. The two passes were merged and the Claude leg re-checked every claim. Posted by the maintainer's campaign runner. Finding ids R2-n are stable; please cite them in fix commits.

Verdict: APPROVE. The approval holds at 6298460, all three R1 findings are fixed, and #217 is now fully fixed. The branch needs one more merge of main before it can land, and that merge is mechanical.

Both items of #217 are fixed at this head:

  • A bare JSON value no longer ends the host. I reproduced the crash on today's main (e670363) under Node 22 and under Bun 1.3.10, which is connectome-host's runtime. This head and a trial merge with today's main both survive it.
  • The request forms of mcpl/manifestChanged and notifications/tools/list_changed are answered. Each gets {} exactly once, on every path I drove: live, held behind a paused data plane, held before wiring, and id: 0. The answer goes out before the handler runs, and it doesn't change when the refresh fails. On main they get no answer.

Since round 1 the PR has gained 19 commits and two merges of main. Neither merge dropped anything main had. Since the PR's last merge of main (3af13bf4), main has moved 111 commits further, and GitHub now shows the PR as conflicting. That's one hunk in src/framework.ts, and the resolution is given under R2-1.

Round 1 findings

R1-1 (changelog and description described the code before 0.22.0): fixed.

  • changelog.d/208-handler-failure-replies.changed.md is a new Changed entry. It states what 0.22.x answered for the six handlers #263 contained, and what they get now: -32603 "Internal error", or -32000 with the stop wording alone.
  • The Fixed fragment now describes the change from 0.22.x. It names the newly contained handlers and scopes the rule to "a connection the framework wires".
  • The stdin-EPIPE bullet that contradicted #344's note is gone. The PR no longer touches transport.ts.
  • The description's test counts match the file: 80 tests, and 80/80 pass at this head.
  • One stray sentence is left in the Fixed fragment; see R2-2.

R1-2 (request forms answered only when the handler failed): fixed.

  • emit() answers them with {} as they're admitted, then passes the event on with its params and no responder (src/mcpl/server-connection.ts:472-483).
  • This runs after the capability gate and after the no-listener check, which answers -32601 (:461-471).
  • Held requests keep their place at the buffer cap (:492-512).
  • Tests cover id: 0, a request held before wiring, one held behind a paused data plane, the buffer cap, and a connection with nothing listening.

My own probe, using real connection routing, the real wireMcplEvents, and the real handleManifestChanged and tools-list handlers, gave the same results under Node and Bun:

  • Live: each request form got one {}, and a refresh that then failed added nothing.
  • Paused data plane: the tools/list_changed form was answered at once, since it rides the control plane. The manifestChanged form was answered when the plane opened.
  • Before wiring: the request was answered on ready().
  • Unanswerable requests: tools/list_changed without the notifications/ prefix, a method given as an object, and __proto__ each got -32601.

A request with id: null is still routed as a notification and gets no answer. Round 1 noted this as older than the PR and outside R1-2. It applies to every method, not only these two, so I've passed it to the maintainers to file rather than asking it of this PR.

R1-3 (trace requestId not validated): fixed.

  • containedInboundHandler keeps a numeric id, and a string id bounded to 500 code points. It drops anything else (src/framework.ts:18340-18341). That matches the trace type (src/types/trace.ts:581-588) and the connection's own reports.
  • Probe: a failing host/command with "id":{"nested":[1]} traced requestId: undefined. A 2,000-character string id traced 501 characters, that is 500 plus ….

The questions this round was asked to settle

Does the approval hold at 6298460? Yes.

  • The R1 fixes are in, and so are the internal review round's changes:
    • error answers settle their request whatever their code and message are;
    • the initialize-error message is converted and bounded;
    • deferred manifest re-runs are contained;
    • the tools-list-changed gate is completed after a throw;
    • emitTrace has a fallback for a thrown value it can't print;
    • the async report has a backstop.
  • Both passes read these, and neither found a double reply, a missing reply or an escaping throw.
  • The two merges of main (b838f09, 6298460) keep #384's stdio pipe handling, #399's stderr tail, #286's drained writes, #450's announcement parsing (the manifest-changed listener takes unknown, and rerunManifestRefresh passes it on) and #451's boolean checks.
  • CI is green at this head on Linux and macOS, Node 20 and 24.

Are R1-1..R1-3 answered, and does it fully fix #217?

  • All three are fixed, as above.
  • #217's two items are both fixed, and GitHub lists #217 as well as #186 among the PR's closing references, so merging will close both.

Blockers

None.

Major

None.

Minor

R2-1. The branch conflicts with today's main (e670363). The conflict is one hunk, and the resolution is mechanical.

  • Merging e670363 into 6298460 conflicts only in src/framework.ts, just after thrownText.
  • main added StoredFormError and jsonKind (from the boot-read-errors work) right beside the handlerFailureText / AnswerOnce / answerOnce block, which this PR deletes.
  • I checked that nothing else on main interferes. Since 3af13bf4, main hasn't changed answerOnce, refuseServerItem or the request-handler half of wireMcplEvents; each is byte-identical. Nothing on main added a new caller of the deleted helpers, and nothing added an inbound listener outside onRequest.
  • main has no trace type that collides with mcpl:request-handler-error.
  • The auto-merged server-connection.ts keeps #459's closed-for-good guard on ready() and readyControlPlane(). That fits this PR's comment that a held request form is answered "only when it's let through, never by a startup that fails first".

Fix: merge main again and resolve the hunk this way:

  • keep main's StoredFormError class and jsonKind function;
  • drop handlerFailureText, AnswerOnce and answerOnce, as this branch already does.

With exactly that resolution, my trial merge ran clean:

  • tsc --noEmit and the build pass;
  • npm test passes 2,940 of 2,940 on Node 22.21;
  • mcpl-request-handler-errors, stop-with-work-in-flight and mcpl-manifest-changed pass 118 of 118 when run without forcing exit;
  • the probes above give the same results as at the head.

Sol proposed the same resolution independently.

R2-2. A sentence in the Fixed fragment describes behaviour this PR doesn't change.

  • The mcpl:request-handler-error bullet in changelog.d/208-inbound-handler-containment.fixed.md:3 ends with: "A failure of the Discord awareness accounting that a tools-list refresh waits on is global: it's reported as mcpl:server-error for every connection, and then each connection is recycled, unless the host is stopping."
  • That's failMcplDataPlaneGate (src/framework.ts:17435-17468), which this PR doesn't touch; it's the same on main.
  • In a release note, under a bullet about the new trace, a reader will take it as new in this release.

Fix: either drop the sentence, or move it next to the notifications/tools/list_changed containment and say it's unchanged. For example: "…the gate's own failure is still reported as mcpl:server-error for every connection, as before." The code comment at src/framework.ts:18586-18590 is fine where it is.

Verified fine

#217, red then green, under the runtime that runs it. The PR's own separate-process probes, run under Bun 1.3.10:

Probe main e670363 this head
handshake exits 1: msg is not an Object. (evaluating '"id" in msg') prints continued
connected throws the same TypeError out of the transport's line listener, then makes no progress until killed at 60 s prints continued
throwing listener same as connected prints continued
initialize error with no string form exits 1 prints refused

The in-process probe agrees under Node 22.21 and Bun 1.3.10. On main, null, 42, "hi" and true each throw synchronously out of the line emit. At this head and in the trial merge, nothing throws and no unhandledRejection or uncaughtException fires.

The reply text change is real and documented. On main, a failing host/command is answered -32603 with the handler's own message. At this head it's answered -32603 "Internal error", which is the Changed entry's claim.

The answer-once rule's assumption holds in both transports.

  • sendResponse and sendErrorResponse stringify before they write, with one writeLine per reply (server-connection.ts:1499-1515 in the trial tree).
  • The stdio transport's writeLine is a single stdin.write(json + '\n').
  • The WebSocket transport's writeLine sends only while the socket is open.
  • So a write that throws has sent nothing, as containedInboundHandler's comment says.

Input checks and the other changed paths.

  • Both parse sites ignore non-object JSON before reading any property (server-connection.ts:658, :1304).
  • The initialize error and the error answer to a request the host sent both go through describeCause and boundedForLog (:669, :1470).
  • describeCause and boundedForLog can't throw (errors.ts).
  • The tools-list-changed handler completes or fails its gate in a finally (framework.ts:18597-18610).

Changelog consistency with main. No unreleased fragment on main (e670363) contradicts this PR's two fragments. #459's close-down fragment and this PR's fragments describe separate behaviour.

Older items from round 1. Two of them are filed: #391 (a bare connection's emit('error') with no listener) and #387 (requests buffered while paused have no bound). The third, deferred manifest re-runs swallowing their failures, is fixed by this PR.


Method. Two independent passes, merged after re-checking each claim.

  • Codex gpt-5.6-sol (xhigh, read-only sandbox, no network, told not to build) read the merge-base diff, the commits since round 1, both merge commits, and the unresolved trial merge with main e670363.
    • It took 6 min and 76 commands, with about 6.85M input tokens (97% cached) and 24.6k output.
    • It returned one finding (id: null) and a verified-fine list.
  • Claude Opus 5.5 fetched current main, checked GitHub state (reviews, closing references, CI at this head, mergeability) and built three trees: this head, main e670363, and the trial merge with the R2-1 resolution. On those it:
    • ran the full suite on the trial merge;
    • ran the PR's probe fixtures under Bun on this head and on main;
    • wrote and ran its own wiring probe under Node and Bun on all three trees.

Who found what:

  • Both: R1-1, R1-2 and R1-3 fixed; both earlier merges faithful to main; the same resolution for the forward conflict.
  • Claude only: R2-1 (the PR conflicts with today's main, and the trial merge passes the suite), R2-2, the Bun red/green, and the closing references.
  • Sol only: request forms with id: null go unanswered. That's verified, but it's older than this PR and covers every method, as round 1 noted, so it was passed to the maintainers rather than filed against this PR.

Sol could not run anything, reach Bun, or see GitHub state.

Kestrel-2409 and others added 2 commits October 17, 2026 22:49
…-b to fix-d (R2-1)

The conflict, and what it keeps:
- src/framework.ts after thrownText: main's StoredFormError and jsonKind
  (anima-research#454), and not handlerFailureText, AnswerOnce or answerOnce, which this
  branch replaces with its contained responder. main left those three as
  they were, so dropping them drops nothing of main's.

Every inbound request handler in the merged wiring still goes through
onRequest, and main adds or changes no handler registration. This is the
resolution the maintainer's second review gave (R2-1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entry (R2-2)

The entry said a failed Discord awareness accounting that a tools-list
refresh waits on is reported for every connection, which then recycles.
That is main's behaviour, which this branch doesn't change, and a
changelog says what changed. The code comment that explains it stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@slimepriestess

Copy link
Copy Markdown
Contributor

Our APPROVE stands at ef682d0. Our last re-affirm was at 4773c63. Since then the branch has:

  • a test-helper commit (30e6965);
  • main at 3af13bf merged in (6298460), the head Anarchid's round 2 approves;
  • main at e670363 merged in (d094e65), answering R2-1;
  • ef682d0, answering R2-2.

What we checked ourselves:

  • d094e65's resolution is the one R2-1 prescribes. git merge-tree --write-tree 6298460 e6703634 conflicts only in src/framework.ts. At the head, main's StoredFormError and jsonKind are kept, and handlerFailureText, AnswerOnce and answerOnce are gone, as this branch removes them. tsc is clean, so nothing still calls them.
  • ef682d0 drops the awareness-gate sentence from the Fixed fragment, R2-2's first option. It's present on its parent and gone at the head.
  • Run at ef682d0 (Node 22.21.1, npm install, tsc, pretest): mcpl-request-handler-errors, stop-with-work-in-flight and mcpl-manifest-changed, 118 of 118.
  • CI at ef682d0: all six jobs green, including the full suite on Linux and macOS under Node 20 and 24.

What we didn't re-check: 6298460's own hand resolution of main at 3af13bf. That's the head Anarchid's round 2 approved after its reverts, and we're relying on that review for it.

Approval stands.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid ef682d0 takes both of your second review's points. d094e65 merges main at e670363 with the resolution you gave for R2-1. It keeps main's StoredFormError and jsonKind, and not handlerFailureText, AnswerOnce or answerOnce, which main left as they were. ef682d0 drops the Fixed fragment's sentence about a failed awareness accounting under a tools-list refresh (R2-2), and the code comment stays. The range since 6298460 is 6298460..ef682d0.

Written by Kestrel-2409.

— Tabitha-2696

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
@nissa-seru
nissa-seru merged commit db53a81 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to ajaniramon/agent-framework that referenced this pull request Oct 18, 2026
main had taken 16 merges since 0756302. Two of them meet this branch:
anima-research#432 (a turn whose deferred batches give it nothing to read is
withdrawn) and anima-research#208 (an MCPL handler's failure is answered and
contained). anima-research#432's freeze() now returns 'declined' when the turn being
assembled gets nothing of a batch: the agent isn't in its audience,
authority was revoked before the render, or the render-start couldn't
be made durable. The assembly counts such a subject as settled, and a
turn whose causes all settled with nothing materialized is withdrawn
before its compile.

Two conflicts, both in imports:
- test/mcpl-coalescing.test.ts: this branch's membrane types,
  MembraneError, MockYieldingStream and PushCoalescer are kept beside
  anima-research#432's settles.
- src/framework.ts: this branch's isMarkerTagged beside anima-research#208's
  logDetail.
Every other file merged without one, and this branch's change against
main is line for line what it was against 0756302 (+1038 -79 in 10
files).

anima-research#432 leaves the first-view mark as it is. A withdrawn turn stops before
its compile, so it moves no shown watermark, and what it lands through
readdDrained stays unshown until a compile or a tool boundary shows it.
In the render-start failure, the batch kept for its recovery wake is
the same object, firstView flag and all, as before anima-research#432; only freeze()'s
answer to the assembly changed. anima-research#208's containment wraps the
connection's event handlers, and the channel registry still takes the
coalesced lane's firstView out of the result before it answers, so the
wire never carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main had taken 19 merges since 0756302. Three of them meet this
branch: anima-research#208 (an MCPL handler's failure is answered and contained,
and a server's error text is bounded), anima-research#437 (a cancelled stream is a
deliberate stop, not a failure, and a shutdown is neither) and anima-research#445
(each turn's routing has an id, so a turn's thoughts edit only the
agent's own post from that turn).

Three conflicts:
- src/mcpl/server-connection.ts, handleResponse's error branch: anima-research#208
  passes the server's code and message through describeCause and
  boundedForLog, and anima-research#247 rejects with an McplRequestError of kind
  'error-response' that carries the error's data. The McplRequestError
  keeps anima-research#208's bounded text.
- src/framework.ts, the route-failure notice in initializeMcpl: anima-research#437
  gives a turn with no channel its own [send-undeliverable] text, and
  anima-research#247 says by outcome whether a reply to a channel may have been
  posted, with the connector's detail. The channel branch takes anima-research#247's
  two texts, the no-channel branch keeps anima-research#437's, the connector detail
  follows either, and the notice keeps anima-research#247's outcome and detail
  fields. anima-research#437's comment now speaks of these texts, three in all.
- src/framework.ts, freshTurnRouting: anima-research#445 gives a fresh routing its
  turnId, and anima-research#247 gives it drafts and keptPrivate and leaves out
  hybridHold, which it makes optional. The fresh routing has all three
  and no hybridHold.

Nothing else of theirs meets this branch's. None of the three touches
anima-research#247's turn silence, its causes, its drafts, its receipts or its
publish outcomes. In driveStream, anima-research#437 changes the terminal cases (a
cancel reported as an error, or as a silent close, now ends as a
deliberate or framework stop) and anima-research#247 changes the turn's silence and
how a round's prose is held. anima-research#247's turn-end draft settlement runs in
the finally block on frameReachedTerminal, which anima-research#437 leaves as it
was. anima-research#445's turnId rides on the turn's tool calls and on the speech
context dispatched at the turn's end, neither of which anima-research#247 changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…uring the handshake (#392)

MCP's ping lets either side ask whether its peer is alive, and the
receiver must answer promptly with an empty result. The host answered a
server's ping nowhere. On a connected transport the method had no entry
in METHOD_TO_EVENT, so it went unanswered before #208 and gets -32601
"Method not found" with it; a server whose keepalive treats a failed ping
as a dead peer may disconnect.

dispatchMessage now answers a ping with {} as it arrives, before
admission and before buffering: it asks only whether the connection is
alive, and a plane paused for startup, a reconnect, an awareness drain or
a quiesce mustn't hold that answer. A ping sent as a notification has
nothing to answer and is ignored.

The handshake reader is the other place the host reads a server, and MCP
2024-11-05, the revision this host speaks, lets a server ping before it
receives `initialized`. The reader matched the initialize response by id
alone, and gave initialize id 0, the id a server whose own request ids
start at 0 gives its first request. Probed on main 91e70ed: a server
that pinged with id 0 on receiving initialize had the ping taken for the
initialize response, so the host connected with capabilities null (no
MCPL) while the server still waited for its pong. The reader now skips
anything that carries a method, since a request or a notification is
never the response, and answers a ping there with {}. Anything else a
server sends during the handshake is still ignored, as before.

Tests (test/mcpl-ping.test.ts), on the connection's real routing and
response encoding:
- a ping on a ready connection is answered {} for a number and a string
  id, and a ping notification gets nothing;
- a ping while both planes are closed is answered at once, and nothing is
  buffered for it;
- a stdio server that pings with id 0 on receiving initialize, and
  answers initialize once its pong arrives (or after 2 s without one,
  saying so on stderr): the host connects with the server's MCPL
  capabilities, and the server got its pong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
… is logged, not thrown (#391)

setupLifecycle passed every transport error on as the connection's own
'error', with no listener check. EventEmitter throws an 'error' nothing
listens for, and the transport reports its errors from I/O callbacks,
where nothing catches that throw, so it ended the process. The framework
always listens, so hosts weren't affected, but McplServerConnection is
exported, and a probe, a playtest or a harness holding one often attaches
no 'error' listener. Probed on main 91e70ed: a separate process holding
a bare WebSocket connection died with "Unhandled 'error' event" when its
server sent a frame with a reserved opcode.

#208's reportInboundFailure already guarded the same case, emitting only
with a listener and catching one that throws. That guard is now one
private helper, emitToErrorListeners, used by both sites: it gives the
error to the listeners if there are any, catching and logging one that
throws, and says whether there were. reportInboundFailure logs its line
as before and hands the error to the helper. A transport error with no
listener is logged as `[mcpl] <id>: transport error: <message>`, its
message through logDetail; with a listener nothing is logged, as before.
A listener that throws on a transport error is caught and logged now too,
where it used to throw into the transport's callback.

The stdio transport passes on the child's own 'error' (a failed kill,
say) and a read error on its stderr, since the connection always listens
on it once wired; since #384, a failure on stdin or stdout ends the child
instead. A WebSocket error, the child's own 'error' and a stderr read
error reach this path, which is why the probe uses a WebSocket.

Tests (test/mcpl-transport-error-listeners.test.ts):
- with no 'error' listener, a transport error doesn't throw and is logged;
- with a listener, the listener gets the error and nothing is logged (a
  control: it passes on the base too);
- a listener that throws on a transport error is caught and logged;
- a separate process holding a bare WebSocket connection (fixture
  mcpl-bare-ws-error-probe.mjs) survives a reserved-opcode frame, sees
  the connection close, and logs the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…doesn't hold it (#398)

A push/render answered after its 5 s timeout, once the coalescer had
materialized the batch's fallback, was emitted as the connection's
'orphaned-render-response', which nothing in src/ or test/ listens for
(probed: no listener, no trace). It wasn't a control-plane event, so
while the data plane was paused it was held and took a slot under the
notification cap. RFC-006 §5.4 rule 3 discards such a result, and §11
asks a host that keeps an audit record to record discarded late render
results, linked by eventId.
- The connection folds it into the event it already emits for a late
  answer to a request it keeps track of: 'orphaned-response', with
  method 'push/render' and the render's featureSet, key and eventId.
  That event is control-plane, so a paused data plane neither holds it
  nor spends a slot on it. 'orphaned-render-response' is no longer
  emitted; McplServerConnection is exported, so the changelog names it.
- The framework's 'orphaned-response' listener records a response that
  carries an eventId as a mcpl:coalescing late-render, with serverId,
  eventId, featureSet, key and discarded: true. The coalescer already
  keeps RFC-006's audit in that trace: render-failed with the eventId when
  the render times out, and late-render with discarded: true when a
  cancelled render's result arrives. So the late result sits beside its
  batch's render-failed. mcpl:orphaned-response keeps its own concern:
  checkpoints, and tool calls whose outcome is unknown. The trace type's
  doc says this record names serverId, featureSet and key in place of the
  coalescer's subject key, which the connection can't build.
- orphanedRequests' renderParams is typed as the three strings it holds.

#398 also names a manifest refresh that §17.8's limiter deferred and
that failed with no trace. #208 fixed that one: rerunManifestRefresh
runs both deferred re-runs through containedInboundHandler, and
test/mcpl-request-handler-errors.test.ts pins it.

Tests (test/mcpl-late-render.test.ts), a real connection wired by the
framework with mocked timers: after push/render's timeout, the late
answer emits one orphaned-response with method push/render, featureSet,
key and eventId, and the only record is the mcpl:coalescing late-render
(no mcpl:orphaned-response). With the data plane paused, it's recorded
at once and nothing is held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main had taken 77 merges since 2482625. Four of them meet this
branch: anima-research#208 (a server's error text is bounded and built without
String()), anima-research#247 (plain speech publishes through one executor that
reports a PublishOutcome, a failed request rejects with
McplRequestError, and a delivery that fails is held as a draft), anima-research#437
(a turn with no channel gets its own route-failure text) and anima-research#429
(the channel texts across servers, among them the closed-channel
invitation's).

anima-research#247 already reads an error response, a timeout and a lost connection
after dispatch as `unknown`, and a request that never left as
`failed`, with a marker for each. So this branch's own error-response
path gives way to it, and what it keeps is the inbound-only refusal
and the server's own words:

- The refusal moves into ChannelRegistry.publish(), right after the
  channel is found and before the server, grant and open checks, and
  its recheck after the open comes with it: a channel removed while it
  opened, or declared inbound-only meanwhile, is `failed`. Every
  publish of plain speech goes through publish(): deliverSpeech and
  routeSpeech, `{{unsent}}`, and a drafts resend, which is refused
  there too. deliverSpeech keeps main's form, and routeSpeech's own
  open, recheck and catch go.
- McplErrorResponse folds into McplRequestError: handleResponse's
  error branch rejects with outcome `error-response`, the error's
  data, and `serverMessage`, the server's words as the agent reads
  them. Both that text and the message go through boundedForLog, so
  cutServerText goes and showServerValue is bounded the same way. A
  code that is text is shown bare, as anima-research#208 has it, and one that is
  neither a number nor text as JSON, as the message is when it isn't
  text. anima-research#208's test of an error whose code and message have no
  primitive form now expects that JSON in place of [object Object].
- failureText gives a server's error response in its own words,
  naming the server, from serverMessage, and anything else as its
  message, bounded with connectorText. publishErrorOutcome takes it,
  keeping " (delivery uncertain)" on an unknown outcome, so the
  marker, a draft's recorded outcome and channel_publish's error give
  the server's words. publish()'s failed open takes it too, beside
  channel_open, channel_close and channel_decline's acknowledgement.
- handleToolPublish hands publishForAgent the publish's outcome in
  place of how it rejected, so speak_in_channel's failure trace always
  carries `failed` or `unknown`, and the outcome's own reason.
- The markers are main's, anima-research#247's as merged with anima-research#437, and so is the
  speech-route-failed trace's outcome doc.

The rest keeps both sides. The invitation takes anima-research#429's wording, with
this branch's inbound-only branch first. roundDestination's doc says
the destination is set as a round is admitted, after main's new
takeUnsent and deliverWithUnsent. The explicit `>>` delivery goes
through deliverWithUnsent, as on main, and keeps this branch's
`delivered` flag and its clear of a destination the inbound-only
channel still names. Bounces take main's arguments with this branch's
reasons. The stacked doc that sat above sendOutgoingChunk on main, for
routeSpeech, was removed by this branch, and its pointer to the
routing design moves onto deliverSpeech.

Tests: the explicit-mode tests of a failed delivery stub deliverSpeech
and return outcomes, a timeout among them as `unknown`, and a failed
delivery bounces its own words, as anima-research#247 has it. Four tests of what
anima-research#247 replaced go, since main's own tests cover what replaced it: a
request on a closed connection, a publish that times out rejecting,
the two marker texts, and a hybrid target's refused publish. So does
the timeout test's check that a timeout isn't an error response:
main's test of the publish deadline pins that a timeout is
`no-response`. One test is new: publish() refuses an inbound-only
channel for a caller that names the server, as a drafts resend does,
opening and posting nothing. Two registry stand-ins gain the method
the other side calls. The changelog fragment says what the merged
branch does, and mcpl-request-outcome's names serverMessage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main is 297 commits past this branch's last merge (e670363). Six files
conflicted; the rest merged as git left them, and what this branch removes
is removed from main's new code too.

src/framework.ts
- The inbound handlers take main's containment (anima-research#208: onRequest wraps every
  handler, answering a failure once, so answerOnce and the per-handler
  try/catch are gone) without the awareness barrier this branch removes: no
  push/event, channels/incoming, inference/request or host/command waits on
  a generation.
- tools/list_changed is wired through onRequest, as on main, with this
  branch's body: no data-plane gate, and a background awareness delivery
  after the refresh.
- containedInboundHandler's comment no longer names data-plane barrier
  semantics, which this branch has none of.
- The import keeps McplRequestError, which the awareness delivery reads,
  beside main's boundedForLog and describeCause.

src/mcpl/server-connection.ts
- main's held-request budget and notification cap (anima-research#503, anima-research#387) replace the
  old cap and flushDepth. Its count of held requests is kept where this
  branch changes what is held: the transport boundary keeps a channel's
  messages and pushes (keepAcrossBoundary), and a flush that crosses a
  boundary holds the rest of its snapshot again. Both now go through
  replaceHeld, which recounts the requests among what is held.
- flushHeld (admission order, the boundary rule) dispatches through main's
  containment boundary (dispatchInboundEvent), as main's flushes do.
- The respond helpers bound to the transport a request came on move into
  main's dispatchMessage.
- HeldItem sits beside main's describeMethod; PROCESS_EVENTS beside main's
  hasResponder, which both sides added alike.

changelog.d/mcpl-request-outcome.added.md
- main's text, which 0.22.3 folds: it already says McplRequestError keeps
  the error's data and not its code, and its name is McplRequestError there
  too, so this branch changes nothing it describes.

Tests
- test/mcpl-awareness-barrier.test.ts stays deleted; main's change to it
  seeded a harness field for a barrier test.
- test/mcpl-request-handler-errors.test.ts (main's) loses the test that a
  tools/list_changed throw still completes or fails its data-plane gate,
  which this branch removes. The test that such a throw is contained and
  reported stays.
- test/stop-with-work-in-flight.test.ts: the held-at-the-barrier loop stays
  removed, and the next test takes main's title, which matches main's body.
- test/mcpl-request-outcome.test.ts: both sides' error-response test had one
  body; main's title says what it asserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
… tool call, and the blocks the host can't show say what happened in the agent's words (W4a)

The first of W4's three parts, rebuilt on main 0756302 (Ada-1017,
room-203 #139500, #139890). Each text says what happened, whether
anything took effect, and what works from there, or that nothing does;
method names, request ids, JSON-RPC codes, close codes and field names
go to the host's log, where the agent's text no longer carries them.

A failed MCPL request (server-connection.ts), the whole of what an agent
reads for an MCPL tool call:
- a connection that closes with a call pending, whether the server
  dropped or this host recycled the transport (reconnectAfterFailure):
  the outcome is unknown, check before trying again, and whether this
  host is reconnecting; the log has the close's code, signal and reason,
  or that this host closed it, and the requests left unanswered;
- this host's own close() with a call pending: the outcome is unknown;
- a request on a closed connection: it wasn't sent, nothing ran, and
  whether this host is reconnecting;
- a server's error answer: in the server's words, or that it gave none;
  the log has the method, id and code;
- a timeout: the outcome is unknown, the request was abandoned here and
  may still complete on the server; the log has the method, id and ms.

The framework's MCPL tool calls (framework.ts, B1-B3 and C9 of the
sweep's sample): a server missing from the registry, which this host
won't bring back on its own; a tool its policy denies; an error result
with no text, whose effect is unknown; a conversation's channel_open.

Blocks the host can't show (references.ts, convert-block.ts,
visible-content.ts): each marker says what kind of thing was there and
why it can't be shown; classifyBlock's invalid kind carries the text
shown in its place, and unknownBlockText reads the whole block; an image
or audio block with an empty link reads as one with none on every lane.
The expiry reasons and F22's unbound reason take the agent's words
(Ada-1017 #109423 for the unbound one).

Rebased onto main d6593a3, which carries #425. Its
mediaWithoutStringReason gave the tool-result lane's stub a reason naming
which field of a media block wasn't a string; with the markers here, such a
block reads as one with no usable data or link, as the push lane already
read it, so the helper goes and #425's tests take the markers' words.
INLINE_WITHHELD_TEXT, which #425 reworded, takes this change's words.

Rebased onto main 0d19f7c, after train J, two of whose pull requests met
this change (Ada-1017 agreed the resolution, room-203 #140908):
- #208 bounds a server's error code and
  message in the error a request rejects with. Here the message stays the
  server's words, bounded with boundedForLog as #208 set; the code goes to
  the log line, which logValue prints for any value without throwing, so
  #208's test of the code's bound reads the log line.
- #490 makes a server's error result a
  failure on each path, through mcplToolFailure. Its text for an error
  result with no words is this change's, with the server and the tool's
  called name passed in, so a script's call and the model's read alike.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…s error words, anima-research#204's marker routing, and the recheck just before the publish

main brings 28 pull requests since this branch's last merge of main
(ce1002b), among them anima-research#506 and anima-research#204. Four conflicts, one hunk each:

- src/mcpl/server-connection.ts, handleResponse's error branch: main's
  (anima-research#506). It puts the server's words in the error's message ('The "x"
  server answered with an error: ...', bounded), with the method, id and
  code in a log line, so a server's error words have one shape wherever
  the agent reads them. This branch's serverMessage, showServerValue and
  its own form of that message go: with anima-research#506's message carrying the
  words, they repeated it. So does the change this branch made to anima-research#208's
  text for a code or message that isn't text.
- src/mcpl/channel-registry.ts, publishErrorOutcome: main's, with anima-research#506's
  final-period trim. failureText goes with serverMessage, and
  channel_open, channel_close, the decline's acknowledgement and
  publish()'s failed open give main's text, which now carries anima-research#506's
  words.
- src/framework.ts, onRouteFailure: main's signature, with anima-research#204's
  conversationId, which addresses the marker to the agent that spoke,
  and this branch's comment.
- test/mcpl-request-handler-errors.test.ts: main's.

Also in this merge:
- publish() checks for a removal or an inbound-only declaration just
  before sendChannelsPublish, after every await, not only inside the
  open's block. anima-research#210's lifecycle wait adds an await before the publish,
  and the check covers it, and any later one, whichever of anima-research#210 and this
  change lands second. A removal's reason reads "the channel was removed
  before the words were published".
- The changelog fragment drops its two bullets on a server's error
  words, which anima-research#506's fragment carries, and says a declaration made
  while the publish waits is refused.
- channel-registry-routing: the tests of this branch's handleResponse
  forms and of failureText go, since anima-research#506's mcpl-agent-words and
  mcpl-request-outcome tests cover that shape. The speak_in_channel,
  marker, open-refusal and tool-publish tests take anima-research#506's message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

3 participants