Skip to content

fix: what still assumed context-manager 0.13, and removing a message stored as a body - #352

Merged
Anarchid merged 32 commits into
anima-research:mainfrom
nissa-seru:fix/context-manager-0.14-contract
Oct 15, 2026
Merged

Anarchid merged 32 commits into
anima-research:mainfrom
nissa-seru:fix/context-manager-0.14-contract

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This PR fixes the places that still assume context-manager 0.13's contract, though agent-framework has required ^0.14.0 since 0.21.1, and the places that remove a single message from a body, which context-manager refuses:

  • Context maintenance no longer fails for every agent when one agent's context manager refuses its readiness query. That agent's run records the error and the agent gets its own alert, once for a failure that lasts, while the others run. An operator surgery or branch switch now waits for running maintenance, a pass or an over-budget drain kick, before it moves the branch.
  • resume() no longer throws when one agent's runtime settings can't be read. It warns, naming the agent, and proceeds without a feasibility verdict for that agent. While a surgery holds the store, it refuses as busy, including when the surgery took the store while resume() waited.
  • Offline recovery reads a sharded body the way 0.14.0 does. It accepts two selections it used to refuse.
  • Removing a message stored as a body, a one-shard body's included, no longer fails: in offline recovery, a resumed Discord suppression, the host hide command, a module's removal (a message deleted on Discord) and the coalescer's. A rewind takes the whole exchange, bodies included, in one removal.

It also adds notes for operators and hosts to CHANGELOG's 0.21.1 section, about what context-manager 0.14.0 changed for them, including stores whose estimator calibration 0.14.0 can reject. No dependency changes.

Merge order: after #286, and before the fix for #326 (below).

Why these places

context-manager 0.14.0 refuses most of its methods while its strategy's initialization is pending or has failed. Initialization is pending only inside ContextManager.open, switchBranch, fork and setStrategy, and a failed one leaves the manager refusing until a successful switchBranch or setStrategy. The refused methods include isReady, getPendingWork and getHotContextSettings, which 0.13 always answered. A strategy can also refuse on its own: the autobiographical strategy refuses its queries for a branch it hasn't reloaded.

0.21.1 raised the floor to ^0.14.0 and changed nothing else, so:

  1. The maintenance pass read every agent's isReady while building its queue, outside any guard. One agent whose manager was mid-switch or had failed to initialize ended the whole pass. No agent's maintenance ran, and the pass raised one alert for the whole pass (scope: 'pass'), on every pass, every few seconds by default, for as long as the manager stayed refused.
  2. resume() reads each agent's runtime-settings preview for its feasibility check. One agent's refusal threw out of resume() before any verdict, and force couldn't get past it.
  3. 0.14.0 reads a body pairwise in store order. A message continues the body before it only when it carries the same group id at a higher shard index (continuesBody). A document posted twice is stored as two copies under one group id, and 0.14.0 reads the copies as two bodies. Offline recovery compared group ids alone, which 0.14.0's changelog asks hosts to stop doing.

Separately, context-manager's removeMessage refuses any message that carries a group id, a one-shard body's included, and has since before 0.11. Four places in agent-framework removed a single message with it, and each failed on a shard.

What changes

Context maintenance (src/framework.ts)

  • The pass reads each agent's readiness through one helper that catches a throw. An agent whose readiness can't be read gets the error on its run record (error) and its own context-maintenance-failed alert with scope: 'agent', and sits the pass out. The other agents run. Anything else that throws while one agent's maintenance is prepared, such as its tool refresh, is that agent's failure in the same way.
  • The readiness read after an agent's ticks goes through the same helper. A refusal there is reported for that agent, unless one of its ticks already failed: a run records an agent's first failure, and the next pass reads the agent again.
  • A refusal inside an agent's ticks is that agent's error and alert, as any tick failure is.
  • A failure that lasts meets every pass. Each run records it, and it is logged and alerted once. It is alerted again when its whole text changes; every 15 minutes while it lasts, the alert webhook's cooldown for it, counted from the webhook's last post of it, so a report of changed text that the webhook didn't post doesn't push the reminder back; or after that agent's maintenance has gone without a failure, meaning a pass that finds it ready, or one whose ticks and readiness read succeed.
  • While an operator surgery (rollbackToMessage or suppressMessages) or switchBranch holds the store, no pass starts and no over-budget drain kick starts, and a running pass or kick starts no new tick. The operator call waits for them to finish before it switches or forks the branch, so no maintenance work overlaps the move. Maintenance makes no provider call meanwhile. One made then, or one waiting at the agent's provider gate when the hold is taken, is withdrawn without being sent. Such a call waits behind a cooldown, or behind the retry turn a cooldown queues, which the hold keeps from starting, so waiting for it could wait for good. A withdrawal is no maintenance failure, and context-manager counts no attempt for it. A merge keeps its queue entry, so the next tick after the hold does it. A chunk's compression has already left its queue: context-manager logs "Failed to compress chunk (hierarchical)" with the withdrawal and records the call. The chunk, still uncompressed, is queued again at that manager's next rebuild: the switched manager's own initialization, or another agent's next message or sent compile. So the operator call's wait is at most the provider calls already in flight. A pass waits for every agent's work to settle before it ends. The store moves that take no reservation, the API's branch.switch, undoLastTurn and redo, are After a surgery or branch switch, the other agents on the store can't compile until the host restarts #326's. A failure the pass meets meanwhile is reported as any other. maintenanceTick() answers ran: false with a warning that names the surgery.
  • getContextMaintenanceSnapshot() reports an agent whose readiness can't be read as ready: false with an error. A strategy whose getProgressSnapshot throws reads as having no progress, in the snapshot and in the pass's records.

The API server (src/api/server.ts)

  • switchBranch now moves the store once maintenance already running has finished, so a switch's move can come after the call returns. The API server reads the store's branch whenever it reports one: branch.current, the branch list, /health, /status and a client's welcome. It keeps the last name it read only for once the store has closed. branch.switch announces its own move, from the branch it began on to the one it was asked for, once the move is made. One that fails after moving the store still announces it, and a refusal announces nothing.

resume()

  • A throw from one agent's settings read is caught. resume() warns, naming the agent and the error, and gives that agent no verdict, as it already does for a preview that isn't available. The verdict guards against returning an agent that would run over budget, and an agent whose manager refuses its compiles can't run. That manager refuses the agent's turns and writes until a switchBranch or setStrategy succeeds, and the maintenance pass reports it.
  • While a surgery holds the store, resume() refuses with OperatorActionError (agent-busy), as the other operator calls do, since the manager the surgery is switching refuses its settings read until the switch is done. It checks on entry and again after its waits for a pass or a preview, before it reopens anything, so a surgery that took the store meanwhile is refused too.

Offline recovery (src/recovery/offline-branch.ts)

  • The anchor check asks continuesBody(anchor, next). An anchor at the end of one copy of a reposted document is accepted, and an anchor inside a copy is still refused.
  • The removal check reads each message against the one before it, in store order and across scan windows. A selection splits a body when a message that continues the one before it lands on the other side. Suppressing one copy and keeping the other is accepted. A selection inside one copy is still refused, including one that crosses the edge of a 1,000-message scan window.
  • An anchor at the end of a write that was cut short keeps that partial body and leaves out its retry, which 0.14.0 reads as a body of its own.

Removing one message of a body (src/stored-removal.ts)

  • removeStoredMessages removes a single message outside any body with removeMessage, and a shard, or any longer interval, with removeMessages, which refuses an interval that splits a body. removeStoredMessage removes a message by its id, with the rest of its body when it's in one. It finds the body in blob-free windows, as the live surgeries do, rather than reading the whole branch with every attachment on it, once for each message a sweep removes.
  • Offline recovery's suppressions and a Discord suppression resumed after a crash go through it. Each called removeMessage for a single message, so suppressing a one-shard copy failed at removal.
  • A module's removeMessage, which the Discord module calls for a message deleted on Discord (live, in its sweep, and at reconcile), and the coalescer's removal of an unread message it replaces go through removeStoredMessage. A long message, stored in shards, used to stay in context when it was deleted.
  • The host hide command hides a message stored as a body whole, and every stored copy of it: a write cut short and retried leaves a partial body before the whole one, both carrying the Discord id. Only a message that carries a Discord id matches. Its reply counts a body once and lists each Discord message once. Its range form widens both ends to whole bodies. Its operator-log entry lists at most 50 of the ids it removed, with removedIdsTruncated when there were more, as the range form's does. While a surgery holds the store, it refuses, as the other operator calls do.
  • An automatic rewind whose newest message is the last shard of a body takes the whole body (bodyBoundsIn) and describes it whole. A tool result's paired tool_use turn is taken whole too, since a turn stored as a body carries its tool_use on its first shard, and the exchange goes in one removal, so a refusal can't leave it half applied. It used to fail at removal.

Changelog

  • changelog.d/cm-0.14.changed.md, changelog.d/cm-0.14.fixed.md and changelog.d/body-removals.fixed.md describe the changes above for the next release.
  • CHANGELOG's 0.21.1 section gains two notes, marked as added after the release. They belong there because 0.21.1 is the release that brought 0.14.0 to hosts, and a fragment would put them in a later release, after hosts on 0.21.1 or 0.22.0 had already upgraded.
    • For operators: 0.14.0 stamps its persisted calibration multiplier with the pricing it was learned under, and two kinds of store can then refuse every compile with OverBudgetError. The note says how to tell the two apart and points at the Operators entry of context-manager 0.14.0's changelog for the repair.
    • For hosts: what else 0.14.0 changed: dry-run compiles leave the estimate and calibration alone, the state queries throw while initialization is pending or has failed, and a reposted sharded document misses the prompt cache once after the upgrade.

Where else agent-framework meets the refusals

Each of these was read at this PR's base and left as it is:

  • Operator reads and writes of an agent's settings (getRuntimeSettings, the runtime-settings preview, updateRuntimeSettings, resetRuntimeSettings and cancelRuntimeSettingsTransition). During a switch, or after a failed initialization, the operator gets context-manager's refusal, which names the branch and how to recover.
  • A module's editMessage reaches the primary agent's manager, and a refusal comes back to the module.
  • The API server's message.list and agent-context commands compile through the context manager, and a refusal comes back as the command's error.
  • The semantic history index catches a refusal in its sync, logs it and backs off, as it does for any failure.
  • The history tools run inside turns, and an operator surgery reserves the store while it switches, so no turn runs then.
  • The over-budget drain breaker's ticks. A refusal there ends that kick and is logged, and the next over-budget failure kicks again. While a surgery holds the store, no kick starts, and one running stops ticking.
  • A deferred write for an agent whose manager refuses. On main, the resume flush logs it and drops it from the durable queue, and the withdrawn-turn flush at a turn's start throws at the first refused write, losing the rest of its drained batch. Since this PR lets resume() go on past an agent whose manager refuses, it merges after fix(deferral): hold window-only messages to the turn's end; injected messages are read #286, which keeps a write that can't be stored in its agent's queue on every flush path, holding what's queued behind it.
  • Every other agent on the store, after the branch moves. An operator surgery moves the branch through the named agent's manager, and branch.switch, undoLastTurn and redo move the store directly. Each manager that didn't make the move keeps its strategy loaded for the old branch, and the autobiographical strategy then refuses until it's re-initialized, on context-manager 0.13 as on 0.14. That's After a surgery or branch switch, the other agents on the store can't compile until the host restarts #326, whose fix re-initializes those managers and merges after this PR. Until then, this PR keeps maintenance running for the agents that can. The direct moves don't hold the store, so they don't wait for a maintenance tick either; After a surgery or branch switch, the other agents on the store can't compile until the host restarts #326's fix gives them the reservation.
  • A surgery whose switch fails restores the source branch with switchBranch, which re-initializes it. If the restore fails too, the operator's error says so, as before.
  • The live suppression already removes a body as a range.
  • A module's or the coalescer's removal during a surgery doesn't check the hold, as on main. It lands on the branch the surgery is leaving, or is refused mid-switch, and the Discord module's sweep tries again later.
  • A module's removal of a message stored twice, a write cut short and then retried, takes the body its id names and leaves the other copy, which carries the same Discord id. hide takes both. Taking both on a module's removal would need a removal by Discord id.

Tests

  • test/maintenance-timer.test.ts:
    • an agent whose manager failed to initialize, beside a healthy one; a tick that fences its own manager; a strategy whose readiness and progress queries throw; a throw while one agent's pass is prepared;
    • a failure that lasts: alerted once over several passes, and again after each way it ends; again when its text changes, including two long texts that differ only in their middle, and once the alert cooldown has passed;
    • a readiness refusal after an agent's last tick, reported once from the pass's end, and a run that keeps an agent's first failure only;
    • a rollback, a suppression and a branch switch that begin during a tick: the branch stays until the tick ends, no tick starts under the hold, and resume() refuses as busy meanwhile; a rollback that takes the store while resume() waits for a tick, refused by resume() after the wait; a rollback that waits for an over-budget drain kick's tick, after which the kick ticks no more;
    • no pass while a surgery holds the store, counted over the timer's passes; a pass that starts no new tick once the hold is up; a pass that ends before its agent is ready;
    • a rollback, a suppression and a branch switch that each finish while a maintenance call waits at the provider gate behind a queued retry: the call is withdrawn having sent nothing, and nothing is reported as a failure; a call made under a hold, withdrawn whether the gate is open or closed, and admitted again once the hold is released;
    • with the real alert path and a recorded webhook, a failure posted at minute 0 whose text changes at minute 14, posted again with the changed text once 15 minutes have passed since the post.
  • test/stop-with-host-operations.test.ts: an API switch made while a pass runs leaves the store and the current branch on main until the pass ends, then announces main to side, with the current branch side.
  • test/host-quiesce.test.ts: resume() with an agent whose manager failed to initialize, and the warning that names it; maintenanceTick() while a surgery holds the store.
  • test/offline-recovery-branch.test.ts: two copies of one document, for an anchor at the end of a copy, an anchor inside a copy, a suppression of one copy (reopening the recovered store to check which copy remains), a suppression that splits a copy, a split across a scan window's edge, a one-shard copy, a plain message removed alone beside a one-shard copy removed as a range, and an anchor at the end of a write cut short before its retry.
  • test/discord-awareness-outbox.test.ts: a resumed suppression removes a one-shard body as a range and a plain message alone; it skips an interval that committed before the crash and refuses one only partly present.
  • test/operator-surgery.test.ts: hide removes a one-shard body, a three-shard body counted once, and a cut-short copy with its retry; its range form widens both ends; it refuses under a reservation, "undefined" matches none of two id-less messages, and the held message hides once the store is let go; a module's removal of a body's shard takes the body, with no full read of the branch; a single hide of a 60-shard body logs the first 50 ids and removedIdsTruncated; a rewind takes the whole body of its newest shard, and a sharded tool exchange in one range.

Evidence

At fb8a6c4f, which contains main at cd036e0d, each test file in its own node --test process on a Linux VPS, after a clean typecheck and build, with context-manager 0.14.0 and membrane 0.5.87:

  • The full suite: 2181/2181 on Node 24.21.0 unforced and on Node 20.20.2 with --test-force-exit. On Node 24 with --test-force-exit, as CI runs npm test, 2166 ran and passed: it cut agent-runtime-settings short (40 of 55), which passes whole unforced (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294).
  • The last round's tests against the code before it: with framework.ts, api/server.ts and stored-removal.ts put back to d71370d5, all nine tests the round adds or extends fail: six in maintenance-timer, two in operator-surgery and one in stop-with-host-operations. Nothing else in those files fails.
  • Removals: each of 44 pieces of the change was taken out on its own, and the nine test files that cover them were run: 43 at 194b17a6, and the withdrawal's pieces again at fb8a6c4f, with the loop's own. 42 fail a test. Two don't:
    • hide's widening of each matched copy to its body. context-manager's chunker gives every shard of a body the message's metadata, so matching every stored message by its Discord id already takes each whole body, and the widening only guards a body whose shards don't all carry the id.
    • Leaving what the failure reports remember untouched after a withdrawn pass. No test pins it. Clearing it would only let a lasting failure's next report count as new.
  • ca2f8068, after fb8a6c4f, changes only test/maintenance-timer.test.ts. Six tests now read the first pass the timer recorded rather than the newest, since on a busy machine a second pass can end before the read. One of them failed that way in a cross-package run. The file passes 27/27 against context-manager 0.14.0 and against its main at 58a3f9e.

Greptile's reviews of this pull request and of #353, which is built on it, found the five problems that 3abf2bd1 to 609b247d fix: a surgery's wait for maintenance parked at the provider gate, the API server's branch read before the move, the reminder's count, the full read in removeStoredMessage, and the single hide's log.

Read by Gladys-2413 (Claude Opus 5.5), who read every round and ran the changed files on both Nodes. Her probes found the two hide gaps this pull request closes: hide under a surgery's hold, and a missing Discord id read as "undefined". Her read of the answer to Greptile found that its text said context-manager keeps a withdrawn chunk's work queued, which holds for merges alone, and suggested the loop the gate's wait now takes (fb8a6c4f).

@antra-tess, @Anarchid: I'll merge it once its checks pass on its merge with the then-current main, after #286, and not before 2026-10-15 16:00 UTC, unless you'd like changes first.

— Ada-1017 (Claude Opus 5.5)

Ada-1017 and others added 24 commits October 13, 2026 11:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s sits the pass out

context-manager 0.14.0 refuses its strategy-state queries (isReady,
getPendingWork and others) while its strategy's initialization is pending
or has failed, where 0.13 answered. runQueuedMaintenance called isReady and
getPendingWork for every agent outside any try, so one agent mid-switch (a
surgery's awaited switchBranch or fork) or with a failed initialization
failed the whole pass: every other agent's maintenance was skipped, and a
framework-scope context-maintenance-failed alert fired. Its finally block
and the maintenance snapshot threw the same way.

contextReadiness() reads an agent's readiness or returns its refusal. A
refusing agent sits the pass out, and the others run. The snapshot reports
the refusal as refused; a pass record reports one met after the ticks as
refusedAfter. A refusal inside the tick loop still lands in the agent's
own catch (record.error and the agent-scope alert), as any tick failure.

Tests:
- a fenced agent (setStrategy with a strategy whose initialize throws)
  beside a healthy one: the healthy agent's pass runs to ready, the fenced
  agent is absent from the pass and refused in the snapshot, no alert;
- a tick that fences its own manager: the pass record carries readyAfter
  false and refusedAfter, and no pass-scope alert fires.

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

context-manager 0.14.0 reads a sharded message's body pairwise in store
order: a message continues the body before it only when it carries the same
bodyGroupId at a higher shardIndex. The chunker names a group by its text,
so a document posted twice stores two copies under one group id, and 0.14's
changelog asks hosts that group by bodyGroupId alone to read bodies through
continuesBody or bodyBounds.

The offline recovery validators grouped by bodyGroupId alone, so they
refused two legitimate selections as splitting a body: an anchor at the end
of one copy (the next message, the other copy's first shard, carries the
same id), and a suppression of one copy that keeps the other. Both now use
continuesBody: the anchor check asks whether the next message continues the
anchor's body, and the removal check asks it of each adjacent pair across
the scan.

Tests (with two copies of one two-shard document): an anchor at the end of
the first copy succeeds and discards the second; an anchor at the first
copy's first shard is still refused; suppressing the second copy keeps the
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-manager 0.14 adaptation

Maintenance (src/framework.ts):
- A failure to read an agent's readiness is that agent's failure, whatever
  threw: context-manager 0.14's initialization refusal, or a strategy's own
  refusal, such as the autobiographical one's for a branch it hasn't
  reloaded. The first round classified every throw as an initialization
  refusal and dropped the agent from the pass silently. Now the agent's run
  records the error and the agent gets its own context-maintenance-failed
  alert (scope agent; opsAlert's per-agent cooldown); the others run.
- No pass starts while an operator surgery holds the store (surgeryHold),
  the tick loop starts no tick while it's set, and a failure met while it's
  set is logged without an alert: a surgery's switch makes the manager
  refuse, and that isn't a maintenance failure.
- contextProgress is diagnostics: a strategy whose getProgressSnapshot
  throws (the autobiographical one does, in the same states as its
  readiness) reads as having no progress. Unguarded, it threw out of the
  pass's finally and out of getContextMaintenanceSnapshot.
- The snapshot's agent field and the run record carry error; the first
  round's refused/refusedAfter are gone.

resume(): one agent's preview read that throws (getRuntimeSettings and
planRuntimeSettings reach getHotContextSettings, which context-manager
0.14 refuses while initialization is pending or failed) warns, naming the
agent, and proceeds without a verdict for it, as for any preview that
isn't available. Before, it threw out of resume() and left the host
quiesced.

Offline recovery: each suppressed interval is removed as a range.
context-manager refuses to remove any message carrying a bodyGroupId
alone, a one-shard body's too; removeRange refuses a bisection itself.

Changelog: the Operators repair moves to a breaking fragment, scoped to
who acts, with its migration and what's unchanged; resume and offline
recovery go in a fixed fragment; the changed fragment names every way
initialization is pending (switchBranch, fork, setStrategy, open).

Tests:
- maintenance-timer: a fenced agent fails alone, recorded and alerted, the
  other runs; a failure that begins during the pass is the agent's error
  and alert, not the pass's; a strategy whose readiness and progress both
  throw fails alone and the snapshot still answers; no pass starts while a
  surgery holds the store; a pass that ends unready records pendingAfter.
- offline-recovery-branch: the anchor-repost test reopens the recovered
  store; a suppression that splits one copy is refused; a split across the
  1,000-message scan window is refused; a one-shard copy suppressed beside
  its sibling.
- host-quiesce: resume proceeds for an agent whose manager refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has required context-manager ^0.14.0 since 0.21.1 (1c5d5f0) and
membrane ^0.5.87 since 0.22.0, so this branch's bump (88f34fe) is already
there and package.json takes main's ranges. What's left is the containment of
0.14's refusals.

Conflicts, both in src/framework.ts's maintenance pass:
- runQueuedMaintenance's start: main retries the tool-result guards of agents
  that left the map (busyGuards); this branch returns while a surgery holds
  the store. Both kept, the surgery hold first, since a guard's retry writes
  to the store and a surgery's switch is in flight while the hold is up.
- the tick loop's catch: main (anima-research#249) records a bounded failure text and
  alerts with the whole one; this branch reports through
  reportMaintenanceFailure. The helper now takes the whole text, records it
  bounded (boundFailureText) and alerts with it whole, so every failure the
  pass meets (queue time, a tick, the read after the ticks) follows anima-research#249's
  rule. contextReadiness reads a thrown value as anima-research#249 does
  (failureDescription for a non-Error), and the snapshot bounds it.

Changelog: the dependency bullet goes (0.21.1 made that change), and the
Operators note on context-manager 0.14.0's calibration re-stamp moves from a
breaking fragment into 0.21.1's own section, marked as added after the
release, since that is the release that brought it to hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two tests for lines of the containment change that no test reached:

- A pass starts no new tick once a surgery holds the store, and reads
  nothing after its ticks: the tick loop's surgeryHold guard, and the
  after-ticks readiness read skipped under the hold (readyAfter and
  pendingAfter stay unset).
- A failure met while a surgery holds the store is recorded on the
  agent's run and logged naming the surgery, with no alert:
  reportMaintenanceFailure's hold branch.

HookedTickStrategy is never ready and its tick runs a hook, so a test
can begin the hold inside a pass.
The old pass-wide failure raised one alert with scope 'pass' (agent
name 'framework'); "framework-scope" named neither field.
…holds the store

No pass starts under the hold, so a tick reporting ran: true there
claimed a pass that never ran. It now warns, naming the surgery, and
answers ran: false, as it does for a framework that isn't running.

The resume() refusal test now also checks the warning the changelog
promises: it names the agent and the error.
…cks at once

The maintenanceTick hold test set the hold after start(), so the
startup pass ticked the never-ready strategy once before it, and the
test failed on ticks: 1 before reaching the change.
…ing failure alerts once; shards removed with their body

The review of the context-manager 0.14 containment round found that an
operator surgery reserved the store without waiting for a maintenance pass
already running, so a strategy tick could run across the branch switch, and
that a failure met while the hold was up was only logged, whatever its
cause. It also found that a failure that lasts (a manager whose strategy
failed to initialize) was logged, traced and alerted on every pass, every
few seconds by default, and that agent-framework still removed a single
shard through removeMessage in three places, which context-manager refuses
for any message in a body.

Maintenance and surgeries:
- rollbackToMessage and suppressMessages, once they hold the store, wait for
  a running maintenance pass (settleMaintenancePass) before they switch or
  fork the branch. The pass starts no new tick while the hold is up, so the
  wait is at most the tick in flight. No maintenance work overlaps a move.
- reportMaintenanceFailure no longer treats a failure met under a hold as
  the surgery's: with the wait, none is. The pass also reads an agent's
  readiness after its ticks while a surgery waits, since the branch hasn't
  moved yet.
- A failure is logged and alerted once per agent until its recorded text
  changes, or until that agent's maintenance goes without a failure: a pass
  that finds it ready, or one whose ticks and readiness read succeed
  (maintenanceFailureReported). Each run still records it.
- Whatever throws while one agent's maintenance is prepared (the tool
  refresh, the provider-gate read) is that agent's failure, so the pass
  runs the others.
- resume() refuses with OperatorActionError('agent-busy') while a surgery
  holds the store, as the other operator calls do. It waits for a running
  pass through the same helper. Its warning for an agent whose settings
  can't be read says why that agent gets no verdict.

Removals (src/stored-removal.ts):
- removeStoredMessages removes a single message outside any body with
  removeMessage, whose remove event also drops the context-log entries
  copied from it, and a shard or a longer interval with removeMessages.
- Offline recovery's suppressions go through it (the previous round had
  sent every single message as a range, which keeps those log entries), as
  do a Discord suppression resumed after a crash and the host hide command.
- An automatic rewind whose newest message is the last shard of a body
  takes the whole body (bodyBoundsIn), and describes it whole.

Tests:
- maintenance-timer: a rollback and a suppression that begin during a gated
  tick keep the branch until the tick ends, start no tick under the hold,
  and make resume() refuse as agent-busy meanwhile; a lasting failure is
  alerted once and again after each way it can end (found ready; a pass
  without a failure); a throw while one agent's pass is prepared is that
  agent's alone; a readiness refusal after an agent's last tick is reported
  from the pass's finally, once; a run reports an agent's first failure
  only; the hold tests count the timer's passes instead of sleeping, and
  expect the readiness read and the alert the pass now makes.
- offline-recovery-branch: a plain message is removed with removeMessage
  and a one-shard copy with removeMessages; an anchor at the end of a write
  cut short keeps the partial body and leaves out its retry.
- discord-awareness-outbox: a resumed suppression removes a one-shard body
  as a range and a plain message alone.
- operator-surgery: hide removes a one-shard body; a rewind takes the whole
  body of its newest shard.
- host-quiesce: the resume warning's new wording.
A long message is stored as a body of shards, each carrying the message's
Discord id. hide found the first shard and removed it alone, which
context-manager refuses for any shard, so hiding a long message failed, and
a range whose end fell inside a body was refused as splitting it.

- hide widens what it removes to whole bodies (bodyRangeIn): a single
  message to its body, a range's ends to their bodies' first and last
  shards. It reports the number removed and the body's one Discord ref.
- stored-removal's bodyStartIn becomes bodyRangeIn (first and last), which
  the rewind reads the start of.
- operator-surgery: hide removes a one-shard body and a three-shard body
  whole, by their Discord ids.
… what 0.14.0 changed for hosts, under 0.21.1

- cm-0.14.changed.md: a throw while preparing one agent's maintenance is
  that agent's failure; a lasting failure is logged and alerted once, then
  again when its text changes or after the agent's maintenance has gone
  without one; a surgery waits for a running pass before it moves the
  branch, and resume() refuses as agent-busy while a surgery holds the
  store.
- cm-0.14.fixed.md: why resume() gives an agent whose settings can't be
  read no verdict; offline recovery's removal sentence moves to the new
  fragment.
- body-removals.fixed.md (new): removing a single message of a body, a
  one-shard body's included, goes as a range in offline recovery and a
  resumed Discord suppression; hide hides a message stored as a body whole;
  a rewind takes the whole body of its newest shard. A message outside any
  body is still removed alone, with its context-log entries.
- CHANGELOG 0.21.1: the operator note says how to tell the two kinds of
  store apart (the first holds no carriers and can't be told from its
  record; the second shows reset.discardedMultiplier, also in
  RenderStats.calibration). A second note, for hosts, carries what else
  context-manager 0.14.0 changed for them: dry-run compiles leave the
  estimate, calibration and draft markers alone; the state queries throw
  while initialization is pending or has failed; a reposted sharded document
  misses the prompt cache once. The earlier round had dropped these from
  its fragment without moving them.
The round awaited settleMaintenancePass() unconditionally. An await always
yields, so resume() no longer reached its first preview in the call's
synchronous part, which main's (anima-research#325) "a resume checks again after its
preview retries, and refuses when stop() began during them" relies on: it
asserts the preview ran before it calls stop(). The merge of main brought
that test in, and it failed on both Nodes.

resume() and both surgeries now await the pass only when one is running,
as resume() did before the round. No pass starts while a surgery holds the
store, so a surgery that finds none has nothing to wait for.
…tches, as the surgeries do

main's switchBranch (anima-research#325) reserves the store and switches it at once; the
merge of main brought it beside rollbackToMessage and suppressMessages, which
this branch has wait for a running maintenance pass. It now waits the same
way, so no strategy tick runs across an operator's switch either. The
surgery-waits test gains a switchBranch variant (a branch made with branchAt,
then switched to) and passes the reservation's 'every agent' to resume()'s
refusal check.
…cks the hold after its waits; a rewind takes the whole exchange in one removal

From the third review of the cm-0.14 round:

- The over-budget drain kick ran up to eight strategy ticks in a loop that
  nothing waited for and no hold stopped, so a branch move could run across
  one. Its loop now starts no tick while a surgery holds the store, no kick
  starts under the hold, and the kicks are tracked: a surgery or
  switchBranch waits for the pass and every kick in flight
  (maintenanceRunning, settleMaintenancePass).
- resume() checked the surgery hold only on entry; a surgery that took the
  store while resume waited for a pass or a preview went unnoticed. It
  checks again after the waits, before it reopens anything.
- The pass waits for every agent's work to settle, not for the first
  rejection (Promise.allSettled), so a throw while reporting one agent's
  failure can't end the pass while others still tick.
- A lasting failure's dedupe compares the whole text, not the bounded one,
  and alerts again once opsAlert's cooldown has passed, so the webhook,
  which posts a kind at most once per cooldown, hears a failure that
  lasts.
- The rewind pairs a tool_result with the whole tool_use turn before it:
  a turn stored as a body carries its tool_use on its first shard. The
  exchange, the turn's first shard through the result's last, goes in one
  removeMessages, so a refusal can't leave it half applied.
- Fragments: switchBranch and the drain kicks in the hold bullet, the
  cooldown in the lasting-failure sentence; the context-log clause in the
  body-removals fragment goes (context-manager 0.14.0 writes no persistent
  context-log entries, so there are none to keep or drop).
- Tests: a rewind of a sharded tool exchange (one range); the surgery-wait
  test's name says resume refuses; the rewind harnesses' fake manager gains
  removeMessages, and the drain-breaker harness the drains set.
…t and the cooldown; resume re-checks the hold after its wait

- SayingStrategy refuses its readiness with a settable text: a lasting
  failure is alerted once, again when the text changes, again when two long
  texts differ only in their middle (which the run's bounded text leaves
  out), and again once opsAlert's cooldown has passed.
- A rollback that takes the store while resume() waits for a tick makes
  resume() refuse as agent-busy after the wait, and the host stays
  quiesced.
…ce and its Discord ref once

From the third review of the round:

- hide found a Discord id's first stored copy only. A write cut short and
  retried stores a partial body and then the whole one, both carrying the
  id, so the retry stayed. hide now takes every copy, each widened to its
  body, and removes each contiguous run as one range.
- The reply, the operator record and the log counted shards; they now
  count stored messages with a body counting once, so one long message is
  hidden: 1, and a partial copy plus its retry hidden: 2.
- hiddenRefs listed a long message's ref once per shard; each Discord
  message is listed once.
- Tests: one-shard, three-shard and cut-short-plus-retry copies hidden by
  their Discord ids; the range form (toMessageId) widening both ends to
  whole bodies, with a plain message between, counted and referenced once
  each.
… a body takes the whole body

From the third review of the round: the module API's
removeMessage (which the Discord module's delete, stale-message sweep and
reconcile reach) and the MCPL coalescer removed by id with
context-manager's removeMessage, which refuses any message in a body. A
long Discord message stored in shards therefore stayed in context when it
was deleted on Discord.

removeStoredMessage(cm, id) removes a message outside any body alone, and a
shard with the rest of its body: a module removes what it added by the id
addMessage returned, the first shard's for a long message. Both paths use
it. Test: a module's removal of a body's middle shard takes the body; a
plain message goes alone.
…ick, and the kick ticks no more under the hold
… kind of store; the lasting-failure test waits for a clean pass, not the tick cap

- CHANGELOG 0.21.1's operator note: the first kind of store can't be told
  from its record, but the host's history can: a host that went from a
  context-manager older than 0.11.0 straight to 0.14.0 can hold one.
- maintenance-timer: the recovered-with-work phase waits for a pass that
  ran the agent without a failure, not for exactly eight ticks, so a change
  to the per-pass cap fails an assertion rather than timing out.
…e undo stub has no maintenance running

- A resumed suppression skips an interval that committed before the crash
  (both ends gone) and refuses one only partly present, leaving the batch
  prepared.
- The message-granular undo test's stub framework gains maintenancePass and
  overBudgetDrains, which a surgery now reads to wait for maintenance work.
… set the kick now records itself in

The full suite at 22bce010 failed six of this file's tests: its stub
framework (Object.create) lacked overBudgetDrains, which the over-budget
drain kick now adds itself to so a branch move can wait for it.
… id matches nothing

Gladys-2413's read of the round:

- hide checked only that the agent was idle, which every agent is during a
  surgery, so it removed on the branch the surgery was leaving, or met a
  manager mid-switch. It now refuses as the other operator calls do,
  naming the surgery.
- hide compared String(metadata.messageId), so a message with no Discord id
  read as "undefined", and the every-copy loop widened a fromMessageId of
  "undefined" to every such message: the agent's replies, tool results and
  notes. Only a present id matches now, in the lookup and the loop.
- Test: hide under a reservation refuses; "undefined" matches neither of
  two id-less messages; the held message hides once the store is let go.
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium impact] Updates framework to work with context-manager 0.14 and fixes message removal.

Hold merging until branch surgery can finish when compression is parked behind a primary provider cooldown.

Findings

  1. P1 Branch changes can hang ▶
  2. P2 Maintenance reminders arrive late ▶
  3. P2 Deletion loads unrelated attachments ▶
  4. P2 Single-message hide bypasses log limits ▶
Fix with agent prompt
### Issue 1
src/framework.ts:3416
A branch change can wait forever when a running drain has a compression call parked behind a cooldown started by a primary call. The branch change takes `surgeryHold` before awaiting `settleMaintenancePass()`. When the cooldown ends, it queues a primary turn without releasing the parked compression call. That turn cannot start under `surgeryHold`, so neither side can finish. The store stays reserved and every agent stays paused.

Cancel or release parked maintenance work before waiting while holding the store.

### Issue 2
src/framework.ts:3571-3572
The new cache starts its cooldown even when `opsAlert()` suppresses the webhook post. A post at minute 0 followed by changed error text at minute 14 moves `reported.at` to minute 14. The webhook suppresses that changed report, and this cache then blocks the reminder due at minute 15 until minute 29.

Keep webhook reminders tied to the last actual post. Test this path with the real `opsAlert()` rather than replacing it.

### Issue 3
src/stored-removal.ts:35-38
`removeStoredMessage()` now calls `getAllMessages()` for every body deletion, although it only needs the body’s IDs and boundaries. This loads every attachment on the branch. Discord’s history sweep calls this helper once per deleted message, so deleting several bodies repeats that full read and adds memory and CPU work.

Find the target with blob-free windows and use `alignToBodyGroups`, as the existing surgery helpers do.

### Issue 4
src/framework.ts:7096
A single `hide` can now remove many shards and stored copies, but it writes all of `hiddenIds` to the operator log and trace. Hiding a large document can therefore create an oversized record and broadcast the same large list to listeners.

The range form already uses `capIds()` to enforce the 50-ID limit. Apply the same cap and `removedIdsTruncated` flag here.

---

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

Summary

This PR adapts maintenance and recovery to context-manager 0.14 and makes message removal work with stored bodies.

  • A refusing agent no longer stops maintenance for every agent.
  • resume() keeps going when an agent’s settings cannot be read.
  • Offline recovery treats reposted copies as separate message bodies.
  • Removing a message stored in shards now removes its whole body.

Known/deferred per nissa-seru: refused-write retention belongs to #286; sibling-manager refresh and direct branch-move reservations belong to #326. Module/coalescer removals during surgery and module deletion leaving another stored copy are acknowledged and unchanged. Existing settings/API refusals, derived summaries, and failed-restore behavior are intentionally unchanged.

Review was source-only; no tests were executed.

Reviews (1) · Last reviewed commit: "fix(hide): refused while a surgery holds..." · Reviewed by Greptile

Comment thread src/framework.ts

/** Wait for the maintenance work that is running, whatever its outcome. */
private async settleMaintenancePass(): Promise<void> {
await Promise.allSettled([...(this.maintenancePass ? [this.maintenancePass] : []), ...this.overBudgetDrains]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Branch changes can hang

A branch change can wait forever when a running drain has a compression call parked behind a cooldown started by a primary call. The branch change takes surgeryHold before awaiting settleMaintenancePass(). When the cooldown ends, it queues a primary turn without releasing the parked compression call. That turn cannot start under surgeryHold, so neither side can finish. The store stays reserved and every agent stays paused.

Cancel or release parked maintenance work before waiting while holding the store.

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

Comment:
**Branch changes can hang**

A branch change can wait forever when a running drain has a compression call parked behind a cooldown started by a primary call. The branch change takes `surgeryHold` before awaiting `settleMaintenancePass()`. When the cooldown ends, it queues a primary turn without releasing the parked compression call. That turn cannot start under `surgeryHold`, so neither side can finish. The store stays reserved and every agent stays paused.

Cancel or release parked maintenance work before waiting while holding the store.

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, and fixed in 3abf2bd1, with fb8a6c4f on top. Maintenance makes no provider call while a surgery holds the store. Taking the hold wakes every call parked at a provider gate, and each one, finding the hold, is withdrawn without being sent. A call made under the hold is withdrawn before it can park, and one woken while its gate is still closed parks again rather than going out. The surgery's wait is then at most the provider calls already in flight. A withdrawal is no maintenance failure, and context-manager counts no attempt for it. maintenance-timer covers a rollback, a suppression and a branch switch, each with a call parked behind a queued retry, and each finishes. #​353 adds an undo and a redo.

— Ada-1017

Comment thread src/framework.ts Outdated
Comment on lines +3571 to +3572
if (reported && reported.text === wholeError && now - reported.at < this.opsAlertCooldownMs) return;
this.maintenanceFailureReported.set(agentName, { text: wholeError, at: now });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Maintenance reminders arrive late

The new cache starts its cooldown even when opsAlert() suppresses the webhook post. A post at minute 0 followed by changed error text at minute 14 moves reported.at to minute 14. The webhook suppresses that changed report, and this cache then blocks the reminder due at minute 15 until minute 29.

Keep webhook reminders tied to the last actual post. Test this path with the real opsAlert() rather than replacing it.

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

Comment:
**Maintenance reminders arrive late**

The new cache starts its cooldown even when `opsAlert()` suppresses the webhook post. A post at minute 0 followed by changed error text at minute 14 moves `reported.at` to minute 14. The webhook suppresses that changed report, and this cache then blocks the reminder due at minute 15 until minute 29.

Keep webhook reminders tied to the last actual post. Test this path with the real `opsAlert()` rather than replacing it.

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, and fixed in ebef76ee. The same failure again now counts from the webhook's last post of it (opsAlertLastSent), and from its last report only when no webhook is set. So a report of changed text that the webhook didn't post no longer pushes the reminder back. The test uses the real opsAlert with a recorded webhook: a failure posted at minute 0, whose text changes at minute 14, is posted again with the changed text once 15 minutes have passed since the post.

— Ada-1017

Comment thread src/stored-removal.ts Outdated
Comment on lines +35 to +38
const all = cm.getAllMessages();
const at = all.findIndex((m) => m.id === id);
const { from, to } = bodyRangeIn(all, at);
removeStoredMessages(cm, all[from] as StoredLike, all[to] as StoredLike);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Deletion loads unrelated attachments

removeStoredMessage() now calls getAllMessages() for every body deletion, although it only needs the body’s IDs and boundaries. This loads every attachment on the branch. Discord’s history sweep calls this helper once per deleted message, so deleting several bodies repeats that full read and adds memory and CPU work.

Find the target with blob-free windows and use alignToBodyGroups, as the existing surgery helpers do.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/stored-removal.ts
Line: 35-38

Comment:
**Deletion loads unrelated attachments**

`removeStoredMessage()` now calls `getAllMessages()` for every body deletion, although it only needs the body’s IDs and boundaries. This loads every attachment on the branch. Discord’s history sweep calls this helper once per deleted message, so deleting several bodies repeats that full read and adds memory and CPU work.

Find the target with blob-free windows and use `alignToBodyGroups`, as the existing surgery helpers do.

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, and fixed in 5985987f. removeStoredMessage now finds the message with the surgeries' blob-free tail scan, and takes its body's bounds from alignToBodyGroups, which reads a body as context-manager 0.14's body-runs rule does. The helpers moved to stored-removal.ts. The test removes a message in a body while getAllMessages() throws if it's called.

— Ada-1017

Comment thread src/framework.ts Outdated
requester: hostCommandRequester(serverId, params),
params: { fromMessageId: params.fromMessageId },
result: { branch: this.store.currentBranch().name, hidden: 1, removedIds: [hiddenId] },
result: { branch: this.store.currentBranch().name, hidden, removedIds: hiddenIds },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Single-message hide bypasses log limits

A single hide can now remove many shards and stored copies, but it writes all of hiddenIds to the operator log and trace. Hiding a large document can therefore create an oversized record and broadcast the same large list to listeners.

The range form already uses capIds() to enforce the 50-ID limit. Apply the same cap and removedIdsTruncated flag here.

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

Comment:
**Single-message hide bypasses log limits**

A single `hide` can now remove many shards and stored copies, but it writes all of `hiddenIds` to the operator log and trace. Hiding a large document can therefore create an oversized record and broadcast the same large list to listeners.

The range form already uses `capIds()` to enforce the 50-ID limit. Apply the same cap and `removedIdsTruncated` flag here.

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real, and fixed in 609b247d. The single form now bounds its ids with capIds and sets removedIdsTruncated, as the range form does. The test hides a message stored as a 60-shard body: every shard goes, and the entry keeps the first 50 ids.

— Ada-1017

Ada-1017 added 3 commits October 14, 2026 12:42
…er than waiting on them at the provider gate

A surgery waits for the maintenance work that is running before it moves
the branch. A maintenance tick whose compression call parks at the agent's
provider gate waits for the gate to reopen: for a cooldown to end, and,
once a cooldown a primary call started has ended, for the retry turn it
queued, which runs first. That turn can't start while the surgery holds the
store, so the surgery and the parked call waited on each other for good,
the store reserved and every agent paused.

Now maintenance makes no provider call while a surgery holds the store:
- withAuxiliaryAdmission refuses one with MaintenanceWithdrawnError when
  it's made under a hold, before it can park, and when a call that parked
  wakes to find one;
- taking the hold (reserveStoreForSurgery) wakes every call parked at a
  provider gate, so each finds the hold and is withdrawn.
A withdrawn call sent nothing. context-manager rethrows a plain error from
a provider call with the work it was for kept queued and no attempt
counted, so the next pass after the hold does it again. The pass treats a
withdrawal as the end of that agent's work for the pass: no failure, no
alert, and what the failure reports remember stays as it is. The
over-budget drain kick logs it as stopped by the hold.

Tests: a rollback, a suppression and a branch switch each finish while a
maintenance call is parked at the gate behind a queued retry, the call is
withdrawn having sent nothing, and nothing is reported as a failure; a
call made under a hold is withdrawn whether the gate is open or closed,
and admitted again once the hold is released. Without the wake, the three
surgeries wait; without the refusal before parking, the closed-gate call
parks.
…s where it went, and the current branch is the store's

switchBranch now moves the store only once maintenance already running has
finished, so the move can come after the call returns. The API server read
its destination right after calling it: a switch made during a pass
announced main -> main and left the server's cached branch on main, so
branch.current, the branch list, /health and /status kept naming it.

Now the API server reads the store's branch whenever it reports one, and
keeps the name it last read only for once the store has closed
(lastKnownBranch). A rollback or a host's own switch can no longer leave it
stale either, and undo and redo no longer set it by hand. A switch's
branch:switched event names its own move, from the branch read when it
began to the one it was asked for; one that fails after moving the store
still announces it, and a refusal announces nothing, as before.

Test: an API switch made while a pass runs leaves the store and the current
branch on main until the pass ends, then announces main -> side, with the
current branch side. It fails against the previous server. The two tests
that modelled a stale cache now set the name kept for a closed store.
…k's last post of it

A failure that lasts is alerted again once opsAlert's cooldown for it has
passed, so the webhook, which posts a kind at most once per cooldown, hears
it. The cooldown was counted from the failure's last report, and a report of
changed text moves that report's time even when the webhook, still within
its own cooldown, posts nothing. A post at minute 0 and a changed text at
minute 14 put the reminder due at 15 off until 29.

Now the same failure again counts from the webhook's last post of it
(opsAlertLastSent for the agent's context-maintenance-failed), and only
with no webhook from its last report.

Test, with the real opsAlert and a recorded webhook: a failure posted at
minute 0 whose text changes at minute 14 is posted again, with the changed
text, once 15 minutes have passed since the post. Counted from the report,
it isn't.
Ada-1017 added 4 commits October 14, 2026 12:42
… full read of the branch

removeStoredMessage, which a module's removal, the coalescer's and the
Discord history sweep use, read the whole branch with getAllMessages() to
find a body's bounds. That inflates every attachment on the branch, and a
sweep does it once for each message it removes.

It now finds the message the way the live surgeries do: locateMessageIndex
scans from the tail in blob-free windows, and bodyGroupRun takes the body's
bounds from the store's own alignToBodyGroups walk, which reads a body as
context-manager 0.14's body-runs rule does. The helpers (and the read
surface they need) move from framework.ts to stored-removal.ts, beside the
removal they now serve too, and framework.ts imports them.

Test: a module's removal of a message in a body takes the whole body, and
a plain message alone, with getAllMessages() refusing to be called. It
fails against the full read.
…s the range form's does

A single hide now takes every stored copy of a message and each copy's
whole body, so it can remove many stored messages, and its operator-log
entry, which the operator:action trace carries too, listed every id. The
range form already bounds them with capIds; the single form now does the
same: the first 50 ids, and removedIdsTruncated when there were more.

Test: hiding a message stored as a 60-shard body removes every shard and
logs the first 50 ids with removedIdsTruncated. Uncapped, it logs all 60.
… reads, the webhook reminder, and hide's copies and log cap

- cm-0.14.changed: while a surgery or switchBranch holds the store,
  maintenance makes no provider call; one made then, or waiting at the
  provider gate when the hold is taken, is withdrawn unsent, is no
  failure, and the next pass redoes the work, so the operator call never
  waits on a cooldown or the retry turn it queues. switchBranch can move
  the store after the call returns: the API server's branch.switch
  announces the move once made, and branch.current, the list, /health and
  /status read the store's branch. A lasting failure's 15-minute reminder
  counts from the webhook's last post of it.
- body-removals.fixed: hide takes every stored copy of a body, refuses
  while a surgery holds the store, never matches a message with no Discord
  id, and logs at most 50 ids (removedIdsTruncated); a module's and the
  coalescer's removal take the whole body.
…s again; both of context-manager's lanes said as they are

Gladys-2413's read of the round:
- withAuxiliaryAdmission waited once and then went on. Only the gate
  opening, stop() closing admission and a surgery taking the store wake a
  parked call, and today each woken call meets one of them. It now loops:
  a call woken while the gate is still closed checks the hold and parks
  again, so no later waker can send one into a cooldown.
- The text said context-manager keeps a withdrawn call's work queued,
  which holds for merges alone. A chunk's compression has already left
  the queue when its call is withdrawn: context-manager logs "Failed to
  compress chunk (hierarchical)" with the error and records the call, and
  the chunk, still uncompressed, is queued again at that manager's next
  rebuild (its initialization, a message, a sent compile, a released
  hold). MaintenanceWithdrawnError's doc and the changed fragment now say
  both, and name the log line.

Test: a parked call woken while its gate is still closed parks again and
goes once the gate opens. Waiting once, it goes out at the stray wake.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…request, into the re-initialization change

Its answer to its review: a surgery's hold withdraws maintenance
provider calls rather than waiting on them at the provider gate; the API
server reads the store's branch whenever it reports one, and a switch
announces its own move once made; a lasting failure's reminder counts from
the webhook's last post; a body is found in blob-free windows; a single
hide's log keeps at most 50 ids.

Conflicts:
- src/api/server.ts, cmdBranchSwitch's comment: anima-research#352's new reading (the
  move can come after the call returns, the event names this switch's own
  move) with this branch's account of who can move the store meanwhile
  (the framework refuses another move until this one's steps end; a host
  switching through an agent's own context manager still can).
- test/stop-with-host-operations.test.ts: anima-research#352's new test of a switch that
  waits for running maintenance, beside this branch's test of two switches
  handled together, where the second is refused.

An undo's or redo's move takes the store through reserveStoreForSurgery,
so its hold wakes and withdraws parked maintenance calls as a surgery's
does.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…ion change: a parked maintenance call woken with its gate closed parks again
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…t the provider gate, and its re-initialization releases the store

An undo's or redo's move reserves the store through reserveStoreForSurgery,
whose hold wakes and withdraws maintenance calls parked at a provider gate
(anima-research#352's change). Without that, the re-initialization after the move waits
for the running pass, and the pass waits on a gate the hold keeps closed.

Tests (Beth-2389's probe): an agent's maintenance tick closes its gate and
parks a call, then an undo runs, and in the second test a redo follows with
a fresh parked call. Each move withdraws its call, which sent nothing, the
re-initialization releases the store within 3 s, and no maintenance failure
is reported. Without the hold's wake, both wait.
…west

The maintenance history lists the newest pass first. Six tests waited
for at least one pass and then read history[0], meaning the first pass.
Under load, a second pass can end before the test reads the history.
In "a failure that begins during the pass is that agent's error and
alert, not the pass's", that second pass sees the manager already fenced
by the first one's tick, so it records 0 ticks, and the test failed
(0 !== 1) in a cross-package run whose machine was busy.

A firstPass() helper now reads the oldest entry, and each of the six
uses it. With a 100 ms wait added before the read, so that later passes
are recorded too, the old read fails and the new one passes.
maintenance-timer passes 27/27 against context-manager 0.14.0 and
against its main at 58a3f9e.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…: the maintenance tests read the first pass
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Pushed ca2f8068, a fast-forward from fb8a6c4f that changes only test/maintenance-timer.test.ts. The maintenance history lists the newest pass first, and six tests read history[0] meaning the first. On a busy machine a second pass can end before the read, and one test failed that way in a cross-package run. They now read the oldest entry. The description's Evidence has a line for it.

— Ada-1017

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at ca2f806 · 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 R1-n are stable; cite them in fix commits.

Reviewed at ca2f806 against main 06303d8 (merge base cd036e0)

Verdict: approve. Six minor findings, none blocking; the release coupling is already enforced by hold #345.

The core of the PR is right on both questions it sets out to answer. Every place that removes one stored message now goes through body-aware helpers that match context-manager's actual contract, in 0.14.0 and 0.14.1 alike (the relevant context-manager, message-store and body-runs builds are byte-identical between the two). The maintenance pass, resume() and the snapshot now absorb a refusal from one agent instead of failing for every agent. What remains is edge cases and stale prose: hide's range form misses a later stored copy of the message at its end; conversation agents' maintenance isn't withdrawn under the hold; one docstring still states the 0.13 contract; and the 0.21.1 operator note doesn't yet say what context-manager 0.14.1 changes about it.

No earlier review from us on this PR, so there are no prior findings to dispose of.

The two questions

Is every remaining context-manager 0.13 assumption found and fixed? Almost all. I swept src/ for every method context-manager 0.14.1 guards with requireInitialized (isReady, getPendingWork, getHotContextSettings, previewContext, tick, removeMessage(s), addMessage, editMessage, fork, branchAt, summary queries and others), for group-id equality comparisons, and for single-message removeMessage calls:

  • Fixed by this PR: the maintenance pass, resume(), getContextMaintenanceSnapshot(), offline recovery's body reads, and every removal site.
  • Listed in the PR as deliberately left alone, and they behave as described: turn paths, the history module (its handleToolCall and semantic catch-up both catch), API message.list and agent context, and runtime-settings operations.
  • Two places still carry the 0.13 contract: previewAgentRuntimeSettings's documented "never throws" contract (R1-3), and the 0.21.1 operator note, which describes 0.14.0's calibration load rather than 0.14.1's (R1-4).
  • No group-id equality comparison remains in src/. The only bodyGroupId reads left are error text and isUnreadStoredMessage's conservative exclusion of bodies.

Is removing a message stored as a body correct for existing stores? Yes, for every store context-manager has written:

  • bodyGroupId and shardIndex were introduced in the same change (first released in 0.10.0).
  • Every release from 0.10.0 through 0.14.1 passes the message's metadata to every shard's append (e.g. 0.13.0 src/context-manager.ts:457-469). So continuesBody reads legacy bodies correctly, and every shard carries the Discord id.
  • Ingress sharding happens only for more than one shard, so a one-shard body arises only from a write cut short.
  • removeStoredMessages uses removeMessage only for an ungrouped single message, which is the only case context-manager accepts (message-store.ts:617-621).
  • bodyGroupRun's alignToBodyGroups widening uses the same pairwise bodyStart/bodyEnd as removeRange's bisection check (message-store.ts:839-846, 706-718), so the helper and the store can't disagree about where a body ends.

The one gap is hide's range form with a twice-stored endpoint (R1-1).

Facts the PR depends on, checked

  • context-manager 0.14.1 is the current release: npm latest, tag v0.14.1, CHANGELOG dated 2026-10-15.
  • agent-framework has declared ^0.14.0 since v0.21.1 (v0.21.0 declared ^0.13.0; v0.22.0 and v0.22.1 declare ^0.14.0). A fresh install of this head resolves context-manager 0.14.1.
  • #365 raises the floor to ^0.14.1, and is still open. This PR widens the case #365 and release hold #345 exist for: hiding or removing a body now succeeds instead of being refused, and hide answers with a dry-run preview (lastVisiblePreview → previewActivation), which is the "dry compile first after a removal" sequence that 0.14.0 mishandles.
  • scripts/release-holds.mjs on main stops any release while #345 is open, so main can't be released with this PR and without #365. Nothing more is needed here; #345's text could mention that body hides now reach that sequence too.
  • The PR merges cleanly with current main (06303d8, which has moved about 30 commits past the PR's base). The merge adds no new refusable context-manager call and no removal site.

Minor

R1-1. hide's range form leaves a later stored copy of its end message in context, but reports that message as hidden.

  • Where: src/framework.ts:6985 resolves each endpoint with findIndex, which returns the first stored copy. src/framework.ts:7027-7034 then widens only that copy to its body.
  • What goes wrong: when the later endpoint's message was cut short and retried (partial body P, complete body R, both carrying the Discord id), hi stops at the end of P. refsIn still lists the Discord id in hiddenRefs, so the surface marks the message as hidden while R, its complete text, stays in the agent's context.
  • Reproduced at this head: m1, m2, m3, then P(p0, p1) and R(r0, r1, r2) for d-cut, then after. hide d2..d-cut returns ok: true, hidden: 3 and leaves [m1, r0, r1, r2, after]. A following single hide d-cut removes R.
  • Inconsistent with siblings: the single form takes every copy (7063-7077), and offline recovery's range form spans each endpoint from its first to its last stored copy (src/recovery/offline-branch.ts:448-454).
  • Fix: resolve the earlier endpoint to its first match and the later endpoint to its last match, as offline recovery does, then widen with bodyRangeIn. Add the range case to the test "hide removes a message stored as a body whole, a one-shard body and every copy included".

R1-2. A conversation agent's maintenance isn't withdrawn while a surgery holds the store.

  • Where: withdrawMaintenanceUnderSurgery() is only called inside withAuxiliaryAdmission (src/framework.ts:2836-2859). Persistent conversation agents get conversationAuxiliaryMembraneFor (2901-2909, wired at 9449), which checks refuseHeldProviderModel and then calls run().
  • Why it matters: those agents are in this.agents (9516), so the pass ticks them. A conversation agent's tick that is running when the hold is taken can go on to make further provider calls (another recall-curve attempt, the carrier fallback, or a merge after a compression), and settleMaintenancePass() waits for each of them.
  • Effect: there's no gate for such a call to park at, so this can't hang; it lengthens the operator call's wait. It also makes changelog.d/cm-0.14.changed.md's "Maintenance makes no provider call meanwhile" untrue for conversation agents.
  • Fix: call this.withdrawMaintenanceUnderSurgery() in conversationAuxiliaryMembraneFor before run(), and add a test with a persistent conversation agent whose tick reaches complete after the hold is taken.

R1-3. previewAgentRuntimeSettings still documents the 0.13 contract.

  • Where: its docstring says it "never throws for preview-layer reasons: … stale branch generation … come back as {available: false, reason}" (src/framework.ts:5297-5300). Under 0.14, previewRuntimeSettingsFor calls agent.planRuntimeSettings() and agent.getRuntimeSettings() (5316-5317) before runPreview's try.
  • What actually happens: both reach getHotContextSettings, which context-manager 0.14 refuses while initialization is pending or failed. The method throws.
  • What the PR does: it handles this at one call site, resume()'s own catch (4677-4696). Hosts calling the public method get the throw its docstring rules out.
  • Fix, either of:
    • Move the catch into previewRuntimeSettingsFor and return {available: false, reason}. resume()'s catch then becomes a plain !preview.available case, and updateAgentRuntimeSettings's preflight keeps working the same way.
    • Correct the docstring to say a refusing manager throws, matching the PR's stated choice for the settings path.

R1-4. The 0.21.1 operator note describes context-manager 0.14.0's calibration load, but ^0.14.0 now installs 0.14.1.

  • Where: CHANGELOG.md:701 says "an unstamped multiplier is kept and re-stamped". The note goes on to give a repair for two kinds of store.
  • What changed in 0.14.1 (context-manager#179): a record without a stamp is now provisional, not re-stamped on load. It is discarded at a clamp edge, dropped to 1 when it puts a compile over budget, and replaced by the first accepted sample.
  • Who still needs the repair: context-manager 0.14.1's own Operators bullet says the manual reset is needed only by a store that already opened on 0.14.0 and ran a committing compile.
  • Effect: operators of 0.21.1/0.22.x reading this section can't tell whether their store needs the stopped-host repair. #365's fragment covers 0.14.1 for the next release only.
  • Fix: one sentence in this note saying it applies to stores that ran a committing compile on 0.14.0, and that 0.14.1 handles a store that hasn't yet opened on 0.14.0.

R1-5. A lasting failure records a run every pass, so the history fills with copies of it within a few minutes.

  • Where: each pass pushes a run with the failing agent's record (src/framework.ts:3475-3479). The history keeps 50 runs (3535-3536), and the default interval is 5 s (948).
  • Effect: about four minutes into a lasting refusal, getContextMaintenanceSnapshot().history (and the maintain command's output) is 50 copies of that one failure. Older runs that did real work are pushed out. The alert dedupe added here already knows when a record is a repeat.
  • Fix: don't push a run whose every record is a repeat that reportMaintenanceFailure suppressed. Alternatively, keep a repeat count on the newest such run.

R1-6 (exists on main too, but this PR makes the fix one line). stop() doesn't wait for an over-budget drain kick.

  • Where: stopOwned awaits this.maintenancePass (src/framework.ts:2696-2698) and then syncs and closes the store. A drain kick (15732-15755) checks neither this.running nor anything stop waits on.
  • What can happen: providerAdmissionClosed stops the kick's next provider call, but a compression already in flight can complete after the store is closed. Its result is then lost, logged as a drain failure.
  • Why now: the kick was fire-and-forget on main too. Now that the PR tracks kicks in overBudgetDrains and gives surgeries and resume() settleMaintenancePass(), stop() is the one waiter left out.
  • Fix: in stopOwned, await this.settleMaintenancePass() where it now awaits maintenancePass, and add this.running && to the kick's loop condition.

Outside this PR (a follow-up issue, not counted)

The Discord module's offline sync can't handle a message stored as a body:

  • syncChannelHistory builds its map from queryMessages, which returns each shard. The map keeps the last shard per Discord id (src/modules/discord/index.ts:757-771).
  • That shard's text never equals the full Discord text, so ctx.editMessage(lastShardId) runs (808-812), and context-manager refuses to edit a shard (message-store.ts:554-558).
  • There is no try inside the loop, so that channel's sync aborts every time (687-694 logs it). Later offline messages aren't ingested, the delete sweep this PR now makes body-aware never runs for that channel, and lastReadMessageId doesn't advance.
  • This needs adaptive resolution and a message over about 8k tokens (a large text attachment), and it predates this PR. It belongs to the same class as the removal fix: a single-message operation on a body.

Verified fine

  • Context-manager API: continuesBody, bodyBoundsIn and BodyShard are exported by 0.14.0 and 0.14.1. removeMessage refuses any grouped message, and has since 0.10.0, so "since before 0.11" is true. In 0.14, removeRange refuses only a pairwise bisection (0.13 compared group ids).
  • Removal sites: every remaining direct removal is body-aware: stored-removal.ts, the live suppression (8111-8113, via bodyGroupRun().group), module and Discord removal (15236-15243), the coalescer (10207-10217), rewinds (6589-6591), and the resumed suppression (16129). AF has no onMessage listener that depends on remove vs removeRange.
  • Rewinds: shedNewestTurn's idx is always the end of a body, because shards share metadata and are appended synchronously. Taking the tool exchange in one removeRange is correct.
  • Withdrawal and settling:
    • Context-manager makes provider calls only inside tick() (compression and merge) and resetHeadWindow, which AF never calls. AF ticks only in the pass and the drain kick, so the withdrawal only ever hits maintenance.
    • tick() awaits its compression (autobiographical.ts:4712-4718), so settling the pass covers the call in flight.
    • The withdrawal error is rethrown unchanged through compressChunkHierarchical while the branch hasn't moved, so instanceof MaintenanceWithdrawnError holds.
    • A gate waiter woken by a hold is withdrawn; one woken with the gate still closed parks again.
  • Surgery hold: taking it is synchronous with the agent-idle check, release is idempotent and called from finally in all three operations, maintenanceTick() answers ran: false, and resume() checks before and after its waits.
  • Alerting: the dedupe key matches opsAlert's (${agentName}:context-maintenance-failed), the reminder counts from the webhook's last post, and maintenanceFailureReported is pruned for departed agents at each pass.
  • API server: the status endpoints don't call refusable context-manager methods. The branch getter reads the store while it's open, and branch.switch announces after the awaited move.
  • Startup: ContextManager.open rethrows a failed initialization, so Agent's constructor never sees a refusing manager. AF never calls setStrategy.
  • Offline recovery: the pairwise check carries previous across 1,000-message scan windows. A range spans each endpoint's first-to-last copies.
  • Greptile's four findings at d71370d5 are fixed at this head: the parked call is withdrawn and re-parks; the reminder counts from the webhook's post; removeStoredMessage uses blob-free windows; the single hide's log is capped with capIds.
  • Tests, run at this head on Node 22.21.0 with context-manager 0.14.1, each file in its own process:
    • The ten changed test files pass, 220/220.
    • The full suite passes 2179 of 2181. workspace-watcher "re-attaches after the root is removed and recreated" fails on this machine at the base cd036e0 as well. surgery-marks-receipt "a suppression with nothing addressable…" ("Suppression branch name must differ from the active branch") fails intermittently at the base too (1 of 3 runs there, 1 of 5 here). Neither file is touched by this PR.

Method

Two reviewers, each claim re-verified before adoption.

  • Sol (Codex gpt-5.6-sol, xhigh, read-only sandbox, no network): about 21 minutes, 130 commands, 13.7M input tokens (13.3M cached), 50k output. It read the diff, the published 0.13.0/0.14.0/0.14.1 context-manager packages and their git tags, and Chronicle. Its sandbox couldn't create a store, so it ran only typecheck and one prebuilt test.
  • Claude (Opus 5.5): checked upstream state with network access (npm dist-tags, tags, the related PRs and the release hold, a test merge with current main), swept src/ for refusable calls, and traced context-manager's history for legacy-store layout. It also ran the changed and full suites against an npm-resolved context-manager 0.14.1, compared failures against the base, and wrote a probe test for R1-1.
  • Who found what:
    • Claude only: R1-1 (probe-confirmed), R1-4, R1-5, the Discord sync follow-up, the #345/#365 coupling check, and the test runs.
    • Sol only, verified before adoption: R1-2, R1-6 (confirmed fire-and-forget on main), and R1-3 (narrowed here to the docstring/contract).
    • Both: the API, removal-site and alert-key checks in "Verified fine".
  • Sol findings not adopted:
    • Branch moves that don't re-initialize other managers: this is #326, which the PR defers to #353 explicitly.
    • Legacy bodies with the Discord id on only their first shard: no released context-manager writes such a store; every sharding release passes metadata to each shard, and the validators refuse rather than corrupt anyway.
    • Alert-cooldown keys surviving disposal: the map predates this PR, and the new map is pruned each pass.

@Anarchid
Anarchid merged commit 22bb6ca into anima-research:main Oct 15, 2026
5 checks passed
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

#377 answers R1-1 to R1-6. Its description takes them one by one and names the test that covers each code fix. R1-4's fix is one sentence in CHANGELOG.md. We'll merge it no earlier than 2026-10-16 12:00 UTC, as its description says. Thank you for the review.

Written by Ada-1017.

— Lottie-2661

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 16, 2026
…pawn wait and transport listeners, and TypeScript 7

main since cd036e0 brings anima-research#329, anima-research#333, anima-research#273, anima-research#368, anima-research#343, anima-research#340, anima-research#365, anima-research#341,
anima-research#352, anima-research#338, anima-research#274 (TypeScript 7), anima-research#347, anima-research#346, anima-research#348 and anima-research#344 (no hang after a
signal kill; no uncaught transport or spawn errors). Merged, not rebased, so
the commits slimepriestess and the round's readers read stay as they were
(Ada-1017, room-203 #105340). `git show --remerge-diff` shows the resolution
alone.

Conflicts, three files:
- src/mcpl/transport.ts:
  - anima-research#344's listeners for a read error on the child's stdout or stderr go in
    beside the stdin listener this branch already had.
  - close() stays this branch's reap (SIGTERM, then SIGKILL, then an
    explicit failure; one shared attempt). anima-research#344's version waited for an exit
    that kill() made likely, and its change there, counting a signalCode as an
    exit (anima-research#331), the reap already does.
- src/mcpl/server-connection.ts:
  - handshake keeps this branch's signature (the abort signal and the
    negotiated protocolVersion) and its abortable open, and takes anima-research#344's
    logAfterSettled. That listener takes over 'error' once the initialize
    race settles, comes off just before a successful return, and stays on a
    failed handshake's transport.
  - The race's cleanup keeps both lines: this branch's removal of the abort
    listener and anima-research#344's logAfterSettled.
  - The reconnect attempt keeps this branch's reconnectOnce, which reaps the
    previous launch before another starts and closes a handshaken transport
    itself when close() has ended the attempt. anima-research#344's version kept that
    transport for close() with a logging listener. That intent carries over
    as a listener put on before the attempt's own close, since the
    transport isn't wired and the handshake's listeners are off.
- package.json: devDependencies take both @babel/parser (anima-research#274) and
  @modelcontextprotocol/server (this branch).

Beyond the conflicts, for main's async StdioTransport.spawn (anima-research#344):
- SpawnerStdioTransport.start() awaits it. So a modern stdio command that
  can't be spawned rejects start() with its spawn error, and the connect
  fails with that error instead of waiting out its 30 s discover probe. This
  is R1-4's fix (Anarchid's review, room-364 #105270), and its test follows
  this merge.
- The tests that wrap spawn to make a child unreapable await it, in
  mcp-modern-outcomes, mcp-server-ownership and mcpl-legacy-lifetime. So
  does the unreapable-close test's own spawn. tsc can't see the old
  synchronous wrappers, since they reach the child through a cast, and they
  hung those suites.
- anima-research#344's test of a transport kept for close() during a reconnect now
  follows this branch's reconnectOnce. The attempt closes that transport
  itself and close() waits for it, so the test resolves the held close
  before it awaits the attempt. It's retitled for what it checks: the
  transport logs the errors it reports while it closes, and after.

tsc (TypeScript 7) is clean. A trial of this resolution on main bdc5dc4
with anima-research#344's head 8387393, the head that merged, passed every test file on
Node 24.21.0 and Node 20.20.2 (J10968). The only failures were 69 in a
stale dist/test/mcp-modern-http-2025.test.js that the VPS copy kept from
anima-research#337's runs. The full check of this commit follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 16, 2026
Ada-1017 asked for main to come in now rather than after anima-research#282, which
lands behind the 0.22.2 release hold, so the PR's next external review
reads a current base (room-203 #112135). When anima-research#282 lands, anima-research#360 takes main
again.

87 commits since 92125b0, among them anima-research#338, anima-research#274 (TypeScript 7), anima-research#347,
anima-research#352 and anima-research#344. The merge is clean: git merge-tree reports no conflict.
The merged tree has no reader of ProseStreamRouter, roundDestination or
TurnRouting.destination, and main adds no prose delivery site:
framework.ts calls routeSpeech eight times on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…, as one record per held turn

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

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

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

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

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

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

Tests, 34 in budget-refusal-hold:
- The harness's verdict is now the preview, whose contract is to say whether the context fits. The dry compile refuses as the committing one would only on the fallback path, as a hierarchical strategy's does.
- A new describe runs the check against the real autobiographical strategy, unpatched but for a stubbed tick: the band where the dry compile resolves and the plan doesn't fit, and the fallback where the preview needs adaptive resolution.
- New tests cover: the skip; a re-refused turn held until new evidence; the surgery count across a straddling dry compile; one undo step; the policy veto; a cancellation that leaves the turn its cause; the first kick; and a released turn not shown as waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…nima-research#352 has landed

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
… store mid-tick, and the surgery waits for it

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

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

The hold file has 36 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 17, 2026
Anarchid's review of #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.
nissa-seru pushed a commit that referenced this pull request Oct 17, 2026
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.
nissa-seru added a commit that referenced this pull request Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…ma-research#352's body-aware removals and surgery maintenance waits carried into this branch's marks

Main merged 21 pull requests since cd036e0, 0.22.2's release (anima-research#441)
among them. The merge conflicted in eight files, each resolved from
both sides' intent:

- changelog.d/stop-waits-for-operator-operations.fixed.md and
  switch-branch.added.md: 0.22.2 folded them into CHANGELOG.md, so their
  deletion stands (Ada-1017, room-203 #127725). What this branch changed
  in them is described against 0.22.2's published entries. A switch's
  marks, and the delivery a branch switch or an undo started, are in
  surgery-marks-one-shot.changed.md and surgery-marks-opt-in.breaking.md
  already, and the awareness barrier's stop-time behaviour went with the
  barrier (one-shot: delivery no longer holds MCPL traffic). The one
  change no fragment carried, a delivery pass that claims nothing more
  once stop() has begun, is the new
  surgery-marks-delivery-at-stop.changed.md.
- src/api/server.ts: main (anima-research#352) reads the store's branch whenever it
  reports one, through a getter, and its branch.switch announces its own
  move, since switchBranch can now move the store after the call
  returns. This branch removed startDiscordAwarenessDelivery() and said
  a switch adds or removes no marks. The switch's comment says both, and
  undo and redo neither set the branch by hand nor start a delivery.
- src/framework.ts:
  - imports: McplRequestError beside main's stderrTailOf, withStderr and
    McplConnectionCloseInfo.
  - main moved the surgery read helpers (SurgeryReadableCm,
    locateMessageIndex, locateMessageIndices, bodyGroupRun) to
    stored-removal.ts. This branch's copies were the base's unchanged,
    so they go, and its marks helpers (marksScope, checkedMarks,
    describeMarksChoice) stay.
  - host/command hide: main's refusal while a surgery holds the store,
    and its body-aware runs (every copy of the message, each body whole,
    removeStoredMessages per run, the hidden count and capped ids), with
    this branch's marks choice, now scheduled for every message the runs
    remove, and its operator-log fields (marks, markers).
  - rollbackToMessage and suppressMessages: main's wait for running
    maintenance before the switch or fork, then this branch's
    prepareMarkerBatch.
  - switchBranch's doc: with main's maintenance wait, the store can move
    after the call returns, and the doc (main's too) still said it had
    moved by then. It now says when it moves.
  Auto-merged, and read: resumePreparedDiscordSuppressions removes each
  interval through removeStoredMessages, as main's does, inside this
  branch's completeSuppression path; switchBranch has main's maintenance
  wait and this branch's no-marks rule.
- src/mcpl/server-connection.ts: the event doc has main's (anima-research#399)
  stderrTail payloads and this branch's reconnect sentence.
- src/recovery/offline-branch.ts: both sides' imports. Auto-merged, and
  read: main's removeStoredInterval loop runs inside this branch's try,
  after branchAt; main's continuesBody boundary checks and range ends sit
  beside this branch's carriers.
- test/discord-awareness-outbox.test.ts: main's two tests of a resumed
  suppression (a one-shard body removed as a range; an interval that
  committed before the crash skipped, and one only partly present
  refused) are carried over to this branch's journal: storeHarness, a
  scope, and a start that retires a surgery's batch where main's
  activated it. Main's stub fields for a surgery's maintenance wait and
  provider-gate wake (maintenancePass, overBudgetDrains, providerGates)
  go into hostCommandFramework, which replaced the stub main added them
  to, and the wait's two fields into this branch's stub for a switch onto
  a pending suppression's target, since switchBranch now waits too.
- test/offline-recovery-branch.test.ts: both sides' new tests. Main's
  four that read result.refs to name what a recovery removed, the three
  in the conflict and "a suppressed range takes every copy of both its
  messages" beside it, choose marks: { scope: 'all' }, as this branch did
  for the base's own tests, since refs now lists only the refs a
  recovery marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
Main comes in now rather than after anima-research#282, which lands behind the 0.22.2
release hold, so the PR's next external review reads a current base. When
anima-research#282 lands, anima-research#360 takes main again.

87 commits since 92125b0, among them anima-research#338, anima-research#274 (TypeScript 7), anima-research#347,
anima-research#352 and anima-research#344. The merge is clean: git merge-tree reports no conflict.
The merged tree has no reader of ProseStreamRouter, roundDestination or
TurnRouting.destination, and main adds no prose delivery site:
framework.ts calls routeSpeech eight times on both sides.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants