Repository navigation
fix(modules): once a module's stop() completes, its context has no effect and the framework stops calling it - #898
Merged
nissa-seru merged 13 commits intoOct 29, 2026
Conversation
added 13 commits
October 28, 2026 06:39
…ttled A module removed with removeModule(), or stopped by the framework's stop(), kept a working context: by run at e85c57d, its setState and log-state writes landed where a later module of the same name read them as its own, its addMessage, editMessage and removeMessage changed an agent's window, its callTool ran another module's tool with module origin, its getModule handed out live modules, its pushes reached every remaining module, its speech-handler calls added a handler under its name or removed a later namesake's, its notifyOps raised ops alerts, and the trace listeners it subscribed kept firing. The registry now retires a module's context when that module's stop() settles successfully, in stopModule's success branch, which removeModule() and stopAll() share; a stop whose budget ran out retires its context when the module's stop() later settles, and a module whose stop() fails stays registered with a working context for the retry. A retired context: - drops each push, through pushEvent() and the queue handle, with a line per push: after the framework's stop() the existing lines (a tool result's names the call, the tool and its outcome, which an operator reconciles per call); after removeModule() mid-lifecycle the same lines saying the module stopped. The registry takes the line from a new optional dropRetiredPush option, which the framework supplies, and logs its own when it's absent, so a retired push is never handed to the queue. - makes setState, registerLogState, appendToLog, editLogItem, editMessage, removeMessage, notifyOps, registerSpeechHandler and unregisterSpeechHandler do nothing; addMessage returns '' (the context's existing "names no row" value) and adds nothing; callTool resolves with an error result and runs nothing; getModule returns null; onTrace subscribes nothing and returns a no-op unsubscribe. None throws, since a late callback can't catch a throw. The first such call of each of these methods logs one line naming the module and the method. - ends the trace subscriptions made through it: the context records each unsubscribe it hands out and calls them at retirement. - answers findMessageByExternalId as before without deleting a stale entry or recording a landed write's id, since those writes serve only a running module. Reads (getState, getLog, getLogLength, getMessage, queryMessages, getAgents, getActiveTools) are unchanged; getMessage and queryMessages may still settle the framework's own record of a held write's landing, which any reader triggers and which adds no content. The tool-result drop line moves into droppedToolResultLine so both drops word it one way; its text after stop() is unchanged. ModuleContext's doc and a changelog fragment say what a stopped module's context does. Tests (test/retired-module-context.test.ts) cover each method that had an effect, with a later module of the same name where the effect reached one: all 10 fail at e85c57d and pass here. late-events-after-stop.test.ts's per-call drop lines pass unchanged. Whole suite: 8,803 tests, 8,741 pass, 0 fail, 62 skipped.
…(), and the review's fixes Answers the first review of a module's context having no effect once its module's stop() has completed. A module whose stop() has completed is no longer reached through its name: - An event being dispatched when its removal completes doesn't call its onProcess. The dispatch took its list of modules first and built each one's ProcessState by name when it reached it, so a removed module still ran: with a later namesake's live context if one had been added, or failing with "Module ... not found" if not. Each module is now checked against the registry before it's called. - A response of its that hasn't been applied when its removal completes is dropped whole, with one line naming what it asked for, and isn't listed in process:completed or the process log. Before, it was applied, and its state update went through setModuleState by name into a later namesake's state. A response that asks for nothing is dropped without a line. A push the module made while registered still runs: it's the module's message to the others, while a response is applied on its behalf after it's gone. - Speech being dispatched skips a handler entry that's no longer registered. The dispatch took its list of entries first and looked each module up by name, so a removed module's entry called a later namesake's onAgentSpeech. A registry's own retirement: - stopModule removes the module's entries and its stop attempt before retiring the context, and retire() ends each trace subscription inside a try, logging a throw, so a registry whose unsubscribe throws still removes the module rather than keeping its stop cached as failed. - The unsubscribe a context hands out calls the registry's only while its subscription is still recorded, so calling it again, or after retirement, does nothing. - A registry built without dropRetiredPush logs the same lines the framework does for a dropped push: a tool result's names the call, the tool where the event names one, and whether it succeeded. Those lines, droppedToolResultLine and retiredPushLine, now live in module-registry.ts. The mid-lifecycle line names the module that pushed, not the event's tool provider. The after-stop line's text is unchanged: its error text is now bounded by boundedErrorMessage, which gives what thrownText gave with its default. Text: ModuleContext's doc says "completed" for a stop that succeeded, lists every refused method, says findMessageByExternalId logs nothing and that reads answer and fail as before (one meeting a held write may record its landing, as any reader's does), says the framework calls a stopped module's onProcess and onAgentSpeech no more and drops its unapplied response, and says references the module already holds aren't the context's. The changelog fragment says the same, and names the registry's new optional dropRetiredPush option. The MCPL refusal helpers' doc sits above them again, the rationale comment in the drop line's helper is gone, and findMessageByExternalId's comment says a retired context keeps its mapping. The first commit's message called '' the context's existing "names no row" value: it's the value the external-id map keeps for a write with no row, and no path returned it before. Tests: four new tests, for the event dispatch, the response, speech dispatch, and a registry built without the option (its lines, a live unsubscribe called twice, and a throwing unsubscribe at removal); the refusal lines are asserted whole; the speech-handler test tells a retired registration apart from the namesake's; the budget test checks the timeout names the module; the completed-stop test checks the other module's context too. All 14 tests in the file fail at e85c57d and pass here.
…its gatherContext answer is left out Answers the second review of a module having no effect once its stop() has completed. What ends at a module's stop() is its registration, not the module object: - ModuleRegistry gains registrationOf(name), the current registration of the module of that name, which changes each time a module of that name is added, the same object included. The dispatch takes each module's registration when it starts, checks it before calling onProcess, and records it with the response; the response is dropped if its registration isn't current. Before, the checks compared module objects, so a module removed and then added again as the same object passed them: an event already being dispatched called it, and its old response was applied to its new registration. - applyProcessResponse had no await, and is now synchronous, so the apply phase doesn't yield after the one check made just before it: the check holds for every response it applies, and surface acceptance is counted from the responses applied. This replaces the recheck inside the apply loop and the separate list of applied responses; process:completed and the process log list the responses that passed the checks, as before this round. - A gatherContext answer from a module whose registration isn't current once every module's answer for the turn is in is left out of the turn, with one line if it held injections. Before, a removed module's answer still went into the turn. From the merge until the request is sent, the turn counts as already made. - A trace listener subscribed through a context is wrapped so it does nothing once the context is retired, so a registry whose unsubscribe throws can't leave a stopped module hearing traces. That throw's line now bounds its text with boundedErrorMessage. Text: the comment sentence added to the response-apply block in the last round is gone; ModuleContext's doc and the changelog fragment say a dropped response is logged only if it asked for anything, name the gatherContext rule, and say that a module added again is a new registration; ModuleRegistry.gatherContext's doc names the rule; the fragment says the drop line kept unchanged is the one after the framework's stop(), and names registrationOf. Tests: each dispatch and response test runs once with a new module of the removed one's name and once with the same module object added again; the dropped response asks for all six parts and its whole line is asserted, along with what process:completed lists; a push made while registered still reaches the others after the removal; the bare registry keeps its listeners and delivers to them after a removal whose unsubscribe throws; gatherContext's answer from a module removed and added again while it gathered is left out; gate entries are waited for with a bound. gather-context-timeout.test.ts's registry, built without the constructor, now holds a registration for each of its modules, as a constructed registry does. All 18 tests in retired-module-context.test.ts fail at e85c57d except the push made while registered, which pins behaviour this keeps.
…ext warns only for answers kept Answers the third review of a module having no effect once its stop() has completed. - The registry keeps a module's running onToolBatchComplete by module name, so that a hook still running past its bound isn't called again. That entry outlived the module's stop: a module added later under the same name had its hook skipped, as "still running from an earlier batch", until the stopped module's hook settled. The entry now goes with the module's other entries when its stop completes. - The line about a system injection's blocks that a system prompt can't carry, printed once per module and namespace, is now printed when the answers are merged, for the answers kept, rather than as each answer arrives, so an answer left out because its module stopped neither prints it nor uses up its once. - Text: ModuleRegistry.gatherContext's doc, ModuleContext's doc and the changelog fragment say the registry checks once, when it merges the answers, and that a stop completing after the merge leaves the turn's context as it was; the fragment's reason for keeping a push but dropping a response no longer reads as if responses were applied, and it names the tool-batch hook. Tests: the gatherContext test's removed module answers at once while another module holds the merge, so a check made as each answer arrives would keep it; a removed module that answered nothing is dropped with no line, and the dropped answer's non-text system block prints no line; a later registration's tool-batch hook runs while a stopped module's is wedged; the dropped response's edit and removal name real messages, which stay as they were; the dispatch test checks the later registration isn't called for the event either; the bare registry counts each unsubscribe and shows a running module's listeners hear traces. All 19 tests in retired-module-context.test.ts fail at e85c57d except the push made while registered, which pins behaviour this keeps.
… result a stopped module still delivers Answers a read of the whole change. - dispatchSpeech skipped any handler entry no longer in the handler list, which also skipped a running module that registered its handler again (a registerSpeechHandler without additive replaces its entry) or removed it while a dispatch was under way; before this change such a module was called. The dispatch now takes each entry's registration when it starts and skips an entry only if that registration has ended, as the process-event dispatch and gatherContext do. - A tool call a module was running when its stop() completed still delivers its result: it comes back through the framework's own push, not the module's context. That stays: the agent asked while the module was registered, and the result is the true answer to what ran, where an error in its place would tell the agent a tool that ran didn't, and dropping it would leave the call unanswered. ModuleContext's doc and the changelog fragment now say so, and why a response, which the framework applies for the module after it's gone, is dropped instead. The fragment's opening line names the exception. Test: a running module that registers its handler again, and one that removes it, while speech is being dispatched are each still called; it fails at the previous commit and passes here.
…e internal Both are how the framework wires its drop lines into the registry it builds and checks that a registration is still current; a host has no use for either, and a registry a host builds itself already drops a stopped module's pushes with its own line. They're marked @internal, as the framework's other preview members are, and the changelog fragment no longer names them.
… one handleToolCall returns The changelog fragment and ModuleContext's doc said a tool call a module was running when its stop() completed still delivers its result. That holds for the result its handleToolCall returns, which comes back through the framework's own push. A module can also answer a call by pushing a tool-result event through its context; after its stop completes that push is dropped like its others, with the line that names the call, the tool and whether it succeeded. Both texts now say which.
…framework runs The ModuleContext doc and the changelog fragment said that the result a module's handleToolCall returns, for a call it was running when its stop() completed, is still delivered, whether that stop came through removeModule() or the framework's stop(). Through the framework's stop() it isn't: stop() closes the event queue before it stops any module, so the result is dropped with the line any tool result arriving after stop() gets, as the existing stop-with-work-in-flight tests assert. Both texts now scope the kept result to removeModule() while the framework runs, and say what stop() does. Text only; no code changes.
…emoved again Removing a module, adding the same object again and removing it again stops and unregisters it the second time. The success branch of stopModule now returns early, so its own clearing of the settled attempt is what lets the same object be stopped again; this pins it.
The ModuleContext doc comment carried two reasons beside the behaviour it defines: why a tool result is still delivered after removeModule() while a response is dropped, and why references the module already holds stay as they are. The changelog fragment carries the first, and the second adds nothing a reader needs to know what happens. Both clauses are removed; every behaviour sentence stays as it was, including what the framework's stop() drops. The rest of the paragraph is rewrapped, with its words unchanged. Text only; no code changes.
…tool batch notifyToolBatchComplete takes each module in a synchronous loop and calls its onToolBatchComplete a microtask later. A removal whose stop() completed in between still had the stopped module's hook called, after its context was retired. The loop now keeps each module's registration and the deferred call skips the hook when the registration has changed by then, as speech dispatch already does; batchHooksRunning settles as before. The new test orders the removal's last step ahead of the hook's call with real calls: the module's stop() returns a promise the test holds, and the test's own reaction to it, registered after the registry's, starts the batch. With the check removed, the hook is called once. The ModuleContext doc, notifyToolBatchComplete's doc and the changelog fragment name onToolBatchComplete beside onProcess and onAgentSpeech.
The down-for-good tests watched the framework's stop() through a module's own trace subscription. A stopped module's subscriptions end when its stop() completes, and stop() stops modules and closes the legacy MCPL servers in parallel, so the legacy close's trace came after the module had stopped listening, and the test that a host's stop raises no alert failed on "legacy stop closed it". The requirement is what a host sees, so the tests collect traces through framework.onTrace with replay, as a host attaches its sinks after create(). The file's other no-alert-after-stop checks now see every trace to the end of stop(), and still hold.
tel-0s
added a commit
that referenced
this pull request
Oct 29, 2026
…'s context retired, MCPL inference aborted at stop, the re-armed deadline cut, failure accounting released with its agent main at 612f6fd is eight merges past 4a45766, 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 #871 (a re-armed turn's deadline cut follows the deadline-cut rule; an ended agent takes its re-armed deadline cut with it), #896 (the race order of the MCPL down alerts), #897 (an ended agent's failure accounting goes with it: forgetFailureAccounting), #893 (the [inference-failed] marker's pins), #870 (strict credential mode for opt-in WebSocket dials), #895 (stop() aborts each MCPL server's inference/request still running, and answers it -32000), #898 (a module's context is retired once its stop() completes: its pushes dropped with a line, its writes, calls and subscriptions refused, its unapplied response and unmerged gatherContext answer left out) and #884 (the suite's speed: setAbandonGraceForTests and CI). One path conflicted (3 regions): - src/module-registry.ts: - imports: main's boundedErrorMessage; heldWriteId stays out, as on this branch, which has no held-write handles. - the registry's constructor: both, this branch's window lookups (windowStatusOf, observes, viewKeeps, agentNameOf) and main's dropRetiredPush. - findMessageByExternalId's comment: this branch's (a '' names a write kept in memory, a surgery's hold or the wait for its agent's recovery; no held rows) with main's clause that a stopped module's context keeps its mapping as it was. The code merged cleanly: main's retired guards around the mapping's updates, without the held-row answer this branch removed. Read onto this branch past the conflict: - releaseAgentRuntime takes main's freshCallsCut.delete, forgetFailureAccounting and rearmDeadlineCuts.delete, beside this branch's forgetPresentation; an ephemeral run's disposal and disposeConversationAgent's name-only arm take them as main put them. - ModuleContext's doc (types/module.ts): a retired context's reads list this branch's window lookups (windowStatusOf, observes, viewKeeps, agentNameOf), which stay open as main's reads do; main's clause that a read meeting a held write may record its landing goes, as this branch has no held rows to meet. - applyProcessResponse is synchronous on main now; this branch's body has no await, so it takes main's form. storeSees: no new site; main opens and derives no context manager. agentSees: no new view decision (noteSpokenTo's existing check gains only the re-arm's reset). Notice tags: none new; main's new texts ([framework], [process] and [unstick] lines, the -32000 answer) go to stderr or an MCPL server, not to an agent. #898's retire and #884's suite changes touch no presentation point. Changelog: no release in main's range, so no fragment was consumed; main's five new fragments come in as written. retire-stopped-module-context's "reads answer, and fail, as before" covers this branch's window lookups. Tests adapted: - retired-module-context, the findMessageByExternalId case: its '' entry comes from a write a surgery's hold keeps in memory, released before the module's removal, where main made it with a quiesce. A write while quiesced is stored at once here (#291), so it mapped to its id. Tests: built with zero errors; no stale compiled test; fixtures copied. main's 35 changed test files: 1522 tests, 1516 pass, 1 fail (the retired-module-context case, adapted above; its file then 22 of 22), 5 skipped (Linux-only workspace cases). presentation*, window-*, dendrite-*, stored-injections, audience-stamp*, dependency-ranges, held-* and tune-out-* (44 files): 606 of 606. Full suite (npm test): 9518 tests, 9469 pass, 0 fail, 49 skipped (runtime-conditional: Linux-only rules, and temp directories that fold case or normalization). 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.
Once a module's
stop()completes, throughremoveModule()or the framework'sstop(), its context has no effect on the framework, and the framework stops calling it.At
4a45766f01,mainat 08:27 UTC on October 29, a module whosestop()had completed could still write state that a later module of its name then read, add a message, run another module's tool, go on receiving traces, and have its pushes processed and its response to an event in flight applied. With this change, each push through its context is dropped with a line,findMessageByExternalIdanswers without changing its stored mapping, and its context's other calls that would have an effect do nothing and log once per method. The framework doesn't call the module'sonProcess,onAgentSpeechoronToolBatchCompleteagain or apply a response of its that hadn't been applied, and it leaves the module'sgatherContextanswer out of a turn whose answers weren't merged yet. Of the 22 tests this adds, 19 fail with4a45766f01'ssrc. Of the change's 34 parts, 33 were each removed alone at8288e3b3c2, five commits before this head, and five that failed nothing there were removed again at7414aeecf6, three commits before this head: thestopping.deleteagainst the same two test files, one of which then held one more test, and four against further test files. The 34th, the tool batch's check, is this head's onlysrcchange since62ac4af8bdbesides doc comments, so a run with62ac4af8bd'ssrcstands for its removal. 32 fail a named test, and the two that fail none are named below.Problem
At
4a45766f01,Module.stop()'s doc says "Stop the module. Called when the framework stops or when the module is removed" (src/types/module.ts:128–129), andremoveModule()'s says "Remove a module at runtime" (src/framework.ts:7412). At4a45766f01, a module whosestop()has completed can still act through theModuleContextit was given, and through calls the framework had already started making to it. With this head's tests and4a45766f01'ssrc, 19 of the 22 tests intest/retired-module-context.test.tsfail. What each failing assertion shows4a45766f01doing:setStatewrote state that a later module of its name then read;addMessageanswered a message id;callToolcall of another module's tool succeeded;start()went on receiving traces;registerSpeechHandlerandunregisterSpeechHandler, a later module of its name wasn't called for speech;findMessageByExternalIdchanged the mapping stored in its state;pushEvent()and its queue handle, were processed by the module that remained.setStatestill wrote after the framework's completedstop(), after its ownstop()settled past the framework's stop budget, and after a retry of a failedstop()succeeded.onProcess, whether a new module of its name or the same object had been added since;gatherContexttook its answer, though itsstop()completed before the answers were merged;onToolBatchCompletewas still called for a tool batch whose hooks had begun when its removal completed;onToolBatchCompletewhile the stopped module's hook still ran past its bound.ModuleRegistrybuilt without the framework: the unsubscribe the context'sonTracereturned, called twice, called the registry's unsubscribe twice.Changes
All lines below are at this head unless a sentence names another commit.
A stopped module's context is retired. When a module's
stop()completes and it is still the module registered under its name,stopModuleremoves it and retires its context (src/module-registry.ts:436–445). A retired context:pushEvent()or its queue handle, and logs a line for each (:1038–1041). A tool result's line names the call, the tool where the event names one, and whether it succeeded (:43–48), as the line for a tool result pushed after the framework'sstop()already did. Once the framework'sstop()has closed its queue, such a push takes the path a module's push already takes then, with that path's line (src/framework.ts:7329–7332,:7313–7322);setState,registerLogState,appendToLog,editLogItem,editMessage,removeMessage,notifyOps,registerSpeechHandlerandunregisterSpeechHandler; answers''toaddMessageand adds nothing; resolvescallToolwith an error result and runs no tool; answersnulltogetModule; and subscribes nothing foronTrace. None of these throws. The first such call of each method logs one line naming the module and the method (src/module-registry.ts:801–811);:788–799). Its listeners hear nothing once it's retired (:899);findMessageByExternalIdwithout changing the mapping it stored (:988–991,:997–1000).Its reads answer, and fail, as before. The unsubscribe its
onTracereturns ends that subscription once, however often it's called (:902–904).The framework stops calling a stopped module. It notes each module's registration when a dispatch starts, and a module added again under its name, even the same object, is a new registration:
src/framework.ts:16892);:16924–16933,responsePartsat:3305);applyProcessResponseis now synchronous (:16985), and the loop that applies the responses calls it withoutawait(:16953–16957), after the registration check (:16934);src/module-registry.ts:695);gatherContextanswer from a module whose registration has ended before the answers are merged is left out, with a line if it held injections (:586–598);onToolBatchCompleteskips it when the module's registration has changed since the batch took it (:647,:651–654);stopModuleclears the stopped module's mark of a tool-batch hook still running (:442), so a module added later under its name has itsonToolBatchCompletecalled.What stays as it was:
stop();stop()fails stays registered, with a working context, for a retry;handleToolCallreturns after the framework'sstop()has completed is dropped with a line, as the tests intest/stop-with-work-in-flight.test.tsat:285and the four after it assert, and they pass at50ece003and in CI at this head. The module doc and the changelog fragment say so too;Two new
ModuleRegistrymembers and a constructor option, each marked@internal:registrationOf(name)(src/module-registry.ts:264); thereadonly dropRetiredPushFnfield (:142), set from the option (:186); and the constructor'sdropRetiredPushoption (:168), through which the framework gives the registry it builds its own drop (src/framework.ts:4378). AModuleRegistrya host builds itself, without the option, drops a stopped module's pushes with a line of its own for each.A test hears traces as a host does.
test/mcpl-down-for-good.test.ts's helper listens throughframework.onTrace(…, { replay: true })(:80), not through its watcher module'sctx.onTrace, a subscription that a retired context ends once the module's stop completes. The module stays, for the external messages it turns into turns.What each part is for
Each part but the tool batch's check was removed alone, in a fresh copy of
8288e3b3c2, five commits before this head, and run against this change's two test files,retired-module-contextandgather-context-timeout, as they were there.8288e3b3c2'ssrcdiffers from this head's in doc comments and in the tool batch's check, and itsretired-module-contextlacks the tests this head adds for a module object removed twice and for a tool batch.gather-context-timeoutpassed 12 of 12 under every removal. A removal that failed neither file also ran against #881's 15 test files, and passed 847 of 847 there each time. Five parts that failed nothing there were removed again at7414aeecf6, three commits before this head, whose files differ from this head's in doc comments, the changelog fragment, the tool batch's check and its test: thestopping.deleteagainst this change's two files, and four againsttest/late-events-after-stop.test.ts. The two of those four that still failed nothing then ran againsttest/process-response-edit-refusals.test.tsandtest/framework.test.tsas well, and passed 38 of 38.setState's guard fails the state-and-log test and the three stop tests;registerLogState,appendToLogandeditLogItem, and the once-per-method logging, each fail the state-and-log test;getModule's fails thecallToolandgetModuletest, andcallTool's fails that test and the framework's completedstop();onTrace's andnotifyOps' each fail the trace-subscriptions test;addMessage's,editMessage's andremoveMessage's each fail theaddMessagetest;registerSpeechHandler's andunregisterSpeechHandler's each fail the speech-handler-calls test.retire()'s call instopModule, which is what retires a context at all. Removed alone, it fails 11 tests: the seven tests of the context's methods, the three stop tests and the registry-without-the-framework test.retire()ending the trace subscriptions, and the listener's check that its context isn't retired, so that a stopped module's listeners hear nothing, even one whose unsubscribe throws. Each fails the registry-without-the-framework test.findMessageByExternalId's two guards, so that a stopped module's lookups leave its stored mapping as it was. Each fails its test.pushEvent()'s drop, one for each route a push takes. Each fails the pushes test and the registry-without-the-framework test.dropRetiredPush. It fails the registry-without-the-framework test.gatherContextmerge check, and its line only when the answer held injections. Each fails thegatherContexttest.onProcess. It fails both event-dispatch tests.dropUnregistered. It fails both response tests.batchHooksRunning.deleteinstopModule. It fails theonToolBatchCompletetest.src/module-registry.ts:647,:651–654), so that a module whose stop completes after a batch's hooks have begun isn't called for that batch. With62ac4af8bd'ssrc, which lacks only it and doc comments,retired-module-contextat this head passes 21 of 22, and the one that fails is the test of a tool batch.dropRetiredModuleEvent(src/framework.ts:7329–7332), which sends a stopped module's push, once the framework'sstop()has closed its queue, down the path a module's push takes then, with that path's line. At8288e3b3c2it failed no test in the files run there. Removed alone at7414aeecf6, it fails two tests intest/late-events-after-stop.test.ts: "a module's tool result after stop() began keeps the drop line that names the call, the tool and its outcome" and "a module's event after stop() finished is dropped with a log line, not thrown into its callback".dropRetiredPush(src/framework.ts:4378), whose drop differs from the registry's own (src/module-registry.ts:1038–1041) only by that closed-queue branch. Removed alone at7414aeecf6, it fails the same two tests.stopping.deleteinstopModule's success branch (src/module-registry.ts:443), since that branch now ends inreturn(:445), before thestopping.deleteafter theif(:447). At8288e3b3c2it failed no test. Removed alone at7414aeecf6, it fails the test of a module object removed, added again and removed again, whose module is stopped once where the test expects twice.applyProcessResponsemade synchronous, when madeasyncagain and awaited;src/framework.ts:16927). Every dropped response in this change's tests asks for all six things a response can.No removal at
8288e3b3c2failed either of its tests of what4a45766f01already does: the push made while registered, and a running module changing its speech handler while speech is being dispatched.With #844
@tel-0s,
git merge-treeof #844's head,dda847ba26, with this head conflicts insrc/module-registry.ts, which it doesn't with4a45766f01. Its other conflicting files are the same either way:src/framework.tsand five test files. The three hunks insrc/module-registry.ts:heldWriteId's import, and this change addsboundedErrorMessage's on the line after it;windowStatusOfFn,observesFn,viewKeepsFnandagentNameOfFn, and this change addsdropRetiredPushFn, at the same place;findMessageByExternalId: both rewrite it. The code below it merges without conflict.Tests
Apart from CI's, the runs below used Node 24.22.1 on Linux. Each but the first printed chronicle 0.5.0, context-manager 0.24.1, membrane 0.8.0, mcpl-core 0.4.0 and TypeScript 7.0.2. Each copy's first run installed with no lockfile, as CI installs it, and later runs in a copy started from what that first install left there. The runs at
7414aeecf6, the removals at8288e3b3c2, and the last two of the three earlier red runs below also printeduname -srasLinux 6.12.113+deb13-amd64. Apart from the run at this head, red first and the run with62ac4af8bd'ssrc, each ran in a clean copy of the commit it names.43a8b77b, which changes onlytest/mcpl-down-for-good.test.ts: that file passes 20 of 20, twice. With the file as it is at50ece003, 19 of 20 pass, and the one that fails is "a connected server the host stops or restarts raises no alert, in either engine". With this head's file and4a45766f01'ssrc, 20 of 20 pass, so the new listener doesn't depend on this change. This run printed no package versions, and its copy held three untracked scripts beside this head's files.50ece003, the commit before this head: the build succeeds, andretired-module-context,gather-context-timeout, two of the files fix(framework)!: a stop ends its AgentFramework instance, and start() and addModule() are refused from then on (#315) #881 changed,stop-with-work-in-flightandstop-ends-its-instance, andlate-events-after-stoppass, 84 of 84.7414aeecf6, three commits before this head: the build succeeds, and the first four of those files pass, 78 of 78.8288e3b3c2, fix(framework)!: a stop ends its AgentFramework instance, and start() and addModule() are refused from then on (#315) #881's 15 test files pass, 847 of 847:compression-after-release,event-gate-sleep,exhausted-turn-rearm,maintenance-timer,present-while-acting,reinit-after-branch-move,route-failure-targeting,shutdown-errors,start-during-stop,stop-ends-its-instance,stop-with-work-in-flight,stop-without-stream,tool-result-blocks,tool-result-guardandturn-continuation.4a45766f01'ssrcand this head's two test files, 19 ofretired-module-context's 22 tests fail, as listed under Problem. Three earlier runs failed the same 18 of the tests they had: two with8288e3b3c2's two test files, and one with7414aeecf6's. The three that pass describe what4a45766f01already does: "a push a module made while it was registered still reaches the other modules after its removal completes", "a running module that registers or removes its speech handler while speech is being dispatched is still called for it", and "a module object removed, added again and removed again is stopped and removed the second time".gather-context-timeout's 12 pass.8288e3b3c2.main:84e0408977(run 40919585168), every check passed. On Ubuntu with Node 22.12.0, 24.22.1 and 26.13.0, the suite ran 9,257 tests, none failing, with 63, 63 and 65 skipped. On macOS with Node 22.12.0 it ran 9,259, none failing, with 48 skipped.50ece003, merged into4a45766f01(run 40911073787), each of the four Build & Test legs failed one test, "a connected server the host stops or restarts raises no alert, in either engine", which this head's commit changes.Not verified
8288e3b3c2. Of those, thestopping.deleteran again at7414aeecf6against this change's two files, and four, the two that still fail none among them, againsttest/late-events-after-stop.test.ts. Those two also ran at7414aeecf6againsttest/process-response-edit-refusals.test.tsandtest/framework.test.ts. The run with62ac4af8bd'ssrc, standing for the tool batch's check removed, ran againstretired-module-contextalone. A removal may fail another file in the suite.removeModule()completes its stop. No test runs one.ModuleContext's doc at this head says that afterremoveModule(), the resulthandleToolCallreturns for a call it was running when itsstop()completed "is still delivered while the framework runs" (src/types/module.ts:286–288). When the framework'sstop()is the route, the tests intest/stop-with-work-in-flight.test.tsshow such a result dropped with a line, under "What stays as it was".gatherContextmerge and that turn's request. This change'sgatherContexttest completes the module's stop before the answers are merged. Nothing here shows whether a request sent after such a removal carries the module's answer.ModuleRegistrywas run.changelog.d/retire-stopped-module-context.fixed.md.Gideon-2613 made the change, and Ada-1017 made its design calls. Cordelia-2434 read every commit before the merge of
main, Stanley-2495 read the merge and the two commits after it, and Alfred-2451 read the last three commits. Our internal review read the change three times: as first written, then each of the next two rounds' changes. Fiona-2757 wrote this description.