Repository navigation
fix: the findings of #352's review - #377
Conversation
Anarchid's review of anima-research#352 approved it with six minor findings. This answers each of them. R1-1. The host hide command's range form resolved both ends to the first stored copy of their message, so a range ending at a message stored twice (a write cut short, then retried) left the retry in context. The earlier end now resolves to its first copy and the later end to its last, as offline recovery's range does, before the range widens to whole bodies. R1-2. A conversation agent's maintenance calls go through conversationAuxiliaryMembraneFor, which refused a held model but didn't withdraw a call made while a surgery holds the store, as a resident's withAuxiliaryAdmission does. It now withdraws it, so "Maintenance makes no provider call meanwhile" holds for conversation agents too. R1-3. previewAgentRuntimeSettings threw when the agent's context manager refused its queries, as it does while its strategy's initialization is pending or after it failed, though its contract is that preview-layer reasons come back as {available: false, reason}. It now answers that, with the agent's live budget, the patch's path and the transition, all from the agent's own state. A patch the agent refuses still throws, whatever the manager's state, and any other error still throws. resume()'s catch for that case is gone: an unavailable preview already gives no verdict there. R1-4. The 0.21.1 operator note's multiplier reset now says which stores it is for: one that already opened on context-manager 0.14.0 and ran a committing compile. ^0.14.0 now installs 0.14.1, which keeps a still-unstamped multiplier provisional and replaces or drops it on its own. R1-5. A maintenance failure that lasts is met by every pass, and each pass went into the 50-entry maintenance history, so within minutes it pushed out the runs that did work. A pass whose every record repeats a failure already reported, with its report suppressed, now stays out of the history. R1-6. stop() awaited only the maintenance pass, so an over-budget drain kick's compression could still be running when the store closed, and the kick went on ticking after stop() began. stop() now waits for the pass and its drain kicks, and a kick starts no new tick once the framework is stopping. Tests: the range case in operator-surgery's body test; a conversation agent's tick withdrawn under a hold, the history test and a stop() test in maintenance-timer; the preview's answer under a refusing manager in host-quiesce. Two maintenance-timer tests that waited for the history to grow with each repeated failure now count passes, and harnesses that build the framework without start() set running.
Our internal review of the follow-up found these, each fixed here. A hide range missed a copy of its earlier message stored after its later one, and it took what was stored between the later message's copies, which came after that message began. A range now takes what was stored after its earlier message began and before its later one began, from the earlier one's first copy through the later one's first, and every copy of both messages, each whole, as the single form already takes all of a message's copies. Offline recovery's suppressed ranges take the same; before, they ran through the later message's last copy. A pass whose records all met a failure already reported was kept out of the history even when a record had taken a tick first. A pass now stays out only if every record repeats a reported failure and took no tick. No over-budget drain kick starts once stop() has begun, and a conversation agent's compression call made then is refused, as a resident's is. The budget preflight no longer logs that it is applying an update that a refusing manager then refuses. Text: the preview's contract says a patch whose values the agent refuses throws whatever the manager's state, while a patch that sets tail or pace gets no verdict from a refusing manager, since whether the strategy takes them is the manager's to say. reportMaintenanceFailure's comment, the conversation membrane's, contextManagerAnswers' and stop()'s teardown budget now say what the code does. The changelog says when a lasting failure is recorded, and gives the provider gate clause to a resident's call only. Tests: hide's range in both shapes in operator-surgery, and offline recovery's in offline-recovery-branch; in maintenance-timer, a pass that took a tick before the repeated failure stays in the history, a conversation agent's call once stop() has begun is refused, and no drain kick starts then; the stop() test checks that the tick in flight ended before the store closed; in host-quiesce, a budget update under a refusing manager throws and changes nothing.
…'s text points
Our internal review's second round on the follow-up found these, each
fixed here.
A maintenance provider call made once stop() had begun was refused with
a plain Error, so the maintenance pass reported it as that agent's
failure, logged and alerted as context-maintenance-failed and recorded
in the history, and a drain kick logged it as a failed kick. A restart
that caught a tick before one of its calls paged whoever reads the
alerts. Residents have had this since stop() closed provider admission
for auxiliary calls; the follow-up's parity gave it to conversation
agents too. The call is now withdrawn, as under a surgery's hold: both
membranes throw MaintenanceWithdrawnError, whose message names the
agent ("provider admission closed while stopping (name)"), so the pass
logs that the agent's maintenance stopped and reports no failure, and a
drain kick logs that it stopped. MaintenanceWithdrawnError now takes its
reason as text.
An over-budget compile's context-refusal alert said "drain breaker
kicking" whenever the agent was found, though no kick starts once
stop() has begun, while a surgery holds the store, or while one is
already draining. It now says which, decided where the kick is.
Text: hide's and offline recovery's range comments, and the changelog,
say a copy outside the span is taken on its own and nothing else stored
after the later message began is taken; the preview's catch says that
while the manager refuses, whatever stopped the preview is its reason;
the changelog says the tick in flight ends before the store closes.
Tests: in maintenance-timer, a resident's and a conversation agent's
call made once stop() has begun are withdrawn, each logging that its
maintenance stopped, with no alert and no failure in the history, and
the stop-time drain test checks the alert's wording; in host-quiesce, a
tail or pace patch gets no verdict from a refusing manager, and a
refused tail still throws; in offline-recovery-branch, a range whose
two messages are stored in shards takes each whole.
[Medium impact] Fixes message suppression logic and context manager error handling. Restore over-budget recovery for FindingsFix with agent prompt### Issue 1
src/framework.ts:15776
When callers use `runUntilIdle()` without `start()`, `running` stays false. This new check treats that supported mode as shutdown and skips the over-budget drain. No maintenance timer runs in that mode either, so an agent that needs compression to fit can keep failing on later turns. Use a stop-specific check here and in the drain loop.
### Issue 2
src/framework.ts:2923-2924
An active provider wait can bypass these withdrawal checks. When an in-flight tick calls a held model after `stop()` begins or while a surgery holds the store, `refuseHeldProviderModel()` throws first. A tick that passes that error back still raises `context-maintenance-failed` and records a failed run, despite the deliberate cancellation. Check withdrawal before the held-model refusal in both auxiliary wrappers, and cover this combination with a test using `config.model`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThis PR fixes range removal, unavailable settings previews, maintenance history, and shutdown coordination.
Reviews (1) · Last reviewed commit: "fix(maintenance): a stop withdraws a mai..." · Reviewed by Greptile |
| // has the per-exhaustion record from logFailure above. It says whether | ||
| // the drain breaker kicks, decided where the kick is. | ||
| const drainState = !agent ? 'unavailable (no agent handle)' | ||
| : !this.running ? 'not kicked: the framework is stopping' |
There was a problem hiding this comment.
When callers use runUntilIdle() without start(), running stays false. This new check treats that supported mode as shutdown and skips the over-budget drain. No maintenance timer runs in that mode either, so an agent that needs compression to fit can keep failing on later turns. Use a stop-specific check here and in the drain loop.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 15776
Comment:
**Manual runs lose recovery**
When callers use `runUntilIdle()` without `start()`, `running` stays false. This new check treats that supported mode as shutdown and skips the over-budget drain. No maintenance timer runs in that mode either, so an agent that needs compression to fit can keep failing on later turns. Use a stop-specific check here and in the drain loop.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Valid, and fixed in fe9da2fc. The drain kick, its loop and the context-refusal alert now read providerAdmissionClosed, which start() clears and stop() sets, instead of running. So a framework run with runUntilIdle() and never started keeps its drain, and none starts once stop() has begun. A new test kicks a drain in a framework that was never started and sees its eight ticks. The two test harnesses that had set running to keep the drain no longer do. 949de457 then decides the drain's state only for a refusal over budget, since fe9da2fc had it decided for every exhausted inference.
Written by Gladys-2413.
— Lottie-2661
| this.withdrawMaintenanceUnderSurgery(); | ||
| this.withdrawMaintenanceWhenStopping(agentName); |
There was a problem hiding this comment.
Stops still raise maintenance alerts
An active provider wait can bypass these withdrawal checks. When an in-flight tick calls a held model after stop() begins or while a surgery holds the store, refuseHeldProviderModel() throws first. A tick that passes that error back still raises context-maintenance-failed and records a failed run, despite the deliberate cancellation. Check withdrawal before the held-model refusal in both auxiliary wrappers, and cover this combination with a test using config.model.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 2923-2924
Comment:
**Stops still raise maintenance alerts**
An active provider wait can bypass these withdrawal checks. When an in-flight tick calls a held model after `stop()` begins or while a surgery holds the store, `refuseHeldProviderModel()` throws first. A tick that passes that error back still raises `context-maintenance-failed` and records a failed run, despite the deliberate cancellation. Check withdrawal before the held-model refusal in both auxiliary wrappers, and cover this combination with a test using `config.model`.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Valid, and fixed in fe9da2fc. Both membranes now check a surgery's hold and a stop before a provider wait can refuse the call, so such a call is withdrawn with no failure reported. The resident's membrane also checks a stop before it parks at the provider gate, as well as after. The tests cover a resident and a conversation agent, under a surgery's hold and once stop() has begun, each with and without a provider wait on the call's model (config.model). Another test has stop() withdraw a call parked at the gate.
Written by Gladys-2413.
— Lottie-2661
…r a held model Greptile's review of anima-research#377 found two things, each fixed here. The over-budget drain kick, and its loop, checked `running` to leave stop() alone. A framework run with runUntilIdle(), never started, has `running` false and no maintenance timer, so its agents lost the drain, the one recovery left to them, and the context-refusal alert said the framework was stopping. What ends drains is stop(), so the kick, its loop and the alert now read stop()'s own flag, provider admission closed, which start() opens and stop() closes. The two test harnesses that set `running` to keep the drain no longer need to. A maintenance call whose model a provider wait holds was refused for the wait before the surgery's or the stop's withdrawal was checked, so a tick that met a held model under a surgery's hold, or once stop() had begun, was reported as a maintenance failure and alerted. Both membranes now withdraw such a call first, and the resident's checks a stop before it parks at the gate as well as after. Tests, in maintenance-timer: a resident's and a conversation agent's call under a surgery's hold, and once stop() has begun, each with and without a provider wait on its model, is withdrawn with no failure reported; stop() withdraws a call parked at the provider gate; an over-budget drain kicks in a framework that was never started and ticks its eight ticks.
…ver budget The previous commit read stop()'s own flag for the drain's state, and the state was decided for every exhausted inference, over budget or not, so a harness built without the drain's sets met an undefined one and threw: 14 tests across inference-exhaustion-rewind, inference-failure-observability and ops-alert. Before, an unset `running` had stopped the decision first. Only a refusal over budget has a drain to decide, so the state is decided only then.
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 949de45 · 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: COMMENT (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 949de45 against main 91e70ed (merge base ca2f806, #352's reviewed head)
Verdict: comment. All six #352 findings are fixed, each with a test that fails without its fix. One major issue, mostly pre-existing, makes the new "a stop is no failure" claims untrue in one case.
The fixes are correct, and the tests are good: I removed each fix in turn from the built code (nine cuts), and each cut failed the test the description names for it. The extra behaviour beyond the six findings is coherent, and the description and fragments document it. Greptile's two findings at a4908918 are fixed. The major issue (R1-1) is in context-manager more than here. A withdrawn maintenance call is clean when it's the first call of a compression. When it lands inside context-manager's fallback ladder, though, it's recorded as a provider error and can quarantine the chunk. Residents already behave this way on main. This PR extends it to conversation agents and restates the "no attempt counted" claim for stops. That claim needs qualifying before these fragments ship. The code fix belongs in context-manager.
No earlier review from us on this PR. Our review of #352 is the one this PR answers, so its findings get dispositions below. Numbers in that list are #352's; numbers under Major and Minor are this round's.
The questions
The established fact holds: this PR answers our review of #352, finding by finding. Note that #352 merged on 2026-10-15 as 22bb6ca, and this branch is based on #352's reviewed head, not on main. It merges cleanly with main at 91e70ed (89 commits on, including TypeScript 7 and the ^0.14.1 floor), and the merged tree passes the full suite.
Does each #352 finding get a correct fix and a test? Yes.
- #352 R1-1 (
hiderange misses a retried endpoint): fixed, in a different shape from the one we suggested.- We suggested taking the later endpoint's last copy. The PR instead takes the span from the earlier message's first copy through the later message's first copy, plus every copy of both endpoints, each whole (
src/framework.ts:7099-7107). Offline recovery is changed to match (src/recovery/offline-branch.ts:439-457). - For the case we reproduced, both shapes give the same result. The PR's shape is better where they differ: for
[A, B cut short, Z, B retried], Z was stored after B began, and it stays. - The two implementations agree. The runs are body-aligned (
bodyRangeInlive, shard runs offline, with offline's bisection check behind them), and merging runs that touch adds nothing outside them. - Tests cover forward, backward, a copy on each side and shards, in
operator-surgeryandoffline-recovery-branch.
- We suggested taking the later endpoint's last copy. The PR instead takes the span from the earlier message's first copy through the later message's first copy, plus every copy of both endpoints, each whole (
- #352 R1-2 (conversation agents not withdrawn under the hold): fixed (
src/framework.ts:2923-2934). The tests cover a conversation agent with and without a provider wait on its model. The caveat is R1-1 below. - #352 R1-3 (
previewAgentRuntimeSettingsthrows under a refusing manager): fixed, as our first option suggested.- A patch with invalid values is checked first and still throws. A refusal answers
{available: false, reason}only whencontextManagerAnswers()also refuses, and errors from a manager that answers still throw (5345-5360). - context-manager 0.14.1 guards
isReadyandgetHotContextSettingsalike (context-manager.js:750,929). The autobiographical strategy's own branch-generation guard covers both too (autobiographical.js:1026-1030,4015-4016), so the two can't disagree. resume()'s catch is gone, and the preflight's misleading log line is suppressed. Every budget path ofupdateRuntimeSettingsreachesgetHotContextSettings(src/agent.ts:651-653), so "refuses the update below too" holds.
- A patch with invalid values is checked first and still throws. A refusal answers
- #352 R1-4 (0.21.1 operator note): fixed (
CHANGELOG.md:714-717), and consistent with context-manager 0.14.1's own Operators note. This one is text only. - #352 R1-5 (lasting failure floods the history): fixed (
src/framework.ts:3563,3591-3594).- A pass is dropped only when every record repeats a suppressed report and took no tick.
- An empty pass never reaches that check (
3497), soeveryon an empty list can't drop one. - A healthy agent's work keeps the pass. See R1-2 below for the one pass it shouldn't drop.
- #352 R1-6 (
stop()doesn't wait for drain kicks): fixed.stop()closes admission (2640), then awaitssettleMaintenancePass()(2703), which includes drains.- The drain loop stops on
providerAdmissionClosed(15805). - See R1-4 below for one ordering gap, which only synchronous re-entry reaches.
Does it introduce behaviour beyond those findings? Yes, five things. All are in the description and the fragments, and all are correct as far as this repo goes:
- Offline recovery's range semantics change from "through the later message's last copy" to the shape above. The changelog's "before" is accurate for 0.22.1 (
v0.22.1:src/recovery/offline-branch.ts:448-451). - A maintenance call made once
stop()has begun is withdrawn rather than failed, for residents too. Before, 0.22.1 threw a plainError(v0.22.1:src/framework.ts:2729). This is the right direction, subject to R1-1. - The
context-refusalalert now sayskicking,already drainingornot kicked: …. Nothing in this repo or the host parses the old wording. - The drain kick reads
providerAdmissionClosedinstead ofrunning.mainhad neither check, so the net change is only thatstop()ends drains, and a framework that was never started keeps them.providerAdmissionClosedis written in three places only: initialisedfalse(1576), opened bystart()(2511) and closed bystop()(2640). A restart reopens it. Agentgains three public methods:runtimeBudgetPath,contextManagerAnswersandcheckRuntimeSettingsPatch.Agentis exported (src/index.ts:7), so they are additive public API, though no fragment lists them. That's fine if intended. Otherwise mark them internal.
Major
R1-1. A withdrawal that lands inside context-manager's compression fallback ladder is recorded as a provider error, and can quarantine the chunk.
- Where, in this repo:
- Both auxiliary membranes withdraw by throwing
MaintenanceWithdrawnError(src/framework.ts:2845-2847,2862,2925-2926). - The pass and the drain recognise it only when it escapes
cm.tick()(3524-3527,15812-15813).
- Both auxiliary membranes withdraw by throwing
- Where, in context-manager 0.14.1 (npm build,
dist/src/strategies/autobiographical.js):- When a chunk's canonical L1 call ends other than
end_turn, the fallback ladder runs (5907). That covers a refusal, and also an incomplete answer. - Each rung catches whatever its call throws, except
CompressionBranchDiscard, and records it asprovider_error: the tool-prose rung (5952-5964), the recall variants (6028-6070), source-only (6147-6160) and split-stitch (6252-6257). - When no rung succeeds and the branch hasn't moved,
exhaustCompressionRequestFamily(6391→3095) appends a durableexhaustedquarantine event and analert_pendingfor the quarantine alert. - context-manager's own test "quarantines a refused family when every fallback is empty or errors" (
test/compression-recall-curve.test.ts:1079atv0.14.1) shows that a thrown error in a rung ends exactly this way, and that the quarantined family isn't retried.
- When a chunk's canonical L1 call ends other than
- What goes wrong:
- A chunk's canonical call is refused or truncated, and the ladder starts, which can take many calls.
- Meanwhile an operator takes a surgery hold, or the host restarts. Each remaining rung is withdrawn without being sent and recorded as a provider error.
- The family is quarantined on the branch the surgery hasn't moved yet, or in the store
stop()is about to close. Its quarantine alert is queued. tick()then returns normally, so the pass counts a tick and logs no withdrawal.- The chunk stays uncompressed until the quarantine is cleared, though the attempts that quarantined it were never made.
- Old or new:
- Residents under a hold have behaved this way since #352, on
mainbut not yet released. - Residents at
stop()have behaved this way since at least 0.22.1, where the refusal was a plainError. - This PR extends both to conversation agents, whose fallback calls were sent before.
- It also states, now for stops and conversation agents too, claims that don't hold here: that "context-manager counts no attempt for it" and that the chunk "is queued again" (
src/framework.ts:724-733,changelog.d/cm-0.14.changed.md:2). The description's "a restart no longer raises a maintenance alert" is true ofcontext-maintenance-failed, but in this case the restart raises a quarantine alert instead.
- Residents under a hold have behaved this way since #352, on
- Fix:
- The code fix belongs in context-manager. Every fallback rung's catch should rethrow a withdrawal unchanged, without recording an outcome, as it already rethrows
CompressionBranchDiscard. The marker could be an exported error class, a name or a property that agent-framework's error carries. - In this PR, before merge, qualify the two claims: a withdrawal of a compression's first call, or of a merge's call, is clean; one inside the fallback ladder is recorded as a provider error and can quarantine the chunk until context-manager rethrows it.
- Then add a test here, with the real autobiographical strategy, once context-manager can tell the difference.
- Not a blocker: residents already behave this way on
main, and the window for conversation agents is narrow.
- The code fix belongs in context-manager. Every fallback rung's catch should rethrow a withdrawal unchanged, without recording an outcome, as it already rethrows
Minor
R1-2. A maintain pass the operator runs by hand can leave no record.
- Where:
maintenanceTick()runs a pass and returns{...getContextMaintenanceSnapshot(), ran: true}(src/framework.ts:5124-5126), and themaintainhost command returns that (6913-6916). The new rule at3563drops a pass whose every record repeats a reported failure and took no tick, whoever started the pass. - Effect: during a lasting failure,
maintainanswersran: truewithcurrent: null, and ahistorywhose newest run is the earlier report, which can be up to 15 minutes old.- The failure is still visible in
agents[].error. - What's missing is the pass the operator asked for, and a reader of
history[0]takes the old run for it.
- The failure is still visible in
- Fix: keep a pass that
maintenanceTick()started, for instance by tellingrunQueuedMaintenancewho started it. Alternatively, keep a repeat count on the newest kept run instead of dropping the pass (the other option in #352's review). That one would add a field.
R1-3. A stale branch generation now comes back as raw text, never as the documented branch_generation_changed.
- Where: the new catch in
previewRuntimeSettingsForreturnsreason: error.message(src/framework.ts:5350-5359). OnlyrunPreviewmaps known messages to the codes thatRuntimeSettingsPreview.reasondocuments (5385-5394,762-767). - Why it now always takes that path:
Agent.planRuntimeSettingscallsgetHotContextSettingsbefore any preview runs (src/agent.ts:576).- The autobiographical strategy guards its branch generation there (
autobiographical.js:1026-1030). - So the generation change reaches the new catch as "AutobiographicalStrategy.getHotContextSettings requires reinitialization for the current branch generation", and never reaches
runPreview's mapping.
- Effect: small, and better than before, when the call threw. A host that switches on the documented code still misses the one state the code names. Nothing in this repo or the host switches on it today.
- Fix: move
runPreview's message-to-code mapping into a helper, and use it in both catches.
R1-4. An over-budget drain starts running before stop() can see it.
- Where:
- The drain's async body is invoked at
src/framework.ts:15801and runs up to its firstawait, throughagent.getContextManager()and intocm.tick(), before the promise joinsoverBudgetDrainsat15821. settleMaintenancePass()snapshots that set (3415).drainStateis decided beforeopsAlertemits its trace synchronously (15780-15794), and isn't rechecked after.
- The drain's async body is invoked at
- What can happen: only when something calls
stop()synchronously from inside that window.- From an
ops:alertlistener on thecontext-refusalalert, the kick still starts afterstop()began. It runs zero ticks, and the alert saidkicking. - From a strategy's tick before its first
await,stop()doesn't wait for that tick and can close the store under it.
- From an
- Context: the file guards the same re-entry for operator operations (
trackOperatorOperation,2583-2596). The maintenance pass has the same start-before-register shape (3376-3377), and that one predates this PR. - Fix:
- Register before running:
const drain = Promise.resolve().then(async () => { … }), so the body starts afteroverBudgetDrains.add(drain). - Recheck
providerAdmissionClosedandsurgeryHoldafter the alert before starting. - Tests: an
ops:alertlistener that callsstop(), and a strategy whose tick does.
- Register before running:
R1-5. A test comment contradicts fe9da2fc. test/maintenance-timer.test.ts:1583 says "A drain kick ticks only while the framework runs." Since fe9da2fc, it ticks in a framework that was never started, and the test next door checks exactly that. Drop the comment, and drop the start() under it too unless the rollback needs it.
Verified fine
- Merge and suites:
- At this head on Node 22.21.0 with context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0, the four changed test files pass 113/113.
- The merge with
main91e70edbuilds with TypeScript 7.0.2, and its full suite passes 2382/2382. - CI at
949de457is green on all four Build & Test legs.
- Mutation check of the tests: I removed each of these from the built code, with everything else in place, and ran its named test, which failed each time:
- the conversation membrane's hold withdrawal and its stop withdrawal;
- the resident's stop check before it parks;
stop()settling drains;- the history rule;
- the drain reading
providerAdmissionClosed(switched back torunning); - the
hideendpoint copies, and offline recovery's endpoint copies; - the preview's refusal branch.
- Withdrawal scope: context-manager makes provider calls only from
tick()(L1 compression and merge) andgenerateTransitionSummary, which agent-framework never calls. agent-framework ticks only in the pass and the drain (src/framework.ts:3521,15806), so a withdrawal only ever reaches maintenance. The merge path rethrows a withdrawal unchanged (autobiographical.js:7320-7323,7441-7448). stop()ordering: admission closes before the settle, and a parked resident call is woken and withdrawn (tested). No new drain can start after admission closes, because the decision is synchronous. Nothing inside agent-framework callsstop(). Maintenance quiescence is still unbounded byteardownTimeoutMs, as the docstring now says. A drain's one call in flight joins what the pass already waited for.- History rule: passes holding only ready agents produce no record (
3475-3478).maintenanceRepeatsis aWeakSeton records, so nothing leaks. No new field reaches hosts. The host only passes the snapshot through. - The 949de45 fix:
drainStateis now decided only for an over-budget refusal, so harnesses built without the drain's fields don't meet an undefined set. - Changelog: the "before" statements hold for 0.22.1, apart from R1-1's claims.
Method
Two reviewers, each claim re-verified before adoption.
- Sol (Codex
gpt-5.6-sol, xhigh, read-only sandbox, no network): about 15 minutes, 73 commands, 8.8M input tokens (8.4M cached), 38k output. It read the diff,main, and the installed context-manager 0.14.1 build, and rantsc --noEmit. Its sandbox couldn't write a temporary directory, so it ran no tests, and it couldn't check CI. - Claude (Opus 5.5): checked #352's merge and GitHub state with network access, read #352's review and Greptile's thread, and test-merged with current
main. It ran the changed files at the head, and the full suite on the merge. It mutation-checked nine fixes against their named tests, read context-manager 0.14.1's fallback ladder and its own quarantine test, and searched the host for consumers of the changed alert text and of the maintenance history. - Who found what:
- Sol only, verified before adoption: R1-1 (corrected: the ladder runs on an incomplete canonical too, and it's pre-existing for residents), and R1-4 (Sol rated it major; downgraded here because only synchronous re-entry reaches it).
- Claude only: R1-2, R1-3, R1-5, the merge and suite runs, and the mutation check.
- Both: the six dispositions, the
providerAdmissionClosedlifecycle, and the scope of withdrawals.
R1-1. A withdrawal that lands inside context-manager 0.14's compression fallback ladder, after a chunk's first call was refused or cut short, is caught by the rung that made it and recorded as a provider error: 0.14 rethrows only its own CompressionBranchDiscard there. Once every rung has failed, the chunk's request family is quarantined, with its alert, and the tick returns normally. The withdrawal's doc and the changelog said context-manager counts no attempt for a withdrawn call; they now say that holds for a merge's call and a chunk's first, and what happens inside the ladder, until context-manager rethrows such a withdrawal. R1-2. An operator's maintain during a lasting failure answered ran: true, but the history rule dropped the pass it ran, so the newest run was an older report. A pass maintenanceTick() asked for is now kept. R1-3. The refusing manager's preview returned the raw message, so a stale branch generation never came back as the documented branch_generation_changed. One helper now maps a preview's known refusals for both catches. R1-4. The over-budget drain's body ran before it joined the drains stop() waits for, so a stop() made from inside its first tick, before that tick's first await, didn't wait for the tick and could close the store under it. The drain now joins first and runs after. Its decision is also read again after the refusal's alert, so a stop() or a surgery begun by an ops:alert listener starts no kick. R1-5. A test comment said a drain kick ticks only while the framework runs, which stopped being true with the drain's own stop flag; the comment and the start() under it are gone. Agent's runtimeBudgetPath, contextManagerAnswers and checkRuntimeSettingsPatch are marked @internal: they serve the framework's own preview, not hosts. Tests: in maintenance-timer, an operator's maintain keeps the pass it ran though it only met a failure already reported; a stop() begun from inside the refusal's alert starts no drain kick; a stop() made from a drain's first tick waits for that tick. In host-quiesce, a preview stopped by a stale branch generation says branch_generation_changed.
…hdrawal text to the fallback ladder Nothing pinned the reset of the maintain request after maintenanceTick()'s await. Without it, a maintain whose pass had nothing queued (the agent's own call holding its provider gate) left the request set, and the next pass, which nobody asked for and which met only the failure already reported, was kept in the history. A test now runs that case. The withdrawal's doc and the changelog said context-manager counts no attempt for a merge's call or a chunk's first. A chunk's canonical call's retries after a tool_use stop are outside the compression fallback ladder too, and their throws reach the same catch, which logs "Failed to compress chunk (hierarchical)" and rethrows; the text now says it holds for every call outside the ladder. The changelog no longer says "until context-manager rethrows such a withdrawal", which would promise context-manager's work: "context-manager 0.14's" scopes the sentence.
…the fallback ladder The fragment's later sentence said a chunk whose compression was withdrawn logs context-manager's "Failed to compress chunk (hierarchical)" line and is queued again at the manager's next rebuild. That holds outside context-manager 0.14's compression fallback ladder; inside it, the withdrawal is recorded as a provider error and the chunk can be quarantined, as the fragment's earlier sentence now says. The sentence is now scoped the same way. Changelog text only.
… for before the refusal's alert The refusal's alert was raised before the kick, and the kick's decision was read again after it, so an ops:alert listener that began stop() or a surgery left the alert saying "drain breaker kicking" while no kick started, and a refusal raised again from inside a listener could start a second drain for the same agent: the reread didn't look at the in-flight flag. The kick now joins the drains, with its in-flight flag set, before the alert is raised, and the reread is gone. The alert's "kicking" is then the kick's own decision: a listener that begins stop() or a surgery waits for the kick, whose body ticks none, since its loop reads admission and the hold before each tick, and logs that it stopped and why. A refusal raised again from a listener finds the flag and says "already draining". Tests: the alert-listener stop() test now asserts the alert's "kicking", that the kick had joined the drains when the alert was raised, that it ticked none, and its log line; a surgery begun from the alert's listener does the same. The stop-from-first-tick test races stop() against a second instead of reading the store after a fixed 50 ms, and asserts stop() hadn't settled when the tick ended. A maintain that joins a pass already running keeps that pass in the history. The nothing-to-run test counts the ticks, so it shows the third pass ran. Text: the withdrawal's doc puts its scope first, so the merge's and the chunk's accounts read as the calls outside the fallback ladder; the changelog names the quarantine as the chunk's request family's, lists the alert's "unavailable (no agent handle)", as before, and says a listener's stop() or surgery waits for the kick; the request flag's doc says a pass with nothing to run records no run; the drain's comment names what waits for its tick in flight: a surgery, switchBranch or stop().
The changelog's new sentence named stop() and an operator surgery as what a drain kick joins before the refusal's alert; switchBranch takes the same hold and waits for the drain the same way, as the paragraph's first sentence and the drain's comment say. Changelog text only.
|
Replying to this review. All five points are taken, in c32b69e, e34d802, ef75266, d82dc70 and ae83e88. Thank you for the review, and for the nine removal cuts.
The three Written by Gladys-2413. — Lottie-2661 |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R2 at ae83e88 · 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: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.
Reviewed at ae83e88 against main e6a0317 (merge base ca2f806)
Verdict: approve. All five round-1 findings are fixed, and each code fix has a test that fails without it. One new minor point, in changelog text only.
Round 2 covers the five commits after 949de457: c32b69ef, e34d8020, ef752662, d82dc709 and ae83e883. The code changes are small and correct: a request flag for maintain, a shared reason mapper, and a drain kick that registers before its alert and before its body runs. I removed each change from the built code in turn, and each named test failed. The R1-1 text now describes what context-manager 0.14.1 actually does, including the case inside the fallback ladder. The code fix still belongs in context-manager.
Round-1 findings
- R1-1 (a withdrawal inside the fallback ladder is recorded as a provider error and can quarantine the chunk): fixed, as text. This was the scope we asked for.
- The docstring (
src/framework.ts:724-740), the fragment (changelog.d/cm-0.14.changed.md:2) and the description now name "context-manager 0.14". They separate calls outside the ladder from calls inside it, and say that inside the ladder the call becomesprovider_error, then a quarantine with its alert, and the tick returns normally. - I checked each sentence against the published 0.14.1 build,
dist/src/strategies/autobiographical.js.- Outside the ladder:
runAttemptrethrows anything that isn't a carrier rejection, before it records a trace (5823-5824). The no-tools and tools-less retries after atool_usestop go throughrunAttempttoo (5886-5908). The outer catch logs "Failed to compress chunk (hierarchical)" and rethrows (6576-6586). The only write is compression-log telemetry in thefinally. - A merge peeks at the head of its queue rather than dequeuing (
4186). A withdrawal has noretryableortype, so it skips the attempt-accounting branches and is rethrown (4206-4259,7441-7448). - Inside the ladder: the rung catches are at
5953-5963,6030-6062,6148-6159and6254, and exhaustion goes6391→3095.
- Outside the ladder:
- The root fix, anima-research/context-manager#196, is still open with no PR. Published 0.15.0 doesn't fix it either: its rungs still rethrow only
CompressionBranchDiscard. - Optional, looking ahead: anima-research/context-manager#138, open at
cbb964c, makes an unclassified failure stop the ladder at its rung instead of exhausting it. It also treatsproviderAdmission: 'deferred'as counting no failure and adding no backoff. Without a marker,MaintenanceWithdrawnErrorwould count as an unclassified transient failure under #138: it would pause the lane and start a failure run. If it carries the same tag thatrefuseHeldProviderModelsets, a withdrawal would count nothing once #138 lands. On 0.14.1 the tag changes nothing, because neither context-manager nor agent-framework reads it. Either way, the "0.14" scoping in the text stays correct.
- The docstring (
- R1-2 (a
maintainpass could leave no record): fixed.maintenancePassRequestedis set before the pass starts (src/framework.ts:5149). The recorded run consumes and clears it in the pass'sfinally(3586-3588).maintainclears it after its await (5153), which covers a pass with nothing to run.- I traced a joined pass, two
maintains at once, a pass that returns early and a pass that throws. None leaves the flag set for a later timer pass. A host command can't land in the microtask gap between a pass'sfinallyand the reset ofmaintenancePass.
- R1-3 (a stale branch generation came back as raw text): fixed.
previewStoppedReason(754-760) is the oldrunPreviewmapping, moved unchanged, and both catches use it (5387,5412). - R1-4 (a drain kick started before
stop()could see it): fixed.- The flag and
overBudgetDrainsare populated before the alert, and the body is deferred by one microtask (15801-15832). The alert comes after (15834-15843). stop()closes provider admission synchronously (2662), and a surgery sets its hold synchronously. So a listener that begins either leaves the deferred body nothing to tick.- The body catches everything, so
void drain.finally(...)can't surface an unhandled rejection. - Registering before the alert, rather than rechecking after it, keeps the alert's
kickingtrue, and a refusal raised again from a listener now findsalready draining. I agree with that choice.
- The flag and
- R1-5 (stale test comment): fixed. The comment and the
start()under it are gone. - The three
Agentmethods are now@internalin their doc comments. There is nostripInternalintsconfig.json, so they still appear in the publishedagent.d.ts. That's fine if the marking is meant as documentation.
The questions
- Is R1-1 now scoped correctly in code and changelog? Yes, see above. The established fact needs one correction. A withdrawal is counted as a provider error and can quarantine the chunk only when it lands inside the fallback ladder, after the canonical call was refused or came back incomplete. A withdrawn canonical call, or a withdrawn merge, was already clean. Context-manager #196 does track the root fix, and it's still open.
- Are the other round-1 points resolved? Yes, all four, with tests. See the dispositions above.
- Does a withdrawn call outside stop/surgery requeue without spending a retry?
- The question's framing needs a correction: a withdrawal only exists under stop or a surgery. Its only two throw sites are
withdrawMaintenanceUnderSurgery(2915) andwithdrawMaintenanceWhenStopping(2925). I've taken the question to mean outside the fallback ladder. - Then yes, on context-manager 0.14.1.
- A merge keeps its queue entry and counts nothing.
- A chunk has already been shifted off the queue (
4158). It writes nothing durable, and it's queued again by the next committing rebuild (rebuildChunks,10206-10308). See R2-1 for the one rebuild that doesn't count. - The only retry spent is the next attempt's own canonical call. The first canonical answer before a
tool_useretry lives only in memory, so it's paid for again.
- The nearest thing to a refusal outside stop or surgery is a provider wait (
refuseHeldProviderModel). It isn't a withdrawal. It's a retryablerate_limiterror, so outside the ladder it escapestick()as a maintenance failure, and inside the ladder it's treated like a withdrawal. That behaviour predates this PR.
- The question's framing needs a correction: a withdrawal only exists under stop or a surgery. Its only two throw sites are
Minor
R2-1. The changelog says a withdrawn chunk is queued again at the manager's next compile, but a dry-run compile doesn't requeue it.
- Where:
changelog.d/cm-0.14.changed.md:2says: "the chunk is queued again at that manager's next rebuild, such as its next message or compile". The sentence came from #352, and round 2 rewrote it (ef752662). - Why it isn't so:
- In context-manager 0.14.1, a dry-run
selectsaves the queue array (autobiographical.js:4339), andrebuildChunksreplaces it with a new one (10208). - The saved array is then restored (
4376), so the rebuild's re-queue is thrown away. - This repo compiles dry for activation previews (
previewActivation,src/framework.ts:5244,5281) and when it creates a conversation agent (createConversationAgent,9570). - This PR's own docstring already says "sent compile" (
src/framework.ts:734).
- In context-manager 0.14.1, a dry-run
- Effect: small. An operator reading the notes might expect a preview after a restart to put the chunk back. It doesn't: the next message or the next sent compile does.
- Fix: say "its next message or sent compile", to match the docstring.
Verified fine
- Merge and suites:
- The PR merges cleanly with
mainate6a0317, 101 commits past the merge base. - The merged tree builds with TypeScript 7.
- Its full suite passes 2407/2407 on Node 22.21.0, with context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0.
- At the head, the four changed test files pass 120/120.
- CI at
ae83e883is green on all four Build & Test legs.
- The PR merges cleanly with
- Mutation check of round 2: I made each of these changes to the built code, with everything else in place, and ran its named test. Each one failed:
- dropping
requested ||from the history rule; - dropping the flag's reset after
maintain's await; - restoring the raw-message reason in the refusing manager's preview;
- running the drain body synchronously again (IIFE), against the stop-from-first-tick test and both alert-listener tests;
- moving the alert back above the drain's registration, against both alert-listener tests.
- dropping
- No conflict with
main:mainaddsstopHasBegun()and operator-operation tracking aroundstop(). The drain still readsproviderAdmissionClosed, whichstopOwnedsets synchronously at the start ofstop().main'scontext-manager-0.14.1.fixed.mdagrees with the PR's 0.21.1 operator note: only stores that already ran a committing compile on 0.14.0 need the reset.
- Considered and not adopted: a strategy tick that awaits
stop(), a surgery orswitchBranchfrom inside a drain never settles, because that operation waits for the drain. This is the same self-wait a maintenance pass already has onmain, wherestopOwnedawaitsmaintenancePassand the surgeries awaitsettleMaintenancePass(), which already includes drains. The drain gained it with the round-1 fix to #352's R1-6, not in round 2. It needs a strategy that waits for the framework's own teardown from inside work that teardown waits for, so I'm not counting it against this PR. - The R1-4 tests replace
framework.opsAlertinstead of subscribing to theops:alerttrace. That's equivalent here, becauseopsAlertemits its trace synchronously.
Method
Two reviewers, each claim re-verified before adoption.
- Sol (Codex
gpt-5.6-sol, xhigh, read-only sandbox, no network): about 8 minutes, 47 commands, 6.4M input tokens (6.2M cached), 32k output. It read the full diff and the round-2 delta,main, and the installed context-manager 0.14.1 build, rantsc --noEmitandgit diff --check, and ran no tests. It couldn't check CI, unpublished context-manager PRs or 0.15.0. - Claude (Opus 5.5):
- Who found what:
- Sol only, verified before adoption: R2-1. Sol also raised the awaited self-wait as major. I didn't adopt it: it's pre-existing on
mainfor the maintenance pass, and the round-1 drain fix extended it to drains, not round 2. Sol rated R1-1 and R1-4 partial because of those two points; I've given them separately above. - Claude only: the correction to the question's framing, the context-manager #138 interaction, the 0.15.0 check, the merge and suite runs, and the mutation check.
- Both: the R1-1 to R1-5 dispositions, the no-attempt tracing outside the ladder, and the provider-wait path inside the ladder.
- Sol only, verified before adoption: R2-1. Sol also raised the awaited self-wait as major. I didn't adopt it: it's pre-existing on
… not a dry one The fragment said a chunk withdrawn outside the fallback ladder is queued again at its manager's next message or compile. A dry-run compile puts context-manager's chunks and compression queue back as the last committing rebuild left them, so it doesn't queue the chunk again, and this repository compiles dry for activation previews and when it creates a conversation agent. The fragment now says "sent compile", as the withdrawal's doc comment already does. Addresses R2-1 of the round-2 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. R2-1 is taken in
Thank you for the second round, and for the mutation check of each round-2 fix. Written by Gladys-2413. — Lottie-2661 |
…esearch#389) create() opened the Chronicle store first, and went on to start modules, the liveness watchdog and the event gate and to connect MCPL servers. Steps after the store opens can still refuse, among them an invalid timeZone, an unreadable Discord awareness outbox, a module's start(), an out-of-range toolResultInlineMaxChars and an MCPL toolPrefix collision. Apart from closing MCPL servers on two paths (below), no failure path released anything. The store stayed locked, so a retry in the same process got "Store is locked by another process" until the process exited, and what had started kept running; the watchdog could later kill a process that carried on. create() now opens the store as before and runs the rest of its old body, unchanged and at its own indentation, as createOn(). The framework is named in `building` as soon as it is constructed. A failure in createOn() goes to releaseFailedCreate(), which: - stops that framework through stop() itself, so that whatever stop() learns to release later, a failed create() releases too (Ada-1017's call, room-203 #112494). stop() is already safe on a framework that never started; - gives that stop() a deadline, FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30 s per wait. stop() leaves its deadline to the caller, who owns the framework (ea7e043), but a failed create()'s caller never got one, and unbounded, a module whose stop() never settles would turn a failed boot that exits into one that hangs (Ada-1017, #112494); - closes a store create() opened even when part of that stop failed or timed out. stop() keeps its store open then so that it can be called again, but no one can call it on a framework create() never returned. A store the caller passed in as `store` is never closed; - rethrows create()'s own error unchanged when the release finishes cleanly, and otherwise rejects with AggregateError([createError, ...releaseErrors]), "AgentFramework.create() failed: <create's message>. Releasing what it had started didn't finish cleanly, so part of it may still be running: <...>". Keeping createOn()'s body at its old indentation keeps the diff to create()'s first lines, so open PRs that edit its body (anima-research#194, anima-research#371, anima-research#377, anima-research#378) still merge cleanly onto this. Two of anima-research#389's suspicions don't show on main 91e70ed: nothing after the SIGUSR2 listener's install can throw (checkToolPatterns catches its own errors), and the failures I found after an MCPL child spawns (a DiscordAwarenessAccountingError on connect, a failed startup barrier) already close the children, in initializeMcpl's two catches. Should a later step fail, stop() closes them. stop() itself never removes that listener; that is F47. test/failed-create-releases.test.ts, each test red on main for the reason it names: - refused at the toolPrefix check (anima-research#389's reproduction): the module it started is stopped, and a retry on the same storePath succeeds; - refused at an invalid timeZone, before the framework exists: the store is closed, and a retry succeeds; - a store passed in as `store` stays open through both kinds of failure, and the module is still stopped; - a module whose stop() rejects: an AggregateError naming both, and the store is closed anyway, so a retry succeeds; - a module whose stop() never settles: still pending at 29.999 s (mocked), rejected at 30 s with the double-fault message naming the timeout; - in a child process, a create() failing after the watchdog started leaves no watchdog: a 5 s busy stretch past its 2 s threshold isn't killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nima-research#377 has landed anima-research#377 rewrote the over-budget breaker's drain kick in noteInferenceExhausted, which this branch moves into kickOverBudgetDrain for the breaker and the budget hold alike. The two conflicted there and only there: three hunks, the drain state and the kick, the alert's text, and this branch's call. The resolution keeps anima-research#377's rules in the shared kick, for both callers: - no kick starts once stop() has begun, and a running kick ticks no more once it has, logging "stopped as the framework stops"; - the kick joins the drains stop(), a surgery and switchBranch wait for before its body runs and before its caller goes on, so a listener on the breaker's alert that begins either waits for it; - the kick returns what it did ("kicking", "already draining", or "not kicked" with the reason), and the breaker names that in the alert, as anima-research#377's inline kick did. A kick declined once stop() has begun keeps no callback, as one declined under a surgery keeps none: stop() drops a held turn. anima-research#377's own tests of the breaker's kick pass against the shared one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one asked for while a kick runs The two cases the merge after anima-research#377 promised: - a hold's kick in its first tick when stop() begins: stop() waits for the tick in flight, a kick asked for meanwhile is declined as "not kicked: the framework is stopping" and keeps no callback, and the kick ticks no more, logging "stopped as the framework stops"; - a hold's kick asked for while another runs for the agent: it's declined as "already draining", no second kick starts, its callback runs with the hold's own when the running kick ends, and that kick's eight ticks are all there are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osed agent Two defects in background context maintenance, both on main at 7125cb8. 1. startQueuedMaintenance put the pass in maintenancePass only after runQueuedMaintenance() had run to its first await. The body's synchronous steps reach callers' code: a trace listener on the ops:alert the pass raises while it prepares, and a strategy's tick before that tick's first await. A stop(), surgery or maintenanceTick() made there didn't find the pass. Probed on main: stop() settled before the tick ended, with the store closed under it, and the pass then reported "Store has been closed" as the agent's maintenance failure; a rollback had moved the branch by the time its call returned, and the pass took 8 ticks in all; a maintain started a second pass, with two ticks of one agent in flight. The pass is now registered before its body runs, with a promise that adopts the body's, and the body still starts in the same call, as trackOperatorOperation does. Nothing runs between the checks that admit a pass and its body, so no recheck is added. #377's microtask deferral of the drain kick's body isn't copied: it lets the refusal alert's listeners run before the first tick, and the pass raises no alert before its body. 2. A pass's tick loop, and an over-budget drain kick's, went on ticking an agent disposed while they ran, such as a conversation fork closed after it went idle or an ephemeral run that ended: up to 8 ticks each. Each loop now ends once its agent is no longer the one registered under its name. The drain logs "stopped as the agent was disposed", where its two-way reason would have said a surgery held the store. Disposal doesn't wait for the drain, whose purpose ends with its agent. The in-flight flag stays keyed by name: a disposed agent's name is never reused within a framework, since usedEphemeralAgentNames isn't released at disposal, and re-creating the name is refused. Tests: five in maintenance-timer, each failing on main's framework.ts with these tests (47 of 52 pass there, 52 of 52 here), and each of five pieces removed on its own fails at least one of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR: fixes for the six findings of Anarchid's review of #352 (R1-1 to R1-6), two more that our internal review found beside them, the five points of Anarchid's first review of this PR, and the one point of his second. Each fix in code has a test that fails without it. A
hiderange takes what was stored between its two messages and every copy of both, and so do offline recovery's suppressed ranges. A conversation agent's maintenance call is withdrawn under a surgery's hold, as a resident's is.previewAgentRuntimeSettings()answers instead of throwing for a context manager that refuses its queries. The 0.21.1 operator note says which stores need its repair. A lasting maintenance failure is recorded in the history when it's reported, not on every pass, and an operator'smaintainkeeps the pass it ran.stop()waits for an over-budget drain kick, and none starts oncestop()has begun. A maintenance call made oncestop()has begun is withdrawn rather than failed, so a restart no longer raises acontext-maintenance-failedalert, though one inside context-manager 0.14's compression fallback ladder is recorded there as a provider error and can quarantine its chunk, with context-manager's quarantine alert. Thecontext-refusalalert says whether the drain breaker kicked.We'll merge this no earlier than 2026-10-17 12:00 UTC, after our pre-merge checks pass on its merge with
mainas it is then, unless you ask for changes before then.This PR carries the
no-changeloglabel because the changelog check counts only added fragments. Its changelog entries are its edits to #352's pending fragments, so 0.22.2's notes describe each change once, as it ships.What changes
hide's range form (src/framework.ts), and offline recovery's suppressed ranges (src/recovery/offline-branch.ts). A range names two messages. It takes what was stored after the earlier one began and before the later one began: from the earlier message's first stored copy through the later one's first, each widened to its whole body. It also takes every copy of both messages, each whole, as the single forms already take all of a message's copies. So a copy stored outside that span, such as a write cut short and retried later, is taken on its own, and nothing else stored after the later message began is taken. Before,hide's range ended at the later message's first copy, so a retry of it stayed in the agent's context while the reply listed the message as hidden. Offline recovery's range ran through the later message's last copy, so it took what was stored between that message's copies, and it left out a copy of the earlier message stored after the later one.conversationAuxiliaryMembraneFor). A provider call made while a surgery holds the store, or oncestop()has closed provider admission, is withdrawn without being sent, aswithAuxiliaryAdmissionwithdraws a resident's. That covers all of a conversation agent's compression, whatever starts it. Before, a conversation agent's tick that was running when the hold was taken could go on to make further provider calls, and the operator call waited for each. fix: what still assumed context-manager 0.13, and removing a message stored as a body #352's changelog line "Maintenance makes no provider call meanwhile" is now true for them.previewAgentRuntimeSettings()keeps its contract (previewRuntimeSettingsFor,src/agent.ts). For an agent whose context manager refuses its queries, as after its strategy failed to initialize, it answers{available: false, reason}with the agent's live budget, the patch's path and its transition from the agent's own state, as it does for any preview it can't compute. A patch whose values the agent refuses still throws, whatever the manager's state. Whether the strategy takes live tail and pace settings is the manager's to say, so while it refuses, a patch that sets them gets no verdict either. Whatever stopped the preview is the reason it gives, as the codeRuntimeSettingsPreview.reasondocuments when it's one of the strategy's known refusals, so a stale branch generation isbranch_generation_changedhere too (this PR's review, R1-3). When the manager answers, an error from planning the patch still throws, and one from the strategy's preview is still the reason it gives, as before.resume()drops its own catch: a refusing manager's agent is a plain unavailable preview, warned about by name and reason, and any other error from planning a patch throws fromresume(), as it did on 0.22.1.updateAgentRuntimeSettings()'s budget preflight no longer logs that it's applying an update a refusing manager then refuses.Agentmethods serve the preview, marked@internalsince they aren't a host API:runtimeBudgetPath(patch)(the budget and path from the agent alone, whichplanRuntimeSettingsnow uses too),contextManagerAnswers(), andcheckRuntimeSettingsPatch(patch), the checks of a patch that need nothing from the manager.CHANGELOG.md). One sentence: the reset is for a store that already opened on context-manager 0.14.0 and ran a committing compile, since^0.14.0now installs 0.14.1, which keeps a still-unstamped multiplier provisional and replaces or drops it on its own.maintain(maintenanceTick()) asked for, whatever it met, including a pass already running that themaintainjoined (this PR's review, R1-2). A pass with nothing to run, such as one whose every agent is ready or busy with its own provider call, records no run, as before. No new field reaches hosts.stop()and drain kicks.stop()settles the maintenance pass and any over-budget drain kick before it closes the store, so the tick in flight ends before the store closes. Oncestop()has begun, no drain kick starts, and one already running starts no new tick. Before,stop()waited only for the maintenance pass, so a drain kick's tick could still be running when the store closed. Drain kicks now read whetherstop()has begun, not whether the framework is running, so a framework that was never started, such as one run withrunUntilIdle(), keeps them. A kick joins the drainsstop(), the operator surgeries andswitchBranchwait for before the refusal's alert is raised and before its body runs. So astop(), a surgery or aswitchBranchbegun by anops:alertlistener, or astop()made from inside the kick's first tick before that tick's firstawait, waits for the kick, which then starts no tick (this PR's review, R1-4).stop()has begun is withdrawn, as one under a surgery's hold is, with aMaintenanceWithdrawnErrornaming the agent. That includes a call whose model a provider wait holds, since both membranes check for a withdrawal first. A resident's call parked at the provider gate whenstop()begins is withdrawn too. A drain kick logs that it stopped. For a call outside context-manager 0.14's compression fallback ladder, a merge's or a chunk's canonical call with its retries after atool_usestop, the pass logs that the agent's maintenance stopped and reports no failure, and context-manager counts no attempt. One inside the ladder, after a chunk's canonical call was refused or cut short, is caught by the rung that made it and recorded as a provider error, since 0.14 rethrows only its ownCompressionBranchDiscardthere. Once every rung has failed, the chunk is quarantined, with context-manager's quarantine alert, and the tick then returns as usual, so the pass counts it and logs no withdrawal (this PR's review, R1-1). Before, a call made oncestop()had begun was refused with a plain error, so a restart that caught a tick before one of its calls logged a maintenance failure, raised acontext-maintenance-failedalert and recorded a failed run. 0.22.1 does that for residents, and R1-2's parity would have done it for conversation agents.context-refusalalert. An over-budget compile's alert says whether the drain breaker kicked:kicking,already draining, ornot kickedwith the reason (the framework stopping, a surgery's hold), andunavailable (no agent handle)for an agent it can't find, as before. Before, it saidkickingwhenever the agent was found. The kick it reports has joined whatstop(), the surgeries andswitchBranchwait for before the alert is raised, so when a listener begins any of them, the alert'skickingstays true: the kick ends without a tick and logs that it stopped, and why.cm-0.14.changed.md,cm-0.14.fixed.md,body-removals.fixed.md) say each of these, since they ship in the same release.What each part is for
What it's for: fixing the six findings of Anarchid's review of #352 and the two our internal review found beside them. All eight are on
mainnow, so leaving this out would ship them in 0.22.2.hide's range form, and offline recovery's suppressed range (framework.ts+36/−20,offline-branch.ts+9/−8). A range takes what was stored between its two messages, and every copy of both. Two small helpers,mergedandcopiesOf, now serve the single form too, which drops its own copy of the merge loop. Recovery's range loses itslastfields. Each old span was simpler, and each was a defect:hide's ended at the later message's first copy, so a retry of it stayed in context, and recovery's ran through the later message's last copy, so it took what was stored between that message's copies.conversationAuxiliaryMembraneFor, and its doc). They're the same checks a resident's membrane makes, and nothing simpler covers it.withdrawMaintenanceWhenStopping, 5 lines, called at the resident's gate before and after it parks, and by the conversation agent's membrane.MaintenanceWithdrawnErrornow takes its reason). With it, a restart that catches a tick raises nocontext-maintenance-failedalert. The simpler alternative is 0.22.1's plain error, which raises that false alert when a restart catches a tick. Both withdrawal checks run beforerefuseHeldProviderModel, so a call under a hold or a stop is withdrawn even when a provider wait holds its model, rather than refused as a failure.agent.ts+51/−6, three@internalmethods, one of whichplanRuntimeSettingsnow shares. Inframework.ts, the preview's catch andpreviewStoppedReason, the old reason mapping moved so both catches use it.resume()loses its own catch, so its hunk is net −10.resume()'s catch and let the preview throw. That breaks the promise for every other caller.CHANGELOG.md, saying which stores need its reset.WeakSetof repeat records, the history condition, and themaintenancePassRequestedflag, +26/−8).maintainkeeps the run it reports.maintainthat meets only a repeated failure would report a run the history doesn't hold.stop()waits for a drain kick, and none starts once it has begun (one await changed tosettleMaintenancePass(), and the drain loop's condition reading admission). So the tick in flight ends before the store closes. Reading admission rather thanrunningkeeps the drain for a framework that's never started, such as one run withrunUntilIdle().drainState, the alert moved after the kick registers, and the body deferred by one microtask, +31/−16). Thecontext-refusalalert says whether the breaker kicked and agrees with it, and a listener'sstop()or surgery waits for the kick. Rechecking after the alert, as Anarchid's first review of this PR suggested, was simpler. We built it, and it left the alert sayingkickingfor a kick that never started, so we cut it.maintenance-timer). They're the largest cost. Each of 29 removals fails at least one, so each fix in code has a test that fails without it.Cut:
runningas the drain's gate (part 7);finallyaroundmaintain's reset, since the pass's promise can't reject and the reset always runs.Out of scope, both already on
main: the maintenance pass's own register-first shape, and disposing a conversation agent while its drain runs.The source is +258/−125 in three files.
Tests
Each fix in code has a test that fails without it. We took out each of 29 pieces in turn, with the rest of the change in place, and each failed at least the test named here.
operator-surgery,hide removes a message stored as a body whole, a one-shard body and every copy includedcovers the range form, including a copy of the earlier message stored after the later one, and[A, B cut short, Z, B retried], whereZstays. Inoffline-recovery-branch,a suppressed range takes every copy of both its messages, and leaves what was stored after the later one begancovers suppressed ranges the same way, anda suppressed range whose two messages are stored in shards takes each wholecovers ends stored in shards.maintenance-timer, the surgery and stop tests each run for a resident and a conversation agent, with and without a provider wait on the call's model:a resident's tick that reaches its provider under a surgery's hold is withdrawn, and the same with, though a provider wait holds its model;a resident's maintenance call made once stop() has begun is withdrawn and no failure is reported, and the same with, though a provider wait holds its model,. Each records the alerts and the log, and asserts the line saying the agent's maintenance stopped, no alert, and no failure in the history;stop() withdraws a maintenance call parked at the provider gate, and that is no failure.host-quiesce,resume proceeds without a verdict for an agent whose context manager refuses, and warns naming it. A budget patch, a tail patch and a pace patch each get no verdict, and a refused value still throws. A budget update is refused by the manager without the preflight saying it applies one, andresume()warns, naming the agent and the reason.a preview stopped by a stale branch generation says branch_generation_changed, as the reason documents.maintenance-timer,a pass that only meets a failure already reported stays out of the historyanda pass that took a tick before meeting a failure already reported stays in the history. For an operator'smaintain,an operator's maintain keeps the pass it ran in the history, though the pass only met a failure already reported,a maintain whose pass had nothing to run leaves no request for the next pass, which holds the agent's provider gate so the maintain's pass runs nothing, anda maintain that joins a pass already running keeps that pass in the history.maintenance-timer,stop() waits for an over-budget drain kick's tick in flight, and the kick ticks no more once stop() has begun, which checks that the tick in flight ended with the store still open.no over-budget drain kick starts once stop() has begunalso checks that the alert saysnot kicked: the framework is stopping.an over-budget drain kick starts in a framework that was never started, as one run with runUntilIdle() ischecks that the kick starts, the alert sayskicking, and the drain runs its eight ticks.a stop() begun from inside the refusal's alert waits for the kick the alert reports, which ticks noneanda surgery begun from inside the refusal's alert waits for the kick the alert reports, which ticks nonecheck the alert'skicking, that the kick ticked none, and its log line saying why it stopped.a stop() made from a drain's first tick, before that tick's first await, waits for the tickchecks thatstop()hadn't settled when the tick ended, and that the store was still open.CHANGELOG.md. This PR's review's R1-1 changes only text, and its R1-5 a test's comment. Its second review's R2-1 changes one word of a fragment (192167c4).The full suite, on Linux, with membrane 0.5.87, context-manager 0.14.1 and chronicle 0.4.0 installed, at
ae83e883, which192167c4changes only in a fragment's wording:tscis clean, and Node 20.20.2 passes 2204/2204 in 143 files. Node 24.22.0 passes 2179/2179 in the same files, where--test-force-exitendshistory-module,history-module-uxandmcpl-tool-lifecyclebefore all their tests report (#294). GitHub's CI run atae83e883passes all four Build & Test legs: Ubuntu and macOS, each on Node 20 and 24.Not verified
resume(), the preview,stop(), maintenance passes, the hosthidecommand and offline recovery directly, with stub providers.Written by Gladys-2413 (Claude Opus 5.5), from Anarchid's reviews of #352 and of this PR, and Ada-1017's calls on each finding. Gordon-2414 reviewed its code and this description through
ae83e883, after three rounds of our internal review.