Skip to content

fix(context): a released context manager makes no provider call, and managers close before an owned store - #587

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/ephemeral-compression-withdrawn
Oct 26, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/ephemeral-compression-withdrawn

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A context manager the framework is done with now stops calling the provider, and is closed before a store the framework owns closes.

Problem

A context manager the framework is done with went on working (#550).

  • It went on calling the provider. An ephemeral agent's manager had the framework's plain membrane, so nothing withdrew its compression. A strategy that compresses in the background, as connectome-host builds a subagent's, went on calling the provider for the compression it still had queued: after its run ended, after cleanup(), while a surgery held the store, and while stop() tore down, where a resident's and a conversation agent's calls are withdrawn. A disposed conversation fork's drain went on calling too, and so did the drain of a fork whose creation failed partway, once its seed had queued compression. On a quiesced host, a disposed fork's drain called until the resume.
  • Its work outlived its store. No teardown closed a manager. When stop() closed a store it owns with a compression call still out, the call answered into the closed store, and the strategy logged speculative-drain error: Error: Store has been closed.

By run at main 773a1451, with context-manager 0.22.1, this change's tests fail there for each of those cases (Evidence).

Requirements

  1. A context manager the framework is done with makes no further provider call: an ephemeral agent's once its run has ended or cleanup() has released it; a conversation fork's once it has been disposed, or its creation has failed partway; and an ephemeral agent's under a surgery's hold and once stop() has begun, as a resident's and a fork's already were. A call it has out when it's released sends nothing more, retries included. A run after cleanup() is refused, saying so.
  2. None of its work runs after its store closes: stop(), with a store it owns, closes every manager that could still work before it closes the store, and so does a failed create() that closes the store it owns.

Changes

  • A manager the framework is done with is released: from an ephemeral run's end or its cleanup(), and from a fork's disposal. A wrapper on the membrane it gets (withdrawnOnceReleased) withdraws its new calls once it's released, and gives each call its own AbortController, which the release aborts, combined with the caller's signal (AbortSignal.any). membrane 0.7.2 checks the caller's signal before it retries a call, and its wait between attempts ends on it, so a call out at the release sends nothing more, its retries included; by reading, a request it builds after a beforeRequest hook goes to the adapter with that aborted signal. A call the release aborted rejects with the withdrawal, MaintenanceWithdrawnError, an instance of context-manager's CompressionWithdrawn, so the strategy records no failure, and a maintenance pass already ticking it says once that its call was withdrawn, as it does for a surgery's hold. The maintenance pass doesn't queue a released manager, and one it's already ticking stops at its first withdrawn call.
  • An ephemeral manager's own membrane also withdraws its new calls under a surgery's hold and once stop() has closed provider admission, as a resident's and a fork's already did (ephemeralAuxiliaryMembraneFor); a call already out then runs on, as theirs does, until stop(), with a store the framework owns, closes its manager. As before, an ephemeral run records no stated wait.
  • A released manager stays open, so what comes after its release lands as on main: a run's receipts, a guard's stored results, a disposed fork's held writes at its window's release, and a compile of it. It starts no work of its own: by our reading, in context-manager 0.22.1 a manager has no timer or subscription. A write made through it still ticks its strategy, whose provider call is withdrawn.
  • The framework holds each manager it opens weakly (openManagers, a set of WeakRefs pruned by a FinalizationRegistry). The set doesn't keep a released manager alive, as on main. By our reading, one whose strategy work is still pending stays reachable through that work: in context-manager 0.22.1 the strategy keeps its context as hostContext (autobiographical.ts:2694), and the context's tools and systemPrompt getters close over the manager (context-manager.ts:2321–2337).
  • Managers close only where their store is about to close, and at a failed creation:
    • stop(), with a store it owns, closes every manager still alive, released ones included, after its last flush and before it closes the store. context-manager 0.22.1's close() detaches the manager's strategy, which ends its generation and cancels each call its work has out. So work a release left unwinding, such as a call inside a beforeRequest hook, which an abort doesn't interrupt, fails its currency check rather than read the closed store. With a store the host owns, the managers stay open, since a start() may follow the stop().
    • A failed create() closes the managers it opened, whoever owns the store, since the framework is never returned and nothing can use them, and work its timed-out stop() left running would otherwise go on writing into the store. context-manager's close() leaves a store the host passed in open.
    • A fork or an ephemeral agent whose creation fails after its manager opened has that manager closed.
    • A stop() that closed a manager and then failed to close its owned store leaves start() refused, saying so, since the managers it would run on are closed.
  • cleanup() is final: runEphemeralToCompletion refuses an agent it has released, saying so, before the refusals a retry could pass.

What callers will notice

  • cleanup() is final: a run after it is refused, where it ran. 0.27.0's doc comment on createEphemeralAgent says to "Call cleanup() when done to release the ContextManager", so the refusal comes after the end of the lifecycle callers were told about, and its changelog fragment is a fix, not a breaking change. By our reading of connectome-host's main, its subagent retries create a new agent for each attempt, so they don't meet this.
  • After a run's end, cleanup() or a fork's disposal, the manager's compression calls stop, and one it has out is aborted.
  • With a store the framework owns, a manager kept after stop(), released or not, refuses strategy work, by our reading of context-manager 0.22.1, with its "after close()" error, where it met a closed store.
  • A start() after a stop() that closed a manager and then failed to close its owned store is refused, where it started on closed managers.
  • A failed create() on a store the host owns closes the managers it opened; the store stays open.
  • An ephemeral agent's manager now has a membrane of its own, as a resident's and a fork's already did. By our reading, context-manager keeps its compression domains by membrane instance, so an ephemeral's compression failures are weighed against its own landings, not the subconscious's and the other ephemerals'. So a short-lived one, by the same reading, is less likely to reach a second failure in its domain, and a failing chunk then shows in its compression debt rather than in the quarantine count.
  • On a released manager whose strategy asks the provider for its transition text, as the autobiographical strategy does, resetHeadWindow() with no text rejects with a CompressionWithdrawn, where it asked the provider for one.

What each part is for

  • The wrapper and its released flag (withdrawnOnceReleased, ManagerRelease): requirement 1 for a released manager, which stays open. It costs a proxy per ephemeral or fork manager and a flag.
  • Aborting the calls out at the release (each call's AbortController, combined with the caller's signal by AbortSignal.any, and ManagerRelease.calls): a withdrawal reaches only a new call, and membrane retries a call out inside complete(). It costs a controller per call and a set of the calls out. Rejecting an aborted call with the withdrawal keeps a pass already ticking from reporting a failure.
  • ephemeralAuxiliaryMembraneFor: the surgery's and the stop's withdrawals, which a resident's and a fork's membranes already had. It costs one admitting membrane.
  • openManagers, held weakly: the managers stop() and a failed create() close. A released manager stays in it, since a close is what ends its strategy's generation, so work the release left unwinding doesn't read a closed store. Held strongly, it would keep every ended run's and disposed fork's manager alive for the host's life; held weakly, it keeps none alive itself. It costs a set of WeakRefs, a map from manager to its ref, a FinalizationRegistry that prunes collected ones, and an add at each of the four places a manager is opened.
  • The close at a failed creation: the manager opened is held by nothing else, and the caller never gets it. It costs a close in each of the two catches.
  • The refusal of start() after a stop() that closed a manager and failed to close the store (ownedStoreManagersClosed): without it, the restart ran on closed managers, and failed at the first compile. It's set only when a manager was closed, so a stop() with none leaves a retry to main's rules. It costs a flag and a check.
  • The refusal of a run after cleanup() (releasedByCleanup): the run would meet a manager whose calls are withdrawn. It costs a set and a check.
  • The maintenance pass's skip of a released manager: its tick would only be withdrawn, and the pass would say so each time. It costs a check.
  • The close of a failed create()'s managers whoever owns the store: with a store the host owns, they'd stay attached, and work still out would write into the host's store after create() rejected. It costs moving the close out of the owned-store branch.
  • The tests' helpers: a stand-in membrane that rejects a call when its signal fires, as membrane does, and records each abort and whether each call kept its formatter; a provider that holds a call and then fails it as retryable, for membrane's own retry; a provider that only rejects on its signal, behind a real membrane's beforeRequest hook; and a strategy that records its detach(), which close() calls. forksTickOnlyInMaintenance gives a fork a strategy that doesn't drain on each write, so the call out is the maintenance pass's.

Tests

test/compression-after-release.test.ts, each test from a requirement:

  • an ephemeral run that has ended, with compression still queued, makes no further call, and a reset with no text is withdrawn;
  • after cleanup() with no run, the call it has out is aborted, it makes no call, each call it made kept the strategy's formatter, and another agent compresses as before;
  • after cleanup() while its run is still going, no call, and the run goes on to its end;
  • a call cleanup() comes to while its request is out, and one it comes to while membrane waits to retry it, sends nothing more: membrane retries nothing (a real membrane, with a provider that fails the call as retryable);
  • a released ephemeral manager stays open with its strategy attached, so a write after cleanup() lands, until stop() with a store it owns closes it, detaching its strategy;
  • a released manager whose call is inside a beforeRequest hook at cleanup() alone, and at cleanup() and then stop() with a store it owns: no further call enters the hook, the call reaches the provider once, on a signal already aborted, and nothing of its work reads a closed store;
  • a run after cleanup() is refused in the framework's own words, while quiesced and after stop();
  • once stop() has begun, an ephemeral manager makes no call while stop() is still tearing down;
  • a disposed fork makes no call, the call it had out is aborted, and a reset with no text is withdrawn;
  • a fork disposed while a maintenance pass's call for it is out has that call aborted and makes no call after, and the pass says once that it was withdrawn, not that it failed;
  • a fork whose creation fails partway has its manager closed and makes no call, and a fork made again under its name compresses as before;
  • an ephemeral agent whose creation fails after its manager opened has that manager closed;
  • under a surgery's hold an ephemeral manager makes no call, and its calls go out once the hold is released;
  • a fork disposed on a quiesced host makes no call after the disposal, and its held writes land at the resume;
  • stop() with a store it owns closes the resident's, the subconscious's, a live ephemeral agent's, a live fork's, a released ephemeral agent's and a disposed fork's managers before it closes the store, and nothing of any manager's runs once the store has closed;
  • stop() with a store it owns aborts the call a live ephemeral agent's manager has out;
  • a failed create() whose own stop() fails closes the resident's manager before it closes the store it owns;
  • a failed create() on a store the host owns closes the resident's manager and leaves the store open;
  • a start() after a stop() that closed the managers and then failed to close its owned store is refused, and one after a stop() that had no manager to close isn't refused for its managers;
  • stop() with a store the host owns leaves the managers open.

One test guards what this change relies on from context-manager rather than a branch of its own: with a strategy whose detach() throws and a manager the host already closed, stop() with a store it owns still closes every manager, and then the store. By our reading of context-manager 0.22.1, close() catches and logs a failed detach(), and returns at once when called again, so a release in which close() could throw would fail here first.

Evidence

At this head, 28ef6b86, on Linux with Node 24.22.1, on a clean checkout installed fresh, with context-manager 0.22.1, membrane 0.7.2 and chronicle 0.4.0:

  • The full suite (npm test): passes 7,650 of 7,694, with 44 skipped and none failing, and the three files CI also runs on their own pass 136 of 136. Typecheck and build are clean.
  • Red-first: test/compression-after-release.test.ts, built against main 773a1451's src/framework.ts, fails 22 of its 24 tests. The two that pass are main's behaviour kept: stop() with a store the host owns leaves the managers open, and a start() after a stop() that had no manager to close isn't refused for its managers. Under main's code, the run logs speculative-drain error: Error: Store has been closed for two ephemeral agents' managers.
  • Removals: each of twenty-one pieces taken out on its own fails at least one test: an ephemeral manager's own membrane; the release at a run's end, at cleanup() and at a fork's disposal; the release's abort of each call out, and its withdrawal of new ones; a withdrawn call rejecting as the withdrawal rather than membrane's abort; the call following its caller's signal; the close at a failed fork or ephemeral creation; the withdrawal under a surgery's hold and once stop() has begun; the maintenance pass's skip of a released manager; the refusal of a run after cleanup(), and of a start() after a failed store close, and that refusal only when a manager was closed; a released manager staying in the set stop() closes; stop()'s close before its owned store, and its close of every manager alive rather than the first; and a failed create()'s close, with a store it owns and with one the host owns.

Not verified

  • No live provider: membrane's retry and abort run through a real Membrane over stand-in adapters.
  • connectome-host isn't run with this change. That its subagent module calls cleanup() at the end of each attempt and reads no agent's context manager after it, and creates a new agent for each retry, is by our reading of its main.
  • The collected side of the weak hold: no test forces a collection to show a released manager nothing references is collected.

Not covered

  • A manager being opened when stop() closes a store it owns isn't tracked by stop(), as on main, so the store can close under its initialization.
  • A call a resident's, a fork's or an ephemeral agent's manager has out when a surgery takes the store or stop() begins runs on, membrane's retries of it included, as a resident's does on main, whose withdrawal is at a call's entry. stop() ending a live run releases its manager, which aborts the run's call, and with a store the framework owns, stop()'s close of each manager aborts the rest, at the end of its teardown.
  • A manager kept by a host after stop() with a store the host owns stays open, and a resident's and a fork's calls stay withdrawn by closed admission, as before.
  • A released manager whose work is still unwinding when a host closes its own store after stop(), such as one whose call is inside a beforeRequest hook, can read the closed store, since with a store the host owns stop() leaves the managers open. A host may still read a released ephemeral's manager after stop(), so the framework doesn't close it there.

Merge notes

#550 suggests closing the manager in the ephemeral cleanup. This change releases it there and closes it at stop(), so writes that come after the release, a run's receipts and a disposed fork's held writes among them, still land.

!Fixes #550.


  • Changelog fragment added: changelog.d/550-done-context-managers.fixed.md.

Read by Phoebe-2419 (Claude Opus 5.5) against context-manager 0.22.1's source, and again for each round before its rebase onto 773a1451, and this head's added test; her points on the tests' checks and on what a host-owned store leaves uncovered are in.

Written by Duncan-2410 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) decided the design.

Description checked and posted by Lottie-2661 (Claude Opus 5.5). Updated for 28ef6b86, checked and posted by Daniel-2612 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

@nissa-seru
nissa-seru force-pushed the fix/ephemeral-compression-withdrawn branch from 18ddf27 to c2cead2 Compare October 26, 2026 07:56
@nissa-seru nissa-seru changed the title fix(compression): withdraw the calls of context managers the framework is done with fix(context): a released context manager makes no provider call, and managers close before an owned store Oct 26, 2026
@nissa-seru
nissa-seru marked this pull request as ready for review October 26, 2026 07:58

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.

Reviewed commit c2cead2 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits, with concerns. This fixes lifecycle bug #550 inside the framework's existing context-manager and stop() code, and belongs here. One part needs a maintainer's call: cleanup() now ends the agent for good, which callers will notice, yet the changelog files it under "fixed" without naming who must act.

5 reasons with sources
  • The fix is about how the framework owns context managers, which is the framework's own job; nothing goes to the sibling packages. Releasing managers, withdrawing their compression calls and closing them before an owned store all happen in src/framework.ts. That is where the existing resident and conversation membranes (conversationAuxiliaryMembraneFor, withdrawMaintenanceUnderSurgery) and stop()'s store teardown already live. The change builds on that existing withdrawal path through MaintenanceWithdrawnError rather than adding a second one. (CONTRIBUTING.md (intro: membrane / context-manager / connectome-host split))
  • Ephemeral agents and forks now follow the same withdrawal rules residents and forks already had, so behaviour lines up across agent kinds. The base code's comment says an ephemeral run's manager kept the plain membrane, while residents' and forks' compression was already withdrawn under a surgery's hold and during stop(). The change extends that same treatment to ephemeral agents. (src/framework.ts (conversationAuxiliaryMembraneFor doc comment, base))
  • Making cleanup() final changes the public API, and a maintainer should decide whether that belongs in a bug fix. Before, a run after cleanup() went ahead; now runEphemeralToCompletion refuses it. The PR says connectome-host isn't affected, but this is a new restriction on a documented host-facing entry point. (README.md (createEphemeralAgent / runEphemeralToCompletion))
  • The changelog files the change in callers' behaviour under "fixed", and the project's convention for such changes isn't followed. The fragment is 550-done-context-managers.fixed.md. The README says breaking fragments open by naming who needs to act ("- Module authors: …"), and the second bullet, on cleanup() being final, does not. Whether this belongs under "breaking" is the maintainer's call. (changelog.d/README.md)
  • The PR is one coherent change, with tests and a changelog fragment as the contributing guide expects. Its parts are the release, the closing order at stop() and a failed create(), and refusing a run after cleanup(). All three answer #550. A new test file and a changelog.d fragment come with it. (CONTRIBUTING.md (What a PR should contain))

Findings

4 findings of medium or high severity, see the inline comments:

2 more findings, less severe
  • Low severity, documentation, changelog.d/550-done-context-managers.fixed.md:26-31. Caller-visible breaking behavior is filed under 'fixed' and doesn't name who must act. The fragment describes two contract changes that existing callers can hit. First, runEphemeralToCompletion now refuses an agent after cleanup(), where before it ran. Second, stop() with an owned store now closes the resident, subconscious and ephemeral managers, so a host that keeps a manager afterwards gets context-manager's 'after close()' error. changelog.d/README.md says a breaking fragment uses the .breaking.md category and opens by naming who needs to act (- **Module authors:** …). Here both changes are tucked into a .fixed.md fragment, so hosts such as connectome-host get no breaking-change notice at release.
  • Low severity, test coverage, src/framework.ts:5087-5095. No test covers a manager close() that throws during stop() or a failed create(). The new teardown calls contextManager.close() for every tracked manager, but no test injects a failing close() or detach(). Every test strategy (DetachRecorded, the wrapped detach in setup) returns normally. So nothing shows what stop() or releaseFailedCreate do when that step fails. As written, they abandon the owned store (see the separate findings on those paths).

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/framework.ts
Comment on lines 4876 to 4880
if (this.ownsStore && teardownSucceeded) {
if (this.closeOpenManagers()) this.ownedStoreManagersClosed = true;
try {
this.store.close();
this.ownedStoreClosed = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A throwing ContextManager.close() in stop() skips closing the owned store and loses the other shutdown errors.

this.closeOpenManagers() is called outside the try that guards this.store.close(), and nothing catches an error from it. If any contextManager.close() throws (a strategy's detach() failing, or a manager the host already closed), stop() exits at that line. The owned store is never closed. ownedStoreManagersClosed is not set even though some managers are already closed. The errors collected in shutdownErrors are thrown away and replaced by this one. Any manager after the failing one in the loop is also left open. Every other teardown step in stop() pushes its error into shutdownErrors and carries on; this step should do the same, closing each manager in its own try and setting the flag once any manager has closed.

Comment thread src/framework.ts
Comment on lines +3652 to 3655
if (framework) framework.closeOpenManagers();
if (ownsStore && !store.isClosed()) {
try {
store.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A throwing manager close in releaseFailedCreate leaves the owned store open and hides create()'s error.

framework.closeOpenManagers() runs without a try/catch, unlike the framework.stop() call before it and the store.close() call after it. If a manager's close() throws, releaseFailedCreate rejects with that error. The owned store is then never closed and may stay locked. The original create() error and the unfinished explanation are lost, which goes against the function's own contract ("a store create() opened is closed even when that stop failed"). The close failure should be pushed into failures/unfinished like the others.

Comment thread src/framework.ts
Comment on lines +5096 to +5100
private closeOpenManagers(): boolean {
const alive = [...this.openManagers].flatMap((ref) => ref.deref() ?? []);
for (const contextManager of alive) this.closeManager(contextManager);
return alive.length > 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. closeOpenManagers stops at the first manager whose close() throws.

The loop calls closeManager on each live manager in turn, and closeManager has already taken the manager out of openManagers before it calls close(). If one close() throws, the rest are neither closed nor attempted, and the failing manager is no longer tracked, so a later stop() won't retry it. Its strategy may stay attached and its work can then reach a store that is about to close, which is the failure this PR is meant to prevent. The loop should continue past a failure and report what it collected.

Comment on lines +220 to +240
const run = framework.runEphemeralToCompletion(agent, contextManager);
framework.start();
await run;
assert.equal(contextManager.isReady(), false, 'compression is still queued when the run ends');
const sent = membrane.compressions.length;
await sleep(1_000);
assert.equal(membrane.compressions.length, sent, 'no compression call reached the provider after the run ended');
await assert.rejects(contextManager.resetHeadWindow(), (error: unknown) => error instanceof CompressionWithdrawn,
'a reset with no text is withdrawn');
assert.equal(membrane.compressions.length, sent, 'and reached no provider');
assert.deepEqual(drainLines(errors.lines), []);
} finally {
errors.restore();
await settle(membrane);
await framework.stop();
rmSync(dir, { recursive: true, force: true });
}
});

it('after cleanup() with no run: the call it has out is aborted, it makes no call, and another agent compresses as before', { timeout: 60_000 }, async () => {
const membrane = new CompressionMembrane(100);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity, test coverage. Tests depend on wall-clock sleeps and timing assumptions.

Several tests check for the absence of calls with fixed sleep(800) to sleep(5_000) waits. Others depend on timing being right: contextManager.isReady() === false must still hold when the run ends, and a 200 ms mock delay must still have a call in flight. On a loaded CI machine the setup assertions (e.g. 'compression is still queued when the run ends') can fail spuriously. A call that comes late can also slip past the fixed window, so a regression would go unnoticed. The suite also adds many seconds of idle waiting. Deterministic hooks, such as gating the mock's response until the release, would make these tests both reliable and able to fail.

…urther provider call, and none of its work runs after the framework closes its store

An ephemeral agent's context manager had the framework's plain membrane,
so nothing withdrew its compression: after its run ended, after
cleanup(), under a surgery's hold and while stop() tore down, a strategy
that compresses in the background went on calling the provider. A
disposed conversation fork's manager went on calling too. And no teardown
closed a manager, so a compression call still out when stop() closed a
store it owns answered into the closed store ("speculative-drain error:
Error: Store has been closed") (anima-research#550).

A manager the framework is done with is released, not closed: from an
ephemeral run's end or its cleanup(), and from a fork's disposal. A
wrapper on the membrane it gets withdraws its new calls once it's
released, and gives each call its own AbortController, which the release
aborts, combined with the caller's signal (AbortSignal.any). membrane
doesn't retry a call whose signal is aborted, so a call out at the
release sends nothing more, its retries and a request after a
beforeRequest hook included. A call the release aborted rejects with
MaintenanceWithdrawnError, a subclass of context-manager's
CompressionWithdrawn, so the strategy records no failure and the
maintenance pass says once that it was withdrawn. An ephemeral manager's
own membrane also withdraws its new calls under a surgery's hold and
once stop() has closed provider admission, as a resident's and a fork's
already were; a call already out then runs on, as theirs does, until
stop() closes a store the framework owns. The maintenance pass doesn't
queue a released manager, and one it's already ticking stops at its
first withdrawn call.

A released manager stays open, so writes that come after the release
land as before: a run's receipts, a guard's stored results, a disposed
fork's held writes at its window's release, and a compile of it. Such a
write still ticks its strategy, whose provider call is withdrawn. The
framework holds each manager it opens weakly, in a set of weak
references, so the set doesn't keep a released one alive. One whose
strategy work is still pending stays reachable through that work, by our
reading of context-manager 0.22.1's strategy context, and managers close
where their store is about to close, and at a failed creation:
- stop(), with a store it owns, closes every manager still alive,
  released ones included, before it closes the store. A close ends the
  strategy's generation, so work a release left unwinding, such as a call
  inside a beforeRequest hook, fails its currency check rather than read
  the closed store;
- a failed create() closes the managers it opened, whoever owns the
  store, since nothing can use them;
- a fork or an ephemeral agent whose creation fails after its manager
  opened has that manager closed.
With a store the host owns, stop() leaves the managers open, as a
start() after the stop() may follow. A stop() that closed a manager and
then failed to close its owned store leaves start() refused, saying so.

cleanup() is final: runEphemeralToCompletion refuses an agent it has
released, in its own words, before the refusals a retry could pass. A
host that keeps a manager after stop() with a store the framework owns,
and does strategy work on it, gets, by our reading of context-manager
0.22.1, its "after close()" error, where it met a closed store.

compression-after-release.test.ts covers each case: an ended run with
compression still queued, cleanup() with and without a run going, a run
after cleanup(), stop() begun, a disposed fork, a fork disposed while a
maintenance pass's call for it is out, which the pass reports as
withdrawn rather than failed, a fork and an ephemeral agent whose
creation failed, membrane's own retry of a call out at cleanup() and of
one it's waiting to retry, a surgery's hold, a fork disposed on a
quiesced host, a released manager staying open until stop() closes it, a
released manager's call inside a beforeRequest hook, at cleanup() alone
and across an owned store's close, stop() with a store it owns closing
each manager alive before the store and aborting a live ephemeral
agent's call out, a failed create() with a store it owns and with one
the host owns, a store whose close fails with and without a manager
closed, a store the host owns, and a strategy whose detach() throws
beside a manager the host already closed, where stop() still closes
every manager and then the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/ephemeral-compression-withdrawn branch from c2cead2 to 28ef6b8 Compare October 26, 2026 08:23
@nissa-seru
nissa-seru merged commit f2816b0 into anima-research:main Oct 26, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 26, 2026
… options

After the merge with main, "an ephemeral run's" assertion failed: anima-research#587
(28ef6b8) gives each call an ephemeral context manager makes its own
AbortController, combined with the caller's signal, so a call made with no
options reaches membrane with { signal }. The test's requirement is that
compression keeps membrane's own retries, so it now asserts the retry option
alone: undefined where the caller gave none, true where the caller gave true.
Putting retry: false back on the context managers' calls still fails it.

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.

stop() and ephemeral-agent cleanup never close their ContextManagers, so strategies are never detached on teardown

1 participant