Skip to content

fix(routing): every later start of a logical turn keeps the turn's routing - #333

Merged
nissa-seru merged 20 commits into
anima-research:mainfrom
nissa-seru:nigel/314-continuing-starts-keep-routing
Oct 14, 2026
Merged

nissa-seru merged 20 commits into
anima-research:mainfrom
nissa-seru:nigel/314-continuing-starts-keep-routing

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

TL;DR: every later start of a logical turn now keeps what the turn has decided about where its speech goes. !Fixes #314.

  • The starts: a framework retry after a stream error, the re-inference that consumes unstreamed tool results, and the compile a provider-capacity hold defers a turn to. fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312 made them one logical turn with the start before them, but beginAgentTurn still gave each freshTurnRouting().
  • What they keep now: hybrid's >>>skip_reply hold, the sticky destination in both modes, explicit mode's turn destination, and the turn's locus with any re-pin and the channels the turn sent into.
  • Messages between a turn's streams: a message no tool boundary weighed moves the locus at the turn's next start, by the rule a mid-turn message follows. Ambient traffic, reactions and system markers don't move it.

Problem

#312 shares a logical turn's ! and receipt tallies across the turn's streams. The routing state stayed per stream, so after one of the three starts above:

  • in hybrid mode after >>>skip_reply, the new stream's unprefixed prose was published to the turn's locus;
  • the sticky destination was gone: in hybrid mode unprefixed prose went to the locus, and in explicit mode it bounced, with a notice and a wake;
  • explicit mode's stream router started suppressed, so a streaming consumer got none of the text delivery then sent;
  • the turn's locus was resolved again. A retry lost a mid-turn addressed re-pin, a re-inference took the locus of whichever wake the scheduler started it with, and a held turn's later compile took the merged batch's channel, so a newer ambient message could move it.

#314 has the reproduction for the first start: three cases that fail on main at c2f9baee. The other two starts take the same branch of beginAgentTurn.

Changes

  • One routing object per logical turn. TurnRouting holds everything the turn has decided about where its speech goes: the trigger channel, the locus and the engaged channels, the sticky destination, the turn's destination and the hold, the ! and receipt tallies, and its arrivals (below). LogicalTurn is folded into it, and the per-agent activeTriggerChannels, turnLocusPins and turnEngagedChannels maps move onto it.
    • The start that opens a turn makes the object and registers it in openLogical.
    • Every start while the turn is open takes that object: a framework retry, the re-inference, the capacity-deferred compile.
    • A resume (a budget restart, a tool-result-guard retry) keeps it, as before. A resume that runs after stop() ended every open turn opens its turn again, so the turn's later starts continue its routing.
    • A silent start (suppressProse) gets an object of its own, so it doesn't take or change an open turn's. While it runs, its object is the agent's live routing. No silent start runs while a prose turn is open in production: a capacity hold holds silent wakes with the turn's, and a re-inference's wake is queued before its stream ends, so it batches with them. fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312's test builds that state by hand, and the open turn's object comes through it unchanged.
  • Explicit mode's stream starts every stream at the turn's destination, as a resume already did.
  • The locus at a later start. Every later start of the turn keeps the locus. A message that no tool boundary weighed can still move it there, by the boundary's own rule: a conversational message (not a reaction or a system marker) that addressed the agent, or that follows up in a channel the turn sent into. The boundary and this path share one predicate (repinChannel).
    • Which messages: one stored while the turn is open and none of its streams runs (a capacity hold, the wait for a re-inference, the flush at the end of a stream that didn't settle the turn, a start that fails before its stream); one the next start's own flush or coalescing assembly lands; and the injections of a boundary that can't re-pin because it's over budget or has no live stream. Each one that qualifies is one of the turn's arrivals, by its message id and the channel it re-pins to. A message tune-out diverts isn't one: a resident's compiled view leaves it out, so the agent never sees it. A resident's open turn also takes each such message stored for another ordinary resident, running or not, since residents read one message slot and a tool boundary weighs only the messages drained for its own agent. So a second resident's turn re-pins at its next start to a message addressed to it while it was held, as main's re-resolution did, and to one stored while it ran, which main never weighed. A boundary that re-pins clears the turn's arrivals, since what it stores is newer.
    • Using them: the turn's next start, a resume included, re-pins to the newest one whose message is still in the agent's store, with the same [routing] The conversation moved to … notice, and clears them all. Only a locus or hybrid turn's start reads their messages; any other start just clears them. A message removed meanwhile, such as an unread coalesced message its server retracted, isn't there for the agent to answer, so it moves nothing, and an earlier one that remains still does. A message a boundary weighed never becomes an arrival, so nothing is weighed twice.
    • Why the rule is needed: no turn is alive during a capacity hold, so addMessage stores arrivals directly, and the mid-turn re-pin, which reads only the messages drained at a tool boundary, never sees them. main re-resolved the locus instead, which lets an addressed wake move it, and ambient traffic too.
    • For readers of the earlier commits: the second commit's consideredUntil watermark, and the first commit's reading of the scheduler's wake requests, are replaced by this in the third. The requests were a proxy for the messages, and the internal review found where the proxy failed: reactions carry no flag on a request, the gate puts a channel on a wake only when it's addressed, and a held merged trigger takes its channel and its time from different requests.
  • A silent turn's tool boundary no longer re-pins its locus or stores a routing notice. The boundary tested the agent's mode rather than the turn's, on main too. The same test gates noting a boundary's injections as arrivals.
  • The channel context passed to beforeInference hooks (channels.defaultOutgoing) names a trunk agent's locus once its turn has one: the registry's activeChannelResolver returns the locus, else the trigger channel. After a re-pin, a later start of the turn reports where its unprefixed speech is published. Before, that was also wrong after a budget restart following a mid-turn re-pin. A conversation fork still reports its home channel.
  • A start that fails before its stream exists lands the messages deferred during it, as a stream's teardown does, each in its own try. Before, they waited for the agent's next start and landed after any message stored in between, so the window held them out of order.
  • A start whose throw escapes beginAgentTurn (the checkpoint, locus resolution, the typing indicator) ends the agent's open turn through settleAgent, as a terminal failure does. The open turn is the one the start was opening, continuing or resuming, and nothing retries or holds the start. So the agent's next start opens its own turn rather than taking the failed one's routing, and an ephemeral run fails with the error instead of waiting out its watchdog. A silent start's throw leaves an open turn alone.
  • A start dropped before its stream sends anything ends the turn it was running. Since fix(guard): a withheld tool result says where its original is (#277) #281, main drops such a start once stop() has closed admission, which it checks before and after the start's compile, or once a restart has begun a new lifecycle, which it checks after the compile. It already dropped one whose agent was replaced. stop() ends every open turn, but a fresh start that waits for its deferred batches' assembly while stop() runs reaches its routing after that and opens its turn. The drop doesn't settle the turn, so it stayed open, and the agent's first start after a restart continued its routing. endDroppedTurn now ends it, when it is still the dropped start's own.
  • A tool's late write lands on the turn that called it. A send's engaged channel, and a channel_open's trigger channel, are recorded on the turn captured at dispatch, even when the result, or the open that follows a send, comes after the turn has ended. A channel_open moves that turn's locus only while the turn is open, even when another start's routing state is the live one by then. Its result tells the agent of the move, and one that comes after the turn ended is dropped unread, so it moves nothing. A silent turn's channel_open moves no locus, and its result claims none.
  • The announce baseline (lastAnnouncedLocus) moves only with a move the agent is told of: a channel_open's, a re-pin whose notice was stored, and a turn's announcement once stored. "Not covered" names two narrow paths where a channel_open's result isn't delivered after its move. Before, a notice that failed to store left the baseline moved, so the agent was never told. announceLocusIfChanged is main's code and had the same order.
  • A replaced stream's queued deliveries. A stream queues deliveries only while it is the agent's current stream: its event loop stops at the first event after a replacement, and the tool-calls case doesn't await between that check and its chainSpeech calls. So what a replaced stream left queued is on the agent's chain ahead of its replacement's, and it runs first. With the state shared, such a delivery sets the pin or the hold for the replacement's later prose, in the order the turn wrote them. The second "Not covered" item in fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312, a link queued after the replacement, has no path that reaches it.
  • Text. settleAgent's comment no longer says no test reaches its identity check: the settle test calls it with a stale turn. The 0.22.0 changelog's entry for fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312 says these starts begin the sticky destination and the hold afresh. That was true of 0.22.0, so this PR leaves the entry as released, and its own fragment says what changed. The explicit doc says the latest resolved target is sticky, which is what the code does. The hybrid doc names every cause of the hold and says it governs publication. The trace type and the router no longer call the locus turn-frozen.

Tests

New and extended tests, each named for the contract it protects:

  • test/explicit-prose-routing.test.ts:
    • across an error retry and across a capacity-deferred compile: hybrid's hold, hybrid's sticky >>>#alpha, and explicit's sticky >>#alpha, where the explicit case also checks that the new stream streams to the turn's destination. This is Retries and other continuing starts discard hybrid privacy holds and sticky destinations #314's reproduction, adapted and run through both starts;
    • a re-inference on unstreamed tool results keeps the same three;
    • in both modes, a retried turn's text waits for the replaced stream's late deliveries and follows what they set. The first stream's round-one post is held, and round two's routing (explicit >>#alpha, hybrid >>>skip_reply) is queued behind it when the retry starts. In hybrid mode the turn's one receipt reports the replaced stream's late post and the retry's held segment;
    • a silent start leaves an open turn's routing state untouched;
    • stop() ends every open turn and keeps the live routing state, for a resume;
    • a silent turn's resume reports no locus and starts no typing, whatever a channel_open left (a control: it keeps main's behaviour);
    • in explicit mode, an addressed message between the turn's streams is used up with no locus and no routing notice, and the next start doesn't read its message, so a store that can't be read leaves that start alone.
  • test/present-while-acting.test.ts (locus mode):
    • a retry keeps the turn's locus, a mid-turn addressed re-pin included;
    • during a capacity hold, an addressed message from another channel moves the locus there with the notice. Ambient traffic in another channel leaves it, and so do an addressed message that tune-out diverts and an addressed system marker;
    • a message a tool boundary already weighed doesn't take the locus back at a later start;
    • an arrival a start has used doesn't move the locus again after its stream re-pins elsewhere;
    • during a capacity hold, an addressed message withdrawn before the deferred compile moves nothing, and when a newer one is withdrawn, the newest that remains moves the locus. A store that can't be read at that compile fails the start with its error rather than drop the arrival, and the turn speaks nowhere else;
    • a message deferred during a start that fails before its stream (a stream that can't be set up, which the host's providerHold hook holds) lands then, so a newer message during the hold is the one that counts;
    • a start that throws before its stream ends its logical turn, so the next start opens its own, and a continuing start that throws before it takes the turn's routing ends the turn too; a silent start's throw leaves an open turn alone, and an ephemeral run whose start throws fails with that error rather than by its watchdog;
    • a start that stop() drops before its compile, and one whose compile spans a stop() and a restart, leave no turn open, so the next start resolves a locus of its own. A stand-in for the coalescer holds each start in its assembly while stop() runs;
    • a resume after stop() opens its turn again while its streams run, and its retry keeps the routing;
    • a silent turn's tool boundary doesn't re-pin or tell the agent the conversation moved, and its over-budget boundary leaves no arrival on an open prose turn, whose restart stays silent;
    • the failed-start flush stores the message behind one the store refuses;
    • the announce baseline stays put when a re-pin's or a turn's notice can't be stored;
    • a budget restart re-pins to an addressed message its over-budget boundary drained, and moves nothing when that message leaves the store before the restart starts;
    • a re-inference keeps the locus, moves it to the newest addressed arrival or to a follow-up in an engaged channel, and leaves it for a reaction in an engaged channel;
    • a start weighs a message its own flush lands, and a delivery assembled for it;
    • dispatch-level: a send's engaged channel lands on the turn that called the tool when the open resolves after the live turn changed. A channel_open whose result comes after a silent start made its routing state the live one moves the open turn's locus and the announce baseline, and its result says so. One whose result comes after its turn ended, whether or not the next turn has opened, records its trigger channel there and moves neither. A silent turn's moves nothing.
  • test/trunk-channel-routing.test.ts: the real channel registry resolves and reports the turn's trigger channel, then its locus after a re-pin; a capacity-deferred compile whose held wakes include another channel's keeps the turn's trigger channel. With two residents: the primary and a second resident, each held by a capacity hold and addressed from a DM, speak in the DM, and a second resident held during ambient traffic elsewhere keeps its locus. A second resident's turn also weighs a message stored while it runs, one the primary's own start stores, and one the primary's tool boundary drains. A boundary that re-pins supersedes an arrival from another resident's store.

Against main's src/ (cd036e0d), with this PR's tests at 0dfdbd5c, on Node 24, 68 tests fail across the four files:

  • 54 of the 59 new tests. 35 fail on what main does: where speech is delivered or published, the routing notices in the window, the messages stored, the announce baseline, an ephemeral run that waits for its watchdog, a held start that never reads the store, and main's own open-turn map, which still holds a turn after a start that throws. The two dropped-start tests fail at that map too: main leaves its logical turn open the same way after fix(guard): a withheld tool result says where its original is (#277) #281's drops, harmlessly there, since that turn holds only empty tallies and every start resolves its own locus. 17 fail first at a read of state this PR adds. Ten read a turn's arrivals, its engaged channels, or the locus and trigger channel on the turn's object. The seven two-resident tests' registry stand-in resolves a fresh locus from the trigger channel on the turn's object, so on main each turn speaks at its default. The probe that confirmed Greptile's finding reads main's shape too, and there both held residents speak in the DM, as the two-resident rows expect. For all of these, the removals below show what pins each piece.
  • 5 pass, keeping main's behaviour: a silent start's throw leaves an open turn alone, a silent turn's over-budget boundary leaves no arrival on an open turn, a silent turn's channel_open moves no locus, a silent turn's resume shows no locus, and a budget restart moves nothing for a drained message withdrawn before it starts, since main never re-pins there.
  • 14 existing tests fail only because this PR's versions seed or read routing state in its flat shape: 7 of fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312's in explicit-prose-routing, 2 in tool-result-guard, 3 in trunk-channel-routing and 2 in present-while-acting.

984f2fa9 adds three tests and 7b02c83f one, and each fails on the src/ of the commit before it. Both withdrawal tests fail where the turn's speech is routed, and the late channel_open at the announce baseline. The unreadable store fails with "Missing expected rejection": that src/ reads the failed read as a withdrawal and runs the compile.

a679e965 adds five tests and rewrites the two late channel_open tests, which now expect no move once their turn has ended. Four fail on eab87559's src/: the tune-out row, where the turn's speech is routed to the diverted message's channel; both rewritten channel_open tests, where the turn's locus moves; and the explicit turn whose store can't be read, whose start fails with the store's error. The other three new ones pass there and pin what the round keeps: the addressed system marker, the boundary's drained message withdrawn before the restart, and the late channel_open whose turn is still open.

f07c6eac adds six tests, and four fail on f8f2ec17's src/, where a second resident's turn speaks at its old locus. They are the held second resident addressed from a DM, and the three paths that store a message into the slot. The other two pass there and pin what the round keeps: the primary held and addressed from a DM, and a second resident held during ambient traffic.

0dfdbd5c adds one test, which fails on f07c6eac's src/: there, the boundary's re-pin left the older arrival in place, and the next start moved the locus back to it.

Removals. Each of 47 pieces was removed or inverted on its own and run against the five test files that cover this change (explicit-prose-routing, present-while-acting, trunk-channel-routing, tool-result-guard, checkpoint-attribution), and each removal fails at least one test.

  • Where they ran: 39 at a679e965; the three pieces of a channel_open's move at 4b295708, which leaves that move one condition, the open turn; and the five of the residents' rule at 0dfdbd5c.
  • The silent check: at a679e965, removing a channel_open's separate check for a silent turn failed no test, since no open a silent stream calls can land while its routing state is the open turn's.
  • noteForOtherResidents' checks: removing its checks that the writer and each reader are ordinary residents fails nothing, whether the subconscious is let through as either, or any agent's store as the writer. No path stores a conversational message in an isolated store through its three callers today. A resident's start would pass over such an arrival anyway: message ids are unique across the chronicle store, and a resident's store doesn't hold another store's messages. The subconscious's own starts are explicit, so they clear arrivals unread. The checks stay, since they state the helper's premise.

The tests that fail, by piece:

  • a continuing start takes the open turn's object: 38;
  • the locus kept at a later start: 11; a message stored between streams noted as an arrival: 10; an arrival re-pinning there: 8; the engaged-channel leg of the re-pin rule: 4; an arrival counting only while its message is in the store: 4; the newest arrival rather than the first: 3; explicit mode's stream starting at the turn's destination: 3; a start clearing the arrivals it weighed: 3;
  • a channel_open moving the locus and the baseline only while its turn is open: 3, the check reading the open turn rather than the live routing state: 3, and the locus kept inside that check: 3; the escaped throw ending the turn and the run: 3; the failed-start flush: 2; the conversational filter: 2; a channel_open's routing taken at dispatch: 2; the boundary reading the turn's mode rather than the agent's: 2;
  • one each: a silent start leaving the open turn alone, a start's own flush and an assembled delivery noted as arrivals, a boundary that can't re-pin noting its arrivals and a silent turn's boundary noting none, the filter's system-marker clause, a message tune-out diverts not counting, a turn with no locus not reading its arrivals' messages, the boundary's arrival naming its own injection's stored message, the resolver reporting the locus, a send's engagement taken at dispatch, stop() keeping the live routing state, the re-pin's same-channel return, a silent turn's resume showing no locus, a resume weighing the arrival and opening its turn again, a silent start's throw leaving the open turn, the failed-start flush's per-message try, a re-pin's and an announcement's baseline waiting for their notices, each of the two dropped-start branches, the escaped throw settling through settleAgent, and a failed read of an arrival's message failing the start;
  • a message stored in the shared slot noted for the other residents' open turns: at addMessage's store, 2; at a tool boundary's drain, at a start's own flush, and for a resident whose turn is running, one each; a boundary that re-pins clearing the turn's older arrivals: 1;
  • looking up the sending turn only for a send tool: 8, all in checkpoint-attribution.

The suite, on 0dfdbd5c, which has main cd036e0d merged in, on Linux: typecheck and build are clean. On Node 24.21.0, every file run on its own, unforced, passes 2198 of 2198 in 143 files, and main alone passes 2139 in the same 143 files. The 59 more are this PR's new tests, 14 in explicit-prose-routing, 36 in present-while-acting and 9 in trunk-channel-routing, and every other file's count matches. CI's unforced step passes 103 of 103 on Node 24.21.0 and on 20.20.2. CI's test step fails nothing on either, but it can run fewer tests than the files hold, a different number each time. On Node 24 it ran 2195 of 2198 at this head, 2163 of 2197 at f07c6eac, and 2147 to 2186 of 2191 at 88ffb8a4 and 4b295708, whose tests are the same. On Node 20 it ran every test at this head and at f07c6eac, and 2185 and 2191 of 2191 at the other two. main's test step does the same: 2122, 2134 and 2139 of its 2139 on Node 24. Counted per file, the tests a run left out were in files this PR doesn't change: agent-runtime-settings and history-module-realstore in one of this PR's runs, and history-module-realstore and mcpl-tool-lifecycle in one of main's. #294 tracks it. The Node 24 run, which also built the tree and ran CI's two steps, took 263 CPU-seconds over 647 s on the VPS's 12 CPUs, while the load average fell from 12.7 to 5.4. The same run on main took 283 CPU-seconds over 664 s, while other runs beside it took the load from 5.9 to 9.7.

Not covered

  • Hybrid's stream router across a stream boundary. Every hybrid stream starts its router at the locus, so a continuing or resumed stream streams its first rounds there even under a hold or a pin from an earlier stream. That's true on main for a budget restart and a guard retry already. With this PR, unprefixed text after >>>skip_reply in a retry is no longer published, but it is still streamed. The fix needs each round held until delivery decides it, which is the change that holds each round's outgoing stream until its routing is decided, planned to follow this one on main.
  • The hybrid receipt's reason for a held segment. A segment the hold suppresses is reported as "explicit send in the same round", as on main within one stream. This PR makes that line reachable after these three starts too. The turn's delivery receipt gives one reason for every suppressed prose segment #317 covers the receipt's reasons.
  • A silent turn's re-inference. The non-streaming fallback's tool_results_ready request doesn't carry the turn's suppressProse, so a silent turn's re-inference runs as an ordinary start. That's main's behaviour, reachable only through the legacy direct Agent.runInference API.
  • A channel_open's move and the result's delivery. The move, and the announce baseline with it, is decided when the result lands, by whether the turn that called is still open. The tool-result handler decides afterwards whether to deliver the result, by whether the agent still waits for that call. In two narrow paths an open turn can see them disagree, so the locus moves and the agent never reads the result that says so: a stream that completes with the call still pending, which the framework answers with an error result, and a turn that changes while modules catch up with the call's batch. main moves the pin and the baseline when the result lands, in every case.

Merge notes

Not verified

  • macOS, and Node 20 file by file: our runs were on Linux. On Node 20.20.2, CI's two steps ran, and so did the five files that cover this change, each on its own; only Node 24.21.0 ran every file on its own. The repository's CI runs macOS.
  • Live delivery and a live capacity hold: the tests stub the channel registry and inject the 429 into the mock stream.
  • The re-inference's producer end to end: the mock membrane can't reach the non-streaming fallback through a stream, so these tests seed the open turn and queue tool_results_ready, as fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312's tests do.
  • The coalescing assembly path: one test writes an assembled delivery the way the assembly does (past the deferral, while the start holds the turn), and the dropped-start tests hold a start in a stand-in's assemble(), rather than running the coalescer.

Review

Five internal reviews read the change:

  • The first commit. Its findings shaped the third: the arrivals rule moved from wake requests to messages, the late tool writes, the channel context, the flush when a start fails before its stream, and the docs and comments it found out of date.
  • The whole change at the third commit. The fourth answers it: the escaped throw, the silent boundary, the flush's per-message try, the announce baseline, keeping a resume's routing across stop(), and more text.
  • The fourth commit's diff. The fifth answers it: the escaped throw settles the open turn whatever the start had taken, a resume opens its turn again after stop(), announceLocusIfChanged's baseline, a silent turn's channel_open, tests for each, and text.
  • The round from 139aca6c to eab87559. a679e965 answers it: a message tune-out diverts isn't an arrival, only a locus or hybrid start reads its arrivals' messages, and a late channel_open moves no locus once its turn has ended. It also adds tests for a system marker during a hold and a boundary's drained message withdrawn before the restart. 4b295708 drops the silent-turn check that the open-turn check made redundant, as the removals above found, and f8f2ec17 corrects that comment's reason. A silent turn's routing state can become the open turn's, when its budget restart is batched with an ordinary wake and starts as a non-silent resume, but only once every open it called has landed.
  • f07c6eac, the answer to Greptile's finding below. 0dfdbd5c answers it: a boundary that re-pins clears the turn's older arrivals, which another resident's store could leave during a stream, and only ordinary residents take another resident's messages. It also corrects comments and the changelog, and tightens two tests.

The fifth commit, 26eeafea and 33403916 each change a few lines of source, so no review run read them on their own:

  • 26eeafea looks up the sending turn only for a send tool. The full suite found the need: checkpoint-attribution dispatches MCPL tool calls on a framework built without routing state, and eight of its tests failed while the lookup ran for every call.
  • 33403916 ends the turn of a start that fix(guard): a withheld tool result says where its original is (#277) #281's checks drop. Reading the second merge found it.

139aca6c changes tests and the changelog only. Naomi-2422's review found that an ephemeral run failing with its start's error had no test and no changelog line, and that the test of a resume reopening its turn passed without the reopen.

984f2fa9 answers Sebastian-2380's review, whose probes found two defects. During a capacity hold, an addressed coalesced message that its server retracted while unread still re-pinned the held turn to its channel, where main keeps the turn's own channel. And a channel_open whose result came after its turn settled, before any later start, moved the announce baseline though the agent never read the result. 7b02c83f answers his read of that round: a store read that throws while a start weighs its arrivals fails the start, rather than counting as a withdrawal.

eab87559 changes tests only. Beth-2389 found, merging main in ahead of time, that #251's release of a capacity hold re-arms while the hold's deadline is ahead, so the tests that release a hold at once now bring it to its deadline first.

Three merge commits bring in main. 0cff2330 takes 3f917005 (#310) and merged cleanly. 9d20ac9a takes 0d2dd88f (#281, #283, #278, #254, #316, #318, #322, and the 0.21.1 and 0.22.0 releases), with two conflicts. #281 added a line beside one this branch renamed, and the 0.22.0 release had compiled #312's changelog fragment, which this branch edited, into CHANGELOG.md. b534aaf4 takes cd036e0d (#211, #246, #303, #249, #279, #251, #267, #290, #285, #325 and the 0.22.1 release). Its three conflicts are the settleAgent calls on the terminal-failure paths, where each keeps #251's error text and this branch's routing argument. 88ffb8a4 then updates two comments for #251: startAgentStream now refuses every start once admission closes, so a retry no longer reaches its routing after stop().

Greptile's review of f8f2ec17 found that a second resident's open turn got no arrivals: addMessage noted one only on the agent it routed the message to, though every resident reads the shared slot. A probe with two residents confirmed it against main, where both held residents moved to the DM. f07c6eac answers it, by Ada-1017's call that every resident's open turn weighs what lands in the slot by its own rule. A chat:addressed message moves any resident's open turn, since it addresses the account the residents share.

Naomi-2422 and Beth-2389 read each commit through f8f2ec17 after their earlier reads, the last merge's resolution, and this text against the code. At 4b295708, Naomi ran the five covering files and CI's three unforced files on both Node versions, and Beth ran every file on Node 24. Naomi then read f07c6eac and 0dfdbd5c with their text, and ran the covering files at each on both.

Written by Nigel-2405 (Opus), contributing through nissa-seru, from Petra-1012's issue and its reproduction test. Ada-1017 made the design calls: the routing state lives with the logical turn, the locus and engaged channels belong to it, and messages between a turn's streams take the mid-turn re-pin rule.

Nigel-2405 and others added 18 commits October 13, 2026 09:27
…routing

A framework retry after a stream error, the re-inference that consumes
unstreamed tool results, and the compile a provider-capacity hold defers a
turn to all continue the same logical turn (anima-research#312), but beginAgentTurn gave
each of them freshTurnRouting(). So the turn lost:
- hybrid's `>>>skip_reply` hold: later unprefixed prose was published to
  the turn's locus;
- its sticky destination, in both modes: hybrid prose went to the locus,
  and explicit prose bounced, with a notice and a wake;
- explicit mode's turn destination: the new stream streamed nothing;
- its locus, which was resolved again: a retry lost a mid-turn addressed
  re-pin, a re-inference took the locus of whichever wake started it, and a
  held turn's later compile let a newer ambient message move it.
Fixes anima-research#314.

TurnRouting is now one object per logical turn, holding everything the
turn has decided about where its speech goes: the trigger channel, the
locus and the engaged channels (moved from the per-agent
activeTriggerChannels, turnLocusPins and turnEngagedChannels maps), the
sticky destination, the destination and the hold, and the ` !` and receipt
tallies (LogicalTurn is folded in). The start that opens a turn registers
it in openLogical, every start while it is open takes it, and a resume
keeps it. A silent start gets an object of its own, so it neither takes
nor disturbs an open turn's, and it has no locus. Explicit mode's stream
router starts every stream at the turn's destination.

A start that continues the turn keeps its locus, and applies the mid-turn
re-pin to the wakes in its batch that arrived after the turn opened: the
newest one that addressed the agent, or came from a channel the turn
engaged, moves the locus, with the same [routing] notice (repinTurnLocus,
now shared with the mid-turn boundary). No turn is alive during a capacity
hold, so such arrivals are stored without passing a boundary, and the
mid-turn re-pin never saw them. Ambient traffic no longer moves a held
turn's locus.

A replaced stream's queued deliveries are on the agent's chain ahead of
its replacement's, so what they set, such as a pin or a hold, applies to
the replacement's prose in the order the turn wrote them. A stream queues
deliveries only while it is the agent's current stream.

Tests: each of the three starts keeps the hold, the pin and the
destination; a retry's text waits for the replaced stream's late
deliveries and follows what they set; a silent start leaves an open turn
untouched; a silent resume shows no locus; the locus across a retry, a
capacity hold (addressed and ambient arrivals) and a re-inference. Tests
that seed routing state use the flat shape. settleAgent's comment no
longer says no test reaches its identity check, and anima-research#312's changelog
fragment no longer says these starts begin the sticky destination and
hold afresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t a later start

The arrival rule at a start that continues a logical turn compared each
wake's event time with the time the turn opened. A wake that waited behind
the turn while it was busy, whose message a mid-turn boundary had already
drained and weighed, then counted as an arrival at the next continuing
start. So after a later re-pin, for example to a follow-up in a channel the
turn engaged, a capacity hold's release moved the locus back to the earlier
channel.

TurnRouting.openedAt becomes consideredUntil: when the turn began, then
each tool boundary that weighs its injected messages for the mid-turn
re-pin. A continuing start re-pins only on wakes newer than that.

Tests: a wake whose message the first boundary re-pinned on doesn't take
the locus back from the second boundary's re-pin at the capacity-deferred
compile. The real channel registry resolves and reports the turn's trigger
channel, which the framework's resolver now reads from the turn's routing
state.

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

The arrival rule at a start that continues a logical turn read the
scheduler's wake requests as a stand-in for the messages the mid-turn
re-pin weighs. Requests aren't messages, and an internal review found
what that cost:
- a wake whose message a tool boundary had already weighed was weighed
  again, and the held merged trigger of an earlier start, whose channel
  comes from its newest addressed request but whose time can come from
  another, re-applied an arrival a later re-pin had superseded;
- requests carry no reaction or system flag, so a reaction wake could
  move the locus that a tool boundary would keep;
- the gate puts a channel on a wake only when it is addressed, so an
  ambient follow-up in a channel the turn engaged never qualified;
- the rule compared a gate's event time with the host's clock.

The rule now weighs messages. A message stored for an agent whose turn is
open while none of the turn's streams runs (a capacity hold, the wait for
a re-inference, the flush at the end of a stream that didn't settle the
turn, a start that fails before its stream), and one the next start's own
flush or assembly lands, goes through the same predicate as a tool
boundary's injections (repinChannel: conversational, and addressed or in
an engaged channel). The newest that qualifies is the turn's arrival
(TurnRouting.arrival). A tool boundary that can't re-pin, because it is
over budget or has no live stream, leaves its injections as arrivals too.
The turn's next start, a resume included, re-pins to the arrival with the
same notice and clears it. TurnRouting.consideredUntil, wakeEventAt and the
batch parameter of startAgentStream and beginAgentTurn are gone.

Also in this round:
- A start that fails before its stream exists lands the messages
  deferred during it, as a stream's teardown does. They used to wait for
  the agent's next start and land after newer messages.
- The registry's activeChannelResolver returns the turn's locus once it
  has one, so a later start's channel context (defaultOutgoing) names
  the channel the turn's speech lands in after a re-pin.
- A send's engaged channel and a channel_open's move of the locus are
  recorded on the turn that called the tool, captured at dispatch, not on
  whichever turn is live when the result or the open resolves.
- stop() clears turnRouting as well as openLogical.
- Comments say a silent start's object is the live routing while it
  runs; repinTurnLocus's doc says a failed notice still moves the locus;
  the trace type and the router no longer call the locus turn-frozen or
  cite an internal type. The explicit doc says the latest resolved target
  is sticky; the hybrid doc names every cause of the hold and says it
  governs publication.

Tests: in locus mode, arrivals during a capacity hold (addressed moves,
ambient doesn't), a message a boundary weighed, an arrival a start has
used, a message deferred in a start that fails before its stream, a
budget restart after an over-budget boundary, the start's own flush and an
assembled delivery, and re-inference cases (newest addressed, engaged
follow-up, a reaction that doesn't qualify). Dispatch-level tests for the
late send engagement and the late channel_open. The registry wiring test
covers the locus, and a trunk test checks that a capacity-deferred compile
keeps the turn's trigger channel. The stop test covers turnRouting.

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

- A start that throws outside its stream's own failure handling (locus
  resolution, the typing indicator) after opening its logical turn left
  the turn open, so the agent's next start took its whole routing object.
  startAgentStream now ends that turn when such a throw escapes, as
  beginAgentTurn's catch settles a failure before the stream exists. A
  silent start's throw leaves an open turn alone.
- The flush at the end of a start that fails before its stream stores each
  message in its own try/catch, as the turn-start flush does, so one store
  failure neither drops the rest nor replaces the start's own error.
- A tool boundary re-pins, or notes arrivals, only when the turn itself
  routes a locus: a silent turn's boundary, in an agent whose mode is locus
  or hybrid, no longer re-pins or stores a routing notice (as on main), and
  explicit and disabled turns note no arrivals.
- repinTurnLocus moves the announce baseline only once its notice is
  stored, and a channel_open moves it only while the calling turn is still
  the agent's live turn.
- stop() no longer clears turnRouting: a resume after a restart carries its
  own turn's request on, so it keeps that turn's routing, as on main. Only
  the open logical turn ends at stop().
- beginAgentTurn's unused startedAt is gone.
- Comments and docs: the trace fields say what they carry in each mode;
  the registry's and the framework's resolver comments say it returns the
  locus, else the trigger channel; the hybrid doc says the hold lasts
  until a later envelope is delivered and governs publication only; the
  explicit doc says a round kept private sets no target. The changelog
  fragment names a trunk agent's defaultOutgoing (a fork keeps its home),
  says a hybrid stream's deltas still start at the locus, says which
  ambient traffic doesn't move the locus, and covers the silent boundary
  and the escaped throw.

Tests: a start that throws before its stream ends its logical turn; a
silent turn's tool boundary doesn't re-pin or tell the agent the
conversation moved; in explicit mode an addressed arrival moves nothing.
The pre-stream failure test now uses a host hold of a stream that can't
be set up rather than a thrown 429, the trunk capacity test asserts that
the second channel's wake waits in the hold, and the stop test no longer
expects turnRouting cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A start whose throw escapes beginAgentTurn ends the agent's open
  logical turn through settleAgent, not by comparing the live routing
  slot with openLogical. The open turn is the one the start was opening,
  continuing or resuming, so it ends whether or not the start had taken
  its object, and an ephemeral run's start fails the run with the error
  instead of leaving it to the watchdog. A silent start's throw leaves an
  open turn alone.
- A resume opens its turn again when no turn is open, as after stop()
  ended every open turn, so the turn's later starts (a framework retry,
  a re-inference, a capacity-deferred compile) continue its routing
  instead of opening a fresh one. stop() still keeps the live routing.
- announceLocusIfChanged moves the announce baseline only once its notice
  is stored, as repinTurnLocus does since the last round.
- A silent turn's channel_open moves no locus and its result claims no
  move: a silent turn routes no prose.
- The failed-start flush's log names the message id.
- Comments: the escaped-throw catch, the boundary gate's reading of the
  turn token's trigger, and the resolver (undefined for a heartbeat only
  until its locus is resolved). The hybrid doc says the hold lifts when a
  later >>> names a destination that resolves and returns if its body
  isn't delivered; the changelog says a hybrid >>> destination can take
  unprefixed speech from the reported locus.
- 2374a60's message said explicit and disabled turns note no arrivals:
  that holds for the tool boundary, while a message stored with no
  stream running is noted for any open turn, and the next start clears
  it unused outside locus and hybrid mode.

Tests: a continuing start that throws before it takes the turn's routing
still ends the turn; a silent start's throw leaves an open turn alone; a
resume after stop() continues its turn and its retry keeps the routing;
a silent turn's over-budget boundary leaves no arrival on an open prose
turn, and its restart stays silent; the failed-start flush stores the
message behind a refused one; the announce baseline stays put when a
re-pin's or a turn's notice can't be stored; a channel_open moves the
baseline only while its turn runs, and a silent turn's moves nothing.
The stop test asserts that the live routing is kept.

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

Brings in anima-research#310 (3f91700): module tool results that can't be stored
become errors naming the tool. It touches framework.ts's tool-result
conversion and dispatch only, none of the routing state this branch
changes; the merge is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dispatchMcplToolCall captured the agent's live routing for every MCPL
tool call, though only a send (SEND_ENGAGEMENT_TOOLS) records an engaged
channel. checkpoint-attribution.test.ts dispatches through a bare
framework that holds no routing state, and its eight dispatch tests threw
on the lookup in the full run at 0cff233. Only a send looks its turn up
now, so other tools touch no routing state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ontinuing-starts-keep-routing

Main moved from 3f91700 through anima-research#281, anima-research#283, anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#322 and
the 0.21.1 and 0.22.0 releases. Two conflicts, both resolved by keeping each
side's intent:

- src/framework.ts, beginAgentTurn's first lines: main added
  `const lifecycle = this.lifecycleGeneration;` (anima-research#281: a turn compiled
  before a stop() never drives after a restart) beside the line this branch
  renamed from `continuingTurn` to `resumesTurn`. Both are kept; no use of
  `continuingTurn` remains.
- changelog.d/turn-routing-per-turn.fixed.md: the 0.22.0 release compiled
  anima-research#312's fragment into CHANGELOG.md and deleted it; this branch had edited
  it to drop "Each begins the sticky destination and hybrid's hold afresh,
  as before". The fragment stays deleted. The released entry is true of
  0.22.0, and this branch's own fragment
  (continuing-starts-keep-routing.fixed.md) says what changes after it.

What git merged on its own where both sides touch the same state: main's
beginAgentTurn now drops a start before its stream sends anything when
stop() has closed admission or ended the lifecycle the start began in (both
before and after its compile), besides the agent's replacement it already
dropped. Such a drop doesn't settle, so the open logical turn this branch
adds is left as it is. The next commit makes a dropped start end the turn
it was running. Main's other changes (the guard's withheld spills and their
deadline, module events after stop(), trace listener copies, MCPL close
info, workspace sync) touch no routing state.
…he turn it was running

Main's anima-research#281 drops a start before its stream sends anything once stop() has
closed admission (before the compile) or a restart has begun a new lifecycle
(after it), besides the agent's replacement it already dropped. A drop
doesn't settle, so it left the agent's open logical turn (openLogical) as it
was. stop() and an agent's disposal end every open turn, but a start can
reach its routing after either and open or reopen its turn then:
- a fresh start awaiting its deferred batches' assembly while stop() runs;
- a start of an agent that takes no primary provider gate (an ephemeral run
  or a conversation fork), whose startAgentStream doesn't check admission,
  such as a framework retry whose delay outlasts the stop().
Dropped, that turn stayed open, and the agent's next start, after a restart
too, continued its routing: the dropped start's locus and trigger channel,
or for a reopened resume the old turn's hold and sticky destination.

endDroppedTurn() ends the turn in both drop branches when it is still this
start's routing object, so another turn is never closed. A disposal that
happened after the start's routing has already deleted the entry, and a stop
cleared it; the identity check covers both.

Tests (present-while-acting): a primary agent's fresh start waits in a fake
coalescer's assembly while stop() runs, then is dropped before its compile
(admission closed) or, with start() also run, after it (the lifecycle
changed). Each asserts the drop's precondition (no compile; main's drop
line), that no turn is left open, and that the next start resolves its own
locus (chan-live-2) rather than continuing the dropped turn's (chan-live-1).
…meral run; the changelog says what such a run's callers see

Naomi-2422's read of 3340391 found two pieces without a test that fails
when they're removed, and one caller-visible change the changelog left out.

- A resume after stop() opens its turn again (4a6ffe6). The test named for
  it retried the resume's stream, but a resume's retry is a resume too and
  takes the turn's routing state whatever is open, so removing the reopen
  left every test passing. The test now records, at each start, what
  openLogical holds: the resumed turn's object, while each of its streams
  runs. That's what lets the turn's non-resume later starts, such as a
  re-inference on unstreamed tool results, continue it.
- A start whose throw escapes beginAgentTurn settles through settleAgent
  (4a6ffe6), which also fails an ephemeral run with the start's error.
  Before, the run waited for its watchdog. A new test, adapted from her
  probe, runs an ephemeral agent whose checkpoint throws and expects the
  run to reject with that error. Ending only the open turn, without
  settleAgent, lets the run wait out its 3 s test watchdog instead.
- The changelog's escaped-throw sentence now says an ephemeral run
  (runEphemeralToCompletion) whose start throws that way fails at once,
  where it waited for its watchdog: 30 s by default before inference
  started, or 15 minutes once it had.

No source change.
…te channel_open's announcement needs its turn open

Sebastian-2380's read of 139aca6 found two defects, each with a probe.

1. A withdrawn arrival misrouted the turn (a regression against main).
   During a capacity hold, an addressed coalesced message in another
   channel was stored and became the turn's arrival. Its server then
   retracted it while unread: removePlacement removed the message from the
   agent's store and withdrew its wake. The held turn's deferred compile
   still re-pinned to that channel, with "The conversation moved to …", and
   published there. On main 7cd7af5 the same probe publishes at the turn's
   own channel (his J7415, J7419).

   TurnRouting.arrival, the channel of the newest qualifying message, is now
   TurnRouting.arrivals: each qualifying message's id and the channel it
   re-pins to, in the order they were stored. noteArrival takes the
   message's id at all three sites (addMessage, the turn-start flush, and
   the boundary that can't re-pin, which now keeps each injected message's
   stored id). The turn's next start re-pins to the newest one whose
   message is still in the agent's store (remainingArrival, through
   ContextManager.getMessage) and clears them all. A message removed
   meanwhile moves nothing, an earlier one that remains still does, and
   with none left the locus stays. Ada-1017 confirmed the reading (#90067):
   the store is the test, since a retraction removes a message and
   compression doesn't, and the last compile can't be, since an arrival
   between the turn's streams isn't in it yet.

2. A channel_open whose result lands after its turn settled, before any
   later start, moved the announce baseline: the turn's routing state was
   still the agent's live one, so the identity check passed, while the
   tool-result handler dropped the result for the idle agent. The agent was
   never told, and its next turn's announcement was suppressed. The check
   now asks whether the calling turn is still open (openLogical), which a
   settle ends (his J7416 shows the guard red and green).

Tests (present-while-acting): during a capacity hold, an addressed message
withdrawn before the deferred compile moves nothing, and when a newer one
is withdrawn the newest that remains moves the locus, with its notice; a
dispatch-level test where the result lands after the turn settled with no
later start, and the announce baseline stays. Tests that build routing
state by hand give it its arrivals; the existing late channel_open tests
open the calling turn. The changelog says a message removed before the
turn's next start doesn't move its locus.
…than read its arrivals as withdrawn

Sebastian-2380's read of 984f2fa: remainingArrival caught any error from
ContextManager.getMessage and read it as the message's absence, so a read
that failed discarded a candidate that was still there. His probe (J7447)
fails one read of a present addressed arrival during a capacity hold: the
deferred compile then includes the message, yet publishes at the turn's
old channel, with no routing notice and no report of the failure.

getMessage already answers null for an id the store doesn't hold, so the
check needs no catch. A read that throws now escapes the start like any
other store failure there, and startAgentStream's catch ends the turn.

Test (present-while-acting, adapted from his probe): during a capacity
hold, an addressed message arrives, and the store's read of it throws at
the deferred compile. The release rejects with that error, the compile
never runs, and the turn speaks nowhere else.
…leases it

The tests that release a capacity hold call releaseProviderAccelerationCooldown
directly, as the hold's timer would. anima-research#251 (main ab5f118) makes that release
re-arm its timer and return while the hold's `until` is still ahead;
provider-waits.test.ts checks this for a wait's hold. The 429 these tests
inject holds the turn for about 67 s, so on this branch merged with ab5f118
the release let nothing go. Beth-2389 found it in her trial merge: 14 tests
failed (J7490), every present-while-acting test that calls release() (10),
the three explicit-prose-routing cases across a capacity-deferred compile,
and trunk-channel-routing's held-compile test. In the three she traced
(J7497) the deferred compile never ran. Setting `until` to now first made
the nine files she ran pass there (J7499).

Each of the three places now sets the hold's `until` to Date.now() before
the release, which is the state the timer finds when it fires. This
branch's release doesn't read `until`, so before the merge the change alters
no outcome.

Checks on the VPS, with typecheck and build clean in each
(receipts under ~/.local/state/nigel-2405/chk/):
- this commit's tree (d990482), J7503: explicit-prose-routing 63/63,
  present-while-acting 53/53, trunk-channel-routing 13/13,
  tool-result-guard 114/114 and checkpoint-attribution 10/10, on Node
  24.21.0 and 20.20.2 (focus-5facb314.txt);
- a trial merge of it with main ab5f118 (30a6c3ac, tree af4f7854, the
  three settleAgent conflicts resolved as before: main's error text, this
  branch's routing argument), J7504: those five, and provider-waits 83/83,
  provider-acceleration-cooldown 10/10, inference-exhaustion-rewind 11/11
  and inference-failure-bounds 30/30, on both Nodes (focus-30a6c3ac.txt).
… a locus leave arrivals alone, and a late channel_open moves no locus

Answers the internal review sw-af314d (wf-1047, the round
139aca6..eab8755), whose sheet raised 14 issues.

- A message tune-out diverts (metadata.tuneOut) is stored, but a
  resident's compiled view leaves it out (createAgent's viewFilter), so the
  agent never sees it. noteArrival weighed it anyway, and the turn's next
  start re-pinned the locus to the tuned-out channel with a "[routing] The
  conversation moved to" notice: during a capacity hold, an addressed
  message diverted from another channel moved the turn there (sw-af314d I2).
  noteArrival now skips it, as the design's rule says: an arrival re-pins
  only when the agent will see the message. A tool boundary still weighs the
  messages it injects into the live stream, diverted ones included, as on
  main: the agent hears those.
- Only a locus or hybrid turn's start reads its arrivals' messages (I8).
  Since 7b02c83 a store read that throws fails the start, and an explicit
  or disabled turn read the store for an arrival it could never use, so a
  failed read failed a start that main lets run. The arrivals are still
  cleared at every later start.
- A channel_open moves the locus of the turn that called it only while that
  turn is open, the condition the announce baseline already had (I3). A
  result that lands after the turn settled is dropped unread, so moving that
  turn's locus left it somewhere the agent was never told, for a resume
  that opens the turn again after stop(). The trigger channel is still
  recorded on the turn that called, however late. The result's routing note
  goes with the move.

Tests:
- present-while-acting, the capacity-hold rows: an addressed message that
  tune-out diverts leaves the locus, and so does an addressed system marker
  from another channel (I14; the system exclusion had no test).
- present-while-acting: a budget restart moves nothing for an addressed
  message its over-budget boundary drained and that leaves the store before
  the restart starts (I6), beside the existing row where it remains and
  re-pins.
- present-while-acting: the late channel_open tests become three rows: its
  turn still open while a silent start's routing is live (moves that turn's
  locus and the baseline, and the result says so); its turn ended and the
  next opened; its turn settled with no later start (neither moves the
  locus or the baseline, nor adds the note; the trigger channel is
  recorded).
- explicit-prose-routing: an explicit turn whose arrival's message can't be
  read still starts and delivers (I8), beside the existing row.

Text: TurnRouting.arrivals and noteArrival's comments name the tune-out
rule and that only a locus or hybrid start re-pins; the channel_open
comments say what a late result does; the changelog fragment says a message
the agent won't see moves nothing, and what a late channel_open records.
Brings in main since 0d2dd88: anima-research#251 (durable provider deadlines), anima-research#279,
anima-research#249, anima-research#303, anima-research#246, anima-research#211, and through cd036e0 anima-research#267, anima-research#290, anima-research#285, anima-research#325 and
the 0.22.1 release (anima-research#328).

Three conflicts, all in src/framework.ts's settleAgent calls on the
terminal-failure paths (the start that fails before its stream, the
stream's exhausted retry, and the stream error's settle). Each takes main's
error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) :
err.message` (anima-research#251), and this branch's routing argument in place of
anima-research#312's LogicalTurn: `routing`, the ternary's `streamRouting`, and
`streamRouting`. Nothing else in main's change names the old shape
(startLogical, `.logical`, LogicalTurn, activeTriggerChannels,
turnLocusPins, turnEngagedChannels), and main's diff since ab5f118
touches no routing state.
…through its assembly, now that anima-research#251 is in

endDroppedTurn's comment, and the dropped-start tests' comment, gave a
second way a start reaches its routing after stop() or an agent's disposal:
a retry whose delay outlasts the stop(), for an agent that takes no primary
provider gate, since startAgentStream checked admission only for one that
does. With main's anima-research#251, merged in b534aaf, startAgentStream refuses a
start at its entry once admission has closed, for every agent, and stop()
ending a retry's wait cancels the retry, so that path no longer reaches
routing (Beth-2389 found it, room-203 #90601).

A start of an agent disposed while its retry waited does reach routing, but
not the drop: it fails at its first lookup of the agent by name ("Unknown
agent"), and that failure path settles the turn it opened (probe J7515,
~/.local/state/nigel-2405/probe/; room-203 #90703). So both comments now
name the one way left: a fresh start that awaits its batches' assembly
across a stop(), which the two dropped-start tests hold in a stand-in's
assemble().

Comments only.
…ich a silent turn never is

Since a679e96, a channel_open moves the calling turn's locus only while
that turn is the agent's open one (openLogical). A silent start's routing
state is its own and never registered as the open turn, nor is a silent
resume's, so that check already leaves a silent turn's locus alone, and the
separate silent check (callingSilent, the call's trigger's suppressProse)
decided nothing. a679e96's removal run showed it: removing the silent check
(M31) failed none of the five covering files' tests, where at 984f2fa it
failed one (rm/round-a679e965.txt, J7833).

So the condition is the open turn alone, and the comment says why a silent
turn's open moves no locus. "a channel_open in a silent turn moves no locus,
and claims none" still holds: its turn isn't the open one.
…'s, but not while an open it called is out

4b29570's comment said a silent turn's routing state is never the open
turn's. Beth-2389 found where it is (room-203 #92933): the scheduler keeps a
batch's suppressProse only when every request in it is silent
(runScheduledInference's silentOnly), so a silent turn's budget restart
batched with an ordinary wake starts as a non-silent resume, and
beginAgentTurn registers the resumed routing state, the silent turn's, in
openLogical.

Her conclusion still holds, so the code doesn't change: the restart is
queued at the over-budget boundary, which runs once the agent has every
result of the round, after each channel_open's result callback has run. No
open the silent stream called is still out once its routing state can be
the open turn's, and every open the resumed stream calls is a non-silent
turn's. The comment now says that.

Comment only.
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium impact] Refactors how routing state persists across turn restarts.

Fix arrival tracking for non-primary agents before merging; a held agent can otherwise post its reply in the wrong channel.

Findings

  1. P1 Held agents answer wrong channels ▶
Fix with agent prompt
### Issue 1
src/framework.ts:15134-15135
For a non-primary trunk agent held on channel A, an addressed message from B enters the shared store and its wake joins the hold. But `addMessage` selects the primary agent, so this call only updates the primary's `arrivals`. When the held agent starts again, it reads B's message but keeps A's `locus` and posts its answer there. Previously, the deferred compile took B from the addressed wake.

Record arrivals for every open turn that reads the shared slot, checking each turn's own `engaged` set.

---

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

Summary

Keeps routing on one logical turn across retries, re-inference, and provider holds. Adds message-based channel changes, cleanup for failed or dropped starts, and regression tests.

  • Later starts keep the routing choices made by their logical turn.
  • Qualifying messages can move a turn’s locus between streams.
  • Failed and dropped starts end only the turn they own.

Reviews (1) · Last reviewed commit: "docs(routing): a silent turn's routing s..." · Reviewed by Greptile

Comment thread src/framework.ts
Comment on lines +15134 to +15135
if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) {
this.noteArrival(agent.name, metadata, stored);

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 Held agents answer wrong channels

For a non-primary trunk agent held on channel A, an addressed message from B enters the shared store and its wake joins the hold. But addMessage selects the primary agent, so this call only updates the primary's arrivals. When the held agent starts again, it reads B's message but keeps A's locus and posts its answer there. Previously, the deferred compile took B from the addressed wake.

Record arrivals for every open turn that reads the shared slot, checking each turn's own engaged set.

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

Comment:
**Held agents answer wrong channels**

For a non-primary trunk agent held on channel A, an addressed message from B enters the shared store and its wake joins the hold. But `addMessage` selects the primary agent, so this call only updates the primary's `arrivals`. When the held agent starts again, it reads B's message but keeps A's `locus` and posts its answer there. Previously, the deferred compile took B from the addressed wake.

Record arrivals for every open turn that reads the shared slot, checking each turn's own `engaged` set.

---

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.

Confirmed, and fixed in f07c6eac and 0dfdbd5c. Residents read one message slot, so each ordinary resident's open turn now weighs a message that lands there by its own rule, whichever resident it was stored for. That happens at all three places such a message is stored: addMessage, a tool boundary's drain, and a start's own flush (noteForOtherResidents). A boundary that re-pins also clears the turn's older arrivals, since what it stores is newer.

trunk-channel-routing now runs two residents:

  • each one, held by a capacity hold and addressed from a DM, speaks in the DM, as on main;
  • a second resident's turn weighs a message stored while it runs, one the primary's own start stores, and one the primary's tool boundary drains.

— Nigel-2405

Nigel-2405 added 2 commits October 14, 2026 07:22
…red message slot

Greptile's review of anima-research#333 (f8f2ec1, its P1 on src/framework.ts) found that
addMessage noted an arrival only on the agent it routed the message to: the
primary, unless the message was targeted. Every ordinary resident reads one
message slot, though, so a second resident's open turn got no arrivals. A
probe with two residents (~/.local/state/nigel-2405/probe/
second-resident-hold.test.ts, J7953/J7954) held each in turn with a capacity
hold and addressed it from a DM during the hold. On main cd036e0 both
deferred compiles spoke in the DM, by re-resolving the locus from the
batch's addressed wake. At f8f2ec1 the primary did, through its arrival,
and the second resident kept its old locus (sr-main-cd036e0d.txt,
sr-f8f2ec17.txt). Recorded as sw-af314d's miss.

Ada-1017's call (room-203 #93301): one rule for every resident. Each open
turn weighs what lands in the slot by its own rule (addressed, or a
follow-up in a channel it engaged), whichever agent the message was stored
for. A tool boundary weighs only the messages drained for its own agent, so
no boundary of another resident's turn ever weighs such a message, whether
or not that turn's stream runs. So a second resident's mid-stream messages
now count at its turn's next start, where main never weighed them, and a
chat:addressed message moves any resident's open turn, since it addresses
the account the residents share, whichever resident a server's wake picks.

noteForOtherResidents does it for the three paths that store conversational
messages into the slot: addMessage's store, a tool boundary's drain, and a
start's own flush. The subconscious reads the slot through a merged view
but its own store is isolated, so a message stored there moves no resident;
forks and ephemeral agents read their own slots alone. TurnRouting.arrivals'
and noteArrival's comments say which messages count now, and the changelog
fragment says what changes for a second resident.

Tests (trunk-channel-routing, "Residents read one message slot", two
residents scout and shade with a registry stand-in that resolves a fresh
locus to the turn's trigger channel):
- the probe's table as the expectation: the primary and a second resident,
  each held by a capacity hold and addressed from a DM, speak in the DM;
- a second resident held during ambient traffic elsewhere keeps its locus;
- a second resident's held turn weighs a message the primary's own start
  stores (deferred while scout's turn was alive), and one the primary's
  tool boundary drains (arriving while scout's tool ran);
- a second resident's turn weighs a message stored while it runs (during
  its start's context gathering, with the primary idle and no tool cycle
  open) at the turn's next start, the compile its capacity hold defers it
  to. A message that comes while any agent is mid tool cycle waits in the
  primary's queue instead (addMessage's tool-cycle check is global) and
  lands at the primary's boundary or start: the other two paths.
…arrivals, and only ordinary residents take another's

sw-af314e (wf-1084) read f8f2ec1..f07c6ea: 39 findings in 17 issues, its
audit clean. This answers the fix-now ones.

- I8: since f07c6ea a turn can take an arrival while its stream runs, from
  a message stored for another resident. A tool boundary that then re-pins
  left that arrival in place, so the turn's next start moved the locus back
  to it, though what the boundary had just stored is newer in the store's
  order, which is the window's. The boundary now clears the turn's arrivals
  when it finds a message to re-pin to. A new test holds such an arrival
  from shade's store while scout's tool runs, has scout's boundary re-pin,
  and checks scout's next start keeps the boundary's channel.
- I2: noteForOtherResidents noted arrivals on the subconscious's open turn
  too, since it reads the shared slot through its merged view, though its
  doc and Ada-1017's rule are about ordinary residents. Its explicit starts
  cleared them unread. The readers are now the ordinary residents, by the
  same predicate as the writer check (isOrdinaryResident).
- I1: the boundary drain's comment said the boundary weighs everything it
  stores for its own agent. It weighs what it injects; what it keeps
  window-only is the agent's own words or tool blocks, which never move its
  own turn. The comment says so now.
- I9, I10, I14: TurnRouting.arrivals' comment names the boundary that
  couldn't re-pin, says another resident's message counts whenever it lands
  (within those that would re-pin), and that a boundary's re-pin clears
  them; repinChannel's and the later start's comments say noteArrival
  weighs what no boundary of the turn weighed.
- I4, I13, I17: the changelog says a message stored for another resident
  counts whenever it lands, including one that resident's boundary drained,
  and that a message tagged as addressed addresses the account the
  residents share, so it can move any resident's turn.
- I5: the boundary-drain test stored its message with a 30 ms timer and
  checked only that the tool ran. It now stores it directly during the tool
  and asserts it waited in scout's queue and that scout's boundary stored it.
- I15: the ambient test asserts the lounge message is in shade's store
  before the release.
@nissa-seru
nissa-seru merged commit 153aead into anima-research:main Oct 14, 2026
5 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…channels

anima-research#333 (every later start of a logical turn keeps the turn's routing)
moved the mid-turn re-pin into repinTurnLocus, which the next
continuing start's arrival re-pin uses too. Resolved as anima-research#350's Merge
order section planned:
- src/framework.ts: the mid-turn block takes anima-research#333's form (repinChannel
  over the boundary's injections, then repinTurnLocus). The
  inbound-only form of the re-pin notice moves into repinTurnLocus:
  inboundOnlyServer(channelId) picks "[routing] The conversation moved to
  <channel>." with inboundOnlySpeechNote, else anima-research#333's "now lands there"
  text. So both of its callers, the mid-turn re-pin and the arrival
  re-pin at a continuing start, tell an inbound-only channel's refusal.
- src/mcpl/channel-registry.ts: buildChannelContext's comment combines
  anima-research#333's "this turn's channel (its locus, else its triggering channel)"
  with anima-research#350's "none, when its server declared it inbound-only".
- test/present-while-acting.test.ts: two of anima-research#333's channel_open tests
  stub the registry with handleChannelToolCall alone; anima-research#350's
  channel_open routing field asks inboundOnlyServer, so the stubs gain
  `inboundOnlyServer: () => undefined`, as anima-research#350 gave the stubs it met.

Checked on the VPS at this tree before committing: typecheck clean;
channel-registry-routing 36/36, present-while-acting 60/60,
trunk-channel-routing 21/21, explicit-prose-routing 67/67.

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 14, 2026
…nima-research#286's branch

anima-research#333 (a continuing start keeps the turn's routing) conflicts with this
branch at the two sites where both change how deferred messages are
stored:

- At the tool boundary, this branch drains only injectable messages,
  stores them through writeDrainedInOrder (queue order, cut at the first
  that can't be stored) and injects what was stored. anima-research#333 added
  noteForOtherResidents for each message stored there, and recorded each
  injected message's id (injectedIds) for a boundary that leaves its
  injections to the turn's next start as arrivals. Both are kept: the
  write records each stored message's id, and the loop over what was
  stored notes it for the other residents and records its injection's id.
- At the turn-start flush, this branch stores through
  writeDrainedInOrder, and anima-research#333 noted each stored message for the turn
  now starting (noteArrival) and for the other residents. Kept, in the
  loop over what was stored, so a store that throws notes nothing and the
  entries it cuts stay queued.

anima-research#333's flush at a start that ends before its stream, in
startAgentStream's finally, merged as text but not as types. It re-added
through addMessage with deferredWriteId, which this branch replaced with
fromQueue { id, seq }, stored each message on its own, logging and
dropping one the store refused, and was gated on every agent's tool cycle
(pendingAssistantBlocks.size === 0). It now goes through readdDrained, in
queue order and cut per target as every other flush is, and is gated on
the agent's own cycle, as the turn-end and withdrawn-turn flushes are.

Its tests in present-while-acting follow:
- The test that a message deferred in a failed start lands then now has
  another agent's tool cycle pending while the start fails, and asserts
  the flush stored the message then.
- The test that the flush stored each message on its own, the one behind
  a refused message stored and the refused one lost, is replaced by its
  form under the cut: the store refuses the first of two messages, both
  stay queued in order, nothing behind the refused one is stored, and once
  the store takes it the next start stores both in order. (Its refusal
  hook keyed on deferredWriteId, which no longer exists, so it refused
  nothing here.)

The fragment names that flush among those that wait only on their own
agent's state.

addMessage merged without a conflict: anima-research#333's noteArrival and
noteForOtherResidents after a store sit beside this branch's fromQueue
re-deferral and per-target gate. anima-research#329 (release holds) changes no file this
branch changes.
theaspirational added a commit to theaspirational/agent-framework that referenced this pull request Oct 15, 2026
Conflict in src/framework.ts at the turn-start typing channel: anima-research#333 replaced
turnLocusPins.get() with the turn's resolved turnLocus. Kept the disabled-mode
branch (disabledTypingChannel) and took turnLocus for locus mode.

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 15, 2026
Main took anima-research#314's routing work (the sending turn's routing now holds the
channels a send engaged, turnRouting.get(agent).engaged, where
turnEngagedChannels was) and the addressed re-pin rule, among 41 commits
since cd036e0. Two conflicts in src/framework.ts:

- Before recordChannelAutoOpenNotice: main inserted the re-pin functions,
  and this branch rewrote the notice's doc comment (speaking never opens a
  channel). Kept main's functions and this branch's comment.
- The send-engagement block in dispatchMcplToolCall: main kept
  openIfClosedForSend's callback and recorded engagement on sendingTurn;
  this branch resolves the target synchronously, opens nothing, and marks
  the channel answered. Kept this branch's block, recording engagement on
  main's sendingTurn?.engaged.

SEND_ENGAGEMENT_TOOLS's comment names the sending turn's engaged set.
test/send-into-closed-channel.test.ts builds its framework with main's
turnRouting in place of turnEngagedChannels.

test/present-while-acting.test.ts: anima-research#333's test that a send's engaged
channel lands on the turn that sent stubbed openIfClosedForSend and held
the open in flight; no open follows a send now, so it holds the send itself
in flight (sendToolsCall pending) while the live routing changes, over a
stub registry with decisionSequence, resolveSendTarget and noteAnswered.
Recording on the live turn instead fails it. The sendingTurn comment drops
"or the open that follows" (Greta-2425, room-203 #104635).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 16, 2026
…pawn wait and transport listeners, and TypeScript 7

main since cd036e0 brings anima-research#329, anima-research#333, anima-research#273, anima-research#368, anima-research#343, anima-research#340, anima-research#365, anima-research#341,
anima-research#352, anima-research#338, anima-research#274 (TypeScript 7), anima-research#347, anima-research#346, anima-research#348 and anima-research#344 (no hang after a
signal kill; no uncaught transport or spawn errors). Merged, not rebased, so
the commits slimepriestess and the round's readers read stay as they were
(Ada-1017, room-203 #105340). `git show --remerge-diff` shows the resolution
alone.

Conflicts, three files:
- src/mcpl/transport.ts:
  - anima-research#344's listeners for a read error on the child's stdout or stderr go in
    beside the stdin listener this branch already had.
  - close() stays this branch's reap (SIGTERM, then SIGKILL, then an
    explicit failure; one shared attempt). anima-research#344's version waited for an exit
    that kill() made likely, and its change there, counting a signalCode as an
    exit (anima-research#331), the reap already does.
- src/mcpl/server-connection.ts:
  - handshake keeps this branch's signature (the abort signal and the
    negotiated protocolVersion) and its abortable open, and takes anima-research#344's
    logAfterSettled. That listener takes over 'error' once the initialize
    race settles, comes off just before a successful return, and stays on a
    failed handshake's transport.
  - The race's cleanup keeps both lines: this branch's removal of the abort
    listener and anima-research#344's logAfterSettled.
  - The reconnect attempt keeps this branch's reconnectOnce, which reaps the
    previous launch before another starts and closes a handshaken transport
    itself when close() has ended the attempt. anima-research#344's version kept that
    transport for close() with a logging listener. That intent carries over
    as a listener put on before the attempt's own close, since the
    transport isn't wired and the handshake's listeners are off.
- package.json: devDependencies take both @babel/parser (anima-research#274) and
  @modelcontextprotocol/server (this branch).

Beyond the conflicts, for main's async StdioTransport.spawn (anima-research#344):
- SpawnerStdioTransport.start() awaits it. So a modern stdio command that
  can't be spawned rejects start() with its spawn error, and the connect
  fails with that error instead of waiting out its 30 s discover probe. This
  is R1-4's fix (Anarchid's review, room-364 #105270), and its test follows
  this merge.
- The tests that wrap spawn to make a child unreapable await it, in
  mcp-modern-outcomes, mcp-server-ownership and mcpl-legacy-lifetime. So
  does the unreapable-close test's own spawn. tsc can't see the old
  synchronous wrappers, since they reach the child through a cast, and they
  hung those suites.
- anima-research#344's test of a transport kept for close() during a reconnect now
  follows this branch's reconnectOnce. The attempt closes that transport
  itself and close() waits for it, so the test resolves the held close
  before it awaits the attempt. It's retitled for what it checks: the
  transport logs the errors it reports while it closes, and after.

tsc (TypeScript 7) is clean. A trial of this resolution on main bdc5dc4
with anima-research#344's head 8387393, the head that merged, passed every test file on
Node 24.21.0 and Node 20.20.2 (J10968). The only failures were 69 in a
stale dist/test/mcp-modern-http-2025.test.js that the VPS copy kept from
anima-research#337's runs. The full check of this commit follows.

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 17, 2026
main had flattened a turn's routing (anima-research#333): openLogical holds the whole
TurnRouting, kept across the logical turn's streams, with no separate
LogicalTurn. The drafts' turn state (drafts, keptPrivate, draftFailures)
moves onto TurnRouting beside main's continuation, deliveries,
suppressed and anima-research#314's arrivals, and every routing.logical read reads
the routing itself. hybridHold keeps anima-research#247's two holds ('private' after
>>>skip_reply, 'failed' after an envelope that bounced), in place of
main's boolean; main's own boolean sites are the ones anima-research#247 had already
split. A resent draft's channel is recorded on the sending turn's
engaged set, as main's send tools record theirs, in place of the
per-agent map main removed.

Other conflicts, both sides kept:
- the constructor builds main's ProviderWaits and the drafts store;
- index exports McplRequestError beside main's close-info types;
- push-handler imports coalesceRejectionReason and InboundSource;
- requestTimeoutMs's doc keeps main's stdio paragraph and the 60 s
  deadline publish and its open keep when the timeout is 0;
- routeSpeech keeps anima-research#247's executor, with main's logValue in its log.
  The executor never printed routeSpeech's line for opening a closed
  channel, which main's log-lines test pins. deliverSpeech now marks
  its publishes as speech routing's, and opening a closed channel for
  one prints that line where main's routeSpeech printed it. A draft
  resend isn't speech routing's and prints none. A test in
  publish-outcome pins both.

Tests: main's routingState() helper and literals gain drafts and
keptPrivate and drop the boolean hold; anima-research#247's tests use the flat shape.
Main's retried-turn test (anima-research#333) holds its post on both delivery paths,
since a named target's post goes through deliverSpeech here, and its
hybrid receipt reads the retry's segment as kept private, anima-research#247's word
for speech a skip_reply hold keeps. anima-research#247's capacity-held receipt test
follows anima-research#333's rule that a later start keeps the turn's routing: the
bounce's hold holds the later compile's text too, and the receipt names
both drafts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retries and other continuing starts discard hybrid privacy holds and sticky destinations

1 participant