Repository navigation
fix(context): a released context manager makes no provider call, and managers close before an owned store - #587
Conversation
18ddf27 to
c2cead2
Compare
There was a problem hiding this comment.
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:
- Medium severity. A throwing ContextManager.close() in stop() skips closing the owned store and loses the other shutdown errors.
- Medium severity. A throwing manager close in releaseFailedCreate leaves the owned store open and hides create()'s error.
- Medium severity. closeOpenManagers stops at the first manager whose close() throws.
- Medium severity, test coverage. Tests depend on wall-clock sleeps and timing assumptions.
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,runEphemeralToCompletionnow refuses an agent aftercleanup(), 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.mdcategory and opens by naming who needs to act (- **Module authors:** …). Here both changes are tucked into a.fixed.mdfragment, 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 callscontextManager.close()for every tracked manager, but no test injects a failingclose()ordetach(). Every test strategy (DetachRecorded, the wrappeddetachin 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.
| if (this.ownsStore && teardownSucceeded) { | ||
| if (this.closeOpenManagers()) this.ownedStoreManagersClosed = true; | ||
| try { | ||
| this.store.close(); | ||
| this.ownedStoreClosed = true; |
There was a problem hiding this comment.
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.
| if (framework) framework.closeOpenManagers(); | ||
| if (ownsStore && !store.isClosed()) { | ||
| try { | ||
| store.close(); |
There was a problem hiding this comment.
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.
| private closeOpenManagers(): boolean { | ||
| const alive = [...this.openManagers].flatMap((ref) => ref.deref() ?? []); | ||
| for (const contextManager of alive) this.closeManager(contextManager); | ||
| return alive.length > 0; | ||
| } |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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>
c2cead2 to
28ef6b8
Compare
… 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>
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).
cleanup(), while a surgery held the store, and whilestop()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.stop()closed a store it owns with a compression call still out, the call answered into the closed store, and the strategy loggedspeculative-drain error: Error: Store has been closed.By run at
main773a1451, with context-manager 0.22.1, this change's tests fail there for each of those cases (Evidence).Requirements
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 oncestop()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 aftercleanup()is refused, saying so.stop(), with a store it owns, closes every manager that could still work before it closes the store, and so does a failedcreate()that closes the store it owns.Changes
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 ownAbortController, 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 abeforeRequesthook goes to the adapter with that aborted signal. A call the release aborted rejects with the withdrawal,MaintenanceWithdrawnError, an instance of context-manager'sCompressionWithdrawn, 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.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, untilstop(), with a store the framework owns, closes its manager. As before, an ephemeral run records no stated wait.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.openManagers, a set ofWeakRefs pruned by aFinalizationRegistry). The set doesn't keep a released manager alive, as onmain. 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 ashostContext(autobiographical.ts:2694), and the context'stoolsandsystemPromptgetters close over the manager (context-manager.ts:2321–2337).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'sclose()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 abeforeRequesthook, 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 astart()may follow thestop().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-outstop()left running would otherwise go on writing into the store. context-manager'sclose()leaves a store the host passed in open.stop()that closed a manager and then failed to close its owned store leavesstart()refused, saying so, since the managers it would run on are closed.cleanup()is final:runEphemeralToCompletionrefuses 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 oncreateEphemeralAgentsays 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'smain, its subagent retries create a new agent for each attempt, so they don't meet this.cleanup()or a fork's disposal, the manager's compression calls stop, and one it has out is aborted.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.start()after astop()that closed a manager and then failed to close its owned store is refused, where it started on closed managers.create()on a store the host owns closes the managers it opened; the store stays open.resetHeadWindow()with no text rejects with aCompressionWithdrawn, where it asked the provider for one.What each part is for
withdrawnOnceReleased,ManagerRelease): requirement 1 for a released manager, which stays open. It costs a proxy per ephemeral or fork manager and a flag.AbortController, combined with the caller's signal byAbortSignal.any, andManagerRelease.calls): a withdrawal reaches only a new call, and membrane retries a call out insidecomplete(). 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 managersstop()and a failedcreate()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 ofWeakRefs, a map from manager to its ref, aFinalizationRegistrythat prunes collected ones, and an add at each of the four places a manager is opened.start()after astop()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 tomain's rules. It costs a flag and a check.cleanup()(releasedByCleanup): the run would meet a manager whose calls are withdrawn. It costs a set and a check.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 aftercreate()rejected. It costs moving the close out of the owned-store branch.beforeRequesthook; and a strategy that records itsdetach(), whichclose()calls.forksTickOnlyInMaintenancegives 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: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;cleanup()while its run is still going, no call, and the run goes on to its end;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);cleanup()lands, untilstop()with a store it owns closes it, detaching its strategy;beforeRequesthook atcleanup()alone, and atcleanup()and thenstop()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;cleanup()is refused in the framework's own words, while quiesced and afterstop();stop()has begun, an ephemeral manager makes no call whilestop()is still tearing down;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;create()whose ownstop()fails closes the resident's manager before it closes the store it owns;create()on a store the host owns closes the resident's manager and leaves the store open;start()after astop()that closed the managers and then failed to close its owned store is refused, and one after astop()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 faileddetach(), and returns at once when called again, so a release in whichclose()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: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.test/compression-after-release.test.ts, built againstmain773a1451'ssrc/framework.ts, fails 22 of its 24 tests. The two that pass aremain's behaviour kept:stop()with a store the host owns leaves the managers open, and astart()after astop()that had no manager to close isn't refused for its managers. Undermain's code, the run logsspeculative-drain error: Error: Store has been closedfor two ephemeral agents' managers.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 oncestop()has begun; the maintenance pass's skip of a released manager; the refusal of a run aftercleanup(), and of astart()after a failed store close, and that refusal only when a manager was closed; a released manager staying in the setstop()closes;stop()'s close before its owned store, and its close of every manager alive rather than the first; and a failedcreate()'s close, with a store it owns and with one the host owns.Not verified
Membraneover stand-in adapters.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 itsmain.Not covered
stop()closes a store it owns isn't tracked bystop(), as onmain, so the store can close under its initialization.stop()begins runs on, membrane's retries of it included, as a resident's does onmain, 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.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.stop(), such as one whose call is inside abeforeRequesthook, can read the closed store, since with a store the host ownsstop()leaves the managers open. A host may still read a released ephemeral's manager afterstop(), so the framework doesn't close it there.Merge notes
judgeMembraneFor). Neither changesconversationAuxiliaryMembraneFororcreateEphemeralAgent, which this change wraps. feat(gate): judge wake rules — ask a small model before waking #801 at59c0ced3merges cleanly with it. Reply judge: wire it only where asked, wake on mentions without it, read a fixed tail, and let resident turns stop it #805 at220dfc55conflicts withmain773a1451in one region ofsrc/framework.ts, and this change adds none.mainhas folded.#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.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-seruaccount.