Repository navigation
fix: a turn refused for budget waits idle until compression makes room - #361
nissa-seru merged 120 commits into
Conversation
…pression makes it fit A compile that can't fit the budget throws context-manager's OverBudgetError before any provider call, for example after an immediate budget decrease. The error policy retried it into the same refusal within seconds and ended the turn. Compression then made the context fit, but nothing ran the turn again, so the agent stayed silent until its next wake. Now the refusal holds the turn (holdForBudgetRefusal), for a resident or a conversation agent whose context manager has compression pending: - The hold is its own per-agent record and never touches the provider gate, so the maintenance loop keeps ticking the agent and its compression calls are admitted as usual. The refused start gives back its turn token and provider admission, so a held agent is idle to the scheduler and to a surgery. - Later wakes join the held requests. Nothing compiles while the hold lasts. - A release check after every maintenance pass, every drain kick and every surgery runs a dry-run compile at the live budget. Once it fits, the held requests go to the queue together, for one fresh compile, and the agent's context gets a system note saying why the turn waited. As a system message it neither wakes the agent nor counts as conversational input for routing. - The breaker's drain kick runs when the hold starts, and again after each check while compression is pending, if the maintenance timer is off. - The hold ends after a fixed 30 minutes (BUDGET_HOLD_MS), or at once when compression has nothing left to do. Its turn's refusals then take the error policy's path, as before, and the failure's reason says how long the turn was held. - A turn a dry run released, refused again, holds again with its first refusal's start and deadline. - An ephemeral run, whose caller is waiting, and a refusal with nothing pending keep the error policy's path. Observable: the logical turn stays open while held, so it settles when the held turn ends. New traces inference:held and inference:hold_ended. healthSnapshot() gives each agent's budgetHold. The context-refusal ops alert goes out when the hold starts. The breaker's drain kick moved into kickOverBudgetDrain, unchanged for the exhaustion path. Tests: test/budget-refusal-hold.test.ts, eleven cases: - the turn runs once compression makes it fit, with no new wake, and its traces; - the release note, its metadata and its routing; - later wakes joining, with no compile while held; - nothing pending at the refusal; - nothing left to compress; - the deadline; - the maintenance timer off; - a held agent's status, its idleness for a surgery, and the surgery's check; - a released turn whose compile refuses again keeping its first deadline; - an ephemeral run's refusal; - the hold at stop. The hold's fields are read with optional chaining where a framework built from the prototype alone, as some tests build one, reaches them.
…ls a fit from a failed dry run, runs again when asked mid-run, and paces its kicks The round on an internal review of 174eeed (sw-af304, 23 issues). The note: - The note that tells the agent why its turn waited is no longer stored. It is an injection of the released turn's compile alone (budgetHoldNotes), consumed when the turn's stream exists. The context manager adds injections after it selects within the budget, so the note can't be what refuses the compile, it is in the request only when the compile fit, a release the compile doesn't confirm adds nothing, and nothing stored can move a turn's routing. The release check: - A dry run that fails for a reason other than the budget ends the hold as check_failed rather than fit, and the turn's own compile meets that failure. - One check runs at a time. A check asked for while one runs marks it to run again: the running dry run may predate the surgery or pass that asked, so its verdict is set aside. No check runs while a surgery holds the store; the surgery's release asks for one. - Undo and redo ask for a check, as surgeries do. - With the maintenance timer off, a check that finds compression still pending kicks the drain again after an interval (budgetHoldRecheckMs, the timer's 5 s default), not at once. A backlog that doesn't move costs a kick and a dry run an interval, not a loop. The hold's state: - A released turn refused after its hold's deadline is spent (afterBudgetHold), so it fails as a hold that reached its bound does, rather than holding afresh. - No hold starts once stop() has closed provider admission. - What a hold keeps for an agent's name (the carry, the spent mark, the note) is cleared at stop, at disposal and when the turn fails, as well as when its stream starts. - inference:held carries the hold's start (since). Text: the changelog fragment and the trace docs say the refused attempt still emits inference:failed, as a provider hold's does, name check_failed, and describe the note as an injection. Tests: budget-refusal-hold now has 18 tests: - the note's request, that nothing is stored, and that the next turn carries none; - the later-wakes test waits for both requests in the hold; - later turns holding afresh after a failed and after a completed held turn; - check_failed; - the timer-off kicks paced, and a backlog that never moves; - the closed provider gate's look an interval later; - a dry run set aside when a surgery asks mid-run; - an undo's check; - a released turn's carried start, and its refusal past the deadline; - stop and disposal clearing the hold's state. A sequential mock membrane gives each turn its own response.
…st compile only, and stays out of the store and the turn's routing Two tests a read of the round asked for. - A held turn whose first stream after the release restarts through a fresh compile (physicalWindowTokens, anima-research#92): the first compile carries the note, the restart's does not. The note is consumed when the turn's first stream exists. - A held turn on a channel (an mcpl:channel-incoming message on discord:guild:chanA): nothing of the note is stored, in any message or its metadata, and every start of the turn, the refused one and the released one, has the channel as its locus.
…reads pending at the dry run's start, and every end but a stop leaves the note From a read of 13bd4f4, with its probe. - The rerun could keep a fit from landing. A check set its verdict aside whenever another was asked for during its dry run, and every maintenance pass asks. With dry runs slower than the asks, every dry run was overlapped and nothing landed until the deadline. Now a check runs one dry run. It runs one more only if that one refused and a check was asked for meanwhile, and takes the second verdict. A fit stands even if it is stale: the released turn's compile decides, and holds again with the carried deadline if it refuses. A refusal the second run reaches is looked at by the next pass. - A refusal ends the hold as nothing_pending only if compression had nothing pending when that dry run began. Read after the dry run, a compression that finished meanwhile turned a stale refusal into a false nothing_pending. The new test hit this before the fix: the outcome was nothing_pending, not fit. - Every end but a stop sets the note, so a turn that fits after its hold's deadline carries how long it waited, and not an earlier release's wait. - budgetHoldCarry's comment, and the carry test's, give the reason the turn's compile can refuse where the dry run fit: content reaching it after the dry run (the turn start's deferred flush, a coalesced batch's assembly, messages stored between). It is not injections, which go in after the select. - The routing test from 263dad1 failed as written: with no channel registry the locus is null, so no start has one. It now reads the turn's routing at the released start and after the compile that carried the note (trigger channel, locus, engaged, pin, destination, arrivals): they match, and the trigger channel is the message's. Tests, 22 in budget-refusal-hold: - a fit lands within 3 s while checks come every 50 ms against 300 ms dry runs (the read's probe); - a turn that fits after its hold's deadline carries the note; - the routing test as above.
|
We'll merge this after #352, once our pre-merge checks pass on its merge with the then-current — Octavia-2412 |
[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
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.SummaryAdds bounded budget holds so resident and conversation turns can wait for compression, then compile again.
Reviews (2) · Last reviewed commit: "test(inference): a held turn's drain kic..." · Reviewed by Greptile |
…kes go, survives the closing-fork reaper, clears on a failed start, and reopens the gate for a continuation Greptile's review of 0085590 on anima-research#361 found five, each checked here and each with a test. - A batch-only start that reaches its compile has had its batches assembled and their content stored. Its held request now loses the batch-only markers, keeping its subject. Marked batch-only, the released start would assemble again: if a later batch of the subject rendered empty, the start would withdraw the turn the stored content was owed. - cancelCoalescedWakes filters a budget hold's requests by subject, as it filters a provider hold's. A withdrawn wake no longer stays in the hold, where it would decide the released turn's prose mode. - The closing-fork reaper counts a budget hold as unfinished work. A closing conversation's closure turn held for budget has no stream and no pending request, so the reaper disposed it and dropped the hold. - startAgentStream's outer catch, where a start fails outside its compile (a checkpoint write, locus resolution), clears what a hold kept for the agent's name (clearBudgetHoldState), as the inner terminal path does. - A released continuation (a budget restart, a guard retry) opens the event gate again when it resumes. The refusal closed the turn at the gate, and a continuation skips the gate's start because its predecessor would have kept the gate open, so the resumed turn ran with the gate closed and its events did not buffer (budgetHoldGateClosed). Tests, 27 in budget-refusal-hold: - a batch-only start's held request is an ordinary cause, with its subject kept; - a withdrawn coalesced wake leaves the hold; - the reaper leaves a held closure turn; - a released start whose checkpoint write fails leaves no hold state; - a held budget restart resumes with the agent inferring at the gate.
…gate closed once it ends A reader's suggestion on 0b245d3: the gate test also checks that the agent is no longer inferring at the gate once the resumed turn ends, so the reopening leaves no turn open at the gate, where it would buffer every agent's events. (The gate's inferring set makes a second open idempotent, so this checks the close, not a count.)
…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.
|
Replying to this comment. Pushed
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 — Octavia-2412 |
Anarchid
left a comment
There was a problem hiding this comment.
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
-
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, sinceselectis synchronous. But there are two caveats:- Near the budget, a wrong
fitverdict 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-refusalalert at the hold's start (15-minute cooldown) and the existing hard-down alert after three exhaustions, so at least 90 minutes (R1-3).
- Near the budget, a wrong
-
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_MSonly 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).
- Every request for the agent is moved into the hold (
-
Does it survive a restart mid-wait? No (R1-2). The hold, its requests and its deadline are memory-only.
stop()drops them, andstart()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.dryRundocuments the exception (0.14.1src/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, andpreviewContextis where infeasibility is reported."- The code matches:
if (result.finalTokens > rejectionBudget && (!dryRun || dryFallback))(autobiographical.ts:8853-8854; 0.14.0 has&& !dryRunat 8823). - Later stages still throw. The emission checks against
prefixBudget(8948,8989,9046,9065,9099) aren't gated ondryRun, 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 reachesbuildPicker(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, soisReady()is false):- Setup: sized the plan with
previewContextat a wide budget, then for each budget rancompile(budget, undefined, { dryRun: true })and a committingcompile(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
previewContextat the same budget saidfits: false. - At 0.975× and below, both refused, the dry run at the emission stage.
previewContext().fitsagreed with the committing compile in every case checked.- A separate sweep of 63 cases well below the budget had no mismatches.
- Setup: sized the plan with
-
Failure scenario: compression is pending and has brought the plan to within about 2% over the budget.
checkBudgetHold's dry run resolves, and the hold endsfit(3366). It sets the carry (3459) and queues the requests.- The released start runs. At attempt 0 it records a turn checkpoint (
11896-11899), then compiles and is refused. holdForBudgetRefusalre-holds with the carry (3246) and schedules a kick (3291).- If a maintenance pass's compression call is in flight, each of the kick's 8
tick()s returns at once (CMtick:if (this.pendingCompression) return;), so the kick'safterruns 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_endedtraces, 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_CHECKPOINTSis 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:
withBacklogpatchesContextManager.prototype.compileso 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)returnsfitsand treats anyOverBudgetErrorasfits: false. The framework already wraps it (previewContextFor,5567). - Strategies without a preview (
previewContextreturnsnull, or throws for a non-adaptive autobiographical config) can keep the dry compile. Their plan check isn't skipped:selectHierarchicalgets nodryRun, and windowed passthrough never holds because itscheckReadinessis always ready. - Compile as the turn will: pass the same
kvUnifiedImmutablePrefixHashthe 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
AutobiographicalStrategyat a budget just under its plan. About 30 lines reproduces the mismatch above.
- Take the verdict from what context-manager reports for a dry run, not from whether it threw.
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,pendingRequestsandopenLogicalare all in-memory.stop()ends every holdstoppedand drops its requests.start()starts the loop and the timers and queues nothing. The durableTurnCheckpoint(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 onebudget-hold-releaserequest 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.
- Persist a small owed-turn record per agent when a hold starts, the way provider waits are persisted: the agent, the hold's
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_pendingfires only whenisReady()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-refusalalert: goes out at each hold's start, with a 15-minute cooldown.
- A merge blocked by a compression hold:
-
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, viagetProgressSnapshot). End the holdstalledwhen 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".
- End the hold early when compression isn't moving. The strategy's progress snapshot is already read for maintenance records (
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,
kickOverBudgetDraindoesn't checksurgeryHold, 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
mainthe breaker's kick has the same race at exhaustion. #352 fixes it: it starts no kick and stops ticking whilesurgeryHoldis set, treatsMaintenanceWithdrawnErroras a stop, and tracks the kick inoverBudgetDrains. - This PR adds kicks at every hold start and, with the timer off, one every 5 s, so landing it first widens that race.
- On
- The PR's ordering note covers this. #352 is still open, at
ca2f806, one test-only commit past thefb8a6c4the 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
afterbefore deciding whether a kick runs (3514-3520). Once #352's "no kick while a surgery holds the store" guard is merged in, anafterregistered during a surgery is never run; it sits inoverBudgetDrainAfteruntil some later kick ends. It's harmless today, since the surgery's release callscheckBudgetHolds(), but the resolution should run or drop the waitingafters 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
InferencePolicyreturns false at release (thepolicy-skipbranch only logs). - A coalescing withdrawal: the release starts as batch-only, because its own trigger was withdrawn while held, and is withdrawn as
turn-withdrawnwith nothing new.
- A policy veto: a custom
- 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:
cancelCoalescedWakesemptiesheldRequests(10379), andendBudgetHoldthen makes up abudget-hold-releaserequest. 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_storeOrderwithout_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_refusalthat 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:
processInferenceRequeststakes a snapshot of the queue, then awaits each agent'sstartAgentStreamin 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_budgetorcontext_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.
- It holds only on
- 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.compileWithInjectionswithdryRunskips the watermark andcompileAtLiveBudget, so it settles no descent (agent.ts:884-909).- context-manager's dry
selectsnapshots and restores chunks, queue, calibration and store order on every path (0.14.1autobiographical.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
stoppedand 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[].budgetHoldmatches the changelog. Thecontext-refusalalert 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-solatxhighran 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 currentmain, #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
previewContextcheck in R1-1, R1-4'safterdetail, 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.
|
Replying to this review. Thank you for the review and for the reproduction. R1-1 is right. — Octavia-2412 |
…, as one record per held turn From Anarchid's review of 75d74fd on anima-research#361 (R1-1 to R1-10). R1-1: the release check took a dry compile that resolved as a fit. context-manager documents that a dry compile doesn't throw an over-budget plan at its plan check (SelectOptions.dryRun), and that previewContext reports feasibility. Just over the budget the dry compile resolved while the turn's committing compile refused, so the hold released into the refusal and held again at compile speed. With the real autobiographical strategy at such a budget, the old hold was held 66 times in a second. - The check now takes the preview's verdict on the agent's next compile, at the budget that compile plans at (Agent.nextCompileBudget, which keeps the tool-result guard's reservation). The preview is synchronous. - A strategy with no preview, or one whose preview needs adaptive resolution, gets the dry compile, whose plan check such a strategy doesn't skip. Only that path awaits, so only it keeps an in-flight field (BudgetHold.checking). The hold is one record per agent with a state (BudgetHold.state: holding, released, spent), in place of the hold map plus the carry, spent, note and gate-closed maps. - released replaces the carry: a refusal of the released turn re-enters the same record, with its first start and deadline. - spent replaces the spent mark. - The note and the continuation flag are fields on it. - The record goes when the turn's stream exists, when the turn fails (inner and outer catch), at a policy veto, and at stop and disposal (settleBudgetHold, dropBudgetHold). - The check's again/rerun and pendingAtStart machinery is gone. pending is read before the verdict, which matters only on the awaiting fallback. A signature (budgetHoldSignature: the strategy's progress snapshot, the agent's runtime settings, and a count of surgeries, undos and redos) decides when the hold looks again. - R1-10: a check is skipped while the signature is unchanged since the last refusal. - A released turn refused again isn't released again until the signature changes. That covers any check/compile mismatch: content that reaches the compile after the check, or a plan the preview can't see (a kv-unified prefix hash). - Undefined, so every check runs, for a strategy that reports no progress. The smaller findings: - R1-6: a held refusal drops the checkpoint its start recorded (dropTurnCheckpoint), so the released start's is the turn's one undo step. It is recorded after the inputs that joined the hold, so undo keeps them. - R1-5: a released or spent record whose turn the inference policy declines ends there. Its logical turn is settled and its record goes, so no note or deadline reaches a later turn. - R1-7: the refused start's own request loses its coalescing subject as well as its batch markers. Its content was stored before its compile, so no cancellation withdraws the turn, and a hold always keeps a cause. A start with no request gets a budget-hold-release request when it is held, not at release. - With the maintenance timer off, a held agent gets one interval that kicks the drain (or with the provider gate closed, only looks) every budgetHoldRecheckMs. - R1-3: BUDGET_HOLD_MS's comment and the changelog now say the bound also ends a compression that is pending but can't move, and that the operator hears of it from the context-refusal alert at each hold's start, at most once per 15 minutes. - R1-2: the changelog says a restart drops the hold, as it drops a turn in flight. Left as they are, for these reasons: - R1-2: in-flight work survives no restart anywhere in the framework. - R1-3: no stall rule: the alert already says it, and the bound ends it. - R1-9: one cause per turn, not a lost or misrouted one. R1-4 and R1-8 come with the merge order: anima-research#352 first, and main's context-manager floor. Tests, 34 in budget-refusal-hold: - The harness's verdict is now the preview, whose contract is to say whether the context fits. The dry compile refuses as the committing one would only on the fallback path, as a hierarchical strategy's does. - A new describe runs the check against the real autobiographical strategy, unpatched but for a stubbed tick: the band where the dry compile resolves and the plan doesn't fit, and the fallback where the preview needs adaptive resolution. - New tests cover: the skip; a re-refused turn held until new evidence; the surgery count across a straddling dry compile; one undo step; the policy veto; a cancellation that leaves the turn its cause; the first kick; and a released turn not shown as waiting.
The redo's ask (checkBudgetHoldsAfterSurgery at redo) had no test of its own: with no maintenance passes after the first, an undo while a turn is held finds no fit, and a redo once it would fit releases the turn. Undoing the redo's ask now fails this test. The hold file has 35 tests.
…nima-research#352 has landed main took anima-research#343, anima-research#340, anima-research#365 (context-manager ^0.14.1), anima-research#341 and anima-research#352 since 92125b0. The one conflict is anima-research#352's drain kick at the exhaustion site, which this branch moved into kickOverBudgetDrain. The site keeps this branch's call. kickOverBudgetDrain takes anima-research#352's kick whole (no tick started while a surgery holds the store, ticking stops when one takes it, MaintenanceWithdrawnError counted as a stop, the kick registered in overBudgetDrains so a surgery waits for it), and adds only the hold's callbacks. A kick declined under a surgery registers none, and the surgery's release asks for the check. This is the resolution checked in trial merges with anima-research#352's earlier heads, and main's kick block is the same as anima-research#352's head ca2f806.
… store mid-tick, and the surgery waits for it From R1-4 of Anarchid's review of 75d74fd on anima-research#361: the existing surgery test's ticks resolve synchronously, so nothing showed what anima-research#352's kick does when a surgery arrives while a tick is in flight. The test lets the start pass finish, then makes each tick wait on a gate. A refusal holds the turn, and the hold's kick enters its first tick, with the hold's check waiting on it. A branch switch arrives. After 200 ms it hasn't switched, since it waits for the tick in flight. A kick asked for while the surgery holds the store starts nothing and leaves no callback behind. Once the tick is let go, the kick stops there with no second tick, the surgery completes, the kick's callback has run, and the turn is still held. The hold file has 36 tests.
…ails Undoing the kick's surgery guard (registering a callback before declining) failed the test's assertion, but the gated tick was never let go, so the stop in its finally waited on the kick forever and the file never exited. The test now lets the tick go in its finally, so such a failure shows as that test failing.
… removed messages, a release keeps its evidence, and a released turn with nothing pending spends its hold From an internal review of 22bb6ca..2f98a2f. Its 29 issues: 17 fixed here, 5 intended, 5 not real and 2 not worth it. Code: - A strategy that reports no progress snapshot now gives the signature whether it still has compression pending. Before, its signature was undefined, so a released turn its compile refused again was released again at every look. The hold now waits for compression to finish, a settings change or a surgery. - The signature takes the agent's message count, so a message removed by any path (hide, removeMessage, a module's removals) brings a look. An edit that shrinks a message is seen at the next compression step or surgery. - A release stamps lookedAt (formerly refusedAt) with the signature it was decided on, and a refusal of the released turn keeps it. Before, the refusal stamped the signature read after the compile, which could take in a compression step the compile never saw and hide the fit it brought. - afterBudgetHold spends a released record when its turn is refused with nothing pending, as it does past the bound, so the failure says when the turn was first held and why the hold ended. - The note is composed at injection (budgetHoldNote), with the wait counted to then. It no longer says that compression caught up, since an undo, redo or surgery can release a hold too. The failure reason says when the turn was first held, which stays true whatever the time went to. - kickOverBudgetDrain's lines name their caller: a hold's kicks log as [budget-hold], the breaker's as [inference-failed]. - Comments now say that stop and disposal take a hold still waiting with its requests. Requests already released are queued wakes, left as any. Tests, 42 in budget-refusal-hold: - new: a no-progress strategy's re-refused turn is held until compression finishes; a compression step during a released turn's refused compile brings a look; a removed message brings a look; a released turn refused with nothing pending fails with that reason; no hold begins for a compile refused once stop() has closed admission; a context refusal holds as an over-budget one does; - strengthened: the first test checks the 30-minute bound; the gate-closed test sees the interval's look release the turn before the gate opens; the fallback test asserts the overlap it depends on; the timer-off test waits for its second kick and bounds the checks by the elapsed intervals; the real-strategy tests see a check run, through a call-through spy on previewContext, before they wait; the undo test asserts what the held compile contains. Judged not to change: - an undo while a turn is held: it rewinds past that turn's messages, as it does past any queued wake's, and the held turn runs on what the undo left. That class, an undo past any waiting request, is beyond this change; - a look asked during the fallback's dry compile is dropped, and the next pass looks again; - hold_ended at release, not again when a released turn is spent; - check_failed when an undo leaves the strategy to reinitialize; - a released hold bounded only at a later refusal.
…rt; its webhook post is what the cooldown limits The first hold test now asserts the context-refusal ops:alert at the hold's start, with its 'held until' wording. The changelog and BUDGET_HOLD_MS's comment now say that the alert's trace goes out at every hold's start, and that only its webhook post is limited to once per 15 minutes for an agent (opsAlert).
main took anima-research#338 (a dropped turn ends with inference:aborted), anima-research#274 (TypeScript 7), anima-research#347, anima-research#346, anima-research#348 and anima-research#344 since 22bb6ca. The merge has no conflicts.
…f a budget hold, and declare the kick's log tag once The hold file, now 46 tests, asserts: - a held turn's traces in order: inference:started, inference:failed, inference:held, inference:hold_ended, then inference:started and inference:completed; - no [inference-failed] marker stored for a held turn; - the hold's interval, when no test shortens it, at the maintenance timer's default of 5 s; - the hold's drain kick logging as "[budget-hold] drain kicked for prime (budget hold, 8 ticks)", and the breaker's kick at exhaustion as [inference-failed] (OverBudget breaker); - a held turn's logical turn open while held, and closed by an inference policy's decline at release; - a conversation agent's refused turn held and run, as a resident's is; - a new framework on the same store, after a stop dropped a hold, with no hold, the held turn's message stored, and its next wake's request carrying that message; - no check while a surgery holds the store, and a look at the surgery's release; - an edit that shrinks a message bringing no look until compression's progress moves; - on the real adaptive strategy, the release check previewing at the budget the refused compile planned at; - on the real hierarchical strategy, its dry compile refusing where the context doesn't fit, as a committing compile does. kickOverBudgetDrain declares its log tag and label once, before the drain, so its two catch branches use them rather than repeating the condition.
…n, and release at a budget with no room for the note - A new test renders the description's two examples exactly: a turn held 7 minutes, by its record, gets "[budget-hold] Your context didn't fit its budget (100,201 tokens against 48,568), so this turn waited 7 min. It fits now.", and a released turn refused past the default 30-minute bound fails with "(the turn was first held 30 min ago; the hold's 30 min bound passed and it still didn't fit)". - The first test fits at 40 ticks rather than 12, past the 16 that the pass at start and the hold's first kick give, so the maintenance loop must tick the held agent, and it checks that the hold leaves the agent's provider gate open. - On the real adaptive strategy, the band test now releases the held turn at the smallest budget whose preview fits, found by bisection, where the plan has under 10 tokens to spare against the preview's rejection budget. The released compile carries the note and runs, so the note, merged after the selection, isn't counted against the budget.
…usal, refuses as a dry compile's does context-manager 0.14.1's previewContext answers an over-budget plan with fits: false and rethrows any other error, so a coverage refusal (UncoveredDropError) comes out of it thrown. budgetHoldPreview counted every throw but "requires adaptiveResolution" as check_failed, so the hold released the turn into the same refusal at every look whose signature had changed. A read of 7c660ee found it, and a probe on the real strategy showed it: on the preview path the turn was held 6 times, the hold ended check_failed 5 times, and 6 context-refusal alerts went out; classified as the dry compile classifies, it was held once. budgetHoldThrownVerdict now classifies a throw for both verdicts, the preview's and the dry compile's: a refusal for budget or coverage refuses, and anything else fails the check. Tests, 48 in budget-refusal-hold: - The read's probe joins the real-strategy tests: with a coverage defect in the strategy's emission, the preview rethrows UncoveredDropError, the turn is held once through four looks with one context-refusal alert, and it runs once the defect clears. With the old classification it was held 6 times. - The harness's preview follows context-manager's contract, rethrowing a refusal other than OverBudgetError rather than answering fits: false. The context-refusal test now fits at 40 ticks, so the check looks while it doesn't fit; with the old classification that turn was held 3 times.
…t the tests show BUDGET_HOLD_MS's comment now says that, short of the bound, a hold ends without a fit only when compression has nothing pending or at a stop, rather than that it "ends sooner only when compression has nothing pending", since a fit and a failed check end it too. It credits its examples of a compression that is pending but can't move to R1-3 of anima-research#361's review, and gives the reason for a fixed bound and no stall rule, rather than "no host has needed another". The changelog fragment drops what no test of this change shows: the comparisons with a provider hold and with a turn in flight, what a module that brackets typing does, the mechanism behind a released turn refused again, and a compression model that keeps failing as its example. It names anima-research#381 for an undo past a pending wake, says a refusal the preview rethrows counts as a refusal and that the hierarchical strategy gets the dry compile, and says the turn's terminal event comes when the held turn ends.
…hangs "runs no check while a surgery holds the store" now releases the store in its finally as well, since stop() waits for a surgery's release: with the check's surgery guard removed, its assertion failed and the file then hung until its 180 s bound, with nothing reported (a removal run at b00a0fc). It now fails in under a second. "leaves nothing of a hold after a restart" closes its first framework in a finally, so a failed poll doesn't leave that framework's timers holding the file open.
"leaves the held agent idle: … a surgery is admitted and looks again" now waits for the hold's first look, which follows its first drain kick, and checks that nothing looks again before the surgery, before it makes the context fit. Before, the kick's look could come after the context fit and release the turn itself: in a removal run at 9951805, with the surgery's ask at its release removed, the test still passed, and a stack trace showed the release coming from the kick's callback. With the ask removed it now fails, "the surgery brought the release check forward", in three runs of three.
…it holds through "holds a context refusal once on the preview path" now asserts that the release check looked at least four times while the four messages arrived, each look a preview that rethrows the refusal, before it asserts the turn was held once. Without it, the test would also pass if no look ran. A reader pointed out the gap between the description's "held once through four looks" and what the test asserted.
|
The automatic review of Five of the should-fix (
What changes for callers:
CreditsOctavia-2412 made the change and wrote this reply, with Ada-1017's design calls. Gladys-2413 listed every place the change calls out mid-operation, which set this round's scope, and read the change. Details for agentsAt The rules, place by place. These are every place the change calls out mid-operation, and every step that finishes a wait.
Each finding:
Earlier answers that no longer hold:
The correction: our replies to the reviews of Runs, on Linux 6.12, Node 24.22.2,
— Octavia-2412 |
|
/review — Octavia-2412 |
|
Automatic review of About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account. Findings, most severe first:
Minor findings 2 to 21 (of 21)
Raised before, still answered (19)
|
… keeps a turn, and a held start is idle before it is announced Answers the automatic review of 715ee57. - puppetToolCall refuses while a budget hold keeps a continuation of the agent's turn, as it refuses while a provider hold keeps a retry, naming the wait and abortInference() as the way on. Before, the puppet was admitted, and the released budget restart, which skips the scheduler's turn-alive check, started over the puppet's turn token. - A refused start that waits gives back its turn token and its provider admission as its window closes, before inference:held, so a listener finds the agent idle and the host drained. The frame's window records that the admission went back, so its end doesn't give it back again. - getHostModeStatus().gatedRequests counts a waiting turn's wakes as it counts every other gated request: each wake merged into one, and none that passes a quiesce. - closureWaits also finds a closure wake merged behind another wake. - The drop reason reads true before and after a wait's end. - Docs: the clear is raised only for a wait whose alert was raised; the alert, the clear's waitedMs and the note count from a wait's first hold, while inference:held, hold_ended and health give each hold's own start; a nudge's 'idle' notes a budget hold. - Tests: reds for each of these; the interval-recheck test makes room only after its first drive has ended; the drop-order tests check that the wake joined the wait; two helper comments go.
|
The automatic review of What changes for callers:
CreditsOctavia-2412 made the change and wrote this reply, with Ada-1017's design calls. Gladys-2413 read the change. She also checked, by reading, that nothing else takes an agent's turn token or starts its turn without the scheduler's check for a budget hold. Details for agentsAt
An earlier answer that no longer holds:
Runs, on Linux 6.12, Node 24.22.2,
— Octavia-2412 |
… turn and wakes count The doc said each wake that joined a budget hold counts, and that a continuation a budget hold holds is counted. Since the count went through mergedBatch and the quiesce filter, a merged wake counts as its members and a continuation among a hold's wakes isn't counted; only the held turn counts once, continuation or not. The doc now says so.
|
@The-Glassy, your Codex lead from Nov 2 holds for the rebuilt change at The framework hears no direct CreditsOctavia-2412 ran the probe and wrote this reply. Ada-1017 called it a named limit. — Octavia-2412 |
|
Thanks — naming it as a limit makes sense to me. In practice the waits I've hit ended at compression's progress or end, and an unpinned write outside the framework is rare for us, so the next recheck or pass end is enough. Thanks also to Octavia-2412, Ada-1017 and Gladys-2413 for carrying this through so many rounds; I'll move my test image to the head once it settles and report back how holds behave on a live agent. |
|
/context-deltas |
|
Automatic context view of What a context view runs and what its checks judge: CHECKS.md. The records of 3 of the 76 runs differ between the base and this head. Separately, 8 of the checks' flags differ between the base and the head, in 1 run; they're listed under "Checks' flags that differ", below. Usage: the agents' requests, subagents' included, in the 76 runs, base → head: 208,916 → 208,900 tokens sent a request, 70.7% of them read from the cache, 146,360,292 → 146,976,678 written to it, 3,934,010 → 3,943,171 output and 38 thinking blocks dropped. The figures of 3 runs moved, for any kind of request; they're under "Usage that differs", below. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change. From a store connectome-host A model's account of those records: Delta 1: Budget-refused turns resume with a wait note In C6h/W8/enforced and H1/W8/enforced, which cut and restore the context budget, and C6h/W11, which does so with older conversation and a tool result marked to stay in context, at turns refused for exceeding that budget.
Delta 2: Later requests retain recovered turns and select different history In later requests opening turns in those budget-cut conversations. Follows from Delta 1.
Delta 3: Summary work changes timing and material (the description doesn't mention it) During these budget-cut conversations, when conversation material is sent for summarisation and summaries are stored.
Delta 4: Private context bookkeeping ends at a later record (the description doesn't mention it) At the end of the default enforced budget-cut conversation.
Delta 5: Run totals include recovered turns (the description doesn't mention it) At the end of these budget-cut conversations. Follows from Delta 1.
Delta 6: Check tallies include resumed turns (the description doesn't mention it) At the end of these budget-cut conversations. Follows from Delta 1.
Runs whose records differ: 3 of 76, with 6 changes
|
C6h/W8/enforced |
kind | requests | input | read from the cache | written to it | output | thinking dropped |
|---|---|---|---|---|---|---|---|
| agent | 28 → 32 | 0 | 4,016,981 → 4,461,984 | 2,639,660 → 2,961,108 | 30,559 → 35,724 | 0 | |
| summariser | 7 | 249,799 | 264,916 | 61,386 | 14,000 | 0 |
H1/W8/enforced |
kind | requests | input | read from the cache | written to it | output | thinking dropped |
|---|---|---|---|---|---|---|---|
| agent | 30 → 32 | 82,873 → 86,503 | 4,256,996 → 4,517,213 | 2,647,298 → 2,784,035 | 38,726 → 40,960 | 0 | |
| summariser | 8 → 9 | 273,019 → 291,372 | 302,642 → 341,820 | 63,510 → 65,538 | 16,000 → 18,000 | 0 |
C6h/W11 |
kind | requests | input | read from the cache | written to it | output | thinking dropped |
|---|---|---|---|---|---|---|---|
| agent | 26 → 28 | 0 | 3,700,887 → 3,994,137 | 3,129,050 → 3,287,251 | 28,847 → 30,609 | 0 | |
| summariser | 6 → 7 | 227,651 → 249,799 | 332,560 → 363,534 | 138,065 → 204,604 | 12,000 → 14,000 | 0 |
The figures of the other 73 runs are the same in the base and this head.
Upgraded runs beside their fresh twins
| On the base | requests | tokens sent a request | read from the cache | written to it | output | thinking dropped | last request dropped | calibration |
|---|---|---|---|---|---|---|---|---|
H1b/continuation/from-0.9.0 |
15 | 191,613 | 65.8% | 838,569 | 20,252 | 38 | 1 | 1.00, unconfirmed after a reset |
H1b/continuation/fresh |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
H1b/continuation/from-0.23.0 |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
| On the head | requests | tokens sent a request | read from the cache | written to it | output | thinking dropped | last request dropped | calibration |
|---|---|---|---|---|---|---|---|---|
H1b/continuation/from-0.9.0 |
15 | 191,613 | 65.8% | 838,569 | 20,252 | 38 | 1 | 1.00, unconfirmed after a reset |
H1b/continuation/fresh |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
H1b/continuation/from-0.23.0 |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.
Held the same in base and head: connectome-host cef933981dc0, chronicle 0.6.0, context-manager 0.25.0, membrane 0.8.0.
…s of it A budget hold copied its wait's start and whether its alert was raised into fields of its own, kept in step with the wait the held turn carries. It now holds that wait and reads both from it, so the state has one copy. Its callsAtHold field was set to 0 and never written again, so the progress check now compares the manager calls that landed with 0 directly. No behaviour changes. In the tests, those that build a hold by hand or age a wait now reach the wait, and those that aged both copies age it once.
Conflicts, resolved to keep both sides: - MarkedRequest carries main's UNPROMPTED mark beside the budget wait. - Expanding a merged start's batch: main gives the batch's first request the merged request's own wake channels; a merged start that carries a budget wait still puts that wait on its member, or keeps itself beside its batch when it isn't one of them, in which case it keeps its own channels and the first member isn't given them. - The quiesce's park key keeps main's unprompted part and the waited part. - docs/dendrite.md: main's locus sentence, with the "while your turn waited" notice kept in the list of starts.
|
/review |
|
Automatic review of About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account. Findings, most severe first:
Raised before, still answered (23)
|
… undo ends a waiting turn after its own record Answers the automatic review of e405aaa. - A silent start's retry gets a fresh routing, and the retry's copy of its trigger has lost readsRow, so a retry refused for budget stood on no row and a rollback that removed the start's row released it to run on what was left. The silent branch now carries the continued routing's start and firstRow, as it carries roundsRan and unflushedRounds, and opening a turn no longer overwrites a start it already has. By grep at this commit, routing.start is read only by rowsTurnStandsOn; main sets it and reads it nowhere. - An undo dropped a waiting turn under way before taking its checkpoint, so a listener of the turn's inference:aborted that undid again took the checkpoint first. The undo now takes the held turn out of its wait before its own checks, and ends it, with its traces, once its own record is kept. - The Fixed fragment: the refusal comes before the start's own provider call; a puppet call is refused while a turn under way waits; the undo's order; hosts that watch for failed turns see a waiting turn through its hold traces and health, not inference:exhausted or the failure marker. - Tests: reds for both fixes; the policy-path test asserts the retry's refusal reached the error policy, the joined-wake test that the wake joined before its release, the admission test waits for the frames to unwind, the gate test that an entry existed as the wait formed, and the real-strategy test that each early release came with progress.
…cy's own call The test of a policy retry refused while a restart's wait already stands counted inference:failed, which the start traces before the budget check and the error policy. So it passed with the policy call skipped while a wait stands, the case it is named for. The resident's error policy now records each error it is called with, and the test asserts the retry's refusal is the second, after the stream's failure.
No conflicts. main brings appendAll (anima-research#942), which the next commit uses for the lists this change adds to.
…ithout call spreads main's appendAll (anima-research#942) replaced push(...list) on these request lists, since V8 throws a RangeError past about 125,000 call arguments. This change's own pushes onto pendingRequests (a wait's joined wakes, a merged start's batch, the released requests) now use it too. Restoring the checkpoints an undo's dropped record had evicted prepends them by concat, since a checkpoint list read from the store is as long as what was stored.
|
The automatic review of What changes for callers:
CreditsOctavia-2412 made the change and wrote this reply, with Ada-1017's design calls. Hilda-2580 read the change. Details for agentsAt
Runs, on Linux 6.12, Node 24.22.2,
— Octavia-2412 |
|
/review — Octavia-2412 |
|
Automatic context view of What a context view runs and what its checks judge: CHECKS.md. The records of 3 of the 76 runs differ between the base and this head. In the files profiled hosts loaded, 161 changed source lines that map to code ran in no profiled drive: Separately, 8 of the checks' flags differ between the base and the head, in 1 run; they're listed under "Checks' flags that differ", below. Usage: the agents' requests, subagents' included, in the 76 runs, base → head: 208,163 → 208,149 tokens sent a request, 70.8% of them read from the cache, 146,596,163 → 147,212,549 written to it, 3,943,783 → 3,952,944 output and 24 thinking blocks dropped. The figures of 3 runs moved, for any kind of request; they're under "Usage that differs", below. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change. A model's account of those records: Delta 1: Budget-refused turns resume with wait notes In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, when turns encounter budget refusals.
Delta 2: Later context includes resumed work and changes its selection In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, in later resident requests after the resumed turns. Follows from Delta 1.
Delta 3: Compression requests shift within the conversations (the description doesn't mention it) In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, in requests to the summariser.
Delta 4: Summary writes change their timing and text inventory (the description doesn't mention it) In the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget, as summaries are recorded in the store.
Delta 5: Run accounting changes (the description doesn't mention it) At the end of the enforced budget-cut runs and the run that pins older history before cutting and restoring the budget.
Delta 6: The final internal context receipt advances (the description doesn't mention it) At the end of the enforced budget-cut conversation under the default context setup.
Runs whose records differ: 3 of 76, with 6 changes
|
C6h/W8/enforced |
kind | requests | input | read from the cache | written to it | output | thinking dropped |
|---|---|---|---|---|---|---|---|
| agent | 28 → 32 | 0 | 4,016,981 → 4,461,984 | 2,639,660 → 2,961,108 | 30,559 → 35,724 | 0 | |
| summariser | 7 | 249,799 | 264,916 | 61,386 | 14,000 | 0 |
H1/W8/enforced |
kind | requests | input | read from the cache | written to it | output | thinking dropped |
|---|---|---|---|---|---|---|---|
| agent | 30 → 32 | 82,873 → 86,503 | 4,256,996 → 4,517,213 | 2,647,298 → 2,784,035 | 38,726 → 40,960 | 0 | |
| summariser | 8 → 9 | 273,019 → 291,372 | 302,642 → 341,820 | 63,510 → 65,538 | 16,000 → 18,000 | 0 |
C6h/W11 |
kind | requests | input | read from the cache | written to it | output | thinking dropped |
|---|---|---|---|---|---|---|---|
| agent | 26 → 28 | 0 | 3,700,887 → 3,994,137 | 3,129,050 → 3,287,251 | 28,847 → 30,609 | 0 | |
| summariser | 6 → 7 | 227,651 → 249,799 | 332,560 → 363,534 | 138,065 → 204,604 | 12,000 → 14,000 | 0 |
The figures of the other 73 runs are the same in the base and this head.
Upgraded runs beside their fresh twins
| On the base | requests | tokens sent a request | read from the cache | written to it | output | thinking dropped | last request dropped | calibration |
|---|---|---|---|---|---|---|---|---|
H1b/continuation/from-0.9.0 |
15 | 159,813 | 52.6% | 993,917 | 20,252 | 24 | 0 | 1.28, confirmed after a reset |
H1b/continuation/fresh |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
H1b/continuation/from-0.23.0 |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
| On the head | requests | tokens sent a request | read from the cache | written to it | output | thinking dropped | last request dropped | calibration |
|---|---|---|---|---|---|---|---|---|
H1b/continuation/from-0.9.0 |
15 | 159,813 | 52.6% | 993,917 | 20,252 | 24 | 0 | 1.28, confirmed after a reset |
H1b/continuation/fresh |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
H1b/continuation/from-0.23.0 |
15 | 155,549 | 56.9% | 862,775 | 20,252 | 0 | 0 | 1.29, confirmed |
A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.
Changed source lines and deletions not seen to run, by file
src/agent.ts: lines 1693–1698, 1700–1701, 1703 and 1705 ran in no profiled drive; lines 712–716, 1672–1674, 1681–1687 and 1706–1707 map to nothing the host runs.
src/framework.ts: lines 7094–7095, 7100–7101, 7227–7228, 7466–7468, 7513–7514, 7529–7530, 7552–7556, 7558–7559, 7561–7562, 7564, 7570–7571, 7595–7596, 7602–7603, 7610–7613, 7620–7621, 7631–7633, 7637–7643, 7666, 7676–7678, 7690–7699, 7713–7716, 7720–7721, 7727–7735, 7737, 7745–7746, 7750–7754, 7758–7764, 7771–7773, 7782–7784, 7791–7794, 7799, 7802–7803, 7805, 7807, 7809, 7865, 7875–7877, 7879–7880, 7883, 8824–8826, 10487, 16359, 16389, 16413, 21007, 22158, 22168, 22742–22743, 22781–22786, 22789–22792, 22862, 27595–27597, 27603, 27607, 27625, 28233, 31318, 31326–31327, 35447–35449, 35451–35452 and 35455 ran in no profiled drive; lines 1304–1308, 1314, 1381–1382, 1549, 1894, 1900–1902, 1906, 1986–1990, 2558–2565, 3111, 3114–3136, 4024–4025, 6780, 7041, 7469, 7544–7545, 7563, 7565–7566, 7572–7574, 7581–7582, 7586–7587, 7600–7601, 7604–7605, 7608–7609, 7614–7615, 7622–7623, 7634–7636, 7644–7645, 7661–7662, 7679–7681, 7700–7701, 7706–7707, 7717–7719, 7722–7723, 7736, 7738–7739, 7743–7744, 7747–7749, 7755–7757, 7765–7767, 7774–7776, 7785–7787, 7795–7796, 7812–7813, 7821–7828, 7884, 7887, 7897–7898, 15164–15166, 16293–16296, 16304–16306, 16308–16312, 18515–18516, 21117–21118, 31328 and 35484–35485 map to nothing the host runs.
src/types/trace.ts: lines 159–207, 1095–1099, 1105–1107 and 1617 map to nothing the host runs.
Held the same in base and head: connectome-host 75b9e445d87b, chronicle 0.6.0, context-manager 0.25.1 (the view chose it for both arms, since connectome-host's lockfile holds 0.25.0, which doesn't meet ^0.25.1 from the base), membrane 0.8.0.
|
Automatic review of About these findingsThe findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits. Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase. Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account. Findings, most severe first:
Raised before, still answered (17)
|
…one by one, and a README rule names the waiting turn Answers the automatic review of 9a76d31. - healthSnapshot()'s budgetHold.heldRequests counted a wake that merges others as one, while gatedRequests counts its members. It now counts them the same way. - The README said a caller's stop made while the agent is idle stops nothing. A turn a budget hold keeps leaves the agent idle, and abortInference() ends it, as the same paragraph says later. The idle case now names that exception. - The Fixed fragment: the progress that ends a wait is a summary written since the refused compile started, which is when its baseline is read. - Tests: the snapshot double counts summaries apart from compressed chunks, so the test of a summary releasing a wait changes only the summary count; the gated-count test asserts the health count; the undo test's title no longer says the held continuation ends first.
…iesce lets through, as gatedRequests does Any request queued for an agent whose turn waits joins the wait, so a module's queued budget restart can be among its wakes. gatedRequests leaves such a request out, and budgetHold.heldRequests counted it. Both now count the same requests, and the gated-count test holds such a wake.
|
The automatic review of What changed:
Declined, with their reasons:
CreditsOctavia-2412 made the change and wrote this reply, with Ada-1017's calls. Hilda-2580 read the change. Details for agentsAt
Runs, on Node 24.22.2. The reds, the mutant and the probe installed
— Octavia-2412 |
6cfecab to
7906517
Compare
Why. On
main, when an agent's context is over its budget and compression is still catching up, its turn is refused and fails within seconds. Once there's room, nothing runs it again, and the agent stays silent until something else wakes it.With this change, a turn refused for budget while compression is pending waits, idle, and runs once compression has made room. The agent then gets a note saying its context didn't fit and how long the turn waited.
This addresses #304.
A turn whose compile is refused for budget while compression is pending
main): the default error policy retries it into the same refusal. The turn ends as exhausted within seconds. Once compression has made room, nothing runs it. The agent waits for its next wake.The wait ends on one of these:
While a surgery holds the store or the host is quiesced, the wait ends once that's over.
The turn is then queued again, and its own compile is the check. If compression is still pending and the compile is refused again, the turn waits again.
A compression call that fails ends no wait. Where the strategy reports its progress, stalled compression ends none either. context-manager 0.25.0's and 0.25.1's autobiographical and knowledge strategies report it.
Messages that arrive meanwhile are answered by that turn. A retry that a provider hold kept answers them the same way on
main. A budget restart's continuation and a tool-result guard's retry are different: messages that arrive during their wait run as a turn of their own after that turn.When compression has nothing left to do and the context still doesn't fit, the turn takes the error policy's path, as before.
Surgeries, undo, redo, puppet calls and quiesce while a turn waits
main): the refused turn has ended, so nothing is refused on its account.gatedRequestsdoesn't count it. An undo can take out the message a queued wake was for. That wake still runs, and its start puts the message back in the window.getHostModeStatus()readsdrained: true. ItsgatedRequestscounts the waiting turn once, and each wake that joined it.abortInference()ends that turn. Onmain, a puppet call is also refused while a provider hold keeps a turn's retry.main, a waiting wake runs on it too.main, a waiting wake's start does too.main,redo()wakes nothing for what it restores either.abortInference()leaves it to run. The inference policy or a withdrawal decides it, as for any queued wake. A continuation becomes a queued continuation, andabortInference()ends it.What the agent sees
main): at the exhaustion, an[inference-failed]marker in its context. Its reason is the refusal.Ops alerts, traces and health
main): the exhaustion raisescontext-refusal. A 15-minute cooldown for each agent and kind thins its webhook posts.context-refusalas it starts waiting. It says the turn waits for compression.inference:heldends the first hold before the alert, the next hold raises it.context-refusalis raised, the turn may be stopped while it waits, or what it stands on may be removed. Either one raisescontext-refusal-clear, and the waiting start doesn't run.inference:heldandinference:hold_endedtrace each time the turn waits.healthSnapshot()gives the wait as the agent'sbudgetHold.A closing conversation fork whose closure wake waits
main): the fork can be disposed before its closure turn runs. A turn of its that was alive at its expiry disposes it when that turn ends. The fallback sweep disposes it while a provider's stated wait keeps the wake.A host with a table or
switchthat must cover everyTraceEvent['type']orPresentationRetractReason(breaking)main): it compiles with an entry for each member.inference:heldandinference:hold_ended, and for the retract reasonunsent-injection. Without them it no longer compiles.Documented rule:
HostModeStatus.gatedRequestsmain): "Continuations of a turn (budget restart, tool results) and a held turn's retry, which a quiesce lets through, are never counted."Documented rule:
nudgeAgent()'sagentStatusmain): "'idle' means no turn holds the agent, so the turn starts on the next scheduler pass unless a provider hold (a provider's stated wait, or a capacity cooldown) holds it until the hold ends, or a start of the agent's that waits for an auxiliary call to finish goes first; anything else means it runs after the current turn."Documented rule:
notifyOps()'s first stepmain): "(1) durable failures.log record (unless the caller already wrote one),"Documented rule:
ops:alert'sdatamain): "Kind-specific structured payload (mirrors the failures.log record), bounded field by field as that record is."Documented rule:
docs/dendrite.md's notices naming which start it ismain): "… naming which start it is —while your turn waited on a provider hold,before this re-inference on your tool results,at your budget restart,while your turn was retried— …"while your turn waited on a provider hold,while your turn waited(after a wait for compression and any provider hold after it, where the start is none of those the notices after it name),before this re-inference on your tool results,at your budget restart,while your turn was retried— …"Documented rule: the README's stops that stop nothing
main): "A caller's stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, while a turn finishes its last delivery, …"@antra-tess, this changes the 6 documented rules quoted above.
changelog.d/budget-refusal-holds-turn.fixed.md,changelog.d/budget-refusal-holds-turn.added.mdandchangelog.d/budget-refusal-holds-turn.breaking.md.Credits
Octavia-2412 built it and wrote this description. Gladys-2413 proposed its design, and Ada-1017 settled it. Gladys-2413, Nell-1783 and Hilda-2580 read the change.
Details for agents
Head
79065172c2, two commits on9a76d312c0.9a76d312c0is one commit on12cceb78bc, which mergesmain53e72cac42intoa3584588d0.a3584588d0is two commits one405aaa4dd, which mergesmain21e6b12417into twenty commits ond0f3c79b15:d7971b9d94, the change;083a247895, which changes tests alone;5db819cf60andf9adf75a55, which change a doc line and the Fixed fragment;144c834ea2,335f736dbc,361a40b77f,eb9d1314cc,e43f80f7a4,dd59713a7fand066ab27e7f, which release a wait on evidence, keep a closing fork through the fallback sweep while its closure wake waits, take injections no request carried out of the window at a wait, and carried a wait past its release;6e5bef6728, which ends the wait at its release, stores the note there, and carries the wait on its request as plain data;b137724f03, which changes a doc comment and the Added fragment alone;73b6c43bdb, which removes an undo check no waiting turn reaches any more, and its test;f63ed41a48, which releases a wait only while no surgery holds the store, orders the release so its listeners find it whole, and sends a refusal the wait can't take to the error policy;3579c44252, which makes every operation on a wait trace last and finish only a wait that is still the agent's;715ee57f33, which changes the Fixed fragment alone;3d0077aa6c, which refuses a puppet call while a budget hold keeps a turn under way, gives back a held start's turn token and provider admission before its wait is announced, counts a waiting turn's merged wakes ingatedRequestsmember by member, leaving out any that passes a quiesce, and finds a closure wake merged behind another wake;fdeba738f0, which changesHostModeStatus.gatedRequests' doc alone, to say how a budget hold's turn and wakes count; andc6ae6072aa, which has a budget hold keep its turn's wait rather than copies of it and drops a field that never changed, with no change in behaviour.5fa6d46aeekeeps the rows a silent turn's retry stands on, and ends a waiting turn under way after an undo's own record.a3584588d0changes one test alone.9a76d312c0appends to the request lists throughmain'sappendAll, with no change in behaviour.2e9918a574and79065172c2count a waiting turn's requests in its health asgatedRequestsdoes, and name the waiting turn in the README's idle case. Merge base withmain53e72cac42. Wheremainis cited at16a16fffbc, the same lines stand at53e72cac42, moved: :22237 to :22299, :27323–27326 to :27414–27417, and :30470 to :30590. Line numbers aresrc/framework.tsat the head unless named.Problem
On
main(16a16fffbc), a compile refused for budget (context-manager'sOverBudgetError, or a context refusal when no summary covers a span the compile would drop) takes the error policy's path whether or not compression is pending: it's retried into the same refusal and the turn ends as exhausted. Once compression catches up, nothing queues the turn again, so the agent waits for its next wake (#304). Red onmain's source with this change's tests as of6e5bef6728: 59 of their 62 budget-hold tests fail, and the head removes one of the 62. The three that pass are "fails as before when compression has nothing pending", the ephemeral run's and the synchronous throw's, which each check whatmainalready does (Tests, below).Changes
The wait (
holdForBudgetRefusal, :7447). A start whose compile is refused for budget while compression is pending (!isReady()) doesn't end its turn: its request leaves the queue and waits in the agent's budget hold. A refusal with nothing pending, an ephemeral run, a frameworkstart()hasn't started or whose stop has begun (:7458), and an agent the framework no longer holds under its name fail as before, and so does a refusal whose agent already has a wait (:7459).inference:held, the open alert if it wasn't raised, the event gate's end of the start (:7531), and the retraction of what no request carried (:7533, below), each followed by a check that the wait is still the agent's. A stop any of their listeners makes,abortInference()included, finds the wait and drops it as any stop does.inference:held(:28571–28579), and the request isn't a pending continuation, sohasTurn()is false,getHostModeStatus()reads drained, a quiesce drains at once, andreserveStoreForSurgery, undo and redo aren't refused on its account.puppetToolCallis refused while a turn under way waits (heldContinuation, :31312), naming the wait andabortInference()as the way on, as it's refused while a provider hold keeps a retry. While a first start waits, a puppet call is admitted, and the released start queues behind its turn token (:22902). The held start ends its event-gate entry (:7531), and a released continuation takes it again (onInferenceResumed). The refused start's turn checkpoint is dropped (:7463), so an undo while a first start waits takes the last completed turn; a drop that throws is logged and sends the refusal the error policy's way, before the wait changes anything else.TURN_RETRY, and keeps the agent's open logical turn, so the released start continues its routing. Messages that arrive are presented as they're written, and their wakes join the hold, so the released start answers them all.TURN_RETRY) waits as it is, its carry included. Presentation still counts its logical turn alive (logicalTurnAlive, :24925), so arrivals stay out of its request while it waits. For a budget restart's continuation or a tool-result guard's retry, they then run as a turn of their own after it (:22889). A retry carryingTURN_RETRYis released into a start with the wakes that joined it, which presents and answers them, asmaindoes for a provider hold's retry (main:22237 at16a16fffbc).The wait's end (
releaseBudgetHold, :7644): the wait is removed, its held requests are queued again and the note is stored (below), and only then aremessage:addedandinference:hold_endedreleasedemitted, so a listener of either finds the release whole. It comes once something the refused compile depends on has changed (budgetHoldMoved, :7614):chunksCompressedandsummaryCounts(:7622), has moved since a reading taken just before the refused compile (:28385), which counts a summary written without a call too. Where that reading read as none, the first later one that reads takes its place (:7618): at that check a landed call still counts, as it did while no snapshot could be read, and from then on the snapshots decide. Where the snapshot reads as none then or now, from a strategy with none, one that throws, one whose snapshot has neither field or one whose snapshot can't be serialized, a call of the agent's context manager that lands while the turn waits counts instead. A call counts only once it lands (:6773,managerCallLanded, :7032), so a failed call releases nothing. context-manager 0.25.0's and 0.25.1's autobiographical strategy, which their knowledge strategy extends, gives a snapshot (getProgressSnapshot,src/strategies/autobiographical.ts:9446), and its passthrough strategies are always ready (checkReadiness,src/strategies/passthrough.ts:30–32 andsrc/strategies/windowed-passthrough.ts:349–351);Agent.contextSettingsChanges(),src/agent.ts:717);checkBudgetHoldsAfterWindowChange, :7795). An undo that completes, or that's cut short or fails to save its checkpoints after taking rows out, runs these checks after its own record andwindow:undone. The last two (:16385, :16409) drop a wait whose rows they took out and end no other, so a wait they leave ends at the next of the ends in this list, not at the undo;resume()ends every wait (:10485), as the last statement of itsfinally, after it persists and reopens, so what came while quiesced isn't lost, and a throw before then still ends them.Pins aren't on the list: the framework changes them only at a turn's start and end. A maintenance pass's end (:8284), the drive and a landed call each end a wait on that evidence. With the maintenance timer off,
budgetHoldRecheckMs(5 s) drives compression again instead (:7577). No release happens while the host is quiesced or a surgery holds the store (:7647): the wait stays untilresume()or the surgery's release ends the waits left, though a stop, a surgery or an undo can still drop one. stop's synchronous part drops every wait (:6382) before any evidence can come, andresume()refuses once a stop has begun. The released start's own compile is the check: refused again while compression is pending, it waits again, and each such start traces its owninference:startedandinference:failed, and each wait its owninference:heldandinference:hold_ended.The drive (
driveBudgetHold, :7586, throughkickOverBudgetDrain, :7812, which the OverBudget breaker also uses): from the wait's start, the context manager is ticked, ending the wait on the evidence above. The drive ticks again only while a strategy whose snapshot reads as none lands a call each tick, and a compression call that fails in it is reported as one in a maintenance pass is.The wait on the request (
BUDGET_WAIT): the held request carries{ since, row, alerted }as plain data:sincewhen the wait began, for a first startrow, the row its turn was answering, andalertedonce the wait'scontext-refusalis raised (:7475, :7515). The scheduler builds a start's trigger anew, so a start takes it from whichever of its requests carries it, as it takesTURN_CARRY(:23173).holdRequestcopies it onto an equal request it already holds (:7226), and where a request that stands for a merged start carries it while none of its batch does, the pass's expansion gives it to the member equal to that request (:22786), or, where none is, puts that request back beside its batch (:22777–22779). The quiesce's coalescing key keeps a request that carries it apart from others (:22856). It leaves the trigger once the start's compile returns (:28405), so a later retry of that turn, refused for budget, waits from its own start.since, raises no newcontext-refusalwhere its wait raised one (:7514), and keeps itsrow.rowleft its window after its wait ended, whatever took it out, is dropped at the top ofbeginAgentTurn, at attempt 0 (:27854,budgetWaitEnded, :7786), with oneinference:aborted; the wakes merged with it go back to the queue. Every place it can be between its wait's end and its start ends there: the queue, a scheduler pass's hands, a provider hold, a park on provider admission.main's behaviour for a queued wake.abortInference()leaves the first to run and ends the second (dropContinuations), and the inference policy or a withdrawal decides either as it would any other.The note (
storeBudgetHoldNote, :7661): as the wait ends, "Your context didn't fit its budget (A tokens against B) when a turn came to start. That turn waited X." is stored as the agent's own stored-injection row, with its mark (storeOwnRow). The two facts stand side by side, with no cause named, since a budget change or a surgery can end the wait too, and they stay true for whichever turn reads them. X runs from the wait'ssinceto its end.hasTurn, :10049), so the row is held back (:7673) and enters at the agent's next presentation point: the turn-start presentation of a retry carryingTURN_RETRY(:27884), or, for a budget restart's continuation or a tool-result guard's retry, which skip it (resumesLogicalTurn, :1268), that start's compile. Its turn's request reads it there, so the next request starts with the same messages, asmain's stored injections do, and later requests read it as history, which the strategy folds like any message.Agent.markUnsentPoint(),src/agent.ts:1675), so where the turn is refused again before a request carries the note, the next wait takes it out of the window (below), and the next end stores a fresh one.What no request carried: for an agent whose window is presented, the agent's own stored context injections that no request has carried leave its window as undone when a turn starts waiting, whichever attempt stored them, a note among them (
retractUnsentInjections, :7706, called at :7533). They're the rowsAgent.unsentInjectionRows()reads (src/agent.ts:1688): this agent's stored injections, of every namespace, after the pointunsentInjectionreads for its branch, in the window, in the record's order. The released start stores them as it would without the wait. A routing notice the refused start stored isn't a stored injection, so it stays, and its arrivals stay announced.presentation:retractednames the rows with the reasonunsent-injection. An agent whose window isn't presented, a failure state in which undo is refused too, keeps them.Surgeries and undo while it waits:
checkBudgetHoldsAfterWindowChange, :7795), for each wait still its agent's (:7798), a waiting wake whosereadsRowrow is gone from the store is dropped. So is the waiting turn if a row it depends on has left the agent's window (rowLeftWindow, :7775: gone from the store, or, for an agent with a presentation index, with no ordinal in its window), and the wakes that joined it go back to the queue before its end is traced (endDetachedHold, :7757, :7760). What a turn depends on (rowsTurnStandsOn, :7544): a first start's trigger row, where its trigger read one, or for a turn under way its first trigger's row and its own rows since the turn opened. The turn ends throughendContinuedTurnwith oneinference:aborted.b137724f03's source, whosesrc/framework.tsis6e5bef6728's, a first turn's request met a provider 429, its provider-held retry was refused for budget and waited with that turn's checkpoint, and the undo ended it and undid that turn. An undo that takes a waiting first start's message out of the window drops the start, as above. Onmain, a queued wake whose message an undo took out still runs, and its start puts the message back in the window. A redo while a turn waits has nothing from before the wait to redo, since that turn's start emptied the redo stack (:28021), so it returns{ redone: false }(:16720), unless an undo admitted during the wait made an entry for it to take. A redo that brings back the message of a waiting start an undo dropped doesn't wake the agent, sinceredo()wakes nothing for any row it restores, onmainas here.main.Stops (
dropBudgetHold, :7722):abortInference()(:8822),stop()and the agent's end end a waiting turn with oneinference:aborted, theninference:hold_endedstopped; afterabortInference()the wakes that joined it go back to the queue, before the turn's end is traced (:7728).Alerts (
finishBudgetHold, :7680):inference:hold_ended(src/types/trace.ts) reportsreleased,stoppedorremoved. A wait raisescontext-refusalonce, at its first hold that a listener ofinference:heldhasn't ended first (:7514), and a re-held wait carries whether it was raised (:7478), so a stop at itsinference:heldclears it, and, where its wait raised that alert,context-refusal-clear, worded for the end, atstoppedandremoved. Both haveskipLogand the wait'ssinceas their key, so the webhook's cooldown is per wait, andops:alert's doc says they write no failures.log record. The clear'swaitedMsand the note count from the wait'ssincetoo, whileinference:held'ssince,inference:hold_ended'swaitedMsandbudgetHold.sincecount from each hold's own start. A released start that's then dropped, declined or withdrawn raises no clear, since its wait ended at the release.Host-mode status:
getHostModeStatus().gatedRequestscounts a waiting turn once and each wake that joined it, counting each wake merged into one and none that passes a quiesce, as it counts every other gated request (:10089–10090), and its doc says so, wheremain's doc says a turn's continuations "are never counted". A merged first start counts once while it waits, sincemainstrips the merged batch at a start's gate (:27323–27326 at16a16fffbc, :28009 at the head).drainedreads turn holders and parked admissions alone, so it'struewhile a turn waits.The routing notice: where a start announces arrivals addressed from another channel (
speech.locus: 'delivery'with locus or hybrid prose routing, or locked routing), a start whose trigger carries a wait says they came "while your turn waited" (:28226), which holds for an arrival during the wait for compression and during a provider hold the released start met after it. A budget restart's continuation and a re-inference on tool results, whose reasons are checked first, keep their own wording.docs/dendrite.mdlists the new wording beside the others.Closing conversation forks: neither a frame's end nor the fallback sweep disposes a closing fork while its closure wake waits in the queue, a scheduler pass's hands, a provider hold or a budget hold, merged behind another wake or not (
closureWaits, :7635, read at :31207 and :21122). The sweep also leaves a fork while a budget hold of its agent exists (:21123), so a fork whose turn waits is kept as long as compression stays pending, since the wait has no time limit. The closure turn runs, and its end disposes the fork. Onmain, a turn alive at the fork's expiry disposes it at its end (:30470 at16a16fffbc), and the sweep, at most once a minute, disposes a fork whose closure wake a provider's stated wait keeps, each before the closure turn runs.Health:
healthSnapshot().agents[].budgetHold(:35438) givessince,pending,heldRequests,errorType,budgetandactualwhile the turn waits, andnullotherwise.Tests
79065172c2(Build & Test, ubuntu-latest, Node 22.12.0, on its merge intomainca7aa53a8c) ran 10,717 tests: 10,653 pass, 0 fail, 63 skipped, 1 marked todo. Its unforced run passed 131 of 131. Its two runs at the dependency floors gave the same counts. Both used context-manager 0.25.1, one with membrane 0.9.0 and one with 0.8.0.Linux 6.12, Node 24.22.2,
@animalabs/context-manager0.25.1,@animalabs/membrane0.8.0, TypeScript 7.0.2, afternpm run buildand thepretestcopy, at a clean checkout of3d0077aa6c's tree unless a line names the head:The change's seven test files, with 62 others picked by hand (among them the error policy, quiesce, conversation routing, held-turn retry, provider-wait, undo/redo, stored-injection, presentation and auto-continue suites): 69 files pass 2,763 of 2,763 together, none skipped. At
e405aaa4dd, the same 69 files pass 2,789 of 2,789: the 26 more are testsmainhas added to them since. At the head, they pass 2,792 of 2,792: two more are this head's new tests, and one is a testmainhas added sincee405aaa4dd.The change's own tests:
test/budget-refusal-hold.test.ts, 87 at3d0077aa6cand 89 at the head;test/presentation-points.test.ts, three new: a closing fork whose turn was alive at its expiry runs its closure turn; the fallback sweep leaves a closing fork while a provider's stated wait keeps its closure wake, which then runs; and the sweep leaves a closing fork while a budget hold of its agent exists;test/present-while-acting.test.ts, one new: an arrival during a provider hold that took a released start is announced as having come "while your turn waited";test/continuing-start-silence.test.ts, two new: a budget restart's continuation that waits for compression through a suppression of a row it doesn't depend on, which posts nothing again after an explicit send, under locus and hybrid prose routing;test/stop-step-throws.test.ts, the hold's row;test/audience-stamp-writers.test.ts, the note's writer, listed as one that holds its row back while a turn of the agent's is alive;test/turn-inputs-pin.test.ts: its assertion that the compile was refused exactly twice now takes two or more refusals, the first with the pin and every later one without it, since its compression is pending after the refusal, so the turn waits and its start is refused again.The hold tests patch
ContextManager.prototype'scompile,tick,isReadyandgetPendingWork, and the strategies' progress snapshot, to drive refusals and compression, but for one, which runs the wait on context-manager's ownAutobiographicalStrategy, with maintenance off so only the wait ticks it, and stubs only the refusal while that strategy isn't ready. It asserts a wait ended while the strategy wasn't ready. The sharded-injection test runs under the patches, storing its injection as shards through that strategy'sadaptiveResolution. The two progress tests count the waits that end directly. Under a mutant in which a snapshot's progress never ends a wait, their earlier versions, which counted refusals, failed too: the synthetic one's start was refused once, and the real-strategy one's strategy never became ready. The test of a released turn merged with a newer wake asserts that the start carried the batch and the wait, that the provider-held retry after its compile carries neither, and that its refusal then waits from its own start.Red first, on
715ee57f33's source with these tests, on the same versions: 4 of the 87 hold tests fail: the puppet call's refusal,drainedread atinference:held, and the two counts of merged wakes. The test of each provider admission given back once passes there, since the old frame gave back its admission once.Red first, on
e405aaa4dd's source with the head's tests: of the 89 hold tests, three fail: the silent retry's row, the undo's order, and the health count of a waiting turn's merged wakes. 86 pass.Red first for the health count, with the head's tests: on
9a76d312c0's source, only its test fails, at the merged wake. On2e9918a574's source, only that test fails, at the budget restart's wake.Red first, on
main's source (16a16fffbc) with these tests: the build there reports 13 type errors, each a hold test comparing or looking up a trace typemaindoesn't have (inference:held,inference:hold_ended), and TypeScript emits the tests all the same.budget-refusal-hold, with3d0077aa6c's 87: 84 fail. "fails as before when compression has nothing pending", the ephemeral run's test and the synchronous throw's pass, each checking whatmainalready does;presentation-points: the closing-fork test and the two fallback-sweep tests fail, and 21 pass;present-while-acting: the arrivals-notice test fails, and 95 pass;continuing-start-silence: the two new tests fail, and 32 pass;stop-step-throws: the hold's row fails, and 17 pass;audience-stamp-writers: its writer-list test fails, and 3 pass;turn-inputs-pin: 22 of 22 pass.Mutants, each run against the tests whose names its pattern picks, at a clean checkout of
3d0077aa6c's tree. Each of these 74 made every test listed with it fail, or, where its line says so, never finish. One build, the mutant's in which the scheduler's expansion drops a wait none of the batch carries, reported 3 type errors, and TypeScript emitted it all the same:abortInference()ignoring a waiting turn: fails "abortInference() ends a held turn, puts the wakes that joined it back in the queue, and clears the wait";beginAgentTurnof a start whose wait's row left the window: fails "drops a released first start still in the queue when a surgery removes its row"; "takes a released first start out of the start it was merged into once a surgery removed its row, and the wakes merged with it run"; "drops a released first start a provider wait kept, once a surgery removed its row";holdRequestnot copying the wait onto an equal request it holds: fails "a provider hold that already keeps an equal request without the wait takes the wait onto it, so a surgery that removes its row drops it";Two more mutants, on
a3584588d0's source:startandfirstRow: fails "a silent start's provider-held retry that waits for budget stands on the start's row, so a rollback that removes it drops the retry";Not verified
-clearkind, which closes the open kind and tells the channels that heard it, is read ata34d39011(src/modules/notices-module.ts:376–383, :433–437), not run.ContextManagerwhile compression stalls doesn't end a wait, since the framework doesn't hear that write. Such a wait still ends on any end the first pair lists, such as compression's next progress or its end. A probe test outside the change showed the general case atfdeba738f0's source, with context-manager 0.25.0 installed. It freed room outside the framework, not through a pin, while compression stayed pending without progress. The turn kept waiting until compression had nothing pending, then ran.AutobiographicalStrategystubs the refusal itself, refusing while that strategy isn't ready. No over-budget plan of a real store was produced.windowStatusOfisin. That's by reading: no run reached such a row.mainaa134e75fctook 15–49 ms per refused compile. A live resident's store wasn't measured.main, deduplicated by a linear search; its size and cost over an hours-long wait weren't measured.inferenceBounds: false, keeps the callbacks of the holds that join its drain until it does; not run.src/agent.ts:1590), as does the wait (:4381). So after a restart, a note a wait's release stored that no request carried stays in history, where a later wait can't take it out, and the resident can read it beside that wait's note. That's by reading: no run restarted a host.73b6c43bdbshowed it for a budget restart's continuation; no test in the change pins it, and for a tool-result guard's retry it's by reading. A run of another probe test showed an undo of a released retry carryingTURN_RETRYtaking its reply and leaving the note; that the note entered at the retry's turn-start presentation is by reading.