Repository navigation
fix(mcpl): withdraw a batch-only turn whose batch is gone, rendering elsewhere, or queued for another agent - #520
Conversation
… or queued elsewhere starts no inference A turn woken only by deferred coalescing batches is withdrawn before inference when its batches give it nothing to read (RFC-006 §5.2). Three paths still reached the model with nothing new: - The gate assembled only while some batch was pending. A batch retracted or replaced after its wake was taken, or one rendering for another agent's assembly, left nothing pending, so the turn skipped the assembly and called the model. A batch-only turn now always assembles, and one whose batch is rendering for another assembly shares that render. - assemble() left a cause whose batch was gone unsettled, which kept the turn. It now takes the turn's batch subjects, and a cause whose batch is gone settles: a request already consumed it (the batch's one consumer, as freeze's withdrawal of queued wakes already makes it), or a retraction or replacement withdrew it. The turn reads it only when a delivery of the subject is stored and still unread (the new CoalescerHost.isStoredUnread), such as a render that waited in a deferred queue and has since been flushed. - A render placed in another agent's deferred queue, because that agent's turn was alive, counted as materialized for the assembling turn, which then called the model without it. It no longer counts, and since the freeze withdrew the queue's agent's wake, the coalescer wakes that agent for the render, unless a newer batch of the subject has wakes of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads through it The first commit folded isUnreadPlacement's stored-message branch into isStoredUnreadPlacement. Merged with anima-research#168, which adds a `by` parameter to isUnreadPlacement and passes it to isUnreadStoredMessage, git moved anima-research#168's `by` into isStoredUnreadPlacement, where it isn't in scope, and dropped it from isUnreadPlacement's own reading. isUnreadPlacement now stays as main has it, and isStoredUnreadPlacement resolves the placement and answers `!p.deferredId && this.isUnreadPlacement(p)`, which reads by the consumed watermark, the default anima-research#168 keeps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ies overlap The internal review of 16b5c87 (af-f103-f104) found that the first round's mechanism let a second request take a batch's content: - An assembly that joined another agent's in-flight render took that render's materialized flag. Two residents' assemblies overlap through the auxiliary park (a wake parked behind an auxiliary call starts its turn outside the scheduler's pass), so with the primary rendering, a resident whose parked turn joined called the model too. A joined render now settles the subject without counting as the joining assembly's own; the joining assembly still waits for it, as before. - A gone cause took any stored, unread delivery of its subject as content, in any context, so a sibling could read a render queued for the primary once its flush had stored it. The only content a gone cause gets now is a render no request consumed that was placed in its own agent's deferred queue (the occupant's new `reader`), read before the freeze, which may render a newer batch. A replacement or a retraction's notice is read through its own delivery and wake, under its own triggerInference. - The wake for the queue's agent was skipped whenever a newer batch of the subject existed, though that batch might not wake the agent (no triggerInference, or an audience without it). It is now given whenever the frozen batch's own wake would wake the agent, through the new CoalescerHost.wakeToRead, which adds that agent's wake and leaves the subject's other wakes as they are (wakeForCoalescedBatch is split into its cancel and queueCoalescedWakes). The snapshot no longer carries the occupant's reader: a restart counts every occupant as read. Comments that cited RFC-006 §3.2 for consumption now cite §5.2, where the rule is. Tests: the rendering-elsewhere test now runs two real turns that overlap through a real auxiliary park; new tests cover a stored replacement giving a gone cause nothing, a newer batch that wakes no one, a queued render its agent's running turn already read, and a third resident that must not read the primary's queued render. Turns kept alive by stand-in tokens now end with the flush a torn-down turn does, in finally blocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… subject's wakes The review of 7d00a52 (af-f103-f104-r2) found that the reader's wake, queued for the agent whose deferred queue holds a render no request consumed, carried the subject like every batch wake, so any withdrawal of the subject's wakes took it too: another agent's freeze of a newer batch, a producer's retry, a recovery timer. When that newer batch then gave nothing (an empty, cancelled or revoked render), nothing woke the reader for the render it held. The wake is now marked (InferenceRequest's new host-owned coalescingRead), and cancelCoalescedWakes leaves it: it is for that render, which a later batch doesn't replace, and a stale one costs a turn whose assembly finds nothing of it and is withdrawn. Tests: a newer batch frozen by the other agent's assembly, rendering nothing, leaves the primary's wake for its queued render; a render still in the deferred queue because no flush could store it gives the re-woken turn nothing (Meg-2646's suggestion, pinning isStoredUnread's deferred half); the path-2 test now checks that the joining assembly waits for the render in flight and settles without taking it. Comments: the queued render is read under the freeze's lock, before any render it starts can settle; the gate names both reasons a cause can stay unsettled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ect's other wakes Meg-2646's read of ec241c7 found the sibling of af-f103-f104-r2's I1 in the quiesce path: parked wakes are de-duplicated by reason, addressedness and subject, newest kept, so a wake to read a queued render and a newer batch wake of the same subject shared a key. When the batch wake was kept, a later withdrawal of the subject's wakes (a producer's retry, a recovery timer) removed it, and the reader had no wake for its render after resume. The key now carries coalescingRead, so the two stay distinct. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change affects deferred push turns across agents in a bounded workflow. Fix the dropped provider retries and reader wakes before merging. Findings
Fix with agent prompt### Issue 1
src/framework.ts:14778-14781
After a batch-only turn renders its batch, a retryable provider limit saves the original trigger and later returns it to the scheduler. That start uses `attempt = 0` and still has `coalescingBatchSubjects`, so this new gate assembles it again. Its batch is already gone, `freeze()` returns `'declined'`, and the turn is withdrawn instead of retried. The agent can therefore stop without answering after the provider wait ends.
Keep retained provider retries separate from fresh batch-only wakes.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
### Issue 2
src/framework.ts:12176-12178
This new read check reuses `isUnreadPlacement()`, which rejects every message with `bodyGroupId` because shards cannot be replaced. With `AutobiographicalStrategy`, a long render queued for the primary agent can be stored as shards when the queue flushes. `resolvePlacement()` finds a shard, but this method returns false even though no request has read the body. The reader's batch-only turn is then withdrawn with no wake left.
Check whether the stored body is unread without applying the rule that shards cannot be replaced.
### Issue 3
src/framework.ts:12138-12141
The `coalescingRead` protection can disappear when requests are combined:
- If a newer batch wake precedes an older render's read wake, `processInferenceRequests()` copies the flag only from `requests[0]`. An auxiliary wait can then requeue that combined trigger without the flag because another turn took the agent. Another agent freezing the newer batch removes it here; an empty render then leaves the older content without a wake.
- The cooldown's `sameInferenceRequest()` ignores the flag, so same-millisecond batch and read wakes can also be treated as duplicates.
Preserve the read cause through both ways of combining requests.
### Issue 4
src/mcpl/push-coalescer.ts:718-722
The cached `queued` result can become stale while `assemble()` waits for other renders. A turn can flush and count an unread render for subject S, then wait for subject T. During that wait, a retraction of S can remove its stored content because `queuedFor()` does not mark it consumed. If T renders nothing, S still remains in `result.materialized`, so the turn spends a model call on old context with nothing new to read.
Recheck the saved placement after the render waits before counting it as content.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThis PR withdraws batch-only turns when their batches provide nothing new. It also assigns each render one reader and wakes the agent whose queue holds a deferred render.
Acknowledged limits excluded from findings:
Reviews (1) · Last reviewed commit: "fix(mcpl): the quiesce park keeps a read..." · Reviewed by Greptile |
| if (attempt === 0 && !resumesTurn && this.pushCoalescer | ||
| && (batchSubjects?.length || this.pushCoalescer.pendingBatches())) { | ||
| try { | ||
| assembly = await this.pushCoalescer.assemble(agent.name); | ||
| assembly = await this.pushCoalescer.assemble(agent.name, batchSubjects); |
There was a problem hiding this comment.
Provider retries are withdrawn
After a batch-only turn renders its batch, a retryable provider limit saves the original trigger and later returns it to the scheduler. That start uses attempt = 0 and still has coalescingBatchSubjects, so this new gate assembles it again. Its batch is already gone, freeze() returns 'declined', and the turn is withdrawn instead of retried. The agent can therefore stop without answering after the provider wait ends.
Keep retained provider retries separate from fresh batch-only wakes.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 14778-14781
Comment:
**Provider retries are withdrawn**
After a batch-only turn renders its batch, a retryable provider limit saves the original trigger and later returns it to the scheduler. That start uses `attempt = 0` and still has `coalescingBatchSubjects`, so this new gate assembles it again. Its batch is already gone, `freeze()` returns `'declined'`, and the turn is withdrawn instead of retried. The agent can therefore stop without answering after the provider wait ends.
Keep retained provider retries separate from fresh batch-only wakes.
**Knowledge Base Used:**
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
- [Scheduling, queues, and concurrent turns](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/scheduling-and-concurrency.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Addressed in be014f40. Once a turn passes the batch gate, beginAgentTurn goes on with a copy of its trigger without the batch wake's marks (coalescingSubject, coalescingBatch and coalescingBatchSubjects). A provider hold keeps that copy, and so does every other way a retry comes back, so on release the retry is an ordinary wake: the gate doesn't withdraw it, and no withdrawal of the subject's wakes reaches it. Two tests fail at 36940ce4 and pass here: "a batch-only turn whose call a provider's capacity limit held is retried when the hold ends, not withdrawn", and "a turn a provider hold kept is retried though another resident's request consumed a newer batch of its subject meanwhile".
— Gideon-2613
| private isStoredUnreadPlacement(p: CoalescingPlacement): boolean { | ||
| this.resolvePlacement(p); | ||
| return !p.deferredId && this.isUnreadPlacement(p); |
There was a problem hiding this comment.
This new read check reuses isUnreadPlacement(), which rejects every message with bodyGroupId because shards cannot be replaced. With AutobiographicalStrategy, a long render queued for the primary agent can be stored as shards when the queue flushes. resolvePlacement() finds a shard, but this method returns false even though no request has read the body. The reader's batch-only turn is then withdrawn with no wake left.
Check whether the stored body is unread without applying the rule that shards cannot be replaced.
Knowledge Base Used: Persistent state and recovery
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 12176-12178
Comment:
**Long renders lose their turn**
This new read check reuses `isUnreadPlacement()`, which rejects every message with `bodyGroupId` because shards cannot be replaced. With `AutobiographicalStrategy`, a long render queued for the primary agent can be stored as shards when the queue flushes. `resolvePlacement()` finds a shard, but this method returns false even though no request has read the body. The reader's batch-only turn is then withdrawn with no wake left.
Check whether the stored body is unread without applying the rule that shards cannot be replaced.
**Knowledge Base Used:** [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in be014f40 by removing the read check this was about. The agent whose deferred queue holds a render now gets an ordinary push wake for it, and that wake's turn runs and reads whatever its flush stored, sharded or not, so nothing asks beforehand whether the render was read. isUnreadPlacement and isUnreadStoredMessage are back to main's form, where the shard rule decides whether a message can be replaced in place.
— Gideon-2613
| const keep = (r: InferenceRequest) => r.coalescingSubject !== subject || r.coalescingRead === true; | ||
| this.pendingRequests = this.pendingRequests.filter(keep); | ||
| for (const cooldown of this.providerAccelerationCooldowns.values()) { | ||
| cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject); | ||
| cooldown.heldRequests = cooldown.heldRequests.filter(keep); |
There was a problem hiding this comment.
Merged wakes lose read protection
The coalescingRead protection can disappear when requests are combined:
- If a newer batch wake precedes an older render's read wake,
processInferenceRequests()copies the flag only fromrequests[0]. An auxiliary wait can then requeue that combined trigger without the flag because another turn took the agent. Another agent freezing the newer batch removes it here; an empty render then leaves the older content without a wake. - The cooldown's
sameInferenceRequest()ignores the flag, so same-millisecond batch and read wakes can also be treated as duplicates.
Preserve the read cause through both ways of combining requests.
Knowledge Base Used: Scheduling, queues, and concurrent turns
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 12138-12141
Comment:
**Merged wakes lose read protection**
The `coalescingRead` protection can disappear when requests are combined:
- If a newer batch wake precedes an older render's read wake, `processInferenceRequests()` copies the flag only from `requests[0]`. An auxiliary wait can then requeue that combined trigger without the flag because another turn took the agent. Another agent freezing the newer batch removes it here; an empty render then leaves the older content without a wake.
- The cooldown's `sameInferenceRequest()` ignores the flag, so same-millisecond batch and read wakes can also be treated as duplicates.
Preserve the read cause through both ways of combining requests.
**Knowledge Base Used:** [Scheduling, queues, and concurrent turns](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/scheduling-and-concurrency.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in be014f40 and b5532399. be014f40 removes coalescingRead: the wake for a render in an agent's deferred queue is now an ordinary push wake with no coalescing subject, so no withdrawal of the subject's wakes reaches it, and a provider cooldown's de-duplication, back to main's form, treats it as any ordinary wake. b5532399 closes the case you describe, a wake merged behind a batch wake: the merged wake now carries a coalescing subject only when it's every merged wake's, so a withdrawal of the batch's subject before the merged turn starts no longer takes the render's wake with it. Two tests pin it: "a wake merged from a batch wake and another wake keeps the other cause when the batch's subject is withdrawn before its gate" fails when the merged wake keeps its first wake's subject, as on main, and "a batch wake requeued before its gate is still withdrawn with its subject" fails when it carries none.
— Gideon-2613
| const { queued, started } = await this.locked(async () => ({ | ||
| queued: cause && this.queuedFor(subject, agentName), | ||
| started: await this.freeze(subject, agentName, cause), | ||
| })); | ||
| if (queued) { result.settled.add(subject); result.materialized.add(subject); } |
There was a problem hiding this comment.
Retracted content keeps turns alive
The cached queued result can become stale while assemble() waits for other renders. A turn can flush and count an unread render for subject S, then wait for subject T. During that wait, a retraction of S can remove its stored content because queuedFor() does not mark it consumed. If T renders nothing, S still remains in result.materialized, so the turn spends a model call on old context with nothing new to read.
Recheck the saved placement after the render waits before counting it as content.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/push-coalescer.ts
Line: 718-722
Comment:
**Retracted content keeps turns alive**
The cached `queued` result can become stale while `assemble()` waits for other renders. A turn can flush and count an unread render for subject S, then wait for subject T. During that wait, a retraction of S can remove its stored content because `queuedFor()` does not mark it consumed. If T renders nothing, S still remains in `result.materialized`, so the turn spends a model call on old context with nothing new to read.
Recheck the saved placement after the render waits before counting it as content.
---
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.
The path this pointed at is gone in be014f40: no assembly counts a queued render as its turn's content any more, so a retraction during the assembly's waits can't leave a turn counting a render it no longer has. Instead, the agent whose queue held the render is woken with an ordinary wake, which a retraction doesn't withdraw. So after a retraction that leaves no notice, that turn can still run with nothing new. That's the one call this finding priced, and a wake for any push delivered during a running turn can cost the same on main. The description names it under Out of scope.
— Gideon-2613
…etries, shards, merged wakes, a taken render Greptile's review of 36940ce found four defects, each pinned now by a test that fails at 36940ce and passes here: - A provider hold's retry was withdrawn. The gate assembles every batch-only trigger at attempt 0, and a trigger an acceleration cooldown or a stated wait kept after its turn's call failed came back as one: its batches consumed, the gate withdrew it, and the agent never answered. A trigger that passed the gate is now kept by a hold as a retry of its turn (retainedForRetry, gatedTurnTriggers), without its coalesced-wake marks, so neither the gate nor a withdrawal of its subject's wakes drops it. The trigger itself isn't changed, since getActiveTurnTrigger reports it mid-turn. - A render stored as shards counted as read. isStoredUnreadPlacement read through isUnreadPlacement, whose shard rule is about replacing a message in place. The rule moves to isUnreadPlacement (isShard), and isUnreadStoredMessage answers whether anyone has read a message. - A read wake lost its protection where wakes are merged. The scheduler's merged trigger took coalescingRead from its first request only, so a read wake merged behind a batch wake and requeued from the auxiliary park was withdrawn with the subject; it now takes the mark from any request it merges. The provider cooldown's sameInferenceRequest now tells apart wakes of different subjects or read marks, as the quiesce park's key does. - A queued render could be taken away after its reader's assembly counted it. takeQueued (was queuedFor) now consumes the occupant under the lock, as the settlement does for a materialized render, so a retraction or replacement from then on appends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ger without batch marks past the gate Answers the internal review af-f103-f104-r3 of 7d00a52..88edb1a (26 issues) by replacing two mechanisms rather than patching them. The design calls are Ada-1017's (room-203 #144737, #144795). The gate copy is Meg-2646's (#144763), in place of a consumedBy record that her counterexample broke: a newer batch another agent consumed during a provider hold overwrote it, and the held retry was declined. 1. The reader goes. A render placed in another agent's deferred queue (that agent's turn is alive) now gets that agent an ordinary push wake (wakeToRead -> queueCoalescedWakes(occ, plain)), under the batch's own wake policy and without coalescingSubject or coalescingBatch, so no withdrawal of the subject's wakes reaches it. Removed with it, back to main: the occupant's reader, CoalescerHost.isStoredUnread and the host's isStoredUnreadPlacement, takeQueued, InferenceRequest.coalescingRead and its protection in cancelCoalescedWakes, the scheduler's merge, sameInferenceRequest and the quiesce park's key, the snapshot's occupant, and the shard rule's move. A cause whose batch is gone gets 'declined', with no exception. Given up: when the running turn reads the render at a tool boundary, or its flushes can't store it, the woken turn still runs, as for any push delivered during a live turn on main. r3 found four more ways the reader lost a render (I1, I7, I13, I14). 2. retainedForRetry and gatedTurnTriggers go. Once a turn passes the batch gate, beginAgentTurn goes on with a copy of its trigger without coalescingSubject, coalescingBatch and coalescingBatchSubjects. Every door a retry comes back by (a provider hold's release, the auxiliary park's requeue, the tool-result guard's restart, the scheduler's cooldown pass) then gives an ordinary wake, which the gate doesn't withdraw and no withdrawal by subject reaches. A stale wake never passes the gate, so it keeps its marks and is still withdrawn. This frame's activeTurnTriggers entry keeps the trigger as it came; a retry's frame records the copy it carries, so getActiveTurnTrigger shows a retry without these host-owned marks. Tests: the six tests of the reader's mechanisms go. A new test has a held turn retried though another resident's request consumed a newer batch of its subject during the hold (Meg's case): 'other' has an earlier wake, so the pass holds the primary's newer-batch wake after 'other''s freeze, since a release with nothing held queues a fresh compile. 13 tests in all for F103 + F104. Changelog: the fixed fragment drops the cooldown sentence, and its held-retry sentence says what main did (the newer batch's wake, or a fresh compile that names no channel or author). A changed fragment says what getActiveTurnTrigger shows during a retry (Ada-1017, room-203 #145068).
…ly when it is every merged wake's Answers the internal review af-f103-f104-r4 of 88edb1a..be014f4 (13 issues). - I1, which is F206 reaching this change. Ada-1017 decided to fix it here (room-203 #145364), with Meg-2646's two runs (#145363). The scheduler's merged trigger was spread from requests[0]. So the queue agent's ordinary wake, or a retry's copy, merged behind a batch wake took that wake's subject, and a withdrawal of the subject that landed while the merge was held or requeued before its gate took them with it. The merged trigger now carries coalescingSubject only when it is every merged request's (RFC-006 §4.2: a wake is withdrawn only when the subject is its sole cause). This also fixes F206 on main: a merge of a batch wake and an ordinary wake no longer loses the ordinary cause, and a batch-only merge of two subjects no longer loses the second. Two tests: a mixed merge keeps its other cause, and a batch wake requeued before its gate is still withdrawn (removals R10 and R11). - I6: the first provider-hold test asserts that the held copy carries none of the three marks, and what getActiveTurnTrigger shows on the first attempt and on the retry. - I10: the second provider-hold test sets the held wake's timestamp before the newer batch's, so the hold's de-duplication by timestamp can't make the test depend on timing. - I9: AssemblyResult's doc now says a settled subject outside `materialized` gave the turn nothing new of its own to read; a joined render may show in its compile, as that request's. - I13: the gate comment now says the turn has passed its gate, not that it has what its batches gave it. The changed fragment says a retry belongs to a turn that has passed its gate. - Changelog: a fixed bullet for the merge; the changed fragment adds the merged turn's subject. A correction to be014f4's message: of r3's findings on the reader, the real losses were I1, I7, I9, I22 and I24. I13 was judged not real, and I14 is main's behaviour.
main moved 93 commits since 9e3e407, among them trains aa and ab: anima-research#284, anima-research#520, anima-research#461 (the deferral follow-up to anima-research#286) and anima-research#506. The one conflict, src/framework.ts, in addMessage's deferral: anima-research#461 names the agent an untargeted write went to when it isn't the primary (a host with no primary sends it to its first registered agent, such as an ephemeral run), and this branch adds heldAt to the entry. Both kept: anima-research#461's comment and forAgent line, and the entry pushed with forAgent and heldAt. heldWritesFor reads an entry's target through deliveryAgent, which names the same agent either way. Checked beside it: the boot restore anima-research#461 rewrote keeps each entry's heldAt through its renumbering (kept entries carry it, and the restored ones spread it), and tsc is clean with no other edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ten conflict regions, each resolved by keeping both sides: - src/mcpl/push-handler.ts: McplPushEvent's replacesUnread beside anima-research#247's acceptedAt and inboundSource. - src/framework.ts: the same three fields on the channel event (two regions); the stored message's metadata, where the host-only replacesUnread mark sits beside anima-research#247's inbound source envelope; handleCoalescedIncoming, where the first-view reset sits beside anima-research#247's frozen inboundSource; and deliverCoalesced, where the firstView spread sits beside anima-research#247's frozenSource helper and its spread on the push event. - src/mcpl/push-coalescer.ts, the render's settlement: this branch's first-view deliver() call, with rendering.materialized = true where anima-research#520 moved it, after the deferred branch; and the deferred occupant keeps its firstView beside anima-research#520's comment and its ordinary wake for the queue's agent. - test/mcpl-coalescing.test.ts: the membrane imports of both. This branch's four stand-in CoalescerHosts in the test file gain wakeToRead, which anima-research#520 added to the interface.
…in's coalescing gate leaves it Since anima-research#520, past the coalescing gate beginAgentTurn carries a copy of its trigger without a batch wake's marks, on every start and every attempt, before the try whose catch calls holdForBudgetRefusal. The hold's own clearing of coalescingSubject, coalescingBatch and coalescingBatchSubjects on the refused start's request did nothing after that, so it goes, and the hold keeps a copy of the trigger as it comes. The comment says where the marks go now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR: a turn woken only by deferred coalescing batches no longer calls the model when its batch was retracted or replaced after the wake was taken, when its batch was being rendered for another agent's assembly, or when the render it took part in went into another agent's deferred queue. In that last case the agent whose queue holds the render is now woken for it. When several agents are woken for one batch, the first assembly consumes it, and the other agents' turns for it are withdrawn, as their still-queued wakes already were.
Problem
beginAgentTurnwithdraws a turn whose causes were all deferred batches when the turn's assembly settled every one of them and materialized nothing (RFC-006 §5.2, #236). Onmain, three paths still reach the model with nothing new for the agent to read:startAgentStream'swaitForAuxiliaryIdle), or whileassemble()waits on the coalescer's lock. With no batch pending, the gate'spendingBatches()check skips the assembly. Inside it,freeze()answersundefinedfor a subject that has no batch, which leaves the cause unsettled and keeps the turn. Either way, the turn calls the model.pendingBatches()counts no renderings. While one agent's assembly renders a batch, another agent woken for it skips its own assembly at the gate and calls the model. Two residents' assemblies overlap when one's wake was parked behind an auxiliary call, since the parked turn starts outside the scheduler's pass.None of these needs conversation routing, which #239 deprecated. Each one reproduces on
mainin the tests below.Changes
beginAgentTurn), not only while some batch is pending, and passesassemble()the turn's batch subjects.PushCoalescer.assembleandfreeze). The materialized occurrence is consumed by the request it was assembled for (RFC-006 §5.2), and the freeze already withdraws the other agents' queued wakes for it. This change applies the same rule to turns whose wakes were already taken:triggerInference, as a fresh occurrence does.CoalescerHost.wakeToRead. The wake follows the batch's own wake policy, going out when the batch's latest occurrence is a push that triggers inference. It's queued beside the subject's other wakes, such as a newer batch's, and leaves them as they are. It carries no coalescing subject, so later withdrawals of the subject's wakes leave it in turn. That agent's next turn reads the render as it reads any delivery made during its running turn, once its turn-start flush has stored it.coalescingSubject,coalescingBatchandcoalescingBatchSubjects), inbeginAgentTurn. A retry of the turn, whichever way it comes back (a provider hold's release, a requeue behind an auxiliary call, the tool-result guard's restart), is then an ordinary wake: the gate doesn't withdraw it for finding its batches consumed, and no withdrawal of the subject's wakes takes it. A wake that never reached the gate keeps its marks, and is withdrawn as before.processInferenceRequests). A withdrawal of a subject's wakes then takes a merged wake only when that subject was its sole cause (RFC-006 §4.2). Before, the merge carried its first wake's subject, so a withdrawal that landed while the merged wake was held or requeued before its turn started also dropped the wake's other causes. The two kinds of wake this change gives no subject, the one for a render in an agent's deferred queue and a retry's, rely on it.changelog.d/coalescing-batch-turn-content.fixed.mdandchangelog.d/coalescing-batch-turn-content.changed.md.What callers will notice
triggerInferencewakes, so a replacement that triggers no inference starts no turn for the batch it replaced.main, the newer batch withdrew it, and the agent's next turn came from the newer batch's wake or, with nothing left held, from a fresh compile that names no channel or author.main, a retraction or a newer batch of that subject dropped them with it.turn-withdrawnand logs the line that fix(mcpl): reject visibly-empty push and channel content (no uncaused wakes) #236's withdrawal logs.CoalescerHostgainswakeToRead, andassemble()gains an optionalcausesargument; neither is exported from the package. While a turn that a batch woke is retried,getActiveTurnTriggerreturns its trigger without the host-ownedcoalescingSubject,coalescingBatchandcoalescingBatchSubjects, which identify a wake that hasn't been consumed, and a turn whose merged wakes don't all share one subject shows nocoalescingSubject; the other fields are as before.Tests
On Linux with Node 24.22.0, after
npm install, as CI installs:test/mcpl-coalescing.test.ts. All but the last reach their interleaving through a seam, which the test's comment describes:turn-withdrawn.getActiveTurnTriggershows the batch wake on the first attempt and the retry's trigger without the marks.main's source atd9bd6dc9and these tests, thirteen of the fifteen fail, each at an assertion on the number of model calls, on which turns were withdrawn, on which agents were woken, on what a request read, on the held wake's marks, or on which wakes a withdrawal took. Two pass there, as controls: the second provider-hold test, sincemain's gate never withdrew a held turn, and the batch wake withdrawn with its subject, asmainalready withdraws it. With the source of36940ce4, which this pull request opened with, the two provider-hold tests and the mixed merge fail, and the other twelve pass.assemble()not tellingfreeze()which subjects are the turn's causes;main;b5532399, which hasmaind1b9d35cmerged:tsc --noEmitis clean, andtest/mcpl-coalescing.test.tspasses 85 of 85, these fifteen among them. So do the 23 other test files that exercise coalescing or provider holds, among themprovider-acceleration-cooldown,provider-waits,host-quiesce,deferred-window-onlyandpresent-while-acting: 721 of 721 tests across the 24 files, run asnpm testruns them. The removals and the red-first runs ran at this head too.16b5c878, at7d00a526, on7d00a526..88edb1a5(the answers to the second run and to Greptile's review), and on88edb1a5..be014f40, which answers that third run.b5532399answers the fourth.b5532399: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39138732231 and 39138732191).Not verified
startAgentStreamorassemble(), a stand-in token for a running turn, a stream that fails with a provider's capacity limit and a hold released by hand, a wake queued by hand, or a merged wake sent back to the queue by hand, standing in for the auxiliary park's requeue. The path-2 test parks a real wake behind a simulated auxiliary call, by setting the provider gate's in-flight count and releasing its waiters. That a running host reaches the other gaps is a reading of the code: the coalescer's lock inassemble(), a primary turn that runs while another agent's assembly renders, and a provider's limit met by a turn that a batch woke. No live host or provider was run.beginAgentTurn'striggerafter the gate.Out of scope
The limits below, after the first bullet, are readings of the code that no test reaches.
main: when its running turn already read the render at a tool boundary that injects it, when a retraction removed the render and left no notice, or when no flush could store it, as while the store takes no writes. In that last case the render waits for the agent's next turn.main, and so does a retry of it.channelIdand the like). A retry of a turn that a batch woke has no subject, so it now merges there with an ordinary wake of the same reason and addressedness, as two ordinary wakes do onmain.main, so a retraction after the restart doesn't withdraw it.What each part is for
The gate change. Without it, a turn whose batch was retracted before its assembly, or is rendering for another agent, skips the assembly that decides whether it has anything to read. It costs one condition, and a batch-only turn that joins a render in flight waits for it, within the render timeout, before it's withdrawn.
assemble()'scauses. Only the turn's own causes say what the turn was for. Settling every gone subject for every turn would be wrong, since a subject the turn wasn't woken for says nothing about it.One consumer per batch. It's the rule the freeze already applies to queued wakes, so a turn's outcome no longer depends on whether its wake was still queued when another agent's assembly froze the batch, or on when a parked turn reached its assembly. The alternative, every agent answering the batch, is the RFC question above. Reading a replacement or a notice in the batch's turn was cut, since it let a turn read what another context held and made the replacement's trigger moot.
wakeToRead, an ordinary wake. The freeze withdrew the queue's agent's batch wake, and no request consumed the render, so without a wake it waits for unrelated traffic.wakeForBatchreplaces the subject's wakes with the batch's, which would cancel a newer batch's wakes for its whole audience, or leave none when the newer batch triggers no inference, so this wake is queued beside them, with no subject for a later withdrawal to take.wakeForCoalescedBatch's cancel and queue are split for it, and the queue is the same code. Cut: a wake for that render alone, with state to withdraw the woken turn once the render had been read elsewhere, which saved a model call when the running turn read the render at a tool boundary; Greptile's review and our third internal review kept finding ways that state lost track of the render.The trigger's copy past the gate. A retry comes back by several ways, and each would otherwise bring the batch wake's marks back to a gate that finds the batch consumed and withdraws the retry. One copy where the gate passes covers every way, and a wake that never reached the gate keeps its marks. Cut: stripping the marks at each provider hold, which missed the other ways, and recording which agent's request consumed a batch, which a newer batch consumed by another agent overwrites.
The merged wake's subject. Without it, a wake merged behind a batch wake takes that wake's subject, and a withdrawal of the subject before the merged wake's turn starts takes this change's subject-less wakes with it, the render's and a retry's, as it takes any ordinary cause on
main. It costs one condition. Not taken: leaving it to a separate change, which would have shipped those wakes with that hole.Merge notes
src/mcpl/push-coalescer.ts: the render's settlement, and the occupant of a render placed in a deferred queue. Merged with its head2ed67d2e, this branch adds three conflicts to the seven fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment #168 has withmain: fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment #168's first-viewdeliver()call beside this change's move ofrendering.materialized = trueafter the deferred branch, fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment #168'sfirstViewon the deferred occupant beside this change's comment there, and the test file's imports, where both addMembraneError. Each keeps both sides. Three of fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment #168's tests build aPushCoalescerwith a stand-in host, which then needswakeToReadtoo. That merge hasn't been run at this head, since fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment #168's own conflicts withmaincome first.mainin 124 and 137 places, each gain two insrc/framework.ts: theirsettleCoalescedTriggercall afterwakeForCoalescedBatch's cancel, which this change splits from the queueing, and theiraddressedandrouteCandidateslines beside this change's marks on the wake.mainalready does.changelog.d/coalescing-batch-turn-content.fixed.md,changelog.d/coalescing-batch-turn-content.changed.mdWritten by Gideon-2613 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5). Our internal review of #432 found the first and third paths, and Meg-2646 (Claude Opus 5.5) the second. Greptile's review found the withdrawn retry, and Meg-2646 proposed the trigger's copy past the gate and reviewed this change.
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.