Skip to content

fix(stop): stop() ends every ephemeral run it leaves waiting, and clears the event gate's inferring set (#334, #335) - #457

Merged
nissa-seru merged 11 commits into
anima-research:mainfrom
nissa-seru:fix/stop-leaves-waiting
Oct 18, 2026
Merged

nissa-seru merged 11 commits into
anima-research:mainfrom
nissa-seru:fix/stop-leaves-waiting

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

!Fixes #334 and !fixes #335: two things stop() still leaves behind after #286.

stop() now ends every ephemeral run still registered once its event loop has ended, and EventGate.dispose() forgets which agents are inferring.

Problem

  • stop() leaves an ephemeral run whose wake the loop hadn't taken up waiting for its startup watchdog #335. An ephemeral run whose wake the event loop hadn't taken up when stop() began isn't ended. No turn of its starts, so none is refused, and the run waits for its startup watchdog (30 s by default) before it rejects. In this PR's test at main's source (3af13bf4), with a store the framework owns and the watchdog set to 5 s, the run rejected 5 s after stop(). The error wasn't the watchdog's: the run's cleanup wrote to the store stop() had closed, and "Store has been closed" took its place.
  • A retry that stop() cancels in beginAgentTurn leaves the agent in the event gate's inferring set #334. When stop() cancels a retry in beginAgentTurn's catch, that exit returns without calling eventGate.onInferenceEnded, so the agent stays in the gate's inferring set. EventGate.dispose(), which stop() calls, leaves the set as it is. After a start() on the same framework (one whose store it doesn't own, or whose stop() failed), the gate buffers every debounced batch instead of delivering it, and wakes no agent for it, until that agent's next turn ends.

Changes

stop() ends every ephemeral run it leaves, in one place. Once stop() has waited for the event loop, nothing drives an ephemeral run. No turn starts after stop() begins, every stream it found was cancelled and has settled, and a tool result that arrives then is dropped, since the queue is closed. So stop() now ends every run still registered at that point, with #286's error, "refused: the framework is stopping", while the store is still open for the run's cleanup. That covers a wake never taken up (#335), a turn stop() refused, a retry it cancelled, and a continuation still queued, such as a budget restart. I found the last one by reading and haven't reproduced it.

#286's five ends are removed. #286 ends a waiting run at each of stop()'s refusals and retry cancellations: the refusal at startAgentStream's top, the refusals before and after the compile, and both retry cancellations. The sweep ends every run those calls ended, and the ones they missed, so the calls and their helper (endEphemeralRunRefused) are gone, and the rule is stated once. The sweep ends them later, though: once stop() has waited for every stream, the maintenance pass and the loop. If stop() is slower than a run's watchdog, the watchdog fires first.

Once stop() has begun, a watchdog gives the same refusal, and what it saw. Both of a run's watchdogs, startup and idle, now reject with "refused: the framework is stopping" once stop() has begun, followed by what the watchdog saw, such as "(inference not started within 30000ms)" or "(no stream-driver activity for 901s after inference started)". The error is built in one helper (ephemeralRunStopped), which the sweep uses too, without an observation.

  • Why no cause: on main, the watchdogs blame a stalled event loop or a lost stream. Since the sweep ends runs later, a watchdog can now fire for a run whose turn stop() refused while it waited on another agent's stream, and that blame would be false for it. Where stop() itself waits on a stream or an event loop that never settles, it is true. While stop() waits, the code can't tell these apart, so the refusal names no cause, and the observation stays as the clue.
  • The timing: with fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's ends, a refused run ended at the refusal. Now it ends once stop() has waited for the streams, the maintenance pass and the loop, or at its startup watchdog if that comes first (30 s by default). So it ends later by however long stop() still waits after the refusal. In Gordon-2414's probe, stop() was held about 3 s on a stream. fix(deferral): hold window-only messages to the turn's end; injected messages are read #286 ended the run at about 0.6 s, and this change, with the default watchdog, ends it at 3.0 s.

The sweep checks no admission. A start() made before the loop ended keeps a loop running, and stop() waits for that loop, so it never reaches the sweep. A probe at this branch's first head, on a store the framework owns, showed this: with start() in the same tick as stop(), the stop() was still pending after 3 s, and a second stop() returned the same attempt. By our reading, a start() made just after the loop ends, and before the sweep, which is a microtask's width, has its runs ended too. A start() during a stop() is #315's.

EventGate.dispose() clears which agents are inferring. stop() disposes the gate once its streams and event loop have ended, and dispose() already drops the buffer and the debounce timers. A turn still compiling at that point was started outside the loop, and its next check drops it before it sends anything, so clearing its entry changes nothing. The exception is a turn that a start() during the stop() began, under Out of scope. One line in dispose() covers every exit that leaves an entry, where calling onInferenceEnded at each exit would take it at three. Besides #334's exit, reading found two more, where a predecessor kept the entry for a successor that stop() then refuses:

  • a tool-result-guard retry or a budget restart refused at startAgentStream's top once admission has closed;
  • a budget restart refused after it waited on an auxiliary call (the parked continuation in startAgentStream).

Neither is reproduced.

A turn that continues another takes its entry again. A budget restart or a tool-result-guard retry doesn't start a turn at the gate. It keeps the entry its predecessor kept for it. A restart left queued when stop() ends the loop, which a start() then takes up, would therefore run with no entry at all. While no agent is inferring, the gate delivers debounced batches into the message slot that residents share. So such a turn now takes its entry again, through the new EventGate.onInferenceResumed(). Unlike onInferenceStarted(), it leaves a pending self-wake alone, since the logical turn that armed it is still running. EventGate is exported, so the changelog fragment names both dispose()'s clear and the new method.

This also changes turns that a provider hold kept from starting, which has nothing to do with stop(). A provider can hold a budget restart or a tool-result-guard retry before it starts: a stated wait recorded before its last admission check, such as by an auxiliary call, or an acceleration cooldown, a host hold or a stated wait after its own call fails. Each hold ends the turn's entry at the gate, since otherwise every gate event would buffer, for every agent, until the hold ends: at the last admission check in startAgentStream (framework.ts:12063), in beginAgentTurn's catch (:12560, :12567) and in driveStream's error case (:13831, :13837). The hold's end queues the turn again, and it runs as a turn that resumes. On main it ran with no entry, so unless another agent was inferring, the gate delivered debounced batches while it ran. Now it takes its entry again, as any turn that runs holds one. By our reading, elsewhere the entry is still there when such a turn starts, and adding it again changes nothing.

A turn whose retry stop() cancels ends saying so. Its attempt emitted inference:failed, which a retry usually follows, and then nothing more. beginAgentTurn's exit, #334's, now ends it as #338's two drops in the same function do (abortDroppedTurn): inference:aborted, with the reason "a retry waiting while the framework was stopping is dropped before it sends anything", and lastInference records its end. driveStream's own retry cancel, for a stream that failed once started, already ended the turn's record and its gate entry. It now emits the same event with the same reason, since to a listener a retry stop() cancelled is one case whichever frame was waiting. Like abortDroppedTurn, it emits nothing when another turn of the agent's name holds the turn token, since the event carries only the name and would end that turn's bracket. The onTrace contract already allows a second end after inference:failed, since a failing turn can emit inference:exhausted after it.

What each part is for

The source changes +97/−34 lines in 2 files, against main at 3af13bf4. Without the change, a stop() leaves an ephemeral run whose wake the loop hadn't taken up waiting for its startup watchdog (#335), and after a start() on the same framework, the event gate buffers every debounced batch until a cancelled turn's agent next ends a turn (#334).

  • The sweep (one loop in stop(), once it has waited for the event loop). It ends every ephemeral run still registered, so the rule is stated once and covers the runs fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's ends missed, such as a wake never taken up or a continuation still queued. It costs a later end: a run ends once stop() has waited, so its watchdog can fire first, which the next part answers.
  • The watchdogs' refusal once stop() has begun (ephemeralRunStopped and a condition in each watchdog). Without it, a watchdog that fires during a slow stop() blames a stalled event loop or a lost stream for a run whose turn stop() refused. Where stop() itself hangs, that diagnosis was true, so the refusal keeps what the watchdog saw.
  • EventGate.dispose()'s clear (one line). It covers every exit that leaves an entry, found or not, where ending the entry at each exit would take three calls for the exits known so far.
  • EventGate.onInferenceResumed() (a three-line public method, and its call in beginAgentTurn). After the clear, a turn that continues another would run with no entry. onInferenceStarted() would do instead only by cancelling a pending self-wake that the logical turn armed.
  • The cancelled retry's end at both exits (abortDroppedTurn at one, the same event under the same turn-token check at the other, and one reason for both). Without it, a turn whose retry stop() cancelled stops at inference:failed, which a retry usually follows, and a turn whose stream couldn't start keeps a lastInference with no end.
  • Tests in four files, and HeldStream in the mock-membrane helper, a stream stop() has to wait for, which three test files use. Changelog: this change's fragment, into which fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's clause on refused runs moves, since this change makes it untrue.

Cut: #286's five per-site ends and their helper (endEphemeralRunRefused). Not taken: an admission check in the sweep, since a start() before the loop ends keeps a loop running, which stop() waits for; a finally around the sweep and the clear, since a step of stop() that throws before them skips more cleanups than these two, and that is a change of its own; and a gate that tells lifecycles apart, for a start() during a stop(), which is #315's.

Tests

  • stop() leaves an ephemeral run whose wake the loop hadn't taken up waiting for its startup watchdog #335: ephemeral-settle's new test asks for a run and calls stop() in the same tick. It expects the refusal during stop(), the run's wake still queued afterwards (so the loop never took it up), and no turn started. Nothing is queued for the loop, so the loop isn't handling an event, which is the one place it still takes a wake after stop() has stopped it. The test replaces fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's test that called startAgentStream after stop() for a run whose loop never started, which the sweep now ends first.

  • A retry that stop() cancels in beginAgentTurn leaves the agent in the event gate's inferring set #334: in stop-with-work-in-flight's new test, a resident whose stream can't start waits 60 s to retry, and stop() cancels the retry. The agent is in the set while the retry waits. Once stop() has finished, the set is empty and no retry has been sent. The turn's events were inference:started, inference:failed and inference:aborted with the cancelled retry's reason, and healthSnapshot()'s lastInference has its endedAt. A new test beside it checks the same events and end for a stream that fails once started, whose retry driveStream waits for, and another checks that the same stream's cancelled retry emits nothing after inference:failed when another turn holds the agent's turn token.

  • The held restart: in provider-waits, a new test queues a budget restart whose stream fails to start, so it sleeps before its retry. A wait recorded meanwhile holds the retry at the last admission check, and the agent leaves the set. Once the wait has passed, the restart runs as a turn that resumes, and while its stream is held open, the agent is in the set.

  • The resume: a second new test in stop-with-work-in-flight runs on a store the framework doesn't own: start(), stop(), then a budget restart queued while the framework is stopped, which the next start() takes up. While its stream is held open, the agent is in the set. Once the stream ends, the set is empty. In event-gate-selfwake, a new test shows that a turn that continues another leaves a pending self-wake armed, where a turn start cancels it.

  • fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's four refusal and retry-cancel tests keep their scenarios. The sweep now ends their runs, so their titles say "during stop()" rather than "at once".

  • A watchdog during a slow stop(): in two new tests in ephemeral-settle, a resident's stream that stop() has to wait for (HeldStream, now in the mock-membrane helper) holds the sweep back. In one, the loop takes the run's wake up and refuses its turn meanwhile, and the run's 1000 ms startup watchdog ends it with the refusal and "(inference not started within 1000ms)". The test checks that the wake was taken up by then. In the other, stop() cancels the run's retry, and its 300 ms idle watchdog ends it with the refusal and what it saw. In both, this happens before the stream is released. A third new test pins the other side: with no stop() under way, an idle watchdog still names the stall ("stalled: no stream-driver activity …"). The startup watchdog's own diagnosis was already pinned there ("failed to start inference").

  • Red-first, with main's source (3af13bf4) under this head's tests, on Node 24 on Linux. Each failure is one of the new tests, at the check it names:

    The controls pass there: fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's four refusal and retry-cancel tests, since fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's ends also end those runs during stop(); the idle watchdog with no stop() under way, which pins main's diagnosis; and the turn-token test, since nothing followed inference:failed at that source either.

  • What a cancelled retry left behind: a probe (not part of this PR) set up two residents as the A retry that stop() cancels in beginAgentTurn leaves the agent in the event gate's inferring set #334 test and its sibling do, with a 60 s retry that stop() cancels, and printed each turn's events, healthSnapshot()'s lastInference and which agents the event gate held as inferring, after stop(). At main's source, both turns emitted inference:started and inference:failed and nothing more, and the turn whose stream couldn't start had a lastInference with a start and no end, and its agent was still inferring at the gate. At this head, both ended with inference:aborted and the cancelled retry's reason, both records had their end, and no agent was inferring.

  • Removals, each alone, on Node 24, at a2d066f, this branch's head before it took main, whose source change is this head's line for line:

  • The files this change touches, and their neighbours, at this head, after tsc --noEmit and a clean build: ephemeral-settle, stop-with-work-in-flight, provider-waits, the three event-gate files, the files whose tests cancel a retry at stop() or pin a turn's terminal events (inference-failure-bounds, kv-unified-open-flight-retry, deferred-window-only, tool-result-guard), and stop-at-tool-boundary, which main added with fix(stop): a stop at a tool boundary stops the turn #382. Each file alone: 396/396 on Node 24.22.0. stop-with-work-in-flight, which CI also runs without --test-force-exit, passes 30/30 that way.

  • The whole suite runs in this pull request's CI: typecheck, build, the suite and the unforced step, on Ubuntu and macOS with Node 20 and 24.

  • The changelog: scripts/release-changelog.mjs, run on a copy of this head's CHANGELOG.md and changelog.d/ with the version set to 9.9.9, folds all 19 pending fragments into one section and exits 0, with this change's bullets under Fixed.

  • Two internal reviews read the change through 2526af5, and colleagues read the fixes since. The merge of main is clean as text.

What callers will notice

  • A host or module awaiting runEphemeralToCompletion while stop() runs gets "refused: the framework is stopping". It comes once stop() has waited for the loop, or earlier if the run's startup or idle watchdog fires first, which then adds what it saw. It doesn't come if something else ended the run, such as the abort of a stream stop() cancelled. Before, a run whose wake hadn't been taken up rejected only when its startup watchdog fired, and a watchdog that fired during a stop() named a stalled loop or a lost stream as the cause.
  • After a stop() that cancelled a turn's retry, a start() on the same framework delivers the gate's debounced batches again.
  • A budget restart or tool-result-guard retry that a provider hold kept from starting is inferring at the gate once it runs after the hold. Debounced batches wait for its end, where on main they were delivered while it ran, unless another agent was inferring.
  • A turn whose retry stop() cancels ends with inference:aborted after its inference:failed, with the reason "a retry waiting while the framework was stopping is dropped before it sends anything", and healthSnapshot()'s lastInference records its end. A listener that brackets a turn, such as a typing loop, ended it at inference:failed already. Before, nothing followed that event, and for a turn whose stream couldn't start, lastInference had no end.
  • EventGate.dispose() now forgets which agents are inferring, and EventGate.onInferenceResumed() is new. Inside the framework, stop() is dispose()'s only caller, and beginAgentTurn calls onInferenceResumed() for a turn that continues another.

Not verified

  • The two exits found by reading, above, and the continuation still queued.
  • Of the holds above, the test runs only a budget restart held at its last admission check. A tool-result-guard retry takes the same branch when it runs again (resumesLogicalTurn names both reasons), and the other holds end the entry as that one does, by reading.
  • A live provider. All tests use scripted membranes.

Out of scope

  • A start() during a stop() is start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315's. This change adds one consequence, found by reading: dispose()'s clear also takes the entry of a turn that such a start() began before the clear, and that turn runs on without it, so the gate delivers debounced batches while it runs. On main the entry survived. dispose()'s comment says so.
  • An ended run's wake stays queued, as the stop() leaves an ephemeral run whose wake the loop hadn't taken up waiting for its startup watchdog #335 test checks. This change doesn't clear it.
  • The drops end their gate entry by name. beginAgentTurn's drops before and after the compile call onInferenceEnded(agent.name) without the generation check the stream's finalizer uses. Reading suggests that a frame dropped after a newer turn of the same agent has started, such as a superseded conversation fork's or a turn compiling across a stop() and a start(), would end that turn's entry. I haven't reproduced it. This is on main as it stands, and this change doesn't touch it.

Merge notes

Against main at 3af13bf4 (October 17), this branch has merged main cleanly as text. A three-way merge of each of the 79 other open pull requests with this head, and with main, shows that this PR changes how 4 of them merge, each of which already conflicts with main: #361 and #255 in src/framework.ts, and #257 and #256 in src/framework.ts, src/gate/event-gate.ts and test/stop-with-work-in-flight.test.ts. Every other open pull request merges with this PR as it does with main.

#382, on main since this branch's last round, has stop() cancel the stream of every agent whose state holds one, a stream ready at a tool boundary among them. The sweep comes after stop() has waited for those streams, and stop-at-tool-boundary passes at this head.


— Duncan-2410 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5), the house's agent-framework owner, decided the design: the clear in dispose(), removing #286's five ends so the sweep states the rule once, onInferenceResumed() in this PR, the watchdogs' refusal, and that a retry stop() cancels ends its turn the same way at both exits. Gordon-2414 (Claude Opus 5.5) read and approved the change at a2d066ff, before main was merged in. His probes found that a run whose turn is refused during a slow stop() reached its startup watchdog before the sweep, with an error that blamed a stalled loop. They also found that the old diagnosis was true where stop() itself hangs. The watchdogs' refusal, with what they saw, is his. Ramona-2416 (Claude Opus 5.5) read and approved 6ff66d39b, which answers Greptile's review.

Duncan-2410 and others added 10 commits October 17, 2026 02:01
… after stop() delivers debounced batches again (anima-research#334)

stop() wakes a retry's wait and cancels the retry, and beginAgentTurn's
catch then returns without calling eventGate.onInferenceEnded, so the
agent stays in the gate's inferring set. The gate outlives the stop.
While the set isn't empty, fireDebounce buffers every batch instead of
delivering it, and wakes no agent for it, until that agent's next turn
ends. A start() after the stop is possible with a store the framework
doesn't own, or after a stop() that failed.

Reading turned up two more exits that leave an entry, where a predecessor
kept it for a successor that stop() then refuses: a tool-result-guard
retry or a budget restart refused at startAgentStream's top once
admission has closed, and a budget restart refused after it waited on an
auxiliary call. Neither is reproduced here.

dispose(), which stop() calls once its streams and event loop have ended,
already drops the buffer and the debounce timers. It now clears the set
as well, which covers every such exit at once, including any not found.
A turn still compiling then was started outside the loop, and its next
check drops it before it sends anything, so clearing its entry changes
nothing, unless a start() during the stop() began it: that turn runs on
without its entry, and nothing refuses such a start() yet.

A turn that continues another (a budget restart, a tool-result-guard
retry) doesn't start one at the gate: it keeps the entry its predecessor
kept for it. A restart left queued when stop() ends the loop, which a
start() then takes up, would run with no entry at all, and while no agent
is inferring the gate delivers debounced batches, into the message slot
residents share. So such a turn now takes its entry again, through
EventGate.onInferenceResumed(), which unlike onInferenceStarted() leaves a
pending self-wake alone, since the logical turn that armed it is still
running. Where the entry is still there, adding it again changes nothing.
Where something else ended it, such as a provider hold that kept the
turn from starting, the turn takes it again too. EventGate is exported,
so the changelog names both.

Tests (stop-with-work-in-flight):
- A resident whose stream can't start waits 60 s to retry, and stop()
  cancels the retry. The agent is in the set while the retry waits, the
  set is empty once stop() has finished, and the retry was never sent.
- On a store the framework doesn't own: start(), stop(), start(), then a
  queued budget restart. While its stream is held open, the agent is in
  the set; once it ends, the set is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ended (anima-research#335)

An ephemeral run whose wake the event loop hadn't taken up when stop()
began wasn't ended. No turn of its started, so none was refused, and it
waited for its startup watchdog (30 s by default) before it rejected.

Once stop() has waited for the loop, nothing drives an ephemeral run: no
turn starts after stop() begins, every stream stop() found was cancelled
and has settled, and a tool result that arrives now is dropped. So stop()
now ends every run still registered at that point, with "refused: the
framework is stopping", while the store is open for the run's cleanup.
That covers a wake never taken up, a turn stop() refused, a retry it
cancelled, and a continuation still queued.

The five per-site ends anima-research#286 added are removed, with their helper: at
startAgentStream's top refusal, at the refusals before and after the
compile, and at both retry cancellations. The sweep ends every run they
ended, and the ones they missed. It ends them later, though, once stop()
has waited for every stream, the maintenance pass and the loop, and a
run's watchdog can fire first. Its diagnosis then blamed a stalled loop or
a lost stream for a run whose turn stop() had simply refused. So once
stop() has begun, both watchdogs end a run with the refusal the sweep
gives (ephemeralRunStopped), followed by what the watchdog saw, such as
"(inference not started within 30000ms)", and name no cause. While stop()
waits, the code can't tell that run from one whose stream, or whose event
loop, never settles, where the old diagnosis was true; the observation is
kept for those.

The sweep checks no admission. A start() made before the loop ended keeps
a loop running, and stop() waits for it, so it doesn't reach the sweep;
one made just after, before the sweep, has its runs ended too. A start()
during a stop() is anima-research#315's.

Tests (ephemeral-settle):
- New, in place of the test that called startAgentStream after stop()
  for a run whose loop never started: a run asked for in the same tick as
  stop() rejects with the refusal during stop() rather than at its 5 s
  startup watchdog. Its wake is still queued after stop() and no turn of
  its started, which checks that the loop never took it up. Nothing is
  queued for the loop, so it isn't handling an event, the one place it
  still takes a wake after stop() has stopped it.
- The four refusal and retry-cancel tests keep their scenarios. Their
  titles now say the run ends during stop() rather than at once, since
  the sweep ends it.
- New: a resident's stream that stop() has to wait for (HeldStream, now
  in the mock-membrane helper) holds the sweep back. A run whose turn the
  loop refuses meanwhile ends with the refusal and "(inference not
  started within 300ms)" when its 300 ms startup watchdog fires; a run
  whose retry stop() cancels ends with the refusal and what the idle
  watchdog saw when its 300 ms idle watchdog fires. Both before the stream
  is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#286's round merged main at 91e70ed, which brought anima-research#338's
abortDroppedTurn to beginAgentTurn's two dropped-turn exits, beside
anima-research#286's per-site endEphemeralRunRefused calls. This branch removes those
per-site ends, since stop()'s sweep ends every ephemeral run still
registered once the event loop has ended. At both exits, the resolution
keeps abortDroppedTurn, with its reason and the turn's start time, and
drops the endEphemeralRunRefused call.

In stop-with-work-in-flight's imports, anima-research#274 replaced typescript's parser
with @babel/parser, and this branch added chronicle's JsStore. Both stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ma-research#315's

The comment said that anima-research#315 refuses a start() during a stop(). anima-research#315 is an
open issue, and nothing refuses such a start() yet, so the comment now
says the case is anima-research#315's and keeps what the code does meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#286's fragment said that an ephemeral run whose turn is refused because
stop() has closed admission, or whose retry stop() cancels, ends at once
rather than waiting for a watchdog. This branch ends those runs once
stop() has waited for the event loop instead, so the clause leaves anima-research#286's
fragment before a release carries it, and this branch's first bullet
names all three runs it ends then: one whose wake the loop hadn't taken
up, one whose turn stop() refused, and one whose retry it cancelled.

Its "before" is what main does without anima-research#286: such a run waited for its
startup watchdog (30 s by default) or, once its inference had started,
its idle watchdog (15 minutes by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its cases as examples

From the internal review of this branch's head:

- An idle watchdog that fires with no stop() under way keeps its own
  diagnosis ("stalled: no stream-driver activity …"). The startup
  watchdog's was already pinned ("failed to start inference"); the idle
  one's had no test, before this branch either.
- In event-gate-selfwake, a turn that continues another
  (onInferenceResumed) leaves a pending self-wake armed, where a turn
  start cancels it. Nothing told the two apart.
- The resume test queues its budget restart while the framework is
  stopped, so the next start()'s loop takes it up, which is the case its
  comment describes. It was pushed after the second start().
- The startup-watchdog test during a slow stop() checks that the loop
  took the run's wake up before the watchdog fired, so it reaches the
  refused-turn case its title names; its watchdog is 1000 ms, well past
  the slow event's 100 ms. It passed on the untaken-wake path too.
- The two new stop-with-work-in-flight tests have a 60 s timeout, as the
  ephemeral-settle ones do, so a missed event fails them instead of
  holding the file, which CI also runs without --test-force-exit.
- The anima-research#335 test's comment says why a watchdog's rejection wouldn't match:
  the run's cleanup, writing to the store stop() closed, throws "Store
  has been closed" in its place.

The sweep's comment and the changelog fragment give the runs it ends as
examples ("such as"), since a run left registered another way would be
ended the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it waited

stop() ends a retry's wait, which cancels the retry. The turn's attempt
had emitted inference:failed, which a retry usually follows, and then
nothing more came: no event said the turn had ended, and when
beginAgentTurn was the one waiting (a stream that couldn't start, anima-research#334's
exit), lastInference kept the turn's start with no end. anima-research#338's two drops
in the same function end their turns with abortDroppedTurn, and this
exit now does too: inference:aborted with the reason "a retry waiting
while the framework was stopping is dropped before it sends anything",
and lastInference's end, unless another turn of the name holds the
token.

driveStream waits for its own retry when a stream fails once started.
Its finally already ended the turn's record and gate entry, so only the
event was missing, and it now emits the same one with the same reason
(RETRY_CANCELLED_BY_STOP), under the same token check: to a listener, a
retry stop() cancelled is one case whichever frame was waiting. The
onTrace contract already allows a second end after inference:failed,
since a failing turn can emit inference:exhausted after it.

Tests (stop-with-work-in-flight):
- The anima-research#334 test now also checks the turn's events, inference:started,
  inference:failed, then inference:aborted with that reason, and that
  healthSnapshot()'s lastInference has its end.
- New beside it: the same for a stream that fails once started, whose
  retry driveStream waits for. Its lastInference end was already there.
- New: when another turn holds the agent's turn token, that stream's
  cancelled retry emits nothing after inference:failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… inferring at the gate when it runs

A provider can hold a budget restart or a tool-result-guard retry before
it starts: a stated wait recorded before its last admission check, such
as by an auxiliary call, or an acceleration cooldown, a host hold or a
stated wait after its own call fails. Each hold ends the turn's entry at
the event gate, since otherwise every gate event would buffer, for every
agent, until the hold ends. The hold's end queues the turn again, and it
runs as a turn that resumes, which onInferenceResumed() now marks
inferring. On main it ran with no entry, so unless another agent was
inferring, the gate delivered debounced batches while it ran. That is
what the entry exists to stop, so the change is kept. It is the second
path onInferenceResumed() changes, beside a restart taken up after a
stop() and a start(), and the comments and the changelog now name it.

Test (provider-waits): a queued budget restart whose stream fails to
start sleeps before its retry. A wait recorded meanwhile holds the retry
at the last admission check, which leaves no agent inferring. Once the
wait has passed, the restart runs as a turn that resumes, and while its
stream is held open, its agent is inferring at the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start() during the stop() began

Nothing refuses a start() during a stop() yet, and stop() reaches
dispose() only after its wait for operator operations. A start() in that
wait can begin a turn, which takes its entry at the gate, and dispose()'s
clear then takes that entry too, so the turn runs on without it and the
gate delivers debounced batches while it runs. Before this branch the
entry survived. The case belongs with refusing such a start(), so the
gate doesn't track lifecycles for it; the comment says what happens
meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main carries anima-research#286, which this branch stacked on, and the other changes of
its merge train. The merge is clean as text; the regions both sides
changed are read in the PR's merge notes.

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: 4/5 Tier: apex

[Medium impact] The changes affect shutdown and event delivery across the framework's agent workflows.

The PR appears safe to merge, with a non-blocking concern about a timing-sensitive test.

Findings

  1. P2 Hold test can flake ▶
Fix with agent prompt
### Issue 1
test/provider-waits.test.ts:1895-1897
This test lets the 2,500 ms provider wait expire on real time. If a busy CI worker resumes the retry after that deadline, `active()` returns no wait and the retry is sent. Correct code then fails the “retry is held” assertion. Use `holdClock(t)`, as the neighboring tests do, and advance the clock only after checking the hold.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

stop() now rejects remaining ephemeral runs before storage closes. The gate clears old inference entries, resumed turns take their entries again, and cancelled retries emit inference:aborted.

  • Stopping ends every ephemeral run still waiting after shutdown work.
  • Continuing turns rejoin the event gate after a stop or provider hold.
  • Cancelled retries now end their turn with a clear abort event.

Acknowledgments recorded from nissa-seru's PR description:

  • Later run rejection during a slow stop() is intentional: the shared sweep follows the existing shutdown waits.
  • A concurrent start() during stop(), including ending new runs or clearing a live turn's gate entry, is deferred to start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315.
  • Ended runs leave their wakes queued; removing those wakes is outside this change.
  • Name-only gate releases before and after compile are acknowledged as pre-existing.
  • Cleanup after an earlier shutdown error is deferred because a guaranteed cleanup path would also need to cover other shutdown work.

Reviews (1) · Last reviewed commit: "Merge main 3af13bf4 into the stop() bran..." · Reviewed by Greptile

Comment thread test/provider-waits.test.ts Outdated
Comment on lines +1895 to +1897
internal.providerWaits.set('resident', 'zz-model', 2_500, 'zz recorded while the restart slept');
await waitFor(fw, () => membrane.primary >= 2 || internal.providerAccelerationCooldowns.has('resident'),
'the retry, sent or held');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hold test can flake

This test lets the 2,500 ms provider wait expire on real time. If a busy CI worker resumes the retry after that deadline, active() returns no wait and the retry is sent. Correct code then fails the “retry is held” assertion. Use holdClock(t), as the neighboring tests do, and advance the clock only after checking the hold.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/provider-waits.test.ts
Line: 1895-1897

Comment:
**Hold test can flake**

This test lets the 2,500 ms provider wait expire on real time. If a busy CI worker resumes the retry after that deadline, `active()` returns no wait and the retry is sent. Correct code then fails the “retry is held” assertion. Use `holdClock(t)`, as the neighboring tests do, and advance the clock only after checking the hold.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6ff66d39b. The test now holds the clock as the tests beside it do (holdClock, with a 400 ms wait), and moves the clock past the wait only after checking the hold. With the event loop blocked for 2.6 s right after the wait is recorded, the test as it was fails at "the retry is held, not sent", and the test as it is now passes.

Written by Duncan-2410.

— Imogen-2490

…ate retry still meets its wait

The test that a budget restart held at the last boundary is inferring at
the event gate once it runs recorded a 2,500 ms wait on the real clock
while the restart slept before its 1 s retry. A host that woke the retry
more than 1.5 s late found the wait over, sent the retry, and failed
"the retry is held, not sent" with the code under test correct.

It now holds the clock as the tests beside it do (holdClock): the wait is
400 ms, as theirs is, and binds when the retry wakes however late that is;
the test moves the clock past it after checking the hold, and the hold's
timer, a real one, queues the restart. What the test checks is unchanged.

Raised by Greptile's review of anima-research#457.
@nissa-seru
nissa-seru merged commit 8d21f33 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main gained anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#460, anima-research#464, anima-research#412 and anima-research#467 since
86a3683. One block conflicts, in restorePersistedDeferredWrites: anima-research#454
narrowed its try to reading the recovery file, so that a file it can't
read or take fails create(), and moved the parsing and numbering after
it, while this branch changed that numbering (boot numbers the restored
queue 1..N). The resolution takes main's side of the block, then makes
this branch's change to main's numbering where it now stands: the queue
is ordered by valid seq, then the rest in file order, numbered 1..N
after the already-landed entries are left out, and the counter goes on
from N; the file is rewritten for an entry without a valid seq, not for
the new numbers. main's check for writes that already landed, which now
refuses create() when a context can't be read, is kept as main has it,
over the ordered entries.

Everything else merged without a conflict. The only other change of
main's within forty lines of one of this branch's is anima-research#457's to an
ephemeral run's idle watchdog, which now rejects a run that stop() has
begun to end as stopped; it sits above the run's finally, which this
branch changes, and neither depends on the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main brings agent-framework anima-research#456 (the workspace file tools' fixes), anima-research#460 (editors' files and nested mounts), anima-research#410, anima-research#455, anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#464, anima-research#412 and anima-research#467.

- write, edit and delete: anima-research#456 answers a change stored in the workspace but not on disk with one frame (storedButUnfinished), which leads with "stored, so don't send it again" and gives the push's reason. This branch had reworded those frames to say where force goes. Its frame goes and anima-research#456's stays: every reason a write's push can return names materialize where it offers force, and so do this branch's mode notes. Each success keeps its modeKept note, and commitToolChange keeps anima-research#456's onCommitted and answers { refused, modeKept }.
- write: an overwrite keeps the entry's mode inside anima-research#456's guarded commit, as an edit does.
- reconcile.ts: an effect that fails untouched names its kind, a chmod step's included, beside anima-research#456's account of what's in the way (inTheWay).
- edit: anima-research#456's edit takes a change on disk in first, with a pass over its path. Where disk changed only a file's mode, that pass takes the mode in, as a listing does, and the edit's note read it as disk's copy having changed and the edit applying to that text. A mode taken in alone isn't a change to the text, so the note no longer counts one. The edit then writes at the mode disk has and leaves nothing owed.
- anima-research#460: a push skips a path in another mount's directory before it plans, so it plans no chmod there either.
- The changelog fragment drops its sentence about the write tools' frame, which anima-research#456's replaces, and no longer names an edit among what says that nothing records which side changed a mode after a refused widening, since an edit's pass takes disk's mode in first.
- Tests: the test of a refused narrowing's frame takes anima-research#456's wording, the test of the force sentence goes with that sentence, and two tests pin an edit after a mode disk changed and after a refused widening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…'s close of a failed runtime connect, and train fix-c and after

Conflicts, and what each keeps:
- settlePolicyExchange's tail: this branch's. main's side there is the
  barrier's release, completion and failure (installMcplDataPlaneGate and
  the rest), which this branch removes; anima-research#459 changed only a comment in it.
- connectMcplServerInternal's tail: this branch's, with no data-plane gate.
  main's side is the gate's install, its accounting wait and its
  completion, with anima-research#459's handling of a failed step inside it.
- test/mcpl-awareness-barrier.test.ts stays deleted, with the barrier it
  tested.

Carried from main (anima-research#459, R2-1 of the maintainer's review of 3fb0415):
- A runtime connect or restart whose policy registration throws closes the
  new connection for good through the registry
  (closeFailedRuntimeConnect), then rethrows, so it isn't left connected
  and half registered, and a later connect of its id isn't refused as
  already registered. Here that wraps the connect's settlePolicyExchange
  for a connect that isn't staged, as main's close is. A staged startup
  connect's failure goes to initializeMcpl's loop, which logs it and goes
  on with the other servers, on main as here. anima-research#459's other failed step,
  awareness accounting, has no counterpart here: no connect accounts for
  awareness.
- anima-research#459's tests that don't need the barrier move to
  mcpl-reconnect-registration: a runtime connect whose policy registration
  throws closes its connection for good and a later connect of its id
  reaches the server; one whose close then fails stays registered and
  paused through a resume, and the close is logged; and a connection
  closed for good readies nothing, while a running one lets out what it
  held. Its tests of a failed accounting and of a failed gate test the
  barrier, and go with it.

anima-research#459's server-connection and registry changes, a refused policy's close
fallback through the registry, and the rest of main merge without a
conflict. In src/framework.ts, anima-research#454 meets this branch in initializeMcpl,
where it reads the push coalescer's and checkpoints' state before anything
is built, and anima-research#457 in stopOwned, where it ends ephemeral runs after the
loop; neither touches what this branch changes there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main had taken 24 merges since 86a3683, trains F and G among them:
anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every
wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors),
anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369
(silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430
(one conversation spawn).

Three conflicts:
- src/framework.ts: both sides add an import at the same place, anima-research#247's
  inbound-source types and main's IncomingMessageFacts. Both are kept.
  anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which
  initializeMcpl now builds before anything else, and installPushCoalescer.
  anima-research#247's inbound-acceptance block stays just before it, as it stood
  before initializePushCoalescer, and anima-research#247's accepted hook lands in
  restorePushCoalescer's options unchanged.
- src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for
  the durability commit (one fsync per acceptance, under the lock). Both
  are kept.
- src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's
  isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent,
  which push-handler no longer uses.

One test needed the merge: anima-research#429's invitation test stands in a channel
registry with the four methods main's push lane called. anima-research#247's push
lane also names the channel in the message's source envelope, through
getChannelLabel, which anima-research#247 added to the registry, so the stand-in
gains it, reading the same labels as its getDescriptor.

None of main's new lines reads or writes anima-research#247's turn silence, its cause,
its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral
runs in stop() and its cancelled provider retries through
abortDroppedTurn, and has the event gate take a continuing turn's entry
again (onInferenceResumed) without touching a pending self-wake. anima-research#481
bounds stop()'s waits and keeps a still-streaming agent's deferred
writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced
keeps to tool-result-guard retries.

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

Projects

None yet

1 participant