Skip to content

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 into
anima-research:mainfrom
nissa-seru:gideon2613/retire-stopped-module-context
Oct 29, 2026
Merged

nissa-seru merged 13 commits into
anima-research:mainfrom
nissa-seru:gideon2613/retire-stopped-module-context

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Once a module's stop() completes, through removeModule() or the framework's stop(), its context has no effect on the framework, and the framework stops calling it.

At 4a45766f01, main at 08:27 UTC on October 29, a module whose stop() 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, findMessageByExternalId answers 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's onProcess, onAgentSpeech or onToolBatchComplete again or apply a response of its that hadn't been applied, and it leaves the module's gatherContext answer out of a turn whose answers weren't merged yet. Of the 22 tests this adds, 19 fail with 4a45766f01's src. Of the change's 34 parts, 33 were each removed alone at 8288e3b3c2, five commits before this head, and five that failed nothing there were removed again at 7414aeecf6, three commits before this head: the stopping.delete against 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 only src change since 62ac4af8bd besides doc comments, so a run with 62ac4af8bd's src stands 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), and removeModule()'s says "Remove a module at runtime" (src/framework.ts:7412). At 4a45766f01, a module whose stop() has completed can still act through the ModuleContext it was given, and through calls the framework had already started making to it. With this head's tests and 4a45766f01's src, 19 of the 22 tests in test/retired-module-context.test.ts fail. What each failing assertion shows 4a45766f01 doing:

  • Through its context, after its removal completed:
    • its setState wrote state that a later module of its name then read;
    • its addMessage answered a message id;
    • its callTool call of another module's tool succeeded;
    • the trace subscription it made in start() went on receiving traces;
    • after its calls of registerSpeechHandler and unregisterSpeechHandler, a later module of its name wasn't called for speech;
    • its findMessageByExternalId changed the mapping stored in its state;
    • its pushes, through pushEvent() and its queue handle, were processed by the module that remained.
  • After other stops: its setState still wrote after the framework's completed stop(), after its own stop() settled past the framework's stop budget, and after a retry of a failed stop() succeeded.
  • Through the framework's calls:
    • an event being dispatched when its removal completed still called its onProcess, whether a new module of its name or the same object had been added since;
    • its response to that event was applied, adding a message and editing another;
    • speech being dispatched when its removal completed reached a module of its name added since, which had registered no handler;
    • a turn's gatherContext took its answer, though its stop() completed before the answers were merged;
    • its onToolBatchComplete was still called for a tool batch whose hooks had begun when its removal completed;
    • a module added under its name wasn't called for onToolBatchComplete while the stopped module's hook still ran past its bound.
  • In a ModuleRegistry built without the framework: the unsubscribe the context's onTrace returned, 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, stopModule removes it and retires its context (src/module-registry.ts:436–445). A retired context:

    • drops each push, through 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's stop() already did. Once the framework's stop() 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);
    • does nothing for setState, registerLogState, appendToLog, editLogItem, editMessage, removeMessage, notifyOps, registerSpeechHandler and unregisterSpeechHandler; answers '' to addMessage and adds nothing; resolves callTool with an error result and runs no tool; answers null to getModule; and subscribes nothing for onTrace. 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);
    • ends the trace subscriptions made through it, and logs a line if an unsubscribe throws (:788–799). Its listeners hear nothing once it's retired (:899);
    • answers findMessageByExternalId without changing the mapping it stored (:988–991, :997–1000).

    Its reads answer, and fail, as before. The unsubscribe its onTrace returns 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:

    • an event's dispatch skips a module whose registration has ended since the dispatch started (src/framework.ts:16892);
    • a response whose module's registration has ended before it's applied is dropped whole, with a line naming what it asked for if it asked for anything (:16924–16933, responseParts at :3305);
    • applyProcessResponse is now synchronous (:16985), and the loop that applies the responses calls it without await (:16953–16957), after the registration check (:16934);
    • speech being dispatched skips a handler whose module's registration has ended since the dispatch started (src/module-registry.ts:695);
    • a gatherContext answer from a module whose registration has ended before the answers are merged is left out, with a line if it held injections (:586–598);
    • a tool batch's deferred call of a module's onToolBatchComplete skips it when the module's registration has changed since the batch took it (:647, :651–654);
    • stopModule clears the stopped module's mark of a tool-batch hook still running (:442), so a module added later under its name has its onToolBatchComplete called.
  • What stays as it was:

    • a module's writes from inside its own stop();
    • a module whose stop() fails stays registered, with a working context, for a retry;
    • a push a module made while it was registered still reaches the other modules after its stop completes, since a push is its message to the others, made while it was there;
    • a tool result a module's handleToolCall returns after the framework's stop() has completed is dropped with a line, as the tests in test/stop-with-work-in-flight.test.ts at :285 and the four after it assert, and they pass at 50ece003 and in CI at this head. The module doc and the changelog fragment say so too;
    • references the module already holds, to other modules or to its own timers, which aren't the context's.
  • Two new ModuleRegistry members and a constructor option, each marked @internal: registrationOf(name) (src/module-registry.ts:264); the readonly dropRetiredPushFn field (:142), set from the option (:186); and the constructor's dropRetiredPush option (:168), through which the framework gives the registry it builds its own drop (src/framework.ts:4378). A ModuleRegistry a 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 through framework.onTrace(…, { replay: true }) (:80), not through its watcher module's ctx.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-context and gather-context-timeout, as they were there. 8288e3b3c2's src differs from this head's in doc comments and in the tool batch's check, and its retired-module-context lacks the tests this head adds for a module object removed twice and for a tool batch. gather-context-timeout passed 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 at 7414aeecf6, 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: the stopping.delete against this change's two files, and four against test/late-events-after-stop.test.ts. The two of those four that still failed nothing then ran against test/process-response-edit-refusals.test.ts and test/framework.test.ts as well, and passed 38 of 38.

  • Each refusing method, so that a stopped module can't act through its context. Removed alone:
    • setState's guard fails the state-and-log test and the three stop tests;
    • the guards of registerLogState, appendToLog and editLogItem, and the once-per-method logging, each fail the state-and-log test;
    • getModule's fails the callTool and getModule test, and callTool's fails that test and the framework's completed stop();
    • onTrace's and notifyOps' each fail the trace-subscriptions test;
    • addMessage's, editMessage's and removeMessage's each fail the addMessage test;
    • registerSpeechHandler's and unregisterSpeechHandler's each fail the speech-handler-calls test.
  • retire()'s call in stopModule, 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.
  • The queue handle's drop and pushEvent()'s drop, one for each route a push takes. Each fails the pushes test and the registry-without-the-framework test.
  • The registry's own line, for a registry built without dropRetiredPush. It fails the registry-without-the-framework test.
  • The gatherContext merge check, and its line only when the answer held injections. Each fails the gatherContext test.
  • The speech dispatch's registration check. It fails the test of speech reaching a module of the name added since.
  • The skip before onProcess. It fails both event-dispatch tests.
  • dropUnregistered. It fails both response tests.
  • batchHooksRunning.delete in stopModule. It fails the onToolBatchComplete test.
  • The tool batch's registration check (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. With 62ac4af8bd's src, which lacks only it and doc comments, retired-module-context at this head passes 21 of 22, and the one that fails is the test of a tool batch.
  • The closed-queue branch in dropRetiredModuleEvent (src/framework.ts:7329–7332), which sends a stopped module's push, once the framework's stop() has closed its queue, down the path a module's push takes then, with that path's line. At 8288e3b3c2 it failed no test in the files run there. Removed alone at 7414aeecf6, it fails two tests in test/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".
  • The framework passing 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 at 7414aeecf6, it fails the same two tests.
  • The stopping.delete in stopModule's success branch (src/module-registry.ts:443), since that branch now ends in return (:445), before the stopping.delete after the if (:447). At 8288e3b3c2 it failed no test. Removed alone at 7414aeecf6, it fails the test of a module object removed, added again and removed again, whose module is stopped once where the test expects twice.
  • Two parts fail no test in the files they ran against:
    • applyProcessResponse made synchronous, when made async again and awaited;
    • the dropped response's line only when it asked for something (src/framework.ts:16927). Every dropped response in this change's tests asks for all six things a response can.

No removal at 8288e3b3c2 failed either of its tests of what 4a45766f01 already 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-tree of #844's head, dda847ba26, with this head conflicts in src/module-registry.ts, which it doesn't with 4a45766f01. Its other conflicting files are the same either way: src/framework.ts and five test files. The three hunks in src/module-registry.ts:

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 at 8288e3b3c2, and the last two of the three earlier red runs below also printed uname -sr as Linux 6.12.113+deb13-amd64. Apart from the run at this head, red first and the run with 62ac4af8bd's src, each ran in a clean copy of the commit it names.

  • At this head, 43a8b77b, which changes only test/mcpl-down-for-good.test.ts: that file passes 20 of 20, twice. With the file as it is at 50ece003, 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 and 4a45766f01's src, 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.
  • At 50ece003, the commit before this head: the build succeeds, and retired-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-flight and stop-ends-its-instance, and late-events-after-stop pass, 84 of 84.
  • At 7414aeecf6, three commits before this head: the build succeeds, and the first four of those files pass, 78 of 78.
  • At 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-guard and turn-continuation.
  • Red first: with 4a45766f01's src and this head's two test files, 19 of retired-module-context's 22 tests fail, as listed under Problem. Three earlier runs failed the same 18 of the tests they had: two with 8288e3b3c2's two test files, and one with 7414aeecf6's. The three that pass describe what 4a45766f01 already 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.
  • Each part removed alone: as listed under "What each part is for". Before the removals, the two files passed 32 of 32 at 8288e3b3c2.
  • CI, which runs the whole suite on GitHub's merge of the pull request into main:
    • At this head, merged into 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.
    • At 50ece003, merged into 4a45766f01 (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

  • Removals beyond the files run. Each removal ran against this change's two files, and those that failed neither, against fix(framework)!: a stop ends its AgentFramework instance, and start() and addModule() are refused from then on (#315) #881's 15, at 8288e3b3c2. Of those, the stopping.delete ran again at 7414aeecf6 against this change's two files, and four, the two that still fail none among them, against test/late-events-after-stop.test.ts. Those two also ran at 7414aeecf6 against test/process-response-edit-refusals.test.ts and test/framework.test.ts. The run with 62ac4af8bd's src, standing for the tool batch's check removed, ran against retired-module-context alone. A removal may fail another file in the suite.
  • A tool call a module is running when removeModule() completes its stop. No test runs one. ModuleContext's doc at this head says that after removeModule(), the result handleToolCall returns for a call it was running when its stop() completed "is still delivered while the framework runs" (src/types/module.ts:286–288). When the framework's stop() is the route, the tests in test/stop-with-work-in-flight.test.ts show such a result dropped with a line, under "What stays as it was".
  • A removal that completes between a turn's gatherContext merge and that turn's request. This change's gatherContext test 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.
  • Hosts. No run used a host, and no host's own ModuleRegistry was run.
  • Other platforms. Apart from CI's macOS leg, every run here was on Linux.
  • A module's own references. A reference a stopped module already holds, to another module or to its own timers, isn't the context's, and this change leaves it as it was. No test covers what a module does through one.

  • Changelog fragment added: 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.

Gideon-2613 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.
@nissa-seru
nissa-seru merged commit 9b54c58 into anima-research:main Oct 29, 2026
12 checks passed
@nissa-seru
nissa-seru deleted the gideon2613/retire-stopped-module-context branch October 29, 2026 18:33
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>
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