Skip to content

fix: a turn refused for budget waits idle until compression makes room - #361

Merged
nissa-seru merged 120 commits into
anima-research:mainfrom
nissa-seru:fix/304-budget-refusal-holds-turn
Nov 9, 2026
Merged

nissa-seru merged 120 commits into
anima-research:mainfrom
nissa-seru:fix/304-budget-refusal-holds-turn

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Why. On main, when an agent's context is over its budget and compression is still catching up, its turn is refused and fails within seconds. Once there's room, nothing runs it again, and the agent stays silent until something else wakes it.

With this change, a turn refused for budget while compression is pending waits, idle, and runs once compression has made room. The agent then gets a note saying its context didn't fit and how long the turn waited.

This addresses #304.

A turn whose compile is refused for budget while compression is pending

  • base (main): the default error policy retries it into the same refusal. The turn ends as exhausted within seconds. Once compression has made room, nothing runs it. The agent waits for its next wake.
  • head: the turn waits.
    • The wait ends on one of these:

      • compression has made progress, or has nothing left pending;
      • a surgery or an undo;
      • a change to the agent's budget or live context settings;
      • a quiesce's end.

      While a surgery holds the store or the host is quiesced, the wait ends once that's over.

    • The turn is then queued again, and its own compile is the check. If compression is still pending and the compile is refused again, the turn waits again.

    • A compression call that fails ends no wait. Where the strategy reports its progress, stalled compression ends none either. context-manager 0.25.0's and 0.25.1's autobiographical and knowledge strategies report it.

    • Messages that arrive meanwhile are answered by that turn. A retry that a provider hold kept answers them the same way on main. A budget restart's continuation and a tool-result guard's retry are different: messages that arrive during their wait run as a turn of their own after that turn.

    • When compression has nothing left to do and the context still doesn't fit, the turn takes the error policy's path, as before.

Surgeries, undo, redo, puppet calls and quiesce while a turn waits

  • base (main): the refused turn has ended, so nothing is refused on its account. gatedRequests doesn't count it. An undo can take out the message a queued wake was for. That wake still runs, and its start puts the message back in the window.
  • head: a waiting turn holds nothing.
    • Status: getHostModeStatus() reads drained: true. Its gatedRequests counts the waiting turn once, and each wake that joined it.
    • Admitted: a quiesce drains at once. A branch switch, rollback, suppression, undo or redo is admitted.
    • Puppet calls: while a first start waits, a puppet call is admitted. While a turn under way waits, a puppet call is refused. The refusal says abortInference() ends that turn. On main, a puppet call is also refused while a provider hold keeps a turn's retry.
    • Turns a surgery or an undo drops:
      • Some waiting turns have a wake that reads one message stored for their agent. If a surgery removes that message, or an undo takes it out of the agent's window, that turn is dropped. This holds while the turn waits, and after its wait ends until it starts.
      • If a surgery removes any rounds of a turn already under way, that turn is dropped too.
      • If an agent's turn under way is waiting, an undo of that agent ends the turn. The turn's end is traced after the undo's.
    • Turns that run on: some waiting turns have no rounds yet and a wake that reads no single message. Examples are a turn woken by console input, a heartbeat, several messages, or a module event that names a channel.
      • A surgery leaves such a turn to run on what it left. On main, a waiting wake runs on it too.
      • After an undo, such a turn's start presents again what the undo took out. On main, a waiting wake's start does too.
    • Redo: an undo can drop a waiting turn. A redo that brings back its message doesn't wake the agent. On main, redo() wakes nothing for what it restores either.
    • After the wait ends: a first start is a queued wake until it starts. abortInference() leaves it to run. The inference policy or a withdrawal decides it, as for any queued wake. A continuation becomes a queued continuation, and abortInference() ends it.

What the agent sees

  • base (main): at the exhaustion, an [inference-failed] marker in its context. Its reason is the refusal.
  • head: the turn runs late. Before it, a note is stored in the agent's context as the wait ends. For example: "[budget-hold] Your context didn't fit its budget (100,201 tokens against 48,568) when a turn came to start. That turn waited 7 min."
    • The figure runs from the refusal that began the wait to the wait's end. A turn refused again before its request is built waits on. Its note still counts from the refusal that began the wait.
    • The note is a row of the agent's history, stored before the turn's request is built. That request reads it where it was stored. So the agent's next request starts with the same messages. Later requests read it as history, and the strategy folds it like any message.
    • Undo: an undo of a budget restart's continuation or a tool-result guard's retry takes the note with the reply. An undo of any other turn leaves it.
    • A turn that doesn't run: its note stays for the agent's next turn. If that turn waits too, a fresh note replaces it.
    • Injections: some of the agent's own context injections were carried by no request. They leave the window while a turn waits, and a note can be among them. The released start stores them as it would without the wait.
    • Arrivals: some prose routing announces messages from other channels. Under it, a message that arrived while the turn waited is announced at the turn's start as having come "while your turn waited". That holds for a wait for compression, and for a provider hold after it. A budget restart's continuation and a re-inference on tool results keep their own wording.

Ops alerts, traces and health

  • base (main): the exhaustion raises context-refusal. A 15-minute cooldown for each agent and kind thins its webhook posts.
  • head: a waiting turn raises one context-refusal as it starts waiting. It says the turn waits for compression.
    • Refused again: a turn refused again before its request is built raises no second one. If a listener of inference:held ends the first hold before the alert, the next hold raises it.
    • The clear: once the turn's context-refusal is raised, the turn may be stopped while it waits, or what it stands on may be removed. Either one raises context-refusal-clear, and the waiting start doesn't run.
    • No clear: a wait can end, and its turn can then be dropped, declined or withdrawn before it starts. That raises no clear.
    • Cooldown: each wait's pair has a cooldown key of its own. So both reach the webhook within the cooldown.
    • Traces and health: inference:held and inference:hold_ended trace each time the turn waits. healthSnapshot() gives the wait as the agent's budgetHold.

A closing conversation fork whose closure wake waits

  • base (main): the fork can be disposed before its closure turn runs. A turn of its that was alive at its expiry disposes it when that turn ends. The fallback sweep disposes it while a provider's stated wait keeps the wake.
  • head: the fork is kept while its closure wake waits. It's disposed when the closure turn ends.

A host with a table or switch that must cover every TraceEvent['type'] or PresentationRetractReason (breaking)

  • base (main): it compiles with an entry for each member.
  • head: it needs an entry for each of inference:held and inference:hold_ended, and for the retract reason unsent-injection. Without them it no longer compiles.

Documented rule: HostModeStatus.gatedRequests

  • base (main): "Continuations of a turn (budget restart, tool results) and a held turn's retry, which a quiesce lets through, are never counted."
  • head: "A turn a budget hold holds counts once, continuation or not. The wakes merged into its start are part of it. Wakes that joined it while it waited count as other gated requests do. Other continuations (budget restart, tool results, the tool-result guard's retry) and a held turn's retry, which a quiesce lets through, are never counted."

Documented rule: nudgeAgent()'s agentStatus

  • base (main): "'idle' means no turn holds the agent, so the turn starts on the next scheduler pass unless a provider hold (a provider's stated wait, or a capacity cooldown) holds it until the hold ends, or a start of the agent's that waits for an auxiliary call to finish goes first; anything else means it runs after the current turn."
  • head: "'idle' means no turn holds the agent, so the turn starts on the next scheduler pass unless a provider hold (a provider's stated wait, or a capacity cooldown) holds it until the hold ends, or a budget hold keeps it until compression makes room, or a start of the agent's that waits for an auxiliary call to finish goes first; anything else means it runs after the current turn."

Documented rule: notifyOps()'s first step

  • base (main): "(1) durable failures.log record (unless the caller already wrote one),"
  • head: "(1) durable failures.log record (unless the caller already wrote one, or the alert keeps none, as a budget hold's open and clear don't),"

Documented rule: ops:alert's data

  • base (main): "Kind-specific structured payload (mirrors the failures.log record), bounded field by field as that record is."
  • head: "Kind-specific structured payload (mirrors the failures.log record, where the alert writes one: a budget hold's 'context-refusal' and its clear write none), bounded field by field as that record is."

Documented rule: docs/dendrite.md's notices naming which start it is

  • base (main): "… naming which start it is — while your turn waited on a provider hold, before this re-inference on your tool results, at your budget restart, while your turn was retried — …"
  • head: "… naming which start it is — while your turn waited on a provider hold, while your turn waited (after a wait for compression and any provider hold after it, where the start is none of those the notices after it name), before this re-inference on your tool results, at your budget restart, while your turn was retried — …"

Documented rule: the README's stops that stop nothing

  • base (main): "A caller's stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, while a turn finishes its last delivery, …"
  • head: "A caller's stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, except for a turn a budget hold keeps (below), while a turn finishes its last delivery, …"

@antra-tess, this changes the 6 documented rules quoted above.

  • Changelog fragments added: changelog.d/budget-refusal-holds-turn.fixed.md, changelog.d/budget-refusal-holds-turn.added.md and changelog.d/budget-refusal-holds-turn.breaking.md.
Credits

Octavia-2412 built it and wrote this description. Gladys-2413 proposed its design, and Ada-1017 settled it. Gladys-2413, Nell-1783 and Hilda-2580 read the change.

Details for agents

Head 79065172c2, two commits on 9a76d312c0. 9a76d312c0 is one commit on 12cceb78bc, which merges main 53e72cac42 into a3584588d0. a3584588d0 is two commits on e405aaa4dd, which merges main 21e6b12417 into twenty commits on d0f3c79b15: d7971b9d94, the change; 083a247895, which changes tests alone; 5db819cf60 and f9adf75a55, which change a doc line and the Fixed fragment; 144c834ea2, 335f736dbc, 361a40b77f, eb9d1314cc, e43f80f7a4, dd59713a7f and 066ab27e7f, which release a wait on evidence, keep a closing fork through the fallback sweep while its closure wake waits, take injections no request carried out of the window at a wait, and carried a wait past its release; 6e5bef6728, which ends the wait at its release, stores the note there, and carries the wait on its request as plain data; b137724f03, which changes a doc comment and the Added fragment alone; 73b6c43bdb, which removes an undo check no waiting turn reaches any more, and its test; f63ed41a48, which releases a wait only while no surgery holds the store, orders the release so its listeners find it whole, and sends a refusal the wait can't take to the error policy; 3579c44252, which makes every operation on a wait trace last and finish only a wait that is still the agent's; 715ee57f33, which changes the Fixed fragment alone; 3d0077aa6c, which refuses a puppet call while a budget hold keeps a turn under way, gives back a held start's turn token and provider admission before its wait is announced, counts a waiting turn's merged wakes in gatedRequests member by member, leaving out any that passes a quiesce, and finds a closure wake merged behind another wake; fdeba738f0, which changes HostModeStatus.gatedRequests' doc alone, to say how a budget hold's turn and wakes count; and c6ae6072aa, which has a budget hold keep its turn's wait rather than copies of it and drops a field that never changed, with no change in behaviour. 5fa6d46aee keeps the rows a silent turn's retry stands on, and ends a waiting turn under way after an undo's own record. a3584588d0 changes one test alone. 9a76d312c0 appends to the request lists through main's appendAll, with no change in behaviour. 2e9918a574 and 79065172c2 count a waiting turn's requests in its health as gatedRequests does, and name the waiting turn in the README's idle case. Merge base with main 53e72cac42. Where main is cited at 16a16fffbc, the same lines stand at 53e72cac42, moved: :22237 to :22299, :27323–27326 to :27414–27417, and :30470 to :30590. Line numbers are src/framework.ts at the head unless named.

Problem

On main (16a16fffbc), a compile refused for budget (context-manager's OverBudgetError, or a context refusal when no summary covers a span the compile would drop) takes the error policy's path whether or not compression is pending: it's retried into the same refusal and the turn ends as exhausted. Once compression catches up, nothing queues the turn again, so the agent waits for its next wake (#304). Red on main's source with this change's tests as of 6e5bef6728: 59 of their 62 budget-hold tests fail, and the head removes one of the 62. The three that pass are "fails as before when compression has nothing pending", the ephemeral run's and the synchronous throw's, which each check what main already does (Tests, below).

Changes

  • The wait (holdForBudgetRefusal, :7447). A start whose compile is refused for budget while compression is pending (!isReady()) doesn't end its turn: its request leaves the queue and waits in the agent's budget hold. A refusal with nothing pending, an ephemeral run, a framework start() hasn't started or whose stop has begun (:7458), and an agent the framework no longer holds under its name fail as before, and so does a refusal whose agent already has a wait (:7459).

    • Forming it: the wait is made whole first, its record, which holds the wait and with it whether the wait's alert was raised (:7484), and its recheck, and the start's window closes (:7498). Only then come inference:held, the open alert if it wasn't raised, the event gate's end of the start (:7531), and the retraction of what no request carried (:7533, below), each followed by a check that the wait is still the agent's. A stop any of their listeners makes, abortInference() included, finds the wait and drops it as any stop does.
    • Idle while it waits: the refused start gives back its turn token and its provider admission as its window closes, before inference:held (:28571–28579), and the request isn't a pending continuation, so hasTurn() is false, getHostModeStatus() reads drained, a quiesce drains at once, and reserveStoreForSurgery, undo and redo aren't refused on its account. puppetToolCall is refused while a turn under way waits (heldContinuation, :31312), naming the wait and abortInference() as the way on, as it's refused while a provider hold keeps a retry. While a first start waits, a puppet call is admitted, and the released start queues behind its turn token (:22902). The held start ends its event-gate entry (:7531), and a released continuation takes it again (onInferenceResumed). The refused start's turn checkpoint is dropped (:7463), so an undo while a first start waits takes the last completed turn; a drop that throws is logged and sends the refusal the error policy's way, before the wait changes anything else.
    • A first start waits as its retry, without TURN_RETRY, and keeps the agent's open logical turn, so the released start continues its routing. Messages that arrive are presented as they're written, and their wakes join the hold, so the released start answers them all.
    • A turn already under way (a continuation, such as a budget restart's or a tool-result guard's retry, or a retry carrying TURN_RETRY) waits as it is, its carry included. Presentation still counts its logical turn alive (logicalTurnAlive, :24925), so arrivals stay out of its request while it waits. For a budget restart's continuation or a tool-result guard's retry, they then run as a turn of their own after it (:22889). A retry carrying TURN_RETRY is released into a start with the wakes that joined it, which presents and answers them, as main does for a provider hold's retry (main :22237 at 16a16fffbc).
  • The wait's end (releaseBudgetHold, :7644): the wait is removed, its held requests are queued again and the note is stored (below), and only then are message:added and inference:hold_ended released emitted, so a listener of either finds the release whole. It comes once something the refused compile depends on has changed (budgetHoldMoved, :7614):

    • compression's progress: the strategy's progress snapshot, taken as chunksCompressed and summaryCounts (:7622), has moved since a reading taken just before the refused compile (:28385), which counts a summary written without a call too. Where that reading read as none, the first later one that reads takes its place (:7618): at that check a landed call still counts, as it did while no snapshot could be read, and from then on the snapshots decide. Where the snapshot reads as none then or now, from a strategy with none, one that throws, one whose snapshot has neither field or one whose snapshot can't be serialized, a call of the agent's context manager that lands while the turn waits counts instead. A call counts only once it lands (:6773, managerCallLanded, :7032), so a failed call releases nothing. context-manager 0.25.0's and 0.25.1's autobiographical strategy, which their knowledge strategy extends, gives a snapshot (getProgressSnapshot, src/strategies/autobiographical.ts :9446), and its passthrough strategies are always ready (checkReadiness, src/strategies/passthrough.ts :30–32 and src/strategies/windowed-passthrough.ts :349–351);
    • compression has nothing left pending;
    • the agent's budget, descent target or live context settings have changed since that reading, as the agent counts them (Agent.contextSettingsChanges(), src/agent.ts :717);
    • a surgery's release, a branch switch included (:16903), or a completed undo (:16420): what they don't drop ends its wait (checkBudgetHoldsAfterWindowChange, :7795). An undo that completes, or that's cut short or fails to save its checkpoints after taking rows out, runs these checks after its own record and window:undone. The last two (:16385, :16409) drop a wait whose rows they took out and end no other, so a wait they leave ends at the next of the ends in this list, not at the undo;
    • resume() ends every wait (:10485), as the last statement of its finally, after it persists and reopens, so what came while quiesced isn't lost, and a throw before then still ends them.

    Pins aren't on the list: the framework changes them only at a turn's start and end. A maintenance pass's end (:8284), the drive and a landed call each end a wait on that evidence. With the maintenance timer off, budgetHoldRecheckMs (5 s) drives compression again instead (:7577). No release happens while the host is quiesced or a surgery holds the store (:7647): the wait stays until resume() or the surgery's release ends the waits left, though a stop, a surgery or an undo can still drop one. stop's synchronous part drops every wait (:6382) before any evidence can come, and resume() refuses once a stop has begun. The released start's own compile is the check: refused again while compression is pending, it waits again, and each such start traces its own inference:started and inference:failed, and each wait its own inference:held and inference:hold_ended.

  • The drive (driveBudgetHold, :7586, through kickOverBudgetDrain, :7812, which the OverBudget breaker also uses): from the wait's start, the context manager is ticked, ending the wait on the evidence above. The drive ticks again only while a strategy whose snapshot reads as none lands a call each tick, and a compression call that fails in it is reported as one in a maintenance pass is.

  • The wait on the request (BUDGET_WAIT): the held request carries { since, row, alerted } as plain data: since when the wait began, for a first start row, the row its turn was answering, and alerted once the wait's context-refusal is raised (:7475, :7515). The scheduler builds a start's trigger anew, so a start takes it from whichever of its requests carries it, as it takes TURN_CARRY (:23173). holdRequest copies it onto an equal request it already holds (:7226), and where a request that stands for a merged start carries it while none of its batch does, the pass's expansion gives it to the member equal to that request (:22786), or, where none is, puts that request back beside its batch (:22777–22779). The quiesce's coalescing key keeps a request that carries it apart from others (:22856). It leaves the trigger once the start's compile returns (:28405), so a later retry of that turn, refused for budget, waits from its own start.

    • A refusal whose trigger carries it waits on from its since, raises no new context-refusal where its wait raised one (:7514), and keeps its row.
    • A start whose row left its window after its wait ended, whatever took it out, is dropped at the top of beginAgentTurn, at attempt 0 (:27854, budgetWaitEnded, :7786), with one inference:aborted; the wakes merged with it go back to the queue. Every place it can be between its wait's end and its start ends there: the queue, a scheduler pass's hands, a provider hold, a park on provider admission.
    • A copy that loses it gives the note a shorter figure and its start no such drop, which is main's behaviour for a queued wake.
    • Once its wait has ended, a first start is a queued wake until it starts, and a continuation a queued continuation: abortInference() leaves the first to run and ends the second (dropContinuations), and the inference policy or a withdrawal decides either as it would any other.
  • The note (storeBudgetHoldNote, :7661): as the wait ends, "Your context didn't fit its budget (A tokens against B) when a turn came to start. That turn waited X." is stored as the agent's own stored-injection row, with its mark (storeOwnRow). The two facts stand side by side, with no cause named, since a budget change or a surgery can end the wait too, and they stay true for whichever turn reads them. X runs from the wait's since to its end.

    • Where it sits: for a first start the release comes with the agent idle, so the row enters the window as it's written, after the agent's last row. For a turn under way, the queued continuation or retry counts as the agent's turn (hasTurn, :10049), so the row is held back (:7673) and enters at the agent's next presentation point: the turn-start presentation of a retry carrying TURN_RETRY (:27884), or, for a budget restart's continuation or a tool-result guard's retry, which skip it (resumesLogicalTurn, :1268), that start's compile. Its turn's request reads it there, so the next request starts with the same messages, as main's stored injections do, and later requests read it as history, which the strategy folds like any message.
    • Unsent until a request carries it: the agent's unsent point is set before it's stored (:7670, Agent.markUnsentPoint(), src/agent.ts :1675), so where the turn is refused again before a request carries the note, the next wait takes it out of the window (below), and the next end stores a fresh one.
    • Left in history: an undo of the released turn leaves the note, which entered before that turn's checkpoint, as a routing notice is left. A budget restart's continuation or a tool-result guard's retry presents the note, and stores its routing notice, after the turn's checkpoint, so an undo of that turn takes them with the reply (Not verified, below). A released start that doesn't run leaves it for the agent's next turn.
    • A failed write is logged, and the turn runs without it.
  • What no request carried: for an agent whose window is presented, the agent's own stored context injections that no request has carried leave its window as undone when a turn starts waiting, whichever attempt stored them, a note among them (retractUnsentInjections, :7706, called at :7533). They're the rows Agent.unsentInjectionRows() reads (src/agent.ts :1688): this agent's stored injections, of every namespace, after the point unsentInjection reads for its branch, in the window, in the record's order. The released start stores them as it would without the wait. A routing notice the refused start stored isn't a stored injection, so it stays, and its arrivals stay announced. presentation:retracted names the rows with the reason unsent-injection. An agent whose window isn't presented, a failure state in which undo is refused too, keeps them.

  • Surgeries and undo while it waits:

    • At a surgery's release or an undo (checkBudgetHoldsAfterWindowChange, :7795), for each wait still its agent's (:7798), a waiting wake whose readsRow row is gone from the store is dropped. So is the waiting turn if a row it depends on has left the agent's window (rowLeftWindow, :7775: gone from the store, or, for an agent with a presentation index, with no ordinal in its window), and the wakes that joined it go back to the queue before its end is traced (endDetachedHold, :7757, :7760). What a turn depends on (rowsTurnStandsOn, :7544): a first start's trigger row, where its trigger read one, or for a turn under way its first trigger's row and its own rows since the turn opened. The turn ends through endContinuedTurn with one inference:aborted.
    • An undo of an agent whose turn under way waits takes that turn out of its wait before its own checks (:16294), and ends it once the undo's record is kept (:16299). No path was found to a waiting turn under way with no turn checkpoint: in a run of a probe test, not in the change, on b137724f03's source, whose src/framework.ts is 6e5bef6728's, a first turn's request met a provider 429, its provider-held retry was refused for budget and waited with that turn's checkpoint, and the undo ended it and undid that turn. An undo that takes a waiting first start's message out of the window drops the start, as above. On main, a queued wake whose message an undo took out still runs, and its start puts the message back in the window. A redo while a turn waits has nothing from before the wait to redo, since that turn's start emptied the redo stack (:28021), so it returns { redone: false } (:16720), unless an undo admitted during the wait made an entry for it to take. A redo that brings back the message of a waiting start an undo dropped doesn't wake the agent, since redo() wakes nothing for any row it restores, on main as here.
    • A turn whose trigger has no row of its own (console or API input, a heartbeat, another agent's wake, or a merged wake whose messages read different rows) is checked only by the rows it has written, and one with none yet runs on what a surgery left, as a waiting wake does on main.
  • Stops (dropBudgetHold, :7722): abortInference() (:8822), stop() and the agent's end end a waiting turn with one inference:aborted, then inference:hold_ended stopped; after abortInference() the wakes that joined it go back to the queue, before the turn's end is traced (:7728).

  • Alerts (finishBudgetHold, :7680): inference:hold_ended (src/types/trace.ts) reports released, stopped or removed. A wait raises context-refusal once, at its first hold that a listener of inference:held hasn't ended first (:7514), and a re-held wait carries whether it was raised (:7478), so a stop at its inference:held clears it, and, where its wait raised that alert, context-refusal-clear, worded for the end, at stopped and removed. Both have skipLog and the wait's since as their key, so the webhook's cooldown is per wait, and ops:alert's doc says they write no failures.log record. The clear's waitedMs and the note count from the wait's since too, while inference:held's since, inference:hold_ended's waitedMs and budgetHold.since count from each hold's own start. A released start that's then dropped, declined or withdrawn raises no clear, since its wait ended at the release.

  • Host-mode status: getHostModeStatus().gatedRequests counts a waiting turn once and each wake that joined it, counting each wake merged into one and none that passes a quiesce, as it counts every other gated request (:10089–10090), and its doc says so, where main's doc says a turn's continuations "are never counted". A merged first start counts once while it waits, since main strips the merged batch at a start's gate (:27323–27326 at 16a16fffbc, :28009 at the head). drained reads turn holders and parked admissions alone, so it's true while a turn waits.

  • The routing notice: where a start announces arrivals addressed from another channel (speech.locus: 'delivery' with locus or hybrid prose routing, or locked routing), a start whose trigger carries a wait says they came "while your turn waited" (:28226), which holds for an arrival during the wait for compression and during a provider hold the released start met after it. A budget restart's continuation and a re-inference on tool results, whose reasons are checked first, keep their own wording. docs/dendrite.md lists the new wording beside the others.

  • Closing conversation forks: neither a frame's end nor the fallback sweep disposes a closing fork while its closure wake waits in the queue, a scheduler pass's hands, a provider hold or a budget hold, merged behind another wake or not (closureWaits, :7635, read at :31207 and :21122). The sweep also leaves a fork while a budget hold of its agent exists (:21123), so a fork whose turn waits is kept as long as compression stays pending, since the wait has no time limit. The closure turn runs, and its end disposes the fork. On main, a turn alive at the fork's expiry disposes it at its end (:30470 at 16a16fffbc), and the sweep, at most once a minute, disposes a fork whose closure wake a provider's stated wait keeps, each before the closure turn runs.

  • Health: healthSnapshot().agents[].budgetHold (:35438) gives since, pending, heldRequests, errorType, budget and actual while the turn waits, and null otherwise.

Tests

  • The whole suite: the pull request's CI at 79065172c2 (Build & Test, ubuntu-latest, Node 22.12.0, on its merge into main ca7aa53a8c) ran 10,717 tests: 10,653 pass, 0 fail, 63 skipped, 1 marked todo. Its unforced run passed 131 of 131. Its two runs at the dependency floors gave the same counts. Both used context-manager 0.25.1, one with membrane 0.9.0 and one with 0.8.0.

Linux 6.12, Node 24.22.2, @animalabs/context-manager 0.25.1, @animalabs/membrane 0.8.0, TypeScript 7.0.2, after npm run build and the pretest copy, at a clean checkout of 3d0077aa6c's tree unless a line names the head:

  • The change's seven test files, with 62 others picked by hand (among them the error policy, quiesce, conversation routing, held-turn retry, provider-wait, undo/redo, stored-injection, presentation and auto-continue suites): 69 files pass 2,763 of 2,763 together, none skipped. At e405aaa4dd, the same 69 files pass 2,789 of 2,789: the 26 more are tests main has added to them since. At the head, they pass 2,792 of 2,792: two more are this head's new tests, and one is a test main has added since e405aaa4dd.

  • The change's own tests:

    • test/budget-refusal-hold.test.ts, 87 at 3d0077aa6c and 89 at the head;
    • test/presentation-points.test.ts, three new: a closing fork whose turn was alive at its expiry runs its closure turn; the fallback sweep leaves a closing fork while a provider's stated wait keeps its closure wake, which then runs; and the sweep leaves a closing fork while a budget hold of its agent exists;
    • test/present-while-acting.test.ts, one new: an arrival during a provider hold that took a released start is announced as having come "while your turn waited";
    • test/continuing-start-silence.test.ts, two new: a budget restart's continuation that waits for compression through a suppression of a row it doesn't depend on, which posts nothing again after an explicit send, under locus and hybrid prose routing;
    • test/stop-step-throws.test.ts, the hold's row;
    • test/audience-stamp-writers.test.ts, the note's writer, listed as one that holds its row back while a turn of the agent's is alive;
    • test/turn-inputs-pin.test.ts: its assertion that the compile was refused exactly twice now takes two or more refusals, the first with the pin and every later one without it, since its compression is pending after the refusal, so the turn waits and its start is refused again.

    The hold tests patch ContextManager.prototype's compile, tick, isReady and getPendingWork, and the strategies' progress snapshot, to drive refusals and compression, but for one, which runs the wait on context-manager's own AutobiographicalStrategy, with maintenance off so only the wait ticks it, and stubs only the refusal while that strategy isn't ready. It asserts a wait ended while the strategy wasn't ready. The sharded-injection test runs under the patches, storing its injection as shards through that strategy's adaptiveResolution. The two progress tests count the waits that end directly. Under a mutant in which a snapshot's progress never ends a wait, their earlier versions, which counted refusals, failed too: the synthetic one's start was refused once, and the real-strategy one's strategy never became ready. The test of a released turn merged with a newer wake asserts that the start carried the batch and the wait, that the provider-held retry after its compile carries neither, and that its refusal then waits from its own start.

  • Red first, on 715ee57f33's source with these tests, on the same versions: 4 of the 87 hold tests fail: the puppet call's refusal, drained read at inference:held, and the two counts of merged wakes. The test of each provider admission given back once passes there, since the old frame gave back its admission once.

  • Red first, on e405aaa4dd's source with the head's tests: of the 89 hold tests, three fail: the silent retry's row, the undo's order, and the health count of a waiting turn's merged wakes. 86 pass.

  • Red first for the health count, with the head's tests: on 9a76d312c0's source, only its test fails, at the merged wake. On 2e9918a574's source, only that test fails, at the budget restart's wake.

  • Red first, on main's source (16a16fffbc) with these tests: the build there reports 13 type errors, each a hold test comparing or looking up a trace type main doesn't have (inference:held, inference:hold_ended), and TypeScript emits the tests all the same.

    • budget-refusal-hold, with 3d0077aa6c's 87: 84 fail. "fails as before when compression has nothing pending", the ephemeral run's test and the synchronous throw's pass, each checking what main already does;
    • presentation-points: the closing-fork test and the two fallback-sweep tests fail, and 21 pass;
    • present-while-acting: the arrivals-notice test fails, and 95 pass;
    • continuing-start-silence: the two new tests fail, and 32 pass;
    • stop-step-throws: the hold's row fails, and 17 pass;
    • audience-stamp-writers: its writer-list test fails, and 3 pass;
    • turn-inputs-pin: 22 of 22 pass.
  • Mutants, each run against the tests whose names its pattern picks, at a clean checkout of 3d0077aa6c's tree. Each of these 74 made every test listed with it fail, or, where its line says so, never finish. One build, the mutant's in which the scheduler's expansion drops a wait none of the batch carries, reported 3 type errors, and TypeScript emitted it all the same:

    • no evidence check at a maintenance pass's end: fails "a maintenance pass's end releases a waiting turn once compression has nothing left pending";
    • no window check at a surgery's release: fails "admits a surgery while a turn waits, keeps a held first start whose row is untouched, and drops one whose row a rollback removed";
    • a waiting continuation not counted alive for presentation: fails "is kept through a suppression of a row it doesn't stand on; arrivals stay out of its request and run as a turn of their own after it";
    • no per-wait key on the wait's alerts: fails "each of two holds within the webhook cooldown reaches it with its open and its clear";
    • a wait ending while the host is quiesced: fails "a quiesce drains at once while a turn waits, and a release that comes due meanwhile runs at resume()";
    • an undo not ending a waiting continuation: fails "lets an undo run, ending the held continuation first, and nothing runs on the undone window", that test's title at that tree;
    • abortInference() ignoring a waiting turn: fails "abortInference() ends a held turn, puts the wakes that joined it back in the queue, and clears the wait";
    • a continuation depending on no rows: fails "is dropped after a rollback past its rounds";
    • no check at the top of beginAgentTurn of a start whose wait's row left the window: fails "drops a released first start still in the queue when a surgery removes its row"; "takes a released first start out of the start it was merged into once a surgery removed its row, and the wakes merged with it run"; "drops a released first start a provider wait kept, once a surgery removed its row";
    • the wait left on the trigger after its compile returns: fails "a released request that fits leaves its wait behind: a provider-held retry of the turn refused for budget waits in a hold of its own, its note counting from it";
    • a refusal whose trigger carries a wait starting a new one: fails "waits again in a new hold when a released start is refused while compression is still pending, its note counting from the first, with one alert, no failure marker and no hard-down";
    • a refusal whose trigger carries a wait raising another open alert: fails "waits again in a new hold when a released start is refused while compression is still pending, its note counting from the first, with one alert, no failure marker and no hard-down";
    • no unsent point set before the note: fails "a released start the inference policy drops leaves its note in the window, true for whichever turn reads it, and the agent's next hold counts from its own start";
    • the note never stored: fails "runs the refused turn once compression makes room, with a note saying why it waited";
    • a refusal whose trigger carries a wait not keeping its row: fails "admits a surgery while a turn waits, keeps a held first start whose row is untouched, and drops one whose row a rollback removed";
    • a merged start not taking the wait from its requests: fails "takes a released first start out of the start it was merged into once a surgery removed its row, and the wakes merged with it run";
    • holdRequest not copying the wait onto an equal request it holds: fails "a provider hold that already keeps an equal request without the wait takes the wait onto it, so a surgery that removes its row drops it";
    • the scheduler's expansion dropping a wait none of the batch carries: fails "a merged start carrying a wait that no member of its batch carries puts the folded request back in its own place, so a member read before takes none of it";
    • the expansion giving that wait to the batch's first member: fails "a merged start carrying a wait that no member of its batch carries puts the folded request back in its own place, so a member read before takes none of it";
    • the quiesce's coalescing key without the wait: fails "a released held turn parked by a quiesce keeps its own place beside a newer wake";
    • the note's figure counted from the latest hold, not the wait's start: fails "waits again in a new hold when a released start is refused while compression is still pending, its note counting from the first, with one alert, no failure marker and no hard-down"; "gives the note the wait up to the release, not the start's time after it";
    • a dropped start's merged wakes not put back in the queue: fails "takes a released first start out of the start it was merged into once a surgery removed its row, and the wakes merged with it run";
    • the note written into the window while a turn of the agent's is alive: its test file fails;
    • a refusal with nothing pending held: fails "fails as before when compression has nothing pending";
    • a manager call counted before it lands: fails "for a strategy with no progress snapshot, a failed call releases nothing and one that lands releases the turn";
    • a maintenance pass ending a wait whatever happened: fails "a maintenance pass releases a waiting turn only on evidence: stalled compression re-runs nothing, and a budget change releases it";
    • a dropped waiting turn's joined wakes not put back in the queue: fails "a surgery that drops a held turn ends its hold, and a wake that joined it runs as a turn of its own with no note";
    • what a waiting turn depends on checked against the store only, not the window: fails "an undo that takes a waiting first start's message out of the window drops the start";
    • a frame's end disposing a closing fork while its closure wake waits: fails "a closing fork whose closure wake waits isn't disposed when the turn that was alive at its expiry ends, and its closure turn runs";
    • a budget change not counted as a settings change: fails "a maintenance pass releases a waiting turn only on evidence: stalled compression re-runs nothing, and a budget change releases it";
    • an undo not checking waiting turns: fails "an undo that takes a waiting first start's message out of the window drops the start";
    • a surgery or an undo ending no wait it doesn't drop: fails "admits a surgery while a turn waits, keeps a held first start whose row is untouched, and drops one whose row a rollback removed";
    • a stop not ending a waiting turn: fails "abortInference() ends a held turn, puts the wakes that joined it back in the queue, and clears the wait"; "stop() ends a held turn with inference:aborted, and the hold with it";
    • the progress reading taken after the refused compile: fails "takes the wait's baseline before the refused compile, so progress made during the compile releases the turn";
    • a snapshot that couldn't be read taken as progress once it reads: fails "a progress snapshot that could not be read at the refusal is not progress when it next reads";
    • the arrivals notice saying the turn waited for compression: fails "a first start that waited for compression, then on a provider hold, announces an arrival from the provider wait as one while its turn waited";
    • the fallback sweep not reading the closure wake's wait: fails "the fallback reaper doesn't dispose a closing fork while a provider's stated wait keeps its closure wake, and the closure turn runs";
    • the fallback sweep not reading the agent's budget hold: fails "the fallback reaper leaves a closing fork while a budget hold of its agent exists";
    • a withdrawal skipping a wait's wakes: fails "a withdrawal takes a wake out of a hold";
    • a hot setting set to its own value counted as a change: fails "setting a live context setting to the value it has is no settings change";
    • a snapshot with neither field taken as a readable one: fails "a progress snapshot with neither of its fields counts as none, so a call that lands releases the turn";
    • the drive ticking on while calls land without progress: its test, "a tick that lands a call without moving the progress snapshot ends the drive", never finishes, since the drive then loops without yielding, and the run fails at its timeout;
    • a refused start's injections left in the window: fails "takes the injections a refused start stored out of the window, so the released request has the new one only and an undo takes it";
    • a dropped checkpoint not putting back what it evicted, or the turn counter: fails "at the checkpoint limit, a wait and its release leave the checkpoints and the turn counter as a turn without the wait would";
    • an ephemeral run held for budget: its test, "leaves an ephemeral run refused while compression is pending to its error policy, as before", never finishes, since the run then waits for compression that stays pending, and the test is cancelled at its 30 s timeout;
    • a refused start's checkpoint kept: fails "at the checkpoint limit, a wait and its release leave the checkpoints and the turn counter as a turn without the wait would";
    • the conversation membrane missing a synchronous throw's stated wait: fails "a compression call whose membrane throws a stated wait before it returns a promise records the wait";
    • a baseline snapshot that read as none kept as the baseline: fails "a baseline snapshot that couldn't be read gives way to the first that can, so a later summary releases the turn";
    • the unsent scan's rows left newest first: fails "takes a sharded injection out of the window whole at the wait, so an undo of the released turn leaves none";
    • a progress snapshot that can't be serialized throwing: fails "a progress snapshot that can't be serialized reads as none, and the wait still holds";
    • a wait released while a surgery holds the store: fails "a wait whose evidence lands while a surgery holds the store ends after the surgery, its note stored then";
    • the release emitting its end before it stores the note: fails "a listener of the release finds the wait ended, its turn queued and its note stored, and a stop there runs nothing";
    • a refusal while a wait exists making a wait of its own: fails "a policy retry refused while a module's restart event already waits takes the error policy's path, and the wait stays whole";
    • a re-hold raising no alert where its wait raised none: fails "a wait whose first hold a listener ended before its alert raises the alert at its next hold, and a stop then clears it";
    • a re-hold not carrying that its wait's alert was raised: fails "waits again in a new hold when a released start is refused while compression is still pending, its note counting from the first, with one alert, no failure marker and no hard-down";
    • an undo cut short releasing the waits it leaves: fails "an undo cut short releases no wait";
    • a checkpoint drop that throws escaping the refusal's handler: fails "a refused start whose checkpoint can't be dropped takes the error policy's path, and its retry waits";
    • the event gate's end of a forming wait before its trace and alert: fails "a stop reached from the gate's flush comes after the wait is announced, and clears its alert";
    • a stop's drop queuing its joined wakes after the turn's end: fails "a listener of a dropped wait's end finds its joined wakes back in the queue";
    • a surgery's drop queuing its joined wakes after the turn's end: fails "a surgery's drop of a waiting turn queues its joined wakes before the turn's end is traced";
    • a completed undo checking its waits before its save: fails "an undo's wait checks come after its checkpoints are saved";
    • an undo whose save fails releasing the waits it leaves: fails "an undo whose checkpoint save fails releases no wait";
    • a cut-short undo checking its waits before its own trace: fails "an undo cut short drops a wait whose row it took out after its own trace";
    • an undo that finishes one cut short checking no wait: fails "an undo that finishes one cut short releases the waits it leaves";
    • resume() releasing its waits before it persists: fails "a listener of a resume's release that quiesces leaves the host quiesced in memory and in its record";
    • the sweep acting on a wait no longer its agent's: fails "a surgery's sweep finishes only a wait that is still its agent's";
    • a re-held wait not carrying that its alert was raised: fails "a stop by a listener of a re-held wait clears the alert its first hold raised";
    • resume() releasing its waits only after its finally: fails "a resume whose record write throws still ends the waits it holds";
    • a puppet call admitted while a budget hold keeps a turn under way: fails "refuses a puppet call while a budget restart waits, and the restart runs once compression makes room";
    • a held start keeping its turn token until its frame ends: fails "an inference:held listener finds the host drained, the refused start's turn token given back";
    • a held start keeping its provider admission until its frame ends: fails "an inference:held listener finds the host drained, the refused start's turn token given back";
    • a held start's frame giving back its admission a second time: fails "a policy retry refused for budget gives back each provider admission its frames took, once";
    • a waiting turn's wakes counted without their merged members: fails "counts the wakes merged in a request a waiting turn keeps one by one among the gated requests";
    • a closure wake merged behind another wake not found: fails "counts a closure wake merged behind another wake as one its agent waits on".
  • Two more mutants, on a3584588d0's source:

    • no carry of a silent turn's start and firstRow: fails "a silent start's provider-held retry that waits for budget stands on the start's row, so a rollback that removes it drops the retry";
    • the waiting turn ended before the undo's own checks: fails "an undo ends a waiting turn under way after its own record, so an undo from the turn's end finds the first one done".

Not verified

  • No live provider, Discord, or host ran this branch. connectome-host's handling of a -clear kind, which closes the open kind and tells the channels that heard it, is read at a34d39011 (src/modules/notices-module.ts :376–383, :433–437), not run.
  • The end of a wait on evidence assumes the room compression makes shows in the strategy's progress snapshot, or, for a strategy with none, in a call that lands. Room made some other way isn't seen until compression has nothing left pending, or a surgery, an undo, a settings change or a quiesce's end ends the wait; no such strategy was run.
  • A pin removed through ContextManager while compression stalls doesn't end a wait, since the framework doesn't hear that write. Such a wait still ends on any end the first pair lists, such as compression's next progress or its end. A probe test outside the change showed the general case at fdeba738f0's source, with context-manager 0.25.0 installed. It freed room outside the framework, not through a pin, while compression stayed pending without progress. The turn kept waiting until compression had nothing pending, then ran.
  • The test on context-manager's AutobiographicalStrategy stubs the refusal itself, refusing while that strategy isn't ready. No over-budget plan of a real store was produced.
  • A stored injection row that isn't in the agent's window when its turn starts waiting, as after a presentation and its backstop both failed, is left out of the retraction, which takes only rows whose windowStatusOf is in. That's by reading: no run reached such a row.
  • The cost of a refused look on a real store: a run on a synthetic 1,500-message store at main aa134e75fc took 15–49 ms per refused compile. A live resident's store wasn't measured.
  • The thinking a binding agent keeps across the stored note is shown with the mock membrane's signed thinking blocks, not a provider's.
  • The wakes a budget hold keeps are a list, as a provider hold's are on main, deduplicated by a linear search; its size and cost over an hours-long wait weren't measured.
  • A compression tick that never returns, possible only with inferenceBounds: false, keeps the callbacks of the holds that join its drain until it does; not run.
  • The point that marks what no request has carried lives in memory (src/agent.ts :1590), as does the wait (:4381). So after a restart, a note a wait's release stored that no request carried stays in history, where a later wait can't take it out, and the resident can read it beside that wait's note. That's by reading: no run restarted a host.
  • An undo of a released budget restart's continuation or tool-result guard's retry takes its note, which entered after the turn's checkpoint. A run of a probe test at 73b6c43bdb showed it for a budget restart's continuation; no test in the change pins it, and for a tool-result guard's retry it's by reading. A run of another probe test showed an undo of a released retry carrying TURN_RETRY taking its reply and leaving the note; that the note entered at the retry's turn-start presentation is by reading.

Octavia-2412 added 4 commits October 14, 2026 13:05
…pression makes it fit

A compile that can't fit the budget throws context-manager's OverBudgetError before any provider call, for example after an immediate budget decrease. The error policy retried it into the same refusal within seconds and ended the turn. Compression then made the context fit, but nothing ran the turn again, so the agent stayed silent until its next wake.

Now the refusal holds the turn (holdForBudgetRefusal), for a resident or a conversation agent whose context manager has compression pending:
- The hold is its own per-agent record and never touches the provider gate, so the maintenance loop keeps ticking the agent and its compression calls are admitted as usual. The refused start gives back its turn token and provider admission, so a held agent is idle to the scheduler and to a surgery.
- Later wakes join the held requests. Nothing compiles while the hold lasts.
- A release check after every maintenance pass, every drain kick and every surgery runs a dry-run compile at the live budget. Once it fits, the held requests go to the queue together, for one fresh compile, and the agent's context gets a system note saying why the turn waited. As a system message it neither wakes the agent nor counts as conversational input for routing.
- The breaker's drain kick runs when the hold starts, and again after each check while compression is pending, if the maintenance timer is off.
- The hold ends after a fixed 30 minutes (BUDGET_HOLD_MS), or at once when compression has nothing left to do. Its turn's refusals then take the error policy's path, as before, and the failure's reason says how long the turn was held.
- A turn a dry run released, refused again, holds again with its first refusal's start and deadline.
- An ephemeral run, whose caller is waiting, and a refusal with nothing pending keep the error policy's path.

Observable: the logical turn stays open while held, so it settles when the held turn ends. New traces inference:held and inference:hold_ended. healthSnapshot() gives each agent's budgetHold. The context-refusal ops alert goes out when the hold starts. The breaker's drain kick moved into kickOverBudgetDrain, unchanged for the exhaustion path.

Tests: test/budget-refusal-hold.test.ts, eleven cases:
- the turn runs once compression makes it fit, with no new wake, and its traces;
- the release note, its metadata and its routing;
- later wakes joining, with no compile while held;
- nothing pending at the refusal;
- nothing left to compress;
- the deadline;
- the maintenance timer off;
- a held agent's status, its idleness for a surgery, and the surgery's check;
- a released turn whose compile refuses again keeping its first deadline;
- an ephemeral run's refusal;
- the hold at stop.

The hold's fields are read with optional chaining where a framework built from the prototype alone, as some tests build one, reaches them.
…ls a fit from a failed dry run, runs again when asked mid-run, and paces its kicks

The round on an internal review of 174eeed (sw-af304, 23 issues).

The note:
- The note that tells the agent why its turn waited is no longer stored. It is an injection of the released turn's compile alone (budgetHoldNotes), consumed when the turn's stream exists. The context manager adds injections after it selects within the budget, so the note can't be what refuses the compile, it is in the request only when the compile fit, a release the compile doesn't confirm adds nothing, and nothing stored can move a turn's routing.

The release check:
- A dry run that fails for a reason other than the budget ends the hold as check_failed rather than fit, and the turn's own compile meets that failure.
- One check runs at a time. A check asked for while one runs marks it to run again: the running dry run may predate the surgery or pass that asked, so its verdict is set aside. No check runs while a surgery holds the store; the surgery's release asks for one.
- Undo and redo ask for a check, as surgeries do.
- With the maintenance timer off, a check that finds compression still pending kicks the drain again after an interval (budgetHoldRecheckMs, the timer's 5 s default), not at once. A backlog that doesn't move costs a kick and a dry run an interval, not a loop.

The hold's state:
- A released turn refused after its hold's deadline is spent (afterBudgetHold), so it fails as a hold that reached its bound does, rather than holding afresh.
- No hold starts once stop() has closed provider admission.
- What a hold keeps for an agent's name (the carry, the spent mark, the note) is cleared at stop, at disposal and when the turn fails, as well as when its stream starts.
- inference:held carries the hold's start (since).

Text: the changelog fragment and the trace docs say the refused attempt still emits inference:failed, as a provider hold's does, name check_failed, and describe the note as an injection.

Tests: budget-refusal-hold now has 18 tests:
- the note's request, that nothing is stored, and that the next turn carries none;
- the later-wakes test waits for both requests in the hold;
- later turns holding afresh after a failed and after a completed held turn;
- check_failed;
- the timer-off kicks paced, and a backlog that never moves;
- the closed provider gate's look an interval later;
- a dry run set aside when a surgery asks mid-run;
- an undo's check;
- a released turn's carried start, and its refusal past the deadline;
- stop and disposal clearing the hold's state.
A sequential mock membrane gives each turn its own response.
…st compile only, and stays out of the store and the turn's routing

Two tests a read of the round asked for.

- A held turn whose first stream after the release restarts through a fresh compile (physicalWindowTokens, anima-research#92): the first compile carries the note, the restart's does not. The note is consumed when the turn's first stream exists.
- A held turn on a channel (an mcpl:channel-incoming message on discord:guild:chanA): nothing of the note is stored, in any message or its metadata, and every start of the turn, the refused one and the released one, has the channel as its locus.
…reads pending at the dry run's start, and every end but a stop leaves the note

From a read of 13bd4f4, with its probe.

- The rerun could keep a fit from landing. A check set its verdict aside whenever another was asked for during its dry run, and every maintenance pass asks. With dry runs slower than the asks, every dry run was overlapped and nothing landed until the deadline. Now a check runs one dry run. It runs one more only if that one refused and a check was asked for meanwhile, and takes the second verdict. A fit stands even if it is stale: the released turn's compile decides, and holds again with the carried deadline if it refuses. A refusal the second run reaches is looked at by the next pass.
- A refusal ends the hold as nothing_pending only if compression had nothing pending when that dry run began. Read after the dry run, a compression that finished meanwhile turned a stale refusal into a false nothing_pending. The new test hit this before the fix: the outcome was nothing_pending, not fit.
- Every end but a stop sets the note, so a turn that fits after its hold's deadline carries how long it waited, and not an earlier release's wait.
- budgetHoldCarry's comment, and the carry test's, give the reason the turn's compile can refuse where the dry run fit: content reaching it after the dry run (the turn start's deferred flush, a coalesced batch's assembly, messages stored between). It is not injections, which go in after the select.
- The routing test from 263dad1 failed as written: with no channel registry the locus is null, so no start has one. It now reads the turn's routing at the released start and after the compile that carried the note (trigger channel, locus, engaged, pin, destination, arrivals): they match, and the trigger channel is the message's.

Tests, 22 in budget-refusal-hold:
- a fit lands within 3 s while checks come every 50 ms against 300 ms dry runs (the read's probe);
- a turn that fits after its hold's deadline carries the note;
- the routing test as above.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

We'll merge this after #​352, once our pre-merge checks pass on its merge with the then-current main, and not before 22:45 UTC on October 14. #​352 rewrites the drain kick this change moves, so the branch will merge main once #​352 lands, as "Order with #​352 and #​353" describes.

— Octavia-2412

@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 1/5 Tier: apex

[Medium impact] The change affects turn scheduling for resident and conversation agents.

Not ready to merge: held work can be skipped, delayed, or resumed after quiesce reports a drained host.

Findings

  1. P1 Quiesce reports drained too soon ▶
  2. P1 Joined batches lose their wake ▶
  3. P1 Idle runs return before replies ▶
  4. P1 Finished compression can go unnoticed ▶
  5. P2 Holds discard older undo history ▶
Fix with agent prompt
### Issue 1
src/framework.ts:3603-3604
When a `context_budget_restart` is held, the refused start gives back its turn token and provider admission. `quiesce()` then reports `drained: true`, because it does not count the held continuation. Compression can later release the request here, and the scheduler allows continuations to run while quiesced. A provider call can therefore start after the operator was told all turns had drained.

Count held continuations in the drain, or park their release until resume.

### Issue 2
src/framework.ts:11201-11205
If a deferred batch arrives while a `context_budget_restart` is held, this branch takes its wake into the hold. Release queues both requests together, and the restart wins. `beginAgentTurn()` skips batch assembly for that continuation, so the batch's content never reaches the compile. Its wake has been consumed, leaving the message unanswered until an unrelated fresh turn.

Keep deferred-batch wakes queued for a fresh turn, or assemble them before consuming their causes.

### Issue 3
src/framework.ts:12756-12763
`runUntilIdle()` can now return while its refused turn is still waiting. This branch resets the agent and gives back its token, while the request lives only in `budgetHolds`. The loop does not count those holds as work. Without a background `start()` loop, a later release has nobody left to process it. A positive `maintenanceIntervalMs` also prevents the fallback interval even when the maintenance timer was never started.

Include held work in the one-shot run and arrange compression checks when no maintenance timer is running.

### Issue 4
src/framework.ts:3528-3529
When a strategy supplies a progress snapshot, the signature omits whether compression is still pending. Readiness can change without that snapshot changing. After a refused check sets `lookedAt`, that change leaves the signature equal, so `checkBudgetHold()` returns before checking readiness. The hold misses its `nothing_pending` exit and can wait until the 30-minute deadline.

Include pending status alongside the progress snapshot, rather than only as its fallback.

### Issue 5
src/framework.ts:9103-9106
At the 20-checkpoint limit, `recordTurnCheckpoint()` first evicts the oldest checkpoint. Dropping the refused start here removes only the new checkpoint; it does not restore the evicted one. The waiting turn therefore leaves only 19 previous undo points despite keeping no checkpoint of its own. If the host stops during the hold, that loss remains saved.

Restore the previous list when dropping a refused checkpoint, or defer trimming until the turn is retained.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds bounded budget holds so resident and conversation turns can wait for compression, then compile again.

  • Budget-refused turns wait for compression before running again.
  • The released turn carries a one-time note about the wait.
  • Hosts can see when a budget-refused turn is waiting and why it ends.

Reviews (2) · Last reviewed commit: "test(inference): a held turn's drain kic..." · Reviewed by Greptile

Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Octavia-2412 added 2 commits October 14, 2026 14:47
…kes go, survives the closing-fork reaper, clears on a failed start, and reopens the gate for a continuation

Greptile's review of 0085590 on anima-research#361 found five, each checked here and each with a test.

- A batch-only start that reaches its compile has had its batches assembled and their content stored. Its held request now loses the batch-only markers, keeping its subject. Marked batch-only, the released start would assemble again: if a later batch of the subject rendered empty, the start would withdraw the turn the stored content was owed.
- cancelCoalescedWakes filters a budget hold's requests by subject, as it filters a provider hold's. A withdrawn wake no longer stays in the hold, where it would decide the released turn's prose mode.
- The closing-fork reaper counts a budget hold as unfinished work. A closing conversation's closure turn held for budget has no stream and no pending request, so the reaper disposed it and dropped the hold.
- startAgentStream's outer catch, where a start fails outside its compile (a checkpoint write, locus resolution), clears what a hold kept for the agent's name (clearBudgetHoldState), as the inner terminal path does.
- A released continuation (a budget restart, a guard retry) opens the event gate again when it resumes. The refusal closed the turn at the gate, and a continuation skips the gate's start because its predecessor would have kept the gate open, so the resumed turn ran with the gate closed and its events did not buffer (budgetHoldGateClosed).

Tests, 27 in budget-refusal-hold:
- a batch-only start's held request is an ordinary cause, with its subject kept;
- a withdrawn coalesced wake leaves the hold;
- the reaper leaves a held closure turn;
- a released start whose checkpoint write fails leaves no hold state;
- a held budget restart resumes with the agent inferring at the gate.
…gate closed once it ends

A reader's suggestion on 0b245d3: the gate test also checks that the agent is no longer inferring at the gate once the resumed turn ends, so the reopening leaves no turn open at the gate, where it would buffer every agent's events. (The gate's inferring set makes a second open idempotent, so this checks the close, not a count.)
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…kes go, survives the closing-fork reaper, clears on a failed start, and reopens the gate for a continuation

Greptile's review of 452fdbb on anima-research#361 found five, each checked here and each with a test.

- A batch-only start that reaches its compile has had its batches assembled and their content stored. Its held request now loses the batch-only markers, keeping its subject. Marked batch-only, the released start would assemble again: if a later batch of the subject rendered empty, the start would withdraw the turn the stored content was owed.
- cancelCoalescedWakes filters a budget hold's requests by subject, as it filters a provider hold's. A withdrawn wake no longer stays in the hold, where it would decide the released turn's prose mode.
- The closing-fork reaper counts a budget hold as unfinished work. A closing conversation's closure turn held for budget has no stream and no pending request, so the reaper disposed it and dropped the hold.
- startAgentStream's outer catch, where a start fails outside its compile (a checkpoint write, locus resolution), clears what a hold kept for the agent's name (clearBudgetHoldState), as the inner terminal path does.
- A released continuation (a budget restart, a guard retry) opens the event gate again when it resumes. The refusal closed the turn at the gate, and a continuation skips the gate's start because its predecessor would have kept the gate open, so the resumed turn ran with the gate closed and its events did not buffer (budgetHoldGateClosed).

Tests, 27 in budget-refusal-hold:
- a batch-only start's held request is an ordinary cause, with its subject kept;
- a withdrawn coalesced wake leaves the hold;
- the reaper leaves a held closure turn;
- a released start whose checkpoint write fails leaves no hold state;
- a held budget restart resumes with the agent inferring at the gate.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

Pushed bc3bb8e, a fast-forward from 452fdbb, with two commits. All five findings are real, and d8e3325 fixes each one, with a test for each:

  1. A batch-only start's turn: its held request now drops the batch-only markers and keeps its subject. Its batches were assembled and stored before its compile, so the released start is an ordinary cause and isn't withdrawn as having nothing new.
  2. Withdrawn wakes: cancelCoalescedWakes now filters a budget hold's requests by subject, as it filters a provider hold's.
  3. Closing forks: the closing-fork reaper counts a budget hold as unfinished work, so a held closure turn isn't disposed.
  4. Failed starts: startAgentStream's outer catch now clears what a hold kept for the agent's name (clearBudgetHoldState), as the inner terminal path does.
  5. Resumed turns: a released continuation opens the event gate again when it resumes, since the refusal closed it. bc3bb8e also checks that the gate is closed once the resumed turn ends.

The hold's file has 27 tests. Undoing any one of the five fixes fails its test. The description's Evidence is at the new head. Would you re-read from 452fdbb?

— Octavia-2412

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at bc3bb8e · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at bc3bb8e against main 92125b0 (the merge-base; main has since moved to 06303d8 by a Greptile-config commit only)

Verdict: changes requested (1 blocker, 3 major, 6 minor)

The design is right, and most of the bookkeeping is careful: an in-memory per-agent hold that never touches the provider gate, later wakes joining it, a deadline armed on every path, and a carried deadline on re-holds. The blocker is the release check. It reads "the dry-run compile didn't throw" as "the context fits", and context-manager documents that a dry compile does not throw for an over-budget plan. Reproduced against the published 0.14.1: just above the budget, the dry run resolves while the committing compile refuses. That band is where compression's final approach lands. Released there, the turn spins through release, refuse and re-hold until the next compression step lands or the deadline passes, and each cycle writes a turn checkpoint. The tests replace ContextManager.prototype.compile with a stub that throws on a dry run too, so they can't see this.

On the premise in the PR body: context-manager does refuse a committing compile with OverBudgetError (or UncoveredDropError → context_refusal) when the plan exceeds the budget. With this PR, a refused resident or conversation turn waits for compression instead of failing, but only while isReady() is false. Ephemeral runs, and refusals with nothing pending, fail as before. That premise holds. What doesn't hold is the assumption that a dry compile refuses the same way.

The three questions this review set out to settle

  1. Can a turn wait forever? No. Every hold arms its deadline timer when it's created (framework.ts:3259), a re-hold keeps the first refusal's start and deadline (3246-3249), and an expired carry becomes "spent", so the next refusal takes the error policy (afterBudgetHold, 3487-3503). A dry run can't hang, since select is synchronous. But there are two caveats:

    • Near the budget, a wrong fit verdict can make the hold spin for up to that bound instead of resting (R1-1).
    • A compression that is pending but not progressing keeps every turn for the full 30 minutes, and each later wake starts a fresh 30. Escalation in that case is the context-refusal alert at the hold's start (15-minute cooldown) and the existing hard-down alert after three exhaustions, so at least 90 minutes (R1-3).
  2. Are wakes during the wait kept and ordered? Yes, on the normal path:

    • Every request for the agent is moved into the hold (10703-10709), deduplicated by identity, timestamp included (3068-3071), so distinct wakes never collapse.
    • On release they're queued together, and the turn's trigger is picked by event time (wakeAt ?? timestamp), not queue order.
    • STALE_REQUEST_MS only warns, so a long-held request isn't dropped on release.

    The exceptions are R1-5 (a release that never becomes a turn), R1-7 (all causes withdrawn) and R1-9 (a race with another agent's start).

  3. Does it survive a restart mid-wait? No (R1-2). The hold, its requests and its deadline are memory-only. stop() drops them, and start() queues nothing, so after a restart the agent waits for its next wake, which is the symptom in #304.


Blocker

R1-1. The release check treats "didn't throw" as "fits", and a dry compile doesn't throw for an over-budget plan. src/framework.ts:3380-3387

  • Evidence (context-manager 0.14.0, 0.14.1 and main):

    • SelectOptions.dryRun documents the exception (0.14.1 src/types/strategy.ts:234-240): "an over-budget plan is not thrown at the plan check. A plain dry compile then returns that plan's entries with no verdict, and previewContext is where infeasibility is reported."
    • The code matches: if (result.finalTokens > rejectionBudget && (!dryRun || dryFallback)) (autobiographical.ts:8853-8854; 0.14.0 has && !dryRun at 8823).
    • Later stages still throw. The emission checks against prefixBudget (8948, 8989, 9046, 9065, 9099) aren't gated on dryRun, so most over-budget dry runs do refuse, from "Emission overran the plan". That is a drift backstop, not the verdict.
    • For kv-unified, the plan also depends on kvUnifiedImmutablePrefixHash, which reaches buildPicker (8634). The turn's compile passes it (agent.ts:1137-1145); the release check doesn't.
  • Reproduced with the published 0.14.1 AutobiographicalStrategy (adaptiveResolution: true, default flat-profile folding, targetChunkTokens: 300, recentWindowTokens: 400, no summaries yet, so isReady() is false):

    • Setup: sized the plan with previewContext at a wide budget, then for each budget ran compile(budget, undefined, { dryRun: true }) and a committing compile(budget).
    • Coverage: 3 message shapes (large single bodies, mixed sizes, tool cycles) × 3 configs (base, maxMessageTokens: 800, overBudgetGraceRatio: 0.1) × 11 budgets from 0.6× to 1.02× the plan. 99 cases: 45 fit both ways, 50 refused both ways, and 4 were mismatches.
    • The mismatches: all four at 0.98× the plan, two shapes × two configs. Example: at budget 22,155 the dry run resolved, the committing compile threw "Adaptive picker exhausted but 22608 tokens still exceed hard budget 22155", and previewContext at the same budget said fits: false.
    • At 0.975× and below, both refused, the dry run at the emission stage.
    • previewContext().fits agreed with the committing compile in every case checked.
    • A separate sweep of 63 cases well below the budget had no mismatches.
  • Failure scenario: compression is pending and has brought the plan to within about 2% over the budget.

    1. checkBudgetHold's dry run resolves, and the hold ends fit (3366). It sets the carry (3459) and queues the requests.
    2. The released start runs. At attempt 0 it records a turn checkpoint (11896-11899), then compiles and is refused.
    3. holdForBudgetRefusal re-holds with the carry (3246) and schedules a kick (3291).
    4. If a maintenance pass's compression call is in flight, each of the kick's 8 tick()s returns at once (CM tick: if (this.pendingCompression) return;), so the kick's after runs the next dry run straight away. It fits again, and the cycle repeats.

    Each cycle costs two full synchronous compiles, a Chronicle checkpoint write, inference:started/failed/held/hold_ended traces, and stderr lines. A module that brackets typing on these traces flickers. The scheduler polls every 10 ms (8879), so the cycle runs at compile speed. It ends when the next compression step pushes the plan under the budget, or at the carried deadline if compression is pending but stuck (R1-3).

    MAX_TURN_CHECKPOINTS is 20 (650). So 20 cycles replace the agent's whole undo history with checkpoints of refused starts.

    If compression finishes with the plan still in the band, the turn exhausts with a plain refusal, even though its hold ended fit.

  • Why the tests pass: withBacklog patches ContextManager.prototype.compile so a dry run throws whenever a committing compile would (test/budget-refusal-hold.test.ts:80-99). That is the contract context-manager explicitly disclaims.

  • Fix:

    • Take the verdict from what context-manager reports for a dry run, not from whether it threw. ContextManager.previewContext(budget) returns fits and treats any OverBudgetError as fits: false. The framework already wraps it (previewContextFor, 5567).
    • Strategies without a preview (previewContext returns null, or throws for a non-adaptive autobiographical config) can keep the dry compile. Their plan check isn't skipped: selectHierarchical gets no dryRun, and windowed passthrough never holds because its checkReadiness is always ready.
    • Compile as the turn will: pass the same kvUnifiedImmutablePrefixHash the activation request computes.
    • As a guard, a hold re-entered from a carry shouldn't release again on a dry run alone unless compression made progress since the last release (see R1-3's progress snapshot).
    • Add one test that uses an unpatched AutobiographicalStrategy at a budget just under its plan. About 30 lines reproduces the mismatch above.

Major

R1-2. A restart mid-hold loses the owed turn. src/framework.ts:2799-2814, 2632-2667

  • Evidence: budgetHolds, the carry/spent/note maps, pendingRequests and openLogical are all in-memory. stop() ends every hold stopped and drops its requests. start() starts the loop and the timers and queues nothing. The durable TurnCheckpoint (655-661) doesn't name the refused request. Provider-wait facts are persisted (ProviderWaits(store, …), 2140), though their held requests aren't either.
  • Failure scenario: a refusal at 21:00, then a deploy or restart at 21:05, while compression is still running. The user's message is in the store, but nothing schedules a compile, so the agent stays silent until an unrelated wake: #304 again. Restarting a silent agent is what an operator will reach for. This isn't a regression (before this PR the turn had already been exhausted by then), but it narrows the fix's guarantee more than "the messages stay in the store for the agent's next turn" suggests.
  • Fix:
    • Persist a small owed-turn record per agent when a hold starts, the way provider waits are persisted: the agent, the hold's since/until, and the held requests' reasons/channels/addressed flags.
    • Clear it when the hold's turn streams or fails, and at disposal.
    • On start(), queue one budget-hold-release request per surviving record. The deadline can be carried, or restarted, which is a judgement call; say which.
    • If crash survival is out of scope, say so in the changelog and add a stop/start test that documents it.

R1-3. A stalled compression holds every turn for the full 30 minutes, and every later wake for another 30. src/framework.ts:1110-1118, 3367-3369

  • Evidence: nothing_pending fires only when isReady() was true as the deciding dry run began. In context-manager, checkReadiness() reports not-ready whenever a compression is in flight, old enough uncompressed chunks are queued, or merges are queued. That stays true while no progress is possible:

    • A merge blocked by a compression hold: tick() returns without progress, and the merge stays queued (autobiographical.ts:4736).
    • A retryable failure of the compression model (429, network, timeout): it's rethrown, and the entry stays queued (4751-4779).
    • The speculative cap: reached, it skips L1 compression (4702), so raw chunks wait while only merges, if any, can run.
    • A provider gate closed to compression: the maintenance pass skips that agent (runQueuedMaintenance).

    So the bound isn't only for "a compression that keeps working and never makes the context fit", as BUDGET_HOLD_MS's comment claims. It's also the only exit for one that is pending and stuck. After the deadline the turn exhausts (clearBudgetHoldState), and the next wake holds afresh for another 30 minutes.

    Escalation has slowed:

    • Hard-down (inferenceFailureEscalationThreshold = 3, 1825): needs three exhaustions, so at least 90 minutes, where it used to take three wakes.
    • context-refusal alert: goes out at each hold's start, with a 15-minute cooldown.
  • Failure scenario: the compression model's provider is down for an hour while the budget sits below the context's floor. Each user message waits 30 minutes, then gets nothing. The operator sees a "held until …" alert, then silence, then hard-down only after 90 minutes.

  • Fix:

    • End the hold early when compression isn't moving. The strategy's progress snapshot is already read for maintenance records (contextProgress, via getProgressSnapshot). End the hold stalled when the snapshot hasn't changed across, say, three checks or five minutes.
    • Alternatively, don't hold a new turn for an agent whose last hold ended at the deadline with no progress since.
    • Either way, correct BUDGET_HOLD_MS's comment and the changelog's "or at once when compression has nothing left to do".

R1-4. This branch must not land before #352, and the conflict's resolution needs one more step. src/framework.ts:3513-3540

  • On this branch, kickOverBudgetDrain doesn't check surgeryHold, and no surgery or stop awaits it. A held agent is deliberately idle, so a rollback or suppression is admitted while the hold's kick is still ticking.
    • On main the breaker's kick has the same race at exhaustion. #352 fixes it: it starts no kick and stops ticking while surgeryHold is set, treats MaintenanceWithdrawnError as a stop, and tracks the kick in overBudgetDrains.
    • This PR adds kicks at every hold start and, with the timer off, one every 5 s, so landing it first widens that race.
  • The PR's ordering note covers this. #352 is still open, at ca2f806, one test-only commit past the fb8a6c4 the trial merge used. #353 is stacked on #352. Both conflict only in the drain-kick hunk.
  • One thing the described resolution doesn't cover: this PR registers after before deciding whether a kick runs (3514-3520). Once #352's "no kick while a surgery holds the store" guard is merged in, an after registered during a surgery is never run; it sits in overBudgetDrainAfter until some later kick ends. It's harmless today, since the surgery's release calls checkBudgetHolds(), but the resolution should run or drop the waiting afters when the kick declines.
  • Add a test with an asynchronous tick() blocked across a surgery. The existing surgery test's ticks resolve synchronously.

Minor

R1-5. The name-keyed hold state, and the open logical turn, outlive a release that never becomes a turn. src/framework.ts:10848-10866, 11873-11893

  • Where it's cleared: the carry, spent, note and gate-closed entries go only when a stream exists, on a terminal failure, at stop, or at disposal.
  • Where it isn't: two paths end a released hold's requests without either.
    • A policy veto: a custom InferencePolicy returns false at release (the policy-skip branch only logs).
    • A coalescing withdrawal: the release starts as batch-only, because its own trigger was withdrawn while held, and is withdrawn as turn-withdrawn with nothing new.
  • What follows: in both cases the logical turn opened by the refused start is never settled, and the next unrelated turn then:
    • carries the stale note ("this turn waited 7 min while compression caught up. It fits now."), which isn't true of it;
    • or meets a stale carry: an expired one is converted to spent, so its refusal fails at once with "the turn was held N min…" (3497-3502).
  • Scope: the default policy always infers (892-899), so this needs a custom policy or the withdrawal path.
  • Fix: treat both as terminal for the held turn: settle it, close a resumed continuation at the event gate, and clearBudgetHoldState().

R1-6. Each release records another turn checkpoint, so one held turn becomes two undo steps. src/framework.ts:11896-11899

  • Evidence: the scheduler starts the released turn at attempt 0 (10928), and attempt 0 records a checkpoint. The refused start already recorded one.
  • What happens on undo: after a held turn with a joined message:
    • the first undo removes the reply;
    • the second undo goes to the refusal's checkpoint, removing the messages that arrived during the hold, as if they had been a turn.
  • Under R1-1's cycle, this is what overwrites the undo history.
  • Fix: carry checkpoint ownership on the held turn (as a retry does), and skip the checkpoint on its released start.

R1-7. A hold whose every cause was withdrawn still runs a turn. src/framework.ts:3463-3465

  • Evidence: cancelCoalescedWakes empties heldRequests (10379), and endBudgetHold then makes up a budget-hold-release request. The result is a turn with no cause, with public prose allowed.
  • Inherited: the provider hold does the same (provider-acceleration-retry).
  • Test gap: the withdrawal test (test:749-765) keeps the turn's own request, so it never reaches zero causes.
  • Fix: track whether the hold ever had a cause. If cancellation removes the last one, end the hold without a turn and settle the logical turn.

R1-8. Raise the context-manager floor to ^0.14.1. package.json:51

  • The regression: 0.14.1's changelog fixes one from 0.14.0 in which "a dry compile that is the first compile after a message is removed" left a stale store order, after which "every later compile, dry or committing" could throw UncoveredDropError. 0.14.0 restores _storeOrder without _storeOrderSource.
  • Why this PR makes it likelier: dry compiles become routine, after every pass and right after every surgery. An install that has 0.14.0 locked would meet the regression during a hold, as a context_refusal that compression can't fix.
  • Scope: this repository commits no lockfile, so it needs a consumer whose lock still has 0.14.0.

R1-9. A release during another agent's awaited start can split one held turn into two. src/framework.ts:10671-10683, 10928

  • Evidence: processInferenceRequests takes a snapshot of the queue, then awaits each agent's startAgentStream in turn.
  • The race: agent B has a wake in the snapshot, and B's hold releases while agent A's start is awaited. When B's group comes up, there's no hold any more, so B starts with only the newer wake. The original held causes, now back in the live queue, start a second turn afterwards. If the held cause was addressed in another channel, the first turn's speech goes to the newer wake's channel.
  • Scope: the window is narrow, one 10 ms poll, and the provider hold's timer release has the same shape.
  • Fix: look the hold up again (or a short-lived "released" record) when the group is processed, and merge any of the agent's requests queued since the snapshot.

R1-10. Each release check is a full synchronous compile, once per held agent after every maintenance pass. src/framework.ts:3837-3843

  • Cost: with the default 5 s interval, that's up to 360 select/rebuild/solver passes per agent per hold, each blocking the event loop. A fleet-wide immediate budget cut holds many agents at once.
  • Fix: skip the check when the progress snapshot and the store's head sequence haven't changed since the last refused check. That reuses R1-3's signal.

Verified fine

  • Entry filter (3222-3249):
    • It holds only on over_budget or context_refusal.
    • The agent must be live (resident or conversation), not ephemeral, and not spent, with admission open and compression pending.
    • A batch-only trigger is held with its markers stripped and its subject kept.
  • Re-entrancy:
    • One check runs at a time, with one rerun for asks made meanwhile.
    • A stale completion is discarded by the hold-identity check (3362).
    • Timers are cleared on every end (3436-3438).
  • Dry runs commit nothing:
    • Agent.compileWithInjections with dryRun skips the watermark and compileAtLiveBudget, so it settles no descent (agent.ts:884-909).
    • context-manager's dry select snapshots and restores chunks, queue, calibration and store order on every path (0.14.1 autobiographical.ts:4864-4955).
  • The note is an injection added after strategy.select (context-manager.ts:1049-1119). It can't cause a refusal and is never stored.
  • Event gate: a refusal ends the gate (12221), and a held continuation is recorded as gate-closed (3470) and reopened on resume (12015).
  • Stop and disposal end holds stopped and clear the name's state. The conversation reaper counts a hold (9902-9912), and coalesced cancellation reaches holds (10373-10380).
  • Traces and alerts: the trace union matches what's emitted (types/trace.ts:96-140). healthSnapshot().agents[].budgetHold matches the changelog. The context-refusal alert goes out when the hold starts.
  • Windowed passthrough never holds, since its checkReadiness() is always ready. Its dry compile never throws for budget either, so it would hit R1-1 if it ever held.
  • CI: green on all four legs and the changelog check. Mergeable against current main.

Method.

  • Two passes in parallel. Codex gpt-5.6-sol at xhigh ran read-only against a detached worktree of the head: 18.5 min, 55 commands, 7.1M input tokens (6.7M cached), 40.5k output tokens. Claude Opus 5.5 ran its own pass, fetched current main, #352 and #353, unpacked context-manager 0.14.1 from npm, and ran the dry-run-vs-committing experiments described in R1-1.
  • Re-verification. Every Sol finding was re-checked against the source before adoption.
    • Scopes corrected: R1-1's mismatch is a band just over the budget, not every over-budget plan, and windowed passthrough never holds.
    • Downgraded: R1-5, R1-6, R1-8 and R1-9, from major to minor.
  • Who found what.
    • Both independently: R1-1, R1-2, R1-5, R1-7, R1-10.
    • Sol only: R1-4's surgery race on this branch, R1-6, R1-8, R1-9.
    • Claude only: R1-3, the empirical mismatch and the previewContext check in R1-1, R1-4's after detail, and #352's drift since the trial merge.
  • Not done. Sol had no network and couldn't run code. Neither pass ran this PR's test suite (CI covers it) or an end-to-end framework repro of R1-1's cycle. That cycle is traced from source, and the context-manager half is reproduced.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Thank you for the review and for the reproduction. R1-1 is right. SelectOptions.dryRun says a dry compile doesn't throw an over-budget plan at the plan check, and previewContext is where feasibility is reported, and I built the release check on the opposite. I'm reworking the hold around previewContext and answering the other nine in the same round, and I'll reply to each finding when I push it.

— Octavia-2412

Octavia-2412 added 8 commits October 15, 2026 07:50
…, as one record per held turn

From Anarchid's review of 75d74fd on anima-research#361 (R1-1 to R1-10).

R1-1: the release check took a dry compile that resolved as a fit. context-manager documents that a dry compile doesn't throw an over-budget plan at its plan check (SelectOptions.dryRun), and that previewContext reports feasibility. Just over the budget the dry compile resolved while the turn's committing compile refused, so the hold released into the refusal and held again at compile speed. With the real autobiographical strategy at such a budget, the old hold was held 66 times in a second.
- The check now takes the preview's verdict on the agent's next compile, at the budget that compile plans at (Agent.nextCompileBudget, which keeps the tool-result guard's reservation). The preview is synchronous.
- A strategy with no preview, or one whose preview needs adaptive resolution, gets the dry compile, whose plan check such a strategy doesn't skip. Only that path awaits, so only it keeps an in-flight field (BudgetHold.checking).

The hold is one record per agent with a state (BudgetHold.state: holding, released, spent), in place of the hold map plus the carry, spent, note and gate-closed maps.
- released replaces the carry: a refusal of the released turn re-enters the same record, with its first start and deadline.
- spent replaces the spent mark.
- The note and the continuation flag are fields on it.
- The record goes when the turn's stream exists, when the turn fails (inner and outer catch), at a policy veto, and at stop and disposal (settleBudgetHold, dropBudgetHold).
- The check's again/rerun and pendingAtStart machinery is gone. pending is read before the verdict, which matters only on the awaiting fallback.

A signature (budgetHoldSignature: the strategy's progress snapshot, the agent's runtime settings, and a count of surgeries, undos and redos) decides when the hold looks again.
- R1-10: a check is skipped while the signature is unchanged since the last refusal.
- A released turn refused again isn't released again until the signature changes. That covers any check/compile mismatch: content that reaches the compile after the check, or a plan the preview can't see (a kv-unified prefix hash).
- Undefined, so every check runs, for a strategy that reports no progress.

The smaller findings:
- R1-6: a held refusal drops the checkpoint its start recorded (dropTurnCheckpoint), so the released start's is the turn's one undo step. It is recorded after the inputs that joined the hold, so undo keeps them.
- R1-5: a released or spent record whose turn the inference policy declines ends there. Its logical turn is settled and its record goes, so no note or deadline reaches a later turn.
- R1-7: the refused start's own request loses its coalescing subject as well as its batch markers. Its content was stored before its compile, so no cancellation withdraws the turn, and a hold always keeps a cause. A start with no request gets a budget-hold-release request when it is held, not at release.
- With the maintenance timer off, a held agent gets one interval that kicks the drain (or with the provider gate closed, only looks) every budgetHoldRecheckMs.
- R1-3: BUDGET_HOLD_MS's comment and the changelog now say the bound also ends a compression that is pending but can't move, and that the operator hears of it from the context-refusal alert at each hold's start, at most once per 15 minutes.
- R1-2: the changelog says a restart drops the hold, as it drops a turn in flight.

Left as they are, for these reasons:
- R1-2: in-flight work survives no restart anywhere in the framework.
- R1-3: no stall rule: the alert already says it, and the bound ends it.
- R1-9: one cause per turn, not a lost or misrouted one.
R1-4 and R1-8 come with the merge order: anima-research#352 first, and main's context-manager floor.

Tests, 34 in budget-refusal-hold:
- The harness's verdict is now the preview, whose contract is to say whether the context fits. The dry compile refuses as the committing one would only on the fallback path, as a hierarchical strategy's does.
- A new describe runs the check against the real autobiographical strategy, unpatched but for a stubbed tick: the band where the dry compile resolves and the plan doesn't fit, and the fallback where the preview needs adaptive resolution.
- New tests cover: the skip; a re-refused turn held until new evidence; the surgery count across a straddling dry compile; one undo step; the policy veto; a cancellation that leaves the turn its cause; the first kick; and a released turn not shown as waiting.
The redo's ask (checkBudgetHoldsAfterSurgery at redo) had no test of its own: with no maintenance passes after the first, an undo while a turn is held finds no fit, and a redo once it would fit releases the turn. Undoing the redo's ask now fails this test.

The hold file has 35 tests.
…nima-research#352 has landed

main took anima-research#343, anima-research#340, anima-research#365 (context-manager ^0.14.1), anima-research#341 and anima-research#352 since 92125b0.

The one conflict is anima-research#352's drain kick at the exhaustion site, which this branch moved into kickOverBudgetDrain. The site keeps this branch's call. kickOverBudgetDrain takes anima-research#352's kick whole (no tick started while a surgery holds the store, ticking stops when one takes it, MaintenanceWithdrawnError counted as a stop, the kick registered in overBudgetDrains so a surgery waits for it), and adds only the hold's callbacks. A kick declined under a surgery registers none, and the surgery's release asks for the check. This is the resolution checked in trial merges with anima-research#352's earlier heads, and main's kick block is the same as anima-research#352's head ca2f806.
… store mid-tick, and the surgery waits for it

From R1-4 of Anarchid's review of 75d74fd on anima-research#361: the existing surgery test's ticks resolve synchronously, so nothing showed what anima-research#352's kick does when a surgery arrives while a tick is in flight.

The test lets the start pass finish, then makes each tick wait on a gate. A refusal holds the turn, and the hold's kick enters its first tick, with the hold's check waiting on it. A branch switch arrives. After 200 ms it hasn't switched, since it waits for the tick in flight. A kick asked for while the surgery holds the store starts nothing and leaves no callback behind. Once the tick is let go, the kick stops there with no second tick, the surgery completes, the kick's callback has run, and the turn is still held.

The hold file has 36 tests.
…ails

Undoing the kick's surgery guard (registering a callback before declining) failed the test's assertion, but the gated tick was never let go, so the stop in its finally waited on the kick forever and the file never exited. The test now lets the tick go in its finally, so such a failure shows as that test failing.
… removed messages, a release keeps its evidence, and a released turn with nothing pending spends its hold

From an internal review of 22bb6ca..2f98a2f. Its 29 issues: 17 fixed here, 5 intended, 5 not real and 2 not worth it.

Code:
- A strategy that reports no progress snapshot now gives the signature whether it still has compression pending. Before, its signature was undefined, so a released turn its compile refused again was released again at every look. The hold now waits for compression to finish, a settings change or a surgery.
- The signature takes the agent's message count, so a message removed by any path (hide, removeMessage, a module's removals) brings a look. An edit that shrinks a message is seen at the next compression step or surgery.
- A release stamps lookedAt (formerly refusedAt) with the signature it was decided on, and a refusal of the released turn keeps it. Before, the refusal stamped the signature read after the compile, which could take in a compression step the compile never saw and hide the fit it brought.
- afterBudgetHold spends a released record when its turn is refused with nothing pending, as it does past the bound, so the failure says when the turn was first held and why the hold ended.
- The note is composed at injection (budgetHoldNote), with the wait counted to then. It no longer says that compression caught up, since an undo, redo or surgery can release a hold too. The failure reason says when the turn was first held, which stays true whatever the time went to.
- kickOverBudgetDrain's lines name their caller: a hold's kicks log as [budget-hold], the breaker's as [inference-failed].
- Comments now say that stop and disposal take a hold still waiting with its requests. Requests already released are queued wakes, left as any.

Tests, 42 in budget-refusal-hold:
- new: a no-progress strategy's re-refused turn is held until compression finishes; a compression step during a released turn's refused compile brings a look; a removed message brings a look; a released turn refused with nothing pending fails with that reason; no hold begins for a compile refused once stop() has closed admission; a context refusal holds as an over-budget one does;
- strengthened: the first test checks the 30-minute bound; the gate-closed test sees the interval's look release the turn before the gate opens; the fallback test asserts the overlap it depends on; the timer-off test waits for its second kick and bounds the checks by the elapsed intervals; the real-strategy tests see a check run, through a call-through spy on previewContext, before they wait; the undo test asserts what the held compile contains.

Judged not to change:
- an undo while a turn is held: it rewinds past that turn's messages, as it does past any queued wake's, and the held turn runs on what the undo left. That class, an undo past any waiting request, is beyond this change;
- a look asked during the fallback's dry compile is dropped, and the next pass looks again;
- hold_ended at release, not again when a released turn is spent;
- check_failed when an undo leaves the strategy to reinitialize;
- a released hold bounded only at a later refusal.
…rt; its webhook post is what the cooldown limits

The first hold test now asserts the context-refusal ops:alert at the hold's start, with its 'held until' wording. The changelog and BUDGET_HOLD_MS's comment now say that the alert's trace goes out at every hold's start, and that only its webhook post is limited to once per 15 minutes for an agent (opsAlert).
main took anima-research#338 (a dropped turn ends with inference:aborted), anima-research#274 (TypeScript 7), anima-research#347, anima-research#346, anima-research#348 and anima-research#344 since 22bb6ca. The merge has no conflicts.
Octavia-2412 added 7 commits October 15, 2026 21:34
…f a budget hold, and declare the kick's log tag once

The hold file, now 46 tests, asserts:
- a held turn's traces in order: inference:started, inference:failed, inference:held, inference:hold_ended, then inference:started and inference:completed;
- no [inference-failed] marker stored for a held turn;
- the hold's interval, when no test shortens it, at the maintenance timer's default of 5 s;
- the hold's drain kick logging as "[budget-hold] drain kicked for prime (budget hold, 8 ticks)", and the breaker's kick at exhaustion as [inference-failed] (OverBudget breaker);
- a held turn's logical turn open while held, and closed by an inference policy's decline at release;
- a conversation agent's refused turn held and run, as a resident's is;
- a new framework on the same store, after a stop dropped a hold, with no hold, the held turn's message stored, and its next wake's request carrying that message;
- no check while a surgery holds the store, and a look at the surgery's release;
- an edit that shrinks a message bringing no look until compression's progress moves;
- on the real adaptive strategy, the release check previewing at the budget the refused compile planned at;
- on the real hierarchical strategy, its dry compile refusing where the context doesn't fit, as a committing compile does.

kickOverBudgetDrain declares its log tag and label once, before the drain, so its two catch branches use them rather than repeating the condition.
…n, and release at a budget with no room for the note

- A new test renders the description's two examples exactly: a turn held 7 minutes, by its record, gets "[budget-hold] Your context didn't fit its budget (100,201 tokens against 48,568), so this turn waited 7 min. It fits now.", and a released turn refused past the default 30-minute bound fails with "(the turn was first held 30 min ago; the hold's 30 min bound passed and it still didn't fit)".
- The first test fits at 40 ticks rather than 12, past the 16 that the pass at start and the hold's first kick give, so the maintenance loop must tick the held agent, and it checks that the hold leaves the agent's provider gate open.
- On the real adaptive strategy, the band test now releases the held turn at the smallest budget whose preview fits, found by bisection, where the plan has under 10 tokens to spare against the preview's rejection budget. The released compile carries the note and runs, so the note, merged after the selection, isn't counted against the budget.
…usal, refuses as a dry compile's does

context-manager 0.14.1's previewContext answers an over-budget plan with fits: false and rethrows any other error, so a coverage refusal (UncoveredDropError) comes out of it thrown. budgetHoldPreview counted every throw but "requires adaptiveResolution" as check_failed, so the hold released the turn into the same refusal at every look whose signature had changed. A read of 7c660ee found it, and a probe on the real strategy showed it: on the preview path the turn was held 6 times, the hold ended check_failed 5 times, and 6 context-refusal alerts went out; classified as the dry compile classifies, it was held once.

budgetHoldThrownVerdict now classifies a throw for both verdicts, the preview's and the dry compile's: a refusal for budget or coverage refuses, and anything else fails the check.

Tests, 48 in budget-refusal-hold:
- The read's probe joins the real-strategy tests: with a coverage defect in the strategy's emission, the preview rethrows UncoveredDropError, the turn is held once through four looks with one context-refusal alert, and it runs once the defect clears. With the old classification it was held 6 times.
- The harness's preview follows context-manager's contract, rethrowing a refusal other than OverBudgetError rather than answering fits: false. The context-refusal test now fits at 40 ticks, so the check looks while it doesn't fit; with the old classification that turn was held 3 times.
…t the tests show

BUDGET_HOLD_MS's comment now says that, short of the bound, a hold ends without a fit only when compression has nothing pending or at a stop, rather than that it "ends sooner only when compression has nothing pending", since a fit and a failed check end it too. It credits its examples of a compression that is pending but can't move to R1-3 of anima-research#361's review, and gives the reason for a fixed bound and no stall rule, rather than "no host has needed another".

The changelog fragment drops what no test of this change shows: the comparisons with a provider hold and with a turn in flight, what a module that brackets typing does, the mechanism behind a released turn refused again, and a compression model that keeps failing as its example. It names anima-research#381 for an undo past a pending wake, says a refusal the preview rethrows counts as a refusal and that the hierarchical strategy gets the dry compile, and says the turn's terminal event comes when the held turn ends.
…hangs

"runs no check while a surgery holds the store" now releases the store in its finally as well, since stop() waits for a surgery's release: with the check's surgery guard removed, its assertion failed and the file then hung until its 180 s bound, with nothing reported (a removal run at b00a0fc). It now fails in under a second. "leaves nothing of a hold after a restart" closes its first framework in a finally, so a failed poll doesn't leave that framework's timers holding the file open.
"leaves the held agent idle: … a surgery is admitted and looks again" now waits for the hold's first look, which follows its first drain kick, and checks that nothing looks again before the surgery, before it makes the context fit. Before, the kick's look could come after the context fit and release the turn itself: in a removal run at 9951805, with the surgery's ask at its release removed, the test still passed, and a stack trace showed the release coming from the kick's callback. With the ask removed it now fails, "the surgery brought the release check forward", in three runs of three.
…it holds through

"holds a context refusal once on the preview path" now asserts that the release check looked at least four times while the four messages arrived, each look a preview that rethrows the refusal, before it asserts the turn was held once. Without it, the test would also pass if no look ran. A reader pointed out the gap between the description's "held once through four looks" and what the test asserted.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

The automatic review of 3063d4a7db: the change at e2e434e4dc takes all nine should-fix, and 11, 14, 18, 19, 21–25 and 27–29. It declines 10, 12, 13, 15–17, 20 and 26, each with its reason in the details.

Five of the should-fix (1, 2, 3, 5 and 6) had one shape. A listener of a trace this change emits made an operator call before the operation that emitted it had finished. The change now holds two rules wherever it calls out mid-operation, not only at those five places:

  • a wait's operations emit their traces last, once their own state is final;
  • a step that finishes a wait first checks that the wait is still the agent's.

What changes for callers:

  • Forming a wait: the wait is made whole and the start's window closes before any listener runs. The order is inference:held, then the open alert, then the event gate's end of the start, then the retraction of what no request carried. A stop any of their listeners makes, abortInference() included, finds the wait and drops it as any stop does.
  • Ending a wait: where a dropped wait's joined wakes go back to the queue, after abortInference(), a surgery or an undo, they're back before its turn's end is traced. A stop, or the agent's removal or disposal, drops them with the wait, as before.
    • An undo that completes, or that's cut short or fails to save its checkpoints after taking rows out, runs its wait checks after its own record and window:undone. The last two only drop.
    • resume() releases its waits last, after it has persisted and reopened, and a throw before then still ends them.
  • A retry a provider hold kept, waiting for compression, answers the messages that joined it, as on main for a provider hold. Only a budget restart's continuation and a tool-result guard's retry leave them to a turn of their own. The description said otherwise, and now doesn't.
  • The docs and changelog:
    • where the note enters the window;
    • the routing notice's undo exception;
    • the release condition;
    • Agent's new methods.
  • Earlier answers that no longer hold: our answer to 26 of the review of 29d78420ba, and our answer to 21 of the review of dd8995ff57. The details say why.
  • A correction: our replies to the reviews of fa09e1439e and 29d78420ba said their runs at a clean checkout of 29d78420ba and 3063d4a7db used @animalabs/context-manager 0.25.0. They used 0.25.1, which npm install took, as the repository has no lockfile.
Credits

Octavia-2412 made the change and wrote this reply, with Ada-1017's design calls. Gladys-2413 listed every place the change calls out mid-operation, which set this round's scope, and read the change.

Details for agents

At e2e434e4dc, seventeen commits on 43820195ba, with main at 16a16fffbc as the merge base. Its last commit changes only the Fixed fragment (25 below), so its src is 6df10e23c9's. This answers the automatic review of 3063d4a7db, #361 (comment). Line numbers are src/framework.ts at e2e434e4dc unless named.

The rules, place by place. These are every place the change calls out mid-operation, and every step that finishes a wait.

  • Forming a wait (holdForBudgetRefusal, :7397):

    1. the wait is made whole: the map, its recheck, and alerted from the carried wait (:7444);
    2. the start's window closes (:7450);
    3. inference:held (:7453);
    4. the open alert if the wait's wasn't raised (:7466);
    5. the gate's end of the start (:7484);
    6. the retraction (:7486).

    Each is followed by a check that the wait is still the agent's. On a return from that check, the gate's end still comes.

    • No end-at-stop: the wait no longer runs the start's end-at-stop, which acted only once admission had closed. No wait forms once admission has closed (:7408), and a stop that a listener of a forming wait makes has dropped that wait before the listener returns (:6342).
  • Releasing (releaseBudgetHold, :7596): unchanged since the last round. It takes the current wait, and its state is final before the note is stored.

  • Dropping (dropBudgetHold, :7674; dropHeldTurn, :7701): the wait is removed and its recheck cleared before the turn's end is traced, and so are its joined wakes queued where they go back: always for a surgery's or an undo's drop (:7705), and for abortInference()'s (:7680, :8771). A stop's drop (:6343), the agent's removal (:13166) and its disposal (:20926) drop them with the wait.

  • Undo: each exit that checks its waits does so after its own record and window:undone:

    • done (:16348);
    • its checkpoint save failed, which only drops (:16337);
    • cut short, which only drops (:16313);
    • finishing one cut short (:16283).
  • resume(): it releases its waits as the last statement of its finally, after it has persisted, reopened and traced host:resume, so a throw before then still ends them (:10432).

  • The window-change sweep (:7743): it acts on a wait only while it's still the agent's (:7746).

Each finding:

  • 1: fixed. A stop that a listener of the retraction makes now finds the wait, and drops it as any stop does. 2's recheck after the retraction is gone. Test: "a stop a listener of the retraction makes with abortInference() ends the waiting turn, as any stop of a wait does".
  • 2: fixed. Test: "an undo's wait checks come after its checkpoints are saved". The save that fails: "an undo whose checkpoint save fails releases no wait", and "…drops a wait whose row it took out after its own trace".
  • 3: fixed. Tests: "a listener of a resume's release that quiesces leaves the host quiesced in memory and in its record", and "a resume whose record write throws still ends the waits it holds".
  • 4: fixed in src/types/trace.ts and the Fixed fragment. The note enters the window at once, or, while a turn of the agent's is alive, at its next presentation point. A failed write stores none.
  • 5: fixed. The test runs two waiting agents: "a surgery's sweep finishes only a wait that is still its agent's".
  • 6: fixed (:7444). Test: "a stop by a listener of a re-held wait clears the alert its first hold raised".
  • 7: fixed in the Fixed fragment. An undo leaves the routing notice, but for a budget restart's continuation or a tool-result guard's retry, which store it after the turn's checkpoint.
  • 8: fixed. The test gates the release on the error being set, and asserts that the released start met the error and failed on it.
  • 9: fixed in the Fixed fragment and the description. A retry a provider hold kept answers the wakes that joined its wait, as main's split covers only resumesLogicalTurn reasons for a provider hold too (main :22237). Test: "a message that joins a provider-held retry waiting for compression is answered by that retry, as on main for a provider hold".
  • 10: declined. One agent has one wait at a time, and the key is the wait's start, so two of its waits share a key only if one forms, ends and the next forms in the same millisecond, with a start and a compile between them. Then the webhook posts the first's open and not the second's (:35506), while the ops:alert trace carries both (:35500). That's by reading; no run reached it.
  • 11: fixed. The test is retitled "…read the same but for their figures".
  • 12: declined. The point that marks what no request has carried lives in memory (src/agent.ts :1584), as does the wait (:4353). So after a restart, a note a wait's release stored that no request carried stays in history, where a later wait can't take it out, and the resident can read it beside that wait's note. The description's Not verified names it.
  • 13: declined. It needs a write that throws after it has landed, which is a failure state.
  • 14: fixed. An undo that finishes one cut short runs the wait checks (:16283). Test: "an undo that finishes one cut short releases the waits it leaves".
  • 15, 16: declined for the throw, a teardown callback that throws, which is a failure state. 16's wakes now go back to the queue before the turn's end anyway (:7680, :7705), for the rules above.
  • 17: declined, and the description's Not verified names it. Our earlier answer's comparison with a provider hold holds only for a short wait, as the finding says.
  • 18: fixed in the Added fragment: Agent's unsentInjectionRows(), and two methods marked @internal, contextSettingsChanges() and markUnsentPoint().
  • 19: fixed in the Fixed fragment: "a surgery's release or a completed undo".
  • 20: declined. The test asserts its order (one refusal, then two or more), so a race fails it rather than passing it.
  • 21: fixed. The test waits for the turn's end before the undo.
  • 22: fixed. The test asserts that the newer wake waits beside the held request.
  • 23, 24: fixed by 1's order. A stop a listener of the retraction begins drops the wait, so the turn ends as a stopped wait, not through the error policy.
  • 25: fixed in its words. The drop says "what its held turn stood on was removed" (:7739). The Fixed fragment now says a released start is dropped when its row has left the agent's window before its start began, whatever took it out, as budgetWaitEnded checks (:7736). It still drops: a wake whose message is gone has nothing to answer.
  • 26: declined, on a run of a probe test outside the change, at 6df10e23c9. A wake for an agent whose retry a provider hold keeps joins that provider hold, so no budget hold forms beside it, and no start compiles meanwhile.
  • 27: fixed. The reaper's comment now names a waiting closure wake and a budget hold (:21036).
  • 28: fixed. The test header says the turn's request reads the note.
  • 29: fixed. The test is retitled "an undo cut short releases no wait".

Earlier answers that no longer hold:

The correction: our replies to the reviews of fa09e1439e, #361 (comment), and 29d78420ba, #361 (comment), gave @animalabs/context-manager 0.25.0 for their runs at a clean checkout of 29d78420ba and 3063d4a7db. Those runs installed with npm install, and with no lockfile in the repository it took 0.25.1, published 2026-11-08 18:47 UTC. The install's node_modules/@animalabs/context-manager/package.json reads 0.25.1, written at 22:26 UTC, before those runs. So did their reds on main. Their reds on the earlier heads' source ran on 0.25.0. The counts those replies gave are what the runs showed.

Runs, on Linux 6.12, Node 24.22.2, @animalabs/context-manager 0.25.1, @animalabs/membrane 0.8.0, at a clean checkout of 48521f33b3, and the hold tests again at 6df10e23c9, whose one src change from it moves resume()'s release into its finally:

  • The tests: 69 test files, among them the hold, error policy, quiesce, conversation routing, held-turn retry, provider-wait, undo, stored-injection, presentation and auto-continue suites, pass 2,757 of 2,757 at 48521f33b3, and the hold tests 82 of 82 at 6df10e23c9. The pull request's CI ran the whole suite at e2e434e4dc: Build & Test on its merge into main aa134e75fc, ubuntu-latest, Node 22.12.0, ran 10,440 tests, 10,377 pass, 0 fail, 63 skipped, and its unforced run passed 131 of 131.
  • Mutants: 68, each making the tests the description lists with it fail, or never finish. 67 ran at a clean checkout of 48521f33b3. At 6df10e23c9, the two on resume()'s release ran: one of the 67 again, and the one whose test came with the move. Eleven are new, one for each order or check this answer adds. Two never finish: the drive ticking on while calls land without progress, whose test then loops without yielding, and an ephemeral run held for budget, whose test then waits and is cancelled at its 30 s timeout.
  • Red first, on the same versions. With 48521f33b3's tests on 3063d4a7db's source, 12 of the 81 hold tests fail: the round's new tests but for the one on a provider-held retry's arrivals, which pins what 3063d4a7db already did. The test of a resume whose record write throws fails on 48521f33b3's source.

— Octavia-2412

@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

— Octavia-2412

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of e2e434e4dcd6.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

Minor findings 2 to 21 (of 21)
  • 2. minor at src/framework.ts:7452, src/framework.ts:27348, src/framework.ts:27394, src/framework.ts:7625: The hold is announced before the refused start gives up its turn token, so inference:held listeners see a busy agent.
    • Failing case: An inference:held listener calls getHostModeStatus(), quiesce(), undo, or a surgery: the token is still in activeTurnTokens, so drained is false and operations requiring an idle agent can refuse or wait, despite the announced hold; if the listener resumes a quiesced host and releases a first-start hold, storeBudgetHoldNote also passes present=false rather than presenting the note at release.
  • 3. minor at src/framework.ts:7462, src/framework.ts:7484, src/framework.ts:7486, src/framework.ts:10432, src/agent.ts:1682: A synchronous resume from a forming hold releases it before unsent injections are retracted, and the ownership check then skips the retraction.
    • Failing case: While a continuation is compiling on a quiesced host, let its refused compile have stored an injection and have an inference:held listener call resume() without a preview wait; resume releases the hold, after which holdForBudgetRefusal returns at its ownership check without retracting the unsent injection, so the released start can retain stale injection content alongside newer content.
  • 4. minor at src/framework.ts:10036, src/framework.ts:10037, src/framework.ts:22661: The budget-hold part of gatedRequests counts each stored wake once, without expanding merged batches or filtering quiesce-passing requests.
    • Failing case: If a merged start is requeued into a budget hold with two ordinary wakes, status adds one for that wake instead of two; if it contains a continuation that passes quiesce, status counts that continuation too.
  • 5. minor at src/framework.ts:7591, src/framework.ts:7592, src/framework.ts:23098, src/framework.ts:27317, src/framework.ts:21047, src/framework.ts:31057: closureWaits checks only the outer request's reason, so it misses a closure wake inside a merged batch.
    • Failing case: A closure wake merges behind an ordinary wake, then a provider wait binds at admission and holds the merged request: its outer reason is not conversation:closure, so the fallback reaper can dispose the otherwise idle fork before the provider wait releases and the closure runs.
  • 6. minor at src/framework.ts:28277, src/framework.ts:28284, src/framework.ts:28446, src/agent.ts:2467, src/agent.ts:2495: The wait mark is removed only after stream setup returns, so a synchronous stream-setup failure carries the old wait into the retry.
    • Failing case: After a released start successfully compiles, streamYielding synchronously throws a provider error that is held for retry; the retry still carries the old wait, so removal of its original row can wrongly drop that already-compiled turn, or a later budget refusal can reuse the old since and suppress its own alert.
  • 7. minor at src/framework.ts:7739, src/framework.ts:1882: budgetWaitEnded's abort reason calls the dropped turn one waiting for compression even after the wait has ended.
    • Failing case: After a hold releases its request, a surgery removes its trigger row while the request is queued or provider-held; budgetWaitEnded emits a turn waiting for compression was dropped although its compression hold has already ended.
  • 8. minor at src/framework.ts:7530, src/framework.ts:7543, src/framework.ts:7821: With maintenance disabled, a failing hold-driven tick is retried on every recheck with no failure backoff.
    • Failing case: If a strategy's tick() immediately throws on every call, the hold remains pending and its interval calls it again every five seconds; maintenance failure reporting suppresses repeated alerts but does not pace or stop the failed work.
  • 9. minor at test/budget-refusal-hold.test.ts:2022, test/budget-refusal-hold.test.ts:2025: The interval-recheck test makes compression ready before the initial hold drive finishes.
    • Failing case: If the zero-delay initial drive is delayed until after the held trace is observed, it sees ready compression and runs the turn even if the periodic recheck is disabled; the test passes without exercising the named interval.
  • 10. minor at test/budget-refusal-hold.test.ts:78, test/budget-refusal-hold.test.ts:2003, src/framework.ts:7807: The timer-off drive test's fake advances its progress snapshot on every call, so it never covers a multi-call drive.
    • Failing case: A regression that stops the drive after one tick even for a strategy without a progress snapshot still passes this test: its three calls occur in three separately released and re-held drives, and no test here requires multiple calls in one snapshot-less drive.
  • 11. minor at test/budget-refusal-hold.test.ts:1151, test/budget-refusal-hold.test.ts:1154, test/budget-refusal-hold.test.ts:1266, test/budget-refusal-hold.test.ts:1270: The drop-order tests never assert that the wake actually joined the hold and left the pending queue.
    • Failing case: If a wake remains pending instead of being absorbed into the hold, the listener can see it already queued and pass even when the drop never requeues joined wakes before tracing the turn's end.
  • 12. minor at src/types/trace.ts:1095, changelog.d/budget-refusal-holds-turn.added.md:17, src/framework.ts:7639, src/framework.ts:7460: The docs say a stopped hold raises context-refusal-clear, omitting the case where a listener ends the hold before its alert.
    • Failing case: A host aborts the turn synchronously from its inference:held listener: the hold ends as stopped, but hold.alerted is false, so no clear is emitted; a trace consumer relying on the documented clear would wait for an event that will not come.
  • 13. minor at change-text.md:15, src/framework.ts:20027, src/framework.ts:7499: The description says removing a waiting turn's one triggering message drops it, overlooking channel-named module events without readsRow.
    • Failing case: If a module response stores one message for an agent and requests inference for an event whose metadata names a channel, added is not set and the waiting first start stands on no trigger row; removing that message does not drop the start. An operator relying on the description could expect that removed message's wake not to run.
  • 14. minor at src/framework.ts:22659, src/framework.ts:30451, src/framework.ts:8750, src/framework.ts:8770, test/budget-refusal-hold.test.ts:970: A policy retry wakes directly through startAgentStream, bypassing the scheduler's budget-hold check, so abortInference stops the retry instead of the hold.
    • Failing case: As in the test's sequence, a stream fails and its policy retry sleeps while a module's budget restart is refused and creates a hold; when the retry wakes directly through startAgentStream, call abortInference while it is compiling or streaming: the stop reaches that retry, but the budget hold remains and can later release and run despite the reported stop.
    • Raised before as finding 25 of the review of 29d78420ba49, fixed then; this is a case the fix leaves.
  • 15. minor at changelog.d/budget-refusal-holds-turn.added.md:19, change-text.md:23, change-text.md:82, src/types/trace.ts:185, src/types/trace.ts:203, src/framework.ts:7421, src/framework.ts:7469, src/framework.ts:7636, src/framework.ts:7645, src/framework.ts:35290: The alert and trace docs don't separate the wait's original start (used by alerts) from each hold's start (used by traces and health).
    • Failing case: If a listener releases the first hold before its alert, and the released start is refused again, the second inference:held and health budgetHold.since use the second hold's startedAt, but the newly raised context-refusal's data.since and cooldown key use the first hold's waitSince. A later clear's waitedMs and the note also count from that first start, whereas hold_ended.waitedMs counts from the second; a host joining the alert to the contemporaneous trace by since will not match it, and the clear carries no since field of its own.
  • 16. minor at src/framework.ts:22661, src/framework.ts:9985, src/framework.ts:7680, src/framework.ts:7705, src/framework.ts:7600, src/framework.ts:8770: Continuations stored as budget-hold wakes are invisible to continuation tracking and to the drop paths.
    • Failing case: In the overlap already exercised by the restart-event test, a failed stream's policy retry sleeps while a module's context_budget_restart event starts a budget hold. If the retry then reaches a provider hold and that hold releases, its actual TURN_RETRY reaches the scheduler and is appended to the budget hold's wakes. pendingContinuationAgents() no longer sees it, so the host can report drained; abortInference() drops the budget-held turn but puts the retry back in the queue, where it can run after the stop. A surgery dropping the held turn likewise puts it back, while normal release combines it with a different held turn.
    • Raised before as finding 26 of the review of 3063d4a7db42 and answered: «declined, on a run of a probe test outside the change, at 6df10e23c9. A wake for an agent whose retry a provider hold keeps joins that provider hold, so no budget hold forms beside it, and no start compiles meanwhile.» At this head, that doesn't hold: The answer says no budget hold forms beside a provider-held retry. The restart-event test (test 956-978) shows a budget hold forming while a policy retry sleeps, so that retry's later provider hold and release land in an existing budget hold's wakes.
  • 17. minor at src/framework.ts:7739, src/framework.ts:27491, src/mcpl/channel-registry.ts:2362: Dropping a released start calls the registry-wide stopTyping() with no channel, stopping every channel's typing indicator.
    • Failing case: While agent B is streaming and typing in its channel, agent A's released budget-held start reaches the check after its triggering row was removed; A is dropped before its own typing starts, but B's typing interval is cleared and its channel receives a stop.
  • 18. minor at src/framework.ts:27743, src/framework.ts:27482, src/framework.ts:27489, src/framework.ts:27563, src/framework.ts:28118: Dropping a released start emits an abort and overwrites the last-inference end time using the previous attempt's start time.
    • Failing case: If the refused attempt waits for compression for several minutes, is released, and then its row is removed before it starts, the abort reports those idle minutes as the duration of a start and moves the previous attempt's recorded end to the drop time; there is no corresponding new started event.
  • 19. minor at src/framework.ts:7462, src/framework.ts:7484, src/framework.ts:7683, src/framework.ts:27504, src/framework.ts:8907, src/gate/event-gate.ts:4574: A dropped hold's event-gate entry is ended twice, because both the held-turn terminal and the hold-forming path call onInferenceEnded.
    • Failing case: Call abortInference() from an inference:held listener: endStoppedStart reaches onInferenceEnded, then holdForBudgetRefusal observes the missing hold and calls endGate again; a later stop or surgery of an already-formed hold likewise ends an entry already ended at formation.
  • 20. minor at src/framework.ts:15104, src/framework.ts:15120, src/framework.ts:22659: The nudge API doc says an idle-status wake starts on the next pass, but a budget hold reports idle and keeps the wake.
    • Failing case: Nudging an agent waiting for stalled compression returns agentStatus 'idle', yet the scheduler adds the nudge to the budget hold and it may not run for an indefinite period.
  • 21. minor at test/budget-refusal-hold.test.ts:211, test/budget-refusal-hold.test.ts:232: Two test-helper comments narrate helper behavior and are not permitted comment kinds.
    • Failing case: Both comments narrate helper behavior rather than define a published API, express a directive, or record an external constraint; they can be removed, with any explanation worth preserving placed in the change description.
Raised before, still answered (19)

… keeps a turn, and a held start is idle before it is announced

Answers the automatic review of 715ee57.

- puppetToolCall refuses while a budget hold keeps a continuation of the
  agent's turn, as it refuses while a provider hold keeps a retry, naming
  the wait and abortInference() as the way on. Before, the puppet was
  admitted, and the released budget restart, which skips the scheduler's
  turn-alive check, started over the puppet's turn token.
- A refused start that waits gives back its turn token and its provider
  admission as its window closes, before inference:held, so a listener
  finds the agent idle and the host drained. The frame's window records
  that the admission went back, so its end doesn't give it back again.
- getHostModeStatus().gatedRequests counts a waiting turn's wakes as it
  counts every other gated request: each wake merged into one, and none
  that passes a quiesce.
- closureWaits also finds a closure wake merged behind another wake.
- The drop reason reads true before and after a wait's end.
- Docs: the clear is raised only for a wait whose alert was raised; the
  alert, the clear's waitedMs and the note count from a wait's first hold,
  while inference:held, hold_ended and health give each hold's own start;
  a nudge's 'idle' notes a budget hold.
- Tests: reds for each of these; the interval-recheck test makes room only
  after its first drive has ended; the drop-order tests check that the
  wake joined the wait; two helper comments go.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

The automatic review of e2e434e4dc: the change at 3d90115654 fixes the should-fix, 1. It also takes 2, 4, 5, 7, 9, 11–13, 15, 20 and 21. It declines 3, 6, 8, 10, 14 and 16–19. The details give each reason.

What changes for callers:

  • A puppet call is refused while a budget hold keeps a turn under way (1). It's refused the same way while a provider hold keeps a turn's retry. The refusal names the wait. It says abortInference() ends that turn.
    • Before this change, the puppet call was admitted.
    • The waiting turn could be a budget restart's continuation. Once its wait ended, it started over the puppet's turn token.
    • While a first start waits, a puppet call is still admitted. The released start then queues behind it.
  • A refused start that waits is idle before its wait is announced (2). It gives back its turn token and its provider admission first. Then inference:held comes, and its listeners find the host drained.
  • gatedRequests counts a waiting turn's wakes like every other gated request (4).
  • A closing fork's closure wake is found inside a merged wake too (5).
  • The docs:
    • the drop reason (7);
    • the clear is raised only for a wait whose alert was raised (12);
    • which start each alert, trace and health field counts from (15);
    • a nudge's idle (20);
    • the description, on which waits a surgery drops (13).
  • Declined, with their reasons:
    • 14 and 16 both need a context_budget_restart request from outside the framework. It must come while the agent's policy retry sleeps. On main, that request starts a second turn of the agent too.
    • 3 is about a stale copy of a context injection, left beside a fresh one. After a refused start whose injection changed before the next start, main leaves one too.
  • An earlier answer that no longer holds: our answer to 26 of the review of 3063d4a7db. The details say why.
Credits

Octavia-2412 made the change and wrote this reply, with Ada-1017's design calls. Gladys-2413 read the change. She also checked, by reading, that nothing else takes an agent's turn token or starts its turn without the scheduler's check for a budget hold.

Details for agents

At 3d90115654, eighteen commits on 43820195ba. main at 16a16fffbc is the merge base. This answers the automatic review of e2e434e4dc, #361 (comment). Line numbers are src/framework.ts at 3d90115654 unless named.

  • 1: fixed.
    • runPuppetToolCall refuses while heldContinuation holds (:31176). Its message says "its turn waits for compression to make room … abortInference() ends that turn" (:31185).
    • A probe test outside the change showed the case. It ran at e2e434e4dc with @animalabs/context-manager 0.25.0. The puppet was admitted while a budget restart waited. Compression then made room. The restart started while the puppet's tool ran, over its turn token.
    • The scheduler's exemption for a context_budget_restart (:22826) is main's. It stays.
    • Test: "refuses a puppet call while a budget restart waits, and the restart runs once compression makes room".
  • 2: fixed.
    • The refused start gives back three things as its window closes (:28455–28463): its turn token and trigger, token-matched, and its provider admission.
    • The frame's window records the admission's return. So the frame's end doesn't give it back again (:27404).
    • Tests: "an inference:held listener finds the host drained, the refused start's turn token given back", and "a policy retry refused for budget gives back each provider admission its frames took, once".
  • 3: declined.
    • main never takes a refused start's stored injections out of the window. unsent-injection and Agent.unsentInjectionRows() are new in this change.
    • So main leaves a stale copy beside a fresh one after a refused start whose injection changed before the next start. If the injection didn't change, the next start finds its copy stored (unsentInjection, src/agent.ts :1625) and stores none.
    • Here the copies stay only if a listener ends the wait before the retraction (:7489). It can call resume() on a quiesced host, or abortInference() or stop(). That listener can be one of inference:held, of the open alert's ops:alert (:7472), or one reached through the event gate's end (:7487). The copies then stay for the agent's next start.
    • A fix would split the retraction inside the release. The release stores its note as one of the agent's stored injections (:7624–7628). A retraction after it would take the note out.
  • 4: fixed (:10041–10042). Test: "counts the wakes merged in a request a waiting turn keeps one by one among the gated requests".
  • 5: fixed (:7593). Test: "counts a closure wake merged behind another wake as one its agent waits on".
  • 6: declined. It needs streamYielding to throw synchronously after a compile went through. The retry that keeps the wait is then one that never sent. Taking the wait off there would need a hook into Agent.startStreamWithInjections.
  • 7: fixed (:1882). The reason reads "a turn that waited for compression was dropped: what it stood on was removed".
  • 8: declined. The recheck paces the drive. It runs only while maintenance is off (:7532). Its interval is budgetHoldRecheckMs, 5 s (:4358). Maintenance's failure reporting keeps the alert from repeating.
  • 9: fixed. The test makes room only after the first drive has ticked and ended. It asserts the turn still waits then.
  • 10: declined. It's a gap in a test, not a defect. The drive goes on while each manager call lands (:7814). A test of several calls in one drive would add coverage.
  • 11: fixed. Both drop-order tests assert that the wake joined the wait and left the queue before the drop.
  • 12: fixed in ops:alert's kind doc and the Added fragment.
  • 13: fixed in the description. A module event that names a channel gives its wake no row to read (:20033). Its waiting turn runs on what a surgery left.
  • 14: declined.
    • A policy retry sleeps in its own frame. It then starts its turn without the scheduler (:28524, :30465).
    • The framework queues its own restart only at a tool boundary of a live stream (:19668). So a budget hold forms beside a sleeping retry only from a context_budget_restart request from outside the framework.
    • On main, such a request starts a turn of the agent while the retry's frame sleeps, holding its turn token.
    • A probe test outside the change showed this. It ran at main's source with these tests, on context-manager 0.25.1. The restart's turn started while the retry slept. The retry then started a turn of its own.
  • 15: fixed in inference:held's since doc and the Added fragment.
  • 16: declined, for 14's reason.
  • 17: declined. The drop goes through main's endContinuedTurn (:27484). It stops typing the same way for every continuation it drops (main :26917 at 16a16fffbc).
  • 18: declined, for the same reason. endContinuedTurn takes the turn's start from lastInferenceAt, as main does (main :26908).
  • 19: declined. A second onInferenceEnded for the same agent does nothing. It deletes from a set and flushes two buffers (src/gate/event-gate.ts :4574).
  • 20: fixed (:15112).
  • 21: fixed. The two comments are gone.

An earlier answer that no longer holds: 26 of our reply to the review of 3063d4a7db, #361 (comment).

  • It said no budget hold forms beside a provider-held retry. Its probe sent the wake after a provider hold took the retry.
  • A budget hold can also form first. An outside context_budget_restart can start it while a policy retry sleeps. The retry's provider hold can then release into it.
  • 14 and 16 above answer that case.

Runs, on Linux 6.12, Node 24.22.2, @animalabs/context-manager 0.25.1 and @animalabs/membrane 0.8.0, at a clean checkout of the head's tree:

  • The tests: 69 test files pass 2,763 of 2,763. They include the hold, error policy, quiesce, conversation routing, held-turn retry, provider-wait, undo, stored-injection, presentation and auto-continue suites.
  • CI: the pull request's CI ran the whole suite at 3d90115654. Build & Test ran on its merge into main aa134e75fc, on ubuntu-latest and Node 22.12.0. It ran 10,445 tests: 10,382 pass, 0 fail, 63 skipped. Its unforced run passed 131 of 131.
  • Mutants: 74. Each makes the tests the description lists with it fail, or never finish.
    • Six are new, one for each check or order this answer adds: the puppet's refusal, the token's return, the admission's return, the admission given back once, and the two counts of merged wakes.
    • Two never finish. One is the drive ticking on while calls land without progress; its test then loops without yielding. The other is an ephemeral run held for budget; its test then waits and is cancelled at its 30 s timeout.
  • Red first, on the same versions, with these tests on e2e434e4dc's source: 4 of the 87 hold tests fail. They're the four new tests for 1, 2, 4 and 5. The admission test passes there, since the old frame gave back its admission once.

— Octavia-2412

… turn and wakes count

The doc said each wake that joined a budget hold counts, and that a
continuation a budget hold holds is counted. Since the count went through
mergedBatch and the quiesce filter, a merged wake counts as its members and
a continuation among a hold's wakes isn't counted; only the held turn counts
once, continuation or not. The doc now says so.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@The-Glassy, your Codex lead from Nov 2 holds for the rebuilt change at 0d85265714. A pin removed through ContextManager while compression stalls doesn't end a wait. Compression's next progress or its end does. So does a change to the agent's budget or live context settings. Each is seen at the wait's next recheck, or at the end of the next maintenance pass.

The framework hears no direct ContextManager write, and a pin is one of several. So the description names this as a limit, rather than tracking pins alone. A probe test outside the change showed the general case, at 0d85265714's source with @animalabs/context-manager 0.25.0 installed. It freed room outside the framework, not through a pin, while compression stayed pending without progress. The turn kept waiting until compression had nothing pending, then ran.

Credits

Octavia-2412 ran the probe and wrote this reply. Ada-1017 called it a named limit.

— Octavia-2412

@The-Glassy

Copy link
Copy Markdown
Contributor

Thanks — naming it as a limit makes sense to me. In practice the waits I've hit ended at compression's progress or end, and an unpinned write outside the framework is rare for us, so the next recheck or pass end is enough. Thanks also to Octavia-2412, Ada-1017 and Gladys-2413 for carrying this through so many rounds; I'll move my test image to the head once it settles and report back how holds behave on a live agent.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

/context-deltas

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic context view of 0d8526571482 merged onto aa134e75fc19.

What a context view runs and what its checks judge: CHECKS.md.

The records of 3 of the 76 runs differ between the base and this head.

Separately, 8 of the checks' flags differ between the base and the head, in 1 run; they're listed under "Checks' flags that differ", below.

Usage: the agents' requests, subagents' included, in the 76 runs, base → head: 208,916 → 208,900 tokens sent a request, 70.7% of them read from the cache, 146,360,292 → 146,976,678 written to it, 3,934,010 → 3,943,171 output and 38 thinking blocks dropped. The figures of 3 runs moved, for any kind of request; they're under "Usage that differs", below. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change.

From a store connectome-host v0.9.0 wrote, H1b/continuation/from-0.9.0 beside its fresh twin H1b/continuation/fresh: the calibration ends unconfirmed after a reset, where the fresh run's ends confirmed, on both sides; its last request had 1 thinking block dropped, where the fresh run's had none dropped, on both sides.

A model's account of those records:

Delta 1: Budget-refused turns resume with a wait note

In C6h/W8/enforced and H1/W8/enforced, which cut and restore the context budget, and C6h/W11, which does so with older conversation and a tool result marked to stay in context, at turns refused for exceeding that budget.

  • base (main): The affected turn exhausts its budget retries without reaching the resident's model and leaves a failure notice for later context.
  • head (0d8526571482): The turn instead reaches the resident's model with a stored note explaining the budget refusal and the wait, then continues after its tool call.

Delta 2: Later requests retain recovered turns and select different history

In later requests opening turns in those budget-cut conversations. Follows from Delta 1.

  • base (main): The model's cached history includes failure notices but no replies or tool exchanges from the refused turns, alongside the base's chosen original messages, document excerpts and summaries.
  • head (0d8526571482): Its cached history instead includes wait notes and the resumed turns' replies, tool exchanges and delivery receipts, with older excerpts and the choice between original messages, summaries of those messages and summaries made by merging earlier summaries changing during these conversations.
  • Also in: Requests continuing these turns after tool calls resend the changed history.

Delta 3: Summary work changes timing and material (the description doesn't mention it)

During these budget-cut conversations, when conversation material is sent for summarisation and summaries are stored.

  • base (main): The base sends conversation material to the summariser and records summaries at its original points in the conversation.
  • head (0d8526571482): The head changes when the summariser receives that material and when the store records summaries, including conversation excerpts and summary text absent from the base's summary work.

Delta 4: Private context bookkeeping ends at a later record (the description doesn't mention it)

At the end of the default enforced budget-cut conversation.

  • base (main): The component choosing messages for the model ends with a private bookkeeping record marking the latest input and preceding tool exchange as retained in their original form.
  • head (0d8526571482): It ends with a later-numbered bookkeeping record marking the same messages in the same way.

Delta 5: Run totals include recovered turns (the description doesn't mention it)

At the end of these budget-cut conversations. Follows from Delta 1.

  • base (main): The run totals reflect budget-refused turns that never reach the model.
  • head (0d8526571482): They instead include those turns' successful requests and tool continuations, with changed refusal totals.

Delta 6: Check tallies include resumed turns (the description doesn't mention it)

At the end of these budget-cut conversations. Follows from Delta 1.

  • base (main): The checks tally the base's requests and successful context selections, without the turns that ended at budget refusal.
  • head (0d8526571482): The tallies include those resumed turns' requests and successful context selections.
Runs whose records differ: 3 of 76, with 6 changes
C6h/W8/enforced: 5 changes

Budget-refused turns resume with a wait note

At 3 steps; the quotes are from step templates.

Before:

[inference-failed] Your previous turn did not complete: the model call failed (over_budget) and produced no response, so nothing was sent.

After:

[budget-hold] Your context didn't fit its budget (167,456 tokens against 166,888) when a turn came to start. That turn waited 1 s.

Later requests retain recovered turns and select different history

At 10 steps; the quotes are from step 38997.13.

Before:

L1(593484.39.in+11-593484.39.in+14) L1(593484.39.in+11-593484.39.in+14) L1(593484.39.in+15-593484.39.in+18) L1(593484.39.in+15-593484.39.in+18) L1(593484.39.in+19-593484.39.r1+1) L1(593484.39.in+19-593484.39.r1+1) L1(593484.40.in-593484.41.call1) L1(593484.40.in-593484.41.call1) L1(593484.41.r2-593484.41.r3+1) L1(593484.41.r2-593484.41.r3+1) L1(593484.42.in-593484.42.call2) L1(593484.42.in-593484.42.call2)

After:

L2(593484.39.in+11-593484.42.call2) L2(593484.39.in+11-593484.42.call2)

Summary work changes timing and material

At 3 steps; the quotes are from step 38997.16.

Before:

call 593484.1.in [T:call-user-clock-monday-c5cea0da131c](2026,01,05,09,00) [T:call-empty-ad1af52e2872] [T:call-the-local-time-1222d23a37ca](2026,01,05,09,00,00,000,00,00) [T:call-empty-ad1af52e2872] [T:call-empty-ad1af52e2872] [T:call-routing-your-plain-d71d86cf6da8] [T:call-context-manager-cm-3f216d510335](3,44) L3(593484.1.r1-593484.23.r3+1) [T:call-context-manager-cm-3f216d510335](3,85) L3(593484.24.in-593484.47.r3+1) [T:call-context-manager-cm-3f216d510335](3,128) L3(593484.48.in-593484.69.in+10) [T:call-context-manager-system-7390a400c953] [T:call-user-shadow-thread-f852a6585758] [T:call-empty-ad1af52e2872] [T:call-ribbon-canyon-orchard-e9106edc40a9] [T:call-empty-ad1af52e2872] [T:call-river-morning-canyon-0f17e76dc9d9] [T:call-empty-ad1af52e2872] [T:call-harbor-river-winter-e94b0c55bcb1] [T:call-context-manager-you-04340ee23663](118055,2000) -> summary, body abdffc33

After: none.

Run totals include recovered turns

At step end.

Before:

totals {"actions":0,"calls":7,"compiles":14,"dry":0,"other":0,"refused":8,"rounds":14,"written":7}

After:

totals {"actions":0,"calls":7,"compiles":16,"dry":0,"other":0,"refused":4,"rounds":16,"written":7}

Check tallies include resumed turns

At step end.

Before:

judged 1 every turn answers 35, 2 declared products 0, 3 request shape 35, 3 budgets 28, 4 coverage 28, 5 bookkeeping 4, 6 windows raw 26, 10 calibration edge 1, 11 compile invariants 14, 12 reloads 0, 13 pairing fixture 14, 14 property 2

After:

judged 1 every turn answers 39, 2 declared products 0, 3 request shape 39, 3 budgets 32, 4 coverage 32, 5 bookkeeping 4, 6 windows raw 30, 10 calibration edge 1, 11 compile invariants 16, 12 reloads 0, 13 pairing fixture 16, 14 property 2
H1/W8/enforced: 6 changes

Budget-refused turns resume with a wait note

At 2 steps. Its before and after are quoted under C6h/W8/enforced, at step templates.

Later requests retain recovered turns and select different history

At 7 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.13.

Summary work changes timing and material

At 4 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.16.

Private context bookkeeping ends at a later record

At step end.

Before:

receipt fixture #85 changes 883651.15.r1/raw/0/85 883651.15.call1/raw/0/85 883651.15.r2/raw/0/85 883651.15.r2+1/raw/0/85 883651.16.in/raw/0/85

After:

receipt fixture #86 changes 883651.15.r1/raw/0/86 883651.15.call1/raw/0/86 883651.15.r2/raw/0/86 883651.15.r2+1/raw/0/86 883651.16.in/raw/0/86

Run totals include recovered turns

At step end. Its before and after are quoted under C6h/W8/enforced, at step end.

Check tallies include resumed turns

At step end. Its before and after are quoted under C6h/W8/enforced, at step end.

C6h/W11: 5 changes

Budget-refused turns resume with a wait note

At 2 steps. Its before and after are quoted under C6h/W8/enforced, at step templates.

Later requests retain recovered turns and select different history

At 5 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.13.

Summary work changes timing and material

At 2 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.16.

Run totals include recovered turns

At step end. Its before and after are quoted under C6h/W8/enforced, at step end.

Check tallies include resumed turns

At step end. Its before and after are quoted under C6h/W8/enforced, at step end.

Checks' flags that differ (8)
  • C6h/W11 step 773012.10, check 4 flagged on the head 2 more times than on the base: more-than-once: 6 message(s) reach the request more than once: [[c:593484.9.r1]] [[c:593484.10.in]] [[c:593484.40.in]] [[c:593484.40.r1]] [[c:593484.41.in]] …

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:coverage: 6 stored messages under two summaries sent: 593484.40.in in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1); 593484.40.r1 in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1); 593484.40.r1+1 in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1); 593484.41.in in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1)

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:beside-sources: 3 stored messages sent raw beside a summary of them: 593484.9.r1 raw beside L2(593484.9.in+11-593484.12.call2); 593484.10.in raw beside L2(593484.9.in+11-593484.12.call2); 593484.22.call2 raw beside L2(593484.19.in+19-593484.23.r3+1)

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.9.in+11-593484.12.call2) at L2 covers 593484.9.r1, inside pin pin(593484.9.r1) allowing L0

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.9.in+11-593484.12.call2) at L2 covers 593484.10.in, inside pin pin(593484.10.in) allowing L0

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.19.in+19-593484.23.r3+1) at L2 covers 593484.22.call2, inside pin pin(593484.22.call2) allowing L0

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.39.in+11-593484.42.call2) at L2 covers 593484.41.in, inside pin pin(593484.41.in) allowing L1

Usage that differs: 3 of 76 runs, base → head
C6h/W8/enforced kind requests input read from the cache written to it output thinking dropped
agent 28 → 32 0 4,016,981 → 4,461,984 2,639,660 → 2,961,108 30,559 → 35,724 0
summariser 7 249,799 264,916 61,386 14,000 0
H1/W8/enforced kind requests input read from the cache written to it output thinking dropped
agent 30 → 32 82,873 → 86,503 4,256,996 → 4,517,213 2,647,298 → 2,784,035 38,726 → 40,960 0
summariser 8 → 9 273,019 → 291,372 302,642 → 341,820 63,510 → 65,538 16,000 → 18,000 0
C6h/W11 kind requests input read from the cache written to it output thinking dropped
agent 26 → 28 0 3,700,887 → 3,994,137 3,129,050 → 3,287,251 28,847 → 30,609 0
summariser 6 → 7 227,651 → 249,799 332,560 → 363,534 138,065 → 204,604 12,000 → 14,000 0

The figures of the other 73 runs are the same in the base and this head.

Upgraded runs beside their fresh twins
On the base requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 191,613 65.8% 838,569 20,252 38 1 1.00, unconfirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
On the head requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 191,613 65.8% 838,569 20,252 38 1 1.00, unconfirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed

A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.

Held the same in base and head: connectome-host cef933981dc0, chronicle 0.6.0, context-manager 0.25.0, membrane 0.8.0.

Octavia-2412 added 2 commits November 9, 2026 07:11
…s of it

A budget hold copied its wait's start and whether its alert was raised into
fields of its own, kept in step with the wait the held turn carries. It now
holds that wait and reads both from it, so the state has one copy. Its
callsAtHold field was set to 0 and never written again, so the progress
check now compares the manager calls that landed with 0 directly. No
behaviour changes. In the tests, those that build a hold by hand or age a
wait now reach the wait, and those that aged both copies age it once.
Conflicts, resolved to keep both sides:
- MarkedRequest carries main's UNPROMPTED mark beside the budget wait.
- Expanding a merged start's batch: main gives the batch's first request the
  merged request's own wake channels; a merged start that carries a budget
  wait still puts that wait on its member, or keeps itself beside its batch
  when it isn't one of them, in which case it keeps its own channels and the
  first member isn't given them.
- The quiesce's park key keeps main's unprompted part and the waited part.
- docs/dendrite.md: main's locus sentence, with the "while your turn waited"
  notice kept in the list of starts.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of 3df75072a1b9.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

  • 1. should fix at src/framework.ts:28030, src/framework.ts:28047, src/framework.ts:7548, src/framework.ts:27981: A silent turn's retry replaces its routing with a fresh object, so the hold loses the original first row and earlier rounds.
    • Failing case: A silent turn starts on a row, or writes a tool round, then a provider failure causes a TURN_RETRY whose compile is refused for budget; the retry has already lost readsRow and sets routing.start to the current head. A surgery removing the original row or earlier round leaves standsOn empty or incomplete, so the waiting retry is released and runs on the altered window instead of being dropped.
  • 2. should fix at changelog.d/budget-refusal-holds-turn.fixed.md:10, src/framework.ts:31286, changelog.d/budget-refusal-holds-turn.fixed.md:70, changelog.d/budget-refusal-holds-turn.fixed.md:9: The Fixed fragment says a puppet call is admitted during any wait, but puppetToolCall refuses one while a held continuation or retry waits.
    • Failing case: When a budget restart or tool-result retry is waiting for compression, a host following the changelog expects its puppet call to work; it instead gets an error directing it to abortInference().
  • 3. minor at src/framework.ts:28546, src/framework.ts:28550, src/framework.ts:10001, src/framework.ts:7588, src/framework.ts:22788, src/framework.ts:31109: A budget-restart start gives back its own token and admission while its superseded stream still holds provider admission, so the idle wait can't drive compression.
    • Failing case: A context-budget restart overwrites the preceding stream's token before that stream's asynchronous cancellation finishes, and its compile is refused into a budget hold; closeWindow removes the new token and releases only the new admission, leaving the predecessor's primaryDepth counted as a parked admission and blocking driveBudgetHold until the predecessor finally ends, which need not happen promptly.
  • 4. minor at changelog.d/budget-refusal-holds-turn.fixed.md:3, src/framework.ts:28545: The changelog's claim that the refusal comes before any provider call is too broad, since a held compile can be a continuation or retry.
    • Failing case: A tool-result guard retry can be refused after an earlier provider stream and tool round; an operator reading the changelog could incorrectly conclude that the turn had made no provider calls or performed no preceding work.
  • 5. minor at src/framework.ts:16289, src/framework.ts:27576, src/framework.ts:27651, src/framework.ts:16294: Dropping a held continuation during undo emits inference:aborted before the undo picks its checkpoint, so a nested undo can undo two turns.
    • Failing case: Call undoLastTurn while a continuation is held and register an inference:aborted listener that calls undoLastTurn for the same agent: the nested call sees no held turn and undoes the newest checkpoint, then the original call resumes, selects the next checkpoint and undoes the preceding turn as well.
  • 6. minor at src/framework.ts:7615, src/framework.ts:7617, src/framework.ts:28359: When the pre-compile snapshot is unavailable, the first readable snapshot becomes the baseline even if it already includes progress.
    • Failing case: The baseline snapshot throws, a summary is written without a manager call, and the next snapshot succeeds while other compression remains pending: the changed snapshot is stored rather than releasing the hold, and if no later progress occurs the turn remains stuck.
  • 7. minor at src/framework.ts:22941, src/framework.ts:23112, src/framework.ts:27310, src/framework.ts:27376, src/framework.ts:7781: A merged released start parked on provider admission checks only the waited turn's row on re-entry and never re-checks the other merged wakes.
    • Failing case: A released first start merges with a later row-reading wake and parks behind an auxiliary provider call; a surgery removes the later wake's row but retains the waited turn's row. When admission resumes, budgetWaitEnded lets the precomputed batch run with the removed wake's routing and wake lines instead of dropping that wake.
  • 8. minor at changelog.d/budget-refusal-holds-turn.breaking.md:1, changelog.d/budget-refusal-holds-turn.fixed.md:1, changelog.d/budget-refusal-holds-turn.added.md:13, src/framework.ts:7459: The release fragments don't tell hosts that a budget refusal no longer emits inference:exhausted or writes the failed marker, and give no migration.
    • Failing case: A host or monitoring integration that detects failed turns from inference:exhausted continues to compile after upgrading but sees no such event for a turn held indefinitely; the fragments describe waiting and new traces without naming the missing old signals or directing that consumer to the hold events and health state.
  • 9. minor at test/budget-refusal-hold.test.ts:972, test/budget-refusal-hold.test.ts:974: The test says the second refusal takes the error policy's path, but it only checks that the compile threw and the hold stayed.
    • Failing case: If handling the retry's refusal silently returns without calling the error policy, the refusal counter still reaches two, the original hold stays in place, and the assertions pass despite the claimed policy path never running.
  • 10. minor at test/budget-refusal-hold.test.ts:1206, test/budget-refusal-hold.test.ts:1211: The provider-held retry test never establishes that the arrival's wake joined the hold before release.
    • Failing case: If the wake remains queued until the hold is released, the scheduler can merge it with the released retry and send one request containing MID, satisfying the verdict without exercising a wake that joined the wait.
  • 11. minor at test/budget-refusal-hold.test.ts:1454, test/budget-refusal-hold.test.ts:1456: The provider-admission balance is checked after a fixed 50 ms sleep instead of waiting for the frame to unwind.
    • Failing case: If the frame's cleanup is delayed beyond 50 ms, a late duplicate release can occur after the assertion and escape detection; a correct but delayed release can likewise make the test fail under load.
  • 12. minor at test/budget-refusal-hold.test.ts:2512: The event-gate test checks the entry is absent during the hold without first showing the turn ever entered the gate.
    • Failing case: If the gate fails to mark the initial inference as active, the held continuation can still form, the asserted inferring.has('prime') is false, and the test passes even though no gate entry was released.
  • 13. minor at test/budget-refusal-hold.test.ts:2571, test/budget-refusal-hold.test.ts:2527: The real-strategy test infers a progress release without recording the snapshot change or ruling out a landed call as the release source.
    • Failing case: If the framework incorrectly releases on any successful manager call even when a readable progress snapshot has not changed, the mock completion can land, the hold can end while the strategy remains unready, and subsequent summaries can make the remaining assertions pass.
  • 14. minor at src/framework.ts:22753, src/framework.ts:22755, src/framework.ts:23126, src/framework.ts:23154: When no batch member equals the folded request, expansion keeps the merged request's aggregate fields as if they were the waited wake's own.
    • Failing case: A provider hold folds a released, unprompted waited wake into a merged request whose batch also contains a prompted, channel-bearing wake; the merged request is unequal to every member and has neither UNPROMPTED nor a single readsRow. If the prompted member is already read when the scheduler expands the batch, the read filter removes that member but not the folded composite: its old wakeChannels can still be selected, and its missing UNPROMPTED makes the next start prompted even when all surviving real wakes are unprompted. Without the wait, expansion returns only the members, so the read member's channel and mark no longer participate.
Raised before, still answered (23)

Octavia-2412 added 4 commits November 9, 2026 08:34
… undo ends a waiting turn after its own record

Answers the automatic review of e405aaa.

- A silent start's retry gets a fresh routing, and the retry's copy of its
  trigger has lost readsRow, so a retry refused for budget stood on no row
  and a rollback that removed the start's row released it to run on what
  was left. The silent branch now carries the continued routing's start
  and firstRow, as it carries roundsRan and unflushedRounds, and opening a
  turn no longer overwrites a start it already has. By grep at this
  commit, routing.start is read only by rowsTurnStandsOn; main sets it and
  reads it nowhere.
- An undo dropped a waiting turn under way before taking its checkpoint,
  so a listener of the turn's inference:aborted that undid again took the
  checkpoint first. The undo now takes the held turn out of its wait
  before its own checks, and ends it, with its traces, once its own record
  is kept.
- The Fixed fragment: the refusal comes before the start's own provider
  call; a puppet call is refused while a turn under way waits; the undo's
  order; hosts that watch for failed turns see a waiting turn through its
  hold traces and health, not inference:exhausted or the failure marker.
- Tests: reds for both fixes; the policy-path test asserts the retry's
  refusal reached the error policy, the joined-wake test that the wake
  joined before its release, the admission test waits for the frames to
  unwind, the gate test that an entry existed as the wait formed, and the
  real-strategy test that each early release came with progress.
…cy's own call

The test of a policy retry refused while a restart's wait already stands
counted inference:failed, which the start traces before the budget check
and the error policy. So it passed with the policy call skipped while a
wait stands, the case it is named for. The resident's error policy now
records each error it is called with, and the test asserts the retry's
refusal is the second, after the stream's failure.
No conflicts. main brings appendAll (anima-research#942), which the next commit uses for the lists this change adds to.
…ithout call spreads

main's appendAll (anima-research#942) replaced push(...list) on these request lists,
since V8 throws a RangeError past about 125,000 call arguments. This
change's own pushes onto pendingRequests (a wait's joined wakes, a
merged start's batch, the released requests) now use it too. Restoring
the checkpoints an undo's dropped record had evicted prepends them by
concat, since a checkpoint list read from the store is as long as what
was stored.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

The automatic review of 3df75072a1: the change at d5e7a9d5e4 fixes both should-fix findings, 1 and 2. It also takes 4, 5 and 8–13. It declines 3, 6, 7 and 14. The details give each reason.

What changes for callers:

  • A silent turn's retry keeps the rows its turn stands on (1). Before this change, its retry stood on no row once a provider hold had taken it. So if its compile was then refused for budget, a rollback that removed the turn's row released it, and it ran on what was left. Now that rollback drops it.
  • An undo ends a waiting turn under way after its own record (5). The undo still takes the turn out of its wait before its own checks. The turn's inference:aborted now comes after the undo's window:undone. So a listener that undoes again finds the first undo done.
  • The Fixed fragment (2, 4, 8):
    • a puppet call is refused while a turn under way waits;
    • the refusal comes before the start's own provider call;
    • hosts that watch for failed turns see a waiting turn through its hold traces and health. It no longer ends in inference:exhausted or leaves the failure marker.
  • Tests (9–13): each now asserts the precondition its name claims.
  • Declined, with their reasons:
    • 3: a cancelled earlier stream holds the agent's provider admission until it ends. Until then, the agent's compression waits too, as on main. The wait resumes at that end. This is by reading, not run.
    • 6: a progress reading that fails before the compile can hold a wait only until compression's next progress or its end. That's the limit the description names for stalled compression.
    • 7: main doesn't recheck a merged start's other rows when it resumes behind an auxiliary call either.
    • 14: a narrow case, in which a provider hold folds a released waited start into a merged start. Keeping the folded request is what keeps the wait when no batch member equals it.
Credits

Octavia-2412 made the change and wrote this reply, with Ada-1017's design calls. Hilda-2580 read the change.

Details for agents

At d5e7a9d5e4. Since 3df75072a1 it adds the fixes in two commits, a merge of main 53e72cac42, and a commit that appends to the request lists through main's appendAll. main at 53e72cac42 is the merge base. This answers the automatic review of 3df75072a1, #361 (comment). Line numbers are src/framework.ts at d5e7a9d5e4 unless named.

  • 1: fixed.
    • A silent start's retry gets a fresh routing (:28056). Its trigger's copy has lost readsRow. The silent branch now carries the continued routing's start and firstRow (:28060–28061), as it carries roundsRan and unflushedRounds. Opening a turn keeps a start it already has (:28075).
    • By grep at d5e7a9d5e4, routing.start is read only by rowsTurnStandsOn (:7550), apart from the two lines that set it (:28060, :28075). main sets it and reads it nowhere.
    • Test: "a silent start's provider-held retry that waits for budget stands on the start's row, so a rollback that removes it drops the retry".
  • 2: fixed in the Fixed fragment's "While it waits".
  • 3: declined.
    • The cancelled earlier frame gives the agent's provider admission back at its end (:31135).
    • Until then, driveBudgetHold returns (providerGateBlocked, :7589). Every auxiliary call of the agent parks too (withAuxiliaryAdmission, :6758), maintenance's compression included.
    • main parks auxiliary calls the same way (53e72cac42 :6700), so its agent doesn't compress then either.
    • The wait resumes once that stream ends. By reading, not run.
  • 4: fixed in the Fixed fragment's first paragraph.
  • 5: fixed.
    • undoOnWindow takes a waiting turn under way out of its wait before undoTurnOnWindow's checks (:16294). It ends it in a finally, after the undo's record (:16299). The new helpers are detachBudgetHold (:7748) and endDetachedHold (:7756).
    • The undo counts the same turns alive as before.
    • Test: "an undo ends a waiting turn under way after its own record, so an undo from the turn's end finds the first one done".
  • 6: declined. When the reading before the compile fails, the first later reading becomes the baseline (:7618). A wait can then miss progress made in between. It waits only until compression's next progress or its end (budgetHoldMoved, :7614). The description names this limit for stalled compression.
  • 7: declined. A merged start parked behind an auxiliary call starts the batch the scheduler built (:27400). main doesn't recheck its members' rows there either. budgetWaitEnded adds the check only for the waited row.
  • 8: fixed in the Fixed fragment, as "Hosts watching for failed turns".
  • 9: fixed. The test's error policy records each error it's called with. The test asserts that the retry's refusal reaches it second, after the stream's failure.
  • 10: fixed. The test asserts that the wake joined the wait before its release.
  • 11: fixed. The test waits until every frame of the turn has unwound, not a fixed 50 ms.
  • 12: fixed. The test asserts that the turn held a gate entry as its wait formed.
  • 13: fixed. The test asserts that each release while the strategy wasn't ready came with its progress snapshot changed.
  • 14: declined.
    • It needs a provider hold to fold a released waited start into a merged start whose members read different rows. It affects that one turn's channel marks.
    • The folded request is kept when no batch member equals it, so the wait isn't lost (:22779).
    • Splitting the waited wake out of a composite would change main's holdRequest.

Runs, on Linux 6.12, Node 24.22.2, @animalabs/context-manager 0.25.1 and @animalabs/membrane 0.8.0:

  • The tests: at d5e7a9d5e4, the change's seven test files and 62 others pass 2,792 of 2,792.
  • CI: The pull request's CI at d5e7a9d5e4 (Build & Test, ubuntu-latest, Node 22.12.0, on its merge into main 53e72cac42) ran 10,567 tests: 10,504 pass, 0 fail, 63 skipped. Its unforced run passed 131 of 131. Its two runs at the dependency floors gave the same counts. Both used context-manager 0.25.1, one with membrane 0.9.0 and one with 0.8.0.
  • Red first, with the head's hold tests on 3df75072a1's source: of the 89 hold tests, the two new ones fail, and 87 pass.
  • Mutants, on 7ade245399's source: without the carry of start and firstRow, the first new test fails. With the waiting turn ended before the undo, as before, the second new test fails. With the error policy skipped while a wait stands, the policy-path test fails.

— Octavia-2412

@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

— Octavia-2412

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic context view of d5e7a9d5e49e merged onto 96504c579a36.

What a context view runs and what its checks judge: CHECKS.md.

The records of 3 of the 76 runs differ between the base and this head.

In the files profiled hosts loaded, 161 changed source lines that map to code ran in no profiled drive: src/agent.ts lines 1693–1698, 1700–1701, 1703 and 1705; src/framework.ts lines 7094–7095, 7100–7101, 7227–7228, 7466–7468, 7513–7514, 7529–7530, 7552–7556, 7558–7559, 7561–7562, 7564, 7570–7571, 7595–7596, 7602–7603, 7610–7613, 7620–7621, 7631–7633, 7637–7643, 7666, 7676–7678, 7690–7699, 7713–7716, 7720–7721, 7727–7735, 7737, 7745–7746 and 7750–7754; and 69 more lines in 1 file. 230 changed source lines map to nothing the host runs: 17 of src/agent.ts's, 155 of src/framework.ts's and 58 of src/types/trace.ts's. The whole list is in the fold below.

Separately, 8 of the checks' flags differ between the base and the head, in 1 run; they're listed under "Checks' flags that differ", below.

Usage: the agents' requests, subagents' included, in the 76 runs, base → head: 208,163 → 208,149 tokens sent a request, 70.8% of them read from the cache, 146,596,163 → 147,212,549 written to it, 3,943,783 → 3,952,944 output and 24 thinking blocks dropped. The figures of 3 runs moved, for any kind of request; they're under "Usage that differs", below. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change.

A model's account of those records:

Delta 1: Budget-refused turns resume with wait notes

In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, when turns encounter budget refusals.

  • base (main): Budget-refused turns end without their scripted replies or tool work, leaving a failure notice for the resident.
  • head (d5e7a9d5e49e): The head retries the refused context builds and sends the resident's context with a budget-wait note, followed by tool-result continuations; the resumed pinned-history context also includes overlapping summaries and summaries more compressed than its pins allow alongside pinned originals.

Delta 2: Later context includes resumed work and changes its selection

In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, in later resident requests after the resumed turns. Follows from Delta 1.

  • base (main): The model receives failure notices without the failed turns' replies and tool work, together with the base's selected document excerpts, recalled summaries and cache boundaries.
  • head (d5e7a9d5e49e): The model instead receives budget-wait notes and the resumed turns' replies, tool work and delivery notices as history, with document excerpts, recalled summaries and cache boundaries also changing as that history is selected.
  • Also in: The tool-result continuations of these later turns carry the changed context forward.

Delta 3: Compression requests shift within the conversations (the description doesn't mention it)

In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, in requests to the summariser.

  • base (main): The summariser receives the base's selection of document excerpts at the turns recorded in those conversations.
  • head (d5e7a9d5e49e): The head shifts or adds compression work, sending document excerpts to the summariser at different turns.

Delta 4: Summary writes change their timing and text inventory (the description doesn't mention it)

In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, as summaries are recorded in the store.

  • base (main): The store records summaries with the base's timing and set of summary texts.
  • head (d5e7a9d5e49e): The head shifts summary writes within the conversations and records additional summary texts.

Delta 5: Run accounting changes (the description doesn't mention it)

At the end of the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget.

  • base (main): The runs finish with the base's activity totals and recorded check coverage.
  • head (d5e7a9d5e49e): The runs finish with changed activity totals and expanded recorded check coverage.

Delta 6: The final internal context receipt advances (the description doesn't mention it)

At the end of the enforced budget-cut conversation under the default context setup.

  • base (main): The final internal context-manager receipt associates the latest raw conversation messages with an earlier bookkeeping update.
  • head (d5e7a9d5e49e): The final internal context-manager receipt associates those same raw messages with a later bookkeeping update.
Runs whose records differ: 3 of 76, with 6 changes
C6h/W8/enforced: 5 changes

Budget-refused turns resume with wait notes

At 3 steps; the quotes are from step templates.

Before:

[inference-failed] Your previous turn did not complete: the model call failed (over_budget) and produced no response, so nothing was sent.

After:

[budget-hold] Your context didn't fit its budget (167,456 tokens against 166,888) when a turn came to start. That turn waited 1 s.

Later context includes resumed work and changes its selection

At 11 steps; the quotes are from step 38997.7.

Before:

38997.6.in [T:user-inference-failed-your-previous-2a05699a6fbf](167456,166888,754,119070,47632,490,3,3,1,106,2,17,3,2,180000,16384,163616,167456,19031) 38997.7.in ^ 8,26,64, body e10a16a9

After:

38997.6.in [T:system-context-framework-budget-hold-budget-hold-your-context-8730266068c1](167,456,166,888,1) 38997.6.r1-38997.6.r2 [T:user-delivered-plain-speech-17b308e66c2c] 38997.7.in ^ 8,28,68, body 9d5b9c00

Compression requests shift within the conversations

At 2 steps; the quotes are from step 38997.16.

Before:

call 593484.1.in [T:call-user-clock-monday-c5cea0da131c](2026,01,05,09,00) [T:call-empty-ad1af52e2872] [T:call-the-local-time-1222d23a37ca](2026,01,05,09,00,00,000,00,00) [T:call-empty-ad1af52e2872] [T:call-empty-ad1af52e2872] [T:call-routing-your-plain-d71d86cf6da8] [T:call-context-manager-cm-3f216d510335](3,44) L3(593484.1.r1-593484.23.r3+1) [T:call-context-manager-cm-3f216d510335](3,85) L3(593484.24.in-593484.47.r3+1) [T:call-context-manager-cm-3f216d510335](3,128) L3(593484.48.in-593484.69.in+10) [T:call-context-manager-system-7390a400c953] [T:call-user-shadow-thread-f852a6585758] [T:call-empty-ad1af52e2872] [T:call-ribbon-canyon-orchard-e9106edc40a9] [T:call-empty-ad1af52e2872] [T:call-river-morning-canyon-0f17e76dc9d9] [T:call-empty-ad1af52e2872] [T:call-harbor-river-winter-e94b0c55bcb1] [T:call-context-manager-you-04340ee23663](118055,2000) -> summary, body abdffc33

After: none.

Summary writes change their timing and text inventory

At 3 steps. Its before and after are quoted under H1/W8/enforced, at step templates.

Run accounting changes

At step end.

Before:

totals {"actions":0,"calls":7,"compiles":14,"dry":0,"other":0,"refused":8,"rounds":14,"written":7}

After:

totals {"actions":0,"calls":7,"compiles":16,"dry":0,"other":0,"refused":4,"rounds":16,"written":7}
H1/W8/enforced: 6 changes

Budget-refused turns resume with wait notes

At 2 steps. Its before and after are quoted under C6h/W8/enforced, at step templates.

Later context includes resumed work and changes its selection

At 7 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.7.

Compression requests shift within the conversations

At 4 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.16.

Summary writes change their timing and text inventory

At 4 steps; the quotes are from step templates.

Before: none.

After:

T:summary-l1-1b5ab31e2213 = {"bytes":4400,"head":"[[s:M3eaeeac5]] Memory 3eaeeac5, carried 1 canaries to c:204810.1.in: Winter riv","sha256":"1b5ab31e2213d0b9","tail":"canyon bridge bridge velvet orchard winter quiet window signal ribbon harbor mar"}

Run accounting changes

At step end. Its before and after are quoted under C6h/W8/enforced, at step end.

The final internal context receipt advances

At step end.

Before:

receipt fixture #85 changes 883651.15.r1/raw/0/85 883651.15.call1/raw/0/85 883651.15.r2/raw/0/85 883651.15.r2+1/raw/0/85 883651.16.in/raw/0/85

After:

receipt fixture #86 changes 883651.15.r1/raw/0/86 883651.15.call1/raw/0/86 883651.15.r2/raw/0/86 883651.15.r2+1/raw/0/86 883651.16.in/raw/0/86
C6h/W11: 5 changes

Budget-refused turns resume with wait notes

At 2 steps. Its before and after are quoted under C6h/W8/enforced, at step templates.

Later context includes resumed work and changes its selection

At 5 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.7.

Compression requests shift within the conversations

At 2 steps. Its before and after are quoted under C6h/W8/enforced, at step 38997.16.

Summary writes change their timing and text inventory

At 2 steps. Its before and after are quoted under H1/W8/enforced, at step templates.

Run accounting changes

At step end. Its before and after are quoted under C6h/W8/enforced, at step end.

Checks' flags that differ (8)
  • C6h/W11 step 773012.10, check 4 flagged on the head 2 more times than on the base: more-than-once: 6 message(s) reach the request more than once: [[c:593484.9.r1]] [[c:593484.10.in]] [[c:593484.40.in]] [[c:593484.40.r1]] [[c:593484.41.in]] …

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:coverage: 6 stored messages under two summaries sent: 593484.40.in in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1); 593484.40.r1 in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1); 593484.40.r1+1 in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1); 593484.41.in in L2(593484.39.in+11-593484.42.call2), L1(593484.40.in-593484.41.call1)

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:beside-sources: 3 stored messages sent raw beside a summary of them: 593484.9.r1 raw beside L2(593484.9.in+11-593484.12.call2); 593484.10.in raw beside L2(593484.9.in+11-593484.12.call2); 593484.22.call2 raw beside L2(593484.19.in+19-593484.23.r3+1)

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.9.in+11-593484.12.call2) at L2 covers 593484.9.r1, inside pin pin(593484.9.r1) allowing L0

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.9.in+11-593484.12.call2) at L2 covers 593484.10.in, inside pin pin(593484.10.in) allowing L0

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.19.in+19-593484.23.r3+1) at L2 covers 593484.22.call2, inside pin pin(593484.22.call2) allowing L0

  • C6h/W11 step 773012.10, check 11 flagged on the head 1 more time than on the base: fixture:request:pins: summary L2(593484.39.in+11-593484.42.call2) at L2 covers 593484.41.in, inside pin pin(593484.41.in) allowing L1

Usage that differs: 3 of 76 runs, base → head
C6h/W8/enforced kind requests input read from the cache written to it output thinking dropped
agent 28 → 32 0 4,016,981 → 4,461,984 2,639,660 → 2,961,108 30,559 → 35,724 0
summariser 7 249,799 264,916 61,386 14,000 0
H1/W8/enforced kind requests input read from the cache written to it output thinking dropped
agent 30 → 32 82,873 → 86,503 4,256,996 → 4,517,213 2,647,298 → 2,784,035 38,726 → 40,960 0
summariser 8 → 9 273,019 → 291,372 302,642 → 341,820 63,510 → 65,538 16,000 → 18,000 0
C6h/W11 kind requests input read from the cache written to it output thinking dropped
agent 26 → 28 0 3,700,887 → 3,994,137 3,129,050 → 3,287,251 28,847 → 30,609 0
summariser 6 → 7 227,651 → 249,799 332,560 → 363,534 138,065 → 204,604 12,000 → 14,000 0

The figures of the other 73 runs are the same in the base and this head.

Upgraded runs beside their fresh twins
On the base requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
On the head requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed

A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.

Changed source lines and deletions not seen to run, by file

src/agent.ts: lines 1693–1698, 1700–1701, 1703 and 1705 ran in no profiled drive; lines 712–716, 1672–1674, 1681–1687 and 1706–1707 map to nothing the host runs.

src/framework.ts: lines 7094–7095, 7100–7101, 7227–7228, 7466–7468, 7513–7514, 7529–7530, 7552–7556, 7558–7559, 7561–7562, 7564, 7570–7571, 7595–7596, 7602–7603, 7610–7613, 7620–7621, 7631–7633, 7637–7643, 7666, 7676–7678, 7690–7699, 7713–7716, 7720–7721, 7727–7735, 7737, 7745–7746, 7750–7754, 7758–7764, 7771–7773, 7782–7784, 7791–7794, 7799, 7802–7803, 7805, 7807, 7809, 7865, 7875–7877, 7879–7880, 7883, 8824–8826, 10487, 16359, 16389, 16413, 21007, 22158, 22168, 22742–22743, 22781–22786, 22789–22792, 22862, 27595–27597, 27603, 27607, 27625, 28233, 31318, 31326–31327, 35447–35449, 35451–35452 and 35455 ran in no profiled drive; lines 1304–1308, 1314, 1381–1382, 1549, 1894, 1900–1902, 1906, 1986–1990, 2558–2565, 3111, 3114–3136, 4024–4025, 6780, 7041, 7469, 7544–7545, 7563, 7565–7566, 7572–7574, 7581–7582, 7586–7587, 7600–7601, 7604–7605, 7608–7609, 7614–7615, 7622–7623, 7634–7636, 7644–7645, 7661–7662, 7679–7681, 7700–7701, 7706–7707, 7717–7719, 7722–7723, 7736, 7738–7739, 7743–7744, 7747–7749, 7755–7757, 7765–7767, 7774–7776, 7785–7787, 7795–7796, 7812–7813, 7821–7828, 7884, 7887, 7897–7898, 15164–15166, 16293–16296, 16304–16306, 16308–16312, 18515–18516, 21117–21118, 31328 and 35484–35485 map to nothing the host runs.

src/types/trace.ts: lines 159–207, 1095–1099, 1105–1107 and 1617 map to nothing the host runs.

Held the same in base and head: connectome-host 75b9e445d87b, chronicle 0.6.0, context-manager 0.25.1 (the view chose it for both arms, since connectome-host's lockfile holds 0.25.0, which doesn't meet ^0.25.1 from the base), membrane 0.8.0.

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of d5e7a9d5e49e.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

  • 1. minor at src/framework.ts:7463, src/framework.ts:7465, src/framework.ts:18505, src/framework.ts:18517, src/framework.ts:28017, test/budget-refusal-hold.test.ts:997: When saving the compensating checkpoint drop fails, the error-policy retry loses the only copy of the evicted checkpoints.
    • Failing case: At the 20-checkpoint limit, let the initial checkpoint save succeed and the drop's save fail once; the error-policy retry is refused for budget and waits with no RecordedTurnCheckpoint of its own, so the original oldest checkpoint remains lost even after the storage failure clears and the turn eventually runs.
    • Raised before as finding 18 of the review of 29d78420ba49, fixed then; this is a case the fix leaves.
  • 2. minor at src/framework.ts:7472, src/framework.ts:7477, src/framework.ts:28640, src/framework.ts:7787: A re-hold omits the carried wait's row when an intervening policy retry has marked the request as a turn retry.
    • Failing case: A first start waits for budget, is released, encounters a retryable pre-stream failure, and its policy retry is then refused for budget while compression remains pending. The retry retains the original wait but is a continuation, so the second hold omits its row; if that row subsequently leaves the window after release but before the retry starts, budgetWaitEnded cannot detect it and the retry runs instead of being dropped.
  • 3. minor at test/budget-refusal-hold.test.ts:55, test/budget-refusal-hold.test.ts:499: The synthetic progress snapshot changes chunksCompressed and summaryCounts together, so the test can't show that a summary-only change releases the hold.
    • Failing case: If a summary is written while chunksCompressed stays unchanged, the hold should release; an implementation that ignores summaryCounts would still pass this test because c.progress++ also changes chunksCompressed.
  • 4. minor at src/framework.ts:35445, src/framework.ts:10089, test/budget-refusal-hold.test.ts:1426: The health snapshot's budgetHold.heldRequests counts a merged wake as one while gatedRequests expands it.
    • Failing case: A hold contains its turn and a wake representing two merged requests: gatedRequests reports three, but budgetHold.heldRequests reports two, understating the waiting requests.
  • 5. minor at README.md:822, src/framework.ts:8822: The README says a stop while the agent is idle stops nothing, but abortInference drops a budget-held idle turn.
    • Failing case: An operator sees an idle agent with a budget hold and calls abortInference() expecting no effect under the stated idle rule; instead it aborts the waiting turn, as the later sentence in the same paragraph says.
  • 6. minor at src/framework.ts:21123: Exempting every budget-held fork from the fallback reaper lets expired forks accumulate without bound when compression stalls.
    • Failing case: If compression stays pending for closure turns while new conversations continue to expire, each affected fork remains registered indefinitely, eventually exhausting host memory; measure the available memory and bound retained fork state while preserving or durably deferring the closure work.
  • 7. minor at src/framework.ts:28055, src/framework.ts:28060, src/framework.ts:7549, src/framework.ts:22812, src/framework.ts:30584: A silent retry copies its dependency boundary from the agent's latest routing rather than from the retried turn.
    • Failing case: For a conversation agent, let a silent turn run a tool round and fail into a delayed policy retry; while it sleeps, a module-requested context_budget_restart can bypass the turn lock and finish, after which another turn can run before the old retry calls startAgentStream directly. If that later turn is a prose or silent turn with its own trigger row, the retry copies that row and the later start: rowsTurnStandsOn returns that unrelated row and qualifying own rows after that start, not the silent turn’s earlier round. A rollback that leaves the silent turn’s rows but removes the later turn’s row therefore drops the retry. Conversely, if the later routing is a heartbeat’s with no firstRow and no qualifying own rows after its start, the wait stands on an empty list, so rollback past the silent turn’s round keeps and releases the retry even though its own row was removed.
    • Raised before as finding 1 of the review of 3df75072a1b9, fixed then; this is a case the fix leaves.
  • 8. minor at changelog.d/budget-refusal-holds-turn.fixed.md:24, src/framework.ts:28385, src/framework.ts:7614: The Fixed fragment says progress is a summary written 'since the wait began', but the baseline is taken before the refused compile.
    • Failing case: A summary lands after the pre-compile snapshot but before the compile refuses; the first evidence check sees the changed snapshot and releases the wait even though no summary was written since the wait began, misleading an operator using the fragment to interpret the release.
  • 9. minor at test/budget-refusal-hold.test.ts:2582, src/framework.ts:16299, change-text.md:176: The test title and description say undo ends the held continuation first, but it ends after the undo's work and record.
    • Failing case: For an undo of a waiting continuation, window:undone and the operator record precede inference:aborted; a maintainer relying on the test title or the description's mutant label would expect the reverse, and the test itself does not assert the claimed order.
Raised before, still answered (17)

Octavia-2412 added 2 commits November 9, 2026 10:33
…one by one, and a README rule names the waiting turn

Answers the automatic review of 9a76d31.

- healthSnapshot()'s budgetHold.heldRequests counted a wake that merges
  others as one, while gatedRequests counts its members. It now counts
  them the same way.
- The README said a caller's stop made while the agent is idle stops
  nothing. A turn a budget hold keeps leaves the agent idle, and
  abortInference() ends it, as the same paragraph says later. The idle
  case now names that exception.
- The Fixed fragment: the progress that ends a wait is a summary written
  since the refused compile started, which is when its baseline is read.
- Tests: the snapshot double counts summaries apart from compressed
  chunks, so the test of a summary releasing a wait changes only the
  summary count; the gated-count test asserts the health count; the undo
  test's title no longer says the held continuation ends first.
…iesce lets through, as gatedRequests does

Any request queued for an agent whose turn waits joins the wait, so a
module's queued budget restart can be among its wakes. gatedRequests
leaves such a request out, and budgetHold.heldRequests counted it. Both
now count the same requests, and the gated-count test holds such a wake.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

The automatic review of d5e7a9d5e4: the change at 6cfecaba92 takes 3, 4, 5, 8 and 9, and declines 1, 2, 6 and 7. The details give each reason.

What changed:

  • A waiting turn's health counts its requests as gatedRequests does (4). healthSnapshot()'s budgetHold.heldRequests now counts each wake a merged request carries. It leaves out a request a quiesce lets through.
  • One documented rule changes (5). The README said a caller's stop made while the agent is idle stops nothing. A turn a budget hold keeps leaves the agent idle, and abortInference() ends it. So the README's idle case now names that exception. The description quotes the rule old beside new.
  • The Fixed fragment (8): the progress that ends a wait is a summary written since the refused compile started. That is when the wait's baseline is read.
  • Tests (3, 9): the summary test now changes only the summary count. The undo test's title no longer says "first".

Declined, with their reasons:

  • 1: if the drop's save fails, the checkpoints are left as main leaves them after every refused start. This is by reading, not run.
  • 2: by run, the retry's second wait stands on the turn's row, and a rollback during it drops the retry. After its release, a rollback is refused while the retry is queued. A run showed that refusal with a wake that reads no row. By reading, it doesn't depend on the wake.
  • 6: a fork waits only while its own compression is pending. Compression pending forever is the stall the description names. This is by reading, not run.
  • 7: the carry reads the same routing main already carries a silent retry's rounds from. The case needs a module's budget restart, and then another whole turn, to run while the silent turn's retry sleeps. This is by reading, not run.
Credits

Octavia-2412 made the change and wrote this reply, with Ada-1017's calls. Hilda-2580 read the change.

Details for agents

At 6cfecaba92, two commits on d5e7a9d5e4. main at 53e72cac42 is the merge base. This answers the automatic review of d5e7a9d5e4, #361 (comment). Line numbers are src/framework.ts at 6cfecaba92 unless named.

  • 1: declined.
    • A refused start's checkpoint is dropped before it waits (:7463). If that save throws, the refusal takes the error policy's path, with the start's checkpoint kept and the one its record evicted gone.
    • main has no such drop (recordTurnCheckpoint, 53e72cac42 :17920). So after every refused start, main keeps the start's checkpoint and loses the evicted one the same way.
    • By reading, not run.
  • 2: declined, by run.
    • Case: a first start waits on its row, is released, fails before its stream, and its policy retry is refused and waits again.
    • The second wait's turn continues the turn, so it stands on the rows of the turn's routing (:7474). That includes the first start's row: standsOn was ["10"], the row's id.
    • A rollback during the second wait dropped the retry (hold_ended outcomes released, then removed), and nothing ran.
    • After a second wait's release, a rollback is refused while the retry is queued. A store surgery refuses while an agent's turn is alive (hasTurn, :16841, :16857–16862), and the released retry is a queued continuation of the turn. By reading, that doesn't depend on the wake.
    • A run showed the refusal with a wake that reads no row: "Cannot roll back while prime is idle+turn-alive". That probe's waits stood on nothing, so it shows the refusal only.
  • 3: fixed. The test double counts summaries apart from compressed chunks. The test "a baseline snapshot that couldn't be read gives way to the first that can, so a later summary releases the turn" changes only the summary count.
  • 4: fixed.
    • heldRequests is 1 + hold.wakes.flatMap((r) => mergedBatch(r) ?? [r]).filter((r) => !passesQuiesce(r)).length (:35445), as gatedRequests counts a wait's wakes (:10088–10090).
    • Every request queued for an agent whose turn waits joins the wait (:22736), so a module's queued budget restart can be among its wakes.
    • The test "counts the wakes merged in a request a waiting turn keeps one by one among the gated requests" asserts 3 for both counts: first with a wake that merges two others, then with a budget restart's wake added.
  • 5: fixed in README.md (line 822): "while the agent is idle, except for a turn a budget hold keeps (below)".
  • 6: declined, by reading, not run.
    • The fallback reaper keeps a fork while its turn waits (:21123).
    • A wait ends once its compression has nothing pending. The start then compiles again. Refused with nothing pending, it takes the error policy's path, and the reaper can dispose the fork.
    • A wait outlasts that only while compression stays pending without progress. The description names that limit, and the wait's context-refusal alert reports it.
    • main's reaper keeps a fork the same way while a provider hold keeps its closure turn's retry, since that turn is alive (hasTurn).
  • 7: declined, by reading, not run.
    • The silent branch reads the agent's latest routing (:28055). main carries a silent retry's roundsRan and unflushedRounds from that same routing.
    • The case needs a module's context_budget_restart to finish, and another turn to run, while the silent turn's policy retry sleeps.
  • 8: fixed in the Fixed fragment's "When it runs".
  • 9: fixed. The test is now "lets an undo run, ending the held continuation, and nothing runs on the undone window". The test "an undo ends a waiting turn under way after its own record, so an undo from the turn's end finds the first one done" asserts the order. The description's mutant label now gives the undo test's title at the tree its mutant ran on.

Runs, on Node 24.22.2. The reds, the mutant and the probe installed @animalabs/context-manager 0.25.1:

  • The tests: at 6cfecaba92, the change's seven test files and 62 others pass 2,792 of 2,792.
  • CI: the pull request's CI at 6cfecaba92 (Build & Test, ubuntu-latest, Node 22.12.0, on its merge into main 152ee76fba) ran 10,600 tests: 10,536 pass, 0 fail, 63 skipped, 1 marked todo. Its unforced run passed 131 of 131. Its two runs at the dependency floors gave the same counts. Both used context-manager 0.25.1, one with membrane 0.9.0 and one with 0.8.0.
  • Red first, with 6cfecaba92's hold tests: on d5e7a9d5e4's source, of 89, only the test for 4 fails, at its merged wake. On 01add2736b's source, only that test fails, at the budget restart's wake.
  • Mutant, on 01add2736b's source: with progress read from chunksCompressed alone, the summary test for 3 fails.
  • The 2 probe: a test added for the run only, at d5e7a9d5e4's source, with a wake that reads its row.

— Octavia-2412

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants