Repository navigation
fix(stop): an explicit teardownTimeoutMs bounds stop()'s waits on streams, maintenance and the event loop - #481
Conversation
… 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>
[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
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
Acknowledgments in the supplied PR description ( Reviews (1) · Last reviewed commit: "test(stop): a later stop() waits again f..." · Reviewed by Greptile |
| const entries = [...this.agents.keys()] | ||
| .filter((name) => !this.activeStreams.has(name)) | ||
| .flatMap((name) => this.drainDeferredFor(name)); |
There was a problem hiding this 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:
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.There was a problem hiding this comment.
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>
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>
…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>
…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>
!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 instopOwned: 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.onProcessthat never returns, so the event loop never finishes.#373 has a probe on
main3dc67b8showing 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 awaitsstop()on SIGTERM and SIGINT and exits only once it returns, so astop()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 takesteardownTimeoutMs, as the wait on operator operations does.stop()then fails with aShutdownTimeoutErrornaming what it waited for:stream:<agent>for each stream still running,maintenance, orevent loop. With more than one, or beside another failure, it fails with anAggregateErrorof them all, asstop()already did for several failures. An owned store stays open, andstop()can be called again, as after a timed-out wait on an operator's operations.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.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 later
stop()waits for what an earlier one's budget left running. A wait that runs out leaves its streams or its loop inleftRunning, by promise, until each settles. A laterstop()'s stream wait takes the leftover streams besideactiveStreams, and its loop wait the leftover loops besideloopPromise, 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'sactiveStreamsentry, and astart()replacesloopPromise. Maintenance needs no entry: no pass starts while one runs, and a drain leaves its set only as it settles, so a laterstop()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:
[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 laterstop()takes what that leaves. A stream counts as running while the handlestop()waited on for that agent hasn't settled. AnactiveStreamsentry 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).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 isdriveStream'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.onProcess, finishes that call when it returns, afterstop()has. Astart()made meanwhile setsrunningagain, so that loop would then go on taking events beside the new lifecycle's loop.runLoopnow exits once its lifecycle has passed, so it ends after that iteration, whose rest runs as before: the other modules'onProcessfor the event it was in, thenprocessInferenceRequests. By our reading, the same condition ends a loop that astart()made during astop()finds still running, which could go on the same way before. Whether an instance should be reused afterstop()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 laterstop()waits for it. The docs ofFAILED_CREATE_TEARDOWN_TIMEOUT_MSandreleaseFailedCreatefollow. A frameworkcreate()never started has no streams, loop or maintenance, so a failedcreate()still waits only on operator operations and on modules and MCPL servers.What each part is for
waitFor(one closure instopOwned, 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, asPromise.allSettleddid for the streams, and skips a wait with nothing in it. Three inlinetryblocks aroundwaitForShutdownwould repeat that three times.stream:<agent>,maintenance,event loop). An operator reading the error learns whatstop()was waiting for, in the form the other waits' labels already take (module:<name>,mcpl:<id>,surgery:<verb>).leftRunningandwithLeftovers(one map, and about ten lines). Without them, a secondstop()can close the store under a run's stream that the first one's sweep ended, or under the old loop once astart()has replacedloopPromise, 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 astop()that could finish.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 thatstop()sets, so it fails when an empty wait sets one.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 anactiveStreamsentry can outlive its stream, as above. Reading the entries alone kept such an agent's writes queued at everystop(), budget or not, and lost them wheremainstores them, as Greptile's review of this PR found.runLoop's lifecycle condition (one condition). Without it, a loop that outlived itsstop()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 astop()that finished never left a loop running before.stop()the caller asked to bound.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
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 protectsmain's rule that a stream that crashed doesn't failstop(). The failed-retry test protectsmain's rule thatstop()stores the writes it deferred, which Greptile's review of this PR found the change had broken.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 aShutdownTimeoutErrornamingstream: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 astop()closes the store.stop-with-work-in-flight): a resident stream fails, its error policy retries, and the retry's compile throws, which leaves the old frame'sactiveStreamsentry with no stream behind it.stop()andstop(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.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()andstop(100)each finish and close the store. It's a control:mainwaited on the streams withPromise.allSettled, so this holds there too.onProcess(stop-with-work-in-flight):stop(100)fails namingevent loop, after the modules have stopped, and the store stays open. Once the call returns, astop()closes the store.stop-with-work-in-flight):stop(100)fails with anAggregateErrorholding aShutdownTimeoutErrorfor each,stream:assistantand thenevent loop, its messageFramework shutdown failed:followed by both.stop()after astart()(stop-with-work-in-flight): after astop(100)that ran out on a loop inonProcess, astart()and a secondstop(100). The second fails namingevent loop, since the old loop's call is still running, and the store stays open. Once the call returns, astop()closes it.stop()(stop-with-work-in-flight): after astop(100)that ran out on a loop inonProcess, astart()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 ownstop().maintenance-timer): an over-budget drain kick's tick, held by its strategy.stop(100)fails namingmaintenance, and the store stays open. A secondstop(100), with the tick still held, fails the same way and keeps the store open. Once the tick is released, astop()closes the store, after the tick has ended.start()(maintenance-timer):stop(100)fails namingmaintenance, astart()then starts no second pass beside the held one, and a secondstop(100)fails the same way with the store open. Once the tick is released, astop()closes the store, after the tick has ended.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 secondstop(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 astop()closes the store.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-flight32/37, run at this head;ephemeral-settle17/18, run ated39fbaa5, whose version of it this head keeps; andmaintenance-timer52/54, run atbc07282de, likewise. Each failure is one of the eight budgeted tests above, at its firststop(100), which is still pending 5 s later. The rejection test and the failed-retry test pass there: they're controls, sincemainalready does what they pin.bc07282de's source, this PR's head before its fourth commit, the same way:stop-with-work-in-flight36/37, the failed-retry test failing at "budget undefined: the final flush took the write".ed39fbaa5, on Node 24, context-manager 0.14.1, overstop-with-work-in-flight,ephemeral-settle,maintenance-timerandstop-with-host-operations(165/165 unchanged). All 15 are caught:onProcesstest, the test of both together, the later-stop()test and the outlived-loop test fail.stop(100)should fail resolve instead.stop-with-host-operations).maintenance-timer) fails.stop(), 163/165, and the later-stop()test and the run test fail; a laterstop()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.bc07282de, the three removals whose catchers include a maintenance test ran again overmaintenance-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.bc07282deovermaintenance-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 ofmaintain; astop()that forgets the maintenance its budget left running, 52/54, and both maintenance tests fail, the drain kick's at its secondstop(100), which resolves.stop-with-work-in-flight,ephemeral-settleanddeferred-window-only(84/84 unchanged), each caught at 83/84: the flush holding an agent for itsactiveStreamsentry 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.tsc --noEmitand 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-onlyandfailed-create-releases: 321/321. CI's unforced step's three files,stop-with-work-in-flight,stop-with-host-operationsandconversation-routing, without--test-force-exit: 118/118.scripts/release-changelog.mjs, run ated39fbaa5on a copy of itsCHANGELOG.mdandchangelog.d/, which this head keeps unchanged, with the version set to 9.9.9, exits 0, with this change's entry under Changed.c193e75df. The second,ed39fbaa5, answers it. The third,bc07282de, gives the drain kick's test a secondstop(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
teardownTimeoutMs: astop()that would have waited without end on a stream, maintenance or the event loop now fails once that wait's budget runs out, with aShutdownTimeoutErrorwhosependingnames what it waited for, or anAggregateErrorholding one for each wait that ran out beside any other failure. Teardown has gone on, an owned store is still open, andstop()can be called again. A laterstop()waits for what the earlier one left running and closes the store only once it has settled. Astop()with a budget can take up to five budgets in all.stop(): the run fails with "refused: the framework is stopping" once thatstop()'s stream wait has run out, while the stream still runs. Before, the sweep that ends it came only after that stream had settled.stop()'s wait gets one line saying how many deferred writes stay queued for it, why, and whether they survive the process exiting.start()after astop()that timed out on the event loop: the loop thestop()was waiting for ends after the iteration it's in, rather than go on beside the new loop.Not verified
HeldStream), which yields nothing until the test releases it, cancel included.spillWithheldraces each write against that deadline.start()made during astop(): 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
stop()(start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315). This change makes a loop that outlived itsstop()exit, and leaves the rest of that question as it is.DEFAULT_SHUTDOWN_TIMEOUT_MSstays unbounded, for fix(shutdown): preserve final state and propagate cleanup failures #221's reason.activeStreamsentry does while the framework runs. A retry whose compile fails leaves the old frame's entry, as above. By our reading, the agent's later writes then defer behind it until its next turn ends, onmainas here. This PR changes only whatstop()does with them.Merge notes
Built on
mainate6703634(October 18), this branch's base.mainhas since moved to770663602, with #462, #413 and #466. This head merges with it cleanly. Of them only #413 changessrc/framework.ts, inrunCodeExecutionand 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 thatmainalone, shows that this PR changes how 6 of them merge, all insrc/framework.ts:maincleanly, and conflicts with this head in one block of comments, abovestopOwned's final deferred flush. fix(deferral): a deferred write with no target names its agent, boot numbers the restored queue 1..N, and a sharded body is injected joined (#286's follow-up) #461 rewrites that comment's sentences on an entry for a target no longer registered, and this PR adds a paragraph after them on a still-streaming agent. No code conflicts, and keeping both, fix(deferral): a deferred write with no target names its agent, boot numbers the restored queue 1..N, and a sharded body is injected joined (#286's follow-up) #461's sentences and then this PR's paragraph, gives a comment that says both. The two haven't been run together.main, and conflict differently with this head: feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 and feat(mcpl): every channel item names its conversation in a stored source header #257 intest/stop-with-work-in-flight.test.tstoo.Every other open pull request merges with this PR as it does with
main.changelog.d/stop-budget-bounds-every-wait.changed.md. It'schanged, since a caller that passes a budget now gets a rejection wherestop()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' secondstop(100).