Skip to content

fix(mcpl): manifestChanged without params, WebSocket close reasons in errors, and per-pause drop counts - #450

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:kestrel/mcpl-announce-close-reason-drop-count
Oct 17, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:kestrel/mcpl-announce-close-reason-drop-count

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

Three small fixes to the MCPL connection, one commit each with its tests and changelog fragment, so each can be taken or left on its own. #449 describes the defects, with the runs on main that show them. The new tests fail on main at 3adbdb8 and pass at this head. !Fixes #449.

Changes

  1. mcpl/manifestChanged without params (18ff80e). handleManifestChanged read the announcement's domains for its receipt only after it had applied the fetched manifest. A notification sent without params reaches it as undefined, so it threw once the grant had changed, and emitted no mcpl:manifest-change-receipt and refreshed no tools list. It now reads the announcement first, through announcedDomainsOf, which gives the strings of its params' domains array, or none when the params are missing or malformed. The receipt's announcedDomains is string[] in its trace type, and now always is one.
  2. A WebSocket server's close reason in errors (8605165). Two errors put a WebSocket peer's close reason into their text as the server wrote it: the connect error for a transport that closes during the handshake, and the error each pending request is rejected with when the connection drops. mcpl:server-closed already leaves that text out (reasonFromPeer). Both errors now give the transport's own words for such a close, websocket closed. They keep the close code, which says what the server meant whether or not it sent a reason (4001 marks a refused credential). A request's error already had code=. The handshake's error now ends (websocket closed, code 4001) for any WebSocket close, since, by our reading, a close before the handshake completes reaches no 'close' event, and on main it gave no code. The connect error reaches the host's log, the mcpl:server-connect-failed trace and the mcpl-down alert, and, by our reading, a request's error reaches an MCPL tool call's result. So a reason with a line break could pass for a host log line, or mislead the model. A stdio child's exit reads as before.
  3. The buffered-event cap's drop count (c15fd9d). While a plane is paused, a server's responder-less events are buffered up to MAX_BUFFERED_EVENTS, and past it the oldest is dropped. droppedBufferedEvents counted those drops over the connection's whole life. So a later pause's flush line reported earlier pauses' drops as its own, and the drop log, which speaks at the first drop and at every hundredth after it, counted across pauses too. ready(), which ends a pause, now reads the count for its line and resets it.

What each part is for

Each fix was weighed against a simpler or a wider one.

  • announcedDomainsOf. Reading the announcement before anything is applied ends the throw after the grant has changed, and taking only the strings of an array keeps the receipt's announcedDomains the string[] its type says. Refusing an announcement without params instead would leave a server's real manifest change unapplied for want of a hint the handler doesn't need, since the handler reads the change itself from the fetched manifest.
  • closeReasonText. One helper serves both errors, so the rule mcpl:server-closed already follows, that a peer's close reason stays out, holds wherever a close reason becomes an error. The close code stays in each, since it's a number the server can't use to write a line, and it tells a refused credential from another close. The handshake tells a WebSocket close from a stdio exit by the transport's existing kind, so the close info needs no new field. Escaping the peer's text instead, as host log lines do with logDetail, would keep server-written text in errors that reach the model and the alert.
  • The reset in ready(). ready() ends a pause and reads its count for the flush line, so the read and the reset are one step. Resetting in pauseDataPlane() instead would zero a pause's count whenever the data plane is paused again before it opens, and four call sites pause it: two reconnect paths (reconnectAfterFailure, reconnectOnce), a quiesce (runQuiesce) and the data-plane gate (installMcplDataPlaneGate).

Overlaps

#208 rewrites the manifest-changed listener as onRequest('manifest-changed', ...), so the two conflict once, at that listener in src/framework.ts. Merged together, the listener keeps #208's onRequest form with params: unknown. src/mcpl/server-connection.ts merges without a conflict.

Tests

  • mcpl-manifest-changed. An announcement without params gets one receipt naming no domains, with the fetched manifest applied and the tools list refreshed. Params that are null or a string, or domains that isn't an array, name none, and an array reports only its strings.
  • mcpl-ws-transport. A server that closes with code 4001 during the handshake fails the connect with (websocket closed, code 4001), both with a two-line reason and with none. One that closes with that reason while a tools/call waits rejects it with reason=websocket closed. Neither error holds any of the server's text. Two pauses past the cap report their own drops, 150 and 75, the second through a control-only flush and a second pauseDataPlane(), and the drop log speaks at each pause's first drop.
  • Runs. On main at 3adbdb8 with these tests added, the six new tests fail on Linux with Node 20.20.2 and 24.22.0, each on its defect. At this head typecheck and build are clean, and our runner runs the suite eight files at a time with --test-force-exit. On Node 24.22.0 it reports 2433 of 2434 tests passing in 152 files. The one failure is tool-result-guard's "fix(guard): a withheld tool result says where its original is (#277) #281: batches behind one stalled write cost one deadline: no write starts until it settles", in a file this PR doesn't change, and history-module-overview stopped early at 22 of 28. On Node 20.20.2 it reports 2373 of 2373 in the 151 files that finished, and mcpl-coalescing didn't finish within 1200 seconds. Run one at a time without that flag, history-module-overview passes 28 of 28 and mcpl-coalescing 67 of 67 on both runtimes, and tool-result-guard passes 124 of 124 on Node 24 and 122 of 124 on Node 20, where two of its fix(guard): a withheld tool result says where its original is (#277) #281 tests fail. CI's unforced step passes 105 of 105 on both.

Implemented by Kestrel-2409 and independently reviewed by Esther-2584, both using Claude Opus 5.5. Ada-1017 filed the three defects from an internal review of #208 and made the calls on their scope.

Kestrel-2409 and others added 3 commits October 17, 2026 11:30
…ike any other

handleManifestChanged read announce.domains for its receipt only after it
had applied the fetched manifest: the grant, the feature sets and the policy
request. A notification may carry no params, and the wiring passes them
through as they arrive, so an announcement without params reached the
handler as undefined and threw a TypeError there. The receipt was never
emitted and handleToolsListChanged never ran, though the grant had changed;
the listener's catch logged "manifest refresh failed".

The handler now reads the announcement's domains first, through
announcedDomainsOf: the strings of its params' `domains` array, or none when
the params are missing or malformed. The receipt's announcedDomains is
string[] in its trace type, so a `domains` that isn't an array of strings
no longer reaches it as whatever the server sent. The listener and the
handler take the params as unknown, which is what they are.

Tests: an announcement without params gets one receipt naming no domains,
with the fetched manifest applied and the tools list refreshed; params
that are null or a string, or domains that aren't an array, name none, and
an array holding non-strings reports only its strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a close makes

A WebSocket transport marks a close reason the peer sent as the server's
own text (reasonFromPeer). mcpl:server-closed leaves that text out and keeps
to the transport's words, but two errors still put it in raw: the connect
error when the transport closes during the handshake, and the error every
pending request is rejected with when the connection drops. The connect
error is the error a connect-failed or reconnect-failed event carries,
which the host traces as mcpl:server-connect-failed and, from a
reconnect's fifth failed attempt, posts in the mcpl-down alert; a
request's error reaches its caller, and an MCPL tool call's catch puts it
in the agent's tool result. A reason the server writes with a line break
prints a second line that reads as the host's own.

Both now take the reason through closeReasonText: the transport's words,
and for a peer's reason the words the transport uses for a close without
one, "websocket closed". A WebSocket close's code, a number, says what the
server meant (4001: a refused credential), with or without a reason, so
each error keeps it: a request's error has code= already, and the
handshake's adds it for any WebSocket close, "(websocket closed, code
4001)", since a close before the handshake completes reaches no 'close'
event. On main the handshake's error gave no code, with or without a
reason. The transport's kind tells a WebSocket close from a stdio child's
exit, whose error reads as before.

Tests: a server that closes with code 4001 during the handshake fails the
connect with "(websocket closed, code 4001)", both with a two-line reason
and with none, and one that closes with that reason while a tools/call
waits rejects it with "reason=websocket closed"; neither error holds any
of the server's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a plane is paused, a busy server's responder-less events are
buffered up to MAX_BUFFERED_EVENTS, and past it the oldest is dropped
(issue anima-research#122). droppedBufferedEvents counted those drops for the
connection's whole life and was never reset. So the flush line that ends a
later pause reported every earlier pause's drops as its own ("225 dropped
at cap" for a pause that dropped 75), and the drop log, which speaks at
the first drop and at every hundredth after it, counted across pauses
too.

ready(), which ends a pause by flushing its buffer, now reads the count
for its line and resets it, so each pause reports and logs its own drops.
A control-plane-only flush leaves the data plane paused, and its count
running.

Test: two pauses past the cap. The first drops 150 and its flush line says
150; the second drops 50, runs through a control-only flush and a second
pauseDataPlane(), drops 25 more, and its flush line says 75. The drop log
speaks at the first pause's 1st and 101st drop and at the second pause's
1st.

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

greptile-apps Bot commented Oct 17, 2026

Copy link
Copy Markdown

Required label not found on this PR.

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
@nissa-seru
nissa-seru merged commit 34e61c4 into anima-research:main Oct 17, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…#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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…'s default branch names, anima-research#426's failed-create release, anima-research#450's close reasons and anima-research#451's config checks

Conflicts, and what each keeps:
- src/framework.ts imports: this branch's McplRequestError beside anima-research#451's
  assertServerConfigBooleans.
- initializeMcpl's connect catch: main's rethrow of a
  DiscordAwarenessAccountingError (anima-research#426), so an accounting failure during a
  startup connect ends startup, and create()'s release closes the servers.
- initializeMcpl after its connects, and a runtime connect's tail: this
  branch's, with no data-plane gate. main changed only comments there, and
  anima-research#426 dropped the startup gate's closeAll in favour of create()'s release.
- runRestartMcplServer: anima-research#451's config check before the disconnect, then
  this branch's journal read, for a server that derives the protective
  default only.
- src/mcpl/server-connection.ts, a lost connection's rejection: this
  branch's McplRequestError ('no-response') with anima-research#450's closeReasonText.
- test/fixtures/awareness-barrier-mcpl-server.mjs stays deleted, with the
  barrier it served.

Carried from main:
- anima-research#405's default branch names now pass over a name a suppression whose
  removals aren't recorded as finished claims, so a default name is never
  refused for a claim. While the journal can't be read, a claim counts for
  nothing in the choice, and checkDiscordAwarenessBranchName refuses the name
  with the journal's message. The helper names rollbacks, suppressions,
  turn undos and a host command's message undos. The offline recovery's
  default name follows the same rule once its journal is open; a dry run
  doesn't open the journal, so its default name passes over held branches
  only.
- anima-research#426's test that a create() failing after its MCPL server connected
  closes the server: this branch's startup reconciles awareness before any
  MCPL server connects, so the test injects its failure into the tool
  discovery that follows the connects, and reads the handshake from the
  reregistering fixture's events.
- anima-research#405's test that an explicit name stays the caller's: this branch refuses
  a known collision before anything is prepared, so the refusal it expects
  is "Rollback branch chosen already exists" (invalid), not the store's.

New tests in operator-branch-names: a default rollback name a pending
suppression claims takes the next free name; while the journal can't be
read, a default name is refused with the journal's own message; an
offline recovery's default name a pending suppression claims takes the next
free name, while a dry run's passes over held branches only; and while the
journal can't be read, an offline recovery's default name is refused with
the journal's own message.

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 17, 2026
main had taken 123 commits since 2482625, among them skip_reply's
refused wake (anima-research#198), the deferral rules (anima-research#286) and the close reason a
pending request's rejection names (anima-research#450).

Conflicts, both sides kept:
- prose-segments: anima-research#198's withoutRoundsProse beside anima-research#247's
  splitProseRuns. The framework imports both, and XML mode's turn-end
  runs are anima-research#247's runs of the turn's content without the refused
  rounds' prose, as main's segments are.
- XML mode's turn-end silencing scan: anima-research#247's cause (silenceCauseOf)
  over the calls main's scan counts, a refused skip_reply left out.
- driveStream's finally: anima-research#247's drafts settlement, then main's fork
  disposal (anima-research#286), both after main's turn-end flush. A closing fork's
  closure turn lands its notices in the fork's store, records its
  drafts as noticed, and then the fork goes.
- channel-registry: anima-research#247's acceptInbound beside anima-research#198's channelTools.
- server-connection: a disconnect rejects a pending request with
  anima-research#247's McplRequestError ('no-response'), its text naming anima-research#450's
  closeReasonText.
- prose-segments tests: anima-research#247's trimmed-runs helper and tests beside
  anima-research#198's withoutRoundsProse tests.

Beyond the conflicts: anima-research#198 saves the silence before a refused wake's
round and restores it at the next round, as a boolean. anima-research#247 keeps why
a turn is silenced beside it (an explicit send holds the prose as
drafts; skip_reply keeps it private), so restoring the boolean alone
left the refused round's 'private' standing, and a send after a
refused wake had its prose kept private instead of held. The saved
silence now carries its cause, and the next round restores both. A
test in prose-drafts pins it in native and in XML tool mode.

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
Brings in main since 91e70ed, among it: the check of a server's boolean
settings (anima-research#451), the release of a failed create() (anima-research#426), a broken stdin or
stdout ending a stdio child's connection (anima-research#384), the stderr tail a failed
handshake keeps (anima-research#399), a WebSocket close's reason (anima-research#450) and
unbound references (anima-research#404).

Conflicts, resolved by hand:
- src/mcpl/server-registry.ts: this branch's structure (adding, register,
  holdsUnreapedChild) with anima-research#451's two reads, `config.reconnect === true`
  at the connect and `!connection.willReconnect` at the close.
- src/framework.ts:
  - The startup catch and the startup barrier take main's form: an
    accounting failure or a failed barrier ends startup, and create()'s
    release closes every server through stop(), both engines' connections
    among them. This branch's closeMcpConnectionsAfterFailedStartup goes,
    with its test: stop() closes both engines' connections, each whatever
    the other's close did, and what it couldn't close is in create()'s
    rejection, where the old cleanup logged it.
  - The startup catch keeps this branch's redaction and willRetry, and
    takes main's stderr tail, redacted in its log line and its trace.
  - restartMcplServer checks the boolean settings beside this branch's
    checks, before the running server is torn down.
  - The legacy connect and reconnect failures' traces carry main's
    stderrTail, redacted, and the alert still leaves it out.
  - stop() resets main's stopFinishedTeardown, then ends the connects in
    flight.
  - handleManifestChanged reads its configuration and the announced domains
    before anything else.
- src/mcpl/server-connection.ts: the handshake keeps this branch's
  protocolVersion and owner's close, and takes main's stderr tail on its
  transport failures. connectWithReconnect keeps this branch's throws, with
  `config.reconnect !== true`. The initial connect's and a reconnect
  attempt's log lines and events carry the stderr tail. A pending request's
  error is this branch's McplRequestError with main's closeReasonText.
  withStderr takes the server's redactor, so no log line prints the tail
  unredacted.
- src/mcpl/types.ts: requestTimeoutMs's doc keeps both sides.
- The tests: the reference fields' imports, and transport-close's `within`
  with main's bound.
- changelog.d/stop-waits-for-operator-operations.fixed.md was released in
  0.22.2. This branch's change to it, stop() ending a connect in flight,
  moves to a fragment of its own.

Beyond the conflicts, so that the two sides agree:
- anima-research#451's check skips inheritEnv for every network server, an http(s) one
  included, since only the stdio spawn reads it.
- A script's view of an MCPL tool result registers its references bound
  to their server, as anima-research#404's lanes register the model's, so its expired
  reason is the stub's. The dispatch's fetch pass had bound each reference
  a script reads already.
- anima-research#384's handling of a broken stdin or stdout (the child ended, its close
  naming the error, no 'error' event) replaces this branch's, so two of its
  tests now expect the close's reason.
- Main's tests that wire a legacy connection pass the configuration this
  branch's wiring takes, and build a stand-in StdioTransport with its
  server id. The stderr tail's tests check its redaction in the log lines
  and the trace, and a new one checks it at startup.
- The redaction changelog fragment names the stderr tail.

Checks, on Linux:
- Each of 8 pieces the merge adds, removed on its own, fails a test on
  Node 24.22.0: the stderr tail's redaction in the initial connect's, a
  reconnect's and the startup's log lines, in the startup trace, and in
  the legacy connect and reconnect traces; a script's reference binding;
  and the inheritEnv skip for an http(s) server.
- The typecheck and the build pass, and every test file passes, each in a
  process of its own and unforced: 182 files and 2,940 tests on Node
  24.22.0, and the same on Node 20.20.2.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCPL connection: manifestChanged without params throws, WebSocket close reasons reach errors raw, and the drop count spans pauses

1 participant