Repository navigation
fix(routing): every later start of a logical turn keeps the turn's routing - #333
Conversation
…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.
|
| if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) { | ||
| this.noteArrival(agent.name, metadata, stored); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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
…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.
…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>
…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.
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>
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).
…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>
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.
TL;DR: every later start of a logical turn now keeps what the turn has decided about where its speech goes. !Fixes #314.
beginAgentTurnstill gave eachfreshTurnRouting().>>>skip_replyhold, 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.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:>>>skip_reply, the new stream's unprefixed prose was published to the turn's locus;#314 has the reproduction for the first start: three cases that fail on
mainatc2f9baee. The other two starts take the same branch ofbeginAgentTurn.Changes
TurnRoutingholds 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).LogicalTurnis folded into it, and the per-agentactiveTriggerChannels,turnLocusPinsandturnEngagedChannelsmaps move onto it.openLogical.stop()ended every open turn opens its turn again, so the turn's later starts continue its routing.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.repinChannel).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, asmain's re-resolution did, and to one stored while it ran, whichmainnever weighed. A boundary that re-pins clears the turn's arrivals, since what it stores is newer.[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.addMessagestores arrivals directly, and the mid-turn re-pin, which reads only the messages drained at a tool boundary, never sees them.mainre-resolved the locus instead, which lets an addressed wake move it, and ambient traffic too.consideredUntilwatermark, 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.maintoo. The same test gates noting a boundary's injections as arrivals.beforeInferencehooks (channels.defaultOutgoing) names a trunk agent's locus once its turn has one: the registry'sactiveChannelResolverreturns 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.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.beginAgentTurn(the checkpoint, locus resolution, the typing indicator) ends the agent's open turn throughsettleAgent, 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.maindrops such a start oncestop()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 whilestop()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.endDroppedTurnnow ends it, when it is still the dropped start's own.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. Achannel_openmoves 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'schannel_openmoves no locus, and its result claims none.lastAnnouncedLocus) moves only with a move the agent is told of: achannel_open's, a re-pin whose notice was stored, and a turn's announcement once stored. "Not covered" names two narrow paths where achannel_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.announceLocusIfChangedismain's code and had the same order.tool-callscase doesn't await between that check and itschainSpeechcalls. 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.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:>>>#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;>>#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;stop()ends every open turn and keeps the live routing state, for a resume;channel_openleft (a control: it keepsmain's behaviour);test/present-while-acting.test.ts(locus mode):providerHoldhook holds) lands then, so a newer message during the hold is the one that counts;stop()drops before its compile, and one whose compile spans astop()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 whilestop()runs;stop()opens its turn again while its streams run, and its retry keeps the routing;channel_openwhose 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'ssrc/(cd036e0d), with this PR's tests at0dfdbd5c, on Node 24, 68 tests fail across the four files:maindoes: 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, andmain'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:mainleaves 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 onmaineach turn speaks at its default. The probe that confirmed Greptile's finding readsmain'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.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'schannel_openmoves no locus, a silent turn's resume shows no locus, and a budget restart moves nothing for a drained message withdrawn before it starts, sincemainnever re-pins there.explicit-prose-routing, 2 intool-result-guard, 3 intrunk-channel-routingand 2 inpresent-while-acting.984f2fa9adds three tests and7b02c83fone, and each fails on thesrc/of the commit before it. Both withdrawal tests fail where the turn's speech is routed, and the latechannel_openat the announce baseline. The unreadable store fails with "Missing expected rejection": thatsrc/reads the failed read as a withdrawal and runs the compile.a679e965adds five tests and rewrites the two latechannel_opentests, which now expect no move once their turn has ended. Four fail oneab87559'ssrc/: the tune-out row, where the turn's speech is routed to the diverted message's channel; both rewrittenchannel_opentests, 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 latechannel_openwhose turn is still open.f07c6eacadds six tests, and four fail onf8f2ec17'ssrc/, 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.0dfdbd5cadds one test, which fails onf07c6eac'ssrc/: 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.a679e965; the three pieces of achannel_open's move at4b295708, which leaves that move one condition, the open turn; and the five of the residents' rule at0dfdbd5c.a679e965, removing achannel_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:
channel_openmoving 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; achannel_open's routing taken at dispatch: 2; the boundary reading the turn's mode rather than the agent's: 2;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-messagetry, a re-pin's and an announcement's baseline waiting for their notices, each of the two dropped-start branches, the escaped throw settling throughsettleAgent, and a failed read of an arrival's message failing the start;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;checkpoint-attribution.The suite, on
0dfdbd5c, which hasmaincd036e0dmerged 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, andmainalone passes 2139 in the same 143 files. The 59 more are this PR's new tests, 14 inexplicit-prose-routing, 36 inpresent-while-actingand 9 intrunk-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 atf07c6eac, and 2147 to 2186 of 2191 at88ffb8a4and4b295708, whose tests are the same. On Node 20 it ran every test at this head and atf07c6eac, 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-settingsandhistory-module-realstorein one of this PR's runs, andhistory-module-realstoreandmcpl-tool-lifecyclein one ofmain'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 onmaintook 283 CPU-seconds over 664 s, while other runs beside it took the load from 5.9 to 9.7.Not covered
mainfor a budget restart and a guard retry already. With this PR, unprefixed text after>>>skip_replyin 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 onmain.mainwithin 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.tool_results_readyrequest doesn't carry the turn'ssuppressProse, so a silent turn's re-inference runs as an ordinary start. That'smain's behaviour, reachable only through the legacy directAgent.runInferenceAPI.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.mainmoves the pin and the baseline when the result lands, in every case.Merge notes
mainfirst. feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 takes it when it next mergesmain: the trigger channel, locus, engaged channels and arrivals live on the turn's object here, and the per-agent maps are gone. Three overlaps there are behaviour, not only text. A framework retry keeps the turn's routing in both, and after the merge this PR's mechanism does it alone. feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 starts a new turn at the re-inference and the capacity-deferred compile, carrying only its route, so it drops the hold; this PR keeps everything there. Where this PR re-pins the locus for a qualifying arrival, at a tool boundary or a later start, feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 holds the turn's speech instead, and its hold goes to each of those places at the merge. This PR's arrivals are each a message id and a channel, and feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 adds its candidates' fields to each. This PR's change to the registry'sactiveChannelResolveris formainonly: feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 deletes the resolver and takesdefaultOutgoingfrom the turn's route.readdDrainedcall, andnoteArrivalfollows the write. fix(deferral): hold window-only messages to the turn's end; injected messages are read #286 also stores the messages a tool boundary or a turn start drains throughwriteDrainedInOrder, and this PR's calls at those two sites (injectedIds,noteArrivalandnoteForOtherResidents) go with each message it stores. And fix(deferral): hold window-only messages to the turn's end; injected messages are read #286 gates each flush on the agent's own tool cycle (!this.pendingAssistantBlocks.has(agent.name)) rather than any agent's (size === 0), so the failed-start flush takes that gate too. fix(deferral): hold window-only messages to the turn's end; injected messages are read #286'sdee13683ends a waiting ephemeral run in the drop before the compile, where this PR also ends the dropped start's turn. The two lines are independent, so the merge keeps both.Not verified
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.assemble(), rather than running the coalescer.Review
Five internal reviews read the change:
try, the announce baseline, keeping a resume's routing acrossstop(), and more text.stop(),announceLocusIfChanged's baseline, a silent turn'schannel_open, tests for each, and text.139aca6ctoeab87559.a679e965answers it: a message tune-out diverts isn't an arrival, only a locus or hybrid start reads its arrivals' messages, and a latechannel_openmoves 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.4b295708drops the silent-turn check that the open-turn check made redundant, as the removals above found, andf8f2ec17corrects 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.0dfdbd5canswers 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,
26eeafeaand33403916each change a few lines of source, so no review run read them on their own:26eeafealooks up the sending turn only for a send tool. The full suite found the need:checkpoint-attributiondispatches MCPL tool calls on a framework built without routing state, and eight of its tests failed while the lookup ran for every call.33403916ends 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.139aca6cchanges 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.984f2fa9answers 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, wheremainkeeps the turn's own channel. And achannel_openwhose result came after its turn settled, before any later start, moved the announce baseline though the agent never read the result.7b02c83fanswers 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.eab87559changes tests only. Beth-2389 found, mergingmainin 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.0cff2330takes3f917005(#310) and merged cleanly.9d20ac9atakes0d2dd88f(#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, intoCHANGELOG.md.b534aaf4takescd036e0d(#211, #246, #303, #249, #279, #251, #267, #290, #285, #325 and the 0.22.1 release). Its three conflicts are thesettleAgentcalls on the terminal-failure paths, where each keeps #251's error text and this branch's routing argument.88ffb8a4then updates two comments for #251:startAgentStreamnow refuses every start once admission closes, so a retry no longer reaches its routing afterstop().Greptile's review of
f8f2ec17found that a second resident's open turn got no arrivals:addMessagenoted one only on the agent it routed the message to, though every resident reads the shared slot. A probe with two residents confirmed it againstmain, where both held residents moved to the DM.f07c6eacanswers it, by Ada-1017's call that every resident's open turn weighs what lands in the slot by its own rule. Achat:addressedmessage moves any resident's open turn, since it addresses the account the residents share.Naomi-2422 and Beth-2389 read each commit through
f8f2ec17after their earlier reads, the last merge's resolution, and this text against the code. At4b295708, 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 readf07c6eacand0dfdbd5cwith 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.