Skip to content

fix(stop): a stop reaches a turn that has no live stream - #529

Merged
nissa-seru merged 12 commits into
anima-research:mainfrom
nissa-seru:fix/stop-without-stream
Oct 18, 2026
Merged

nissa-seru merged 12 commits into
anima-research:mainfrom
nissa-seru:fix/stop-without-stream

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Problem

A turn can be alive while no live stream will carry a stop:

  • before its stream exists, while its modules' gatherContext, its MCPL beforeInference hooks or its compile run;
  • while it waits to retry a failed inference, or a failed compile, under the error policy;
  • while the tool-result guard waits for the agent's earlier speech to go out before it retries a refused round. The round's stream has completed by then. The agent still holds it, but no event will come from it.

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.
  • The framework's abortInference() returned false with no stream. During the guard's wait it returned true, 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.ts at 87c66bf).
  • A quiesce's abandon left a turn with no stream running, and the turn then sent its request or its retry while the host was quiesced. During the guard's wait, the abandon cancelled the finished stream, and the retry ran while quiesced.
  • During the guard's wait, stop() cancelled the finished stream, and the turn ended with no inference: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.
  • 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 and after its compile, where it already drops a turn whose agent was replaced or whose framework is stopping;
    • driveStream, right after its wait to retry a failed inference, as the guard does after its wait;
    • a start, right after its wait to retry a failed compile;
    • a start whose compile fails, at the failure, before a provider hold, the error policy's retry or the turn's exhaustion. If by then the agent was replaced, or the framework is stopping or was stopped and started again, the turn is dropped before it sends anything, as after the compile;
    • the guard, after its wait.
  • A start whose framework was stopped and started again since it began is dropped at the check after its hooks and at the check after its wait to retry a failed compile, as the check after its compile already drops it, ahead of a stop kept for the turn, which goes with it. 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.
  • A stream that failed or threw no longer holds the agent while its inference:failed is 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() reports false, and a cancelStream() 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.
  • A stop still kept when a start begins ends nothing, and the start drops it at its entry. Either it was made where no check was ahead, as when a host's interrupt, which reaches every agent busy or not, reaches an idle one, or its turn ended without reaching a check. The agent's next turn runs as before.
  • The framework keeps, per agent, the window of the frame that holds such a turn (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 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 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, as stop() 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 through Agent.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.
  • After the guard's wait, a stop made during it ends the turn instead of the retry. At each check, the framework's own stop comes first, then a caller's: a quiesce's abandon, and after the guard's wait, stop()'s shutdown too. In the guard's wait the framework's stop comes first even when a caller's came before it, and stop() 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 reads stop() 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. A stop() that finds a turn in its hooks, its compile or a wait to retry drops it before it sends anything, as on main.
  • Each stop ends through the terminal the same stop gets on a live stream: a caller's stop as a deliberate stop, and a quiesce's abandon, or 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 one inference:aborted carries the stop's reason.
    • The agent's [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.
    • The stop writes the inference-log row a stop on a live stream writes. A start's stop has no stream to write through and no usage to carry, so it writes its row directly, with the request its compile made, or a note that none was compiled. driveStream's check and the guard's write through the failed or finished stream's writer, after that round's own row.
    • A start that continues a turn also clears the guard's recovery, which the frame that started it kept for its successor.
  • A stop that ends a turn at the check after driveStream's wait to retry leaves the failed inference's lifecycle phase failed, as stop() cancelling that retry does.

What hosts newly see

  • inference:aborted with the stop's reason, where the turn would have sent its request or its retry. Before, the turn went on and completed.
  • abortInference() returning true for a turn with no stream and a check ahead: one in its hooks or compile, or one waiting to retry. Before, it returned false for both.
  • The [turn-interrupted] note, worded for a turn stopped before it sent anything, or before it finished.
  • An inference-log row for the stop, success: false, reading Stream cancelled (<reason>), or Stream aborted: quiesce_abandoned for 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.
  • While the failure of a turn's stream is reported (inference:failed), a listener sees the agent idle, no longer holding that stream, and its abortInference() returns false. Before, it returned true while 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() returns true, and the turn ends at the start's check.
  • For a turn whose compile fails after a stop reached it, while the framework stops, after its agent was replaced, or after the framework was stopped and started again: inference:aborted with a turn whose request couldn't be built while the framework was stopping is dropped before it sends anything, while the agent was replaced or removed or while the framework was stopped and started again, and no note. Before, the turn went on to the error policy as any failed start does: with stop(), a retry it waited for was dropped as a retry waiting while the framework was stopping is dropped before it sends anything.
  • For a start that spans a stop() and a start(): inference:aborted with a turn assembled while the framework was stopped and started again is dropped before it sends anything before its compile, where before the check after the compile dropped it, and a retry waiting while the framework was stopped and started again is dropped before it sends anything before a retry of a failed compile, where before the retry was compiled and sent in the new lifecycle.
  • In the guard's wait, a caller's stop followed by an operator's abandon or stop() ends the turn as quiesce_abandoned or shutdown, with no note, as the other order does, and as shutdown when both reach it, whether the caller's stop came before the abandon or after it.
  • For a turn whose compile fails after a stop reached it: inference:failed, then inference:aborted with 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 as inference:exhausted. A failure that would have exhausted the turn now emits no inference: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's unabandonable listing 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.
  • No change for a stop made where no check of a turn is ahead. That covers an idle agent, a turn finishing its last delivery, a budget restart or a non-streaming turn's tool results waiting to start the turn's next call, and a turn a provider-capacity hold or a provider's stated wait has parked. The agent's next turn runs, and abortInference() returns false.

What each part is for

  • The record 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.
  • Each frame's own check. A start's checks after its hooks and after its compile sit beside its existing "replaced or removed" and "stopping" checks, so a stopped start leaves where those starts leave. One check after the compile would let a stop made during the hooks run the compile first, and one after the hooks would miss a stop made during the compile. The checks right after the waits to retry end the turn in the frame that waited. A retry's start never inherits a stop, so a start that parks or returns early has nothing to carry.
  • The check at a failed start's catch. A failed compile skips the check after it, and its failure is reported while the start's window is still open. Without this check, a stop made during the compile or the report, which 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.
  • Every start dropping the stop it finds. Without it, a stop made while the agent was idle, such as a host's interrupt of every agent, ends the agent's next turn. Kept for a start whose reason is a continuation's instead, a restart trigger replayed after a provider hold would keep a stop made during the hold. Kept for a start that continues the agent's open logical turn, a parked turn's later start, which carries every wake held for the agent, would keep it too, and a message sent after the stop would go unanswered.
  • The window (checkAhead), one per frame. It's what abortInference() 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, so abortInference() 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.
  • The window's waker, which abortInference() and the abandon call. Without it, a stop abortInference() 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.
  • Listing a marked turn that outlives the grace, and not after a resume. Without the first, an abandon whose turn is still in a hook reports nothing left behind, where main listed 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 returning true in a window. Without it, abortInference() returns false for a turn that is running, and the API server's inference.abort, which calls it, says so while the turn goes on. Counted outside a window, it would return true for a turn finishing its last delivery, where nothing will end it.
  • abandonActiveTurns marking a turn in a window. Without it, an operator's abandon leaves such a turn running while the host is quiesced.
  • The note's wording (before). A turn stopped before it sent anything has no cut-off output, so the live stream's wording would be untrue there.
  • Clearing the guard's recovery in 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, since recovering is half of the guard's refusal check.
  • The stop's row, written directly for a start, with its request or a note. Without it, these stops leave no inference-log row, where a live stream's stop leaves one. A stop before a compile made a request has none, and the live stream's note would describe a stream that never existed.
  • Reporting a failure with no stream held. Without it, a listener's abortInference() during the report finds the dead stream and returns true, and its stop goes to a record that the hold, the retry or the exhaustion after the report never reads.
  • Closing a start's window at its hand-off. Without it, a stream that fails at once is reported while the start's window is still open, and abortInference() takes a stop that the exhaustion after the report then loses.
  • Dropping a stopped failed start under stop(), a replaced agent or a restarted framework. Without it, a compile that fails while stop() runs, after the agent was replaced, or after a stop() and a start(), 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.
  • The lifecycle test at the drop after the hooks and after a start's wait to retry. Without the first, a stop made during the hooks of a turn whose framework was stopped and started again ends it as the caller's stop and writes the note into the next lifecycle, since the stop check comes right after that drop. Without the second, a retry that waited across the restart is compiled and sent in the new lifecycle, or, with a stop made during the wait, ends as the caller's stop with the note.
  • Leaving the failed inference's phase at the check after the wait to retry. Without it, a caller's stop or an abandon that cancels the retry turns the inference that failed into an aborted one, where stop() cancelling the same retry leaves it failed.
  • The guard's wait as a window, marked by the abandon either way, and its check reading the mark and closed admission. Without them, once a caller's stop has taken the round's finished stream, an operator's abandon finds no stream and no window, and 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 reads stop() 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.
  • The tests: the 38 in test/stop-without-stream.test.ts, and one in test/provider-waits.test.ts for 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 856b111 unless a line says otherwise. That commit merges main at 8d7694f. After it, c564c42 changes only the changelog fragment, and the head, 229a740, adds a test to test/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's src/agent.ts and src/framework.ts, at 8d7694f, and this branch's tests, 39 of test/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, which main doesn'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 on main the failed compile waits out that delay: the stop alone, a listener's abortInference() on the failure's inference:failed, and the stop with the agent then replaced or the framework then stopped and started again. The 6 that pass are controls that main also satisfies:

    • a stop made while no turn is running ends nothing, and the agent's next turn runs;
    • a message sent after a stop during a provider-capacity hold is answered when the hold ends;
    • a stop made while no check is ahead doesn't end a restart queued later;
    • with no stop, the guard's retry is sent once the post has gone out;
    • an abandon of a turn finishing its last delivery leaves nothing for a later turn;
    • a stop during a turn's last delivery ends nothing, and abortInference() reports none.

    In test/provider-waits.test.ts, only the new test fails on main's source, at its own assertion: abortInference() from a listener of the inference:failed of a conversation agent's turn, held for a provider's wait recorded while it compiled, returns true. In test/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 returns false, and an operator's abandon during that wait doesn't end the turn.

  • Red-first for the later commits. At bb418ed, before the merge with main, with the src/agent.ts and src/framework.ts of e9fddb8, the commit this pull request opened with, and bb418ed's tests, exactly 15 tests fail, each added or changed by a later commit:

    • a listener's abortInference() on a failed inference's inference:failed, and on a thrown stream's, reports false;
    • a listener's cancelStream() there ends the turn at the check after the wait to retry;
    • a stop during a compile that then fails while stop() runs, after its agent was replaced, or after the framework was stopped and started again, drops the turn;
    • a turn whose framework is stopped and started again during its hooks is dropped before its compile, with a stop made during them and with none;
    • a start waiting to retry a failed compile across a stop() and a start() is dropped before it sends anything, with a stop made during the wait and with none;
    • in the guard's wait, a caller's stop followed by an abandon, by stop(), or by both, and an abandon followed by a caller's stop and stop();
    • an abandon of the guard's wait past the grace lists the turn.

    The one new test that passes there, a listener's abortInference() on a failed compile's inference:failed ending the turn, pins what e9fddb8 already did at a failed start.

  • The files beside the change's paths: stop-without-stream 45/45, stop-at-tool-boundary 14/14, cancel-surfaces-as-error 23/23, user-interrupt-not-failure 3/3, shutdown-not-user-attributed 5/5, host-quiesce 48/48, stop-with-host-operations 58/58, stop-with-work-in-flight 40/40, end-turn-usage 41/41, tool-result-guard 146/146, ephemeral-settle 21/21, provider-acceleration-cooldown 10/10, physical-window-restart 12/12, busy-agent-scheduler 4/4, provider-waits 92/92, deferred-write-failures 18/18, inference-lifecycle-failed 9/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-stream unless its row says otherwise:

Piece removed Tests that fail
The record cancelStream() keeps with no stream 17: the stops in the hooks, in the compile, in a compile that then fails (five times: before a retry, before a hold, while stop() 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's cancelStream() on a failed inference's inference: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's inference:failed; and the ephemeral run's
Every start dropping the stop it finds 4: a stop while no turn runs, the message sent after a stop during a hold, a restart queued after a stop made while no check was ahead, and a stop during a turn's last delivery
Opening a start's window 10: abortInference() in the hooks, from a listener of a failed compile's inference: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 grace
Closing a start's window at its hand-off to driveStream 1: a listener's abortInference() on a thrown stream's inference:failed
Closing a start's window when it ends without a hand-off 1: the stop in the guard's retry's own hooks, where abortInference() then reports one after the turn has ended
Opening a wait to retry's window 4: the abandon in the wait, abortInference() in it (twice), and abortInference() in the wait of a failed compile's retry
Closing it after the wait 1: the stop in the wait to retry a failed inference, where abortInference() then reports one after the turn has ended
The check after a wait to retry a failed inference 6: the stop and the abandon in that wait, a listener's cancelStream() on a failed inference's inference:failed, abortInference() in the wait (twice), and abortInference() in the wait of a failed compile's retry
The check after a wait to retry a failed compile 1: the stop in that wait
The check at a failed start 7: the abandon during a compile that then fails, and the stop there, before a retry, before a hold, while stop() runs, after the agent was replaced and after the framework was stopped and started again, and a listener's abortInference() on the failure's inference:failed
The check after the hooks 2: the stop in the hooks, and the stop in the guard's retry's own hooks
The check after the compile 2: the stop and the abandon during the compile
The check after the guard's wait 9: each stop in the guard's wait, a caller's stop followed by an abandon, by stop() or by both, an abandon followed by a caller's stop and stop(), and the abandon of the wait past the grace
abortInference() returning true in a window 7: abortInference() 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's inference: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
abortInference() waking the wait it records a stop for 3: abortInference() 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 restart
The abandon marking a window 8: each abandon of a turn with no stream, a guard's wait after a caller's stop included
The abandon waking the waits it marked 1: the abandon in a wait to retry
The waker ending the whole wait, not one timer step 1: the abandon in an endless wait to retry
Listing a marked turn after the grace 2: the abandon past the grace, of a turn in its hooks and of the guard's wait
Listing it only while quiesced 1: the resume during the grace
The note's wording (before) 11
Clearing the guard's recovery 1: the stop in the guard's retry's own hooks
The stop's row, written directly 9
The row's note when no request was compiled 1: the stop in the hooks
Reporting a failed inference with no stream held 2: a listener's abortInference() and cancelStream() on its inference:failed
Reporting a stream that threw with no stream held 1: a listener's abortInference() on its inference:failed
The hold after a compile cancelling the compiled stream, and closing the start's window, before it reports 1, in provider-waits: a listener's abortInference() on that hold's inference:failed
Dropping a stopped failed start under stop(), a replaced agent or a restarted framework 3: a stop during a compile that then fails while stop() runs, after its agent was replaced, and after the framework was stopped and started again
The failed start's drop counting a framework stopped and started again 1: a stop during a compile that then fails after the framework was stopped and started again
The failed start's drop closing the turn at the event gate 3: each of the three drops of a stopped failed start
The drop after the hooks counting a framework stopped and started again 2: a turn whose framework is stopped and started again during its hooks, with a stop made during them and with none
The drop after a start's wait to retry counting a framework stopped and started again 2: a start waiting to retry a failed compile across a restart, with a stop made during the wait and with none
Leaving the failed inference's phase at the check after the wait to retry 2, in inference-lifecycle-failed: a caller's stop and an operator's abandon during a failed inference's wait to retry
The guard's wait as a window 2: a caller's stop followed by an abandon in the guard's wait, and the abandon of the wait past the grace
The abandon marking the guard's window while the agent still holds the round's stream 1: the abandon of the wait past the grace
Closing the guard's window after its wait 5: the guard's stops by cancelStream(), by abortInference() and by the abandon, a caller's stop followed by an abandon, and the abandon of the wait past the grace, where abortInference() then reports one after the turn has ended
The guard's check reading the window's mark 1: a caller's stop followed by an abandon in the guard's wait
The guard's check reading stop() having closed admission 3: a caller's stop followed by stop(), and by an abandon and then stop(), and an abandon followed by a caller's stop and stop(), in the guard's wait
stop() coming before the abandon, by its record or its mark 2: a caller's stop followed by an abandon and then stop(), and an abandon followed by a caller's stop and stop(), in the guard's wait
  • The rules not taken.
    • Keying the drop on the start's reason instead fails one test: a restart queued after a stop made while no check was ahead, as a hold's release replays a trigger, is never answered.
    • Keying it on the agent's open logical turn fails that test and one more: the message sent after a stop during a hold is never answered.
    • A start's finally closing 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 returns true during a turn's last delivery and while a failure is reported, and three tests fail: the stop during the last delivery, and a listener's abortInference() on the inference:failed of 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.
    • The guard's check reading the stream's record first, whichever stop made it, as a live stream's terminal reads it, fails one test: an abandon followed by a caller's stop and stop() ends as the abandon, while a caller's stop followed by an abandon and stop() ends as the shutdown.

Out of scope

  • A puppet tool call holds the agent's turn with no stream and no check ahead, so a stop or an abandon still doesn't reach it.
  • A wake that is queued but hasn't started a turn. A stop stops what's running, not what's queued.
  • A budget restart, or a non-streaming turn's tool results, between the boundary that queues the turn's next call and that call's start, usually one pass of the run loop. A stop made there ends nothing, and a quiesce's abandon doesn't reach it, so the start, which passes the quiesce gate as a continuation, sends while the host is quiesced, as on main.
  • A stop through Agent.cancelStream() during a wait to retry ends the turn when the wait runs out: Agent has no framework to wake the wait.
  • A turn a provider-capacity hold or a provider's stated wait has parked. No check of it is ahead while it waits, so a stop made then ends nothing, as on main, and the turn runs when the hold ends.

Not verified

  • Not run against a live provider or connectome-host.
  • By our reading of the code, with no run of its own: a retry's start parked for provider admission is a queued wake, which a stop doesn't reach.
  • A real stop() and start() around a start held in its hooks or its compile. The tests of those cases advance the framework's lifecycle generation by two, as stop() and start() each advance it by one, and leave admission open, as start() does, rather than run them.

Neighbours

Trial merges on 2026-10-18 at 20:35 UTC, of this branch, which merges main at 8d7694f, against each of the 54 open pull requests, counting the conflict hunks each gains over its merge with main alone:

The other 50 gain none.


  • Changelog fragment added: changelog.d/stop-without-stream.fixed.md

Written 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.

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

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The change 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

  1. P1 Accepted stop still retries ▶
  2. P2 Failed compile mislabels shutdown ▶
  3. P2 Guard loses framework stop reason ▶
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.

Summary

Extends stops to turns running hooks or compilation, waiting to retry, or waiting for the tool-result guard's earlier speech.

  • Stops now reach turns that are running without a live stream.

Reviews (1) · Last reviewed commit: "fix(stop): a stop reaches a turn that ha..." · Reviewed by Greptile

Comment thread src/framework.ts
Comment on lines +15154 to +15155
const stop = this.agents.get(agent.name) === agent && agent.streamId === myStreamId
? this.takeStartStop(agent, retryWindow) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 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 yet
  • cancelStream() 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 sent
  • abortInference() 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

Comment thread src/framework.ts
Comment on lines +13746 to +13748
const stopAtFailure = this.takeStartStop(agent, checkWindow);
if (stopAtFailure) {
this.endStoppedStart(agent, stopAtFailure, routing, turnStartedAt, undefined, opensTurn);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 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

Comment thread src/framework.ts Outdated
Comment on lines +14534 to +14536
const frameworkStop = this.frameworkCancelledStreams.get(`${agent.name}:${myStreamId}`);
const callerStop = agent.takeCancel();
if (frameworkStop === 'shutdown' || frameworkStop === 'quiesce_abandoned') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 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 abandon
  • a caller's cancelStream() and then stop() end the turn as a shutdown
  • a caller's cancelStream(), an abandon and then stop() end the turn as a shutdown
  • an 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>
Milo-2653 and others added 5 commits October 18, 2026 13:50
…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>
Milo-2653 and others added 4 commits October 18, 2026 17:20
…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>
@nissa-seru
nissa-seru force-pushed the fix/stop-without-stream branch from 0c262c1 to c564c42 Compare October 18, 2026 21:21
… 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>
@nissa-seru
nissa-seru merged commit 57074a8 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
tel-0s added a commit that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant