Skip to content

fix(mcpl): stop() sets reconnectEnabled to false on every MCPL connection - #875

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:amos/f580-no-reconnect-after-stop
Oct 29, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:amos/f580-no-reconnect-after-stop

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

stop() now sets reconnectEnabled to false on every MCPL connection, in either engine, in the step after endConnectsInFlight, and aborts a legacy-engine reconnect attempt already under way. Both engines check that flag before they schedule or start a reconnect attempt (cited under Changes). In this change's tests, a stdio server lost just before or during a stop, in either engine, isn't launched again while stop() waits, and a legacy WebSocket server whose retry is waiting on its access provider isn't dialed again. Before, a server with reconnect: true lost that way kept its scheduled retry. When stop() waited long enough, 8 s in the probe under Problem, its command was started afresh and reconnected, and it was gone once stop() returned. A relaunch runs whatever the server does before it serves: discord-mcpl's main, for instance, connects to Discord and waits for its ready event before it serves stdio (src/index.ts:48–49 and :119 at its main, 833444a).

Problem

At e85c57db9a, stop() calls stopOwned (src/framework.ts:4875), and the comment at stopOwned's first step reads "No MCP server is admitted from here on (stopHasBegun): none is launched or dialed while the rest are being closed" (:5009–5010). That step calls endConnectsInFlight (:5015), whose loop is over this.mcpServerOps entries of kind connect (:4929–4945). A connection's own retry timer isn't one of those: the legacy engine's is set in scheduleReconnect (src/mcpl/server-connection.ts:1987), and the modern engine's in its own (src/mcpl/modern-connection.ts:904).

A probe ran agent-framework at e85c57db9a, and the published 0.28.0 installed from npm, on Linux with Node 24.22.1. Their dependencies installed at the same versions for every package both have: chronicle 0.4.0, context-manager 0.23.1, membrane 0.8.0, @modelcontextprotocol/client 2.4.0, chokidar 4.0.3, discord.js 14.27.0 and ws 8.22.0. In each engine, one stdio server with reconnect: true was connected, its child got SIGTERM, and stop() was called 20 ms later, held open by a model stream for a set time:

  • Not held, or held 2 s, at the default retry interval: one launch, in both engines and both builds.
  • Held 8 s at the default interval: the server was launched again and reconnected while stop() waited, 4.1 to 6.3 s after the loss, in both engines and both builds. The legacy engine logged [mcpl] channels/register from discord refused: the host is stopping each time.
  • At a 200 ms interval, with a child that takes 3 s to serve: held 2 s, the server was launched again and that child was gone before it served; held 8 s, it was launched again once, served, and reconnected 3.2 to 3.5 s after the loss.

In every case, no child was alive 1.5 s after stop() returned.

Changes

  • McplServerConnection.stopReconnecting() (src/mcpl/server-connection.ts:738–741) sets reconnectEnabled to false and aborts a reconnect attempt under way with the abort close() uses. It closes nothing.
  • ModernMcpConnection.stopReconnecting() (src/mcpl/modern-connection.ts:527–529) sets reconnectEnabled to false, and closes nothing.
  • Both methods' doc comments mark them @internal, "For the framework's stop(); not a host API."
  • A new step in stopOwned, right after the endConnectsInFlight step, calls stopReconnecting() on every connection in the legacy registry and every modern connection (src/framework.ts:5016–5022).
  • What reads the flag: the legacy scheduleReconnect and attemptReconnect each return when reconnectEnabled is false (src/mcpl/server-connection.ts:2002, :2030). The modern ones each return unless willReconnect, which reads reconnectEnabled (src/mcpl/modern-connection.ts:913, :921, :519–521).
  • Five test files' stand-in connections and registries gain stopReconnecting() or getAllServers(), which stop() now calls.
  • What it leaves: a modern attempt already under way when stop() begins isn't aborted, since the modern stopReconnecting() only sets reconnectEnabled to false. A reader's probe under Tests shows such an attempt reconnecting during the stop.
  • Changelog fragment: changelog.d/mcpl-no-reconnect-after-stop.fixed.md.

With #844

@tel-0s's draft #844 at 17087e27fb already conflicts with e85c57db9a in 18 files, by git merge-tree. This commit adds a conflict in one more, test/stop-with-host-operations.test.ts: #844 removes the test "stop() waits for a resume past its commit point: it completes, its durable clear lands, and its data plane opens", and this commit adds stopReconnecting(): void {} to that test's stand-in connection. Among the other pull requests open at 11:29Z on Oct 28, this commit adds no conflict.

Tests

At this head, on Linux with Node 24.22.1:

  • The suite: npm run clean and npm run build succeed, and npm test gives 8,795 tests: 8,733 passed, 0 failed and 62 skipped.
  • test/mcpl-no-reconnect-after-stop.test.ts has two tests:
    • in both engines, with reconnect at a 50 ms interval and a model stream holding stop() for 300 ms, a server whose child gets SIGTERM before stop() begins, or after it has begun, or whose every launch fails, launches no child while stop() waits and traces no mcpl:server-reconnected. A shell in front of each server records each launch as it spawns, and the launches running right after the call to stop() are counted with those recorded before it, so a launch spawned just before stop() whose shell records it late isn't counted as one during the stop;
    • a legacy WebSocket server whose retry is waiting on its access provider when stop() begins doesn't dial when the provider answers.
  • Breaks: taking out the new step in stopOwned fails both tests, and so does taking out the legacy stopReconnecting(). Taking out the legacy abort alone fails the access-provider test, and taking out the legacy flag alone, or the modern stopReconnecting(), fails the stop test.

That run didn't record its installed versions. One at aa0cb37dae, an earlier version of this change that differs from this head only by the two @internal doc comments, did: agent-framework commits no lockfile at its root, and npm ls --depth=0 after its install printed chronicle 0.4.0, context-manager 0.23.1, mcpl-core 0.4.0, membrane 0.8.0, @modelcontextprotocol/client and @modelcontextprotocol/server 2.4.0, chokidar 4.0.3, discord.js 14.27.0, ws 8.22.0, tsx 4.23.15 and typescript 7.0.2. There, on Linux with Node 24.22.1, the suite and the breaks gave the same results, and:

  • Time: run alone three times after that install, the test file took 4.0 to 5.0 s, with a one-minute load of 12 just before the first. About 2.1 s of each is its seven held stops, each released after 300 ms.

These runs didn't record their installed versions either:

  • Red first: in five runs of the new test file at e85c57db9a, both tests failed each time. Every stop case launched a child during the stop: a lost server once more, reconnecting in 16 of those 20 cases, and a server failing since its start five or six more times. The WebSocket server was dialed a second time and reconnected.
  • An attempt already under way when stop() begins, from a reader's probe at an earlier version of this change whose src/ differs from this head's only by the two doc comments, with a second launch that waits 2 s before it serves: the legacy attempt was aborted and didn't reconnect, and the modern one reconnected during the stop. In both engines no launch was alive once stop() settled.

Not verified

  • A modern HTTP server wasn't run during a stop, neither a redial nor an attempt under way when stop() begins.
  • A real discord-mcpl wasn't run: the probe and tests use stand-in servers.

  • Changelog fragment added: changelog.d/mcpl-no-reconnect-after-stop.fixed.md.

Amos-2081 made the change. Naomi-2422 read it and ran earlier versions of it, and Alfred-2451 weighed an earlier version's parts against the smallest change that would do. Lottie-2661 wrote this description.

…d again on its own

stop()'s first step says no MCP server is launched or dialed while the rest are being closed, and ends connects in flight. A connection's own reconnect timer wasn't one of those: a server with `reconnect: true` lost just before or during the stop, as when a service manager signals the host and its children together, kept its scheduled retry. When stop()'s waits (a model stream, a script, an operator operation) outlasted the retry's delay, 3.75 to 6.25 seconds at the defaults, its stdio command was started again, could reconnect while stop() waited, and was then closed by the teardown. In both engines, at e85c57d and in the published 0.28.0, a server whose child got SIGTERM 20 ms before stop() was relaunched about 5 s later when stop() was held 8 s, and reconnected. A server that logs in to an outside service before it serves, as a Discord bridge does, did that work for a process the teardown then killed.

- McplServerConnection and ModernMcpConnection gain stopReconnecting(), marked @internal, for the framework's stop() and not a host API: it turns reconnecting off and closes nothing. A retry already scheduled starts nothing when its timer fires (attemptReconnect already checks), and none is scheduled after. The legacy one also aborts an attempt under way, with the abort close() uses, so an attempt still waiting on its access provider never dials and a child it already spawned is reaped. A modern attempt under way has already spawned its child or opened its HTTP transport as open() begins, so it is still ended by the teardown's close, as before.
- stop()'s first step, after endConnectsInFlight, calls it on every registered connection of both engines.

The changelog fragment says what operators see.

Tests (test/mcpl-no-reconnect-after-stop.test.ts): in both engines, with reconnect at a 50 ms interval and a held model stream keeping stop() waiting 300 ms, a server whose child gets SIGTERM before stop() begins, or after it has begun, or whose every launch fails from its first, launches no child while stop() waits and traces no mcpl:server-reconnected; a shell in front of each server records each launch as it spawns, and the launches running when stop() returns are counted with those before it, so a launch spawned just before stop() whose shell writes its pid after the count isn't taken for one during the stop. A legacy WebSocket server whose retry is waiting on its access provider when stop() begins doesn't dial when the provider answers. On e85c57d every case launched or dialed during the stop. Test doubles in five files gained the method or getAllServers, since stop() now calls them; the registry and the modern connections are the framework's own, so no caller hands it one.
@nissa-seru
nissa-seru merged commit 853bcde into anima-research:main Oct 29, 2026
15 checks passed
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 29, 2026
main took ten pull requests after 0.30.0 (anima-research#866, anima-research#864, anima-research#873, anima-research#874, anima-research#877,
anima-research#872, anima-research#881, anima-research#875, anima-research#378, anima-research#876). Two places conflicted, and both keep both
sides:
- src/gate/event-gate.ts: this branch's sleepTurnId field, with main's
  rewritten note on dispose(), since anima-research#881 removed resumeSleep(). This
  branch never called resumeSleep().
- test/turn-trigger-provenance.test.ts: this branch's imports. main added
  an import of MembraneError, which this file already imports, with
  StreamEvent and YieldingStream, from the same package.
tel-0s added a commit that referenced this pull request Oct 29, 2026
…instance, inference bounds, the subconscious's own window and binding

main at 4a45766 is 11 merges past this branch's last (630e2a3, the 0.30.0
release), with no release among them, so package.json stays 0.30.0 and this
branch's floors stand (chronicle ^0.6.0, context-manager ^0.25.0, membrane
^0.8.0), which test/dependency-ranges pins. It brings #881 (a stop ends its
AgentFramework instance, #315: start() and addModule() refused once a stop()
is admitted; lifecycleGeneration and EventGate.resumeSleep() gone), #378
(inferenceBounds: a model call that stops making progress is cut, and a
deadline cut gets one fresh call; the [inference-failed] marker names a
timeout and a call made again), #869 (subconscious.physicalWindowTokens and
thinkingBinding, for the persistent reader and each reader fork), #876 and
#875 (mcpl-down alerts for a server lost for good; stop() ends reconnects),
#877 and #874 (a merged start taken back is merged again from its wakes; a
start parked behind an auxiliary call waits out a provider hold), #873 and
#872 (an outside sender's name marks notice openings; supplied text on one
line at six more places), #864 (a workspace mount's root in status) and #866
(discord.js loaded at the first client).

Six paths conflicted (8 regions):

- src/framework.ts (3):
  - stop()'s comment on ending each live fork: main's text. Its clause on
    a start() after this stop() routing to a gone name goes with #315,
    which refuses that start().
  - stop()'s final pass over deferred writes: this branch's
    reportHeldWritesAtStop stands (no deferral queue, #291). Main changed
    only that block's comment (a start() refused once a stop() is
    admitted).
  - an ephemeral run's disposal: both lines, this branch's
    forgetPresentation and main's deadlineCuts.delete.
- test/reinit-after-branch-move (1): main's form (a new framework on the
  stopped one's store, #315), with this branch's presentAtEndOf on the first
  framework's template, so the row written past the framework is in the
  template's window and the next framework's fork is seeded with it.
- test/route-failure-targeting (1): imports: this branch's audienceOf,
  without JsStore, whose one user (the start-after-stop test) main deleted.
- test/stop-with-host-operations (1): the resume-past-its-commit-point test
  stays deleted, as this branch deleted it: it held the resume's
  flushDeferredWrites, which went with the deferral queue. Main's change to
  it was its connection mock's stopReconnecting.
- test/stop-with-work-in-flight (1): imports: ContentBlock alone. Main
  dropped JsStore with its start-after-stop test, and StreamEvent's one user
  (the write deferred behind a failed retry's entry) is deleted on this
  branch.
- test/turn-continuation (1): main's emptiedBy ('drop' | 'withdrawal') in
  Internals; deferredMessages stays out, as on this branch.

Read onto this branch past the conflicts:
- the [inference-failed] marker's new words (a timeout, the call made
  again, TIMEOUT_ADVICE) merged into this branch's storeOwnRow write: an
  own row, presented as it is stored.
- deadlineCuts.delete is in releaseAgentRuntime, where main put it, beside
  forgetPresentation, and in the two name-keyed releases main also put it
  in (an ephemeral run's disposal, disposeConversationAgent with nothing
  live under the name), as thinkingLossIncidents.forget is.
- the subconscious's AgentConfig takes readerModelFields after the
  primary's inherited fields and before this branch's selfParticipants and
  presentAs overrides; a reader fork's derive config takes them too.

storeSees: no new site. Main opens no context manager: a reader fork's own
window and binding ride the derive, whose view is recorded as before, and
the subconscious's config goes through createSubconsciousAgent's open.
agentSees: no new view decision. Notice tags: main's new and changed agent
texts use NOTICE_TAG (inferenceFailed, script, workspaceEvent,
conflictsWithDisk); its mcpl-down text is an ops alert, and its
[coalescing] and [provider-admission] lines go to stderr. Teardown:
deadlineCuts in releaseAgentRuntime (above).

Changelog: no release in main's range, so no fragment was consumed; main's
15 new fragments come in as written, and none holds a presentation-side
sentence to carry.

Tests: built with zero errors; 1 stale compiled helper removed
(test/helpers/quiesce-crash-child, whose source went with the deferral
queue); fixtures copied. main's 44 changed test files, the five conflicted
among them: 2736 tests, 2711 pass, 0 fail, 25 skipped (workspace watcher
and root cases that need Linux). presentation*, window-*, dendrite-*,
stored-injections, audience-stamp*, dependency-ranges, held-* and
tune-out-* (44 files): 605 of 605.

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.

1 participant