Repository navigation
fix(deferral): a deferred write with no target names its agent, boot numbers the restored queue 1..N, and a sharded body is injected joined (#286's follow-up) - #461
Conversation
…d for it out of the queue, logged as lost An ephemeral run's deregistration and a conversation fork's disposal are the only places an agent leaves the agent map. At each, what is still queued for that agent now leaves the deferred-write queue with it (dropDeferredFor), each entry logged by id as lost, and a persisted queue is rewritten without it. The last flush before the agent leaves can leave an entry queued: one whose store threw, which writeDrainedInOrder puts back, or, on a quiesced host, one that flush defers again. Before, a closing fork's entry stayed queued for the disposed fork, where no flush could store it. An ephemeral run's was dropped by the run's own turn-end flush, which runs after the run is deregistered, with addMessage's line that its delivery target isn't a registered agent, after the requeue's line saying it stays queued. What the store has already taken, waiting for its ack, isn't queued and stays for the ack. Tests: a run whose store refuses a write at its last flush, a run that ends while the host is quiesced, with the recovery file read after, and a closing fork whose store refuses a write at its closure turn's end. On main, the fork's entry stays queued, and the runs' entries reach addMessage after the run is deregistered. Fixes R2-1 of the round-2 review of anima-research#286. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…q at the limit can't hold the counter there Boot kept each restored entry's seq, numbered only the entries without a valid one, after the largest, and set the counter to that. A valid seq at Number.MAX_SAFE_INTEGER left the counter there, and the next deferral's ++ gave 2^53, which ++ doesn't move past, so every later deferral shared it. A seq is read only for order (bySeq, boot's sort, and a re-add's fromQueue, which carries it back into the queue). So boot now restores the queue in the order it resolved to before (valid seqs ascending, then the rest in file order) and numbers it 1..N in that order, and the counter goes on from N. The numbers are given in memory. Boot rewrites the recovery file only for the reasons it did before: landed, malformed or repeated entries, and an entry without a valid seq, so it isn't named again. Every write of the queue writes it whole, and a boot before that numbers the same entries the same way. Test: a recovery file whose one entry has seq Number.MAX_SAFE_INTEGER isn't rewritten at boot, the two writes deferred after it are persisted with the restored entry as 1, 2, 3, the next boot names no seq, and resume stores them in order. On main the file holds MAX_SAFE_INTEGER, 2^53 and 2^53. Fixes R2-2 of the round-2 review of anima-research#286. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ueued entry's id say a flush is acknowledged once, as one batch flushDeferredWrites acknowledges its batch once, after the chronicle syncs, and a boot after a crash mid-flush skips each entry that already landed, by its deferredWriteId. Two comments still said acknowledgements are per message: create()'s note on recovering deferred writes and resume()'s on its flush. A queued entry's id, documented as the identity for per-message flush acknowledgement, said the same. Fixes R2-3 of the round-2 review of anima-research#286, and the id's comment beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as later compiles render it A deferred message that the strategy stores as more than one message, a sharded body, was injected at a tool boundary as given, while later compiles render the store's shards joined. So a body of more than one text block, or with a non-text block after a text block, reached the stream in a different shape from later requests. writeDrainedInOrder already returned what landed, joined (asCompiled), for a write that threw after storing. It now does the same for a write that succeeds, when the store grew by more than one message: it reads the write's copies back and returns them joined. The store is read back only then, so a write of one message costs one count read more than before. Test: four bodies the strategy shards, stored whole at a tool boundary, each with a one-message write after it: what the boundary injects is what the next compile renders, and the store is read back for each body alone. On main three of the four differ: two text blocks, text then an image, and text, an image and text. It runs on context-manager 0.14.1 and 0.15. Takes up the suggestion in the round-2 review of anima-research#286. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g fork before it disposes it The fallback reaper disposes a closing fork that has no stream and no request left, as when its closure inference never ran. Unlike the end of a closure turn and the end of an ephemeral run, nothing stored what was still queued for the fork first. On main it stayed queued for a fork that no longer existed, and with dropDeferredFor it would be logged as lost although the fork's store could take it. storeDeferredFor is the ephemeral run's store-before-leaving step, moved into a method: drain the agent's queue, re-add it past the deferral (deferred again on a quiesced host) and acknowledge. The ephemeral run's finally and the reaper both call it before the agent leaves, and dropDeferredFor takes out only what it couldn't store. Test: a closing fork whose closure request is dropped before it runs, with a write deferred for it. The reaper's sweep stores the write in the fork before disposing it. On main it isn't stored. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The success path's read-back calls storedCopiesOf, whose failure line said the write threw. It now says the store couldn't be read back for what the write stored. - DrainedWrite's and writeDrainedInOrder's docs, and the tool boundary's comment, no longer say a one-message write's content is as later compiles render it. It is carried as given; only a sharded body's join, or what a write that threw left, is claimed as a compile renders it. - Boot recovery's comment no longer says the counter never passes the largest safe integer: only its value at boot is bounded. - stop()'s comment no longer says a disposed fork's entries wait for a boot or resume flush. They leave with the fork, so what can still be left is a write queued after its agent left, or one restored at a boot that doesn't register its agent. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… observed The run test whose store refuses a write now runs with a persisted queue, as after a boot that restored one, and reads the recovery file after: nothing is left in it for the run. Only the quiesced test read the file before, so removing the deferredWritesPersisted half of dropDeferredFor's rewrite condition passed every test. The fork test now checks that the closure turn's flush met the refusal (its "could not be stored" line for the write) before the disposal's lost line, so it can't pass with a flush that never tried the store. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ueued for it can be stored now The fallback reaper disposed a closing fork once it had no active stream and no pending request, and (since f1202da) first stored what was queued for it past its deferral. Two holds got through that guard: - A turn token with no stream. A surgery reserves every registered agent's token while it switches the store, and a puppet call holds its agent's across the tool's await. The sweep runs from the event loop regardless, so the reaper would write a closing fork's queue into the store mid-surgery, or ahead of the puppet's tool pair. - A quiesced host. The store-first deferred the writes again, and the disposal then logged them as lost, though resume() would store them. The guard now also waits for the fork's turn token and for a quiesced host. Each holder stores the fork's queue as it lets go (the puppet's finally, the surgery's release, resume()), and a later sweep disposes the fork. A token nobody clears would keep the fork registered, as it already wedges the agent. Tests: a closing fork whose closure request is dropped, with a write deferred for it. Under a held token the sweep leaves the fork registered and the write queued, and once the token is gone it stores the write and disposes the fork. On a quiesced host the sweep leaves it registered, resume() stores the write, and the next sweep disposes it, with nothing logged as lost. The three reaper tests share their setup. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tries, as the code has them - DrainedWrite's doc, writeDrainedInOrder's and the tool boundary's comment said a sharded body is carried joined, and a write that threw after storing is counted at most once, without saying that both rest on reading the store back. When that read fails (storedCopiesOf logs it), a sharded body is carried as given, and a write that threw is put back as one that couldn't be stored, so one that had landed is stored again at a later flush. The docs now say so. - stop()'s comment named an entry queued after its agent left as one stop() can meet. addMessage drops a write for an agent that isn't registered before it can queue it, and puppetToolCall's one direct push is drained by the puppet's own finally in the same synchronous step, so neither a flush nor stop() meets it. The comment now names only an entry restored at a boot that doesn't register its agent. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…host leaves the fork to the reaper
The end of a fork's closure turn flushes what was held for it, then
disposes the fork. quiesce() sets quiesced and then waits for in-flight
turns to settle (quiesce({abandon}) cancels them), so a closure turn can
end while the host is quiesced. Its flush then defers the fork's queue
again, and the disposal logs it as lost, though resume() would store it:
the case c5a3730 made the reaper wait for, met at the other place a
fork is disposed.
The closure turn's disposal now also waits for an unquiesced host. A
fork it leaves stays closing, and the fallback reaper, which waits for
resume(), stores what is queued for it and disposes it at a later sweep.
A turn token needs no such check there: the frame releases its own token
just before, and a frame that hands the turn to a successor doesn't reach
the disposal.
Test: during a fork's closure turn, a write for the fork is deferred and
quiesce() begins. The turn ends with the fork still registered and the
write queued; resume() stores the write in the fork, the next sweep
disposes it, and nothing is logged as lost. On 4ccaf3a the fork was
disposed while quiesced, with the write logged as lost.
Found in review of this change, with a probe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main carries the merge train after anima-research#286: among its changes, quiesce() now writes the host-mode record before it marks the host quiesced, and resume() clears it once its flush is done. The merge is clean as text; the regions both sides changed are read in the PR's merge notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes stay within deferred writes and conversation-agent lifecycles. Fix default-target departure cleanup and prevent finished forks from blocking host resume before merging. FindingsFix with agent prompt### Issue 1
src/framework.ts:16325
With `agents: []` and one active ephemeral run, `deliveryAgent()` sends a module's default-target write to that run. But `addMessage()` queues it with `forAgent` unset, and `primaryAgentName` remains `null`. This filter therefore misses the write, as does `storeDeferredFor()` through `drainDeferredFor()`.
The write survives deregistration without being stored or logged as lost, including in the recovery file when persisted. Capture the resolved target when deferring, and cover this path with `ModuleContext.addMessage()` rather than an explicit `forAgent`.
### Issue 2
src/framework.ts:14447
Keeping a finished fork registered can prevent the whole host from resuming. With an adaptive `strategyFactory`, a fork can finish its closure turn with context that exceeds its budget. `runResume()` checks every registered agent and rejects `effective.fits === false` before flushing writes. Meanwhile, the reaper waits for `quiesced` to become false before disposing the fork.
Normal `resume()` therefore requires fixing a fork that will never run again. Exclude terminal closing forks from that budget check, or let them finish storing and leave before the check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThis PR ties queued-write cleanup to agent departure, stores a closing fork's queue before fallback disposal, resets restored ordering numbers, and joins successfully stored shards before injection.
Reviews (1) · Last reviewed commit: "Merge main 86a36834 into the deferral fo..." · Reviewed by Greptile |
| * for the ack. | ||
| */ | ||
| private dropDeferredFor(agentName: string, where: string): void { | ||
| const lost = this.deferredMessages.filter((msg) => (msg.forAgent ?? this.primaryAgentName) === agentName); |
There was a problem hiding this comment.
Default writes outlive their run
With agents: [] and one active ephemeral run, deliveryAgent() sends a module's default-target write to that run. But addMessage() queues it with forAgent unset, and primaryAgentName remains null. This filter therefore misses the write, as does storeDeferredFor() through drainDeferredFor().
The write survives deregistration without being stored or logged as lost, including in the recovery file when persisted. Capture the resolved target when deferring, and cover this path with ModuleContext.addMessage() rather than an explicit forAgent.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 16325
Comment:
**Default writes outlive their run**
With `agents: []` and one active ephemeral run, `deliveryAgent()` sends a module's default-target write to that run. But `addMessage()` queues it with `forAgent` unset, and `primaryAgentName` remains `null`. This filter therefore misses the write, as does `storeDeferredFor()` through `drainDeferredFor()`.
The write survives deregistration without being stored or logged as lost, including in the recovery file when persisted. Capture the resolved target when deferring, and cover this path with `ModuleContext.addMessage()` rather than an explicit `forAgent`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 8e0cc02ec. You're right: on a host with no primary, addMessage sent a write with no target to the run, but its queue entry named no agent, so neither the run's flushes nor its leaving took it. main does the same. The deferral now records the agent it resolved, when that isn't the primary, so the entry is the run's as a targeted write's is, and a primary-bound entry still names no agent. A new test in ephemeral-settle writes through a module's ModuleContext.addMessage(), with no target, during the run's tool call, and checks that the write is stored once in the run's namespace and that nothing is left queued or logged as lost. With 45784c313's source it fails: the run never stores the write. Written by Duncan-2410.
— Imogen-2490
| // a quiesced host the flush above deferred what was held again, so the | ||
| // fork is left to the fallback reaper, which disposes it once resume() | ||
| // has stored it. | ||
| if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name) && !this.quiesced) { |
There was a problem hiding this comment.
Keeping a finished fork registered can prevent the whole host from resuming. With an adaptive strategyFactory, a fork can finish its closure turn with context that exceeds its budget. runResume() checks every registered agent and rejects effective.fits === false before flushing writes. Meanwhile, the reaper waits for quiesced to become false before disposing the fork.
Normal resume() therefore requires fixing a fork that will never run again. Exclude terminal closing forks from that budget check, or let them finish storing and leave before the check.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 14447
Comment:
**Finished forks block resume**
Keeping a finished fork registered can prevent the whole host from resuming. With an adaptive `strategyFactory`, a fork can finish its closure turn with context that exceeds its budget. `runResume()` checks every registered agent and rejects `effective.fits === false` before flushing writes. Meanwhile, the reaper waits for `quiesced` to become false before disposing the fork.
Normal `resume()` therefore requires fixing a fork that will never run again. Exclude terminal closing forks from that budget check, or let them finish storing and leave before the check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 5e6448aa7. You're right: this pull request kept a fork whose closure turn ended on a quiesced host registered until resume(), so that resume() could store what was queued for it, and resume()'s feasibility gate then previewed that fork. Leaving it out of the check, as you suggested, would still keep it registered through the quiesce, where, by our reading, the maintenance pass and admission also treat it as an agent that runs again. So a quiesce no longer keeps a closing fork registered: on a quiesced host, the end of its closure turn and the fallback reaper dispose it as on a serving host, and what was queued for it is logged as lost, as an ephemeral run's queue already was there. The two tests of a quiesced host in conversation-routing now check that. With 45784c313's source both fail where they check that the fork is gone: it is still registered. Written by Duncan-2410.
— Imogen-2490
…t to, when that isn't the primary addMessage sends a write with no target to the primary or, on a host with no primary (agents: []), to the first registered agent, such as an ephemeral run. Deferred, the entry kept no target, and every drain matches an entry by forAgent ?? primaryAgentName, which is null there. So the run's own flushes passed it by, and so did its leaving (storeDeferredFor, dropDeferredFor): after the run the entry was still queued, unstored and not logged as lost, and in the recovery file when the queue was persisted. main does the same. The deferral now records the agent it resolved, when that isn't the primary. The entry is then that agent's, as a targeted write's is: its flushes store it, and it leaves with the agent. A primary-bound entry still has no target, as the recovery file and a boot whose primary was renamed expect. Tests: on a host with no primary, a module's write with no target during an ephemeral run's tool call is deferred, stored once in the run's namespace, and nothing is left queued or logged as lost once the run ends. At 45784c3 the entry stayed queued and unstored after the run. And a write deferred for the primary still has no target in its entry. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…queue leaves with it, logged as lost c7a4f56 left a fork whose closure turn ends on a quiesced host to the fallback reaper, and c5a3730 made the reaper wait for an unquiesced host, so that resume() would store what was queued for the fork. That kept a fork that is closing for good registered through the quiesce, where what reads a registered agent treats it as one that runs again: resume()'s feasibility gate previewed it and refused while it didn't fit, the maintenance pass ticked it, and nothing at admission refuses a closing fork, so a nudge, a delivery or a provider hold's release could still start a turn for it. A restart before resume() dropped its queue anyway, since a fork isn't registered again at boot. So on a quiesced host the end of a closure turn and the reaper dispose a closing fork as they do on a serving host. Their flush defers what is queued for it again, and that leaves with the fork, logged as lost, as an ephemeral run's queue does on a quiesced host. The reaper keeps its wait for a turn token, held by a surgery or a puppet call, so that its store-first doesn't write into the store mid-surgery or ahead of the puppet's tool pair. Tests: the two tests of a quiesced host turn round. A fork whose closure turn ends while the host quiesces is disposed at the turn's end, and the reaper disposes a closing fork on a quiesced host. In each, the queued write is logged as lost, nothing is left queued, and resume() stores nothing in the fork. Found in review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#457, anima-research#458, anima-research#459, anima-research#454, anima-research#460, anima-research#464, anima-research#412 and anima-research#467 since 86a3683. One block conflicts, in restorePersistedDeferredWrites: anima-research#454 narrowed its try to reading the recovery file, so that a file it can't read or take fails create(), and moved the parsing and numbering after it, while this branch changed that numbering (boot numbers the restored queue 1..N). The resolution takes main's side of the block, then makes this branch's change to main's numbering where it now stands: the queue is ordered by valid seq, then the rest in file order, numbered 1..N after the already-landed entries are left out, and the counter goes on from N; the file is rewritten for an entry without a valid seq, not for the new numbers. main's check for writes that already landed, which now refuses create() when a context can't be read, is kept as main has it, over the ordered entries. Everything else merged without a conflict. The only other change of main's within forty lines of one of this branch's is anima-research#457's to an ephemeral run's idle watchdog, which now rejects a run that stop() has begun to end as stopped; it sits above the run's finally, which this branch changes, and neither depends on the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in the comment above stop()'s final flush. main kept the paragraph on an entry for a target no longer registered and added one on a registered agent's stream that outlasted stop()'s wait. This branch had rewritten the first: what is queued for an agent leaves with it, so such an entry is one restored at a boot that doesn't register its agent. The resolution keeps this branch's paragraph, with main's new one after it. git merged the rest on its own. Where both sides change the same code: - stop(): main bounds its waits and ends every ephemeral run still running, and keeps a registered agent's entries queued while its stream outlasts the wait. A run that stop() ends returns through the same finally as any run's end, where this branch stores what is queued for it and takes out, logged as lost, what that can't store. - The deferred-write queue's record: main's writes now say whether the record was written, over its cap or failed, inside persistDeferredWrites, which this branch's callers call as before. - test/ephemeral-settle.test.ts: each side adds its own tests.
…is the one leave path main's anima-research#204 releases a deregistered fork's or run's window (releaseWindow). The release stores each write still queued for the agent in its window, logs one its store refuses, and acknowledges them; while the host is quiesced, it waits for resume. That is the rule this branch's storeDeferredFor and dropDeferredFor carried, at the same places an agent leaves: an ephemeral run's end, and a fork's disposal, which the closure turn's end and the fallback reaper both reach. The release stays as the one path, and the branch's two functions go. The four conflicts, all in src/framework.ts against anima-research#204: - stop()'s comment on an entry for a target no longer registered: main's. - resume()'s flush comment: main's, which releases the postponed windows first. Its last sentence spoke of a per-message ack; it now says the batch is stored in queue order and acknowledged once, as flushDeferredWrites does. - The ephemeral run's end: main's, which leaves what is queued to the release below it. - The fallback reaper's comment: main's. Outside the conflicts, git kept parts of this branch that work against the release: - disposeConversationAgent called dropDeferredFor before main's releaseWindowNowOrAfterResume. A fork's queue would have been logged as lost before its release could land it, and on a quiesced host the release's wait for resume would have been undone. Removed, with its doc paragraph and the turn-end comment that described it. - The reaper called storeDeferredFor before disposing a fork, and the disposal's release stores what is queued. Removed. - The reaper's condition checked activeTurnTokens twice, since both sides added it. It checks it once. - storeDeferredFor and dropDeferredFor have no callers left. Removed. - addMessage's comment on a write with no target says the agent's window release takes it. Kept from this branch: a write with no target names the agent it went to when that isn't the primary (the release takes a queue's entries by name, so it needs that to take one), boot recovery's 1..N numbering, a sharded body injected joined at a tool boundary, and the doc corrections. Tests: - conversation-routing: the branch's fork tests find their forks through forkName(), since fork names carry a hash on main. A fork whose closure turn ends while the host is quiesced, and the reaper on a quiesced host: the fork is disposed at once, and what is queued for it lands in its window at resume. A store that refuses a fork's write: the turn-end flush keeps it queued, and the release logs it and takes it out. Two tests go, the reaper's store-first and its wait while a turn token is held, which route-failure-targeting's "lands a queued ordinary write in a released fork's window" and "does not reap a closing fork whose turn holds its token" cover. - ephemeral-settle: a store that refuses a run's write: the release logs it. One test goes, a run that ends while the host is quiesced, which route-failure-targeting's "deregisters a run that ends while the host is quiesced at once" covers. The test of a write with no target drops its check for an "is lost" line, which nothing logs now. The changelog entry drops its bullets on a leaving agent's queue and on the reaper, since anima-research#204's own entry states that behaviour, and its bullet on a write with no target says the release takes the entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s flushes nor the run's end The entry's "Before" said "nor its release", which names the window release that anima-research#204 added to main. Before anima-research#204, the run's end drained its queue by the same rule and didn't take the entry either, and since anima-research#204 the run's end leaves its queue to that release, which doesn't take it either, so "the run's end" holds for both. In a probe under main's source at 9e3e407, the entry was still queued after the run, unstored, and in the recovery file; under this branch's source it was stored in the run's namespace, with nothing queued after the run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved 93 commits since 9e3e407, among them trains aa and ab: anima-research#284, anima-research#520, anima-research#461 (the deferral follow-up to anima-research#286) and anima-research#506. The one conflict, src/framework.ts, in addMessage's deferral: anima-research#461 names the agent an untargeted write went to when it isn't the primary (a host with no primary sends it to its first registered agent, such as an ephemeral run), and this branch adds heldAt to the entry. Both kept: anima-research#461's comment and forAgent line, and the entry pushed with forAgent and heldAt. heldWritesFor reads an entry's target through deliveryAgent, which names the same agent either way. Checked beside it: the boot restore anima-research#461 rewrote keeps each entry's heldAt through its renumbering (kept entries carry it, and the restored ones spread it), and tsc is clean with no other edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndle anima-research#461's test of a module's untargeted write on a host with no primary asserted that ctx.addMessage answered '' while the run's tool call held the write. With this change a module's held write is answered by its held: handle, so the test asserts one handle came back; the rest of it (stored once in the run's namespace, nothing left queued once the run ends) is unchanged. The head run at 9224463 showed it, 43 of 44 files passing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld writes for modules and the modern MCP client meet the registry What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose drafts and the `drafts` tool (#247); a module reads, edits and removes its held writes (#526); the deferred-writes follow-up (#461: a targetless write on a host with no primary, boot renumbering, sharded injection); the modern MCP client (#262) and the inbound source envelope; a stop that reaches a turn with no live stream (#529), every cleanup (#517), an MCP connect in flight, and a start during stop() (#532); allowedTools enforced before every dispatch (#220, breaking for hosts); repeated agent names refused at create() (#523, breaking); the fork-name collision fix (#513); the tune-out coordinator's timer-failure reporting and new refusal words (#506); request-only rows (silent ticks and [Continue] replies) rendered before their response; `callerTurnId` on tool calls. Conflicts (36 hunks, 7 files), and what was decided: - src/framework.ts (23): imports and the registry fields are unions (main's `releasesAfterResume` and `conversationSpawns` kept; the shared-slot set and the home-channel map stay answered by the registry, the one new use in the drafts resend reads `registry.homeChannel`). `isAddressedMessage` stays tag-only; main's call sites pass tags alone. Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as main types it) plus the branch's `addressedElsewhere`. The subconscious setup keeps the reader-forks branch; the coordinator hooks keep `announce`. `getAllTools` offers main's `drafts` beside `tune_out` and `focus`; an agent's tool block is its surface owner's, with the reader tools, filtered by main's `utils` rule. The deferred-writes restore is main's (refuses to start on a queue it can't read; `heldAt`; numbered 1..N) with the branch's re-wake for a restored tune-out release. The channel-incoming handler stamps main's inbound source envelope, then presents the author and admits. The ephemeral run keeps both watchdogs (the branch's deadline, main's stalled-batch abandon). Dispatch sets `callerTurnId` then applies the branch's deny-at-dispatch. `endEphemeralRunRefused` is gone: main's #532 refuses the start itself. `tune_out`/`focus` still dispatch through the queue (main's change there was isError). - src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and `thrownReason`; `enter` still allows no reader (silent hold) and takes main's words for a bound or already-tuned-out channel; the coalesced wake timer wraps the branch's `fireWake` in main's `timerFailed`; `cancel_tuneout` keeps the server-qualified lookup and the all-except path, with main's `notTunedOut` words; `speak_in_channel` has main's words and the branch's caller fallback. - src/agent.ts (2): main's `requestSettings()` plus `assistantParticipant`; `presentOwnTurns` before main's request-only rows, which render under `presentAs`. - src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its transport confirmation; the locus is admission's. `onRouteFailure` is main's type (with `outcome`). - src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions. Outside the hunks, where main's tests met the branch's rules: - main's #506 rule (a refusal is an error result) is applied to `tune_out` and `focus`, whose refusals were `isError: false`; an unknown channel is refused in main's words (`unknown channel: <id>`). - a tune-out with no reader is a silent hold only where the host offers `tune_out` without one: the coordinator gains `holdWithoutReader`, the framework passes `tuneOutOffered`, and main's tune-out-messages test holds as written; the branch's §3.3 row opts its host in. - main's tests that reached `conversationAgentHomes` use the registry (`bindAsConversationFork`, a registry stub); the ended-agents log is registered through `ensureStateRegistered`, as main's registration test requires; main's content-parity test counts the placeholder to the end of its text field, since the channel lane stores it under its author; main's route-failure rows that route by the registry's default locus run under `speech.locus: 'latest-inbound'`, the rule they assume; the branch's present-while-acting routing literal gains main's `turnId`, `drafts` and `keptPrivate`. - main's new `@modelcontextprotocol` dependencies installed (rig relinked after), and the merge fragment extended. Composed rig (the branch chronicle at 03b7bc1 and context-manager at bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests, 4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row, the Python eager-task-factory row, and two deferred-window-only rows that context-manager 0.15 makes moot. Typecheck clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#286's follow-up: R2-1, R2-2 and R2-3 from the round-2 review of #286, and that review's suggestion for the success path, in one pull request on
main, as the reply on #286 said. Since #204 merged, R2-1's rule ismain's, and this PR keeps what that rule still needed.Changes
R2-1: what is still queued for an agent that leaves. #204, which merged on October 18, releases the window of a conversation fork or an ephemeral run once it is deregistered (
releaseWindow). The release stores each write still queued for the agent in the agent's own window, logs one that its store refuses, and acknowledges them. While the host is quiesced, it waits forresume(). It runs at an ephemeral run's end and at a fork's disposal, which both the end of the fork's closure turn and the fallback reaper reach. That is the rule R2-1 asked for, at the same places. This PR had made the same rule with two methods of its own,storeDeferredForanddropDeferredFor, which logged a quiesced host's queue as lost where #204's release waits for resume. Its merge ofmainat9e3e407cdrops both methods, so #204's release is the one place the rule lives. This PR keeps two things for R2-1:agents: []),addMessagesends a write with no target to the first registered agent, such as an ephemeral run. Deferred, its entry named no agent, and every drain matches an entry byforAgent ?? primaryAgentName, which names no agent there, so neither the run's flushes nor its release took it. In a probe atmain's source (9e3e407c), a module's write during a run's tool call was still queued after the run, unstored, and in the recovery file. The deferral now records the agent it resolved when that isn't the primary, so the entry is that agent's as a targeted write's is, and the run's release takes it. A primary-bound entry still names no agent, as the recovery file and a boot whose primary was renamed expect.resume().Boot recovery numbers the restored queue 1..N (R2-2). Boot kept each restored entry's
seq, numbered only the entries without a valid one, after the largest, and the counter went on from there. A validseqatNumber.MAX_SAFE_INTEGERleft the counter at the limit, and the next deferral's++gave 2^53, which++doesn't move past. In this PR's test undermain's source at9e3e407c, the two writes deferred after such an entry were both persisted withseq9007199254740992. Aseqis read only for order, so boot now restores the queue in the order it resolved to before (validseqs ascending, then the rest in file order), numbers it 1..N, and the counter goes on from N.seq, which is written back with one so that the next boot doesn't name it again. Every write of the queue writes it whole, with the new numbers, and a boot before that numbers the same entries the same way.Three comments say a flush is acknowledged once, as one batch (R2-3).
flushDeferredWritesacknowledges its batch once, after the chronicle syncs, and a boot after a crash mid-flush skips each entry that already landed, by itsdeferredWriteId. The two comments R2-3 names, at boot recovery and inresume(), still said acknowledgements are per message, and so did the docstring of a queued entry'sid.A sharded body injected at a tool boundary is carried as later compiles render it (the review's suggestion). A deferred message that the strategy stores as more than one message, a sharded body, was injected as given, while later compiles render its shards joined. So a body of more than one text block, or with a non-text block after a text block, reached the stream in a different shape from later requests, as #286's Limits said.
writeDrainedInOrderalready returned what landed, joined (asCompiled), for a write that threw after storing. It now does the same for a write that succeeds, when the store grew by more than one message.What each part is for
seqforward). It's no larger than what it replaces. The review's fix renumbers only when numbering after the largest validseqwould pass the limit, which leaves one case: a validseqat the limit with nothing to number, after which the next deferral's++gives 2^53. Numbering at every boot has no such case and no threshold to choose.Tests
A write with no target, on a host with no primary (
ephemeral-settle): on a host withagents: [], during an ephemeral run's tool call, a module writes through its context with no target. The write is deferred and stored once in the run's namespace, and once the run ends, nothing is left queued.A run's release, with a write its store refuses (
ephemeral-settle): with a persisted queue, as after a boot that restored one, two writes are deferred during the run's tool call, one named as the run, which waits for the turn's end, and one behind it, which the run's store refuses each time. The first is stored after the run's reply, nothing is left queued for the run or in the recovery file, the release logs the refused write, and no "not a registered agent" line follows.A fork's release, with a write its store refuses (
conversation-routing): a write deferred for the fork during its closure turn, which the fork's store refuses. The closure turn's flush meets the refusal and keeps it queued, and once the fork is disposed, its release logs it and nothing is left queued for the fork.A closure turn that ends on a quiesced host (
conversation-routing): during a fork's closure turn, a write for the fork is deferred andquiesce()begins. The turn's end disposes the fork, the write stays queued and unstored, andresume()lands it in the fork's window.The reaper on a quiesced host (
conversation-routing): a closing fork whose closure request is dropped before it runs, with a write deferred for it, on a quiesced host. The sweep disposes the fork, the write stays queued and unstored, andresume()lands it in the fork's window.A write for the primary names no agent (
deferred-window-only, an assertion added to the test of a write waiting on its target's own tool cycle): the entry the primary's deferred write makes has noforAgent.R2-2 (
host-quiesce): a recovery file whose one entry hasseqNumber.MAX_SAFE_INTEGER. Boot doesn't rewrite it, the two writes deferred after it are persisted with it as 1, 2 and 3, the next boot names noseq, and resume stores the three in order.The success path (
deferred-window-only): four bodies the strategy shards, each deferred during a tool call with a one-message write behind it: one long text block; a short text, then the long text; the long text, then an image; and a short text, an image, then the long text. What the boundary injects is what the next compile renders, and the store is read back for each body and not for the one-message write. Every case runs before the test names those that differ.Red-first, with
main's source (9e3e407c) under this head's tests, on Node 24 on Linux, context-manager 0.14.1. Each failure is one of the new tests, at the check it names:host-quiesce, 47/48: the R2-2 test, whose restored entry keepsseq9007199254740991 and whose two later writes both get 9007199254740992.ephemeral-settle, 20/21: the test of a write with no target, which the run never stores (actual: 0).deferred-window-only, 29/30: the success-path test, where three of the four bodies differ, all but the one long text block. The assertion that a write for the primary names no agent passes onmain, which leaves such an entry unaddressed too: it is a control.This PR's three fork tests (
conversation-routing, 31/31) and its run's release test pass onmain's source, since they check fix(routing): deliver an agent's own notices to the window it reads #204's release, whichmainhas.Removals, each alone, at
e1a6a7dd, on Node 24, context-manager 0.14.1, overconversation-routingandephemeral-settle(52/52 unchanged). All four are caught:resume(): 50/52, and the two tests of a fork disposed on a quiesced host fail, at the write waiting forresume();resume()releasing no postponed window: 50/52, and the same two fail, at the write landing atresume();The last three remove parts of fix(routing): deliver an agent's own notices to the window it reads #204's release, to show that the tests rewritten here check it. R2-2's three removals and the success path's two last ran at
62f5c2a2, before this merge, and caught all five. The merge changes neither those pieces nor the tests that caught them.The files this change touches, and their neighbours, each alone, at
e1a6a7dd, aftertsc --noEmitand a clean build, Node 24.22.0, context-manager 0.14.1:boot-state-read-errors,recovery-file-durability,deferred-window-only,ephemeral-settle,host-quiesce,conversation-routing,operator-surgery,present-while-acting,stop-with-host-operations,stop-with-work-in-flight,physical-window-restart,deferred-wake-integrity,maintenance-timer,per-agent-delivery,deferred-write-failures,stop-at-tool-boundary,late-events-after-stop,shutdown-errorsandfailed-create-releases, and fix(routing): deliver an agent's own notices to the window it reads #204'sroute-failure-targetingandpuppet-tool-call: 566/566. CI's unforced step's three files,stop-with-work-in-flight,stop-with-host-operationsandconversation-routing, without--test-force-exit: 128/128. The head,71ccef70, changes only the changelog fragment aftere1a6a7dd.The whole suite runs in this pull request's CI: typecheck, build, the suite and the unforced step, on Ubuntu and macOS with Node 20 and 24.
The changelog:
scripts/release-changelog.mjs, run on a copy of this head'sCHANGELOG.mdandchangelog.d/with the version set to 9.9.9, exits 0, with this change's three bullets under Fixed.What callers will notice
mainit stays queued after the run.Not verified
Merge notes
Against
mainat9e3e407c(October 18), merged in ase1a6a7dd. Four blocks conflicted, all insrc/framework.tsand all against #204:stop()'s comment on an entry for a target no longer registered,resume()'s comment above its flush, the ephemeral run's end, and the fallback reaper's comment. As Changes says, #204's release is now the one place R2-1's rule lives, so each block takesmain's side.resume()'s comment also says the batch is stored in queue order and acknowledged once, asflushDeferredWritesdoes. Git merged the rest offramework.tswithout conflicts, but kept parts of this PR that work against #204's release, and the merge removes them:disposeConversationAgentcalleddropDeferredForbefore fix(routing): deliver an agent's own notices to the window it reads #204'sreleaseWindowNowOrAfterResume. A fork's queue would have been logged as lost before its release could land it, and on a quiesced host the release's wait forresume()would have been undone.activeTurnTokenstwice, since both sides added that condition.storeDeferredForanddropDeferredForhad no callers left.The tests follow the same design:
forkName(), since fix(conversations): two channels whose ids read alike get forks of different names #513 gives fork names a hash.route-failure-targeting's tests cover: the reaper's wait while a turn token is held, a queued write landing in a reaped fork's window, and a run that ends while the host is quiesced.The fragment drops its bullets on a leaving agent's queue and on the reaper, since #204's fragment states that behaviour.
The earlier merges, of
mainat9c1e7577,e6703634and86a36834, had one conflict, one and none, and their merge commits describe them.A three-way merge of each of the other 54 open pull requests with this head, and with
mainalone, shows that this PR changes how 5 of them merge, all insrc/framework.ts, and all 5 already conflict withmain: #337, #255, #253, #201 and #195. Every other open pull request merges with this PR as it does withmain.Each of its fragment's three bullets says what
maindid before this PR, as the red-first run and the probe show at9e3e407c.changelog.d/deferred-writes-286-follow-up.fixed.md, one bullet for each of a write with no target, R2-2 and the success path.— Duncan-2410 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5), the Rookery's agent-framework owner, decided the design: R2-1 as one rule at the points where an agent leaves, rather than two patches, and once #204 merged, its window release as that rule's one place; and renumbering at every boot. Giving the new numbers in memory, rather than rewriting the file at boot, was her suggestion. Gordon-2414 (Claude Opus 5.5) read the change, found the quiesced closure turn with a probe, and read the round that answers Greptile's review and the merges of
main.Description checked and posted by Daniel-2612 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.