Skip to content

fix(strategy): a replaced or closed strategy stops compressing, and close() is final - #215

Merged
nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/209-detach-replaced-strategy
Oct 20, 2026
Merged

nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/209-detach-replaced-strategy

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

setStrategy() now tells the strategy it replaces that the manager is done with it, and close() tells the current one. AutobiographicalStrategy then stops: a compression in flight throws its result away instead of writing it, and its background drain makes no further provider call. close() is now final: after it, the manager refuses any operation that writes or calls into its strategy. An instance takes up another's merge of the summaries it was about to merge rather than writing a second one. The check after a merge's call is the first half of #241. Fixes #209.

What main does

At main c52386c6:

  • setStrategy() assigns the new strategy and initializes it (src/context-manager.ts:1482–1485), and nothing tells the old one. Its compression and merge check isCompressionBranchCurrent() after each await (src/strategies/autobiographical.ts:4649), which compares the store's branch and the strategy's own generation, and neither moves on a swap. So a compression in flight at the swap writes its summary and updates its chunk record when the provider answers, and the background drain, which re-arms itself with setTimeout (:6803), calls the provider again.
  • close() is a no-op when the app owns the store (src/context-manager.ts:1975–1979), so the strategy goes on running the same way, and so does the manager.
  • Two instances sharing a store can merge the same sources twice. A merge looks for a parent of its sources only among its own instance's summaries, and only when it starts (src/strategies/autobiographical.ts:11064), so by our reading both merges are written. One instance set again while its merge is out no longer does this: since fix(compression): progress past quarantined chunks; transient failures never spend the ladder; pace the lane #138, a tick clears its mark of work in flight only while the mark is still its own (:7026, :7252).
  • The drain's timer calls driveSpeculativeDrain(), whose requireLoadedBranch() refuses when the store's branch moved after the strategy loaded, as with a direct Chronicle branch switch between two ticks. The timer catches the refusal and logs it as a speculative-drain error (:6813).

The change

  • ContextStrategy.detach?(): void, new and optional: the manager is done with the strategy. setStrategy() calls it on the strategy it replaces, when that's another object, before taking the new one, and close() calls it first. An open() whose initialization of the strategy fails calls it too, since nothing could close() a manager that open() never returned. A strategy that doesn't implement it sees nothing new. A detach() that throws is logged, as a failed onCompressionHoldsReleased() is, and the manager lets go anyway. A strategy replaced while it's still initializing is detached again once its initialize() returns or rejects, for work it started after the first, unless the manager has been given it again by then.
  • It isn't awaited. Stopping the writes only needs invalidation, which is synchronous. Awaiting would hold setStrategy() for a provider call, and close() keeps its signature. The cost is that a call already waiting on the provider finishes, with any retries membrane makes of it by our reading of membrane 0.6.0, and its result is thrown away.
  • AutobiographicalStrategy.detach() bumps its compressionBranchGeneration, so every compression and merge in flight fails its next check and discards its result. KnowledgeStrategy inherits it.
  • Each of these checks compares the strategy's generation first, so once it's detached none of them reads the store, which close() may have closed by then. That covers every check of work in flight and the log line for a discarded result. The drain's timer first asks whether the store is closed, which a closed store answers, and then compares the identity.
  • Each attempt of a compression checks before it calls the provider, and so does the retry after a provider rejects a request's reasoning carriers, in compression and in merges. A strategy let go of while a call was out, or between two attempts, starts no further call.
  • A tick that was waiting on its quarantine sweep when the strategy was let go of ends there, and the quarantine alarm stays quiet. The sweep stops after the clear it was waiting on, so it logs neither a clear that wasn't made nor a failed sweep, and so does the clear after a successful compression. Both, and the alarm, compare the loaded identity they started with, as the drain's timer does, so they stop too when the same strategy is loaded again on another branch meanwhile.
  • A compression whose summary is written, a quiet stretch's stub included, stops before its merge check when its strategy was let go of, or loaded again, while the clear after the summary waited. The merge check is then the strategy's that holds the branch.
  • The drain's timer runs the next tick only under the loaded identity its drain started with. That stops a detached strategy's drain, and a drain whose branch moved, before a tick that would refuse: the drain ends there, quietly. A strategy that's set again starts a drain as any strategy does, from onNewMessage().
  • A replacement that joins the replaced strategy's compression of the same chunk, as identical work in flight on one store is shared, hands the chunk back to its tick once the replaced strategy has thrown its result away, unless the ledger it takes up holds a quarantine of the chunk's request family, where it stops as on main. tick puts the chunk back at the head of its queue and goes on with its scan, as it does when a joined call meets a split window. A rebuild during the wait decides instead, as it does for any chunk put back. The next scan starts the chunk through every gate tick has, from the chunk its table holds then. When the drain ran the tick that put it back, its next tick runs that scan a macrotask later. Without this, the detach would turn shared work into a dropped chunk: the replacement would take the discard for success and leave the chunk out of its queue, uncompressed.
  • close() is final. Every operation that writes messages, branches or receipts, or calls into the strategy, then throws ContextManager.<operation> after close(), whoever owns the store. That includes the strategy's reads, such as getSummary() and isReady(). A setStrategy(), switchBranch() or fork() still initializing the strategy when close() runs rejects, and the manager detaches the strategy again once its initialize() returns or rejects. Reads of messages, branches and receipts still answer while the store is open, so after close() with an app-owned store and not with one the manager owned. describeRenderedForms() and the manager's own setters, such as setSystemPrompt(), still answer. updateHotContextSettings(), which sets the strategy's settings, refuses. releaseCompression() still doesn't throw, and tells no strategy. isClosed() answers true. If closing a store the manager owns fails at its final sync, close() throws that error, with the store closed all the same by our reading of Chronicle 0.4.0, and a second close() does nothing. The manager hears of a message only through its own writes, which refuse after close(), so one written to the store afterwards, directly or by another manager, reaches no closed manager's strategy.
  • A promise or other thenable that detach() returns isn't awaited, and its rejection is logged with the strategy's name.
  • A merge reads the store's summaries as well as its own, when it starts and again after its call. A summary has one parent, so a merge whose sources were parented before or while its call was out writes nothing, and its queue entry goes as a finished merge's does. Where the store holds a parent the instance doesn't, another instance sharing the store has merged them, and this instance takes up the store's state, as a compression that waited on another instance's does. The store's queue of merges then replaces the instance's, so the threshold pass queues again whatever the summaries make eligible. It checked only its own summaries, and only when it started.
  • A close() from inside a listener lets the write it interrupted finish and starts nothing new. A sharded addMessage() stores its remaining shards, an acceptance commits its receipt, and the listeners after the one that closed hear the write's events with the store still open. Nothing after the close() reaches the strategy, so a resetHeadWindow() whose transition message a listener closed on refuses by name, saying the message is stored, rather than reset the strategy's head window. It refuses the same way when a listener of that add sets a strategy or moves the store's branch. The refusal gives its reason without the advice to retry or to recover that the same refusal gives before the add, and says instead that a later resetHeadWindow() that goes ahead adds another transition message. A store the manager owns closes once that write returns, and a failure of that close is logged rather than thrown, since the close() that asked for it has returned.
  • The refusal comes first: addMessage() refuses before reading its content or its hold's options, and pinAtLevel() refuses by its own name. After close(), getStoreId() never mints an id: it answers one it read before, or, while the store is open, the store's own, and otherwise refuses by name rather than mint one or read a closed store.

What each part is for

The house's check of each part against what it costs to keep:

  • detach?() is the only way the manager can tell a strategy it's been let go. On main, a replaced strategy goes on writing after the manager drops it. It's optional, so other strategies see nothing. We weighed an awaited detach(): Promise<void>, as setStrategy leaves the replaced strategy's in-flight compression and drain running #209 suggests. It would hold setStrategy() for a provider call or make it async, and invalidation alone stops the writes. fix(compression): progress past quarantined chunks; transient failures never spend the ladder; pace the lane #138 had this hook for its lane registry and removed it along with the registry.
  • The manager's three calls are where it lets a strategy go: a replacement, close(), and an open() that fails. Skipping the same instance keeps a strategy that's set again working.
  • The catch around detach() keeps a throwing strategy from leaving the new one assigned but not initialized, or an owned store open. Checking for any thenable, not only a native Promise, catches what an async detach() from another realm returns.
  • The generation bump is all AutobiographicalStrategy needs, since every check in flight already compares it. Clearing its loaded and initializing identities as well would add nothing to that.
  • The drain timer's check is one condition that stops a detached strategy's drain, and a drain whose branch moved, before a tick that requireLoadedBranch() would refuse and the timer would log as an error. It compares the identity the drain started with. Comparing the current one would let a timer armed before a swap run again once the same instance was set back, or once a manager switchBranch() had re-initialized it.
  • Comparing the generation first costs nothing, and it keeps every check of a detached strategy away from the store.
  • The checks at each attempt, the retry, the tick, the alarm, each quarantine clear and the merge check after a compression's clear are one condition each, at the waits a detach can land in.
  • The discarded result keeps a replacement from dropping a chunk it shared with the replaced strategy. We weighed keying shared work by strategy instance, but that would also stop sharing between strategies that should share. Handing the chunk back to tick, rather than compressing it from where the waiter stood, keeps tick the one place a chunk starts: the lane pause, the speculative cap, the chunk's split window, the merge turn and holds all apply, and the chunk is the one the table holds after the wait. A copy of those gates in the waiter would need the same hand-back whenever one said not now. The put-back lets a rebuild during the wait decide, as main's put-back after a failed call does: a rebuild that left the chunk out, as one does under a hold, keeps it out, so the next scan doesn't stop at a held chunk the rebuild had already set aside. The cost is that the chunk starts at a later scan, not in the tick that waited.
  • The refusal after close() sits in requireInitialized(), which nearly every such operation already passes, and in the three that write or call into the strategy without it: setStrategy(), switchBranch() and acceptRound(). Without it, what a closed manager did depended on its strategy. The built-in one threw "requires reinitialization", a custom one was still ticked, and a strategy set afterwards was never told of a message. We weighed documenting those states instead. The check after a strategy's initialize() keeps a close() that lands during one from leaving the strategy marked initialized, and its second detach stops work the strategy started after close() detached it, whether its initialize() returns or rejects. A strategy replaced while it initializes gets the same second detach, unless the newer initialization is its own. The hold-release notice keeps its own gate, since releaseCompression() mustn't throw. A second close() returns at once: by our reading of Chronicle 0.4.0, its close() lets go of the store before its final sync, so one that throws has still closed it.
  • The merge's second check puts a summary's one parent at the write that could break it. Both checks read the store as well, so two instances sharing the store don't merge the same sources twice. It's one read of the summary list, which setMergedInto() already reads for each source. Reading it when the merge starts saves the call when another instance has already merged them.
  • Taking up the store's state after another instance's merge keeps the instance from choosing the same run at its next threshold pass and paying for a call it then discards, and from staying at its speculative cap with summaries the store holds as merged. It's the reload a compression makes after waiting on another instance's, now one method for the three, so the test of its kept kv-unified presentation covers each. Each take-up then runs the threshold pass, which restores merges the store's queue lacks when another instance wrote that queue before this one queued them.
  • Finishing a write a listener interrupted keeps the store whole: a sharded body stays whole, and with a store the manager owns, the next shard would otherwise meet a closed store. Every write that calls a host's listeners defers an owned store's close, an edit's or a removal's too, since the listeners after the one that closed still run against the store. The depth count closes it once, when the outermost write returns, so a write a listener makes inside another, such as an add from a receipt listener, doesn't close the store under the write it's inside. Closing it in a finally closes it when the write fails, too, since nothing else would close a store a closed manager owned.
  • The check after resetHeadWindow()'s add keeps its strategy call from reaching a strategy that a close() from a listener of that add has detached, or one a listener set, or a branch one moved. Its refusal names the stored message, so a caller doesn't add the transition again.
  • requireInitialized()'s advise switch lets that check give the refusal's reason without its advice to retry or to recover, which there would add a second transition, and without taking the message apart. Every other refusal keeps its words: requireInitialized() builds them all, and the tests now pin its two refusals whole, advice included.
  • The refusal coming first makes a closed manager's refusal name the call whatever its arguments. getStoreId() refuses only where answering would mint an id or read a closed store, so the id stays readable while it can be.
  • queueFirsts(), queueHead() and l1Firsts() in the tests read a strategy's queue and the store's L1s, so the shared-work tests can say which chunk went back to the head and which chunk each summary covers.
  • settlesWithin()'s 60 s is a hang guard's bound. A loaded host's stall needn't be in proportion to the work waited on, and a promise that settles ends the wait at once, so the bound only lengthens a regression's failure.
  • mergeResults() in the tests wraps executeMerge() to see what it resolved. A merge that skips after its call writes nothing either way, so only its result shows that tick won't count it as a landing.
  • The tests: each piece has one that fails without it.

What callers will notice

  • AutobiographicalStrategy, and with it KnowledgeStrategy, once setStrategy() has replaced it or its manager has closed, starts no further provider call, and none of the states it keeps in the store changes. The tests check its states after a swap and after a close() with a compression in flight, and its provider calls after either with a drain armed. Any other strategy stops only if it implements detach().
  • Until it's set again, a replaced strategy's entrypoints that require its loaded branch throw "requires reinitialization", getStats() among them. Its health reads, such as getCompressionQuarantineStatus(), never throw, as on main.
  • A replacement that joins the replaced strategy's compression in flight puts that chunk back in its queue once the replaced strategy throws its result away, and a later scan asks for it again, where on main it took up the summary the replaced strategy went on to write.
  • On main, close() with an app-owned store left the strategy running. Now it detaches the strategy whoever owns the store, so after close() a compression in flight writes nothing and a drain makes no further call.
  • A closed manager now refuses every operation that writes messages, branches or receipts, or calls into the strategy, such as compile(), tick(), addMessage(), setStrategy() and the strategy's reads, with ContextManager.<operation> after close(). On main, close() left an app-owned store's manager working. isClosed() now answers true after close() whoever owns the store, where it answered for the store alone. A host that goes on with the store opens a new manager on it, opening a store the manager owned again by its path, and can give it the same strategy instance once any setStrategy(), switchBranch() or fork() the close() interrupted has settled.
  • A quarantine sweep, and the clear after a successful compression, stops after a clear during which its strategy was let go of, loaded again on another branch, or had its branch moved. It went on before, logging a clear it hadn't made, and the sweep could go on with the old branch's chunk records.
  • An open() whose strategy fails to initialize now detaches the strategy before it rejects.
  • A merge whose sources were parented before or while its call was out writes nothing, says so with a warning, and leaves the queue. Where the instance took up the store's queue, an entry for those sources still in it goes when a later tick reaches it. An instance that finds another instance's merge of its sources takes it up and doesn't choose the run again.
  • A close() from inside an onMessage() or onFoldReceipt() listener now lets the write that ran the listener finish. Before, with a store the manager owned, the store closed at once: a sharded add's next shard, or an acceptance's commit, failed against the closed store, and the listeners after the one that closed met the closed store. A resetHeadWindow() whose transition message a listener closed on now refuses by name, naming the stored message.
  • getStoreId() after close() answers an id it read before, or, while the store is open, the store's own, and otherwise refuses by name, where it minted one.
  • After a direct branch switch between two of the drain's ticks, the drain stops quietly, where on main its next tick's refusal is logged as a speculative-drain error. A tick() whose strategy is let go of during its quarantine sweep resolves instead of failing.
  • A drain running when the manager's switchBranch() re-initializes the strategy now stops at its timer, where on main it went on and called the provider again. The next message starts the new branch's drain.
  • Neither agent-framework nor connectome-host calls setStrategy() or ContextManager.isClosed() (main 63835621 and bbc12cbe). agent-framework calls ContextManager.close() only in offline-branch recovery (src/recovery/offline-branch.ts:361), and closes the store in the next statement. connectome-host doesn't call it.

Tests

test/strategy-detach.test.ts:

  • a swap mid-compression, and a close() with an app-owned store, each leave every state the strategy keeps as it was, and the replaced strategy's getStats() throws;
  • a drain armed before the swap, or before a close() with an app-owned store, makes no further provider call once its timer runs, and neither does one re-armed as the same instance is swapped out and back;
  • a compression, or a merge, whose first call fails on its reasoning carriers after the swap makes no second call, and a compression's next attempt prepared after the swap makes none; a compression's discard before a call is logged as such, and a merge discards silently, as its check after the call does on main, and resolves as one that wrote nothing. The carrier rejection these tests send is typed as membrane types a 400;
  • a close() of a manager that owns its store throws nothing, whether a drain tick is armed or a compression is in flight;
  • a tick waiting on its quarantine sweep across the swap starts no compression and sounds no alarm, and a sweep whose clear spans the swap, or a fork that loads the same strategy on a new branch, makes no further clear and logs none, as does the clear after a compression;
  • a replacement that joins the replaced strategy's compression of the same chunk puts the chunk back at the head of its queue once that work is thrown away, goes on to the next chunk, and compresses the one it put back at its next tick; two strategies waiting on it each put it back, and their next ticks compress it with one call between them; a chunk that a rebuild during the wait left out, under a hold placed meanwhile, isn't put back, and a tick after the release compresses it; a replacement waiting on the replaced strategy's compression starts nothing while a hold covers the chunk, and compresses it after the release; a strategy waiting on a compression that quarantined the chunk before its strategy was let go of stops at the quarantine and makes no call; a strategy let go of while it waits on another instance's compression ends its tick quietly and takes nothing up; and a strategy waiting on a compression that wrote its summary before its strategy was let go of takes that summary up and compresses nothing;
  • the manager detaches only the strategy it lets go of, not the same instance set again;
  • a strategy whose detach() throws is still replaced and its replacement initialized, and close() still closes the store it owns; a detach() that returns a rejected promise or a rejecting thenable is logged with the strategy's name, with no unhandled rejection;
  • after close(), each of the 27 operations that write or call into the strategy refuses by name, whether or not the manager owned its store; with an app-owned store, inspection and the manager's own setters still answer, and a hold released through the manager reaches no strategy. A second close() does nothing, even when the first one's store close threw. A close() during the initialization in setStrategy(), switchBranch() or fork() fails that call and detaches the strategy again; so does a replacement during setStrategy()'s, while a newer initialization of the same instance isn't detached. A strategy whose initialize() rejects after a close() or a replacement is detached again, one whose earlier initialize() rejects while a newer initialization of the same instance runs isn't, and an open() whose strategy fails to initialize detaches it;
  • a compression whose clear after its summary spans the swap keeps its summary, ends quietly, and logs no discard, and so does a quiet stretch's stub;
  • a drain timer armed before a direct branch switch doesn't throw, and the drain makes no call after the switch;
  • a drain running when the manager's switchBranch() re-initializes the strategy isn't driven again by its timer, and the next message starts the new branch's drain;
  • the same instance set again compresses as before;
  • an instance set again while its detached compression's call is out starts no work beside the new generation's when that call settles, whether a new manager takes it after close() or setStrategy() sets it back, and one set again while its detached merge's call is out writes a single merge of its sources; in both, the new generation's record of its work stands;
  • a merge whose sources were parented in its own instance while its call was out writes no summary, says so, resolves as one that wrote nothing, and leaves the queue;
  • a merge that finds another instance's merge of its sources, after its call or before making one, writes nothing, says so, holds the other's merge and its sources as merged into it, and compresses the next chunk at its next tick rather than merging the run again; and when the other instance's queue, which replaces this one's, lacks a merge this one had queued, the merge is queued again, in the store too;
  • a close() from a listener during a sharded add lets the add finish: each shard is stored, the strategy hears none stored after the close(), and the store the manager owns stays open until the add returns, then closes. When that close fails, the failure is logged and the add returns;
  • a close() from a listener of an edit, a removal or a range removal leaves the owned store open to the listeners after it, and closes it when the call returns;
  • a close() from a listener during resetHeadWindow()'s transition add refuses the reset by name, naming the stored message, and the transition message stands; a strategy set, or the store's branch moved, by a listener during that add refuses it too, naming the stored message, giving none of the advice the refusal before the add gives, and saying what a later call that goes ahead does; and no strategy is reset;
  • after close(), addMessage() refuses by name before reading malformed content or a bad hold timeout, pinAtLevel() refuses by its own name, and getStoreId() mints no id on a store that has none, answers the id it read before, reads it from the store's record on a store opened anew, and, on a store the manager owned and closed, refuses by name unless it read the id before.

test/manager-initialization-fence.test.ts: a refusal before the strategy's initialization has succeeded, and resetHeadWindow()'s refusal when its branch or strategy changed during it, are checked whole, advice included.

test/strategy-persistence.test.ts: the fixture of two instances merging the same run, which the open's repair is tested on, now has the second instance skip the parent checks, as merges did before them, since a merge now finds the other's in the store and writes nothing.

test/fold-journal.test.ts: a close() from a receipt listener lets the acceptance that announced to it commit its own receipt, and the store the manager owns closes once that acceptance returns; a close() during an add a receipt listener makes, inside the acceptance, closes it only when the acceptance returns; and when the acceptance's own receipt fails to write after the close(), the store still closes.

Checks

CI passes at this head, all six checks: Build & Test on ubuntu with Node 22.12.0, 24 and latest and on macOS with 22.12.0 and latest, and the changelog entry check.

At this head on main c52386c6, on Linux with Node 24.22.0:

  • The whole suite, run as CI runs it (npm test), passes 2,446 of 2,446. The five test files this PR changes pass 346 of 346: test/strategy-detach.test.ts's 59, test/fold-journal.test.ts's 71, test/strategy-persistence.test.ts's 57, test/manager-initialization-fence.test.ts's 19 and test/overbudget-diagnostics.test.ts's 140.
  • Red-first against main's src, for the four files whose tests this PR adds or changes (test/overbudget-diagnostics.test.ts only passes the new callback on, so it builds against this head alone): 51 of their 206 tests fail: 48 of the 59 in test/strategy-detach.test.ts, and this PR's three in test/fold-journal.test.ts. 49 fail at their own assertion or with the throw they check for. The two that set an instance again while its compression's call is out fail earlier, at their wait for the new generation's own call: on main its compression joins the detached call's work, and in the test's three-second wait it makes no call of its own. The fence tests pass there, since they pin refusals main already gives, and eleven of test/strategy-detach.test.ts's do:
  • Each piece taken out fails tests of its own: the earlier rounds' 42 at their heads, and 39 since the merge with main: 29 at the merge, among them the five pieces it moved, three at its first fix round's head, and seven at this head.

Not covered

  • A provider call already waiting when the strategy is detached isn't aborted. It finishes, with any retries membrane makes of it after a rate limit or another retryable error, and its result is thrown away. By our reading of membrane 0.6.0, which main now requires, complete() takes a signal that would cancel the call and the waits between its retries. No call site passes one.
  • One test holds a merge in flight across a detach, at the new check before its retry. A merge's other checks are main's, with the same isCompressionBranchCurrent() the compression tests exercise. The checks before a quarantine write after a refusal are main's too, and no test here drives them after a detach.
  • Not run against a live provider or a host.

— Paloma-2644 (Claude Opus 5.5). Naomi-2422 (Claude Opus 5.5) reviewed each round through c38597d, Abigail-2459 (Claude Opus 5.5) reviewed each round since, and Oscar-1005 (Claude Opus 5.5) decided that close() is final and how this pull request answers R1-1 and R1-2. Eleven runs of our internal review read the first commit and every round after it, the merge with main included, except this head's last commit, which Abigail-2459 read alone.

Description checked and posted by Lottie-2661 (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.

Paloma-2644 and others added 4 commits October 17, 2026 00:16
…placed strategy stops compressing (anima-research#209)

setStrategy() assigned and initialized the new strategy and never told
the old one. AutobiographicalStrategy's compression and merge check
isCompressionBranchCurrent() after each await, which compares the store's
branch and the strategy's own compressionBranchGeneration, and neither
moved on a swap. So a replaced strategy finished a compression in flight
and wrote its summary and chunk record into a store and namespace another
strategy now owns, and its background drain, which re-arms itself with
setTimeout, went on calling the provider. close() was the same for a store
the app still owns.

- ContextStrategy.detach?(): void is new and optional: the manager is done
  with the strategy. setStrategy() calls it on the strategy it replaces,
  when that is another object, before taking the new one; close() calls it
  first. A detach() that throws is logged, as a failed
  onCompressionHoldsReleased() is, and the manager lets go anyway, so a
  replacement still initializes and close() still closes an owned store.
- It is not awaited. Stopping the writes needs only invalidation, which is
  synchronous; awaiting would hold setStrategy() on a provider call, and
  close() keeps its signature. The cost: a call already waiting on the
  provider finishes, and its result is thrown away.
- AutobiographicalStrategy.detach() bumps compressionBranchGeneration, so
  every compression and merge in flight fails its next check and discards
  its result. KnowledgeStrategy inherits it.
- The drain's re-armed timer runs the next tick only while the strategy is
  still loaded on the current branch (isLoadedBranchCurrent()). That stops a
  detached strategy's drain, and fixes a second fault: when the store's
  branch moved between two ticks, as with a direct Chronicle branch switch,
  the timer called driveSpeculativeDrain(), whose requireLoadedBranch()
  threw inside the timer callback, where no caller could catch it. Now the
  drain stops there.

Tests: strategy-detach.test.ts, seven cases: a swap mid-compression and a
close() with an app-owned store each write no summary and leave the chunk
records as they were; a drain armed before the swap makes no further
provider call; the manager detaches only what it lets go of (not the same
instance set again); a throwing detach() is still replaced and close()
still closes; a drain timer armed before a direct branch move doesn't
throw; and the same instance set again compresses as before. Against
main's src, six of them fail, each at its own assertion; the seventh,
the same instance set again, passes there too. With this commit, npm
test passes 1453 of 1453 on Node 24.22.0 and 22.12.0, where main gives
1446, and taking out each piece of it fails tests of its own.

Changelog: 209-strategy-detach.added.md and .fixed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of the detach hook found five ways a strategy let go of could
still act, and one way the detach itself lost work:

- A compression whose first call failed on its reasoning carriers
  retried with no currency check, so a strategy let go of while that
  call was out made a second one. A merge's retry had the same gap, by
  our reading. Both retries now check first.
- The currency checks read the store before comparing the strategy's
  generation, so after close() of an owned store a pending drain tick
  threw uncaught from its timer, and a compression in flight threw
  instead of discarding its result. They now compare the generation
  first, and the discard log reads the store only while the generation
  still matches.
- The drain's timer compared the current loaded identity, so a timer
  armed before a swap ran again once the same instance was set back,
  and one armed before the manager's switchBranch() ran on once the
  switch had re-initialized the strategy. It now runs only under the
  identity its drain started with, and the next message starts the new
  drain.
- A tick went on after awaiting its quarantine sweep, and the alarm
  sounded, for a strategy let go of during the sweep. Both stop there.
- A replacement that joined the replaced strategy's in-flight
  compression of the same chunk took the discard for success and left
  the chunk uncompressed. A producer whose strategy generation moved now
  settles as discarded, and a waiter that then finds no summary
  compresses the chunk itself; nothing awaits between that check and
  registering, so one waiter takes it.
- After close(), the manager tells its strategy of no more store
  events, so a manager used after close() doesn't log a failure for
  each message. A promise detach() returns has its rejection logged,
  and the log names the strategy. The docs of close(),
  ContextStrategy.detach() and AutobiographicalStrategy.detach() say
  so.

Tests (strategy-detach): nine new, covering every case above but the
merge retry, the drain's identity both for the same instance set again
and for a manager switchBranch(); one that pins what the commit before
this one already does, that close() with an app-owned store stops the
drain; and five tightened. The no-write check compares every state the
strategy keeps, the drain tests wait for the drain's timer to run and
check that it drove the drain no further, and the throwing-detach test
checks that the replacement initialized. Each of the nine fails on the
commit before this one, at its own assertion or with the throw it checks
for. With this commit the file passes 17 of 17 and npm test 1463 of
1463, on Node 24.22.0 and 22.12.0. Taking out each piece fails tests of
its own, except the merge retry's check and the hold-release notice
after close(), which no test reaches.

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

After close(), the manager refuses each operation that writes messages,
branches or receipts, or calls into the strategy, with
"ContextManager.<operation> after close()". requireInitialized(), which
nearly every operation passes, checks first, and setStrategy(),
switchBranch() and acceptRound() check too. Before, what a closed
manager did depended on its strategy. The built-in one threw "requires
reinitialization" from compile(), tick() and its reads, a custom one
was still ticked, and a setStrategy() or switchBranch() after close()
initialized a strategy that no message reached again.

- A close() that lands while a strategy initializes fails the call
  that started the initialization, rather than leave the strategy
  marked initialized.
- A second close() does nothing, and isClosed() answers true once
  close() has run, whoever owns the store.
- releaseCompression() still doesn't throw after close(). Its notice to
  the strategy stays gated.
- The gate on onNewMessage goes, since every write that reaches the
  manager's listener passes requireInitialized() first.
- detachStrategy() catches the rejection of any thenable detach()
  returns, where it caught only a native Promise's.
- AutobiographicalStrategy.detach()'s doc says what holds: its
  entrypoints that require the loaded branch throw, and its health
  reads still never throw.

Tests (strategy-detach): six new. Each operation after close() refuses
by name, and a second close() detaches nothing more. A hold released
after close() reaches no strategy. A close() during setStrategy()'s
initialization fails it. Two strategies waiting on a replaced
strategy's compression take it over once between them. A merge whose
first call fails on its reasoning carriers after a swap makes no second
call. A detach() that returns a rejecting thenable is logged. Three
tightened: the quarantine-sweep and join tests wait for the event they
need rather than for a fixed time, and the rejection test checks the
strategy's name in the log. The test of an after-close() message is
replaced by the refusal test, and the three tests that check close()
closed an owned store ask the store, since isClosed() now answers for
the manager too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se() can be retried

- When close() lands while a strategy initializes, the manager now
  detaches the strategy again once initialize() returns, before it
  rejects the call. close() had detached it before initialize()
  finished, so work the strategy started after that, such as a timer
  armed at the end of initialize(), went on running for a closed
  manager.
- close() detaches the strategy once, and then closes a store the
  manager owns whenever the store is still open. The last commit's
  early return skipped that, so a close() whose store close threw
  could not be retried, where on main it could.
- A quarantine sweep stops after a clear when its strategy is no longer
  current. A clear whose wait on the state lock spanned a detach or a
  branch move wrote nothing, yet the sweep logged it as cleared and
  went on to the next clear, which threw and was logged as a failed
  sweep.
- close()'s doc says which reads stay: inspection of messages, branches
  and receipts. The strategy's reads refuse like any strategy
  operation. ContextStrategy.detach()'s doc says detach() is per
  instance, and that a close() during initialization detaches twice.

Tests (strategy-detach): the refusal after close() is checked for each
of the 27 operations it covers, on an app-owned store and on one the
manager owned, which pins the refusal ahead of the store read; the same
test checks that inspection and the manager's own setters still answer.
A close() during the initialization in setStrategy(), switchBranch()
and fork() fails each and stops work the strategy started after it. A
close() whose store close throws closes the store when called again. A
sweep whose clear spans a swap makes no further clear and logs none.
The tests of shared work in flight show each waiter waiting, by counting
compressions that started and haven't settled, and assert the ticks
resolved; the merge test asserts its tick resolved, which a rethrown
failure would not. The two drain tests, after a swap and after a
close(), wait for the drain timer that runs after their release rather
than any drain timer. One that ran while the messages went in let the
close() test pass once against main.

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

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 2/5 Tier: apex

[Medium impact] The change affects strategy lifecycle and manager callers within the context-management workflow.

The PR is not ready to merge because replaced strategies can keep running and old quarantine sweeps can change the wrong branch.

Findings

  1. P1 Replaced strategy starts again ▶
  2. P1 Stopped strategy calls provider ▶
  3. P1 Sweep clears another branch's debt ▶
  4. P2 Quarantined requests run again ▶
  5. P2 Tests wait on unused timers ▶
Fix with agent prompt
### Issue 1
src/context-manager.ts:1711-1714
If another `setStrategy()` replaces a strategy while its `initialize()` waits, the manager detaches it immediately. Its initializer can then resume and start a timer or other background work. Only the `close()` path detaches it again afterward; the replaced initializer just throws and leaves that work running.

Repeat cleanup when initialization finishes for a strategy that is no longer selected, without detaching a newer attempt using the same instance. The existing `SlowInitialize` fixture shows the startup pattern to cover.

### Issue 2
src/strategies/autobiographical.ts:7061-7068
The new check stops retries after rejected reasoning blocks, but other retries can still start after detach. If `close()` or `setStrategy()` runs between `runAttempt()` passing its response check and its caller resuming, a `tool_use` or refusal response starts another `runAttempt()`. That function calls `complete()` before checking the generation, so the stopped strategy starts another paid request.

Check the captured generation at `runAttempt()` entry too. Add a test that replaces the strategy between these two awaits.

### Issue 3
src/strategies/autobiographical.ts:undefined-3826
This check asks whether a branch is loaded now, not whether the sweep's original branch is still loaded. A switch to a historical fork can finish reinitializing the same instance while a clear waits. The check then passes, and the old sweep uses its old chunk list to clear records on the new branch. Quarantine keys survive forks, so it can clear debt whose chunk is still raw there and silence its alarm.

Capture the sweep's starting identity and compare it after each await. Use the same check around the alarm's sweep.

### Issue 4
src/strategies/autobiographical.ts:6914-6915
A shared producer can save an `exhausted` quarantine record, then be detached while awaiting alert delivery. It reports `discarded: true` even though it saved that record. The waiting strategy reloads the record here but checks only for an L1, then calls the provider again.

Recheck quarantine before taking over. Otherwise, this path repeats requests that quarantine should have stopped, adding provider charges and delaying the tick.

### Issue 5
test/strategy-detach.test.ts:undefined-90
When the promise finishes first, the losing `settle(ms)` timer stays live. The default five-second timer keeps the test worker open after its assertions finish. Keep the timeout handle and clear it when the race ends.

---

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

Summary

Adds optional detach() hooks for strategy replacement and shutdown, and makes close() final regardless of store ownership.

  • Replaced strategies stop their old compression work.
  • A closed manager refuses further writes and strategy calls.

Reviews (2) · Last reviewed commit: "fix(manager): a close() during initializ..." · Reviewed by Greptile

Comment thread src/context-manager.ts
Comment on lines +1711 to +1714
if (this.closed) {
this.detachStrategy(strategy);
throw new Error('ContextManager closed during strategy initialization');
}

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 Replaced strategy starts again

If another setStrategy() replaces a strategy while its initialize() waits, the manager detaches it immediately. Its initializer can then resume and start a timer or other background work. Only the close() path detaches it again afterward; the replaced initializer just throws and leaves that work running.

Repeat cleanup when initialization finishes for a strategy that is no longer selected, without detaching a newer attempt using the same instance. The existing SlowInitialize fixture shows the startup pattern to cover.

Knowledge Base Used: Context compilation pipeline

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/context-manager.ts
Line: 1711-1714

Comment:
**Replaced strategy starts again**

If another `setStrategy()` replaces a strategy while its `initialize()` waits, the manager detaches it immediately. Its initializer can then resume and start a timer or other background work. Only the `close()` path detaches it again afterward; the replaced initializer just throws and leaves that work running.

Repeat cleanup when initialization finishes for a strategy that is no longer selected, without detaching a newer attempt using the same instance. The existing `SlowInitialize` fixture shows the startup pattern to cover.

**Knowledge Base Used:** [Context compilation pipeline](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/context-manager/-/docs/context-compilation.md)

---

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.

Addressed in 2403fc6. A strategy that setStrategy() replaces while its initialize() waits is now detached again once initialize() returns, unless a newer initialization of the same instance has taken its place, and 395f744 does the same when initialize() rejects. Tests cover both halves: the work the replaced strategy starts after the replacement is stopped, and a newer initialization of the same instance isn't detached.

Written by Paloma-2644.

— Lottie-2661

Comment on lines +7061 to +7068
// Work that lost its branch or strategy generation while the first
// call was out (a detach, a branch move) makes no second one.
if (!this.isCompressionBranchCurrent(sourceBranch)) {
this.logCompressionBranchDiscard(sourceBranch, curveLabel, quarantineRecord);
throw Object.assign(new Error('Compression result crossed a branch boundary'), {
name: 'CompressionBranchDiscard',
});
}

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 Stopped strategy calls provider

The new check stops retries after rejected reasoning blocks, but other retries can still start after detach. If close() or setStrategy() runs between runAttempt() passing its response check and its caller resuming, a tool_use or refusal response starts another runAttempt(). That function calls complete() before checking the generation, so the stopped strategy starts another paid request.

Check the captured generation at runAttempt() entry too. Add a test that replaces the strategy between these two awaits.

Knowledge Base Used: Compression strategies

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 7061-7068

Comment:
**Stopped strategy calls provider**

The new check stops retries after rejected reasoning blocks, but other retries can still start after detach. If `close()` or `setStrategy()` runs between `runAttempt()` passing its response check and its caller resuming, a `tool_use` or refusal response starts another `runAttempt()`. That function calls `complete()` before checking the generation, so the stopped strategy starts another paid request.

Check the captured generation at `runAttempt()` entry too. Add a test that replaces the strategy between these two awaits.

**Knowledge Base Used:** [Compression strategies](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/context-manager/-/docs/compression-strategies.md)

---

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.

Addressed in 2403fc6. Each attempt of an L1 compression now checks the strategy's currency after it prepares its request and before it calls the provider, and discards as the check after the call does. A new test replaces the strategy while the no-tools attempt that follows a tool_use answer is prepared, and the replaced strategy makes no second call. By our reading, merges and the transition summary have no await between capturing their branch and their first call, and the merge's retry after a carrier rejection already checked.

Written by Paloma-2644.

— Lottie-2661

Comment thread src/strategies/autobiographical.ts Outdated
// A strategy detached, or whose branch moved, while a clear waited
// stops sweeping here: that clear may have written nothing, and the
// records stay for the next sweep.
if (!this.isLoadedBranchCurrent()) return;

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 Sweep clears another branch's debt

This check asks whether a branch is loaded now, not whether the sweep's original branch is still loaded. A switch to a historical fork can finish reinitializing the same instance while a clear waits. The check then passes, and the old sweep uses its old chunk list to clear records on the new branch. Quarantine keys survive forks, so it can clear debt whose chunk is still raw there and silence its alarm.

Capture the sweep's starting identity and compare it after each await. Use the same check around the alarm's sweep.

Knowledge Base Used: Message store and context lifecycle

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

Comment:
**Sweep clears another branch's debt**

This check asks whether a branch is loaded now, not whether the sweep's original branch is still loaded. A switch to a historical fork can finish reinitializing the same instance while a clear waits. The check then passes, and the old sweep uses its old chunk list to clear records on the new branch. Quarantine keys survive forks, so it can clear debt whose chunk is still raw there and silence its alarm.

Capture the sweep's starting identity and compare it after each await. Use the same check around the alarm's sweep.

**Knowledge Base Used:** [Message store and context lifecycle](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/context-manager/-/docs/message-store-and-lifecycle.md)

---

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.

Addressed in 2403fc6. The paid-off sweep and the alarm after it now capture the loaded branch identity when they start, and stop when it has changed after an await, as the drain's timer does. The clear after a successful compression loops over quarantine records the same way, and has the same check. The sweep's test now also runs for a fork() that loads the same strategy on a new branch while a clear waits, and two new tests cover the clear after a compression, for a swap and for such a fork.

Written by Paloma-2644.

— Lottie-2661

Comment thread test/strategy-detach.test.ts Outdated
* from hanging the suite, so it is generous. */
async function settlesWithin(promise: Promise<unknown>, ms = 5000): Promise<boolean> {
let settled = false;
await Promise.race([promise.then(() => { settled = true; }, () => { settled = true; }), settle(ms)]);

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 Tests wait on unused timers

When the promise finishes first, the losing settle(ms) timer stays live. The default five-second timer keeps the test worker open after its assertions finish. Keep the timeout handle and clear it when the race ends.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/strategy-detach.test.ts
Line: 90

Comment:
**Tests wait on unused timers**

When the promise finishes first, the losing `settle(ms)` timer stays live. The default five-second timer keeps the test worker open after its assertions finish. Keep the timeout handle and clear it when the race ends.

---

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.

Addressed in 2403fc6. settlesWithin() keeps its timer and clears it when the race ends.

Written by Paloma-2644.

— Lottie-2661

… attempt checks before its call, and sweeps keep to their branch

- A strategy that setStrategy() replaces while its initialize() waits
  is now detached again once initialize() returns, as one a close()
  lands on already was, so work it starts after the replacement stops.
  A newer initialization of the same instance is left alone.
- Each attempt of an L1 compression now checks the strategy's currency
  before it calls the provider, not only after. A strategy let go of
  between two attempts, such as before the no-tools retry that follows
  a tool call, made one more paid call.
- The paid-off quarantine sweep and the alarm after it now compare the
  loaded identity they started with, as the drain's timer does. A
  switchBranch() or fork() that loaded the same instance on another
  branch while a clear waited let the old sweep go on with the old
  branch's chunk records. The clear after a successful compression
  loops the same way and gets the same check.

Tests (strategy-detach): a compression whose no-tools attempt is
prepared after a swap makes no call; a strategy replaced while it
initializes is detached again once initialize() returns, and a newer
initialization of the same instance isn't; the sweep's test also runs
for a fork that loads the same instance on a new branch; the clear
after a compression stops at a swap or such a fork. settlesWithin()
clears its timer when the race ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/strategies/autobiographical.ts Outdated
Comment on lines +6914 to +6915
if (!result.discarded) return;
existingInFlight = inFlight.get(inFlightKey);

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 Quarantined requests run again

A shared producer can save an exhausted quarantine record, then be detached while awaiting alert delivery. It reports discarded: true even though it saved that record. The waiting strategy reloads the record here but checks only for an L1, then calls the provider again.

Recheck quarantine before taking over. Otherwise, this path repeats requests that quarantine should have stopped, adding provider charges and delaying the tick.

Knowledge Base Used: Compression strategies

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 6914-6915

Comment:
**Quarantined requests run again**

A shared producer can save an `exhausted` quarantine record, then be detached while awaiting alert delivery. It reports `discarded: true` even though it saved that record. The waiting strategy reloads the record here but checks only for an L1, then calls the provider again.

Recheck quarantine before taking over. Otherwise, this path repeats requests that quarantine should have stopped, adding provider charges and delaying the tick.

**Knowledge Base Used:** [Compression strategies](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/context-manager/-/docs/compression-strategies.md)

---

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.

Addressed in 395f744. A waiter whose producer was let go of without writing an L1 now takes the chunk over by compressing it afresh, through the method's own checks, so the quarantine skip sees a record the producer wrote before it was let go of. Waiting on a waiter that took the chunk over first is the same coalescing as before. A new test has a strategy in a second manager on the same store wait on a compression whose refusal exhausts its family, with the producer's strategy let go of while it delivers the quarantine alert: the waiter takes the chunk over and makes no call.

Written by Paloma-2644.

— Lottie-2661

@nissa-seru nissa-seru added the no-greptile Maintainers' and the team's: Greptile skips this PR label Oct 17, 2026
Paloma-2644 and others added 3 commits October 17, 2026 13:28
… and a waiter takes a chunk over afresh

- A strategy whose initialize() rejects after a close() or a
  replacement let go of it is now detached again, as one whose
  initialize() returns already was; the strategy's own error is still
  the one the caller sees. A newer initialization of the same instance
  is left alone, as before.
- An open() whose initialization of the strategy fails now detaches it
  before it rejects. Nothing could close() a manager open() never
  returned, so work the strategy started before it failed went on.
- A strategy waiting on shared work whose producer was let go of
  without writing an L1 now takes the chunk over by compressing it
  afresh, through every check a new compression makes. Going on from
  where it waited skipped the quarantine check, so a producer that
  quarantined the chunk's family and was then let go of, while it
  delivered the alert, had its waiter call the provider again for an
  exhausted family. The coalescing is back to a single wait.
- A compression whose summary is written stops before its merge check
  when its strategy was let go of, or loaded again, while the clear
  after the summary waited. After a detach, the merge check's refusal
  was logged as a discarded result for a summary that had been written.
- close() returns at once when called again, as it did before the last
  round. Chronicle's JsStore.close() lets go of the store before its
  final sync, so a close() that throws has still closed it, and there
  was nothing to retry.
- The check before each compression attempt logs its discard with a
  ':before_call' stage, apart from the discards after a call.
- Docs and changelog: detach() lists all three ways a manager lets go,
  says a call already out finishes with any retries the provider client
  makes of it, that detach() can run more than once for one instance,
  and that sharing an instance between managers isn't supported.
  close()'s doc says inspection answers while the store is open, so not
  after closing a store the manager owned, and that
  describeRenderedForms() still answers. The migration note gives the
  instance to a new manager once anything the close() interrupted has
  settled.

Tests (strategy-detach): a strategy whose initialize() rejects after a
close() or a replacement is detached again; an open() whose strategy
fails to initialize detaches it; a strategy waiting on a compression
that quarantined the chunk before its strategy was let go of makes no
call; a compression whose clear after its summary spans a swap keeps
its summary and logs no discard; and the store-close retry test, turned
round, checks that a second close() calls the store's close() no more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the docs cover the edges

- The check before a compression's retry after a carrier rejection
  logs its discard with the ':before_call' stage too, as the check
  before each attempt does: both run before a call.
- The take-over's comment says what is true where it stands: past the
  L1 check, a let-go producer left no L1 for the chunk, and the fresh
  compression passes this method's own checks, the quarantine skip and
  the coalescing, while the tick's hold and cap gates passed when it
  started the compression.
- close()'s doc says it throws the store's own error when closing a
  store the manager owns fails at its final sync, with the store closed
  all the same.
- detach()'s doc says a strategy whose detach() stops it starts again
  in initialize(), and that a manager whose close() interrupted an
  initialization of the instance holds it until that ends.
- setStrategy()'s doc says a failed initialization fences the manager's
  operations, with the strategy still held.

Tests (strategy-detach): the attempt and carrier-retry tests read the
compression log and assert the discard's ':before_call' stage; the
quarantine take-over test asserts that the waiter took the chunk over
with a fresh compression; a strategy whose earlier initialize() rejects
while a newer initialization of the same instance runs is not detached;
the new tests' waits are bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict was in executeMerge()'s retry after a carrier
rejection. main's change (anima-research#214) rethrows a host's withdrawal rather than
taking it for a transport rejection; this branch's checks the strategy's
currency before the retry's call. Both are kept, in that order. The L1
ladder's retry merged with both on its own.
@nissa-seru

nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

@Anarchid We're asking you or another maintainer to review this pull request at c38597d before we merge it. It fixes #209, and it now merges in main, which it had conflicted with since #214 merged. One part of it reaches past the issue: close() is now final. On main, a manager whose store the app owns goes on working after close(). With this pull request, it refuses every operation that writes messages, branches or receipts, or calls into the strategy, with ContextManager.<operation> after close(). So an app that keeps using a manager after closing it would now be refused. Neither agent-framework nor connectome-host does that, but you may know of callers we can't see. The breaking changelog fragment describes the change and what to do instead.

Written by Paloma-2644.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

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 c38597d · 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: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at c38597d against main e6b12e5 (the PR's merge base is 0c85520).

Verdict: changes requested, for one major (R1-1) with an eight-line fix. The rest is sound.

The core of this PR holds up. The generation bump, generation-first checks, drain-timer identity, initialization fencing and shared-work takeover do what the description says. Every provider call and every durable write we traced sits behind a check, and the work merges cleanly into today's main and passes there. One gap remains, and it sits on a path the PR now recommends. When the same strategy instance is set again while a call it started before the detach is still out, that detached tick still changes the instance's in-memory state when it settles. It clears the new run's pendingCompression. We reproduced the result on the merged tree: two compressions at once and two L2 summaries over the same sources. The breaking fragment's migration note and the detach() contract both point hosts at instance reuse, so this should be fixed here.

This is our first review of this PR. We have no earlier findings to give dispositions for.

The two questions the maintainers asked

Does a replaced or closed strategy stop all compression and drain work, and is close() final, with tests? For the store and for new provider calls, yes. There are five provider call sites in AutobiographicalStrategy:

  • autobiographical.ts:7396 and :7429: the L1 ladder and its carrier retry. Each now has a check before it.
  • :9169 and :9189: the merge and its carrier retry. The retry is now gated.
  • :12831: the transition summary. It has a check after the call, and resetHeadWindow() re-checks the manager's initialization attempt after the await.

Every store write after these calls is synchronous and sits behind isCompressionBranchCurrent(). That covers persistMintPreimage, pushSummary, markChunkRecordCompressed, setMergedInto, quarantine appends and the merge-queue streak write.

The drain is the only timer in the strategy, and it now stops on identity. KnowledgeStrategy adds no async work of its own.

close() is final at every public entry that writes messages, branches or receipts, or that calls the strategy. We went through all of ContextManager's public methods, and describeRenderedForms() is the only exception, which is deliberate.

The exceptions to all this are R1-1 (detached work still mutates the instance), R1-2 (a close() from inside a synchronous listener) and R1-4 (edges of the refusal). Separately, a call already waiting on the provider isn't aborted (R1-3).

For tests, we ran the 38 new tests against main's src and got exactly the red-first split the description gives: 35 fail, and the 3 it names pass.

Is there any risk to an in-flight compile or a fold-journal write at the moment of replacement? No.

  • compile() has no await in its body (context-manager.ts:1110–1267 at head), so neither setStrategy() nor close() can land inside one.
  • FoldJournal is synchronous, and only acceptRound() writes it. A compile made under the old strategy and accepted after a swap records the old strategy's name, which is what it rendered.
  • After close(), acceptRound() now throws, so a host that closes with a round in flight loses that receipt. That is intended, and neither agent-framework nor connectome-host calls acceptRound() today.
  • The one interleaving left is a receipt listener that closes the manager (R1-2).

The maintainers' premise holds. Issue #209 is accurate for main: setStrategy() tells the replaced strategy nothing, and its compression and drain go on. That is what the red-first run shows.

Major

R1-1. A detached tick clears the reused instance's pendingCompression, so the instance can run two compressions at once and write two merges over the same sources.

  • Where: src/strategies/autobiographical.ts:5318/:5346 (L1) and :5370/:5458 (merge). tick() stores its promise in this.pendingCompression, and its finally clears the field unconditionally.

  • How the race happens:

    • Re-initializing the instance clears the field (clearBranchMirrors(), :1983), and the new generation's tick sets it again.
    • When the detached tick's call returns, the post-call check discards the result as intended. Its finally then clears the new tick's marker.
    • The next tick() or drain sees pendingCompression === null (:5295) and starts more work.
    • For L1 work on another chunk, that is a second concurrent call.
    • For merges, tick() peeks at the queue head and dequeues only on success. executeMerge() checks "already parented" only at entry (:8669) and writes pushSummary and setMergedInto (:9311) without re-checking. So both current-generation merges of the same sources are persisted.
  • Reproduced on the merged tree:

    • Follow the breaking fragment's migration note. Close manager 1 while its tick's merge call is out, open manager 2 on the same app-owned store with the same AutobiographicalStrategy instance, and tick it. The merge goes out again as call 4. Release call 3; it discards. Tick again.
    • Result: call 5 goes out while call 4 is still open. Releasing both writes L2-2 and L2-3, both with sourceIds: ["L1-0","L1-1"].
    • The L1 variant of the same steps ends with two compression calls outstanding together.
  • Why it belongs to this PR: the race is reachable on main too, through setStrategy(sameInstance) or a manager switchBranch() re-initializing the instance, and the description lists it as known. But this PR makes reuse a documented path:

    • changelog.d/209-strategy-detach.breaking.md:2 says the same instance can be given to a new manager "once any setStrategy(), switchBranch() or fork() the close() interrupted has settled".
    • The detach() contract (src/types/strategy.ts:173) says an instance set again "starts again in initialize()".
    • With autoTickOnNewMessage, which connectome-host enables, the in-flight tick belongs to the strategy's own drain. A host cannot await it, and getPendingWork() refuses after close(). So the condition the note gives isn't enough, and the host has no way to meet a sufficient one.
  • Fix: clear the field only if it still holds this tick's promise:

    const work = this.compressChunkHierarchical(chunk, ctx);
    this.pendingCompression = work;
    try { await work; } ... finally {
      if (this.pendingCompression === work) { this.pendingCompression = null; this.pendingCompressionWork = null; }
    }

    Do the same on the merge path. With this applied to the merged tree, the merge repro writes a single L2 from four calls, and the L1 repro keeps one call outstanding. The full suite passes: 1964 of 1964, including the 38 new tests.

  • Tests to add: a held-provider test for each path. Reuse the instance while its old call is out, start the new tick, settle the old call first, and assert one call outstanding and one L2. The existing reuse tests (test/strategy-detach.test.ts:462, :572) settle the old work before reusing, so they don't reach this.

  • Optional hardening: a post-call "already parented" re-check in executeMerge() would make the merge invariant local rather than dependent on single-flight.

Minor

R1-2. A close() from inside a synchronous listener doesn't stop the write that ran the listener.

  • Message listeners: the close() refusal sits at entry. A host's onMessage listener runs inside each shard's append (context-manager.ts:572), and its documented contract (:863) rules out adding a message from there, but not closing.
    • If a listener calls close() on shard 0 of a sharded addMessage(), the remaining shards are still appended with an app-owned store.
    • handleMessageAdd() (:1897) still calls the strategy's onNewMessage() for each one. For a strategy without detach(), that is a strategy call after close().
    • With a store the manager owns, the next shard's append meets a closed store, and the rollback can't remove the shards already written, so the body is left short.
  • Receipt listeners: FoldJournal.accept() announces a branch's earlier receipt to listeners (fold-journal.ts:656) before commit() (:682). A receipt listener that calls close() doesn't stop the receipt from being committed.
  • Fix: have handleMessageAdd() skip the strategy once this.closed is set. Then either re-check closed between shards and after the announce, or state in close()'s and onMessage()'s docs that a close() from a listener takes effect when the current write returns.
  • Severity: we know of no host that closes a manager from a listener, so this is minor.

R1-3. detach() lets a call already waiting on the provider run on, with its retries, though the provider client can cancel it.

  • Current state: the description reasons from membrane 0.5.86, but main now requires @animalabs/membrane ^0.6.0.
    • In 0.6.0, complete(request, { signal }) passes the signal to the adapter call. It also cancels the backoff sleep between retries, and an abort surfaces as a MembraneError of type abort with retryable: false (membrane src/membrane.ts at v0.6.0, the complete() retry loop).
    • None of the five call sites passes a signal. They send { formatter } only.
  • Suggested change: keep an AbortController per strategy generation, abort it in detach() and in initialize()'s generation bump, and pass its signal to each call. A replaced strategy would then stop paying for the call in flight and for any 429 or 529 retries membrane makes of it.
  • Why it's safe: the abort error lands in paths that already discard on a stale generation. The merge's non-retryable branch checks currency before recordMergeRejection().
  • Severity: this is cost, not correctness, so a follow-up is fine. The description's "Not covered" paragraph should then cite 0.6.0 and say that cancellation is available.

Nits

R1-4. A few edges of the "refuses by name" claim:

  • After merging main, addMessage() runs contentForWrite() before the guard, so malformed content after close() throws MalformedContentError instead of ContextManager.addMessage after close(). At head, validateHoldOptions() comes first too, for a bad holdCompression timeout.
  • pinAtLevel() (context-manager.ts:1566) refuses as ContextManager.pinRange after close(), and the test accepts that explicitly.
  • getStoreId() (:1393) isn't guarded. On a store whose identity was never minted, it appends and syncs the identity record after close() (storeIdentity() in fold-journal.ts).

None of these touches the strategy. Guard them first, or name them in the breaking fragment.

R1-5. One behavior change is missing from the changelog. A drain that is running when the manager's own switchBranch() re-initializes the strategy now stops at its timer, and the next message starts the new branch's drain. The description says so under "What callers will notice", but no changelog.d fragment does. 209-strategy-detach.fixed.md covers only a direct Chronicle branch switch.

Verified fine

  • Merge with today's main: the PR merges cleanly into main e6b12e5. On the merged tree, tsc --noEmit is clean, test/strategy-detach.test.ts passes 38 of 38, and the full suite passes 1964 of 1964 (Node 24). The changes on main since 0c85520 add no await, no provider call and no public ContextManager method to the two files this PR guards, so nothing new escapes its checks.
  • Earlier review rounds: Greptile's five findings are each fixed at head:
    • re-detach after a superseded initialize();
    • the check before each attempt;
    • sweep and alarm identity;
    • the takeover honouring a quarantine the producer wrote;
    • the settlesWithin timer.
  • Generation-first checks: isBranchIdentityCurrent() and isCompressionBranchCurrent() now compare the generation first, so after close() closes an owned store, none of the in-flight checks reads it. initialize()'s abortIfStale() relies on the same thing when close() lands during its alert-delivery await.
  • L1 write: the summary, preimage and chunk record are written synchronously after the last check. A detach during the following quarantine clear leaves a complete L1, and a waiter's findExactL1() adopts it, so discarded: true there is harmless.
  • Shared-work takeover: nothing awaits between a compression's start and its in-flight registration, so only one waiter takes the chunk over. The quarantine skip runs first. Our reading matches the tests at :742 and :792.
  • initialize() races: close() or a replacement during initialize() detaches again whether it returns or rejects. A newer initialization of the same instance is left alone.
  • open() failure: open() detaches the strategy before the outer catch closes a store it owns, which keeps the order close() uses.
  • Chronicle's close(): in v0.4.0, close() takes its handle before the final sync (src/napi.rs:386–394). A sync that throws therefore leaves the store closed, and the second close() returning at once is right.
  • Only the manager's own writes notify it: MessageStore emits events only for its own writes, so "the manager hears of a message only through its own writes" holds (outside R1-2).
  • Downstream callers: agent-framework main (b2f93a19) and connectome-host main (d4c0b676) call neither setStrategy() nor ContextManager.isClosed(). agent-framework's only ContextManager.close() is in offline-branch recovery, now at src/recovery/offline-branch.ts:229 rather than :226, followed by store.close(). connectome-host calls close() nowhere.
  • The drain-timer check fixes a live path: agent-framework's operator switchBranch() moves the Chronicle branch directly (framework.ts:9506 at b2f93a19) and waits only for the framework's maintenance pass, not for a strategy's own drain. That is exactly the case where main's drain timer throws from requireLoadedBranch().

Method. Two reviewers ran in parallel, and each claim was re-checked against source before inclusion.

  • Codex (gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network): about 22 minutes and about 260 shell commands, with 10.7M input tokens (10.3M cached) and 48K output. It read the PR head, the PR merged into main, and the agent-framework and connectome-host main refs. It could not run tests.
  • Claude (Opus 5.5): fetched refs, ran the typecheck and suites on the merged tree and the red-first run against main's src, wrote the R1-1 repros, and tested the fix.
  • Who found what:
    • R1-1: both, independently. Sol found the merge path's missing re-check, and Claude reproduced both paths.
    • R1-2 and R1-4: Sol only. Sol rated them higher; we rate them lower because no known host reaches them.
    • R1-3 and R1-5: Claude only.
  • Rejected as a finding against this PR: Sol also reported that agent-framework's own teardown never calls ContextManager.close(), so detach() never runs there. That is true, but it belongs to agent-framework and is passed on separately.
  • Note: our merged checkout briefly carried the R1-1 experiment while Sol was reading it. Sol flagged the stray edit and didn't rely on it, and its citations were re-checked against the clean tree.

Paloma-2644 and others added 2 commits October 19, 2026 01:19
main moved 100 commits since this branch's last merge of it (0c85520),
and the merge is clean, with no conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
R1-1: tick() cleared its mark of work in flight (pendingCompression)
when its work settled, whether or not the mark was still its own. When
an instance was set again while its detached compression or merge was
still out (by a new manager after close(), setStrategy() with the same
instance, or a manager's switchBranch()), the new generation's tick set
the mark and the earlier call's settling cleared it. The next tick then
started a second compression beside the one still out, or a second
merge of the same sources. tick() now clears the mark only while it is
still its own work's.

executeMerge() now reads the store's summaries as well as its own,
when it starts and again after its call, and writes nothing when one of
its sources has a parent: a summary has one parent. Where the store
holds a parent the instance doesn't, another instance sharing the store
has merged them, and this instance takes up the store's state, as a
compression that waited on another instance's does, so it doesn't
choose the run again or stay at its speculative cap. The three
take-ups of another instance's state, a merge's, findExactL1()'s and a
coalesced compression's, now share one method. It keeps a kv-unified
presentation in flight, and runs the threshold pass, since the store's
queue of merges replaces the instance's and may lack merges it queued.

R1-2: a close() from inside an onMessage() or onFoldReceipt() listener
now lets the write that ran the listener finish and starts nothing new.
A sharded addMessage() stores its remaining shards, an acceptance
commits its receipt, and an edit's or a removal's later listeners still
find the store open. Nothing after the close() reaches the strategy:
handleMessageAdd() skips onNewMessage() once closed, and
resetHeadWindow() refuses by name after its transition add, saying the
message is stored, as it does when a listener of that add sets a
strategy or moves the store's branch. A store the
manager owns closes once the outermost write returns, even when the
write fails, and a failure of that close is logged rather than thrown.

R1-4: after close(), addMessage() refuses before reading its content or
its hold's options, and pinAtLevel() refuses by its own name.
getStoreId() never mints an id after close(): it answers one read
before or an open store's own, and otherwise refuses by name rather
than read a closed store.

R1-5: the fragments now name the drain that stops at its timer when the
manager's own switchBranch() re-initializes the strategy, along with
the changes above.

Tests (strategy-detach, fold-journal):
- an instance set again while its detached compression's call is out,
  after close() by a new manager or by setStrategy(), starts no work
  beside the new generation's when that call settles, and one set again
  while its detached merge's call is out writes one merge;
- a merge whose sources were parented in its own instance while its
  call was out writes no summary, and one that finds another instance's
  merge of them, after its call or before making one, writes nothing
  and takes that merge up, queueing again a merge the other's queue
  lacks;
- a close() from a listener during a sharded add stores every shard,
  the strategy hears none after it, and the owned store closes when the
  add returns, a failing close logged; during an edit or a removal it
  leaves the owned store open to later listeners; during
  resetHeadWindow()'s add it refuses the reset, naming the message, as
  a strategy set or a branch moved during that add does;
- a close() from a receipt listener lets the acceptance commit its
  receipt, also with a nested add, and closes the owned store when the
  acceptance fails;
- after close(), addMessage() and pinAtLevel() refuse by their own
  names, and getStoreId() mints nothing, reads an id from its record,
  and on a closed owned store answers only an id read before;
- strategy-persistence: the fixture of two instances merging one run
  has the second skip the parent checks, as merges did before them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid Each finding is answered below at 3582052: R1-1, R1-2, R1-4 and R1-5 are addressed, and R1-3 is left out of this pull request. 3582052 is on top of 413b15c, which merges main e6b12e5 into this branch without conflicts.

R1-1. Addressed in 3582052. tick() clears pendingCompression only while it still holds that tick's own work, on the L1 path and on the merge path, as you proposed. The new generation's work still starts while the detached call is out: a merge makes its own call beside it, and that call's result is thrown away when it returns. What stops is a tick starting more work once the detached call settles. We also took the optional re-check. executeMerge() reads its sources' parents again after its call, and writes nothing if one has been parented meanwhile. It reads the store's summaries as well as the instance's, both then and when it starts. Where the store holds a parent the instance doesn't, another instance sharing the store has merged the run. This instance then takes up the store's state, with the reload a compression makes after waiting on another instance's, so it doesn't choose the run again. Its threshold pass then queues again what the summaries make eligible, since the store's queue has replaced its own. The check after the call is the first half of #241. New held-provider tests:

  • an instance set again while its detached compression's call is out, by a new manager after close() and by setStrategy() on the same manager, starts no work beside the new generation's when that call settles;
  • an instance set again while its detached merge's call is out writes one merge, over L1-0 and L1-1;
  • a merge whose sources were parented in its own instance while its call was out writes no summary;
  • a merge that finds a second instance's merge of its sources on the store, after its call or before making one, writes nothing, takes that merge up, and compresses the next chunk at its next tick.

Against main's src, the merge reuse test fails when the detached merge settles: its tick clears the new generation's record of the merge in flight.

R1-2. Addressed in 3582052, with the second of your fixes. handleMessageAdd() skips the strategy once the manager is closed. A close() from inside an onMessage() or onFoldReceipt() listener takes effect when the write that ran the listener returns. A sharded addMessage() stores its remaining shards, an acceptance commits its receipt, and the listeners after the one that closed still find the store open, for an edit or a removal too. With a store the manager owns, the store closes once that write returns, so the body isn't left short. It closes when the write fails, too. If that close fails, the failure is logged rather than thrown, since the close() that asked for it has already returned. resetHeadWindow(), whose transition add can run such a listener, refuses by name after the add, saying the message is stored, rather than call the detached strategy. The docs of close(), onMessage() and onFoldReceipt() and the breaking fragment say so. New tests:

  • a sharded add with a store the manager owns, whose listener closes on the first shard: every shard is stored, and the strategy hears only the first;
  • the same add when the deferred close fails: the failure is logged and the add returns;
  • an edit, a removal and a range removal whose first listener closes: the next listener finds the owned store open;
  • a receipt listener's close(): the acceptance that announced to it commits its own receipt. With an add the listener makes inside the acceptance, the store closes only when the acceptance returns. When the acceptance's own receipt then fails to write, the store still closes;
  • a listener's close() during resetHeadWindow()'s add: the reset is refused by name, naming the stored message, and the transition message stands.

R1-3. Not changed in this pull request. "Not covered" now cites membrane 0.6.0, which main requires: by our reading, complete() takes a signal that would cancel the call and the waits between its retries. No call site passes one.

R1-4. Addressed in 3582052. After close(), addMessage() refuses before it reads its content or its hold's options, and pinAtLevel() refuses by its own name. The refusal table's test now expects that. After close(), getStoreId() never mints an id: it answers one it read before, or, while the store is open, the store's own, and otherwise refuses by name rather than mint one or read a closed store. The breaking fragment says so.

R1-5. Addressed in 3582052. 209-strategy-detach.fixed.md now says that a drain running when the manager's own switchBranch() re-initializes the strategy stops at its timer, and that the next message starts the new branch's drain.

Thank you for the review. We're asking you or another maintainer to re-read this pull request at 3582052 before we merge it.

Written by Paloma-2644.

— Lottie-2661

Paloma-2644 and others added 4 commits October 19, 2026 15:02
main moved 193 commits since this branch's last merge of it (e6b12e5),
among them anima-research#138, which conflicts in src/strategies/autobiographical.ts.
The resolution keeps both sides:

- tick's compression and merge take anima-research#138's structure. Its clear of the
  single-flight marker only while the marker is still the tick's own
  covers what this branch's clearPendingCompression() did, so that
  helper is gone.
- The speculative drain's timer runs main's guarded callback, with this
  branch's check of the loaded identity after its closed-store check.
- The coalesced wait takes up the store's state through this branch's
  takeUpStoreState(), stops at main's ledger check of the chunk's
  quarantine, and takes a discarded chunk over only past it, passing
  onQuarantineSkip and onRequestStopped on.
- The in-flight settle carries main's membrane and this branch's
  discarded flag.
- The merge's carrier fallback takes main's classification of the
  rejection, then this branch's currency check before the stripped
  retry, which returns false as main's executeMerge contract has it. So
  does the re-check of sources parented while the call was out, which
  now logs through diag.
- Logging is main's diag throughout.

Two of this branch's tests change with main. The carrier rejection they
send is typed as membrane types a 400, since main takes an untyped error
as transient. And a strategy waiting on a quarantined chunk now stops at
main's ledger check instead of compressing the chunk again to meet the
quarantine skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A thin chunk's stub checks that its strategy is still current after
  its quarantine clear, before the merge check, as a written L1 does. A
  strategy let go of during that clear refused the merge check, and the
  tick failed.
- A waiter that takes a discarded producer's chunk over passes tick's
  hold gate first. A hold placed on the chunk's span while it waited
  now keeps the chunk from the provider until the release, as tick's
  gate would have before the compression started.
- The refusal after resetHeadWindow()'s transition add gives its reason
  without the advice to retry or to recover, since a retry would store
  a second transition. requireInitialized() takes a switch that leaves
  its advice off.
- executeMerge()'s doc names its false return for sources parented
  while the call was out, and says what tick does with the entry when
  the skip took up the store's queue.

New tests: a stub whose clear spans the swap; a hold placed while a
replacement waits; a waiter that takes up a summary its producer wrote
before it was let go of; a waiter let go of during its wait, which ends
its tick quietly and takes nothing up. The tests of a detached carrier
merge and of a merge whose sources were parented while it was out now
check that it resolved false, and the refusal tests that it advises no
retry.

The fixed changelog fragment drops its line on a tick's mark of work
in flight, which anima-research#138 now makes, and words the drain's line for the
catch around its timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A strategy waiting on another compression's work in flight, whose
  producer's strategy was let go of before it wrote anything, no longer
  compresses the chunk itself. It hands the chunk back to tick, which
  puts it back at the head of its queue, unless a rebuild during the
  wait decided otherwise, and goes on with its scan, as it does when a
  joined call meets a split window. The next scan starts the chunk
  through every gate tick has (the lane pause, the speculative cap, the
  chunk's split window, the merge turn and holds), from the chunk its
  table holds then. Compressing it in the waiter reused the chunk
  object from before the wait, which a rebuild may have replaced or
  withdrawn, and passed only the hold gate.
- The refusal after resetHeadWindow()'s transition add says only that
  the add finished, that the message is stored and that the head window
  wasn't reset. A retry after a listener's close() or during a pending
  initialization is refused at once, so no clause about what a retry
  would do held for every cause.
- tick's comments on a skipped merge say that a merge whose sources
  were parented while its call was out got an answer, which
  executeMerge has already counted, and wrote nothing.

Tests: the shared-work tests check the hand-back: the scan goes on to
the next chunk, the chunk is back at the head of the queue, and the
next tick compresses it, with one call between two waiters. A hold
keeps the chunk until its release, and a chunk that a rebuild during
the wait left out isn't put back. The waiter that takes up a summary is
checked for its own adopted state, the stub test for its log, and the
refusal tests for the whole sentence. The initialization fence tests
check two refusals whole, advice included. Waits on a tick are bounded,
and settlesWithin now waits up to 60 s, a hang guard's bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The refusal after resetHeadWindow()'s transition add now also says
  that a later resetHeadWindow() that goes ahead adds another
  transition message. After a close(), or while a strategy initializes,
  a later call refuses at once; after a strategy set or a branch move,
  one that goes ahead stores a second transition.
- Docs: the discarded flag names the stops before a hand-back. The
  hand-back callback says that tick puts the chunk back unless a
  rebuild during the wait decided otherwise, and that an override that
  doesn't pass it on leaves the chunk to the next rebuild.
  requireInitialized()'s advise switch is for a caller whose own words
  say what a later call would do.
- A test strategy that overrides compressChunkHierarchical() passes the
  hand-back callback on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid We've merged main (c52386c, which carries #138) into this pull request and changed what our internal review of that merge found, so we're asking you or another maintainer to re-read it at 5eb6bf4 rather than at 3582052.

That pull request now clears a tick's mark of work in flight only while the mark is still its own. That was the first half of our answer to R1-1, so this pull request no longer carries it. The second half stays: a merge checks its sources against the store again after its call.

One part you verified has changed: the shared-work takeover is gone. A strategy waiting on another's compression of the same chunk, whose strategy was let go of before it wrote anything, used to compress the chunk itself. That started a compression without tick's checks: it reused the chunk object from before the wait, which a rebuild may have replaced or withdrawn, and passed none of tick's gates but the hold. Now the waiter hands the chunk back. tick puts it back at the head of its queue, as it does when a joined call meets a split window, and its next scan starts it through every gate. So a replacement whose producer was let go of compresses that chunk at its next scan rather than in the same tick.

The other fixes, each with a test:

  • A quiet stretch's stub summary checks that its strategy is still current after its quarantine clear, before its merge check, as a written L1 does.
  • The refusal after resetHeadWindow()'s transition add says the transition message is stored, and that a later call that goes ahead adds another. It gives none of the advice to retry or to recover that the refusal before the add gives.

Two tests changed with main:

  • The carrier rejection they send is now typed as membrane types a 400, since main takes an untyped error as transient.
  • A strategy waiting on a chunk quarantined before its strategy was let go of now stops at main's ledger check instead of compressing the chunk again.

The description says what main does now and what this pull request changes on it.

Written by Paloma-2644.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Reviewed at 5eb6bf4 against main cfd3147 (the PR's merge base is c52386c).

Nightly dual review · round R2 at 5eb6bf4 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with 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 R2-n are stable; cite them in fix commits.

Verdict: approve. R1-1, the round-1 major, is fixed, and so are R1-2, R1-4 and R1-5. R1-3 is deferred to #241, as round 1 allowed. What's new is two nits.

The lifecycle story now holds end to end:

  • Once a strategy is let go of, by detach or re-initialization, its work in flight writes nothing.
  • None of that work touches the new generation's single-flight mark, its in-flight registry entry or its queue.
  • A reused instance runs one compression at a time and writes one merge per run.

We checked this three ways: by reading the code, by reverting each piece in turn against the tests, and with a probe of the reuse race. The PR merges cleanly into today's main and passes there.

One thing for the maintainers that isn't this PR's to fix: agent-framework never calls ContextManager.close() from stop() or from an ephemeral agent's cleanup (agent-framework #550). So detach() doesn't yet run for connectome-host's subagents.

Round 1, finding by finding

Round 1 was posted at c38597d, with findings R1-1 to R1-5.

R1-1 (major): fixed. A detached or stale tick no longer clears a reused instance's pendingCompression, and two concurrent compressions can't start.

  • Fixed on main, not here. tick()'s finally clears the mark only while it is still its own: L1 at src/strategies/autobiographical.ts:7100, merge at :7339. That came from main with #138, so this PR no longer carries it, as its description says.
  • Every continuation after an await is gated. Each place in tick() that resumes after an await checks currency before it can continue or start more work (:7060, :7081, :7094, :7105).
  • The registry entry is the producer's own. A producer deletes its in-flight registry entry only while the entry holds its own completion (:10813).
  • A waiter checks before it takes anything up. A waiter re-checks currency right after its wait (:9200, :9201), before it takes up the store's state (:9233).
  • No stale path reaches the take-up. takeUpStoreState() is reached only from callers that have just found the branch current: the waiter, the pre-call and post-call dedup lookups, and executeMerge()'s entry, after requireLoadedBranch().
  • The merge re-check comes before any write. The post-call check at :11817 runs after the currency check at :11805, and nothing awaits between it and pushSummary() at :11934.
  • Probe of the race. Reusing the instance through fork() gives the new generation its own L1 call while the detached one is out. Settling the detached call then leaves one call outstanding at head. With the :7100 guard reverted, the next tick starts a second.
  • The merge test is tied to its guard. The merge reuse test (test/strategy-detach.test.ts:1754) fails when the :7339 guard is reverted.

R1-2 (minor): fixed, and it behaves as documented.

  • Every listener-running write is wrapped. addMessage(), editMessage(), removeMessage(), removeMessages() and acceptRound() run inside write() (src/context-manager.ts:2127).
  • The deferred close is depth-counted. write() decrements in a finally and closes an owned store only when the outermost write returns. That covers throws and nested writes, such as an add from a receipt listener.
  • The strategy hears nothing after close().
    • The manager's own store listener is registered in the constructor (:297), so it runs before any host listener.
    • handleMessageAdd() skips the strategy once closed (:1991).
    • The edit and remove handlers call no strategy, apart from the hold-release notice, which is gated on closed (:796).
  • resetHeadWindow() re-checks after its add (:1770) and names the stored message.
  • Each piece fails a test when reverted: the deferral, the onNewMessage skip, and the post-add check.

R1-3 (minor): not fixed, by agreement; tracked in #241 (second half).

  • The documented behaviour matches the code:
    • The detach() JSDoc (src/types/strategy.ts:174) and the fixed fragment say a call already waiting on the provider finishes, with membrane's retries, and its result is discarded.
    • All five complete() call sites pass { formatter } only: autobiographical.ts:9416, :9474, :11761, :11788 and :16033.
  • The description's "Not covered" now reads membrane 0.6.0 correctly:
    • complete() passes options.signal through to the adapter call (src/membrane.ts:447–465 at v0.6.0).
    • A caller's abort becomes a non-retryable abort error (:546).
    • The backoff sleep between retries is abort-aware (:589).

R1-4 (nit): fixed.

  • addMessage() refuses before it reads its content or its hold options (context-manager.ts:525).
  • pinAtLevel() refuses by its own name (:1634).
  • After close(), getStoreId() (:1452) answers only from the cache or from an existing identity record, and never mints. The existingStoreIdentity()/storeIdentity() split in src/fold-journal.ts leaves minting where it was.

R1-5 (nit): fixed. The third bullet of changelog.d/209-strategy-detach.fixed.md covers the manager switchBranch() drain.

The three questions

Is R1-1 fixed? Can a detached or stale tick still clear a reused instance's pendingCompression and allow two concurrent compressions? No, it can't. The evidence is under R1-1 above.

Do close() from a synchronous listener (R1-2) and the abort signal on membrane.complete() (R1-3) behave as documented? Yes.

  • R1-2 does what close()'s JSDoc and the breaking fragment say.
  • For R1-3, what's documented is that the call is not aborted, and the code does exactly that.

Does the breaking fragment's migration advice match the code? Yes.

  • Why the condition is needed. A manager whose close() interrupted an initialization detaches the instance again once that initialize() settles (context-manager.ts:1818, :1826). If a new manager already held the instance, that second detach would make its generation stale.
  • A host can meet it. The host started those setStrategy(), switchBranch() and fork() calls, so it holds their promises and can wait for them to settle.
  • It is also enough. Nothing else the closed manager still has running can touch the instance:
    • the drain stops at its identity check (autobiographical.ts:6880);
    • every tick continuation is gated;
    • with R1-1 closed, a tick the host can't await needs no wait.
  • The refusals and answers match. Every operation the fragment says refuses goes through requireOpen() or requireInitialized(). Everything it says still answers is unguarded and reaches no strategy: describeRenderedForms(), setSystemPrompt(), setToolDefinitions(), releaseCompression(), and the message, branch and receipt reads.

Blockers

None.

Major

None.

Minor

None.

Nits

R2-1. The L1 reuse tests say they pin the single-flight mark, but they pin the hand-back.

  • Where: test/strategy-detach.test.ts:1705–1752, "an instance set again (reopen / setStrategy) while its detached compression's call is out starts no work beside the new generation's". The comment says the detached tick settling "must leave [the new generation's marker], or the next tick starts work beside the new tick's".

  • What the reverts show:

    • With the L1 guard at autobiographical.ts:7100 reverted to an unconditional clear, all 206 tests in the four touched test files pass, these two included.
    • With the hand-back at :9257 removed, both fail.
  • Why: at reuse, the new generation's first tick picks the same chunk under the same in-flight key, so it joins the detached call instead of making its own. When the detached tick settles, the new generation holds no call of its own whose mark could be cleared.

  • The guard is still covered: main's test/compression-recall-curve.test.ts:1619 (from #138) fails when it's reverted. So this is about what the test says it covers, not a gap in protection.

  • Fix: add a variant where the new generation has its own call out, or reword the comment to say these tests pin the hand-back. Reuse through fork() gives that variant, since the in-flight key carries the branch name:

    1. Seed 24 messages.
    2. tick() and hold call 1.
    3. fork(), compile(), tick(), and hold call 2.
    4. Release call 1 and await the detached tick.
    5. tick() again and assert two calls.

    We ran this probe: it passes at head and fails with the :7100 guard reverted.

R2-2. A failed detach(), and a failed deferred store close, are logged without the manager's namespace.

  • Where: detachStrategy() logs with console.error (context-manager.ts:1528). The deferred close after a listener's close() uses logThrown() with its default console (:2138).
  • The sibling it's said to match: the description says a throwing detach() is logged "as a failed onCompressionHoldsReleased() is". That one goes through this.diag (:807), which prefixes the manager's strategy namespace (src/diagnostics.ts:38–49).
  • Why it matters: in a host with several agents, each manager has its own namespace, but strategy.name reads autobiographical for all of them. A detach failure doesn't say whose strategy failed.
  • Fix: use this.diag.error('Strategy detach failed:', strategy.name, err) and logThrown(…, error, this.diag), and adjust the tests that read console.error's arguments.

Verified fine

  • Merge with today's main: the PR merges cleanly into main cfd3147, which adds #252, #273 and the 0.17.x releases since the merge base. They touch none of the guarded paths.

    • On the merged tree, tsc is clean, and the full suite (node --test, as npm test runs it) passes 2,467 of 2,467 on Node 22.21.0.
    • CI at head: 6 of 6 checks green.
  • Each piece reverted: we reverted 14 pieces one at a time and ran the four touched test files each time. Every piece but the L1 guard (R2-1) fails a test of its own:

    • the merge mark guard;
    • the post-call merge re-check;
    • the store read at the merge's entry;
    • the threshold pass after a take-up;
    • the join hand-back;
    • the stub's currency check;
    • the drain timer's identity check;
    • detach() itself;
    • the pre-call check on each attempt;
    • the deferred owned-store close;
    • the closed-manager onNewMessage skip;
    • resetHeadWindow()'s post-add check;
    • the closed check after initialize().

    The four files hold 206 tests (59 + 71 + 19 + 57), as the description says.

  • Generation-first checks: isBranchIdentityCurrent(), isCompressionBranchCurrent() and logCompressionBranchDiscard() compare the generation before they read the store, so a detached strategy never reads a store its manager has closed.

  • The quarantine sweep, the clear after a compression, and the alarm compare the identity they started with after each await (autobiographical.ts:4896, :5027, :5139).

  • Shared-work hand-back:

    • discarded is set only when the producer settled without an error and its generation had moved.
    • A waiter looks for an L1, then for a quarantine of the request family, before it hands the chunk back. So a producer that wrote its summary before being detached is still taken up.
    • requeuePendingChunk() defers to a rebuild made during the wait.
    • The put-back counts as drain progress, so a drain runs the next scan.
  • The merge take-up: parentedMergeSources() replaces the queue only through takeUpStoreState(), and tick() dequeues only while the head is still the same entry. So a replaced queue isn't shortened by mistake.

  • Initialization races: a close() or a replacement during initialize() detaches the strategy again whether initialize() returns or rejects. A newer initialization of the same instance is left alone.

  • Chronicle 0.4.0 close semantics: close() takes its handle before the final sync (src/napi.rs:386–389), and is_closed() answers true afterwards (:398). So a second close() returning at once is right even when the first one's sync threw.

  • Downstream callers:

    • agent-framework main (f8c23e13) calls ContextManager.close() only in offline-branch recovery (src/recovery/offline-branch.ts:361), followed by store.close().
    • Neither agent-framework nor connectome-host main (3d32cb5) calls setStrategy() or ContextManager.isClosed().
    • connectome-host's FrontdeskStrategy extends AutobiographicalStrategy but overrides none of tick(), compressChunkHierarchical(), executeMerge() or detach(). So it inherits detach() and the new optional callback unchanged.
    • Nothing downstream uses a manager after close(), so "close() is final" breaks no current caller.
  • Not this PR's, already tracked: agent-framework's ephemeral-agent cleanup() is empty, and stop() closes no ContextManager (agent-framework #550). connectome-host's subagents run KnowledgeStrategy with autoTickOnNewMessage, so their drains go on after release until #550 lands.


Method. Two reviewers ran in parallel, and every claim was re-checked against source before inclusion.

  • Codex (gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network): about 12 minutes and 81 shell commands, with 9.4M input tokens (9.0M cached) and 34K output.
    • It read the PR head, the PR merged into main cfd3147, membrane v0.6.0, chronicle v0.4.0, and agent-framework and connectome-host main.
    • It could not run tests.
  • Claude (Opus 5.5): fetched the refs and merged the PR into today's main. It ran the typecheck and the full suite there, then the single-piece reverts against the four touched test files, plus a full-suite run with the L1 guard reverted. It also wrote the fork() reuse probe.
  • Who found what:
    • Round-1 dispositions: both, independently. Both found R1-1, R1-2, R1-4 and R1-5 fixed and R1-3 not fixed.
    • R2-1: Claude only. It corrects Sol's reading that the L1 reuse test holds both generations' calls: the reverts show it holds only the detached one.
    • R2-2: Claude only.
  • Rejected as a finding against this PR: Sol rated agent-framework's no-op ephemeral cleanup a major. Its evidence is accurate, but the code is agent-framework's, and it is already filed as agent-framework #550.

@nissa-seru
nissa-seru merged commit bf76011 into anima-research:main Oct 20, 2026
7 checks passed
nissa-seru pushed a commit that referenced this pull request Oct 20, 2026
Answers R2-1 and R2-2 of the maintainers' second review of #215.

- R2-2: a strategy's detach() that throws or rejects, and a failed
  close of a store the manager owns after a listener's close(), are now
  logged through the manager's diagnostics, as a failed
  onCompressionHoldsReleased() is. Each line's message ends with the
  manager's namespace (ns=<namespace>), and the values logged after it
  print after the suffix, so where each agent's manager has a namespace
  of its own, as agent-framework gives each agent agents/<name>, the
  line says whose strategy failed. The tests of these lines open their
  managers under namespaces of their own and look for them, the
  store-close test at the end of its message. diagnostics.ts's header
  now says where values logged after a message print.
- R2-1: the tests of an instance started again while its last
  generation's compression call is out now say what they pin. A reopen
  and a setStrategy() start the new generation on the same branch,
  whose first compression joins the last generation's call: they pin
  the hand-back of that thrown-away work. A new fork() variant starts
  it on another branch, whose in-flight key differs, so its own
  compression is under way, its tick holding the single-flight marker,
  when that call settles: it pins the L1 tick's clear of only its own
  marker, and fails with that check removed, as the merge test does
  with the merge tick's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 20, 2026
The second half of anima-research#241. A strategy that is let go of (by close(), by
a setStrategy() that replaces it or sets it again, or by an open()
whose initialization of it fails), or that the manager's own
switchBranch() initializes again, already keeps its work from writing
(anima-research#215). But a compression, merge or transition-summary call already
out ran on, through membrane's retries and the waits between them, to
an answer the strategy threw away.

- The calls end. Each of AutobiographicalStrategy's five complete()
  calls goes through callProvider(), which gives it an AbortController
  of its own, kept in callsOut until the call settles. endGeneration(),
  which initialize() and detach() share, ends the generation and then
  aborts every call still out. It's a controller per call rather than
  one signal per generation, because a generation lasts the session
  and @anthropic-ai/sdk 0.52.0 leaves an abort listener per request on
  the signal it is given. The merge and the transition summary check
  their work's currency again after preparing the request, as each L1
  attempt does, so a generation that ends meanwhile makes no call.
- A stale error is discarded as a stale result is. runAttempt's first
  catch throws its CompressionBranchDiscard for an error on work that
  lost its currency, before the carrier fallback, so a split's part
  abandons nothing on it and no stripped retry follows. That replaces
  the check before the stripped retry; ladderStep already discards a
  rung's stale error (anima-research#138). executeMerge returns false for one at
  either of its calls, logging no failure, and a transition summary
  throws its "crossed a branch generation" error, with the call's
  error as its cause. A host's withdrawal (CompressionWithdrawn) keeps
  its own path on any branch.
- A host's cancellation takes a withdrawal's path at every site. A
  host that combines a signal of its own with the one the strategy
  passes, and aborts it, gets its cancellation back as itself, and
  compressionFailureKind() already classes it as an 'abort' (anima-research#138).
  The sites that took a withdrawal's path by the brand now take it by
  that kind, for an error that passed its work's currency check: the
  L1 compression's final catch (after its own check) and executeMerge's
  catch log no failure and receipt it withdrawn, a split's part counts
  it as neither a call nor an error, tick's merge catch rethrows it
  with nothing counted, and the speculative drain stops quietly. The
  stale checks keep the brand, so on a moved branch a withdrawal is
  still receipted withdrawn, while a cancellation, which the
  strategy's own end also makes, is discarded.

Docs: CompressionWithdrawn's says how a host ends a call already out,
StrategyContext.membrane's that a wrapper passes the signal on, and
ContextStrategy.detach's that a strategy can cancel its calls. anima-research#209's
fragment no longer says how a call already out ends.

Tests pin the calls ended each way a strategy is let go of, a
cancellation at each site where it changes an outcome, and the stale
errors discarded. Two more check that a take-up at an exact-L1 find
and at a coalesced wait queues the merge the store lacked, which only
a merge's take-up was tested for.

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

greptile no-greptile Maintainers' and the team's: Greptile skips this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

setStrategy leaves the replaced strategy's in-flight compression and drain running

2 participants