Repository navigation
fix(maintenance): find a pass from inside it, and stop ticking a disposed agent - #448
Merged
nissa-seru merged 2 commits intoOct 17, 2026
Conversation
…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>
|
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
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>
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in background context maintenance, both on
main: astop(), 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
A maintenance pass is registered only after its body has run to its first await.
stop(), the surgeries (rollbackToMessage(),suppressMessages(),switchBranch()) andmaintenanceTick()find a running pass throughmaintenancePass. The body's synchronous steps reach callers' code: a trace listener on theops:alertthe 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. Onmain: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 acontext-maintenance-failedalert.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.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.
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
startQueuedMaintenanceregisters 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, andmaintenanceTick()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.this.agents.get(name) === agent). A tick in flight at disposal finishes. The drain's log line then saysstopped as the agent was disposed, ahead of the other reasons.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.framework.ts+16/−6, 8 of the added lines a comment). It registers before invoking, astrackOperatorOperationdoes for operator operations. The body stays in the caller's stack, so nothing can run between the checks that admit a pass (runningwhere it's registered, the surgery hold at the body's start) and the body, and no recheck is needed.maintenance-timer.test.ts, with two small strategies). Five tests, each failing onmain'sframework.ts, and each piece removed on its own fails at least one.Cut:
AgentFramework.create()are conversation forks and ephemeral runs, and both refuse a name inusedEphemeralAgentNames, which no disposal releases. So no new agent of a disposed agent's name exists while its drain runs.Tests
Each new test fails on
main'sframework.tswith 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 tickasserts thatstop()hadn't settled when the tick ended, that the store was still open, and that the pass raised no alert. Onmain,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 holdasserts that the branch hadn't moved when the tick ended, that the rollback went on after it, and that the pass took one tick. Onmain, 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 itasserts one tick in flight at a time and one pass in the history. Onmain, 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 storedisposes 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. Onmain, the first fork took 8 ticks.a pass takes no further tick of a conversation agent disposed while its tick was in flightasserts one tick, which the pass's record shows. Onmain, 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:
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
0ff0d3b3onmain3adbdb86, with membrane 0.5.87, context-manager 0.14.1 and chronicle 0.4.0 installed:tscis clean, and 2437 of 2439 tests pass in 152 files, each file run on its own. That's five tests more thanmain. CI's step without--test-force-exitpasses 105 of 105. The two that failed aretool-result-guard's #281 deadline tests,a write still under way at the deadline is named as unfinished, and nothing waits on it longerandbatches 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.tsat two places, which conflict when merged:this.checkBudgetHolds()there, so every turn held for budget looks again after each pass. Merged, it goes in thefinallythis change registers before the pass's body runs.kickOverBudgetDrain(), which its budget hold also calls. Merged, this change's disposal check and its log reason go in that shared loop, so a hold's kick for a disposed agent ends too.Merged with #361's
2cf69a09that way, the tree builds, andmaintenance-timer(52/52),budget-refusal-hold(55/55) andoverbudget-drain-breaker(10/10) pass.Not verified
runEphemeralToCompletion'sfinally, so the same check ends a pass's or drain's ticks for it. Only the conversation path is tested.suppressMessages()andswitchBranch()called from inside a pass find it through the samemaintenancePassthatstop()androllbackToMessage()read. The tests call onlystop(),rollbackToMessage()andmaintenanceTick().opsAlertat the point where the pass raises its alert, and drive passes, drain kicks and disposals directly, with stub strategies.Out of scope
stop()waits on take their member after it has begun.beginAgentTurnaddsdriveStream's handle toactiveStreamsafter the stream's synchronous start, and a tool-result guard's annotation joinsguardAnnotationsonce it has begun. We haven't followed whether either reaches a caller's listener before it's added.undoLastTurn()andredo()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.