Skip to content

fix: a turn whose compile is refused for budget waits for compression, then runs - #361

Open
nissa-seru wants to merge 55 commits into
anima-research:mainfrom
nissa-seru:fix/304-budget-refusal-holds-turn
Open

nissa-seru wants to merge 55 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

A resident's or conversation agent's turn whose compile is refused for budget now waits while compression is pending, instead of failing within seconds. The waiting turn is alive, as a turn that a provider hold keeps for its retry has been since #774: the host reads it as not drained, a quiesce waits for it, and a branch switch, a rollback or an undo that would move the store is refused while it waits. Later wakes join it. It runs as one fresh compile once a check says the context fits: context-manager's preview, or the framework's dry compile where the strategy has no preview or its preview gives no verdict. After 30 minutes, or once compression has nothing pending, its refusals take the error policy's path as before.

!Fixes #304

Problem

A compile that can't fit its budget throws context-manager's OverBudgetError before any provider call.

  • The retries: the default error policy retries it into the same refusal, and the turn ends as exhausted within seconds. At 0.28.0, in our run, that was three attempts and 3.4 s.
  • The breaker: noteInferenceExhausted calls the over-budget breaker's drain kick, from Compile robustness: empty-text 400 sanitize + OverBudget deadlock breaker #58, and opsAlert with the kind context-refusal (src/framework.ts:28370–28417 at 59d609586e).
  • Then nothing: in our run at 0.28.0, 3 s after the context fit, with no new message, nothing had called the provider. The agent waits for its next wake.

In #304's October 12 report (times in UTC), an immediate budget decrease led to activations refused at 21:00 and 21:01. Compression finished at 21:07, and there was no reply until the owner's next message.

Changes

Against e85c57db, the main this head merges, the change adds 492 lines to src/framework.ts and removes 74, adds 51 to src/types/trace.ts, and adds 11 to src/agent.ts and removes 2. It also adds two changelog fragments, a test file of 78 tests, and a row in test/stop-step-throws.test.ts.

A refused compile holds its turn (holdForBudgetRefusal, where a start's failure is handled, before the error policy and before a provider hold).

  • When: the refusal is over_budget or context_refusal, the agent is a resident or a conversation agent, the framework is running, and its context manager has compression pending.
  • One record per agent (BudgetHold), apart from the provider holds' records. The record's state is holding while the turn waits, released once its requests are queued for one fresh compile, and spent after its deadline or after a refusal with nothing pending.
  • The hold keeps the turn's retry (TURN_RETRY), as a provider hold does since fix(scheduler): a turn a provider hold keeps for its retry is alive #774. So the turn is alive while it waits:
    • getHostModeStatus() reads drained: false and lists the agent in activeTurnAgents;
    • a quiesce lets the turn start once its hold ends, and finish;
    • a branch switch, a rollback and an undo that would move the store are refused (agent-busy), and so is puppetToolCall for the held agent; each refusal names the hold, its deadline and its reason. A redo and a suppression call the same reserveStoreForSurgery (src/framework.ts:13097, through beginSyncMove, and :13482);
    • a quiesce with abandon: true, abortInference(), stop() and a conversation agent's disposal end it, with one inference:aborted.
  • Compression keeps running: the hold leaves the agent's provider gate open, so the maintenance loop keeps ticking the agent. In runQueuedMaintenance, the callback that lists each agent's work returns an empty list for an agent whose provider gate is closed (src/framework.ts:6997).
  • The refused start ends before the hold is reported, so a stop made by a listener of inference:held or of the alert ends the held turn. A hold that a listener of inference:held ends this way isn't logged as held, and no alert is raised for it.
  • Later wakes join it, for one compile, and count in gatedRequests. After an abandon or abortInference(), they go back to the queue. At stop() or a disposal, they go with the turn. A coalesced wake withdrawn while held leaves the hold.
  • A hold adds no undo step.
    • When the refused start recorded a checkpoint, as a turn's first start does, the record keeps it. The start the hold releases then moves it to after the messages that arrived while the turn waited. It gives back the refused start's checkpoint and its turn index. Where recording that checkpoint evicted the oldest one, at the limit of 20, it restores that one too. Then it records the turn's own. The context injections the refused start stored stay before the moved checkpoint, as rows stored before any start do.
    • A hold that's stopped leaves the checkpoint where it is, so the stopped turn keeps its own undo step.
    • When a retry is refused, an error policy's or a tool-result guard's, the turn keeps the checkpoint its first start recorded, before the rounds it has run, and the released start records none. So an undo takes those rounds, and the messages that arrived while it waited too.
    • The redo its new work invalidated stays invalidated, as at any turn's first start.
  • The drain kick runs when the hold starts, through kickOverBudgetDrain, which the breaker's kick now shares.

The release check (checkBudgetHold) takes context-manager's verdict on the turn's next compile: previewContext's fits, at the budget that compile plans at and with the agent's thinking binding.

  • Why the preview: a dry compile can resolve a plan the turn's compile refuses (SelectOptions.dryRun). The preview reports such a plan as not fitting.
  • A refusal the preview rethrows, such as a context refusal (UncoveredDropError), counts as a refusal. Any other error ends the hold (check_failed), and the turn's own compile meets it.
  • A strategy with no preview gets a dry compile, and so does the hierarchical strategy for a context that fits, where its preview gives no verdict (context-manager 0.23.0 throws "requires adaptiveResolution", and 0.23.1 returns null). It's previewActivation's, which compiles with the thinking binding and its identity, as a turn's start does. The hold calls it with no options (src/framework.ts:6322), and its doc comment (:9808–9815) says such a call leaves out the module and MCPL injections a start gathers. Only one dry compile runs at a time. Its refusal counts only if nothing the check watches changed while it ran.
  • When it looks: after every maintenance pass and every drain kick the hold starts. With the maintenance timer off, the hold kicks the drain and looks every 5 s, the timer's default interval, and while the provider gate is closed, it only looks.
  • What it watches: compression's progress and whether it still has compression pending, a count of the store's changes (a message added, edited or removed, counted through context-manager's onMessage), the agent's runtime settings, and the budget its next compile plans at, reserves included. While a readable snapshot of these (budgetHoldSignature) is unchanged, the hold takes no new verdict. The arrival of a stored context injection or of a clock row isn't counted: a released turn's start stores its own injections, the note among them, and a clock row when a clock boundary has passed. The budget is in the snapshot because the tool-result guard's pending reserve can change it with no store event and no runtime setting moving.
  • If it fits: the hold's requests go to the queue together, for one fresh compile. The hold's end is reported once they are queued, so a stop made by a listener of inference:hold_ended ends the turn.
  • A released turn whose own compile still refuses holds again on the same record, with its first refusal's start and deadline, and isn't released again until something the check watches has changed.

The bound is a fixed 30 minutes from the refusal that started the hold (BUDGET_HOLD_MS). In #304, compression finished about 7 minutes after the first refusal. The bound also ends a hold whose compression stays pending without moving. There's no option and no stall rule, since the alert at the hold's start tells the operator, and the bound ends the wait.

When a hold ends without a fit, at its deadline or once compression has nothing pending, its requests go to the queue for one fresh compile. If the context fits by then, the turn runs. Otherwise its refusals take the error policy's path, as before this change. The policy gets each refusal itself, its message saying when the turn was first held and why the hold ended, for example "… (the turn was first held 30 min ago; the hold's 30 min bound passed and it still didn't fit)".

The note. The turn's next start once its hold ends, with a fit or without, adds a note as an afterUser context injection, which main stores since #782. storeInjections' doc comment (src/framework.ts:18979–18982) names two cases where nothing is stored and the compile places the injection instead: the host is quiesced, or another reader of the residents' shared slot has a turn alive. The note reads: "[budget-hold] Your context didn't fit its budget (100,201 tokens against 48,568), so this turn waited 7 min for compression to make room." It says only what happened, so it stays true whatever the compile then meets. It's stored before the compile the preview judged fit, so at the margin it can tip that compile into a refusal. The hold then holds the turn again until something it watches changes, and a turn released twice can carry two notes.

A restart drops a held turn, since the hold lives in memory. The messages stay in the store, and the agent's next wake compiles afresh.

Unchanged:

  • An ephemeral run's refusal fails as before.
  • A refusal in a framework that isn't running fails as before: one only runUntilIdle() drives, without start(), where nothing would run a released turn, and one whose stop() has begun.
  • A refusal with nothing pending fails as before.
  • noteInferenceExhausted still calls the breaker's drain kick at exhaustion (src/framework.ts:29174).

Left out:

  • Persisting a hold across a restart. The held turn's messages stay in the store, and the next wake compiles afresh. A durable owed turn would be a contract for every kind of held work, not for this one alone.
  • An option for the bound, and a stall rule (above).

Where we're less sure. On the fallback's path, the dry compile awaits, and compression can move while it runs. Its refusal counts only if the snapshot is unchanged when it ends, as far as the snapshot can be read. Two tests cover that check, and no removal of it was run.

Since 73def25e, the previous head

This head merges main at e85c57db into 73def25e. The merge's one conflict was where beginAgentTurn handles a terminal failure, and both sides' lines are kept there: the hold's settleBudgetHold call and #846's exhaustingRearms.add (src/framework.ts:21990–21991). The merge leaves test/budget-refusal-hold.test.ts, test/stop-step-throws.test.ts and the two changelog fragments as they were.

Since 54565d8b

54565d8b, the head before 73def25e, let a held turn give back its turn token, so the agent read as idle and a surgery was admitted while the turn waited. Since then, main's #774 made a turn alive while a provider hold keeps it for its retry. A run at 54565d8b showed that this hold had the defect #774 fixed: a rollback past the held turn's message was admitted while the hold waited, and the released turn answered without that message in its history. This head takes main's model instead. That removes:

  • InferenceRequest's releasedByBudgetHold and the checks that read it: a quiesce lets a held turn's retry through, as it lets a provider hold's;
  • getHostModeStatus()'s heldTurns: the turn is in activeTurnAgents;
  • the look after every surgery, undo and redo, and the surgeries in the hold's snapshot, since those moves are now refused while a turn is held (above);
  • the scheduler's own handling of a deferred batch's wake beside a resumed turn, and of a released guard retry. main's handling of continuations takes their place, and the test of a deferred batch's wake beside a held continuation passes at this head.

The note is now stored, after #782, and its words no longer say "It fits now".

What the agent and the host see

The agent:

  • While held: nothing, and no [inference-failed] marker is stored.
  • When the held turn runs: the note above, stored in its history.
  • If refusals after the hold exhaust the error policy: the [inference-failed] marker it gets today, with the reason above.

The host:

  • The traces of a hold that ends in a fit: inference:started, inference:failed for the refused attempt, inference:held, inference:hold_ended, then inference:started again and inference:completed.
    • inference:held carries the refusal's type and message, the budget and size when provided, the hold's start and deadline, and the number of held requests.
    • inference:hold_ended carries fit, check_failed, deadline, nothing_pending or stopped, with the time waited.
  • The turn's terminal event comes when the held turn ends, after a hold of up to 30 minutes and the compile it releases, rather than as an exhaustion within seconds.
  • getHostModeStatus() reads the held turn as alive, and the wakes that joined it among gatedRequests. healthSnapshot() gives each agent's budgetHold while a turn is held waiting, and null otherwise.
  • The refusals: a branch switch, a rollback, an undo that would move the store, and puppetToolCall are refused while a turn is held, for example "Cannot switch branch while prime is idle+turn-alive (a budget hold keeps its turn to start again once its context fits, or at 2026-10-27T16:17:31.965Z at the latest: Compile plan would exceed hard budget: …)", followed by main's advice on quiescing. A host that ran a surgery on an agent whose turn had ended on such a refusal now waits for the hold, up to its 30 minutes, or ends the turn.
  • The context-refusal ops alert goes out when the hold starts, saying the turn is held until its deadline while compression catches up. Its ops:alert trace goes out at every hold's start, and its webhook post goes through opsAlert's cooldown of 15 minutes for each agent and kind (src/framework.ts:3554, :28923).
  • The logs: a hold's drain kicks log as [budget-hold], and the breaker's as [inference-failed].

Tests

test/budget-refusal-hold.test.ts has 78 tests, and test/stop-step-throws.test.ts gains a row for a hold's drop. Most run a resident through a real AgentFramework and a mock membrane. They patch ContextManager's compile, previewContext, tick and isReady to model a compression backlog, following context-manager's contracts: the preview says whether the context fits and rethrows a refusal other than over-budget, and a committing compile refuses while the context doesn't fit. A dry compile, reached only for a strategy with no preview, refuses where the context doesn't fit, as the strategy's hierarchical path does, and one test has it resolve an over-budget plan, as context-manager documents a dry compile can. Their groups are the hold and its release, the turn alive, the verdict, the snapshot, the ends and the record, undo, and the rest: a batch-only start, a withdrawn wake, a held continuation's event gate, a deferred batch's wake beside a held continuation, and a conversation agent.

Three tests run the real autobiographical strategy, with its tick stubbed, since there's no compression model:

  • The note's margin case: at the smallest budget the preview fits, with under 10 tokens to spare, the released start stores its note, its compile is refused, and the hold holds the turn again. With 1,000 more tokens of budget, it runs, and its request carries two notes, one from each release.
  • A context refusal: with a coverage defect injected into the strategy's emission, the preview rethrows UncoveredDropError, the turn is held once through four looks, and it runs once the defect clears.
  • Without adaptive resolution, the strategy's hierarchical path: its preview gives no verdict where the context fits, in either version's form, its dry compile refuses where it doesn't, and the hold ends fit once a budget fits, by the dry compile's verdict.

Other pull requests

This head merges main at e85c57db, still main at 13:37 UTC on October 28. These meet the hold:

Evidence

These runs were on Linux with Node 24.22.1, each from a clean checkout of the commit it names, built from an empty dist. main and this head require context-manager ^0.23.1. 0.23.1 was published on October 27, and it's npm's latest at 13:37 UTC on October 28. On a pull request, this repository's CI runs the suite on Linux with Node 22.12.0, 24 and the latest, and on macOS with Node 22.12.0.

  • At this head, with context-manager 0.23.1 from a fresh install: test/budget-refusal-hold.test.ts passes 78 of 78, test/stop-step-throws.test.ts 21 of 21, and main's test/exhausted-turn-rearm.test.ts 54 of 54. These are the only files run at this head.
  • The suite, at commits before this head's merge of e85c57db:
    • at d3076846, before the last fixes, which change src/framework.ts +17/−12 and src/types/trace.ts +4/−3: 8,597 tests, 8,535 pass, 0 fail, 62 skipped, with context-manager 0.23.1;
    • at 6748c476, before it: 8,595 tests, 8,533 pass, 0 fail, 62 skipped, with 0.23.0 and again with 0.23.1;
    • at main's 8ff287dd, with 0.23.1: 8,520 tests, 8,458 pass, 0 fail, 62 skipped.
  • Under load: six runs of test/budget-refusal-hold.test.ts at once, three times over, pass 18 of 18: at d3076846 at one-minute load averages of 137 to 155 with 0.23.1, and at 6748c476 at 55 to 74 with 0.23.0.
  • On main's source (8ff287dd), with d3076846's test file and context-manager 0.23.1: 72 of its 76 tests fail. The file builds there only with type errors, since main has no hold traces, and its tests run anyway. The four that pass are the cases this change leaves alone: nothing pending at the refusal, a refusal once stop() has closed admission, an ephemeral run, and a framework that was never started.
  • Each test the last review rounds added or strengthened fails, for the reason it names, on the source before its fix: with d3076846's test file, 9 at cb19c3ce, 3 at f91c676d and 2 at 6748c476; with this head's test file, 4 at d3076846.
  • Removals, each piece undone on its own and rebuilt, with test/budget-refusal-hold.test.ts run alone:
    • at d3076846, each of the 11 pieces that 735ab6c6 and 3516350a add or change fails a test aimed at it;
    • at e30e5f32, each of the 3 pieces 63c569b9 adds, where it replaced three of those 11, fails a test aimed at it;
    • at 06e6ac3a, 16 of the 18 pieces of the rebuild on fix(scheduler): a turn a provider hold keeps for its retry is alive #774's model did the same, and the other two are under "Not verified". Since then, 735ab6c6 removed two of those 18, replaced one, the dry compile's binding, and added the clock row to another's rule, the stored-injection exclusion. The replaced piece and the changed one are both among the 11 at d3076846.
  • 0.28.0 beside this change, one probe run unchanged on both: a resident whose compile is refused for budget with compression pending, and which then fits.
    • At 0.28.0 (59d60958, context-manager 0.23.1): three refused attempts, exhausted after 3.4 s; drained: true; a branch switch admitted; and 3 s after the context fit, with no new message, no provider call.
    • At d3076846 (context-manager 0.23.1), an earlier version of this change, before its last fixes and this head's merge of main: held at once; drained: false, with the agent in activeTurnAgents; the branch switch refused, naming the hold; and the turn ran once the context fit.

Not verified


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

Credits

Octavia-2412 wrote this change from Ada-1017's proposal on #304 and her design reviews, among them the choice to take main's held-turn model and to store the note. Hilda-2681's #774 gave that model.

Anarchid's review of bc3bb8e found that the release check took a dry compile that resolved as a fit, which context-manager documents a dry compile can't tell, and reproduced the band where the two disagree against the published strategy. It also found the undo step, the policy veto, the hold left without a cause, the checks run when nothing had changed, and what the text claimed about a restart and a stalled compression. Greptile found five defects in the first version and five in a later head. Beth-2389 read the rounds up to 2cf69a09, and her probes found a rerun that kept a fit from landing and a context refusal the preview rethrows counted as a failed check. Gordon-2414 read the round that answered Greptile's second review, the second merge of main, and the merge of 8ff287dd, where he found that any changed injection, not only the note, could release a refused turn again, and each round after it, where he found that a held tool-result guard's retry gave back the turn's first checkpoint. He also read this head's merge of e85c57db. Milo-2653 and Duncan-2410 found where #529 and #517 meet this change. Greta-2425 read the rebuild after Anarchid's review, and Fiona-2757 and Daniel-2612 checked earlier versions of this description against their evidence. Internal reviews found the rest. theaspirational reported the incident. Lottie-2661 wrote this description, from Octavia-2412's facts.

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 asked for in Ada-1017's read of the round (room-203 #97000).

- 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 Beth-2389's read of 13bd4f4 (room-203 #97114), with her 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 41afd11 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 (Beth'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 Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
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 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.
…gate closed once it ends

Beth-2389's suggestion on d8e3325 (room-203 #97370): 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

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 and others added 8 commits October 15, 2026 07:50
…, as one record per held turn

From Anarchid's review of bc3bb8e 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, with Ada-1017's 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… store mid-tick, and the surgery waits for it

From R1-4 of Anarchid's review of bc3bb8e 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… removed messages, a release keeps its evidence, and a released turn with nothing pending spends its hold

From an internal review of 22bb6ca..48aaf95. 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, with reasons in the review's ledger:
- 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. Ada-1017's call; the class is a framework issue she is filing;
- 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Octavia-2412 and others added 8 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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. Beth-2389 found it reading 65359f3 and showed it with a probe on the real strategy: 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:
- Beth-2389'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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 ace7072). 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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. Beth-2389 pointed out the gap between the description's "held once through four looks" and what the test asserted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ile refuses

The release check's comment, the changelog fragment and the band test's comment said a dry compile "doesn't throw an over-budget plan at its plan check". In context-manager 0.14.1 that holds only for a calibration multiplier that isn't provisional and above 1. Under one that is, the dry compile's plan check throws, as the committing compile's does, and selectPath drops the multiplier to 1 and selects again, where the dry compile reports and doesn't throw (strategies/autobiographical.ts:8853-8854 and 4962-4971). In both cases a dry compile can resolve a plan the turn's compile refuses, which is what the hold needs from the preview and what the band test shows, so the three texts now say that. Greta-2425 pointed out the unscoped claim. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Octavia-2412 and others added 3 commits October 19, 2026 05:10
Six conflicts, all in src/framework.ts:

- stopOwned: anima-research#517 runs each step of the teardown through step(), so one
  that throws can't skip the ones after it. cancelProviderAdmission is
  main's step, and the hold's drop runs through step() too, one step per
  hold, as stopOwned's comment asks of a step added there.
- sweepExpiredConversations: the reaper's comment is anima-research#204's, with the
  hold's sentence after it.
- startAgentStream's catch: anima-research#521's event-gate window end and the hold's
  settle both run, the window end first.
- beginAgentTurn's injections: anima-research#284 removed the silent-heartbeat
  injection, and the hold's note stays.
- beginAgentTurn's hand-off to driveStream: the hold's settle and anima-research#529's
  window close both run.
- beginAgentTurn's catch: anima-research#529's check for a stop the failed start kept
  comes before the budget hold, as it does before the provider hold, the
  stated wait and the error policy. A stop abortInference() reported as
  taken then ends the turn, rather than going into the hold and running
  when the hold ends.

Beyond the conflicts, anima-research#529's endStoppedStart ends a start that a stop
reached before it sent anything, at its checks after the hooks, after the
compile, at a failure and after a wait to retry. A hold's record of a
released turn ended there now goes too (settleBudgetHold), as at the
start's other ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ach a refused or released turn's start

Three tests for what the merge of main composed:

- stop-step-throws gains a row for a budget hold's drop. Of two holds'
  records, the first one's drop throws: every later step of stop() still
  runs, stop() fails with that error, and the second hold's drop still
  runs, since each hold's drop is a step of its own.
- budget-refusal-hold: a stop made while a released turn compiles is
  reported taken and ends the turn at the check after its compile, with
  nothing sent. The hold's record goes with it, and the agent's next turn
  carries no note of the stopped turn's wait.
- budget-refusal-hold: a stop made while a turn compiles, whose compile is
  then refused for budget, ends the turn at its failure rather than
  holding it.

The harness gains compileGate, which makes the nth compile wait until a
promise settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in's coalescing gate leaves it

Since anima-research#520, past the coalescing gate beginAgentTurn carries a copy of its
trigger without a batch wake's marks, on every start and every attempt,
before the try whose catch calls holdForBudgetRefusal. The hold's own
clearing of coalescingSubject, coalescingBatch and coalescingBatchSubjects
on the refused start's request did nothing after that, so it goes, and the
hold keeps a copy of the trigger as it comes. The comment says where the
marks go now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Octavia-2412 and others added 10 commits October 27, 2026 11:41
…n's held-turn model

Main now treats a turn a provider hold keeps for its retry as alive
(TURN_RETRY): it counts in activeTurns, drained reads false, it passes a
quiesce, surgery refuses it as agent-busy, and an abandon, stop() or the
agent's removal ends it with one inference:aborted. anima-research#361's budget hold did
the opposite: the held turn gave its token back and read as idle, so a
surgery was admitted while it waited, and the turn was then released onto
the store the surgery had moved. A run at anima-research#361's head 54565d8 showed that:
a rollback past the held turn's message was admitted, and the released turn
answered without that message in its history.

The conflicts sat on that difference (passesQuiesce, gatedRequests, the
scheduler's turn-alive test and trigger selection, the reaper, the policy
drop). They are resolved by taking main's model and making the budget hold
one more place a turn's retry waits:
- the hold keeps the refused start's request as its turn's retry
  (asTurnRetry, with the turn's carry), joins later wakes with holdRequest,
  and the failed start ends as a provider-held start does (endHeldStart);
- pendingContinuationAgents, gatedRequests and heldTurnRetry read the
  budget holds, so the turn is alive, a wake that joined it counts as
  gated, and surgery's and the puppet's refusals name the hold and the way
  on (a quiesce with abandon);
- dropContinuations ends a budget hold with the turn whose retry it holds,
  putting back the wakes that joined it after a caller's stop or an
  abandon, and endContinuedTurn settles a released hold's record;
- the release queues the held requests as they are, and main's scheduler
  merges and passes them.
What goes: releasedByBudgetHold and the passesQuiesce exclusion; the
surgery, undo and redo re-check (checkBudgetHoldsAfterSurgery, and the
storeSurgeries count in the signature), since no store move is admitted
while a turn is held; the check's surgeryHold guards; HostModeStatus's
heldTurns, since the held turn is in activeTurnAgents; the reaper's own
budgetHolds check, which hasTurn covers; and the scheduler's deferred-batch
and guard-retry trigger handling, which main now does for every
continuation.

Main's stored injections (0e2fa84) store every afterUser injection, so the
note a released turn's compile carries is stored too, where the turn reads
it. Its words now say only what happened ("... so this turn waited 7 min
for compression to make room."), true whatever its compile then meets. The
release check's store-change count leaves out the note's own rows: without
that, a released turn whose compile is refused would be released again on
the strength of its own note.

The release check asks the preview, and the fallback dry compile, with the
agent's thinking binding, as main's previews ask (previewContextFor), so
its verdict prices a summary's carriers as the turn's compile does.

kickOverBudgetDrain takes anima-research#353's stop for an undo or redo that moved the
store under a tick, for the breaker's kick and the hold's, each with its
own tag and label.

Tests: those that pinned an idle held agent are turned round (the status
reads it alive; a surgery, a rollback, an undo and a puppeted call are
refused naming it; a quiesce waits for it and its abandon ends it; a
caller's stop ends it), the note tests read the stored note, and tests of
scheduler behaviour main now owns are removed. One test pins the thinking
binding the check asks with, and one that a dry compile resolving doesn't
release a turn the preview says doesn't fit. Context-manager 0.23's dry
compile and preview agree on the real strategy's fixture at every budget,
so the real-strategy test now pins the stored note's margin case instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The budget hold reported itself (inference:held, then the context-refusal
alert) while the refused start's check window was still open. A stop a
listener made then went to that window, which nothing collects once the
start has handed its turn to the hold, so the turn stayed held.
holdForBudgetRefusal now ends the start (endHeldStart, passed as endStart)
once the hold has the turn's retry, before it reports, and a stop made
then ends the held turn as abortInference ends any held retry.

The new test fails at 5e92441 (the hold never ends within 5 s) and passes
here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… records a stop or disposal drops

upcomingWakesFor lists a turn held for its context budget, as it lists one
a provider cooldown holds, so a reply judge deciding on a batch is told
that a turn will run once the context fits, by the hold's deadline at the
latest. Before, it was told nothing would wake the agent.

stop() and a conversation agent's disposal already dropped every budget
hold's record. Since a held turn now ends through dropContinuations, that
covers what a waiting hold kept, and what is left for them is the record of
a released turn whose compile they reach: that start ends on the
stopping-or-removed path, which settles no record, so without the drop the
record would outlive the stop, or pass to a later agent of the name, with
its old start and deadline. A test pins both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idence; give the judge its deadline

A released turn's start stores its afterUser injections before its compile,
the hold's note among them, and any other whose content changed since its
last copy (an MCPL hook's status text, say). The store-change count left out
only the note, so when the released compile was refused, a changed
injection's row moved the signature, and the next look released the turn
again on a row its own start had stored, round after round until the
deadline, each round adding rows to a context that didn't fit. With the
count leaving out the note only, the new test sees 27 holds in 600 ms where
it expects 2. The count now leaves out every stored injection's arrival
(storedInjectionMarkOf): the framework placing context can't make a refused
compile fit. A removal or an edit of one still counts.

The reply judge's line for a held turn now carries the hold's deadline as
its time, so it reads, for example, "in 30 min: a turn held for its context budget, which
runs once its context fits, and by then at the latest". Without a time it
read "as soon as it is free", which isn't so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ository's comments take

The comments this change added, in src and in its test file, go, except
these:
- the doc comments of the public API it changes: HostModeStatus's drained,
  activeTurnAgents and gatedRequests, the inference:held and
  inference:hold_ended trace events, and Agent.nextCompileBudget;
- main's comments it edited so they stay true of a budget hold beside a
  provider hold (heldTurnRetry, dropContinuations, quiesce's and the
  puppet's refusals);
- main's comment on the breaker's drain kick, which moves with that code
  into kickOverBudgetDrain, its alert now named as the breaker's, since a
  hold's kick comes after the hold's alert;
- lines on context-manager behaviour the code and the tests rely on: the
  preview answers an over-budget plan with fits: false and rethrows any
  other error, a dry compile can resolve a plan the committing compile
  refuses, the hierarchical strategy's preview needs adaptive resolution
  for a context that fits, a strategy's progress snapshot needn't show what
  its readiness reads, and the coverage refusal the preview rethrows;
- ContextManager.compile's default budget, as main's comment named it.

The reasons the removed comments gave are in the commit messages that
brought their code and in the pull request's description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ld's count leaves out

06e6ac3 left every stored context injection's arrival out of the
store-change count the release check compares, not only the hold's note,
and the Fixed fragment still said only the note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p the refusal itself, and widen what the hold leaves out

- A hold's end is reported after its requests are queued and its state
  set. A listener of inference:hold_ended that stopped the turn used to
  find the record still holding: the stop ended the turn, and the release
  then queued the stopped turn's retry, which ran.
- A hold is reported, logged and alerted only while it still stands. A
  listener of inference:held that stopped the turn ended the hold, and
  the alert still said the turn was held until its deadline.
- The fallback verdict, for a strategy with no preview or a fitting
  context on the hierarchical path, is a dry activation
  (previewActivation), which gives the compile the thinking binding with
  its identity. Given the binding alone, context-manager refused the dry
  compile as unbound (refuseUnboundBinding), so the check read
  check_failed whenever the model binds its thinking.
- A spent hold's refusal reaches the error policy as the refusal itself,
  its message carrying when the turn was first held. A plain Error in its
  place lost the refusal's class and its budget and size.
- The checkpoint a hold gives back is the one the turn's first start
  recorded, kept per agent, so a hold entered on an error policy's retry
  gives it back too. dropTurnCheckpoint gives it back only while it is
  still the newest.
- A clock row's arrival is left out of the store-change count, as a
  stored injection's is: a released start stores one when a clock
  boundary has passed, and a refusal of its compile then released the
  turn again on it.
- The signature carries the budget the next compile plans at, so a change
  to its reserve, such as the tool-result guard's pending reserve
  settling, brings a look though no runtime setting moved.
- upcomingWakesFor no longer lists a held turn. The reply judge builds a
  preview of the activation first, which throws the same refusal while
  the context doesn't fit, so the line never reached a judge.

Tests: one each for the hold's end reported after a stop, a hold on the
error policy's retry, the clock row, and the reserve; the deadline test
checks the error the policy gets, the binding test that the verdict is a
fit, and the reported-stop test that no alert names the ended hold. The
surgery, rollback and undo refusals check their agent-busy code, and the
undo-limit test compares the checkpoints whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ends without a fit leads to, and what the count leaves out

inference:held and inference:hold_ended said a hold that ended without a
fit led to the turn's exhaustion. Its requests are queued for one fresh
compile, which runs if the context fits by then; only a refusal of it
takes the error policy's path. The Fixed fragment names the clock row and
the next compile's budget among what the hold watches and leaves out. The
Changed fragment says the note is stored at the turn's next start after
any end of its hold, and placed by the compile while the window is held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no checkpoint

735ab6c gave a hold the checkpoint the agent's last start recorded, so a
hold entered on an error policy's retry gives it back. A tool-result
guard's retry records none (it continues the turn after rounds have run),
so a hold of one, or of an error policy's retry of one, gave back the
checkpoint of the turn's first start: the turn then had no undo step of
its own, and an undo took the turn before it too. The hold now gives
nothing back for a guard retry, whose reason its retries keep.

The new test holds a guard's retry, refused for budget after a tool
round, and checks the turn keeps one undo step; at f91c676 it has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ger 0.23.1's null preview

context-manager 0.23.1 (published 2026-10-27, within the ^0.23.0 range)
returns null from ContextManager.previewContext where the hierarchical
path's select fits and there is no fold plan to preview, where 0.23.0
threw "requires adaptiveResolution". The hold reads either as no verdict
and asks the dry compile, but the test asserted the throw, so it failed
on a fresh install. It now asserts the preview gives no verdict for a
fitting context, by either form.

With 0.23.1 installed fresh, budget-refusal-hold passes 74 of 74 here;
before this change, 73 of 74, this test failing on the missing throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Octavia-2412 and others added 9 commits October 27, 2026 14:34
…rom the first refusal, and two tests wait for the turn's completion

Under heavy load (a load average of 60 to 90), four tests failed now and
then, each for a reason in the test, not the hold:
- The backlog fits once fitAt maintenance ticks have run. The pass at
  start, and under load a second one, could run 12 or 20 ticks before
  the turn's first compile, so the turn was never refused, and the tests
  that wait for a hold saw none ("tells the agent why its turn waited",
  the thinking-binding test, "stores the note once"). A tick now counts
  only once a committing compile has been refused.
- Two tests waited for the answer to be stored and then read the turn's
  inference:completed, which comes after the store. They now wait for
  inference:completed.

Six runs of the file at once, three times over, at a load average of 55
to 74: 18 of 18 pass here. At 1f661e2, one of six runs at once failed a
test, and so did the full suite. Versions of this change that fixed only
some of the above failed one run in 12, and one in 18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…topped hold reports its end after the turn's

- A hold gives back the turn's checkpoint only when the refused start
  recorded it, the turn's first start. A retry that's refused, an error
  policy's or a tool-result guard's, leaves the checkpoint its first
  start recorded, before the rounds the turn ran, and the record says so
  (keepsCheckpoint): the released start then records none. 735ab6c gave
  back the first start's checkpoint at an error policy's retry, so after
  rounds had run, the released start recorded a checkpoint after them,
  and an undo left them in place. The per-agent map that did it is gone,
  with 70c8d24's exception for a guard retry, which this rule covers.
- A stopped hold's inference:hold_ended comes once dropContinuations has
  ended the turn and put back the wakes that joined it (takeBudgetHold
  hands its requests and the report to the caller). It came after the
  record was gone and before the requests went back or the turn ended,
  so a listener found the agent with no turn and could move the store
  under the turn's cleanup.
- The hierarchical preview's comment names both context-manager versions'
  answers for a context that fits: 0.23.0 throws, 0.23.1 returns null.

Tests: a turn whose stream fails after a tool round, and whose error
policy's retry is held, keeps one undo step that takes the round; the
guard-retry and error-policy tests undo the turn; a stopped hold's end is
reported after the turn's inference:aborted, with its joined wake queued.
The deadline test checks the policy gets the very refusal the compile
threw, the reserve test the budget the look asks at, and the undo tests
wait for the turn's end before they undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…retry's undo step falls, and that a refusal after a hold takes the error policy's path

inference:hold_ended says a refusal after a hold that ended without a fit
takes the error policy's path, as inference:held does, and that a stopped
hold's end is reported after the turn's inference:aborted. The Fixed
fragment says when the undo keeps the messages that arrived while a turn
waited (its first start was the one refused) and when it takes them too
(a retry was).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…art adds, and comments only of the kinds the repository keeps

The clock-row test expected two clock rows at the release. A message's
arrival and a turn's first start each store one when a clock boundary has
passed since the last, so a first start a few milliseconds after its
message, across a second boundary, made three on correct code. It now
counts the rows before the release and expects one more.

Comments that weren't a public API's contract or an outside component's
behaviour go: the hold record's keepsCheckpoint, takeBudgetHold's doc, a
line in dropContinuations, and the test helpers' docs added since
ac1e420.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed start moves it, and a stopped hold is reported even if the teardown throws

- A hold no longer gives back the checkpoint its refused start recorded.
  The record keeps it (checkpoint), and the start the hold releases
  moves it: gives it back and records the turn's, after the messages that
  arrived while it waited. A hold that ends stopped leaves it, so the
  stopped turn keeps its own undo step. Given back at the hold, as
  before, a stopped held turn left none, and the next undo took the turn
  before it together with the message that started the held one. When
  the refused start recorded none (a retry), the record holds none and
  the released start records none, as 3516350 made it; this replaces its
  keepsCheckpoint.
- dropContinuations reports a stopped hold in a finally around the turn's
  teardown, so a throw there still leaves its log line and trace.
- The start's checkpoint comment is main's line again.

Tests: a held first start that's stopped keeps its undo step, and the
undo leaves the turn before and the stopped turn's message; the retry
tests add a message while held and check the undo takes it, and check
the reply and the guard's round are there before the undo; the undo
refusal, limit and redo tests read the held turn's checkpoint as kept
until the release moves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and when it moves the turn's checkpoint, and inference:held's doc leaves out a stopped turn's fresh compile

The Fixed fragment said one held turn is one undo step, which a held
budget restart, recording its own checkpoint as on main, doesn't bear
out: a hold adds no undo step. It says the released start moves the
checkpoint a refused start recorded, and that a stopped hold leaves it.
inference:held's doc promised a fresh compile after the hold's end,
which a stopped turn doesn't have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the turn's teardown throws

With endContinuedTurn throwing, abortInference throws, and the hold's
inference:hold_ended('stopped') is still emitted, from 63c569b's
finally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ckpoint keeps the context injections the refused start stored

They sit before the checkpoint the released start moves, as rows stored
before any start do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in startAgentStream's terminal failure: this branch settles
the agent's budget hold record there (settleBudgetHold), and main now
records an exhausted rearmed turn's transient failure there
(exhaustingRearms). Both lines are kept, in that order; neither reads
what the other writes. A budget refusal is no MembraneError, so it is
never a transient failure that main rearms.

budget-refusal-hold (78 tests) and stop-step-throws (21) pass on the
merge with context-manager 0.23.1, which main now requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

Agent stays silent after an immediate budget decrease: compression recovers, but failed activations are not retried

2 participants