Skip to content

fix(diagnostics): one SIGUSR2 listener per process, dumping each running framework - #433

Merged
nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:fix/stop-removes-diagnostics-listener
Oct 17, 2026
Merged

nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:fix/stop-removes-diagnostics-listener

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

One SIGUSR2 listener 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 during create(); 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 a SIGUSR2 listener 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. On main, three create()/stop() pairs leave 1, 2 and 3 listeners (a probe at 91e70eda; the listener's code is the same at 24826254).

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 has been removed is ended by the next SIGUSR2. connectome-host's 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 end the host.

Changes

  • One listener serves the process. The first create() to reach the point below installs it, and it stays for the process's life. Until then the process has no listener, as on main, where it's installed later in create().
  • A framework joins the set that listener dumps partway through create(), once its agents, modules and event gate are built: where create() starts the liveness watchdog, or would if it isn't enabled. It leaves where stop() stops the watchdog:
    • after stop() has waited on its streams, maintenance, event loop and operator operations, so a dump still shows what those waits are on;
    • before its modules and MCPL servers stop, so a stop() whose teardown hangs, fails or times out doesn't keep it listed;
    • a create() that fails after joining leaves through stop(), in the release fix(create): a failed create() releases what it had acquired #426 adds.
  • With no framework in the set, a dump prints one line, [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. On main, callbacks accumulated across create/stop cycles and continued to print stale dumps.

  1. One listener for the process (the diagnosticsListenerInstalled flag and joinDiagnostics()). The obvious fix is for stop() to remove what create() installed, but removing the last SIGUSR2 listener restored the signal's terminating action in our Linux probes on Node and Bun. connectome-doctor sends one for its dump, so removing the listener between one session's framework and the next's would let it kill the host.
  2. The diagnostics set (diagnosed, joined in create() and left in stop() beside the watchdog) supplies the instances the listener prints. Leaving at the end of stop() would keep a framework listed while its module teardown remained unsettled. The held-module test and its removal check pin the earlier departure point.
  3. The "no framework is running" line is one branch, so a dump with nothing to show says so. The doctor's live-dump stage waits 0.7 s after its signal, then reads new log lines. In an isolated run of that stage from connectome-host 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.
  4. printDiagnostics() moves the old listener's body into a method and preserves the fields in each framework's dump.
  5. The tests: 4. Each of six removals fails at least one of them. The fragment: the repository asks for one.

Left out:

Tests

test/diagnostics-signal.test.ts:

  • three frameworks created and stopped one after another leave one listener installed (fails on main, which adds three);
  • a dump prints each running framework, with the keys connectome-doctor reads, then the "no framework is running" line, and a create() refused after it joined doesn't stay in it (fails on main, which prints every stopped framework's dump);
  • a dump while stop() waits on a maintenance pass still lists the framework, and one while its module's stop() is held no longer does (fails on main, and fails if the leave moves to the start or to the end of stop());
  • a control, passing on main too: in a child process, a SIGUSR2 after every framework has stopped leaves the process running. It guards against the removal that would end it.

Checks

At this head, on #426 and main 24826254, on Linux with npm 11.19.1:

  • CI's steps (npm install, tsc --noEmit, npm run build, node --test --test-force-exit dist/test/*.test.js): the typecheck and build are clean.
    • Node 24.22.0: 2427 tests, 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-exit cut 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.
    • CI's unforced files (stop-with-work-in-flight, stop-with-host-operations and conversation-routing): 105 of 105.
    • Node 20.20.2: the new file passes, 4 of 4.
  • The new tests fail without the change. With fix(create): a failed create() releases what it had acquired #426's source and this test file, the three tests above the control fail: three listeners added, every stopped framework's dump, and the framework still listed while its module stops. The control passes there.
  • Each piece has a test. With the rest of the change in place, each of six changes fails at least one test, each at its own assertion: taking out the leave (2 fail), a listener for each framework (3), no "no framework is running" line (1), removing the listener once no framework is left (3, the control among them), the leave at the start of stop() (1), and the leave at its end (2).

Not covered

  • A framework started again after a completed 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.
  • A stop() that throws before it stops the watchdog skips the leave, as it skips the watchdog, module and MCPL cleanup after it. Making stop() 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 SIGUSR2 listener 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 parses gate and activeStreams from 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).

Pippa-2697 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>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] This PR appears safe to merge within its stated lifecycle scope.

Summary

This PR shares one SIGUSR2 listener across frameworks and limits dumps to frameworks inside the stated startup and shutdown boundaries.

  • Failed framework starts now clean up what they opened or started.
  • Workspace stops no longer overwrite restart state before it is applied.
  • One process listener now reports only frameworks in their live window.

Reviews (2) · Last reviewed commit: "fix(diagnostics): the internal review's ..." · Reviewed by Greptile

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
@nissa-seru
nissa-seru merged commit 0dbff69 into anima-research:main Oct 17, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant