Skip to content

fix(deferral): a write the coalescing assembly makes lands after what was deferred for its agent - #572

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/assembly-writes-in-arrival-order
Oct 19, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/assembly-writes-in-arrival-order

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Problem

While an agent's turn is alive, addMessage defers 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:

  • A render placed in the queue during the assembly. Another agent's assembly places a render in this agent's queue, because this agent's turn is alive. This agent's own assembly then renders the subject's next batch and stores it at once, so the later render is read first, and the earlier one after it.
  • A render the turn's start couldn't store. The turn-start flush cuts at a write that fails, so the render stays queued, and the assembly's next render of the subject lands ahead of it.
  • A render the assembly joined. When another agent's assembly is rendering a batch whose audience includes this agent, this agent's assembly waits for that render, so its compile shows the content (vector 25). The render is written for the assembly that froze it, so when it lands in this agent's window, it's deferred into this agent's queue. It's then read at the turn's first tool boundary or its end, after the compile.

Two more things let the assembly's writes reach a live turn or a quiesced host:

  • An assembly that throws (a freeze or a delivery that fails) leaves the renders it started running. Their deliveries still carried bypassDeferralFor, so they were stored into the turn after its compile.
  • A quiesce that begins while an assembly awaits a render lets that turn drain, and the assembly's writes don't wait for resume().

Changes

  • One flush for the window, 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:
    • the turn-start flush writes the queue, as before;
    • while the assembly runs, a write it makes into the agent's own window first writes what is queued for the agent;
    • what was deferred during the assembly is written once the assembly ends. A turn its assembly withdraws, having nothing to read, lands it as before, once it has let the agent go.
  • A write the assembly makes waits behind a held write. When a queued write can't be stored, the drain cuts there, as before, and the assembly's write is deferred behind what stays queued. It gets a new seq and a deferredId, so it lands after the held write, and the coalescer tracks it as an unread render that a retraction can still remove.
  • bypassDeferralFor counts only while that agent's assembly runs, and for stop()'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.
  • Not while the host is quiesced. Neither of the assembly's flushes runs then, and its write waits behind whatever is queued for its agent, so it lands when that does, at the queue's next flush for the agent. With nothing queued for the agent, the write is stored as before.
  • A write from the queue doesn't flush. 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.
  • A wake to read a render starts no turn once the agent has read the render, or it was removed. When a render lands in an agent's queue, the coalescer gives it a wake to read it. Where the scheduler would start a turn, that wake is dropped on evidence only: the row the render landed as is at or below the agent's watermark on its current branch, or the render is in neither the queue nor the store's current branch. Any doubt keeps it, such as a watermark an undo left on another branch, or a store that can't be read. The turn then starts only for what else woke it. A merged wake keeps the mark only when every wake in it shares it, and a provider hold's de-duplication tells wakes apart by it, by a batch's subject, and by the subjects of a merge of several batches. Once a turn starts, its trigger loses the mark, so a retry of that turn is an ordinary wake, and the turn's [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

  • An assembled render is read after the renders queued ahead of it, in the same request.
  • When a queued render keeps failing to be stored, the assembly's next render of its subject waits with it, rather than being read without it. While that lasts, a turn the render's wake starts has nothing new of it to read, as for any write held behind a failing one.
  • What is deferred for an agent while its assembly awaits a render is read in that turn's request, rather than at its first tool boundary or its end. That covers a render it joined, and a message from a module, the API or another server.
  • A render that settles after its assembly has thrown, while the turn is still alive, is deferred like any write during the turn, rather than stored into the turn after its compile. While the host is quiesced, the assembly's writes wait behind what is queued for the agent.
  • A render's wake to read it starts no turn if, when the scheduler comes to start it, the agent has already read the render or the render was retracted. A turn it started that a provider hold retries is retried as before.
  • A provider hold no longer folds two wakes that share a millisecond into one when one of them is a batch's wake or a wake to read a render.
  • A reply to a [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 in src/mcpl/push-coalescer.ts and one field on the wake: +87 −33 lines, without comments and blank lines.

  • flushForStart is 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.
  • The flush before an assembled write puts what came first ahead of it. The other way, deferring every assembled write behind the queue, would take the render out of the request being assembled. The turn could then be withdrawn, and woken again for both renders.
  • Waiting behind a cut keeps the order when a queued write keeps failing. Storing the assembled write anyway would let the model read a render without the one it builds on.
  • The flush once the assembly ends lands what arrived during the assembly after its last write, such as a joined render. Without it, such a write would reach the request only if a later assembled write happened to flush it.
  • The assembly's window (assembling) keeps bypassDeferralFor to the time bypassDeferralFor'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.
  • Skipping the flushes while quiesced keeps quiesce's rule that what it holds waits for resume(). The turn it lets drain finishes its own writes, but doesn't pick up what the quiesce held back.
  • Leaving a write from the queue alone keeps stop()'s drain's entries un-acked until they're stored.
  • Dropping the wake to read a render keeps a turn from running only to read what the request before it read, or what was retracted. It's judged where the scheduler would start a turn, so a turn withdrawn before reading the render keeps its wake, and a wake queued late is judged when it would fire. It drops on evidence only, rather than with the coalescer's own unread test, which resolves doubt toward "read": that is the safe side for never rewriting what may have been seen, and the wrong side for dropping a wake, since a turn that finds nothing new costs less than a render nothing wakes the agent to read. Its cost is one optional field on the wake, one argument on 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.
  • Keying a turn's [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's promptedBy stamp. That was true on main of 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.
  • Telling wakes apart in a provider hold's de-duplication by the render they read and the batches they're for keeps a wake that carries a mark from standing for another cause, which a withdrawal of the batch or the drop of a read render's wake would then take with it. A merge of several batches has no single subject, so its subjects are compared too. That's three comparisons in sameInferenceRequest.

Tests

  • deferred-window-only, 6 new tests:

    • An assembled write lands after a write deferred for its agent before it. It isn't held by another target's queued entry, and with a persisted queue it acknowledges what it stored.
    • An assembled write waits behind a deferred write that can't be stored, and lands after it once the store recovers.
    • An assembled write waits behind what is queued while the host is quiesced, and lands after it on resume. With nothing queued for its agent, or only another target's entry, it's stored.
    • A write naming its agent's assembly after that assembly has ended is deferred, and nothing is flushed into the live turn.
    • 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 at main.
  • mcpl-coalescing, 13 new tests:

    • A render whose write failed at its agent's turn start is read before the next render of its subject, in one request.
    • A render deferred behind a write the store keeps refusing stays tracked: no request reads it, and a retraction withdraws both.
    • A render an assembly joins reaches the joining agent's request, written by the assembly's flush. Only one turn runs, since the render's wake starts none once the render is read, and undoing the turn keeps the render.
    • A wake to read a render that was retracted unread starts no turn.
    • A wake to read a render neither carries its mark into a merge with another cause or a wake reading another render, nor absorbs another wake while parked during a quiesce.
    • A wake to read a render starts its turn while the render is still queued, drained but not yet stored, above the agent's watermark, with no watermark, or after an undo has left the watermark on another branch at or past the render's row. It starts none with the row at the watermark itself, or below it, also while the stored write still awaits the chronicle's sync.
    • A turn a wake to read a render started, held by a provider hold, is retried once the hold ends, though its compile read the render.
    • A wake to read a render starts its turn when the store can't say whether the agent read it, and the failed read is logged.
    • A wake to read a render that's gone is dropped from a pass whose other wakes still start the turn, and the drop is logged.
    • A provider recovery whose released wakes start no turn ends, and auxiliary admission reopens: for a gone render's wake, even under a policy that would start a turn for anything, and for a wake the policy drops. A gone render's wake dropped beside a wake that starts the turn leaves the recovery to that turn.
    • A provider hold keeps a wake to read a render, a batch wake, merges of different batches and an ordinary wake apart, though they share a millisecond.
    • An assembly that throws closes the window in which its writes bypass the turn's deferral.
    • A module's write deferred while the assembly awaits a render lands in that request, or stays queued when the host has been quiesced. The assembly's window has closed afterwards.

    The landed-render and recovery tests run each case through a helper local to the test: land stores a render as a flush stores it and pass runs one scheduler pass with its wake, and releaseThenPass sets 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, since main never copies such a trigger. The tests read the assembly's window, so this run adds a stub of that field, unused, to main'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:

    • no flush before an assembled write;
    • a flush, but no waiting behind a cut;
    • the flush reporting nothing still queued;
    • a write from the queue flushing too;
    • no flush once the assembly ends;
    • the assembly's flushes running while quiesced, or an assembled write stored ahead of the queue while quiesced;
    • the flush after the assembly running while quiesced;
    • bypassDeferralFor honoured outside the assembly;
    • the window never opening, or never closing;
    • no drop of a read render's wake, a wake without the render's id, a render placed without passing its id, a merge keeping the mark it doesn't share, or quiesce compaction ignoring it;
    • the drop treating a queued render as gone, a render gone from the store as unread, a row at the watermark as unread, a watermark on another branch as current, or no watermark as one past the row;
    • the drop treating a drained write not yet stored as gone, a stored write awaiting a sync as unread, or a row below the watermark as unread;
    • a store that can't be read throwing out of the scheduler's pass, a dropped pass leaving a provider recovery pending, a recovery ended beside a wake that starts the turn, a started turn's trigger keeping the mark, or a drop logged only when it empties the pass;
    • a turn's [Continue] prompt recorded under the copy of its trigger;
    • a provider hold's de-duplication ignoring a batch's subject, a merge's subjects, or a render's mark.

Checks

  • This head merges with main at e01a2337 without conflicts (git merge-tree).
  • CI on 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

  • The first case end to end. The tests run a write deferred before an assembled one, but none runs two assemblies at once with a render landing between this agent's turn-start flush and its own render of the subject.
  • An assembly that throws with a render still running, end to end. The tests run a delivery made after the window closed, and the window closing when an assembly throws, but not an assembly that throws while one of its renders is in flight.

Out of scope

  • Writes that land ahead of a held write outside the window, as writeDrainedInOrder documents: a fresh tool_result, whatever its agent's state, since its tool_use must 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.
  • What is deferred after the assembly ends, while the turn gathers context and runs its hooks before the compile. It still lands at the turn's first tool boundary or its end, since writing it after the checkpoint would make it part of what undoing the turn removes.
  • A wake a message queued for itself. A message deferred during the assembly that queued its own wake is read in that request, and its wake still starts a turn after, as when the same message is heard at a tool boundary on main. Only the coalescer's wake to read a render is retired here.
  • A conversation fork's channel deliveries, which are written straight to its store with no deferral, as on 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.
  • A non-primary resident's render of a feature-set subject, which lands in the primary's window and is weighed as any write there, since it isn't a write into the assembling agent's own window.
  • A render that settles after its assembly threw and after its turn ended. It's stored directly, and the coalescer counts it as read by the assembly's request, with no wake, as on main.
  • A tool boundary's drain stores what is queued for its agent whatever the assembly's window or a quiesce, as on main.
  • A turn the scheduler has already started. A turn parked behind an auxiliary call goes on when the call settles, even if the render its wake was for was retracted meanwhile. The drop is judged where the scheduler starts a turn, and a parked turn finds nothing new, as any turn for that wake does on main.
  • A render a redo brings back. An undo switches to a branch made at the undone turn's checkpoint, so a render that landed after it, at a tool boundary or the turn's end, isn't on that branch, and its wake starts no turn there. A redo brings the render back, unread and with no wake. That's the same end state as on main, where the wake would have started a turn on the undo branch instead.

Merge notes


  • Changelog fragment added: 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-seru account.

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

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[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

  1. P1 Ordinary wakes can disappear ▶
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.

Summary

Moves deferred writes into the assembly’s pre-checkpoint window and adds render-specific wake checks.

  • An agent’s assembled writes now follow the writes that arrived before them.
  • Render wakes stop starting turns once their render is read or removed.
  • Replies to [Continue] turns keep the prompt stamp readers look up.

Acknowledged scope:

  • nissa-seru leaves late renders after a failed turn ends, and wake restoration after undo/redo, unchanged from main.
  • nissa-seru intentionally allows a quiesced assembly to store its own input when no earlier write is queued for that agent.
  • nissa-seru marks end-to-end tests for overlapping assemblies and a failed assembly with an in-flight render as unverified.

Reviews (1) · Last reviewed commit: "fix(deferral): a write the coalescing as..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@nissa-seru
nissa-seru merged commit eac1b62 into anima-research:main Oct 19, 2026
6 checks passed
@nissa-seru
nissa-seru deleted the fix/assembly-writes-in-arrival-order branch October 19, 2026 14:31
tel-0s added a commit that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 19, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 23, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant