Skip to content

fix(stop): an explicit teardownTimeoutMs bounds stop()'s waits on streams, maintenance and the event loop - #481

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/stop-budget-bounds-every-wait
Oct 18, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/stop-budget-bounds-every-wait

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

!Closes #373: with a budget, stop() also bounds its waits on streams, maintenance and the event loop, and keeps the store open until whatever a wait that ran out left running has settled.

stop(teardownTimeoutMs) now gives the budget to its waits on the streams it cancels, on maintenance in flight and on the event loop, as it already did to its waits on an operator's operations and on modules and MCPL servers. With no budget, nothing changes.

Problem

stop(teardownTimeoutMs) bounded two of its waits: on an operator's operations already running, and on module and MCPL teardown. Its doc said the budget "does not bound earlier stream, event-loop or maintenance quiescence". So a caller that passed a budget could still wait without end at three points in stopOwned: the wait on the streams it cancels, the wait on a maintenance pass or over-budget drain kick, and the wait on the event loop.

  • A stream whose cancel can't reach it, such as one whose iterator yields nothing until something it waits on answers.
  • A maintenance tick that doesn't settle.
  • A module's onProcess that never returns, so the event loop never finishes.

#373 has a probe on main 3dc67b8 showing the first and the last: stop(1000) still pending 3 s later. Its follow-up comment found, by reading connectome-host's code, where that reaches a host: connectome-host's headless mode awaits stop() on SIGTERM and SIGINT and exits only once it returns, so a stop() waiting at one of these points keeps the process up until it's killed. With a budget that covers them, it could pass one and still exit.

Changes

With a budget, stop() bounds its waits on streams, maintenance and the event loop. Each takes teardownTimeoutMs, as the wait on operator operations does.

  • When one runs out, teardown goes on, and the stop() then fails with a ShutdownTimeoutError naming what it waited for: stream:<agent> for each stream still running, maintenance, or event loop. With more than one, or beside another failure, it fails with an AggregateError of them all, as stop() already did for several failures. An owned store stays open, and stop() can be called again, as after a timed-out wait on an operator's operations.
  • Each wait gets the whole budget, as the doc already said of the two waits it covered, so module and MCPL teardown keeps its budget whatever the streams took. A stop() with a budget can now take up to five of them, besides the withheld-spill deadline of a guard's write still under way, and the doc says so.
  • With no budget, stop() waits for each until it settles, as before, since fix(shutdown): preserve final state and propagate cleanup failures #221 found that an implicit deadline lost a module's final write.
  • A wait with nothing to wait for sets no timer: no stream, or no maintenance in flight.

A later stop() waits for what an earlier one's budget left running. A wait that runs out leaves its streams or its loop in leftRunning, by promise, until each settles. A later stop()'s stream wait takes the leftover streams beside activeStreams, and its loop wait the leftover loops beside loopPromise, each promise once, within its own budget. So the store closes only once they've settled, whatever has happened meanwhile to the entries they were found under: a run's end removes its stream's activeStreams entry, and a start() replaces loopPromise. Maintenance needs no entry: no pass starts while one runs, and a drain leaves its set only as it settles, so a later stop() finds what is still running there, as the two maintenance tests show.

What a wait that ran out leaves running, and what teardown now assumes of it. Teardown after these waits relied on their having settled in three places:

  • The final deferred flush stores each registered agent's queue past the deferral, because "no reply can follow an entry in any stream". That's untrue for an agent whose stream outlasted its wait, and an entry landing ahead of that stream's later writes is what the deferral exists to prevent. So that agent's entries stay queued, and one line says so for that agent: [deferral] stop: N deferred write(s) for <agent> stay queued: its stream outlasted stop()'s wait and may still write, ending with whether the persisted queue keeps them for boot or this memory-only one loses them if the process exits before that stream settles. When that agent's stream settles in a process that lives on, its own turn-end flush takes them, as at any turn's end, and a later stop() takes what that leaves. A stream counts as running while the handle stop() waited on for that agent hasn't settled. An activeStreams entry alone isn't that signal: a retry whose compile fails leaves the old frame's entry with no stream behind it (by our reading, since it has already taken the agent's stream id).
  • The sweep of ephemeral runs still ends every run still registered, with "refused: the framework is stopping", even one whose stream is still running. The caller asked for a bounded stop(), and such a run would otherwise wait for its idle watchdog, fifteen minutes by default. Before, stop() waited for the run's stream first, without end if it never settled. The run's end stores what is queued for it, as at any run's end, whatever its stream is doing, and once a run has left, what its stream yields is dropped, as the run's test shows. By our reading, the drop is driveStream's checks that the run still owns its name, and a write the stream had already begun past them goes to the run's context directly, so it can still land after the stored queue, in the run's namespace, which stays in the store for investigation and which nothing compiles again.
  • A loop still in a call, such as a module's onProcess, finishes that call when it returns, after stop() has. A start() made meanwhile sets running again, so that loop would then go on taking events beside the new lifecycle's loop. runLoop now exits once its lifecycle has passed, so it ends after that iteration, whose rest runs as before: the other modules' onProcess for the event it was in, then processInferenceRequests. By our reading, the same condition ends a loop that a start() made during a stop() finds still running, which could go on the same way before. Whether an instance should be reused after stop() at all stays start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315's question.

Text. stop()'s doc says what each wait takes, the five-budget bound, the errors, what a wait that ran out leaves, and that a later stop() waits for it. The docs of FAILED_CREATE_TEARDOWN_TIMEOUT_MS and releaseFailedCreate follow. A framework create() never started has no streams, loop or maintenance, so a failed create() still waits only on operator operations and on modules and MCPL servers.

What each part is for

  • waitFor (one closure in stopOwned, about fifteen lines). It gives each of the three waits the budget, collects a timeout to report after teardown, keeps what a timed-out stream or loop wait left running, takes a rejection as settling, as Promise.allSettled did for the streams, and skips a wait with nothing in it. Three inline try blocks around waitForShutdown would repeat that three times.
  • The labels (stream:<agent>, maintenance, event loop). An operator reading the error learns what stop() was waiting for, in the form the other waits' labels already take (module:<name>, mcpl:<id>, surgery:<verb>).
  • leftRunning and withLeftovers (one map, and about ten lines). Without them, a second stop() can close the store under a run's stream that the first one's sweep ended, or under the old loop once a start() has replaced loopPromise, which breaks the rule that a budget never lets the store close under work still running. Refusing to close the store while anything is left, without waiting, would need the same tracking and refuse a stop() that could finish.
  • Skipping an empty wait (one line, and the condition on maintenance). Without it, each stop() with a budget sets timers that clear at once. It costs nothing to keep, and the existing test "work an operation starts shares stop()'s deadline, 6000 ms in" records every timer over 100 ms that stop() sets, so it fails when an empty wait sets one.
  • The flush's filter and its log line (a set filled at the stream wait, one condition, and a loop that counts each still-streaming agent's entries). Without the filter, the flush stores entries ahead of a stream that may still write. Without the line, an operator who finds writes left after a stop() that timed out can't tell why. The alternative, storing them anyway, keeps the old behaviour and gives up the deferral's rule exactly where a timeout leaves a stream running. The set is there because an activeStreams entry can outlive its stream, as above. Reading the entries alone kept such an agent's writes queued at every stop(), budget or not, and lost them where main stores them, as Greptile's review of this PR found.
  • runLoop's lifecycle condition (one condition). Without it, a loop that outlived its stop() runs beside the next lifecycle's loop for as long as that lifecycle runs. Leaving that to start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315 would leave a path to two loops that this change opens, since a stop() that finished never left a loop running before.
  • Ending every run after a timeout costs nothing: the sweep already does. Skipping a run whose stream is still running would leave its caller waiting for the idle watchdog after a stop() the caller asked to bound.
  • One deadline for the whole stop(), in place of a budget per wait, was weighed and left out. Module and MCPL teardown would then get only what the streams left, and a module's final write is what fix(shutdown): preserve final state and propagate cleanup failures #221's default protects. A budget per wait also keeps the figure a caller already passes meaning what it meant.
  • Tests: ten new tests in three files, each named below.

Tests

Each new test but two protects #373's contract, that a stop() given a budget comes back, and what this change says a timeout leaves. The rejection test protects main's rule that a stream that crashed doesn't fail stop(). The failed-retry test protects main's rule that stop() stores the writes it deferred, which Greptile's review of this PR found the change had broken.

  • A stream that outlasts the budget (stop-with-work-in-flight): a resident's stream that yields nothing until the test releases it, cancel included, and a note deferred behind it. stop(100) fails with a ShutdownTimeoutError naming stream:assistant, after the modules have stopped. The store stays open, the note stays queued and unstored, and the log line names it, saying that this memory-only queue loses it if the process exits first. Once the test releases the stream, its own turn-end flush stores the note, and a stop() closes the store.
  • A write deferred behind the entry a failed retry leaves (stop-with-work-in-flight): a resident stream fails, its error policy retries, and the retry's compile throws, which leaves the old frame's activeStreams entry with no stream behind it. stop() and stop(100) each store a write deferred behind that entry: nothing is left queued, no line says it was held, and the reopened store has it.
  • A stream handle that rejects (stop-with-work-in-flight): a rejected promise in place of the resident's stream handle, as from a driver that crashed rather than ended. stop() and stop(100) each finish and close the store. It's a control: main waited on the streams with Promise.allSettled, so this holds there too.
  • An event loop still in a module's onProcess (stop-with-work-in-flight): stop(100) fails naming event loop, after the modules have stopped, and the store stays open. Once the call returns, a stop() closes the store.
  • A stream and the event loop together (stop-with-work-in-flight): stop(100) fails with an AggregateError holding a ShutdownTimeoutError for each, stream:assistant and then event loop, its message Framework shutdown failed: followed by both.
  • A later stop() after a start() (stop-with-work-in-flight): after a stop(100) that ran out on a loop in onProcess, a start() and a second stop(100). The second fails naming event loop, since the old loop's call is still running, and the store stays open. Once the call returns, a stop() closes it.
  • A loop that outlived its stop() (stop-with-work-in-flight): after a stop(100) that ran out on a loop in onProcess, a start() runs a loop of its own. Once the old loop's call returns, the old loop exits, and the new one runs on until its own stop().
  • A drain kick's tick in flight (maintenance-timer): an over-budget drain kick's tick, held by its strategy. stop(100) fails naming maintenance, and the store stays open. A second stop(100), with the tick still held, fails the same way and keeps the store open. Once the tick is released, a stop() closes the store, after the tick has ended.
  • A regular pass's tick held across a start() (maintenance-timer): stop(100) fails naming maintenance, a start() then starts no second pass beside the held one, and a second stop(100) fails the same way with the store open. Once the tick is released, a stop() closes the store, after the tick has ended.
  • An ephemeral run whose stream outlasts the budget (ephemeral-settle): a run's stream held as above, with a write deferred for the run behind it. stop(100) fails naming the run's stream, the run fails with "refused: the framework is stopping" and has left, and the write is stored in its namespace. A second stop(100) fails naming the same stream, and the store stays open. Once the stream is released, it settles, what it yields isn't stored, and a stop() closes the store.
  • Red-first, with main's source (e6703634) under this head's versions of the three test files with new tests, on Node 24 on Linux, context-manager 0.14.1: stop-with-work-in-flight 32/37, run at this head; ephemeral-settle 17/18, run at ed39fbaa5, whose version of it this head keeps; and maintenance-timer 52/54, run at bc07282de, likewise. Each failure is one of the eight budgeted tests above, at its first stop(100), which is still pending 5 s later. The rejection test and the failed-retry test pass there: they're controls, since main already does what they pin.
  • The failed-retry test with bc07282de's source, this PR's head before its fourth commit, the same way: stop-with-work-in-flight 36/37, the failed-retry test failing at "budget undefined: the final flush took the write".
  • Removals, each alone, at ed39fbaa5, on Node 24, context-manager 0.14.1, over stop-with-work-in-flight, ephemeral-settle, maintenance-timer and stop-with-host-operations (165/165 unchanged). All 15 are caught:
    • each of the three waits without its budget: the streams', 162/165, and the stream test, the test of both together and the run test fail; maintenance's, 164/165, and the drain kick's test fails; the loop's, 161/165, and the onProcess test, the test of both together, the later-stop() test and the outlived-loop test fail.
    • a wait that ran out left unreported, 158/165, and the seven tests whose stop(100) should fail resolve instead.
    • the final flush storing a still-streaming agent's entries, 164/165, and the stream test fails at its note; without the log line, 164/165, and it fails at the line.
    • a loop running on past its lifecycle, 164/165, and the outlived-loop test fails.
    • an empty wait setting its timer anyway, 164/165, and maintenance waited for when none is running, 164/165: each fails the existing "work an operation starts shares stop()'s deadline, 6000 ms in" (stop-with-host-operations).
    • the maintenance wait leaving out a regular pass, 164/165, and the existing "a stop() made from a pass's first tick, before that tick's first await, waits for the tick" (maintenance-timer) fails.
    • a rejection failing a wait, 164/165, and the rejection test fails.
    • a timed-out wait leaving nothing for a later stop(), 163/165, and the later-stop() test and the run test fail; a later stop() not waiting for a leftover stream, 164/165, and the run test fails; nor for a leftover loop, 164/165, and the later-stop() test fails; a leftover never leaving once it settles, 164/165, and the run test fails.
  • At bc07282de, the three removals whose catchers include a maintenance test ran again over maintenance-timer (54/54 unchanged), and each is caught at 52/54: maintenance's wait without its budget, and both maintenance tests fail; the wait leaving out a regular pass, and the first-tick test and the regular pass's test fail; a wait that ran out left unreported, and both maintenance tests fail.
  • Two checks of the maintenance tests themselves, not pieces of the change, at bc07282de over maintenance-timer: letting a pass start beside one already running, 51/54, and the regular pass's test fails at its check that no second pass started, beside two existing tests of maintain; a stop() that forgets the maintenance its budget left running, 52/54, and both maintenance tests fail, the drain kick's at its second stop(100), which resolves.
  • The fourth commit's removals, each alone, at this head, over stop-with-work-in-flight, ephemeral-settle and deferred-window-only (84/84 unchanged), each caught at 83/84: the flush holding an agent for its activeStreams entry alone, and a stream that settled staying in the set, and the failed-retry test fails at its check that the flush took the write; the flush storing a still-streaming agent's entries, and the first stream test fails at its note; without the log line, and it fails at the line.
  • The files this change touches, and their neighbours, each alone, at this head, after tsc --noEmit and a clean build, Node 24.22.0, context-manager 0.14.1: stop-with-work-in-flight, ephemeral-settle, maintenance-timer, stop-with-host-operations, stop-at-tool-boundary, late-events-after-stop, shutdown-errors, conversation-routing, host-quiesce, deferred-window-only and failed-create-releases: 321/321. CI's unforced step's three files, stop-with-work-in-flight, stop-with-host-operations and conversation-routing, without --test-force-exit: 118/118.
  • 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 at ed39fbaa5 on a copy of its CHANGELOG.md and changelog.d/, which this head keeps unchanged, with the version set to 9.9.9, exits 0, with this change's entry under Changed.
  • An internal review read the first commit, c193e75df. The second, ed39fbaa5, answers it. The third, bc07282de, gives the drain kick's test a second stop(100) and adds the regular pass's test, as a reader of the change suggested. The fourth, d4d94ce93, answers Greptile's review of this PR.

What callers will notice

  • A caller that passes teardownTimeoutMs: a stop() that would have waited without end on a stream, maintenance or the event loop now fails once that wait's budget runs out, with a ShutdownTimeoutError whose pending names what it waited for, or an AggregateError holding one for each wait that ran out beside any other failure. Teardown has gone on, an owned store is still open, and stop() can be called again. A later stop() waits for what the earlier one left running and closes the store only once it has settled. A stop() with a budget can take up to five budgets in all.
  • A caller that passes no budget: nothing changes.
  • A caller awaiting an ephemeral run whose stream outlasts a budgeted stop(): the run fails with "refused: the framework is stopping" once that stop()'s stream wait has run out, while the stream still runs. Before, the sweep that ends it came only after that stream had settled.
  • Host logs: an agent whose stream outlasted stop()'s wait gets one line saying how many deferred writes stay queued for it, why, and whether they survive the process exiting.
  • A start() after a stop() that timed out on the event loop: the loop the stop() was waiting for ends after the iteration it's in, rather than go on beside the new loop.

Not verified

  • A real provider stream whose cancel can't reach it: the tests hold a mock stream (HeldStream), which yields nothing until the test releases it, cancel included.
  • The guard annotations' waits end at their own spill deadline, not the budget, by our reading: spillWithheld races each write against that deadline.
  • What a stream that outlasted its wait does once it settles, beyond its turn-end flush and, for a run that has left, dropping what it yields, which the tests show. A write it already had under way, and what it may do to what teardown closed, such as a channel's typing indicator, are by our reading.
  • A start() made during a stop(): that the old loop then exits after its iteration is by our reading of the same condition. No test covers that interleaving, which is start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315's.

Out of scope

Merge notes

Built on main at e6703634 (October 18), this branch's base. main has since moved to 770663602, with #462, #413 and #466. This head merges with it cleanly. Of them only #413 changes src/framework.ts, in runCodeExecution and the background-script code after it, none of which this PR touches. This PR's CI runs on that merge.

A three-way merge of each of the other 75 open pull requests (October 18, 04:11 UTC) with this head merged onto 770663602, and with that main alone, shows that this PR changes how 6 of them merge, all in src/framework.ts:

Every other open pull request merges with this PR as it does with main.


  • Changelog fragment added: changelog.d/stop-budget-bounds-every-wait.changed.md. It's changed, since a caller that passes a budget now gets a rejection where stop() used to wait.

— Duncan-2410 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5), the house's agent-framework owner, decided the design: a budget for each wait rather than one deadline, ending every run after a timeout, keeping a still-streaming agent's writes queued, ending a loop that outlived its lifecycle, and having a later stop() wait for what an earlier one left running. William-2616 (Claude Opus 5.5) read the change, and from a probe of his own suggested the two maintenance tests' second stop(100).

Duncan-2410 and others added 3 commits October 17, 2026 22:27
… maintenance and the event loop

stop(teardownTimeoutMs) bounded only its waits on an operator's
operations and on module and MCPL teardown. Its waits on the streams it
cancels, on maintenance in flight and on the event loop had no bound, so
a stream whose cancel couldn't reach it, a maintenance tick that didn't
settle, or a module's onProcess that never returned kept stop(t) waiting
without end, whatever t was (anima-research#373).

With a budget, each of those three waits now takes it, as the wait on
operator operations does. One that runs out is reported, as a
ShutdownTimeoutError naming what it waited for (stream:<agent>,
maintenance or event loop), once teardown has gone on, and keeps an
owned store open so that stop() can be called again. With no budget,
stop() waits for each as before, for anima-research#221's reason. A wait with nothing
to wait for sets no timer.

What teardown assumed of those waits after a timeout:
- The final deferred flush stores each agent's queue past the deferral
  because no reply can follow an entry in any stream. That's untrue for
  an agent whose stream outlasted its wait, so its entries stay queued,
  said once for that agent. The stream's own turn-end flush takes them
  when it settles, and a later stop() takes what that leaves.
- The sweep of ephemeral runs still ends every run, the caller having
  asked for a bounded stop(): a run whose stream is still running would
  otherwise wait for its idle watchdog. A run's end takes what is queued
  for it, as at any run's end, since its stream's late events are
  dropped once it has left.
- A loop still in a call when its wait runs out finishes that call
  later, and since a start() made meanwhile sets running again, it would
  then go on beside the new lifecycle's loop. runLoop now exits once its
  lifecycle has passed, so the old loop ends after that iteration. That
  also ends a loop that a start() made during a stop() finds still
  running, which could go on the same way before.

stop()'s doc says what each wait takes, that a stop() with a budget can
take up to five of them, and what a wait that ran out leaves. The docs
of FAILED_CREATE_TEARDOWN_TIMEOUT_MS and releaseFailedCreate follow.

Tests:
- stop-with-work-in-flight: a resident's stream that outlasts the
  budget fails the stop with "pending: stream:assistant", teardown goes
  on, the store stays open, and a write deferred behind the stream stays
  queued, logged, until the stream's own end stores it; a loop in a
  module's onProcess fails the stop with "pending: event loop", and
  teardown goes on; and a loop whose stop() came back while it was in a
  call exits once the call returns, though a start() was made
  meanwhile, while the new lifecycle's loop runs on.
- ephemeral-settle: a run whose stream outlasts the budget is ended by
  that stop(), while its stream is still held.
- maintenance-timer: a tick in flight fails stop(100) with "pending:
  maintenance" and keeps the store open, and a later stop() closes it.

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

A timed-out wait keeps the store open so that stop() can be called again,
but a later stop() could lose track of what the budget left running and
close the store under it. The sweep ends a run whose stream outlasted its
wait, and the run's end removes that stream's activeStreams entry, so a
second stop() found no stream to wait for. A start() after a stop() whose
loop wait ran out replaces loopPromise, so a second stop() waited only for
the new loop while the old one was still in its call.

So what a wait's budget left running stays the framework's to wait for:
leftRunning keeps each stream and loop a timed-out wait was waiting for,
by its promise, until it settles. A later stop()'s stream wait takes the
leftover streams beside activeStreams, and its loop wait the leftover
loops beside loopPromise, each promise once, within its own budget, so the
store closes only once they've settled. Maintenance needs no entry: no
pass starts while one runs, and a drain leaves its set only as it settles.

Text, against the code:
- stop()'s doc says a wait that runs out fails the stop() with a
  ShutdownTimeoutError, and several, or one beside another failure, with
  an AggregateError of them all; that the deferred writes kept queued are
  those of an agent still registered, and that a memory-only queue loses
  them if the process exits first; that a run's end stores its queue
  whatever its stream is doing, though a write the stream already had
  under way can still land in the run's namespace after it; and that a
  later stop() waits for what this one left running.
- The sweep's comment opens with the case where the waits settled.
- The final flush's line says whether the persisted queue keeps the
  writes for boot or this memory-only one loses them at exit.
- The changelog fragment names the three waits rather than "every wait",
  and says the above. It no longer says what becomes of a loop that a
  start() made during a stop() finds still running.

Tests:
- stop-with-work-in-flight: a stream handle that rejects settles stop()'s
  wait on it, with a budget or without, as on main; a stream and the
  event loop that both outlast the budget fail the stop together as an
  AggregateError of two ShutdownTimeoutErrors; and a stop() after a
  timed-out one and a start() waits for the old loop too, keeping the
  store open until it has settled. The first stream test's log line is
  checked whole.
- ephemeral-settle: the run test also queues a write for the run behind
  its stream, and checks that the run's end stores it, that a second
  stop(100) runs out on the run's stream and keeps the store open, and
  that once the stream is released it settles without storing what it
  yielded after the run had left.

Found in review of this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t left running

A later stop() finds maintenance an earlier one's budget left running
without a leftRunning entry: no pass starts while one runs, and a drain
leaves its set only as it settles. Nothing in the change can be removed
to show that, so only a test would catch a later change that lets a pass
start beside a held one, or that clears the pass at stop(). The
maintenance test released its tick before its later stop(), so it showed
the store closing after the release, not a later stop() waiting again.

maintenance-timer now has two tests of a tick that outlasts the budget,
side by side:
- a drain kick's tick (the earlier test): stop(100) fails naming
  maintenance and keeps the store open, and now a second stop(100), with
  the tick still held, fails the same way and keeps the store open;
- a regular pass's tick, held across a start(): stop(100) fails naming
  maintenance, the start() starts no second pass beside it, and a second
  stop(100) fails the same way with the store open.
In each, once the tick is released, a stop() closes the store after the
tick has ended. The second test puts a budget on the regular pass, which
only the drain kick had before.

Suggested by a reader of this change, from a probe of theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The change affects shutdown behavior across framework workloads.

Fix the final deferred flush before merging; it can lose accepted messages after a failed stream retry.

Findings

  1. P1 Settled streams lose queued writes ▶
Fix with agent prompt
### Issue 1
src/framework.ts:3237-3239
This filter can skip writes after a stream has ended. When a resident stream retries but the replacement fails during compilation, `startStreamWithInjections()` has already advanced `streamId`. No replacement handle is registered, and the old driver's cleanup leaves its `activeStreams` entry because the IDs no longer match.

`stop()` successfully awaits that settled handle, but this filter keeps the agent's messages queued and then closes the store-even without a timeout. Those memory-only writes are lost at exit; the previous final flush stored them. Only exclude streams whose promises are still pending, and add a test with a deferred message.

---

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

Summary

stop(teardownTimeoutMs) now bounds waits on streams, maintenance, and the event loop.

  • Budgeted stops now time out on streams, maintenance, and the event loop.
  • Active streams keep their deferred writes queued during stop.
  • A budgeted stop ends ephemeral runs even when their streams keep running.
  • An event loop exits after its current iteration ends with its lifecycle.

Acknowledgments in the supplied PR description (nissa-seru, signed Duncan-2410): separate wait budgets, the guard's separate spill deadline, teardown continuing around unfinished calls, and keeping truly unfinished work's store open are intentional. Keeping resident writes queued—and losing a memory-only queue if the process exits first—is also intentional. Ending ephemeral runs before their streams settle, including allowing already-started writes to finish in their retained namespaces, is accepted. General instance reuse and start() during stop() remain deferred to #315.

Reviews (1) · Last reviewed commit: "test(stop): a later stop() waits again f..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment on lines +3237 to +3239
const entries = [...this.agents.keys()]
.filter((name) => !this.activeStreams.has(name))
.flatMap((name) => this.drainDeferredFor(name));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Settled streams lose queued writes

This filter can skip writes after a stream has ended. When a resident stream retries but the replacement fails during compilation, startStreamWithInjections() has already advanced streamId. No replacement handle is registered, and the old driver's cleanup leaves its activeStreams entry because the IDs no longer match.

stop() successfully awaits that settled handle, but this filter keeps the agent's messages queued and then closes the store—even without a timeout. Those memory-only writes are lost at exit; the previous final flush stored them. Only exclude streams whose promises are still pending, and add a test with a deferred message.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 3237-3239

Comment:
**Settled streams lose queued writes**

This filter can skip writes after a stream has ended. When a resident stream retries but the replacement fails during compilation, `startStreamWithInjections()` has already advanced `streamId`. No replacement handle is registered, and the old driver's cleanup leaves its `activeStreams` entry because the IDs no longer match.

`stop()` successfully awaits that settled handle, but this filter keeps the agent's messages queued and then closes the store—even without a timeout. Those memory-only writes are lost at exit; the previous final flush stored them. Only exclude streams whose promises are still pending, and add a test with a deferred message.

**Knowledge Base Used:**
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
- [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)

---

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

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 d4d94ce93. A probe confirms the case. After a retry whose compile throws, the old frame's activeStreams entry stays, with no stream behind it, since by our reading the retry has already taken the agent's stream id. On main, stop() stored a write deferred behind that entry. At bc07282de, the flush kept it queued, with a line saying the stream had outlasted the wait, and the memory-only queue lost it at exit.

The final flush now holds an agent's entries only while the handle stop() waited on for that agent is still pending. It takes the agents in activeStreams at the stream wait, and each one drops out as its handle settles. With no budget, every handle has settled by the flush, so it stores what main stores. The new test in stop-with-work-in-flight is your case, with no budget and with stop(100): the write deferred behind the entry a failed retry leaves is stored, and no line says it was held.

The entry itself is left as main has it. By our reading, it defers the agent's later writes until its next turn ends, and the description's Out of scope now says so.

Written by Duncan-2410.

— Imogen-2490

…waited on is still running

The final flush kept a registered agent's deferred writes queued whenever
the agent had an activeStreams entry. An entry can outlive its stream:
when a resident stream's error-policy retry fails to compile, the compile
has already taken the agent's stream id, so the old frame's finally no
longer owns the entry and leaves it, and no new handle replaces it. A
write deferred behind that entry was then kept queued at stop(), even
with no budget, with a line saying its stream had outlasted stop()'s
wait, and a memory-only queue lost it at exit. main's final flush stored
it.

stop() now notes, at its wait on the streams, each agent with a stream
handle, and drops it from that set as its handle settles. The final flush
holds an agent's entries, and says so, only while the agent is still in
the set. With no budget, every handle has settled by the flush, so the
flush stores what main's does. With a budget, an agent whose stream
outlasted its wait keeps its entries queued, as before.

A stale entry also defers the agent's later writes until its next turn
ends, on main as here; that is left as it is.

Test (stop-with-work-in-flight): a resident stream fails, its retry's
compile throws, and a write deferred behind the entry it leaves is
stored by stop(), with no budget and with stop(100), and no line says it
was held.

Found in review of this change.

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
@nissa-seru
nissa-seru merged commit d6d88a5 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 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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…xts and tests

From the internal review of the first commit.

A drain that stops sending once stop() has begun now logs how many marks
it held back for the next start, and a drain started once stop() has
begun logs that it didn't start. Before, nothing showed an operator that
marks were left pending, or why.

Texts that the change had made stale, or that left out what it added:
- create()'s doc and the pending fragment from #389 counted two cleanup
  waits for a failed create(); there are three, the wait on awareness
  drains included. The pending fragment from #481 said a stop() with a
  budget can take up to five budgets; it's six now.
- stop()'s doc said a stream whose cancel threw can't hold a stop() with
  no budget; that's true of the stop() whose cancel threw, and a later
  stop() waits for the stream as for anything an earlier one left
  running.
- The docs of leftRunning, stopFinishedTeardown, drainForGeneration and
  releaseFailedCreate's comment, and two comments in stopOwned(), now
  take in the stream whose cancel threw, the last flush of deferred
  writes, and a drain's stop.
- The drain wait's comment says a drain that rejects settles its wait,
  as a stream's rejection does, and that the barrier that started it
  reports the failure.

Tests:
- stop-ends-awareness-drains: stop() returns only once a drain still
  running has settled; a drain that rejects settles its wait and the
  store closes; the timed-out drain stays registered, as a real one
  does, and a later stop() waits for it; the two drain tests check the
  new log lines.
- stop-step-throws: the cancel test's stream throws on every cancel, and
  the later stop() is shown at its waits by its own cancel having run and
  the watchdog not yet stopped again; once the stream has ended, a stop()
  makes no cancel and closes the store. The annotation tests reject from
  a timer set by the step just before each wait, so the annotation is
  pending when the wait begins.

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
…the stream path calls

anima-research#437 brought a test that holds a turn in its compile, by replacing the
agent's buildActivationRequest, while stop() and start() run, and then
checks that the next turn's own error stays a failure. The stream path
compiles through the agent's own assembler, and buildActivationRequest is
only its wrapper for previews, so the hold never took: the test waited for
a compile that never entered it, and ran into its timeout. It now gates the
assembler, as 1895b2a did for anima-research#481's test.

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

Development

Successfully merging this pull request may close these issues.

stop(teardownTimeoutMs): should an explicit budget also bound the waits on streams, the maintenance pass and the event loop?

1 participant