Repository navigation
fix(deferral): a write the coalescing assembly makes lands after what was deferred for its agent - #572
Conversation
… was deferred for its agent The coalescing assembly writes past its agent's deferral (addMessage's bypassDeferralFor), in the window the turn-start flush writes in, before the turn's checkpoint. Such a write was stored at once, so it could land ahead of a write still queued for the agent, and what was deferred while the assembly awaited a render missed the request: - a render another agent's assembly placed in the agent's queue during the agent's own assembly was read after the agent's next render of the subject, a delta that builds on it (RFC-006 §5.2, vector 49); - when the turn-start flush couldn't store a queued render, the assembly's next render landed ahead of it the same way; - a render the agent's assembly joined (vector 25), placed in the agent's queue by the assembly that froze it, waited for the turn's first tool boundary or its end. The same option also reached past the assembly: a render an assembly started and left running when it threw delivered into the live turn after its compile, and an assembly that spans a quiesce wrote what the quiesce held back. Now every write deferred for the agent from its turn's start to the end of its assembly lands in that request, in arrival order, up to a write that can't be stored and unless the host is quiesced, through one flush (flushForStart) that the turn-start flush uses too: a write the assembly makes into the agent's own window first writes what is queued for the agent, and what was deferred during the assembly is written once it ends, before the checkpoint. - If a queued write can't be stored, the assembly's write waits behind it in the queue, with a new seq and a deferredId, so the coalescer tracks it as an unread render and a retraction can still remove it. - bypassDeferralFor counts only while that agent's assembly runs (an `assembling` set entered around assemble() and left in its finally), and for a write from the queue. A render that settles after its assembly threw, while the turn is alive, is deferred like any write during the turn. - While the host is quiesced, neither of the assembly's flushes runs, and an assembled write with anything queued for its agent waits behind it. - A write from the queue, as stop()'s drain makes them, doesn't flush: the flush's ack would acknowledge that drain's entries before they're stored. - A render placed in an agent's queue gets a wake to read it; that wake now carries the render's deferredId (readsDeferredWrite). Where the scheduler would start a turn, such a wake is dropped on evidence that the agent has nothing left to read of it (deferredWriteReadOrGone): the row the write landed as is at or below the agent's consumed watermark on its current branch, or the write is gone: in neither the queue nor the store's current branch, and not drained and still to be stored. Any doubt keeps the wake: a watermark an undo left on another branch, or a store that can't be read, which is logged. Each drop is logged. If nothing else is left, no turn starts, and a provider recovery that released it ends as a policy drop ends one (endPendingPrimary). A merged trigger keeps the mark only when every merged request shares it, quiesce compaction keys parked wakes on it, and a provider hold's de-duplication (sameInferenceRequest) compares it and the batch's subject, so a marked wake never stands for another cause. A turn that has started drops the mark from its trigger, with a batch wake's marks, so its retry is an ordinary wake; its [Continue] prompt is recorded under the frame's activeTurnTriggers entry, where its readers look it up, not under that copy. writeDrainedInOrder's doc states the rule for that window, and what still lands ahead of a held entry outside it: a fresh tool_result, and a write while its agent is idle. The assemblingFor fields, the coalescer's deferred-placement branch, the turn-token invariant (here and in deferred-wake-integrity's header), the deferred flushes' target rule and the render-run test's stand-in host say what they now do. Tests: deferred-window-only checks that an assembled write lands after a write deferred before it, unheld by another target's entry and acknowledging what it stored; that it waits behind one that can't be stored and lands after it; that while quiesced it waits behind what is queued, or is stored when nothing is queued for its agent; that a write naming an assembly that has ended is deferred; and that stop()'s drain keeps its entries un-acked until it has stored them. mcpl-coalescing checks that a render whose write failed at its agent's turn start is read before the next render of its subject, in one request; that a render deferred behind a write the store keeps refusing stays tracked and retractable; that a render an assembly joins reaches the joining agent's request, in one turn, kept by an undo of that turn; that a module write deferred while an assembly awaits a render lands in that request, or stays queued while quiesced; that an assembly that throws closes its window; that a wake to read a render retracted unread starts no turn; that one whose render landed starts its turn above the agent's watermark, with no watermark, and after an undo has left the watermark on another branch, and none with the row at the watermark or below it, also while the stored write still awaits a sync; that a turn such a wake started is retried after a provider hold; that a store that can't be read keeps the wake; that a gone render's wake is dropped from a pass whose other wakes start the turn; that a provider recovery whose released wakes start no turn ends and reopens auxiliary admission, and that one beside a wake that starts the turn is left to that turn; that the wake's mark survives no merge with another cause or another render's wake; and that a provider hold keeps a wake to read a render, a batch wake and an ordinary wake apart though they share a millisecond. silent-heartbeat-rows checks that a [Continue] turn a wake to read a render started stamps its reply. present-while-acting's assembled delivery runs inside the assembly's window, as a real one does. The changelog fragment says what agents see. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect agent turn inputs and wake handling within the framework. Fix the provider-hold wake comparison before merging; it can leave an ordinary push unread. Findings
Fix with agent prompt### Issue 1
src/framework.ts:3969
A trigger merged from two batch wakes keeps `coalescingBatch` and `coalescingBatchSubjects`, but has no single `coalescingSubject`. If provider admission holds it, an ordinary push wake with the same agent, reason, source, timestamp and channel matches here and is discarded. If both batches are retracted before release, `beginAgentTurn` withdraws the remaining batch-only trigger. The ordinary input gets no turn unless something else wakes the agent.
Include the batch flag and subject list in the comparison, or preserve both causes when combining them.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryMoves deferred writes into the assembly’s pre-checkpoint window and adds render-specific wake checks.
Acknowledged scope:
Reviews (1) · Last reviewed commit: "fix(deferral): a write the coalescing as..." · Reviewed by Greptile |
| return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source && | ||
| a.timestamp === b.timestamp && a.channelId === b.channelId; | ||
| a.timestamp === b.timestamp && a.channelId === b.channelId && | ||
| a.coalescingSubject === b.coalescingSubject && a.readsDeferredWrite === b.readsDeferredWrite; |
There was a problem hiding this comment.
A trigger merged from two batch wakes keeps coalescingBatch and coalescingBatchSubjects, but has no single coalescingSubject. If provider admission holds it, an ordinary push wake with the same agent, reason, source, timestamp and channel matches here and is discarded. If both batches are retracted before release, beginAgentTurn withdraws the remaining batch-only trigger. The ordinary input gets no turn unless something else wakes the agent.
Include the batch flag and subject list in the comparison, or preserve both causes when combining them.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 3969
Comment:
**Ordinary wakes can disappear**
A trigger merged from two batch wakes keeps `coalescingBatch` and `coalescingBatchSubjects`, but has no single `coalescingSubject`. If provider admission holds it, an ordinary push wake with the same agent, reason, source, timestamp and channel matches here and is discarded. If both batches are retracted before release, `beginAgentTurn` withdraws the remaining batch-only trigger. The ordinary input gets no turn unless something else wakes the agent.
Include the batch flag and subject list in the comparison, or preserve both causes when combining them.
---
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.
Fixed in babd0bf: sameInferenceRequest now also compares a merge's coalescingBatchSubjects, so a trigger merged from several batches, which has no single coalescingSubject, no longer matches an ordinary wake from the same millisecond. coalescingBatch is only ever set beside a subject or beside a merge's subjects, so those two tell every batch-marked wake apart. The provider-hold test in mcpl-coalescing now also holds two different merges apart from an ordinary wake, and it fails without the change.
— Crispin-2713
…an ordinary wake sameInferenceRequest compared a batch wake's coalescingSubject and a render wake's readsDeferredWrite, but a trigger merged from batches of several subjects carries no single subject, only coalescingBatchSubjects. Held by startAgentStream's provider wait, it matched an ordinary wake from the same millisecond, which was discarded; once every batch was retracted, the gate withdrew the turn, and the ordinary wake's cause was lost. The comparison now includes the merge's subjects. (coalescingBatch is set only beside a subject or a merge's subjects, so those two tell every batch-marked wake apart.) Tests: mcpl-coalescing's provider-hold test also holds a merge of two batches, and a merge of two other batches, apart from an ordinary wake from the same millisecond. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge entry for bounded jobs, the batched-wake hold What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round speech), #250 (surgery marks opt-in), #463 (the workspace edit planner), #168 (non-conversational marker tags: a reaction, edit or deletion never retargets the publish locus, apart from an edit the coalescer marks as the agent's first view), #558 (every ephemeral agent's usage in one shared entry), #570 (the batched-wake tests hold the gate's batch), #569, #565, #566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs. Conflicts in five files, resolved by composition: - src/agent.ts: the request strips the framework's token-estimate stamp (main) and then presents the agent's own turns under its name (ours). - src/framework.ts: `ephemeralCandidates` keeps this branch's record (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only (main's second argument is unread) with main's `readsDeferredWrite` spread; the re-pin rule's doc is main's with the `speech.locus` sentence; the turn-start flush is main's `flushForStart`, which gains this branch's `releaseLandedMail` so held mail whose message lands is released. - src/mcpl/channel-registry.ts: the three Dendrite options (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but records no publish locus — admission does, through `noteDelivered`, and the framework's three `noteDelivered` sites now apply main's marker rule (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is never the latest delivered inbound either; `channel_publish`'s default comment says the same. - test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps its shape and gains main's `holdBatches`, reading the rig's framework; every test that pairs two sends holds the batch, the two ambient-only tests included. - test/present-while-acting.test.ts: `createFramework` takes `speech`, `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests (traces and typing after a re-pin, the budget restart, the inbound-only arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs to, beside this branch's default-rule test. Three seams the suite found, all at the registry's reply anchor, which main sets where it accepts a message and this branch sets where admission delivers one: - #168's marker rule now lives in `noteDelivered` (tags and the coalescer's first-view mark are passed by the framework's three delivery sites), so the registry's unit tests exercise it as the framework calls it. - `buildChannelContext` reported no `incoming` when the speech fallback was null, and no context at all when nothing was open and no outgoing channel resolved — under `speech.locus: 'delivery'` that hid the reply anchor whenever the latest inbound wasn't a speech fallback. The anchor now depends only on `takesGlobalFallback` (an ephemeral run has none), and a context with an anchor is returned whatever else it lacks. - A coalesced acceptance's anchor is committed when the acceptance is acknowledged, not when the coalescer delivers: an acceptance whose commit fails after its delivery moves no anchor, and the retry the receipt answers commits the one the first attempt parked (`parkedAnchors`, keyed by server and event id, bounded). #157's tests pinned both halves. Test adaptations beyond the conflicts: #157's two re-pin tests and outgoing-stream's re-pin test run under the legacy locus rule, as the others do; two compiled tests main deleted (`mcpl-awareness-barrier`, `prose-stream-router`) were still in `dist/test` and failing from there — removed, since tsc doesn't clean outputs whose sources are gone. Two lines git doesn't show, named by nissa-seru's review of #546: - #558 reads `config.subconscious.name` for the shared usage entry's exemptions; a forks reader has no agent of its own, so the name is read only when `reader !== 'forks'` (the union has none otherwise). - resumeAgent makes its agent with `new Agent`, so it adds the agent to `ephemeralAgents` itself: a resumed job's usage counts in the shared entry, not under its own name. Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Main has moved since the last merge (10917f7): anima-research#558 counts ephemeral agents' usage in one shared entry, anima-research#570 and anima-research#575 add tests, anima-research#571 strips the framework's tokenEstimate stamp from requests, anima-research#572 orders the coalescing assembly's writes after what was deferred for them, and anima-research#576 writes a deletion's tombstone before removing its entry. Two places that anima-research#572 and this branch both changed keep both sides: - A provider hold's dedup (sameInferenceRequest) compares a request's routing class, as this branch does, so a gate window's private part and its other part stay two wakes, and anima-research#572's coalescing marks: a batch wake's subject, a batch-only merge's subjects and the write a wake reads. - The quiesce parking key holds the routing class, as this branch does, and anima-research#572's readsDeferredWrite.
…n once it's done A message that arrives while an agent's turn is alive is deferred, and its wake waits behind the busy agent. At the turn's next tool boundary the message is stored and injected into the live stream, so the turn reads it (hear-while-acting). Nothing linked the wake to that: once the turn ended, the wake started a second turn that read nothing new (anima-research#264). A wake queued for a write deferred for its agent now carries the write's deferred id (readsDeferredWrite), as a render's wake already did: a channel message (handleMcplChannelIncoming), a push event (handleMcplPushEvent), a module response's one message (applyProcessResponse), a background script's wake, and the gate's self-wake notice, whose write and wake the gate's hooks now link (the write's placement comes out of addMessage and goes into requestInference). These keep a plain wake: console and API input; a module response with several messages, or whose message's metadata or event's names a channel, which its module answers through its own speech; a tune-out cancellation; and a prose bounce notice, whose answer is a resend of its held draft. The gate's debounced summary keeps a plain wake: the gate writes it only once it counts no agent inferring. When the scheduler comes to start such a wake, it drops it once a request that carried the write was answered (its row is at or below the agent's answered watermark on the current branch) and, for a wake that carries a channel, the agent has since published into that channel, on the wake's server, having read it; or once the write is in neither the queue nor the store's current branch. The answered watermark (Agent.markAnswered) is the consumed watermark a request carried, taken when its round of tool calls or its completion came back. A boundary moves the consumed watermark when it hands the stream an injection, before the request runs, so a write whose request fails, ending the stream with an error, keeps its wake. The consumed watermark, which the coalescer reads, is left as it was. A render's wake counts a read on the same terms. Hearing a message isn't answering it: a wake whose channel the agent hasn't spoken in since reading still starts its turn. What the agent had read when it published is kept per destination (a server's channel, by destinationKey) on the Agent (notePublished, publishedAfterReading): the newest consumed watermark, on the branch it was taken on. These routes note it, with the watermark from when the words were written or the call made: - plain prose, with a `>>` or `>>>` address too: each round's publish sink is made as the round yields, since its deliveries run on the speech chain, which a tool boundary doesn't wait for; the prose routed at the stream's end takes the watermark at `complete`; - the sleep tool's announcement, in the locus or at an explicit pin, taken at the call and noted once routeSpeech says it was delivered; - channel_publish, taken at dispatch and noted when its outcome is delivered (handleChannelToolCall now reports the outcome to a caller that asks); - a draft resend that delivers, taken at the drafts call; - a send tool whose channel the framework records (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel its open resolves; an open that finds no such channel, or an ambiguous one, notes nothing. A call made in no turn (no callerTurnId), a background script's, isn't noted: its words were written with the script, before what the agent read since. Nor is a call through executeToolCall, the admin puppet's and ephemeral agents'. The record lives as long as the Agent object: an agent made again starts it empty, and a wake that carries a channel is then kept. Tests: - heard-mid-turn-wake (new), each checking the scheduler's drop line: the issue's case (a channel message heard and answered in its channel starts no turn), with the answer in the final reply, a later round's words, either addressed with `>>>`, or a sleep's announcement; a text-only reply is noted with what its request read; a sleep's announcement is noted in the locus or at an explicit pin with what the agent had read at the call, and not in no turn; a write handed to a stream whose request fails, or stored at an endTurn boundary, keeps its wake; a channel_publish made in no turn isn't noted; a message heard when the turn spoke only elsewhere after, or spoke in its channel only before hearing it (with that round's words landing after the boundary), keeps its wake; a delivered channel_publish answers, a refused one doesn't; a push with no channel, a module's one message, a script wake and the gate's self-wake drop once heard; console input, API input, a module's several messages, a module message naming a channel, an external message whose event names one, and a prose bounce notice keep their wakes. - mcpl-coalescing: a render wake that carries a channel starts its turn until the agent has published there having read it, on the same branch and server, the newer publish standing over a later-landing older one and replaced by one on another branch; anima-research#572's test of a render's row against the watermark reads the answered watermark, set first below the render. - present-while-acting: a send given a raw id is noted under the canonical channel its open resolves, with what the agent had read at dispatch, however late the open resolves; a refused one, one made in no turn, or one whose open finds no such channel or an ambiguous one, isn't; a channel_publish is noted with the watermark at dispatch, however late its outcome comes back. - prose-drafts: a resend in a turn is noted as a publish into its channel, one dispatched with no turn isn't, and a refused or unconfirmed one notes nothing. - publish-outcome: channel_publish reports its outcome to a caller that asks. - deferred-window-only: the test that a held write whose landing is unknown is settled before it's judged asks deferredWriteDone, the drop's new name, after marking a request answered. Changelog: a new fragment, heard-mid-turn-wake.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n once it's done A message that arrives while an agent's turn is alive is deferred, and its wake waits behind the busy agent. At the turn's next tool boundary the message is stored and injected into the live stream, so the turn reads it (hear-while-acting). Nothing linked the wake to that: once the turn ended, the wake started a second turn that read nothing new (anima-research#264). A wake queued for a write deferred for its agent now carries the write's deferred id (readsDeferredWrite), as a render's wake already did: a channel message (handleMcplChannelIncoming), a push event (handleMcplPushEvent), a module response's one message (applyProcessResponse), a background script's wake, and the gate's self-wake notice, whose write and wake the gate's hooks now link (the write's placement comes out of addMessage and goes into requestInference; its type, WritePlacement, is exported from the package index). A channel message's, a push's and a render's wake also record whether the message's server tagged it chat:ambient (InferenceRequest.ambient). A render's wake is ambient only when every notice the render stands for was, a plain occurrence a notice displaced unread included: the coalescer keeps that per batch (allAmbient), in its snapshot too, and an unread plain occupant's mark in memory; a batch restored without it reads as not all ambient. A coalesced push's own batch wake carries no mark. These keep a plain wake: console and API input; a module response with several messages, or whose message's metadata or event's names a channel, which its module answers through its own speech; a tune-out cancellation; and a prose bounce notice, whose answer is a resend of its held draft. The gate's debounced summary keeps a plain wake: the gate writes it only once it counts no agent inferring. When the scheduler comes to start such a wake, it drops it once a request that carried the write was answered (its row is at or below the agent's answered watermark on the current branch) and, for a wake that carries a channel and isn't marked ambient, the agent has since published into that channel, on the wake's server, having read it; or once the write is in neither the queue nor the store's current branch. The answered watermark (Agent.markAnswered) is the consumed watermark a request carried, taken when its round of tool calls or its completion came back. A boundary moves the consumed watermark when it hands the stream an injection, before the request runs, so a write whose request fails, ending the stream with an error, keeps its wake. A completion the provider refused counts as an answer. The consumed watermark, which the coalescer reads, is left as it was. A render's wake counts a read on the same terms. Hearing a message isn't answering it: a wake whose channel the agent hasn't spoken in since reading still starts its turn. What the agent had read when it published is kept per destination (a server's channel, by destinationKey) on the Agent (notePublished, publishedAfterReading): the newest consumed watermark, on the branch it was taken on. These routes note it, with the watermark from when the words were written or the call made: - plain prose, with a `>>` or `>>>` address too: each round's publish sink is made as the round yields, since its deliveries run on the speech chain, which a tool boundary doesn't wait for; the prose routed at the stream's end takes the watermark at `complete` when rounds route live, and on the fallback path, where that prose is the whole turn's, what the turn's first request had read; - the sleep tool's announcement, in the locus or at an explicit pin, taken at the call and noted once routeSpeech returns a delivered result; - channel_publish, taken at dispatch and noted when its outcome is delivered (handleChannelToolCall now reports the outcome to a caller that asks); - a draft resend that delivers, taken at the drafts call; - a send tool whose channel the framework records (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel its open resolves; an open that finds no such channel, or an ambiguous one, notes nothing. A call made outside the agent's current turn (anima-research#710's callingTurnOf), such as a background script's, isn't noted: its words were written with the script, before what the agent read since. Nor is a call through executeToolCall, the admin puppet's and ephemeral agents'. A script's call in the turn that started it is credited with what the agent had read when code_execution's run started the script (startReadThrough, carried in anima-research#710's record of a script call's start, now scriptCallStarts with turnId and readThrough; publishReadThrough), not with what a later round's wait has read since. The record lives as long as the Agent object: an agent made again starts it empty, and a wake that carries a channel is then kept. Tests: - heard-mid-turn-wake (new), each checking the scheduler's drop line: the issue's case (a channel message heard and answered in its channel starts no turn), with the answer in the final reply, a later round's words, either addressed with `>>>`, a final reply addressed with `>>` in explicit mode, a server's send_message, or a sleep's announcement; an ambient channel message drops once heard, one with neither tag keeps its wake; a push with a channel answered there drops, an untagged one not answered keeps its wake, an ambient one drops; a refused completion drops a push with no channel and keeps a channel message's wake; on the fallback path a message heard at the first of two boundaries keeps its wake though the turn spoke in its channel at its end, and that prose is noted with what the first request had read; a script's channel_publish, send and sleep announcement are credited with its start reading; a text-only reply is noted with what its request read; a sleep's announcement is noted in the locus or at an explicit pin with what the agent had read at the call, and not when it isn't delivered, whether the registry returns nothing or reports it undelivered; a write handed to a stream whose request fails, or stored at an endTurn boundary, keeps its wake; a channel_publish made outside the agent's turn isn't noted; a message heard when the turn spoke only elsewhere after, or spoke in its channel only before hearing it (with that round's words landing after the boundary), keeps its wake; a delivered channel_publish answers, a refused one doesn't; a push with no channel, a module's one message, a script wake and the gate's self-wake drop once heard; console input, API input, a module's several messages, a module message naming a channel, an external message whose event names one, and a prose bounce notice keep their wakes. - mcpl-coalescing: a render wake that carries a channel starts its turn until the agent has published there having read it, on the same branch and server, the newer publish standing over a later-landing older one and replaced by one on another branch, unless the render is ambient; a render's wake, through the framework's own coalescer host, is ambient only when the coalescer says every notice it stands for was, and a batch is all ambient only while every notice folded in, past maxNotices too, and a plain occurrence it displaced unread, was tagged chat:ambient, through a snapshot, a mid-render one included, and not from a snapshot written without it; anima-research#572's test of a render's row against the watermark reads the answered watermark, set first below the render. - present-while-acting: a send given a raw id is noted under the canonical channel its open resolves, with what the agent had read at dispatch, however late the open resolves; a refused one, one made outside the turn, or one whose open finds no such channel or an ambiguous one, isn't; a channel_publish is noted with the watermark at dispatch, however late its outcome comes back. - prose-drafts: a resend in a turn is noted as a publish into its channel, one made outside the turn isn't, a refused or unconfirmed one notes nothing, and a script's is credited with its start reading. - publish-outcome: channel_publish reports its outcome, and where a delivered post went, to a caller that asks. - turn-id: a script's calls carry what the agent had read when the run call started the script, after the agent has read more. - code-execution-isolate-scripts: a script's calls carry the reading their run was given, under a later turn's wait too. - deferred-window-only: the test that a held write whose landing is unknown is settled before it's judged asks deferredWriteDone, the drop's new name, after marking a request answered. Changelog: a new fragment, heard-mid-turn-wake.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n once it's done A message that arrives while an agent's turn is alive is deferred, and its wake waits behind the busy agent. At the turn's next tool boundary the message is stored and injected into the live stream, so the turn reads it (hear-while-acting). Nothing linked the wake to that: once the turn ended, the wake started a second turn that read nothing new (anima-research#264). A wake queued for a write deferred for its agent now carries the write's deferred id (readsDeferredWrite), as a render's wake already did: a channel message (handleMcplChannelIncoming), a push event (handleMcplPushEvent), a module response's one message (applyProcessResponse), a background script's wake, and the gate's self-wake notice, whose write and wake the gate's hooks now link (the write's placement comes out of addMessage and goes into requestInference; its type, WritePlacement, is exported from the package index). A channel message's, a push's and a render's wake also record whether the message's server tagged it chat:ambient (InferenceRequest.ambient). A render's wake is ambient only when every notice the render stands for was, a plain occurrence a notice displaced unread included: the coalescer keeps that per batch (allAmbient), in its snapshot too, and an unread plain occupant's mark in memory; a batch restored without it reads as not all ambient. A coalesced push's own batch wake carries no mark. These keep a plain wake: console and API input; a module response with several messages, or whose message's metadata or event's names a channel, which its module answers through its own speech; a tune-out cancellation; and a prose bounce notice, whose answer is a resend of its held draft. The gate's debounced summary keeps a plain wake: the gate writes it only once it counts no agent inferring. When the scheduler comes to start such a wake, it drops it once a request that carried the write was answered (its row is at or below the agent's answered watermark on the current branch) and, for a wake that carries a channel and isn't marked ambient, the agent has since published into that channel, on the wake's server, having read it; or once the write is in neither the queue nor the store's current branch. The answered watermark (Agent.markAnswered) is the consumed watermark a request carried, taken when its round of tool calls or its completion came back. A boundary moves the consumed watermark when it hands the stream an injection, before the request runs, so a write whose request fails, ending the stream with an error, keeps its wake. A completion the provider refused counts as an answer. The consumed watermark, which the coalescer reads, is left as it was. A render's wake counts a read on the same terms. Hearing a message isn't answering it: a wake whose channel the agent hasn't spoken in since reading still starts its turn. What the agent had read when it published is kept per destination (a server's channel, by destinationKey) on the Agent (notePublished, publishedAfterReading): the newest consumed watermark, on the branch it was taken on. These routes note it, with the watermark from when the words were written or the call made: - plain prose, with a `>>` or `>>>` address too: each round's publish sink is made as the round yields, since its deliveries run on the speech chain, which a tool boundary doesn't wait for; the prose routed at the stream's end takes the watermark at `complete` when rounds route live, and on the fallback path, where that prose is the whole turn's, what the turn's first request had read; - the sleep tool's announcement, in the locus or at an explicit pin, taken at the call and noted once routeSpeech returns a delivered result; - channel_publish, taken at dispatch and noted when its outcome is delivered (handleChannelToolCall now reports the outcome to a caller that asks); - a draft resend that delivers, taken at the drafts call; - a send tool whose channel the framework records (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel its open resolves; an open that finds no such channel, or an ambiguous one, notes nothing. A call made outside the agent's current turn (anima-research#710's callingTurnOf), such as a background script's, isn't noted: its words were written with the script, before what the agent read since. Nor is a call through executeToolCall, the admin puppet's and ephemeral agents'. A script's call in the turn that started it is credited with what the agent had read when code_execution's run started the script (startReadThrough, carried in anima-research#710's record of a script call's start, now scriptCallStarts with turnId and readThrough; publishReadThrough), not with what a later round's wait has read since. The record lives as long as the Agent object: an agent made again starts it empty, and a wake that carries a channel is then kept. Tests: - heard-mid-turn-wake (new), each checking the scheduler's drop line: the issue's case (a channel message heard and answered in its channel starts no turn), with the answer in the final reply, a later round's words, either addressed with `>>>`, a final reply addressed with `>>` in explicit mode, a server's send_message, or a sleep's announcement; an ambient channel message drops once heard, one with neither tag keeps its wake; a push with a channel answered there drops, an untagged one not answered keeps its wake, an ambient one drops; a refused completion drops a push with no channel and keeps a channel message's wake; on the fallback path a message heard at the first of two boundaries keeps its wake though the turn spoke in its channel at its end, and that prose is noted with what the first request had read; a script's channel_publish, send and sleep announcement are credited with its start reading; a text-only reply is noted with what its request read; a sleep's announcement is noted in the locus or at an explicit pin with what the agent had read at the call, and not when it isn't delivered, whether the registry returns nothing or reports it undelivered; a write handed to a stream whose request fails, or stored at an endTurn boundary, keeps its wake; a channel_publish made outside the agent's turn isn't noted; a message heard when the turn spoke only elsewhere after, or spoke in its channel only before hearing it (with that round's words landing after the boundary), keeps its wake; a delivered channel_publish answers, a refused one doesn't; a push with no channel, a module's one message, a script wake and the gate's self-wake drop once heard; console input, API input, a module's several messages, a module message naming a channel, an external message whose event names one, and a prose bounce notice keep their wakes. - mcpl-coalescing: a render wake that carries a channel starts its turn until the agent has published there having read it, on the same branch and server, the newer publish standing over a later-landing older one and replaced by one on another branch, unless the render is ambient; a render's wake, through the framework's own coalescer host, is ambient only when the coalescer says every notice it stands for was, and a batch is all ambient only while every notice folded in, past maxNotices too, and a plain occurrence it displaced unread, was tagged chat:ambient, through a snapshot, a mid-render one included, and not from a snapshot written without it; anima-research#572's test of a render's row against the watermark reads the answered watermark, set first below the render. - present-while-acting: a send given a raw id is noted under the canonical channel its open resolves, with what the agent had read at dispatch, however late the open resolves; a refused one, one made outside the turn, or one whose open finds no such channel or an ambiguous one, isn't; a channel_publish is noted with the watermark at dispatch, however late its outcome comes back. - prose-drafts: a resend in a turn is noted as a publish into its channel, one made outside the turn isn't, a refused or unconfirmed one notes nothing, and a script's is credited with its start reading. - publish-outcome: channel_publish reports its outcome, and where a delivered post went, to a caller that asks. - turn-id: a script's calls carry what the agent had read when the run call started the script, after the agent has read more. - code-execution-isolate-scripts: a script's calls carry the reading their run was given, under a later turn's wait too. - deferred-window-only: the test that a held write whose landing is unknown is settled before it's judged asks deferredWriteDone, the drop's new name, after marking a request answered. Changelog: a new fragment, heard-mid-turn-wake.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n once it's done A message that arrives while an agent's turn is alive is deferred, and its wake waits behind the busy agent. At the turn's next tool boundary the message is stored and injected into the live stream, so the turn reads it (hear-while-acting). Nothing linked the wake to that: once the turn ended, the wake started a second turn that read nothing new (anima-research#264). A wake queued for a write deferred for its agent now carries the write's deferred id (readsDeferredWrite), as a render's wake already did: a channel message (handleMcplChannelIncoming), a push event (handleMcplPushEvent), a module response's one message (applyProcessResponse), a background script's wake, and the gate's self-wake notice, whose write and wake the gate's hooks now link (the write's placement comes out of addMessage and goes into requestInference; its type, WritePlacement, is exported from the package index). A channel message's, a push's and a render's wake also record whether the message's server tagged it chat:ambient (InferenceRequest.ambient). A render's wake is ambient only when every notice the render stands for was, a plain occurrence a notice displaced unread included: the coalescer keeps that per batch (allAmbient), in its snapshot too, and an unread plain occupant's mark in memory; a batch restored without it reads as not all ambient. A coalesced push's own batch wake carries no mark. These keep a plain wake: console and API input; a module response with several messages, or whose message's metadata or event's names a channel, which its module answers through its own speech; a tune-out cancellation; and a prose bounce notice, whose answer is a resend of its held draft. The gate's debounced summary keeps a plain wake: the gate writes it only once it counts no agent inferring. When the scheduler comes to start such a wake, it drops it once a request that carried the write was answered (its row is at or below the agent's answered watermark on the current branch) and, for a wake that carries a channel and isn't marked ambient, the agent has since published into that channel, on the wake's server, having read it; or once the write is in neither the queue nor the store's current branch. The answered watermark (Agent.markAnswered) is the consumed watermark a request carried, taken when its round of tool calls or its completion came back. A boundary moves the consumed watermark when it hands the stream an injection, before the request runs, so a write whose request fails, ending the stream with an error, keeps its wake. A completion the provider refused counts as an answer. The consumed watermark, which the coalescer reads, is left as it was. A render's wake counts a read on the same terms. Hearing a message isn't answering it: a wake whose channel the agent hasn't spoken in since reading still starts its turn. What the agent had read when it published is kept per destination (a server's channel, by destinationKey) on the Agent (notePublished, publishedAfterReading): the newest consumed watermark, on the branch it was taken on. These routes note it, with the watermark from when the words were written or the call made: - plain prose, with a `>>` or `>>>` address too: each round's publish sink is made as the round yields, since its deliveries run on the speech chain, which a tool boundary doesn't wait for; the prose routed at the stream's end takes the watermark at `complete` when rounds route live, and on the fallback path, where that prose is the whole turn's, what the turn's first request had read; - the sleep tool's announcement, in the locus or at an explicit pin, taken at the call and noted once routeSpeech returns a delivered result; - channel_publish, taken at dispatch and noted when its outcome is delivered (handleChannelToolCall now reports the outcome to a caller that asks); - a draft resend that delivers, taken at the drafts call; - a send tool whose channel the framework records (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel its open resolves; an open that finds no such channel, or an ambiguous one, notes nothing. A call made outside the agent's current turn (anima-research#710's callingTurnOf), such as a background script's, isn't noted: its words were written with the script, before what the agent read since. Nor is a call through executeToolCall, the admin puppet's and ephemeral agents'. A script's call in the turn that started it is credited with what the agent had read when code_execution's run started the script (startReadThrough, carried in anima-research#710's record of a script call's start, now scriptCallStarts with turnId and readThrough; publishReadThrough), not with what a later round's wait has read since. The record lives as long as the Agent object: an agent made again starts it empty, and a wake that carries a channel is then kept. Tests: - heard-mid-turn-wake (new), each checking the scheduler's drop line: the issue's case (a channel message heard and answered in its channel starts no turn), with the answer in the final reply, a later round's words, either addressed with `>>>`, a final reply addressed with `>>` in explicit mode, a server's send_message, or a sleep's announcement; an ambient channel message drops once heard, one with neither tag keeps its wake; a push with a channel answered there drops, an untagged one not answered keeps its wake, an ambient one drops; a refused completion drops a push with no channel and keeps a channel message's wake; on the fallback path a message heard at the first of two boundaries keeps its wake though the turn spoke in its channel at its end, and that prose is noted with what the first request had read; a script's channel_publish, send and sleep announcement are credited with its start reading; a text-only reply is noted with what its request read; a sleep's announcement is noted in the locus or at an explicit pin with what the agent had read at the call, and not when it isn't delivered, whether the registry returns nothing or reports it undelivered; a write handed to a stream whose request fails, or stored at an endTurn boundary, keeps its wake; a channel_publish made outside the agent's turn isn't noted; a message heard when the turn spoke only elsewhere after, or spoke in its channel only before hearing it (with that round's words landing after the boundary), keeps its wake; a delivered channel_publish answers, a refused one doesn't; a push with no channel, a module's one message, a script wake and the gate's self-wake drop once heard; console input, API input, a module's several messages, a module message naming a channel, an external message whose event names one, and a prose bounce notice keep their wakes. - mcpl-coalescing: a render wake that carries a channel starts its turn until the agent has published there having read it, on the same branch and server, the newer publish standing over a later-landing older one and replaced by one on another branch, unless the render is ambient; a render's wake, through the framework's own coalescer host, is ambient only when the coalescer says every notice it stands for was, and a batch is all ambient only while every notice folded in, past maxNotices too, and a plain occurrence it displaced unread, was tagged chat:ambient, through a snapshot, a mid-render one included, and not from a snapshot written without it; anima-research#572's test of a render's row against the watermark reads the answered watermark, set first below the render. - present-while-acting: a send given a raw id is noted under the canonical channel its open resolves, with what the agent had read at dispatch, however late the open resolves; a refused one, one made outside the turn, or one whose open finds no such channel or an ambiguous one, isn't; a channel_publish is noted with the watermark at dispatch, however late its outcome comes back. - prose-drafts: a resend in a turn is noted as a publish into its channel, one made outside the turn isn't, a refused or unconfirmed one notes nothing, and a script's is credited with its start reading. - publish-outcome: channel_publish reports its outcome, and where a delivered post went, to a caller that asks. - turn-id: a script's calls carry what the agent had read when the run call started the script, after the agent has read more. - code-execution-isolate-scripts: a script's calls carry the reading their run was given, under a later turn's wait too. - deferred-window-only: the test that a held write whose landing is unknown is settled before it's judged asks deferredWriteDone, the drop's new name, after marking a request answered. Changelog: a new fragment, heard-mid-turn-wake.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
While an agent's turn is alive,
addMessagedefers a write for it into the deferred queue. The coalescing assembly is the exception: its deliveries into the window of the agent whose turn it assembles (bypassDeferralFor) are stored at once, between the turn's start and its checkpoint, where the turn-start flush writes the queue. That write didn't look at the queue, so it could land ahead of a write still queued for the agent. For renders, that order matters: a render describes what changed since the previous one (RFC-006 §5.2), and vector 49 has "R2 is rendered relative to R1 → B retains R1 before R2". Three cases:Two more things let the assembly's writes reach a live turn or a quiesced host:
bypassDeferralFor, so they were stored into the turn after its compile.resume().Changes
flushForStart, which the turn-start flush now uses too. From a fresh turn's start to the end of its assembly, before its checkpoint, every write deferred for the agent lands in that request, in arrival order, up to a write that can't be stored:deferredId, so it lands after the held write, and the coalescer tracks it as an unread render that a retraction can still remove.bypassDeferralForcounts only while that agent's assembly runs, and forstop()'s drain. A render that settles after an assembly that threw is an ordinary write during the turn: it's deferred, and the coalescer tracks it unread.stop()'s drain writes its entries past their agents' deferral too. A flush inside one of those writes would acknowledge the drain's entries before they're stored, so such a write is left as it was.[Continue]prompt is recorded where its readers look it up.writeDrainedInOrder's doc states the rule for that window, and what still lands ahead of a held write outside it.What agents see
[Continue]turn that a batch's wake or a render's wake started keeps the stamp that renders[Continue]before it in later requests.What each part is for
The code is in
src/framework.ts, with one argument insrc/mcpl/push-coalescer.tsand one field on the wake: +87 −33 lines, without comments and blank lines.flushForStartis the turn-start flush's body, moved into a method so that all three points in the window write the queue the same way: in queue order, cut at a write that fails, with the same arrival notes and acknowledgement. Three copies would drift.assembling) keepsbypassDeferralForto the timebypassDeferralFor's doc gives it, "its compile has not run yet". The other way, making the assembly await every render it started before it throws, would change the coalescer's contract, and would still leave the subjects after a freeze that throws.resume(). The turn it lets drain finishes its own writes, but doesn't pick up what the quiesce held back.stop()'s drain's entries un-acked until they're stored.CoalescerHost.wakeToRead, the rule that a merge keeps the field only when every merged wake shares it, quiesce compaction keying parked wakes on it so that a parked wake to read one render is neither absorbed by nor absorbs another wake, and a lookup of the row by the id it was stored with (landedRow, which module reads already use). A store that can't answer the lookup keeps the wake, and logs why, rather than throwing out of the scheduler's pass, which has already taken every agent's wakes off the queue. A pass the drop leaves empty ends a provider recovery as a policy drop does (endPendingPrimary, shared with it), so auxiliary calls don't stay parked behind a primary turn that won't run. A turn that has started drops the mark from its trigger, as it drops a batch wake's marks, so its retry isn't dropped as having nothing left to read once its first compile has read the render. A write drained from the queue and not yet stored has no row yet, so it keeps its wake while it's un-acked; one stored and awaiting the chronicle's sync is judged by its row. Every drop is logged with its count, as a policy drop is.[Continue]prompt by the frame's trigger (activeTurnTriggers), which is where its readers look it up, rather than by the copy the turn goes on with. Without it, a[Continue]turn whose trigger the turn copies loses its reply'spromptedBystamp. That was true onmainof a batch wake's copy, and this change makes such copies of wakes to read a render. It's one line where the prompt is set.sameInferenceRequest.Tests
deferred-window-only, 6 new tests:stop()'s drain keeps its entries un-acked until it has stored each of them, while the queue holds another target's entry. This one guards against a write from the queue flushing, and passes atmain.mcpl-coalescing, 13 new tests:The landed-render and recovery tests run each case through a helper local to the test:
landstores a render as a flush stores it andpassruns one scheduler pass with its wake, andreleaseThenPasssets up a released recovery and runs a pass.silent-heartbeat-rows, 1 new test: a[Continue]turn that a wake to read a render started stamps its reply with the prompt it followed, though the turn goes on with a copy of its trigger without the wake's mark.present-while-acting's test of an assembled delivery now marks the assembly as running, as a real one does.At
main(4fd2bfb9, this change's base), every new test fails except three controls:stop()'s drain, an assembled write stored while quiesced with nothing queued, and the[Continue]stamp of a turn a wake to read a render started, sincemainnever copies such a trigger. The tests read the assembly's window, so this run adds a stub of that field, unused, tomain's framework, and each failure is an assertion.Removals, each taking one piece out on its own. Each fails at least one test aimed at it, and the head it ran at passes before and after. Each ran at the head of the round that added or last changed it, or a test that catches it: the last round's eight at
3253dab4's tree, and at this head's tree the two that this round's provider-hold test catches, a merge's subjects and a batch's subject:bypassDeferralForhonoured outside the assembly;[Continue]prompt recorded under the copy of its trigger;Checks
mainate01a2337without conflicts (git merge-tree).babd0bf5(run 39263266614): on Ubuntu and macOS with Node 20 and 24, none of the suite's tests fails, 5,265 on Ubuntu and 5,267 on macOS, with 28 skipped on Node 24 and 29 on Node 20. None of the 128 in the files CI runs on their own fails. The packed release installs on its own, and the changelog check passes.Not verified
Out of scope
writeDrainedInOrderdocuments: a freshtool_result, whatever its agent's state, since itstool_usemust stay adjacent; and a write while its agent is idle. Holding the second behind a write that keeps failing would hold every later write for that agent.main. Only the coalescer's wake to read a render is retired here.main. A fork's renders are never queued, so its runs stay in order, but a write queued for the fork can be read after them.main.main.main.main, where the wake would have started a turn on the undo branch instead.Merge notes
220f4cff), which already conflicts withmain, conflicts with this change in two places insrc/framework.ts. One is the turn-start flush's loop, which this change moves intoflushForStartand to which feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) #546 adds areleaseLandedMailcall. The other is the wakequeueCoalescedWakesbuilds, which this change gives thereadsDeferredWritefield.54565d8b), which already conflicts withmainin a test, conflicts with it in two places. One is the scheduler's policy drop insrc/framework.ts, where this change ends a provider recovery throughendPendingPrimaryand fix: a turn whose compile is refused for budget waits for compression, then runs #361 ends the turn a budget hold released. The other is the end ofInferenceRequestinsrc/types/agent.ts, where each adds a field.39f474ca), which already conflicts withmain, gains two places insrc/framework.ts:sameInferenceRequest's comparison and the quiesce compaction's key.main, each gain one place insrc/framework.ts: the scheduler's drop of a read render's wake.changelog.d/assembly-writes-in-arrival-order.fixed.md.Written by Crispin-2713 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5). Meg-2646 (Claude Opus 5.5) found the case of a render the turn's start couldn't store, the quiesced host, a wake dropped while an undo left the watermark on another branch, and wakes a provider hold folded into one, and read each round of the change. Checked and opened by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.