Skip to content

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

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

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

TL;DR: A resident or conversation agent whose compile is refused for budget now waits while compression is pending. Later wakes join the held turn. The hold releases the turn for a fresh compile when its context fits, after 30 minutes, or when compression has nothing pending. A turn that fits then runs; otherwise its refusals follow the error policy. It uses context-manager's preview for the release check, keeps one hold record per agent, and skips repeated checks on an unchanged readable snapshot.

!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 three times, 1, 2 and 4 seconds apart, and the turn ends as exhausted.
  • What follows: the over-budget breaker from Compile robustness: empty-text 400 sanitize + OverBudget deadlock breaker #58 kicks the compression drain and raises a context-refusal ops alert.
  • Then nothing: once compression makes the context fit, nothing runs the turn again. 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

A refused compile holds its turn (holdForBudgetRefusal, in the catch that sees a compile's refusal, before the error policy).

  • 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. Its 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.
  • Compression keeps running: the hold leaves the agent's provider gate open, so the maintenance loop keeps ticking the agent.
  • The agent is idle meanwhile: the refused start gives back its turn token, and a surgery, such as a branch switch, an undo or a redo, is admitted.
  • Later wakes join it, for one compile. A coalesced wake withdrawn while held leaves the hold.
  • The refused start's own request is held as an ordinary cause. Past the coalescing gate, main carries a start's trigger without a batch wake's marks (fix(mcpl): withdraw a batch-only turn whose batch is gone, rendering elsewhere, or queued for another agent #520), so a cancellation of its subject doesn't withdraw the held turn.
  • One held turn is one undo step. The refused start's checkpoint is dropped, with what recording it took: the oldest checkpoint it evicted at the limit of 20 and the turn index it took. The redo its new work invalidated stays invalidated, as at any turn's first start, since a redo would switch the store to a branch without the held turn's input. The released start records the turn's, after the messages that arrived while it waited, so undoing the turn keeps them.
  • An undo while a turn is held takes the previous turn, and with it the held turn's messages, which came after that turn's checkpoint. The held turn then runs on what the undo left. An operator's undo leaves a pending wake to run on the rewound branch, and that run clears the redo stack #381 describes this for every pending wake, with a provider hold's and a gate debounce's, and proposes one rule for them all.
  • A closing conversation's held closure turn counts as unfinished work, so the closing-fork reaper leaves it.
  • The drain kick runs when the hold starts.

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

  • Why the preview: just over the budget, a dry compile can resolve a plan the turn's compile refuses (SelectOptions.dryRun). The preview reports that plan as not fitting.
  • A refusal the preview rethrows, such as a context refusal (UncoveredDropError), counts as a refusal, as it does from a dry compile. Any other error ends the hold (check_failed) and releases the turn for its own compile.
  • A strategy with no preview gets a dry compile. The hierarchical strategy's preview reports a context that doesn't fit, and needs adaptive resolution for one that does, so a dry compile decides then. Its dry compile refuses where the context doesn't fit, as its committing compile does. Only one dry compile runs at a time, and its refusal counts only if nothing the check watches, listed below, changed while it ran.
  • When it runs: after every maintenance pass, every drain kick the hold starts, and every surgery, undo and redo.
  • With a readable snapshot of the following values, the check skips another verdict while they remain unchanged:
    • compression's progress and whether it still has compression pending, since a strategy's progress snapshot needn't show what its readiness reads, and some strategies report none;
    • the agent's store: a message added, edited or removed, by any path, counted through context-manager's onMessage;
    • the agent's runtime settings: its budget, and the tail and pace that shape the plan;
    • the store, by a surgery, undo or redo.
  • If it fits: the hold's requests go to the queue together, for one fresh compile.
  • If it refuses and compression has nothing pending, the hold ends without a fit.
  • No check runs while a surgery holds the store. When the surgery releases the store, the hold looks again.
  • With the maintenance timer off, the hold kicks the drain and looks again every 5 s, the timer's default interval. While the provider gate is closed, it only looks.
  • A released turn whose own compile still refuses holds again on the same record, with its first refusal's start and deadline. While its snapshot can be read, it isn't released again until something the check watches has changed since the check that released it.

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. Before the bound, the hold ends when the context fits, when a check fails for another reason, when compression has nothing pending, or at a stop. 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, its requests go to the queue for one fresh compile, as at a fit. If the context fits by then, the turn runs, with the note. Otherwise its refusals take the error policy's path, as before this change, and the failure says 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)". A released turn refused after the bound, or with nothing pending, fails the same way.

A released turn the inference policy declines ends there. Its logical turn closes and its record goes, so no note or deadline of it reaches a later turn.

At stop, or when the agent is disposed, a hold still waiting ends stopped, and its requests go with it. Each hold's drop is a step of stop()'s own, so one that throws doesn't keep the rest of the teardown, or the other holds' drops, from running. A turn that fails leaves no record behind, including a start that fails outside its compile.

A stop that reaches a turn's start before it sends anything, such as an abortInference() made while the turn compiles, ends the turn there, at #529's checks. For a turn whose compile is then refused for budget, that comes before the hold, so the turn isn't held. A released turn ended this way takes its hold's record with it, so no note or deadline of it reaches a later turn.

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.

A released continuation, such as a budget restart, takes its event gate entry again when it resumes, as main does since #457 for any continuation whose entry ended in between. A released budget restart waits, as a wake does, for a turn token someone else holds, such as a puppeted tool call's reservation. Its own turn gave its token back when it was held, so the scheduler's exception that lets a restart start while its own turn still holds the token doesn't apply.

Under host quiesce, a held turn holds no turn token, so quiesce() counts the host drained. A request the hold releases while the host is quiesced parks as a wake does, a continuation included, whether it's still queued or already waiting on provider admission, and runs at resume. So no such turn starts while the host is quiesced. getHostModeStatus() counts it among gatedRequests, and its new heldTurns counts the turns still held.

A deferred batch's wake is left out of a turn that resumes a logical turn, such as a budget restart, since that turn skips batch assembly and the batch would never render. It stays queued, with its own inference policy decision, and wakes a fresh turn of its own once that one ends. This also fixes the same loss on main, where a batch's wake that waited behind a turn which then restarted for its context budget was dequeued with the restart and consumed, or dropped with it when the policy declined the restart. A wake that joins a held continuation is released beside it, and stays out of it the same way. A guard retry a hold releases is its turn's trigger wherever it was queued, as a budget restart already was.

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.
  • The breaker's drain kick, now in kickOverBudgetDrain, runs at exhaustion as before.
  • immediate: true stays allowed, as before. A compile refused after a budget decrease can enter the hold under the conditions above.

What each part is for

What the change is for. A compile refused for budget fails before any provider call. Such a turn should run once compression makes room, without waiting for a new wake, and the wait needs a bound. Against main, the change adds 778 lines to src/framework.ts and removes 92, adds 46 to src/types/trace.ts and 4 to src/types/agent.ts, adds 11 to src/agent.ts and removes 2, plus a changelog fragment, one test file of 68 tests, and a row in stop-step-throws.

The parts, and what each buys:

  • A hold record of its own, one per agent (BudgetHold, apart from the provider holds). The hold leaves the provider gate open, so compression keeps running while the turn waits. A provider hold's record would close that gate, and a maintenance pass skips an agent whose gate is closed (runQueuedMaintenance's providerGateBlocked check).
  • The agent counts as idle while held. A branch switch, an undo or a redo is admitted meanwhile. An agent kept busy would refuse every surgery for up to 30 minutes.
  • Later wakes join the held turn. One compile answers all of them, where each would otherwise start a turn of its own and be refused.
  • The release check uses context-manager's preview. The preview is where context-manager reports whether a plan fits (SelectOptions.dryRun's documentation), and just over the budget a dry compile can resolve a plan the turn's compile refuses. The dry-compile fallback gives a fit verdict where the preview can't: for a strategy with no preview, and for the hierarchical strategy, whose preview can't judge a context that fits.
  • The signature of compression's progress and whether it's pending, a count of the store's changes, the runtime settings and the surgeries. A stalled backlog costs no preview per pass, and a turn its compile refuses again isn't released on the same evidence. It costs an onMessage listener and a counter per context manager, and a look that finds nothing changed costs building and comparing the signature.
  • The hold looks again after every maintenance pass, every drain kick it starts, every surgery, undo and redo, and every 5 s with the maintenance timer off. Each is a moment the context can come to fit.
  • A constant 30-minute bound (BUDGET_HOLD_MS), with no option and no stall rule. The alert at the hold's start tells the operator, and the bound ends the wait.
  • The note in the released turn's compile, so the agent knows why its turn waited. It's an injection, so it isn't stored, isn't counted against the budget and moves no routing.
  • The traces and the status (inference:held, inference:hold_ended and healthSnapshot().budgetHold), so a host can see a held turn and why its hold ended.
  • Quiesce parks what a hold releases (releasedByBudgetHold on the request until its turn starts, read by quiesce's gate, runUntilIdle()'s idle test, gatedRequests and the provider admission recheck). A held turn holds no token, so quiesce reports the host drained, and what the hold releases mustn't start a turn before resume. It costs a framework-owned field on InferenceRequest, one predicate where four checks had a reason test, and heldTurns on the host status.
  • A deferred batch's wake stays out of a turn that resumes a logical turn. Such a turn skips batch assembly, so a batch's wake it consumed never rendered. That happened on main after a restart, and a hold makes it likelier. It costs one filter at the scheduler's choice of trigger. That choice now comes before the busy check and the policy, and it takes a guard retry a hold released wherever it was queued.
  • The hold needs a running framework. Without start(), nothing would run a released turn. It costs one condition, which also covers a refusal once stop() has begun, where an admission check stood.
  • The edges, each answering a defect a review found, each with its test: one undo step (R1-6), a declined released turn (R1-5), a withdrawn wake, the reaper, a start that fails outside its compile, stop and disposal, a refusal the preview rethrows, the undo history at its limit of 20, a released restart while another holds the turn token, and, on the fallback path, a refusal that compression made stale while the dry compile ran. The refused start's own request as a cause (R1-7) keeps its test and needs no code of its own now, since main's coalescing gate strips a batch wake's marks (fix(mcpl): withdraw a batch-only turn whose batch is gone, rendering elsewhere, or queued for another agent #520).
  • The stops main added, each with its test: a hold's drop is a step of stop(), one per hold, and the record of a released turn a stop ends at its start goes with it.

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 (R1-2).
  • An option for the bound, and a stall rule (above).
  • R1-9's re-lookup of a hold when an agent's queued requests are processed. Its window is one scheduler poll.

Where we're less sure. On the fallback's path, the hierarchical strategy and strategies with no preview, the dry compile awaits, and compression can move while it runs. Its refusal counts only if the signature, whether compression is pending included, is unchanged when it ends, as far as the signature can be read. Otherwise the next look takes a fresh verdict. That's code for a path only those strategies take. Without it, a refusal that compression overtook during the dry compile could end the hold nothing_pending, and if the turn's fresh compile were then refused, the turn would fail though compression could still make room.

What the agent and the host see

The agent:

  • While held: nothing, and no [inference-failed] marker is stored.
  • When the held turn runs, after a fit, a failed check, or a bound that passed before the fit, its compile carries a note: "[budget-hold] Your context didn't fit its budget (100,201 tokens against 48,568), so this turn waited 7 min. It fits now." It's an injection of that compile alone, which context-manager merges after selecting within the budget, so the note isn't counted against the budget. A restart later in the same turn compiles without it. It's never stored, and it leaves the turn's routing as it was.
  • If refusals after the hold exhaust the error policy, the agent gets the [inference-failed] marker it gets today, with the reason above.

The host:

  • The tested one-hold success path has these traces: 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 follows the hold, rather than an exhaustion within seconds of the initial refusal. A successful released turn emits inference:completed; refusals that exhaust the error policy emit inference:exhausted. The hold has a 30-minute deadline, and a released compile can finish later.
  • healthSnapshot() gives each agent's budgetHold while a turn is held waiting, and null otherwise.
  • getHostModeStatus() gains heldTurns, the turns still held, and quiesce()'s log line names them. While the host is quiesced, a request a hold has released counts among gatedRequests until resume.
  • 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 at most once per 15 minutes for an agent.
  • 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 68 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. A committing compile refuses while the context doesn't fit. A dry compile, reached only for a strategy with no preview, refuses as the hierarchical strategy's does. Three tests run the real autobiographical strategy, with its tick stubbed, since there's no compression model. Two are otherwise unpatched, and the third injects a coverage defect into its emission:

  • The band: at a budget, found by searching just above the plan's size, where a dry compile resolves and the preview says the plan doesn't fit, the turn's compile is refused and held once. In a second of passes it isn't released into the same refusal. At the smallest budget whose preview fits, with under 10 tokens to spare, it's released, and its compile carries the note.
  • The hierarchical fallback: its preview needs adaptive resolution, its dry compile refuses where the context doesn't fit, and the hold ends fit once a budget fits.
  • A context refusal: with a coverage defect in the strategy's emission, the preview rethrows UncoveredDropError, the turn is held once through four looks, and it runs once the defect clears.

The others cover, in turn: the turn running once compression catches up, through passes made while it's held; its traces; the note's text and its single compile; later wakes; nothing pending at the refusal and later; the bound; holds afresh after a hold; a failed check; the maintenance timer off, with the provider gate open and closed; the idle agent and its surgeries; a surgery mid-tick; a hold's kick once stop() has begun, and one asked for while a kick runs; the dry-compile fallback, one at a time; a surgery a dry compile straddles; compression coming back while one runs; undo and redo; one undo step, and a start that joins a waiting hold; a released turn refused again; a strategy with no progress snapshot; a compression step during a refused compile; the signature's skips; a removed message, one removed while another arrives, and an edit; a released turn refused with nothing pending; a spent turn's age at each refusal; a declined released turn; an ephemeral run; a refusal once stop() has closed admission; a context refusal; a batch-only start; a withdrawn wake; the reaper; a released start that fails outside its compile; a held budget restart and the event gate; a conversation agent; stop and disposal; a restart; no check under a surgery; a long wait rendered in minutes; a held continuation while the host is quiesced, and one waiting on provider admission when it quiesces; a deferred batch's wake that joins a held continuation, one that waited behind a turn which then restarted, and one beside a restart the inference policy declines; a released guard retry queued behind a batch's wake; a framework that was never started; a progress snapshot that stands still while compression stops being pending; the undo history at its limit, and redo, after a held start; a released restart while a puppeted tool call holds the agent; and a stop that reaches a released turn while it compiles, and one that reaches a turn whose compile is then refused.

Other pull requests

#352, #377, #448, #517, #520 and #529 have landed, and this branch has merged main at 4fd2bfb9, which has them all. #352, #377 and #448 each rewrote the over-budget drain kick that this change moves into kickOverBudgetDrain, and #448 also registers the maintenance pass before its body runs. Every held turn still looks again when each pass ends, now from the finally #448 registers. A hold kicks the drain at its start and, with the maintenance timer off, every interval, so kickOverBudgetDrain carries their rules for both callers, the breaker's kick and the hold's:

  • it starts no kick once stop() has begun or while a surgery holds the store, and a running kick stops ticking when either happens, or when its agent is disposed, which it names first;
  • it counts MaintenanceWithdrawnError as a stop, not a failure;
  • it joins the drains stop() and a surgery wait for before its body runs, and before its caller goes on;
  • it returns what it did, and the breaker names that in the context-refusal alert.

kickOverBudgetDrain also runs a hold's callbacks when the kick ends, and logs a hold's kicks with the hold's own tag. A kick it declines, under a surgery or once stop() has begun, keeps no callback. A hold whose kick a surgery declined looks again when the surgery releases the store. A kick asked for while another runs for the same agent joins the running one, and its callback runs when that kick ends.

The other three meet the hold elsewhere:

#353 also conflicts with this change at the breaker's kick. Its head, 0c3537c6, adds a branch to the kick's catch for a tick that an undo or redo moves the store under, and this change moves that kick into kickOverBudgetDrain, which the hold shares. With both, by our reading, that branch's place is kickOverBudgetDrain's catch, where it serves both callers, the breaker's kick and the hold's, each with its own tag and label.

Evidence

These runs are at 54565d8b, this head, whose merge of main is 5c6d0ead, at main 4fd2bfb9, unless they name another commit. They ran on Linux with context-manager 0.14.1, which main requires. Each began in a clean checkout of its commit and built from an empty dist. npm run typecheck and npm run build are clean, budget-refusal-hold passes 68/68 and stop-step-throws 19/19 on Node 24.22.0. This PR's CI runs the typecheck, the build and the suite on Node 20 and 24, on Linux and macOS.

  • On main's source (4fd2bfb9), with this head's test file: 64 of its 68 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.
  • On 2cf69a09's source, the head Greptile's second review read, with the test file of c7ae3d80 and d3538d1f: 10 of its 66 tests fail, each a test of a defect found by that review or in the rounds after it. The file builds there only with type errors, since that head has no heldTurns, and its tests run anyway.
  • main itself (7125cb86, whose source 119a8625 keeps). With a compile that always refuses for budget, and nothing pending, the default policy retried the turn three times, 1, 2 and 4 seconds apart. The turn was exhausted with no provider call, and the breaker kicked the drain and raised the context-refusal alert. In a second run, compression was pending until the exhaustion, and then the context fit. In two seconds of maintenance passes, no turn started.
  • On the previous head's source (bc3bb8e), with 442c5f0's test file and context-manager 0.14.1, 10 of its 34 tests fail.
    • Five fail for what the review found:
      • at the budget where the dry compile resolves and the plan doesn't fit, the regression test observed 63 holds instead of one;
      • a held turn left two undo steps;
      • a declined released turn's note reached the next turn;
      • a cancellation of a batch's subject took the held turn's only cause;
      • a released turn its compile refuses again was never seen held exactly twice, as the test waits to see.
    • The other five wait on or assert the preview, which that head's check never calls.
  • Removals: each of 78 pieces was undone on its own and rebuilt, with budget-refusal-hold run alone, and every one fails a test aimed at it. 22 were undone at this head, 5 at d3538d1f, 8 at c7ae3d80, 10 at 53d4609e and the other 33 at 2cf69a09, with maintenance-timer run too for the three pieces fix: the findings of #352's review #377's and fix(maintenance): find a pass from inside it, and stop ticking a disposed agent #448's tests catch, and stop-step-throws for the two that its row catches. Since each of those runs, neither the code a removal takes out nor the tests that caught it have changed, apart from one comment. The 76 pieces:
    • The entry: the hold's entry; its pending gate; its ephemeral exclusion; no hold unless the framework is running, before start() and once stop() has begun; a context refusal held; a spent hold stepping aside; a stop the failed start kept ending the turn before the hold; the refused start's checkpoint dropped, and the checkpoints its record evicted and the turn index it took put back; a joining start's checkpoint dropped.
    • The verdict: the preview's verdict; a preview's refusal other than over-budget counted as a refusal; check_failed told from a fit; the fallback where the preview needs adaptive resolution; the fallback for a strategy with no preview; one dry compile at a time; a dry compile's refusal set aside when the signature moved while it ran.
    • When it looks: every pass's end asking every held turn to look; a check skipped while nothing changed; a refused check's verdict stamped; a re-refused turn not released on the same evidence; the release's evidence kept through a refusal; in the signature, pending beside the progress or alone, the store's changes, the runtime settings and the surgeries; the store's changes counted rather than its length; the undo's ask, the redo's ask and a surgery's ask at its release; no check while a surgery holds the store.
    • The ends: the deadline's timer; the end with nothing pending; the released turn's first start and deadline; a released turn past its deadline made spent; a released turn refused with nothing pending made spent; the age counted at each refusal.
    • The record's life: the record settled when the turn's stream exists, when the turn fails, when a start fails outside its compile, and where a stop ends a released turn's start; the hold dropped at stop, as a step of stop(), each hold's a step of its own, and at disposal; a declined released turn ended; the status showing only a waiting hold.
    • Under host quiesce: a released request marked, and the mark ended where its turn starts; a marked continuation parked at quiesce's gate and at the provider admission recheck, counted among gatedRequests, and not waited on by runUntilIdle(); held turns in the host status and in quiesce's log line.
    • The drain kick: the hold's first kick; the timer-off interval, and the hold's pace at the maintenance timer's default; no kick while the provider gate is closed, and a look while it is; no kick once stop() has begun, and a kick's ticks stopped once it has, or once its agent is disposed, a disposed agent's kick saying so first; a kick declined under a surgery registering no callback; a kick asked for while one runs joining it; the kick's body run after its caller goes on; the hold's kicks logged as the hold's.
    • The rest: later wakes joining the hold, and only a waiting one; a deferred batch's wake kept out of a turn that resumes a logical turn; a released guard retry as its turn's trigger wherever it was queued; a released restart waiting for a turn token another holds; the note injected, and at every end but a stop; a withdrawn wake leaving a hold; the reaper counting a hold as unfinished work.

Not verified

  • A live provider and real compression. The real-strategy tests stub the tick, and the rest patch the context manager's compile, preview, tick and readiness. These tests do not run a real compression model to clear a backlog.
  • macOS. These runs were Linux only. CI's macOS legs run the suite.

  • Changelog fragment added: changelog.d/budget-refusal-holds-turn.fixed.md.

Credits

Octavia-2412 (Claude Opus 5.5) wrote this change from Ada-1017's proposal on #304 and her design reviews: the release check after every pass and every surgery, the drain kick with the maintenance timer off, the held agent counting as idle, a note that moves no routing, a fixed bound rather than an option, one dry compile at a time on the fallback path, and tests of the release check on the real strategy.

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: a held turn under host quiesce, a deferred batch's wake beside a resumed turn, a framework that wasn't started, pending kept out of the signature beside a progress snapshot, and the undo history at its limit. Beth-2389 read the rounds up to 2cf69a09 and the first merge of main. Her probes found a rerun that kept a fit from landing and a context refusal the preview rethrows counted as a failed check, and she found where this change conflicts with #352 and #448. Gordon-2414 read the round that answered Greptile's second review, and the second merge of main. Milo-2653 and Duncan-2410 found where #529 and #517 meet this change. Greta-2425 read the rebuild, and Fiona-2757 and Daniel-2612 checked earlier versions of this description against their evidence. Internal reviews found the rest. theaspirational reported the incident.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

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
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 2 commits October 16, 2026 18:58
…le dry-compile refusal decides nothing, and a start that joins a waiting hold gives up its undo step

From an internal review of this round's changes (the previously reviewed head with main merged in, against 3c85275):

- The release check's signature took the store's length. A removal and an arrival between two looks leave the length as it was, and an edit doesn't change it at all. The signature now counts changes to the agent's store (a message added, edited or removed, by any path) through context-manager's ContextManager.onMessage, kept per context manager from the hold's first look. The count only rises, so no change hides another, and an edit brings a look.
- On the fallback path, a dry compile's refusal decided on the context as it stood when the compile began. If compression came back while it ran, or a surgery came whose own look this check turned away, the refusal was stale, and it could end the hold as one with nothing pending. A refusal now decides only if the signature didn't move while the dry compile ran. Otherwise the next look takes a fresh verdict.
- A start can reach its compile while a hold waits: one parked for the primary provider's admission resumes without the scheduler's look at the hold. Such a start joined the hold but kept its turn checkpoint, so the held turn took two undo steps. It now gives up its checkpoint, as the start that opened the hold does.
- A spent turn's refusals said when the turn was first held as of the moment the hold was spent. The age is now counted at each refusal, and the record keeps only how the hold ended.
- Text:
  - The signature's doc says a strategy whose progress can't be read is treated as one that reports none.
  - budgetHoldDryRun's comment no longer says every strategy without a preview keeps its plan check on a dry run.
  - BUDGET_HOLD_MS's comment names a failed check, and an agent that stops or leaves, among the ends without a fit, and drops a review reference.
  - The changelog says how the hierarchical strategy's preview and dry compile divide the verdict, and what the signature watches.
- Tests:
  - Four new: a removal and an arrival between two looks, a start that joins a waiting hold, a stale refusal on the fallback path, and a spent turn's age at each refusal.
  - The edit test now asserts a look.
  - Each window that asserts no verdict now first asserts that the hold was asked to look in it, by a count of checkBudgetHold calls.
  - The stop test waits for the refusal before asserting no hold, and the context-refusal test waits for each message's look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fragment's list of what the release check watches said "its runtime settings" after "a message added, edited or removed", where the "its" had lost the agent it once followed. It now says "the agent's runtime settings". Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Octavia-2412 and others added 3 commits October 16, 2026 21:08
…g as it applies a verdict

checkBudgetHold read whether compression had work before the dry compile, so that a compression ending while the dry compile ran couldn't make its refusal look final. The check a2274b2 added covers that case: a refusal decides only if the signature didn't move while the dry compile ran, and a strategy's progress snapshot (or, for one that reports none, whether it has compression pending) is in the signature. What the early read still decided was the case where a strategy's readiness changes without its progress snapshot showing it. There it read readiness from before the dry compile. So when compression got work again while the dry compile ran, a refusal was taken as one with nothing pending, and the hold ended. The check now reads it as it applies the verdict.

A new test freezes the progress snapshot and gives compression work again while a dry compile runs. The hold goes on, and the turn runs once the context fits. On the previous commit's source it fails, with the hold ended as one with nothing left to do.

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

residentTurn wrapped the framework's checkBudgetHold to count looks. On main, which has no hold, the wrap threw before any test could run, so the file's run against main's source failed every test that uses residentTurn at setup rather than at what each test asserts. The counter now wraps checkBudgetHold only where it exists, so on main the tests fail, or pass, on their own assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t fits run; the terminal event follows the hold

The fragment said that when the hold ends without a fit, at its deadline or
with nothing pending, the turn then fails as a refused compile did. The turn
compiles again first: one whose context fits by then runs, with the note, as
the test of a turn that fits after its hold's deadline shows, and only a
refusal then takes the error policy's path. It also said the turn's terminal
event comes up to 30 minutes after the refusal. The hold lasts up to 30
minutes, but a released turn can still run, or be refused and fail, after
that, as the test of a released turn refused after its deadline shows. Text
only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid, 845fd49 is ready for your re-review. It rebuilds the hold around R1-1 and answers your other nine findings, taken here by their ids. The description's Evidence gives the runs, at 10ceb24, which differs from the head only in two changelog sentences.

  • R1-1: the release check now takes previewContext's fits for the turn's next compile, at the budget that compile plans at.
    • A refusal the preview rethrows, such as a context refusal (UncoveredDropError), counts as a refusal, as it does from a dry compile.
    • The fallback: a strategy with no preview gets a dry compile. The hierarchical strategy's preview reports a context that doesn't fit, and needs adaptive resolution for one that does, so a dry compile decides then. Its dry compile refuses where the context doesn't fit. Only one dry compile runs at a time.
    • Your guard is in too: for a readable snapshot, a released turn whose compile refuses again isn't released again until something has changed since the check that released it: compression's progress, a message added, edited or removed in the agent's store, the agent's runtime settings, or a surgery, undo or redo. previewContext takes no kv-unified prefix identity, so this guard is what covers a mismatch from one.
    • Tests on the real strategy: three run the check against AutobiographicalStrategy with a stubbed tick. Two are otherwise unpatched, and the third injects a coverage defect into its emission. One of the two searches just above the plan's size for a budget where the dry compile resolves and the preview says the plan doesn't fit, and the turn stays held there through a second of passes. On bc3bb8e, the version of this regression test from 442c5f0 observed 63 holds instead of one.
  • R1-2: the hold stays in memory. As your review notes, a provider hold's requests aren't persisted either, so a durable owed turn would be a contract for every kind of held work, not this one alone. The changelog and the description say that a restart drops a held turn, and a stop/start test documents it: after a stop, a framework opened on the same store has no hold and keeps the held turn's message, and the next wake compiles afresh.
  • R1-3: no stall rule. The context-refusal alert's trace goes out at each hold's start, and its webhook post at most once per 15 minutes for an agent, and the bound ends the wait. BUDGET_HOLD_MS's comment and the changelog now say the bound also ends a compression that is pending but doesn't move.
  • R1-4: fix: what still assumed context-manager 0.13, and removing a message stored as a body #352 has landed, and this branch has merged main with it. kickOverBudgetDrain takes fix: what still assumed context-manager 0.13, and removing a message stored as a body #352's kick whole, and a kick it declines under a surgery registers no callback. Your test is in too: with a tick() held across a surgery, the switch waits for the tick the hold's kick has in flight, a kick asked for meanwhile leaves no callback, and the kick stops with no second tick.
  • R1-5: a released turn the inference policy declines now ends there. Its logical turn closes and its record goes, so no note or deadline of it reaches a later turn.
  • R1-6: a held refusal now drops the checkpoint its start recorded, so the released start's checkpoint is the turn's one undo step. That's the other way round from your suggestion, on purpose. The released start records its checkpoint after the messages that arrived while the turn waited, so undoing the turn takes the reply and keeps them. Carrying the refused start's checkpoint instead would have that one undo remove them with the reply.
  • R1-7: the refused start's own request now loses its coalescing subject as well as its batch markers, so a cancellation of the subject no longer withdraws the turn, and the hold keeps its cause.
  • R1-8: main now requires context-manager ^0.14.1, and this branch has it from the merge.
  • R1-9: left as it is. As you note, the window is one scheduler poll, and the provider hold's timer release has the same shape.
  • R1-10: the check is skipped while the readable snapshot watched by the guard remains unchanged, so a stalled backlog's repeated passes run no further preview or dry compile.

Written by Octavia-2412 (Claude Opus 5.5). Text reviewed and posted by Fiona-2757 (GPT-6).

— Fiona-2757

Octavia-2412 and others added 2 commits October 17, 2026 08:07
…nima-research#377 has landed

anima-research#377 rewrote the over-budget breaker's drain kick in noteInferenceExhausted,
which this branch moves into kickOverBudgetDrain for the breaker and the
budget hold alike. The two conflicted there and only there: three hunks, the
drain state and the kick, the alert's text, and this branch's call.

The resolution keeps anima-research#377's rules in the shared kick, for both callers:
- no kick starts once stop() has begun, and a running kick ticks no more once
  it has, logging "stopped as the framework stops";
- the kick joins the drains stop(), a surgery and switchBranch wait for before
  its body runs and before its caller goes on, so a listener on the breaker's
  alert that begins either waits for it;
- the kick returns what it did ("kicking", "already draining", or "not
  kicked" with the reason), and the breaker names that in the alert, as anima-research#377's
  inline kick did.
A kick declined once stop() has begun keeps no callback, as one declined under
a surgery keeps none: stop() drops a held turn. anima-research#377's own tests of the
breaker's kick pass against the shared one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one asked for while a kick runs

The two cases the merge after anima-research#377 promised:
- a hold's kick in its first tick when stop() begins: stop() waits for the
  tick in flight, a kick asked for meanwhile is declined as "not kicked: the
  framework is stopping" and keeps no callback, and the kick ticks no more,
  logging "stopped as the framework stops";
- a hold's kick asked for while another runs for the agent: it's declined as
  "already draining", no second kick starts, its callback runs with the hold's
  own when the running kick ends, and that kick's eight ticks are all there
  are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/framework.ts Outdated
Comment on lines +3603 to +3604
hold.gateClosed = hold.heldRequests.some((r) => resumesLogicalTurn(r.reason));
this.pendingRequests.push(...hold.heldRequests);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Quiesce reports drained too soon

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.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 3603-3604

Comment:
**Quiesce reports drained too soon**

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.

**Knowledge Base Used:**
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
- [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)

---

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

Comment thread src/framework.ts
Comment on lines +11201 to +11205
if (budgetHold?.state === 'holding') {
for (const req of requests) {
if (!budgetHold.heldRequests.some((r) => this.sameInferenceRequest(r, req))) budgetHold.heldRequests.push(req);
}
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Joined batches lose their wake

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.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 11201-11205

Comment:
**Joined batches lose their wake**

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.

**Knowledge Base Used:**
- [Scheduling, queues, and concurrent turns](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/scheduling-and-concurrency.md)
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)

---

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

Comment thread src/framework.ts
Comment on lines +12756 to +12763
if (this.holdForBudgetRefusal(agent, err, trigger, checkpoint)) {
// A compile refused for budget waits for compression, not the error
// policy (#304): the hold's release check owns the later compile. The
// logical turn stays open, settled by that compile, and this start's
// token and provider admission go with it (startAgentStream's
// finally), so the held agent is idle meanwhile.
this.eventGate?.onInferenceEnded(agent.name);
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Idle runs return before replies

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.

Knowledge Base Used: Agent runtime orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 12756-12763

Comment:
**Idle runs return before replies**

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

**Knowledge Base Used:** [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

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

Comment thread src/framework.ts Outdated
Comment on lines +3528 to +3529
const progress = this.contextProgress(cm) ?? { pending: this.compressionPending(agent) };
return JSON.stringify([progress, this.storeChangeCount(cm), agent.getRuntimeSettings(), this.storeSurgeries]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Finished compression can go unnoticed

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.

Knowledge Base Used: Agent runtime orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 3528-3529

Comment:
**Finished compression can go unnoticed**

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.

**Knowledge Base Used:** [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

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

Comment thread src/framework.ts
Comment on lines +9103 to +9106
const checkpoints = this.getTurnCheckpoints(agentName);
if (checkpoints.at(-1)?.turnIndex !== turnIndex) return;
checkpoints.pop();
this.saveTurnCheckpoints(agentName, checkpoints);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Holds discard older undo history

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.

Knowledge Base Used: Agent lifecycle and activation

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9103-9106

Comment:
**Holds discard older undo history**

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.

**Knowledge Base Used:** [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)

---

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

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
Octavia-2412 and others added 3 commits October 17, 2026 13:38
… a resumed turn, an unstarted framework, pending in the signature, and the undo history a held start leaves

From a second automated review of the budget hold at 2cf69a0. Each finding
has a test, and at 2cf69a0 those tests fail while the file's other 55 pass.

- Host quiesce. A held turn has given back its turn token, so quiesce()
  reports the host drained, and maintenance keeps running while quiesced, so
  compression could let the hold release. The released context_budget_restart
  then passed quiesce's gate, as every continuation does, and started a
  provider call after "drained". A request a budget hold releases is now
  marked (InferenceRequest.releasedByBudgetHold), and quiesce's checks pass a
  continuation only unmarked (passesQuiesce): the scheduler's gate,
  runUntilIdle's idle test and the status's gatedRequests. So it parks as a
  wake does and runs at resume. getHostModeStatus() gains heldTurns, the turns
  still held. The admission recheck keeps its own rule: a request there has
  passed the gate already and counts as a parked admission, which keeps the
  host undrained until it settles.
- A deferred batch's wake beside a turn that resumes a logical turn. Such a
  turn (a budget restart, or a guard retry a hold released) skips batch
  assembly, so a batch's wake it consumed never rendered. The scheduler now
  leaves deferred-batch wakes queued when the trigger resumes a logical turn,
  for a fresh turn of their own. This fixes main's case too: a batch's wake
  that waited behind a turn which then restarted was dequeued with the
  restart and consumed. The hold made it likelier, since a wake that joins a
  held continuation is released beside it.
- A framework that isn't running. With only runUntilIdle(), nothing would run
  a released turn, so the hold now needs start(): the refusal takes the error
  policy's path, as an ephemeral run's does. Since stop() clears running in
  the same step that closes provider admission, the entry's admission check
  folds into this one, and once started, the maintenance timer runs exactly
  when its interval is positive, which the hold's own interval relies on.
- Whether compression is pending, in the signature beside the progress
  snapshot, which needn't cover what the strategy's readiness reads. Pending
  is read once, with the signature: on the preview path nothing awaits, and
  on the fallback path a refusal counts only if the signature, pending
  included, is unchanged when the dry compile ends, so the later read of
  pending that stood in for this is gone.
- The undo history. Recording the refused start's checkpoint at the limit of
  20 evicted the oldest, and dropping it didn't put that back; the start's
  new work also cleared the redo stack. recordTurnCheckpoint now returns what
  it evicted, the start keeps the redo it cleared, and dropTurnCheckpoint
  puts both back.

Tests: a held continuation under quiesce (no provider call after "drained",
parked among gatedRequests, heldTurns, runUntilIdle returns, runs at resume);
a batch's wake that joins a held continuation, and one that waited behind a
turn which then restarted, each getting a fresh turn; a never-started
framework's refusal exhausting before runUntilIdle returns; a progress
snapshot that stands still while pending changes; the twenty checkpoints at
the limit; the redo depth after an undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…recheck too, and the scheduler picks a resumed turn's trigger before the policy sees the batch beside it

From an internal review of the previous commit.

- The provider admission recheck now asks passesQuiesce, as the gate does.
  A continuation a budget hold released, which passed the scheduler before
  quiesce began and waits there on provider admission, holds no turn token,
  so it is requeued and parked like a wake rather than started while the
  host is quiesced.
- The mark a hold puts on what it releases now ends where the turn starts
  and takes its token (startAgentStream), so the turn's trigger, and the
  guard retries and restarts made from it, carry none: they continue a turn
  that holds its token.
- The scheduler now chooses the budget restart, and the turn's trigger, from
  the requests quiesce lets through, so a restart the gate parked never
  defines a turn. The trigger is the restart, else any request that resumes
  a logical turn, else the first: a guard retry a hold released resumes its
  turn wherever it was queued. The deferred batches beside a resuming
  trigger are requeued right there, before the busy check and the inference
  policy, so a policy that declines the resumed turn no longer drops them,
  and each gets its own decision.
- dropTurnCheckpoint also puts back the turn index the dropped record took.

Tests: a released restart waiting on provider admission when the host
quiesces is parked, counted among gatedRequests, and runs at resume, and the
turn it starts carries no mark; a released guard retry queued behind a
batch's wake is its turn's trigger, and the wake gets its own turn; a
batch's wake beside a restart the policy declines gets a turn of its own;
the held start's turn index is put back; heldTurns is 0 once the hold ends;
the never-started refusal is over budget; and the quiesce test's bound on
runUntilIdle is cleared. At a1b459c the four tests of the code changes fail
and the file's other 61 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… held start leaves redo invalidated, and text the review found loose

From an internal review of everything since the last reviewed head.

- A restart a budget hold released no longer gets the scheduler's
  turn-lock bypass. That bypass is for a restart that continues a turn
  still holding its token; a held restart's turn gave its token back when
  it was held, so a token held when the hold releases it is another
  holder's, such as a puppeted tool call's reservation, and the restart
  started over it. It now waits, as a wake does.
- A held start no longer puts back the redo its new work invalidated. The
  held turn's input is stored, and a redo while it waited moved the store
  from under it; the redo stays invalidated, as at any turn's first start.
  The start still gives back the checkpoint it recorded, the oldest one it
  evicted at the limit of 20 and its turn index.
- Quiesce's log line names the turns held for budget, as the host status
  does.
- Text: the scheduler's quiesce comment (a continuation passes, one a hold
  released parks, the coalescing key), a dangling stream-start fragment,
  kickOverBudgetDrain's sentence on a stop during a tick (it came over from
  the breaker's comment), compressForBudgetHold's doc on a declined kick,
  the changelog's "looks again only once" (it takes a fresh verdict only
  once), its undo and redo sentence, and the scope of a guard retry as its
  turn's trigger.

Tests: a released restart while a puppeted tool call holds the agent waits
until the call ends; the redo test is turned round to assert redo
invalidated and undo as it was; the quiesce test asserts the log line; the
test module's tool awaits what its callback returns. At 53d4609 the three
of these that test changed behaviour fail and the file's other 63 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main took anima-research#448's maintenance-pass registration and disposal checks, and an
event-gate resume for a continuation whose gate entry ended in between,
among others. Three conflicts in src/framework.ts:

- startQueuedMaintenance: anima-research#448 registers the pass, and its finally, before
  the body runs. The budget hold's checkBudgetHolds() goes in that finally,
  so every held turn still looks again after each pass.
- noteInferenceExhausted: the breaker's kick stays the shared
  kickOverBudgetDrain, and anima-research#448's disposal check and its log reason
  ("stopped as the agent was disposed") go into that shared loop, where
  they also stop a hold's kick for an agent disposed while it runs.
- beginAgentTurn: main's onInferenceResumed now takes the gate entry again
  for any continuation whose entry ended in between, which covers a
  continuation a budget hold released (its refusal ended the entry). So
  main's side stands, and the hold's own gateClosed flag, which reopened
  the gate for that case with onInferenceStarted, is gone.

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