Skip to content

fix(maintenance): find a pass from inside it, and stop ticking a disposed agent - #448

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:gladys/maintenance-stop-and-dispose
Oct 17, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:gladys/maintenance-stop-and-dispose

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

Two defects in background context maintenance, both on main: a stop(), surgery or maintain called from inside a maintenance pass doesn't find the pass, and a pass or an over-budget drain kick goes on ticking an agent that was disposed while it ran.

Problem

  1. A maintenance pass is registered only after its body has run to its first await. stop(), the surgeries (rollbackToMessage(), suppressMessages(), switchBranch()) and maintenanceTick() find a running pass through maintenancePass. The body's synchronous steps reach callers' code: a trace listener on the ops:alert the pass raises while it prepares, and a strategy's tick before that tick's first await. A call made there doesn't find the pass. On main:

    • A stop() made from a pass's first tick settled before the tick ended, and the store was closed under it. The pass then reported "Store has been closed" as the agent's maintenance failure, with a context-maintenance-failed alert.
    • A rollbackToMessage() made there had moved the branch by the time the call returned. The pass took 8 ticks in all, the rest on the new branch.
    • A maintenanceTick() made from the alert started a second pass beside the first, so two ticks of one agent were in flight at once.

    Anarchid's review of fix: the findings of #352's review #377 found this shape in the over-budget drain kick (its R1-4), and fix: the findings of #352's review #377 fixed it there. The pass has the same shape.

  2. A pass, or an over-budget drain kick, goes on ticking an agent disposed while it runs, such as a conversation fork closed after it went idle, or an ephemeral run that ended. On main, when a fork was disposed while its drain's first tick was in flight, the drain went on to take all 8 of its ticks, and a pass took 8 ticks of a fork that never became ready. Each tick can make a compression call, for a context the framework runs no further turn on.

Changes

  • startQueuedMaintenance registers the pass before invoking its body, with a promise that settles as the body's does, so the body still starts in the same call. A call made from the body's synchronous steps finds the pass: stop() and the surgeries wait for it, and maintenanceTick() joins it. A tick that awaits such a call waits on its own pass, as a tick already did for a call it made after its first await.
  • The pass's tick loop and the drain kick's loop each end once their agent is no longer the one registered under its name (this.agents.get(name) === agent). A tick in flight at disposal finishes. The drain's log line then says stopped as the agent was disposed, ahead of the other reasons.
  • A changelog fragment, maintenance-stop-and-dispose.fixed.md.

What each part is for

What it's for: the two defects above. Both are in the published 0.22.2. Without the first, a stop() made from inside a pass can close the store under a tick and raise a false failure alert, and a surgery made there can move the branch under it. Without the second, each such disposal can cost up to 8 ticks, each of which can make a compression call that nothing uses.

  1. The pass is registered before its body runs (framework.ts +16/−6, 8 of the added lines a comment). It registers before invoking, as trackOperatorOperation does for operator operations. The body stays in the caller's stack, so nothing can run between the checks that admit a pass (running where it's registered, the surgery hold at the body's start) and the body, and no recheck is needed.
    • Not taken: deferring the body by a microtask, as fix: the findings of #352's review #377 did for the drain kick. The drain defers so that its alert's listeners run before its first tick, and the pass raises no alert before its body. A deferral would open a window between registration and the body, which would need a recheck at the body's start.
    • Not taken: a shared helper for registering before invoking, for two sites that register into different holders: a set of labelled operations, and the single pass slot.
  2. The pass's loop ends for a disposed agent (+5/−2, 3 of the added lines a comment). The same check as part 3, at the sibling site.
  3. The drain kick's loop ends for a disposed agent, and its log says why (+12/−9, of which a re-wrapped comment is +7/−7). Without the log's third reason, the old two-way choice would log a drain ended by a disposal as stopped while a surgery holds the store. The disposal comes first among the reasons, since it's the agent's own and a surgery's hold is store-wide, perhaps for another agent.
  4. The changelog fragment.
  5. The tests (+277 lines in maintenance-timer.test.ts, with two small strategies). Five tests, each failing on main's framework.ts, and each piece removed on its own fails at least one.

Cut:

  • Disposal waiting for the drain. The drain's purpose ends with its agent, so waiting would keep the very ticks that are wasted.
  • Keying the drain's in-flight flag by agent rather than by name. The only agents created after AgentFramework.create() are conversation forks and ephemeral runs, and both refuse a name in usedEphemeralAgentNames, which no disposal releases. So no new agent of a disposed agent's name exists while its drain runs.
  • Withdrawing a disposed fork's compression calls in its membrane. The loop check already bounds the waste to the tick in flight, and an ephemeral run's manager has no such wrapper.

Tests

Each new test fails on main's framework.ts with this branch's tests, for the reason its name gives:

  • a stop() made from a pass's first tick, before that tick's first await, waits for the tick asserts that stop() hadn't settled when the tick ended, that the store was still open, and that the pass raised no alert. On main, stop() had settled.
  • a rollback begun from a pass's first tick, before that tick's first await, waits for the tick, and the pass takes no tick under its hold asserts that the branch hadn't moved when the tick ended, that the rollback went on after it, and that the pass took one tick. On main, the rollback had settled first.
  • a maintain begun from an alert a pass raises while it prepares joins that pass, rather than starting a second beside it asserts one tick in flight at a time and one pass in the history. On main, two were in flight.
  • an over-budget drain kick takes no tick of a conversation agent disposed before its first tick, and names the disposal though a surgery holds the store disposes a fork before the drain's first check and asserts no tick and the log line. A second fork, disposed while a surgery holds the store, gets the same log line. On main, the first fork took 8 ticks.
  • a pass takes no further tick of a conversation agent disposed while its tick was in flight asserts one tick, which the pass's record shows. On main, 3.

We took out each of seven pieces in turn, with the rest of the change in place, and each failed at least one test:

  • the registration before the body: the first three tests above;
  • the pass's check for a disposed agent: the last;
  • the drain's check, its log's disposal reason, that reason's place before a surgery's hold, and the check's place before each tick rather than after it: the drain test;
  • the hold's reason beside them: an existing drain test, a surgery begun from inside the refusal's alert waits for the kick the alert reports, which ticks none.

The full suite on Node 20.20.2, at 0ff0d3b3 on main 3adbdb86, with membrane 0.5.87, context-manager 0.14.1 and chronicle 0.4.0 installed: tsc is clean, and 2437 of 2439 tests pass in 152 files, each file run on its own. That's five tests more than main. CI's step without --test-force-exit passes 105 of 105. The two that failed are tool-result-guard's #281 deadline tests, a write still under way at the deadline is named as unfinished, and nothing waits on it longer and batches behind one stalled write cost one deadline: no write starts until it settles, which #407 holds the clock still for. The machine's one-minute load average, read three times during the run, was 55.5 to 78.6, on 12 CPUs. Run alone at the same head, at a load of 82.4 to 87.1, the file passed 124 of 124 on Node 20, and on Node 24 the first of those two tests failed again.

Beside #361

This and #361 change src/framework.ts at two places, which conflict when merged:

Merged with #361's 2cf69a09 that way, the tree builds, and maintenance-timer (52/52), budget-refusal-hold (55/55) and overbudget-drain-breaker (10/10) pass.

Not verified

  • The ephemeral path is covered by reading only. An ephemeral run's agent leaves the framework's agents in runEphemeralToCompletion's finally, so the same check ends a pass's or drain's ticks for it. Only the conversation path is tested.
  • suppressMessages() and switchBranch() called from inside a pass find it through the same maintenancePass that stop() and rollbackToMessage() read. The tests call only stop(), rollbackToMessage() and maintenanceTick().
  • The full suite on Node 24 began here and didn't finish: the job's 45-minute limit cut it off. This repository's CI runs it on Ubuntu and macOS.
  • No host ran this change. The tests stand in for opsAlert at the point where the pass raises its alert, and drive passes, drain kicks and disposals directly, with stub strategies.

Out of scope

  • Two other sets that stop() waits on take their member after it has begun. beginAgentTurn adds driveStream's handle to activeStreams after the stream's synchronous start, and a tool-result guard's annotation joins guardAnnotations once it has begun. We haven't followed whether either reaches a caller's listener before it's added.
  • A pass and an over-budget drain kick can tick one agent at once. The pass checks only for another pass, and the drain only for another drain of that agent. This predates this change.
  • undoLastTurn() and redo() take no store hold and wait for no maintenance tick, as before this change.

Written by Gladys-2413 (Claude Opus 5.5), from Anarchid's review of #377 and our internal reviews. Beth-2389 (Claude Opus 5.5) reviewed its code at 0ff0d3b3.

Gladys-2413 and others added 2 commits October 17, 2026 09:07
…osed agent

Two defects in background context maintenance, both on main at 7125cb8.

1. startQueuedMaintenance put the pass in maintenancePass only after
runQueuedMaintenance() had run to its first await. The body's synchronous
steps reach callers' code: a trace listener on the ops:alert the pass
raises while it prepares, and a strategy's tick before that tick's first
await. A stop(), surgery or maintenanceTick() made there didn't find the
pass. Probed on main: stop() settled before the tick ended, with the store
closed under it, and the pass then reported "Store has been closed" as the
agent's maintenance failure; a rollback had moved the branch by the time
its call returned, and the pass took 8 ticks in all; a maintain started a
second pass, with two ticks of one agent in flight.

The pass is now registered before its body runs, with a promise that
adopts the body's, and the body still starts in the same call, as
trackOperatorOperation does. Nothing runs between the checks that admit a
pass and its body, so no recheck is added. anima-research#377's microtask deferral of the
drain kick's body isn't copied: it lets the refusal alert's listeners run
before the first tick, and the pass raises no alert before its body.

2. A pass's tick loop, and an over-budget drain kick's, went on ticking an
agent disposed while they ran, such as a conversation fork closed after it
went idle or an ephemeral run that ended: up to 8 ticks each. Each loop now
ends once its agent is no longer the one registered under its name. The
drain logs "stopped as the agent was disposed", where its two-way reason
would have said a surgery held the store. Disposal doesn't wait for the
drain, whose purpose ends with its agent. The in-flight flag stays keyed by
name: a disposed agent's name is never reused within a framework, since
usedEphemeralAgentNames isn't released at disposal, and re-creating the
name is refused.

Tests: five in maintenance-timer, each failing on main's framework.ts with
these tests (47 of 52 pass there, 52 of 52 here), and each of five pieces
removed on its own fails at least one of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The drain's log names the disposal first, then the framework stopping,
  then a surgery's hold. A disposed agent's own reason is the more specific
  one, and a surgery's hold is store-wide, perhaps for another agent.
- The drain's disposal test now disposes the fork before the drain's first
  check, so it asserts no tick at all, and a check made after each tick
  instead of before it would fail it. A second fork, disposed while a
  surgery holds the store, pins the log's order. The pass's disposal test
  keeps a tick in flight at disposal, which pins that such a tick finishes
  and none follows.
- startQueuedMaintenance's comment and the changelog say that a tick which
  awaits stop(), a surgery or a maintain waits on its own pass, as a tick
  already did for a call it made after its first await.
- The changelog drops resume() from the calls that wait for the pass:
  runResume() returns at once on a host that isn't quiesced. It also drops
  the clause that stop() and the surgeries still wait for a disposed
  agent's tick in flight, which disposal doesn't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026

Copy link
Copy Markdown

Required label not found on this PR.

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
@nissa-seru
nissa-seru merged commit 129cc9b into anima-research:main Oct 17, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main took anima-research#448's maintenance-pass registration and disposal checks, and an
event-gate resume for a continuation whose gate entry ended in between,
among others. Three conflicts in src/framework.ts:

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

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

1 participant