Repository navigation
fix(stop): a stop reaches a turn that has no live stream - #529
Conversation
A turn can be alive with no live stream to carry a stop: while its modules' gatherContext, its MCPL hooks or its compile run, while it waits to retry a failed inference or a failed compile, and while the tool-result guard waits for the agent's speech before retrying a refused round, when it holds only the round's finished stream. Agent.cancelStream() had nothing to cancel there, or cancelled the finished stream; the framework's abortInference() returned false, or true while the retry went out anyway; and a quiesce's abandon left the turn to send its request or its retry while the host was quiesced. - Agent.cancelStream() keeps a stop made while the agent holds no stream (takeStopWithoutStream), beside the record a live stream's cancel goes to. - Each frame that holds such a turn ends it at a check of its own, before the turn sends anything more: a start after its hooks, after its compile, where its hooks or compile fail (before a provider hold, the error policy's retry or the turn's exhaustion), and after its wait to retry a failed compile; driveStream right after its wait to retry a failed inference; and the guard after its wait. Each ends the turn through the terminal the same stop gets on a live stream: settleDeliberateCancel for a caller's stop, settleFrameworkCancel for a quiesce's abandon, and after the guard's wait for stop()'s shutdown too. - checkAhead keeps, per agent, the window of the frame that holds such a turn: a start's, from its entry to its finally, and driveStream's wait to retry. Each frame closes only its own window, so a start that ends after its retry's stream has begun to wait to retry leaves that wait's window open. For a turn with no stream, abortInference() records the stop and returns true only while a window is open, and a quiesce's abandon marks only such a window; the frame's checks collect the mark, which goes with the window. A marked turn that hasn't reached its check when the grace runs out is listed as unabandonable while the host stays quiesced. - Each wait to retry leaves a waker on its window, which ends the whole wait. abortInference() wakes the wait of the window it records a stop for, and the abandon wakes the waits of the windows it marks, so the turn reaches the check after its wait at once. A stop through Agent.cancelStream() during a wait to retry ends the turn when the wait runs out, since Agent has no framework to wake it. - Every start drops a stop it finds at its entry. Such a stop was made where no check was ahead, as for an idle agent, a turn finishing its last delivery, a budget restart or tool results waiting to start the turn's next call, or a turn a provider hold has parked, or its turn ended without reaching a check. It ends nothing, and the agent's next turn runs. - A start's stop writes its inference-log row directly, with the request its compile made or a note that none was, since no stream ran. The [turn-interrupted] note says the turn was stopped before it sent anything, or, for a start that continues a turn, driveStream's wait to retry and the guard's wait, before it finished. - A stop at a failed start emits no inference:exhausted, the event from which the hard-down alert's failure streak, the over-budget drain and the poison-history breaker count failures, so none of them counts that failure. Tests: test/stop-without-stream.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change reaches stop handling across the framework’s active inference turns. The PR needs a fix before merging because a stop accepted during the failure trace can still send a retry. Findings
Fix with agent prompt### Issue 1
src/framework.ts:15154-15155
A stop made by an `onTrace` listener during `inference:failed` still gets lost. The driver has already read `takeCancel()`, but has not reset the agent. Calling `abortInference()` therefore returns `true` and saves the stop in `_pendingCancel`. This new check reads only `_stopWithoutStream`, so the retry starts, clears `_pendingCancel`, and sends another request despite the accepted stop.
Collect cancellations made during the failure trace before waiting or retrying, and cover this listener ordering in a test.
### Issue 2
src/framework.ts:13746-13748
When a compile fails after both `cancelStream()` and `stop()`, this branch skips the shutdown check used after a successful compile. It calls `endStoppedStart()` even though `providerAdmissionClosed` is already true. The trace and log get the caller's reason, and the agent gets a `[turn-interrupted]` note during shutdown.
Apply the existing shutdown and lifecycle checks before settling `stopAtFailure`, so failed and successful compiles report the same cause.
### Issue 3
src/framework.ts:14534-14536
During the guard's speech wait, a caller's `cancelStream()` removes the finished stream from the agent's state. A later quiesce abandon finds neither that stream nor a `checkAhead` window; `stop()` also skips the streamless agent. Neither records its reason, so this new check writes a caller stop and a `[turn-interrupted]` note instead of `quiesce_abandoned` or `shutdown`.
The turn still stops, but its recorded cause depends on call order. Track this guard wait independently of the stream left in the agent's state.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryExtends stops to turns running hooks or compilation, waiting to retry, or waiting for the tool-result guard's earlier speech.
Reviews (1) · Last reviewed commit: "fix(stop): a stop reaches a turn that ha..." · Reviewed by Greptile |
| const stop = this.agents.get(agent.name) === agent && agent.streamId === myStreamId | ||
| ? this.takeStartStop(agent, retryWindow) : undefined; |
There was a problem hiding this comment.
A stop made by an onTrace listener during inference:failed still gets lost. The driver has already read takeCancel(), but has not reset the agent. Calling abortInference() therefore returns true and saves the stop in _pendingCancel. This new check reads only _stopWithoutStream, so the retry starts, clears _pendingCancel, and sends another request despite the accepted stop.
Collect cancellations made during the failure trace before waiting or retrying, and cover this listener ordering in a test.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 15154-15155
Comment:
**Accepted stop still retries**
A stop made by an `onTrace` listener during `inference:failed` still gets lost. The driver has already read `takeCancel()`, but has not reset the agent. Calling `abortInference()` therefore returns `true` and saves the stop in `_pendingCancel`. This new check reads only `_stopWithoutStream`, so the retry starts, clears `_pendingCancel`, and sends another request despite the accepted stop.
Collect cancellations made during the failure trace before waiting or retrying, and cover this listener ordering in a test.
---
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 41843c1. You're right that the stop was taken during the report and then lost. Rather than collect it after the report, the agent now stops holding a stream that failed before its inference:failed goes out. A stream that threw gets the same treatment, and the hold for a provider's wait after a compile now cancels the compiled stream and closes the start's window before it reports. During those reports abortInference() finds no stream and no check ahead, so it returns false instead of taking a stop that nothing after the report reads. A cancelStream() made during the report is kept as a stop with no stream, and the check after the wait to retry ends the turn with it, before the retry is sent. c11e9e7 closes a start's window when it hands the turn to the stream, so a stream that fails at once is reported with no window open.
A failure to build the request is reported while the start's own check is still ahead. There abortInference() returns true and that check ends the turn, as at e9fddb8. f3bc77e added a test of it.
Tests in test/stop-without-stream.test.ts, each failing on e9fddb8's source:
abortInference() from a listener of a failed inference's inference:failed reports false: the agent holds no stream and no check is ahead yetcancelStream() from a listener of a failed inference's inference:failed ends the turn at the check after the wait to retry, before the retry is sentabortInference() from a listener of the inference:failed of a stream that threw reports false, and the turn ends as exhausted
The test f3bc77e added, abortInference() from a listener of a failed compile's inference:failed reports the stop, which ends the turn there, before the retry, passes on e9fddb8's source too.
In test/provider-waits.test.ts, a stop made while that hold is reported finds nothing to stop: abortInference() from a listener of its inference:failed reports false fails on main's source.
Written by Milo-2653 (Claude Opus 5.5).
— Daniel-2612
| const stopAtFailure = this.takeStartStop(agent, checkWindow); | ||
| if (stopAtFailure) { | ||
| this.endStoppedStart(agent, stopAtFailure, routing, turnStartedAt, undefined, opensTurn); |
There was a problem hiding this comment.
Failed compile mislabels shutdown
When a compile fails after both cancelStream() and stop(), this branch skips the shutdown check used after a successful compile. It calls endStoppedStart() even though providerAdmissionClosed is already true. The trace and log get the caller's reason, and the agent gets a [turn-interrupted] note during shutdown.
Apply the existing shutdown and lifecycle checks before settling stopAtFailure, so failed and successful compiles report the same cause.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 13746-13748
Comment:
**Failed compile mislabels shutdown**
When a compile fails after both `cancelStream()` and `stop()`, this branch skips the shutdown check used after a successful compile. It calls `endStoppedStart()` even though `providerAdmissionClosed` is already true. The trace and log get the caller's reason, and the agent gets a `[turn-interrupted]` note during shutdown.
Apply the existing shutdown and lifecycle checks before settling `stopAtFailure`, so failed and successful compiles report the same cause.
---
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 41843c1, with the reason's wording in f3bc77e and the lifecycle check in 5b663e5. When a compile fails after a stop reached the turn, the check at the failure now gives way to the same three cases the check after a successful compile drops first: a replaced agent, a stopping framework, and a framework stopped and started again. Each drops the turn before it sends anything. The inference:aborted reads a turn whose request couldn't be built while the framework was stopping is dropped before it sends anything, or ends while the agent was replaced or removed or while the framework was stopped and started again. No [turn-interrupted] note is written, and no inference:exhausted is emitted.
bb418ed applies the same lifecycle test at the check after the hooks and after a start's wait to retry a failed compile, where it hadn't been: a stop made during the hooks of a turn whose framework was stopped and started again no longer ends it as the caller's stop in the next lifecycle, and a retry that waited across the restart is dropped rather than compiled and sent in the new lifecycle.
Tests in test/stop-without-stream.test.ts, each failing on e9fddb8's source: a stop during a compile that then fails while stop() runs drops the turn as stop()'s, before it sends anything, with no note, and the same for after its agent was replaced and after the framework was stopped and started again. Each also checks that the drop closes the turn at the event gate. For bb418ed: the two variants of a turn whose framework is stopped and started again during its hooks is dropped before its compile and of a start waiting to retry a failed compile across a stop() and a start() is dropped before it sends anything, which fail on e9fddb8's source too.
Written by Milo-2653 (Claude Opus 5.5).
— Daniel-2612
| const frameworkStop = this.frameworkCancelledStreams.get(`${agent.name}:${myStreamId}`); | ||
| const callerStop = agent.takeCancel(); | ||
| if (frameworkStop === 'shutdown' || frameworkStop === 'quiesce_abandoned') { |
There was a problem hiding this comment.
Guard loses framework stop reason
During the guard's speech wait, a caller's cancelStream() removes the finished stream from the agent's state. A later quiesce abandon finds neither that stream nor a checkAhead window; stop() also skips the streamless agent. Neither records its reason, so this new check writes a caller stop and a [turn-interrupted] note instead of quiesce_abandoned or shutdown.
The turn still stops, but its recorded cause depends on call order. Track this guard wait independently of the stream left in the agent's state.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 14534-14536
Comment:
**Guard loses framework stop reason**
During the guard's speech wait, a caller's `cancelStream()` removes the finished stream from the agent's state. A later quiesce abandon finds neither that stream nor a `checkAhead` window; `stop()` also skips the streamless agent. Neither records its reason, so this new check writes a caller stop and a `[turn-interrupted]` note instead of `quiesce_abandoned` or `shutdown`.
The turn still stops, but its recorded cause depends on call order. Track this guard wait independently of the stream left in the agent's state.
---
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 04e274d, with the order between stop() and an abandon settled in f3bc77e. The guard's wait for the agent's speech now opens a window of its own, beside a start's and the wait to retry. A quiesce abandon marks that window whether or not the agent still holds the round's finished stream. The check after the wait reads the framework's stop ahead of a caller's: stop() first, by its record on the stream or by having closed admission, then the abandon, by its record or its mark. The recorded cause no longer depends on call order. A caller's stop followed by an abandon ends as quiesce_abandoned, followed by stop() as shutdown, and with both an abandon and stop() as shutdown, whether the caller's stop came before the abandon or after it.
Tests in test/stop-without-stream.test.ts, each failing on e9fddb8's source:
a caller's cancelStream() and then a quiesce's abandon end the turn as the abandona caller's cancelStream() and then stop() end the turn as a shutdowna caller's cancelStream(), an abandon and then stop() end the turn as a shutdownan abandon, a caller's cancelStream() and then stop() end the turn as a shutdown
Written by Milo-2653 (Claude Opus 5.5).
— Daniel-2612
…p(), and the guard's wait as a window Answers the review of anima-research#529. - A caller's stop made while a failed inference's inference:failed is delivered (a listener's abortInference(), while the agent still holds the failed stream) went to the stream's record after that stream's terminal had been read, so the retry was sent although abortInference() had returned true. driveStream now collects that record right after the failure is reported and ends the turn as the stop, before a hold, the stated wait or the error policy. - At a failed start, the check for a stop made during the hooks or the compile now comes after a replaced agent and a stopping framework, as after the hooks and after the compile: a compile that fails while stop() runs ends as it did without the stop, with no caller's abort and no [turn-interrupted] note. - The tool-result guard's wait for the agent's speech opens a window of its own (checkAhead). Once a caller's cancelStream() has taken the round's finished stream, a quiesce's abandon reaches the turn through that window, and the guard's check reads the abandon's mark, or stop() having closed admission, as the framework's stop, ahead of the caller's, so the recorded cause no longer depends on which came first. Tests: a listener's abortInference() on inference:failed ends the turn before the policy is asked; a stop and stop() during a compile that then fails leave the turn to stop(); a caller's stop then an abandon, and then stop(), in the guard's wait, end as the abandon and as a shutdown; the guard tests assert no check is left ahead once the turn has ended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op() at a failed start drops the turn Answers the internal review of the previous commit, and replaces its collection of a stop made while inference:failed is delivered. - A stream that failed, or threw, no longer holds the agent while its inference:failed is delivered: driveStream resets the agent before the report, at the error event and in its catch, and the post-compile hold for a provider's wait cancels the compiled stream and closes the start's window before it reports. A listener's abortInference() then finds no stream and no check ahead and reports false, rather than taking a stop that the hold, the retry or the exhaustion after the report would lose. A cancelStream() made then is a stop with no stream: the check after a wait to retry ends the turn with it, and the next start drops it otherwise. The previous commit's collection of the stream's record after the report goes. - At a failed start, a stop kept for the turn while its agent was replaced or the framework is stopping drops the turn as the checks after the hooks and the compile do, before it sends anything and with no note, rather than leaving the turn to a hold, a retry or inference:exhausted with the stop lost. A failed start with no stop goes on as before. - The abandon's stream branch also marks the frame's window, so the tool-result guard's wait, which holds the round's finished stream, is listed as unabandonable if it hasn't reached its check when the grace runs out, as when a caller's stop had taken the stream. - The guard's check reads a stream record only when it is a shutdown or an abandon, and stop() having closed admission ahead of the abandon's mark, as stop()'s record overwrites an abandon's on a stream. Tests: a listener's abortInference() on a failed inference's, and on a thrown stream's, inference:failed reports false, and a listener's cancelStream() ends the turn at the check after the wait to retry; a conversation agent's post-compile hold reports with nothing to stop; a stop at a compile that fails while stop() runs, or after its agent was replaced, drops the turn; a caller's stop, an abandon and stop() in the guard's wait end it as a shutdown; an abandon of the guard's wait still holding its stream lists it past the grace; the check that another agent's wait stays asleep outlasts a timer tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…am; two tests the change had made stale - A start's window now closes as the start hands its turn to driveStream, where its checks are behind it, rather than in its finally. A stream that fails at once is reported before the start's finally runs, and the open window made abortInference() from a listener of that report take a stop that the exhaustion after it then lost. The window's doc already said it ends when the start hands its turn to a stream. - provider-waits' test of a retry wait's timer steps calls waitForRetry() directly; it now passes the window that the wait's waker is set on, which waitForRetry() has taken since abortInference() and the abandon began to end the wait they mark. - deferred-write-failures' tests of the quiesce refusal over the cap expected its earlier wording of the abandon's answer, which this change rewrote to say a turn the abandon hasn't ended within its grace. CI on anima-research#529 failed on both tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r they came in; name what a failed start's drop and report are Answers the internal review of the previous two commits. - The tool-result guard's check after its wait read the stream's record before stop() having closed admission. An abandon records itself on the round's finished stream, which the agent still holds; a caller's stop then takes the stream, so stop() finds none to record on, and the turn ended as the abandon, where with the caller's stop first it ended as stop()'s. The check now reads stop() first, by its record or by closed admission, and the abandon after it, by its record or its mark, so where a caller's stop fell between them changes nothing. - The drop of a stopped failed start says "a turn whose request couldn't be built while ...": the hooks fail open and never reach the start's catch, which takes a failure to build the request, in practice the compile's. The comment says why the catch never holds a compiled request. - README and the changelog fragment: abortInference() reports false while a failure of a turn's stream is reported; a failure to build a turn's request is reported while the turn's check is still ahead, so a stop made then is reported taken and ends the turn there. "Hooks or compile fail" becomes "compile fails" where a stop came first. The unabandonable listing's exception names a stream cancelled while still streaming. Tests: an abandon, a caller's stop and then stop() in the guard's wait end the turn as a shutdown; abortInference() from a listener of a failed compile's inference:failed reports the stop, which ends the turn before the retry; the stopped failed start's drop closes the turn at the event gate; the check that another agent's wait wasn't woken reads that its window is still open one timer turn after the abort, instead of waiting 50 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stop() disposes the event gate, which clears it, so the stopping variant's check after stop() passed with the drop's gate release removed. Both variants now read the gate in the listener of the drop's inference:aborted, which comes right after the release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt() too, as after the compile The check after a compile drops a turn whose framework was stopped and started again while it compiled; the drop at a failed start, which takes the replaced agent and the stopping framework from the same checks, now takes this case too, with "a turn whose request couldn't be built while the framework was stopped and started again is dropped before it sends anything", rather than ending the earlier lifecycle's turn as the caller's stop, with a note, in the next one. Test: the stopping and replaced variants gain a third, which advances the lifecycle as a stop() and a start() do while the compile runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e check after its hooks and after its wait to retry The check after the compile drops a turn whose framework was stopped and started again while it compiled; "a stop followed by start must not revive this earlier turn". Two other checks of a start didn't: - After the hooks, the drop tested only a replaced agent and a stopping framework, and this change's stop check comes right after it, so a stop made during the hooks of such a turn ended it as the caller's stop and wrote the [turn-interrupted] note into the next lifecycle. The drop now tests the lifecycle too, ahead of the stop, which goes with the turn. A turn with no stop is now dropped before its compile instead of after it. - After a start's wait to retry a failed compile, the drop tested only closed admission. A compile that failed after a stop() and a start() waited in the new lifecycle and then compiled and sent its retry there, or, with a stop made during the wait, ended as the caller's stop with the note. The drop now tests the lifecycle too, with a reason of its own: "a retry waiting while the framework was stopped and started again is dropped before it sends anything". Tests: a turn whose framework is stopped and started again during its hooks is dropped before its compile, with and without a stop made during them; a start waiting to retry a failed compile across them is dropped before it sends anything, with and without a stop made during the wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in driveStream's error case: main now sets the stream's lifecycle phase to `failed` before reporting inference:failed (anima-research#537), and this branch resets the agent there before the report. Both stay, the phase first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… inference failed With main's lifecycle phases (anima-research#537), a stream that fails ends `failed`, and a stop() that cancels its retry leaves that failure standing. The check after driveStream's wait to retry set the phase to `aborted` when a caller's stop or an abandon ended the turn there. It now leaves the phase as the failure set it, and the turn still ends as the stop. Test: a stream that fails, whose retry a caller's stop cancels during its wait, ends failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s it failed The fragment's host-facing lines now say what an MCPL server granted inferenceLifecycle sees when a caller's stop or an abandon cancels a failed inference's retry: the inference ends `failed`, as when stop() cancels it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0c262c1 to
c564c42
Compare
… it failed too The stop check after driveStream's wait to retry ends a caller's stop through one terminal and an abandon through another; the phase test covered only the first. It now covers an operator's quiesce abandon during the wait as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and anima-research#372 main aae86b7 brings anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, anima-research#539, anima-research#372 and anima-research#210. One conflict, a comment in src/framework.ts's utils run: anima-research#372 rewrote its first lines (the registry checks the call against the utility resolved here; the model's path takes the result's stored form), and this branch added a paragraph on where the result goes, before the resultMaxChars line. The resolution keeps anima-research#372's lines, then this branch's paragraph and line. The code below it merged as git made it: the utility call carries resultMaxChars, and the registry takes anima-research#372's { definition: def }. anima-research#538 landed first and meets this branch in a test without a textual conflict: handleScriptToolCall now returns a ScriptToolResult, { text, isError }, where it returned the string. The script case of tool-call-result-max-chars.test.ts reads .text, and checks isError is false, as anima-research#538's own tests read it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld writes for modules and the modern MCP client meet the registry What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose drafts and the `drafts` tool (#247); a module reads, edits and removes its held writes (#526); the deferred-writes follow-up (#461: a targetless write on a host with no primary, boot renumbering, sharded injection); the modern MCP client (#262) and the inbound source envelope; a stop that reaches a turn with no live stream (#529), every cleanup (#517), an MCP connect in flight, and a start during stop() (#532); allowedTools enforced before every dispatch (#220, breaking for hosts); repeated agent names refused at create() (#523, breaking); the fork-name collision fix (#513); the tune-out coordinator's timer-failure reporting and new refusal words (#506); request-only rows (silent ticks and [Continue] replies) rendered before their response; `callerTurnId` on tool calls. Conflicts (36 hunks, 7 files), and what was decided: - src/framework.ts (23): imports and the registry fields are unions (main's `releasesAfterResume` and `conversationSpawns` kept; the shared-slot set and the home-channel map stay answered by the registry, the one new use in the drafts resend reads `registry.homeChannel`). `isAddressedMessage` stays tag-only; main's call sites pass tags alone. Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as main types it) plus the branch's `addressedElsewhere`. The subconscious setup keeps the reader-forks branch; the coordinator hooks keep `announce`. `getAllTools` offers main's `drafts` beside `tune_out` and `focus`; an agent's tool block is its surface owner's, with the reader tools, filtered by main's `utils` rule. The deferred-writes restore is main's (refuses to start on a queue it can't read; `heldAt`; numbered 1..N) with the branch's re-wake for a restored tune-out release. The channel-incoming handler stamps main's inbound source envelope, then presents the author and admits. The ephemeral run keeps both watchdogs (the branch's deadline, main's stalled-batch abandon). Dispatch sets `callerTurnId` then applies the branch's deny-at-dispatch. `endEphemeralRunRefused` is gone: main's #532 refuses the start itself. `tune_out`/`focus` still dispatch through the queue (main's change there was isError). - src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and `thrownReason`; `enter` still allows no reader (silent hold) and takes main's words for a bound or already-tuned-out channel; the coalesced wake timer wraps the branch's `fireWake` in main's `timerFailed`; `cancel_tuneout` keeps the server-qualified lookup and the all-except path, with main's `notTunedOut` words; `speak_in_channel` has main's words and the branch's caller fallback. - src/agent.ts (2): main's `requestSettings()` plus `assistantParticipant`; `presentOwnTurns` before main's request-only rows, which render under `presentAs`. - src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its transport confirmation; the locus is admission's. `onRouteFailure` is main's type (with `outcome`). - src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions. Outside the hunks, where main's tests met the branch's rules: - main's #506 rule (a refusal is an error result) is applied to `tune_out` and `focus`, whose refusals were `isError: false`; an unknown channel is refused in main's words (`unknown channel: <id>`). - a tune-out with no reader is a silent hold only where the host offers `tune_out` without one: the coordinator gains `holdWithoutReader`, the framework passes `tuneOutOffered`, and main's tune-out-messages test holds as written; the branch's §3.3 row opts its host in. - main's tests that reached `conversationAgentHomes` use the registry (`bindAsConversationFork`, a registry stub); the ended-agents log is registered through `ensureStateRegistered`, as main's registration test requires; main's content-parity test counts the placeholder to the end of its text field, since the channel lane stores it under its author; main's route-failure rows that route by the registry's default locus run under `speech.locus: 'latest-inbound'`, the rule they assume; the branch's present-while-acting routing literal gains main's `turnId`, `drafts` and `keptPrivate`. - main's new `@modelcontextprotocol` dependencies installed (rig relinked after), and the merge fragment extended. Composed rig (the branch chronicle at 03b7bc1 and context-manager at bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests, 4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row, the Python eager-task-factory row, and two deferred-window-only rows that context-manager 0.15 makes moot. Typecheck clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back (anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f. - changelog.d/403-truncation-image-slots.fixed.md: main deleted it when 0.22.3 took it into the changelog, where this branch had changed one clause of it, to say a spill file goes only to a writable mount without an include. Taken as deleted: 0.22.3's entry stays as released, and this change's own fragment (mount-include.added.md) already says where the framework keeps its files and that a setup whose only writable mount has an include is treated as having none. git merged the rest on its own. Against main, every file the merge changes differs exactly as this branch did against 8d7694f, so no change of main's is undone. Of the files both sides changed, src/framework.ts, src/types/framework.ts and src/types/trace.ts take this branch's spill texts beside main's changes elsewhere in them, and test/workspace-three-way.test.ts takes the nested-directory tests beside anima-research#528's re-attach tests. No use of firstWritableMountName or text about a writable workspace was added on main since 8d7694f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has taken anima-research#234, anima-research#528, anima-research#529, anima-research#524, anima-research#538, the 0.22.3 merge-back (anima-research#539), anima-research#372, anima-research#210 and anima-research#541 since 8d7694f. - changelog.d/403-truncation-image-slots.fixed.md: main deleted it when 0.22.3 took it into the changelog, where this branch had changed one clause of it, to say a spill file goes only to a writable mount without an include. Taken as deleted: 0.22.3's entry stays as released, and this change's own fragment (mount-include.added.md) already says where the framework keeps its files and that a setup whose only writable mount has an include is treated as having none. git merged the rest on its own. Against main, every file the merge changes differs exactly as this branch did against 8d7694f, so no change of main's is undone. Of the files both sides changed, src/framework.ts, src/types/framework.ts and src/types/trace.ts take this branch's spill texts beside main's changes elsewhere in them, and test/workspace-three-way.test.ts takes the nested-directory tests beside anima-research#528's re-attach tests. No use of firstWritableMountName or text about a writable workspace was added on main since 8d7694f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…his branch's content arrays main moved from ce1a073 by 82 commits (anima-research#538, anima-research#539's 0.22.3 merge-back, anima-research#524, anima-research#529, anima-research#528, anima-research#372, anima-research#210 and anima-research#541). Two conflicts: - src/framework.ts, the script path's text result: anima-research#538 returns `{ text, isError: false }` where this branch passes the result's `dataIsContent` to toolResultDataToHistoryString. Both are kept. - test/script-structured-references.test.ts, its imports: anima-research#538's `ScriptToolResult` beside this branch's `referenceRegistry`. Read beside the conflicts, as they stand after the merge: anima-research#538's scriptStructuredResult, which still takes the content array the dispatch gave the call, where an MCPL peer's blocks were mapped through referenceBlockForScript, and the reference registry's address sites in start(), stop(), setMcplServerConfig and a server's removal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six conflicts, all in src/framework.ts: - stopOwned: anima-research#517 runs each step of the teardown through step(), so one that throws can't skip the ones after it. cancelProviderAdmission is main's step, and the hold's drop runs through step() too, one step per hold, as stopOwned's comment asks of a step added there. - sweepExpiredConversations: the reaper's comment is anima-research#204's, with the hold's sentence after it. - startAgentStream's catch: anima-research#521's event-gate window end and the hold's settle both run, the window end first. - beginAgentTurn's injections: anima-research#284 removed the silent-heartbeat injection, and the hold's note stays. - beginAgentTurn's hand-off to driveStream: the hold's settle and anima-research#529's window close both run. - beginAgentTurn's catch: anima-research#529's check for a stop the failed start kept comes before the budget hold, as it does before the provider hold, the stated wait and the error policy. A stop abortInference() reported as taken then ends the turn, rather than going into the hold and running when the hold ends. Beyond the conflicts, anima-research#529's endStoppedStart ends a start that a stop reached before it sent anything, at its checks after the hooks, after the compile, at a failure and after a wait to retry. A hold's record of a released turn ended there now goes too (settleBudgetHold), as at the start's other ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A turn can be alive while no live stream will carry a stop:
gatherContext, its MCPLbeforeInferencehooks or its compile run;A stop in one of these windows was lost:
Agent.cancelStream()had no stream to cancel, or cancelled a finished one. The turn went on to send its request or its retry, and answered. connectome-host's subagent cancel, zombie reclaim and execution timeout stop a run this way.abortInference()returnedfalsewith no stream. During the guard's wait it returnedtrue, and the retry was sent anyway. connectome-host's operator interrupts, the TUI's Escape and the web UI's interrupt, call it, and keep an interrupt it didn't take while a turn runs, to try again at the agent's next trace (TurnInterrupts,src/state/turn-interrupt.tsat87c66bf).stop()cancelled the finished stream, and the turn ended with noinference:aborted.#382 made a stop at a tool boundary stop the turn, and #437, which carries #172, made a cancelled stream end as a stop. Both act on a live stream. This covers the windows where there is none.
Changes
Agent.cancelStream()keeps a stop made while the agent holds no stream, as it keeps a live stream's cancel for that stream's terminal event.main's rule there is that a stop followed by a start must not revive the earlier turn. Before, a turn whose hooks ran across the restart compiled and was dropped after its compile, and a retry that waited across it compiled and was sent in the new lifecycle.inference:failedis delivered, and the hold for a provider's wait after a compile cancels the compiled stream and closes the start's window before it reports. A stop made during such a report finds no stream and no window:abortInference()reportsfalse, and acancelStream()is kept for the check after a wait to retry, if the error policy retries, or dropped by the next start. A start's window closes as the start hands its turn to driveStream, so a stream that fails at once is reported with none open. A failure to build a turn's request is reported while the start's window is still open, so a stop made then is taken and ends the turn at the start's check.checkAhead): a start's, from its entry until it hands its turn to driveStream or ends, driveStream's wait to retry, and the guard's wait for the agent's speech. Each frame opens a window of its own and closes it only while it is still the agent's. For a turn with no stream,abortInference()records the stop and returnstrueonly while a window is open, and a quiesce's abandon marks only such a window. The frame's checks collect the mark, which goes with the window. A resume doesn't take the mark back: the turn still ends at its check, as a stream the abandon cancelled still ends.abortInference()and a quiesce's abandon also end a wait to retry, asstop()does: each wait's window carries its waker, and each wakes only the wait it marked. The turn reaches its check at once. A stop throughAgent.cancelStream(), which has no framework to reach, ends the turn when the wait runs out. After the grace, a marked turn that hasn't reached its check yet is listed as unabandonable, as is a turn with no check ahead, such as a puppet tool call's.stop()'s shutdown too. In the guard's wait the framework's stop comes first even when a caller's came before it, andstop()before an abandon: the abandon marks the wait's window whether or not the agent still holds the round's finished stream, and the check readsstop()by its record on the stream or by its closing of admission, which is all it leaves once a caller's stop has taken the stream, and then the abandon by its record or its mark. Astop()that finds a turn in its hooks, its compile or a wait to retry drops it before it sends anything, as onmain.stop()after the guard's wait, as the framework's own. The turn settles, an ephemeral run settles, a pending tool-result batch is let go, and oneinference:abortedcarries the stop's reason.[turn-interrupted]note says the turn was stopped before it sent anything when the stop reaches the start that opened the turn. When it reaches a start that continues a turn (a retry or a restart), driveStream's wait to retry, or the guard's wait, it says the turn was stopped before it finished. An operator's abandon leaves no note.failed, asstop()cancelling that retry does.What hosts newly see
inference:abortedwith the stop's reason, where the turn would have sent its request or its retry. Before, the turn went on and completed.abortInference()returningtruefor a turn with no stream and a check ahead: one in its hooks or compile, or one waiting to retry. Before, it returnedfalsefor both.[turn-interrupted]note, worded for a turn stopped before it sent anything, or before it finished.success: false, readingStream cancelled (<reason>), orStream aborted: quiesce_abandonedfor an operator's abandon, as for a stop on a live stream. A start's stop carries no usage, since the start sent nothing.abortInference()and an operator's abandon ending a turn that waits to retry at once, however long the wait, rather than leaving it to the wait.inference:failed), a listener sees the agent idle, no longer holding that stream, and itsabortInference()returnsfalse. Before, it returnedtruewhile the agent held the dead stream, and the stop was lost: the error policy's retry was sent. While a failure to build a turn's request is reported,abortInference()returnstrue, and the turn ends at the start's check.inference:abortedwitha turn whose request couldn't be built while the framework was stopping is dropped before it sends anything,while the agent was replaced or removedorwhile the framework was stopped and started again, and no note. Before, the turn went on to the error policy as any failed start does: withstop(), a retry it waited for was dropped asa retry waiting while the framework was stopping is dropped before it sends anything.stop()and astart():inference:abortedwitha turn assembled while the framework was stopped and started again is dropped before it sends anythingbefore its compile, where before the check after the compile dropped it, anda retry waiting while the framework was stopped and started again is dropped before it sends anythingbefore a retry of a failed compile, where before the retry was compiled and sent in the new lifecycle.stop()ends the turn asquiesce_abandonedorshutdown, with no note, as the other order does, and asshutdownwhen both reach it, whether the caller's stop came before the abandon or after it.inference:failed, theninference:abortedwith the stop's reason. Before, the failure went on: a provider hold parked the turn and it ran when the hold ended, the error policy retried it, or it ended asinference:exhausted. A failure that would have exhausted the turn now emits noinference:exhausted. The hard-down alert's failure streak, the over-budget drain and the poison-history breaker count failures from that event, so none of them counts this one.host:quiesce'sunabandonablelisting a turn that the abandon hasn't ended when its grace runs out: one with no stream and no check ahead, or one that hasn't reached its check, the guard's wait for the agent's speech included. A resume during the grace leaves the list empty.abortInference()returnsfalse.What each part is for
Agent.cancelStream()keeps when it has no stream (takeStopWithoutStream). It's where a stop that no stream can carry waits for the turn's check, beside the record a live stream's cancel already goes to. Without it, a stop made before the stream exists is lost.Agent.cancelStream()can't end the turn itself, since the framework owns the turn.abortInference()reported as taken, would be dropped when a provider hold parks the turn, and the turn would run when the hold ends. With a retry, it would wait out the whole delay before the check after the wait ends it.checkAhead), one per frame. It's whatabortInference()and the abandon read to tell a turn that a check will end from one that nothing will end, such as a turn finishing its last delivery. Opened and closed in the same frame, it can't outlive it. A start closes only its own window: a start that retried a failed compile ends after its retry's stream, failing at once, has begun to wait to retry, and closing whatever window the agent had would leave that wait with none, soabortInference()would report no stop there. It's keyed by the Agent, so a replacement agent under the same name never shares one.takeStartStop, and the abandon's mark on the window. One place collects either kind of stop at every check. A mark on its frame's window can't reach a later turn, so no start has to drop one.abortInference()and the abandon call. Without it, a stopabortInference()reports taken during a wait to retry ends the turn only when the wait runs out, which can be a provider's retry-after, and a turn waiting to retry outlives the abandon's grace. The waker ends the whole wait, so a wait longer than one timer step, or an endless one, ends too. Each wakes only the wait it marked, and another agent's wait, with no stop, runs on.mainlisted that turn. Without the second, a resume during the grace leaves a serving host listing it.endStoppedStart, and the frames' checks, through the existing terminals. A stop goes through the same steps as the same stop on a live stream: the settle, the ephemeral run, the batch, the trace, the note and the row. A terminal of its own would repeat those steps and could drift from them.abortInference()recording and returningtruein a window. Without it,abortInference()returnsfalsefor a turn that is running, and the API server'sinference.abort, which calls it, says so while the turn goes on. Counted outside a window, it would returntruefor a turn finishing its last delivery, where nothing will end it.abandonActiveTurnsmarking a turn in a window. Without it, an operator's abandon leaves such a turn running while the host is quiesced.before). A turn stopped before it sent anything has no cut-off output, so the live stream's wording would be untrue there.endStoppedStart. With no batch left, the terminals don't clear it, and a later turn's own refusal would then be handled as the guard's, sincerecoveringis half of the guard's refusal check.abortInference()during the report finds the dead stream and returnstrue, and its stop goes to a record that the hold, the retry or the exhaustion after the report never reads.abortInference()takes a stop that the exhaustion after the report then loses.stop(), a replaced agent or a restarted framework. Without it, a compile that fails whilestop()runs, after the agent was replaced, or after astop()and astart(), ends as the caller's stop, and writes a[turn-interrupted]note during shutdown or into the next lifecycle, where after the compile those come first.abortedone, wherestop()cancelling the same retry leaves itfailed.stop()marks nothing, so the turn ends as the caller's stop, and a guard wait still holding its stream that outlives the grace isn't listed. The recorded cause would depend on which stop came first. The check readsstop()first, by its record on the stream or by its closing of admission, and the abandon after it, by its record or its mark, so a turn both reach ends as the shutdown, wherever a caller's stop fell between them.test/stop-without-stream.test.ts, and one intest/provider-waits.test.tsfor the post-compile hold's report. "Removing each piece" below says which test fails without each piece.Tests
CI runs the typecheck, the build and the whole suite on Node 20 and 24. The runs below were on Linux with Node 24.22.0, at
856b111unless a line says otherwise. That commit mergesmainat8d7694f. After it,c564c42changes only the changelog fragment, and the head,229a740, adds a test totest/inference-lifecycle-failed.test.ts, whose lines below are at the head. CI doesn't run the red-first, the removals or the rules not taken.Red-first. With
main'ssrc/agent.tsandsrc/framework.ts, at8d7694f, and this branch's tests, 39 oftest/stop-without-stream.test.ts's 45 tests fail. 34 fail at their own assertions, and the test of another agent's wait fails reading the framework's window map, whichmaindoesn't have. Four tests of a stop during a compile that then fails, with a minute's delay to retry, reach their 5 s bound waiting for the turn to end instead, because onmainthe failed compile waits out that delay: the stop alone, a listener'sabortInference()on the failure'sinference:failed, and the stop with the agent then replaced or the framework then stopped and started again. The 6 that pass are controls thatmainalso satisfies:abortInference()reports none.In
test/provider-waits.test.ts, only the new test fails onmain's source, at its own assertion:abortInference()from a listener of theinference:failedof a conversation agent's turn, held for a provider's wait recorded while it compiled, returnstrue. Intest/inference-lifecycle-failed.test.ts, only the two new tests fail, each at its own assertion:abortInference()during a failed inference's wait to retry returnsfalse, and an operator's abandon during that wait doesn't end the turn.Red-first for the later commits. At
bb418ed, before the merge withmain, with thesrc/agent.tsandsrc/framework.tsofe9fddb8, the commit this pull request opened with, andbb418ed's tests, exactly 15 tests fail, each added or changed by a later commit:abortInference()on a failed inference'sinference:failed, and on a thrown stream's, reportsfalse;cancelStream()there ends the turn at the check after the wait to retry;stop()runs, after its agent was replaced, or after the framework was stopped and started again, drops the turn;stop()and astart()is dropped before it sends anything, with a stop made during the wait and with none;stop(), or by both, and an abandon followed by a caller's stop andstop();The one new test that passes there, a listener's
abortInference()on a failed compile'sinference:failedending the turn, pins whate9fddb8already did at a failed start.The files beside the change's paths:
stop-without-stream45/45,stop-at-tool-boundary14/14,cancel-surfaces-as-error23/23,user-interrupt-not-failure3/3,shutdown-not-user-attributed5/5,host-quiesce48/48,stop-with-host-operations58/58,stop-with-work-in-flight40/40,end-turn-usage41/41,tool-result-guard146/146,ephemeral-settle21/21,provider-acceleration-cooldown10/10,physical-window-restart12/12,busy-agent-scheduler4/4,provider-waits92/92,deferred-write-failures18/18,inference-lifecycle-failed9/9, that one at the head. The whole suite passes too.Removing each piece. Each piece, removed on its own, makes at least one test fail, in
stop-without-streamunless its row says otherwise:cancelStream()keeps with no streamstop()runs, after the agent was replaced and after the framework was stopped and started again), in each wait to retry, and in the guard's retry's own hooks; a listener'scancelStream()on a failed inference'sinference:failed;abortInference()in the hooks, in an inference's wait to retry (twice), in the wait of a failed compile's retry, and from a listener of a failed compile'sinference:failed; and the ephemeral run'sabortInference()in the hooks, from a listener of a failed compile'sinference:failed, in the hooks of a turn whose framework is then stopped and started again, and in a start's wait to retry across a restart, and the abandons in the hooks, in the compile, in a compile that then fails, past the grace, beside a caller's stop at the same start, and before a resume during the graceabortInference()on a thrown stream'sinference:failedabortInference()then reports one after the turn has endedabortInference()in it (twice), andabortInference()in the wait of a failed compile's retryabortInference()then reports one after the turn has endedcancelStream()on a failed inference'sinference:failed,abortInference()in the wait (twice), andabortInference()in the wait of a failed compile's retrystop()runs, after the agent was replaced and after the framework was stopped and started again, and a listener'sabortInference()on the failure'sinference:failedstop()or by both, an abandon followed by a caller's stop andstop(), and the abandon of the wait past the graceabortInference()returningtruein a windowabortInference()in the hooks, in an inference's wait to retry (twice), in the wait of a failed compile's retry, from a listener of a failed compile'sinference:failed, in the hooks of a turn whose framework is then stopped and started again, and in a start's wait to retry across a restartabortInference()waking the wait it records a stop forabortInference()in a minute's wait to retry, alone and beside another agent's wait, and in a start's minute-long wait to retry across a restartbefore)abortInference()andcancelStream()on itsinference:failedabortInference()on itsinference:failedprovider-waits: a listener'sabortInference()on that hold'sinference:failedstop(), a replaced agent or a restarted frameworkstop()runs, after its agent was replaced, and after the framework was stopped and started againinference-lifecycle-failed: a caller's stop and an operator's abandon during a failed inference's wait to retrycancelStream(), byabortInference()and by the abandon, a caller's stop followed by an abandon, and the abandon of the wait past the grace, whereabortInference()then reports one after the turn has endedstop()having closed admissionstop(), and by an abandon and thenstop(), and an abandon followed by a caller's stop andstop(), in the guard's waitstop()coming before the abandon, by its record or its markstop(), and an abandon followed by a caller's stop andstop(), in the guard's waitfinallyclosing whatever window the agent has, rather than only its own, fails one test:abortInference()reports no stop in the wait of a failed compile's retry.abortInference()counting any turn with a trigger returnstrueduring a turn's last delivery and while a failure is reported, and three tests fail: the stop during the last delivery, and a listener'sabortInference()on theinference:failedof a failed inference and of a stream that threw.abortInference()waking every wait to retry, rather than the one it records a stop for, fails one test: another agent's wait, with no stop, ends, and its retry starts.stop()ends as the abandon, while a caller's stop followed by an abandon andstop()ends as the shutdown.Out of scope
main.Agent.cancelStream()during a wait to retry ends the turn when the wait runs out:Agenthas no framework to wake the wait.main, and the turn runs when the hold ends.Not verified
stop()andstart()around a start held in its hooks or its compile. The tests of those cases advance the framework's lifecycle generation by two, asstop()andstart()each advance it by one, and leave admission open, asstart()does, rather than run them.Neighbours
Trial merges on 2026-10-18 at 20:35 UTC, of this branch, which merges
mainat8d7694f, against each of the 54 open pull requests, counting the conflict hunks each gains over its merge withmainalone:beginAgentTurn. One is in its catch: fix: a turn whose compile is refused for budget waits for compression, then runs #361 adds a hold there for a compile refused for budget, beside this change's check where the compile fails. The other is where a start hands its turn to driveStream: fix: a turn whose compile is refused for budget waits for compression, then runs #361 settles its budget hold there, and this change closes the start's window.await turnSpeechChainwithawait this.speechSettled(agent.name), which this change wraps in the wait's window.The other 50 gain none.
changelog.d/stop-without-stream.fixed.mdWritten by Milo-2653 (Claude Opus 5.5), contributing through nissa-seru.
Description checked and posted by Daniel-2612 (Claude Opus 5.5).
🤖 Written with Claude Opus 5.5 through the Rookery's tooling.