Repository navigation
fix(mcpl): stop() sets reconnectEnabled to false on every MCPL connection - #875
Merged
nissa-seru merged 1 commit intoOct 29, 2026
Conversation
…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
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.
1 task done
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
stop()now setsreconnectEnabledto false on every MCPL connection, in either engine, in the step afterendConnectsInFlight, 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 whilestop()waits, and a legacy WebSocket server whose retry is waiting on its access provider isn't dialed again. Before, a server withreconnect: truelost that way kept its scheduled retry. Whenstop()waited long enough, 8 s in the probe under Problem, its command was started afresh and reconnected, and it was gone oncestop()returned. A relaunch runs whatever the server does before it serves: discord-mcpl'smain, for instance, connects to Discord and waits for its ready event before it serves stdio (src/index.ts:48–49and:119at itsmain,833444a).Problem
At
e85c57db9a,stop()callsstopOwned(src/framework.ts:4875), and the comment atstopOwned'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 callsendConnectsInFlight(:5015), whose loop is overthis.mcpServerOpsentries of kindconnect(:4929–4945). A connection's own retry timer isn't one of those: the legacy engine's is set inscheduleReconnect(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/client2.4.0, chokidar 4.0.3, discord.js 14.27.0 and ws 8.22.0. In each engine, one stdio server withreconnect: truewas connected, its child got SIGTERM, andstop()was called 20 ms later, held open by a model stream for a set time: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 stoppingeach time.In every case, no child was alive 1.5 s after
stop()returned.Changes
McplServerConnection.stopReconnecting()(src/mcpl/server-connection.ts:738–741) setsreconnectEnabledto false and aborts a reconnect attempt under way with the abortclose()uses. It closes nothing.ModernMcpConnection.stopReconnecting()(src/mcpl/modern-connection.ts:527–529) setsreconnectEnabledto false, and closes nothing.@internal, "For the framework's stop(); not a host API."stopOwned, right after theendConnectsInFlightstep, callsstopReconnecting()on every connection in the legacy registry and every modern connection (src/framework.ts:5016–5022).scheduleReconnectandattemptReconnecteach return whenreconnectEnabledis false (src/mcpl/server-connection.ts:2002,:2030). The modern ones each return unlesswillReconnect, which readsreconnectEnabled(src/mcpl/modern-connection.ts:913,:921,:519–521).stopReconnecting()orgetAllServers(), whichstop()now calls.stop()begins isn't aborted, since the modernstopReconnecting()only setsreconnectEnabledto false. A reader's probe under Tests shows such an attempt reconnecting during the stop.changelog.d/mcpl-no-reconnect-after-stop.fixed.md.With #844
@tel-0s's draft #844 at
17087e27fbalready conflicts withe85c57db9ain 18 files, bygit 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 addsstopReconnecting(): 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:
npm run cleanandnpm run buildsucceed, andnpm testgives 8,795 tests: 8,733 passed, 0 failed and 62 skipped.test/mcpl-no-reconnect-after-stop.test.tshas two tests:stop()for 300 ms, a server whose child gets SIGTERM beforestop()begins, or after it has begun, or whose every launch fails, launches no child whilestop()waits and traces nomcpl:server-reconnected. A shell in front of each server records each launch as it spawns, and the launches running right after the call tostop()are counted with those recorded before it, so a launch spawned just beforestop()whose shell records it late isn't counted as one during the stop;stop()begins doesn't dial when the provider answers.stopOwnedfails both tests, and so does taking out the legacystopReconnecting(). Taking out the legacy abort alone fails the access-provider test, and taking out the legacy flag alone, or the modernstopReconnecting(), 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@internaldoc comments, did: agent-framework commits no lockfile at its root, andnpm ls --depth=0after its install printed chronicle 0.4.0, context-manager 0.23.1, mcpl-core 0.4.0, membrane 0.8.0,@modelcontextprotocol/clientand@modelcontextprotocol/server2.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:These runs didn't record their installed versions either:
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.stop()begins, from a reader's probe at an earlier version of this change whosesrc/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 oncestop()settled.Not verified
stop()begins.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.