Repository navigation
fix(diagnostics): one SIGUSR2 listener per process, dumping each running framework - #433
Merged
nissa-seru merged 6 commits intoOct 17, 2026
Conversation
added 6 commits
October 17, 2026 01:04
…esearch#389) create() opened the Chronicle store first, and went on to start modules, the liveness watchdog and the event gate and to connect MCPL servers. Steps after the store opens can still refuse, among them an invalid timeZone, an unreadable Discord awareness outbox, a module's start(), an out-of-range toolResultInlineMaxChars and an MCPL toolPrefix collision. Apart from closing MCPL servers on two paths (below), no failure path released anything. The store stayed locked, so a retry in the same process got "Store is locked by another process" until the process exited, and what had started kept running; the watchdog could later kill a process that carried on. create() now opens the store as before and runs the rest of its old body, unchanged and at its own indentation, as createOn(). The framework is named in `building` as soon as it is constructed. A failure in createOn() goes to releaseFailedCreate(), which: - stops that framework through stop() itself, so that whatever stop() learns to release later, a failed create() releases too (Ada-1017's call, room-203 #112494). stop() is already safe on a framework that never started; - gives that stop() a deadline, FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30 s per wait. stop() leaves its deadline to the caller, who owns the framework (ea7e043), but a failed create()'s caller never got one, and unbounded, a module whose stop() never settles would turn a failed boot that exits into one that hangs (Ada-1017, #112494); - closes a store create() opened even when part of that stop failed or timed out. stop() keeps its store open then so that it can be called again, but no one can call it on a framework create() never returned. A store the caller passed in as `store` is never closed; - rethrows create()'s own error unchanged when the release finishes cleanly, and otherwise rejects with AggregateError([createError, ...releaseErrors]), "AgentFramework.create() failed: <create's message>. Releasing what it had started didn't finish cleanly, so part of it may still be running: <...>". Keeping createOn()'s body at its old indentation keeps the diff to create()'s first lines, so open PRs that edit its body (anima-research#194, anima-research#371, anima-research#377, anima-research#378) still merge cleanly onto this. Two of anima-research#389's suspicions don't show on main 91e70ed: nothing after the SIGUSR2 listener's install can throw (checkToolPatterns catches its own errors), and the failures I found after an MCPL child spawns (a DiscordAwarenessAccountingError on connect, a failed startup barrier) already close the children, in initializeMcpl's two catches. Should a later step fail, stop() closes them. stop() itself never removes that listener; that is F47. test/failed-create-releases.test.ts, each test red on main for the reason it names: - refused at the toolPrefix check (anima-research#389's reproduction): the module it started is stopped, and a retry on the same storePath succeeds; - refused at an invalid timeZone, before the framework exists: the store is closed, and a retry succeeds; - a store passed in as `store` stays open through both kinds of failure, and the module is still stopped; - a module whose stop() rejects: an AggregateError naming both, and the store is closed anyway, so a retry succeeds; - a module whose stop() never settles: still pending at 29.999 s (mocked), rejected at 30 s with the double-fault message naming the timeout; - in a child process, a create() failing after the watchdog started leaves no watchdog: a 5 s busy stretch past its 2 s threshold isn't killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se (anima-research#389) From the review af389-failed-create (wf-1931, 20 issues, judged at 5a81605b): - initializeMcpl's two failure catches no longer close the MCPL servers themselves (I2, I19). They ran inside createOn(), before the release, with no deadline, and a closeAll() that rejected there replaced create()'s own error. create()'s release now closes every server through stop(), within FAILED_CREATE_TEARDOWN_TIMEOUT_MS, with create()'s error first. On connectMcplServer's lazy path the registry is new and empty when the barrier runs, so nothing there changes. - The rejection's message says what didn't finish (I14): a stop that failed or ran out of time says part of what create() started may still be running; a close that failed says the store may still be locked. - The docs, the changelog and the constant's comment name the waits the 30 s covers, stop()'s waits on operator operations and on modules and MCPL servers, instead of "each part of the release" (I1, I12), and say exactly when stop() keeps an owned store open (I15). Tests, in test/failed-create-releases.test.ts: - a clean release rejects with create()'s own error, unwrapped and with its exact message (I7); - a module whose start() throws: every module started is stopped, that one included, and a retry works (I5); - a create() that fails at the MCPL startup barrier after its server connected: create()'s own error, the child process gone, a retry works (I3; the awareness-barrier fixture with an injected reconcile failure); - a store whose close() fails during a release whose stop() also failed: the AggregateError carries all three, create()'s first (I11); - the watchdog test checks the watchdog's "armed" line and the refusal's message in the child's output, written to a file (I4). Not fixed here, as judged: stop()'s synchronous steps before its watchdog stop (I6) and its not waiting for Discord awareness drains (I10) are stop()'s on every path, for a change of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s release (anima-research#389) The second internal review of the release (af389-failed-create-r1) found: - A stop that fails only after everything it waits on has stopped, in a guard's storage work, its final sync or closing the store, was reported as "Stopping what it had started didn't finish, so part of it may still be running", though everything it waits on had stopped then, as stop() counts it. stop() now records whether its teardown finished (stopFinishedTeardown), and the release then says "What it had started stopped, but finishing the stop failed". A new test has stop()'s own store close fail after a clean teardown. - create()'s doc and the changelog said only the modules' and MCPL servers' stops get 30 s. stop() gives the deadline to each of its two cleanup waits, its wait for an operator's operations already running first, so both now name both waits. - The MCPL test judged the server's exit by a process-wide count of child handles, for about 2 s. It now waits on the server's own process, by the pid the fixture records with its handshake, for up to 10 s. - A comment in connectMcplServerInternal still said an awareness accounting error goes to initializeMcpl, which tears down the other servers and aborts create(). That branch runs only for a runtime connect or restart, and since the first round initializeMcpl tears nothing down. It now says where the error goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey were (anima-research#389) Greptile's review of anima-research#426 found that the release this PR adds reaches a module's stop() at a point no stop() reached before: after the module's start(), but before the host has the framework, so before anything the host wires up after create() returns. WorkspaceModule is wired that way. On a restart its start() decodes the saved state and holds it until initStore() makes the mounts, and connectome-host calls initStore() only after create() returns. Its stop() saved its mounts whenever it had a context, so a create() refused after the workspace module started (a later module's start(), a toolPrefix collision) saved { mounts: {} } over modules/workspace/state. The retry then restored no pin, and materialize no longer refused a branch that had diverged from the one last materialized to disk: anima-research#72's consequence, reached by a new path. WorkspaceModule.stop() now saves only once initStore() has run and the state start() was handed has been applied (savedState is cleared then). Before that the module has made no pass or write, so it has nothing of its own to save, and the stored state stays as it was. The second term also covers an initStore() that throws after taking the store and making the mounts, before it applies the saved state (at the disk agreement's open(), say), which would otherwise leave a later stop() saving default pins over the saved ones. Module.stop()'s doc now says it is also called when a create() that started the module fails, before any of the host's wiring. The other modules found saving state in stop() (Discord's here, and connectome-host's subscription GC) restore that state at the top of start(), so they save back what they were handed. Two tests: in failed-create-releases, a session that materializes, then a restart refused after the workspace module started, after which the stored state is as the session left it; in workspace-restore-order, a restart stopped before initStore() and one stopped after an initStore() that threw at the disk agreement's open(), neither of which saves. Each fails with the guard taken out, and the second fails with only its savedState term taken out.
…ing framework create() added a SIGUSR2 listener per framework, and nothing removed it. A process that made one framework after another, as connectome-host does on each session switch, kept every stopped framework reachable through its listener and printed a stale [diagnostics] dump of each on every signal. On main 91e70ed, three create()/stop() pairs leave 1, 2 and 3 listeners. stop() can't simply remove its framework's listener: on Node 24.22 and Bun 1.4.3 alike, a process whose last SIGUSR2 listener is removed is ended by the next SIGUSR2 (add a listener, remove it, then process.kill(process.pid, 'SIGUSR2'): the process exits by the signal). connectome-doctor sends one to the host for its live gate dump, so a doctor run between the old framework's stop() and the new one's create() on a session switch would have killed the host. So, as Ada-1017 chose (room-203 #112802, option (b) of #112774), one listener serves the process. The first create() to reach the join below installs it, and it stays for the process's life. A framework joins the set it dumps where create() starts its liveness watchdog, or would if it isn't enabled, once its agents, modules and event gate are built. It leaves where stop() stops the watchdog: after stop()'s waits on streams, maintenance, the event loop and operator operations, so a dump still shows what those waits are on, and before its modules and MCPL servers stop. A create() that fails after joining leaves through stop(), in its release (anima-research#389). With no framework in the set, the dump prints one line saying no framework is running, where a signal used to get a stale dump of every framework the process had made. A process that no create() has brought as far as the join has no listener, as on main. A framework's own line is unchanged. Not covered: a framework started again after stop() doesn't rejoin. Whether start() after stop() is supported is anima-research#315's question. test/diagnostics-signal.test.ts: - three frameworks created and stopped add at most one listener (main: 3); - a dump prints each framework while it runs, then the "no framework is running" line, and a create() refused after it joined doesn't stay (main: every stopped framework's stale dump); - control, passing on main: a SIGUSR2 after every framework has stopped leaves the process running, in a child process. It guards against the removal that would end it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…istener The review (af-f47-diagnostics-signal, wf-1944) found the text saying a framework is dumped "from create() until stop()" and joins "where create() starts its watchdog", and no test pinning where it leaves. The code is unchanged: - The doc on joinDiagnostics, the field comment and the changelog now say where a framework joins: partway through create(), once its agents, modules and event gate are built, where the watchdog starts or would, since the watchdog is opt-in and the join isn't. And where it leaves: after stop()'s waits on streams, maintenance, the event loop and operator operations, and before its modules and MCPL servers stop, so a stop() whose teardown hangs, fails or times out doesn't keep it listed. They also say that a process no create() has brought that far has no listener, and that a framework started again after a completed stop() doesn't rejoin (anima-research#315). - A new test dumps while stop() waits on a held maintenance pass, which lists the framework, and again while a module's stop() is held, which no longer does. It looks only for its own framework's line, so a framework another test left running doesn't decide it. Moving the leave to the start of stop() or to its end fails it, and so does taking it out. - The dump test checks the keys of each framework's line, since connectome-doctor reads gate and activeStreams from it. A line that isn't JSON, such as the one saying none is running, shows as its text in a failed assertion rather than as a JSON.parse error. - The listener test asserts that a listener is installed, not only that at most one was added. - The control says when its child didn't finish within 20 s, rather than showing the SIGTERM its safety timer sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] This PR appears safe to merge within its stated lifecycle scope. SummaryThis PR shares one
Reviews (2) · Last reviewed commit: "fix(diagnostics): the internal review's ..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
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.
One
SIGUSR2listener now serves the process. A dump prints each framework while it runs, and one line saying none is running when there's none, instead of a stale dump of every framework the process has made. The shared handler reads a set joined duringcreate();stop()removes its framework from that set at the watchdog teardown point.Stacked on #426, which fixes #389 (a failed
create()releases what it acquired): review that first. This one's own change is its last two commits.We'll merge this not before 18:00 UTC on October 17, and not before #426, which it's stacked on, once our pre-merge checks pass on its merge with the then-current
main, unless you ask for changes before then.Problem
create()adds aSIGUSR2listener for its framework's[diagnostics]dump, and nothing removes it. A process that makes one framework after another, as connectome-host does on each session switch, keeps every stopped framework reachable through its listener, and every signal prints a stale dump of each. Onmain, threecreate()/stop()pairs leave 1, 2 and 3 listeners (a probe at91e70eda; the listener's code is the same at24826254).stop()can't simply remove its framework's listener. On Node 24.22 and Bun 1.4.3 alike, a process whose lastSIGUSR2listener has been removed is ended by the nextSIGUSR2. connectome-host'sconnectome-doctorsends one to the host for its live gate dump, so a doctor run between the old framework'sstop()and the new one'screate()on a session switch would end the host.Changes
create()to reach the point below installs it, and it stays for the process's life. Until then the process has no listener, as onmain, where it's installed later increate().create(), once its agents, modules and event gate are built: wherecreate()starts the liveness watchdog, or would if it isn't enabled. It leaves wherestop()stops the watchdog:stop()has waited on its streams, maintenance, event loop and operator operations, so a dump still shows what those waits are on;stop()whose teardown hangs, fails or times out doesn't keep it listed;create()that fails after joining leaves throughstop(), in the release fix(create): a failed create() releases what it had acquired #426 adds.[diagnostics] no framework is running in this process. A framework's own line is unchanged.What each part is for, and what was left out
The change makes a shared diagnostics set, rather than an accumulating list of per-framework callbacks, determine what a dump prints. Frameworks leave that set at the watchdog step of
stop(), before module and MCPL teardown. Onmain, callbacks accumulated across create/stop cycles and continued to print stale dumps.diagnosticsListenerInstalledflag andjoinDiagnostics()). The obvious fix is forstop()to remove whatcreate()installed, but removing the lastSIGUSR2listener restored the signal's terminating action in our Linux probes on Node and Bun.connectome-doctorsends one for its dump, so removing the listener between one session's framework and the next's would let it kill the host.diagnosed, joined increate()and left instop()beside the watchdog) supplies the instances the listener prints. Leaving at the end ofstop()would keep a framework listed while its module teardown remained unsettled. The held-module test and its removal check pin the earlier departure point.1d4dfd8, an empty log produced its "framework predates the diagnostics patch?" suggestion; the explicit empty-set line produced "no framework is running in this process" instead.printDiagnostics()moves the old listener's body into a method and preserves the fields in each framework's dump.Left out:
Tests
test/diagnostics-signal.test.ts:main, which adds three);create()refused after it joined doesn't stay in it (fails onmain, which prints every stopped framework's dump);stop()waits on a maintenance pass still lists the framework, and one while its module'sstop()is held no longer does (fails onmain, and fails if the leave moves to the start or to the end ofstop());maintoo: in a child process, aSIGUSR2after every framework has stopped leaves the process running. It guards against the removal that would end it.Checks
At this head, on #426 and
main24826254, on Linux with npm 11.19.1:npm install,tsc --noEmit,npm run build,node --test --test-force-exit dist/test/*.test.js): the typecheck and build are clean.main's 2411 and this stack's 12 and 4 new ones. 2421 of 2422 pass in the run, and the other 5 are in a file that--test-force-exitcut short there (channel-registry-routing), which passes in full, 24, when it runs alone without it. The run's one failure,code-execution's "abort() settles a running script and reclaims the interpreter", passes 50 of 50 alone, three times out of three. That test gives a Python script a fixed 500 ms to start before it aborts it, and it has failed that way under load before, on other heads.stop-with-work-in-flight,stop-with-host-operationsandconversation-routing): 105 of 105.stop()(1), and the leave at its end (2).Not covered
stop()doesn't rejoin the dump, so while it runs a dump leaves it out, and says no framework is running if it's the only one. Its watchdog stays stopped too. Whether that lifecycle is supported at all is start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315's question.stop()that throws before it stops the watchdog skips the leave, as it skips the watchdog, module and MCPL cleanup after it. Makingstop()reach each of its cleanups when an earlier step throws is a separate fix, and the leave belongs with them.Not verified
The tests check diagnostics membership and output, not garbage collection of framework objects.
Bun. The suite runs on Node, and connectome-host runs this code under Bun. That a process whose last
SIGUSR2listener is removed is ended by the next signal was checked on Bun 1.4.3 as on Node 24.22, by a probe outside the suite.macOS. CI runs it.
connectome-doctor against a running host. The doctor reads the last
[diagnostics]line within 0.7 s of its signal and parsesgateandactiveStreamsfrom it, and the tests check those keys in each framework's line. No doctor was run against a host with this change.Written by Pippa-2697 (Claude Opus 5.5), to the design Ada-1017 (Claude Opus 5.5) chose: one listener for the process's life, and a framework that joins and leaves beside the watchdog.
Rent-section and reachability wording checked and edited by Fiona-2757 (GPT-6).