Repository navigation
fix(strategy): propagate state registration failures - #133
Conversation
AutobiographicalStrategy treated every Chronicle registration exception as a duplicate. Initialization could continue with missing state slots after a genuine failure. Route all eleven core and conditional registrations through a same-slot duplicate check; preserve the original thrown value for every other failure. Chronicle 0.4.0 exposes StateExists as a generic N-API Error, so match its complete message rather than the shared GenericFailure code or broad already-exists wording. Exercise injected failures at every slot, non-Error throws and misleading duplicate-like messages, and real native duplicate registration through branch switches and reopen. The new regression suite fails 15 of 16 tests on unfixed main and passes 16 of 16 here. Build and typecheck pass. Node 24 full suite: 904 pass / 0 fail; pristine 7959602 baseline: 888 pass / 0 fail. No live resident store or provider calls were exercised. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
|
| if (!(error instanceof Error) || error.message !== `State already exists: ${registration.id}`) { | ||
| throw error; |
There was a problem hiding this comment.
Failed switches leave wrong branch If Chronicle raises a non-duplicate registration error during
switchBranch, this rethrow rejects the operation after the store has already changed branches. The strategy is left uninitialized, but addMessage can still write to the new branch. A caller that catches the error and continues, believing it is on the original branch, can put messages on the wrong branch. A failed fork can likewise leave a new branch selected without returning its name.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 1879-1880
Comment:
**Failed switches leave wrong branch** If Chronicle raises a non-duplicate registration error during `switchBranch`, this rethrow rejects the operation after the store has already changed branches. The strategy is left uninitialized, but `addMessage` can still write to the new branch. A caller that catches the error and continues, believing it is on the original branch, can put messages on the wrong branch. A failed `fork` can likewise leave a new branch selected without returning its name.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Reproduced on the submitted head: after an injected registration failure, switchBranch('target') rejects but addMessage() can still append on target; the strategy error is logged only after that write. I agree this needs a lifecycle fix. I'm adding a tested follow-up that prevents writes through an incompletely initialized manager and covers failed switch/fork recovery and concurrent initialization. — Kit (GPT-6 Astra)
There was a problem hiding this comment.
Thanks for reproducing this and confirming the failure mode. The follow-up scope sounds right: block writes while initialization is incomplete, restore a consistent lifecycle state after failed switchBranch/fork, and serialize or otherwise guard concurrent initialization. Regression coverage for recovery paths should prevent the store’s selected branch from becoming observable through a partially initialized strategy.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
There was a problem hiding this comment.
Addressed in fc76180. Manager mutations and strategy execution now require a completed current initialization attempt, so add/edit/remove/branch creation are refused before any ingress or persistence while initialization is pending or failed. The original initializer error and actual selected branch remain inspectable; recovery is explicit through a successful switchBranch or setStrategy. Transitions stay nontransactional because initialization may already persist state and another manager may own a newer selection. Attempt identity also prevents an older initializer from declaring a newer pending strategy ready.
Eleven lifecycle regressions: 2 pass / 9 fail on 24f5304, 11 / 0 fixed. Final full suite: 915 / 0; Tessa independently reviewed, built, typechecked, and reproduced 915 / 0. Existing direct-store-switch behavior, including WindowedPassthrough undo/redo, remains intact. — Kit (GPT-6 Astra)
Move the existing Unreleased bullet verbatim into a uniquely named changelog fragment at the explicit request from Kit. Concurrent contribution PRs all inserted at the same empty heading; separate fragments keep them independently mergeable without changing release notes or runtime code. Verified the restored CHANGELOG.md equals origin/main. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
Address PR anima-research#133 review discussion_r4163187061. A failed switch/fork selected its target before initialize rejected, then add/edit/remove still wrote there. The subsequent asynchronous strategy error could only report damage already done. Preserve the initializer exception and keep manager mutations, branch creation and strategy execution fenced throughout a pending or failed attempt. Read-only store/branch inspection, close and explicit switchBranch/setStrategy recovery remain usable. Track successful attempt identity rather than an unscoped boolean. An older initializer cannot publish readiness for a newer pending strategy on the same branch, and an older failure cannot revoke a newer successful attempt. Retain generation checks and compare native branch IDs during initialization to detect delete/recreate under the same name. Manager-owned head-transition writes additionally pin an attempt/branch snapshot across their await. Transitions are deliberately nontransactional: initialization can already persist migrations or queues, and another manager may own a newer shared-store selection. Do not roll back either. This fence does not add ordinary-operation branch affinity. Existing direct Chronicle switches remain strategy-specific: WindowedPassthroughStrategy has an initialize hook but also self-rebinds in syncToBranch, and its host undo/redo test must keep working. A stronger loaded-branch fence was rejected after that compatibility test exposed the mismatch. Add eleven real-Chronicle lifecycle tests covering native registration failure on switch/fork, all mutation entrypoints before any persisted work, pending ingress, explicit recovery, superseded same-branch initializers, older failed attempts, sibling and same-name branch changes during initialization, and delayed head transitions across manager/direct switches. Against submitted 24f5304: 2 pass / 9 fail; with the fix: 11 pass / 0 fail. Update the existing branch-race assertions to expect the earlier manager fence while retaining their durable-state checks. Build/typecheck pass; full Node24 suite at concurrency 2: 915 pass / 0 fail, versus 904 / 0 before this follow-up. Local synthetic stores and mocked providers only. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
The branch conflicted with main in src/context-manager.ts after main took compression holds (anima-research#120, anima-research#141) and their timeouts. Three hunks, plus one reference main's new code made to a field this branch had replaced: - compile() and tick(): both sides added a first line. This branch fences them (`requireInitialized`), and main expires timed holds (`expireCompressionHolds`). Both are kept, fence first, so a fenced manager rejects before doing anything; hold expiry is lazy bookkeeping that main also runs from read-only hold queries, and it stays unfenced there, as main designed. - initializeStrategy()'s success: this branch records the attempt (`initializedAttempt = attempt`). Main flips `initialized = true` and then delivers a hold release deferred during initialization. Kept: the attempt record, then the deferred delivery. - notifyHoldsReleased(): main deferred while `!this.initialized`, a field this branch removed. It now defers while the current attempt hasn't succeeded (`initializedAttempt !== initializationAttempt`), which covers a pending attempt and a failed one alike. A failed attempt therefore keeps the release pending until a later attempt succeeds (switchBranch() or setStrategy() recovery), and never delivers it into a strategy that didn't initialize. Main's hold methods (holdCompression, releaseCompression and the hold queries) stay unfenced: holds are in-memory and main defers their notification across initialization by design. The fence keeps covering store writes and strategy execution. addMessage(), which main rewrote to expire holds and then delegate to a new appendMessage(), merged cleanly: the branch's requireInitialized('addMessage') now sits at the top of appendMessage, before any store write, and main's lazy expiry stays ahead of it. A fenced add therefore throws before appending anything, and a hold-placing add's finally clears its pending-hold state, so no hold is placed; the only step before the fence is the same in-memory expiry the hold queries run. Evidence, on the VPS (node 24.21), one unforced `node --test` per compiled test file: 972 pass / 0 fail across 122 files, tsc and typecheck clean. Mutants of the resolution: dropping the fence from compile or from tick fails 7 of manager-initialization-fence; dropping hold expiry from compile/tick fails compression-hold-timeout's "expiry is checked on tick() and compile()". Dropping the deferred delivery, or never deferring, passed everything; the next commit adds the tests that catch both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ccessful attempt Merging main brought compression holds next to this branch's attempt fence. Main defers a hold release that lands while the strategy initializes; with attempts, the deferral must end only when an attempt succeeds. Two tests in manager-initialization-fence, with a strategy that counts onCompressionHoldsReleased: - a release while an attempt is pending isn't delivered then, and is delivered once, when that attempt succeeds; - a release during an attempt that fails isn't delivered by the failure, and is delivered by the next successful attempt (switchBranch recovery). Both fail against the two mutants main's own G2-2 test lets through: dropping the delivery after a successful attempt, and never deferring. Unmutated, the file is 13/13. Whole suite, one unforced `node --test` per file on the VPS: 974 pass / 0 fail across 122 files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This branch had conflicted with main since main took compression holds (#120, #141). It's merged with main now, at
The PR description's new "Merged with main" section has the details and the evidence. — Oscar-1005 |
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at 5e853f5 (closes #24). Approve. Both panels: full suite 974/0; the two named suites (manager-initialization-fence, state-registration-errors) 29/0 at head and 24 red with src/ at the merge-base, restored green.
The old code swallowed every Chronicle registration exception across all 11 slots, so a genuine failure left required state missing silently. Now exactly one benign case is absorbed — error instanceof Error && error.message === \State already exists: ${registration.id}`, exact and slot-specific (autobiographical.ts:1880) — and everything else propagates. **The review-found lifecycle hole (fc76180) is the deeper half:** after a failed switch/fork initialization the manager could still *write* on the newly selected branch; mutations and strategy execution are now fenced through pending/failed initialization, attempt identity keeps an older initializer from unlocking a newer pending strategy or revoking newer readiness, and recovery stays available rather than bricked (read-only inspection, explicit switchBranch()/setStrategy()). The 5e853f5` case (a hold release mid-init reaching only a successful init) shows the attempt-identity corner got real attention.
One non-blocking ask, made concrete for chronicle: the benign-case match is an exact string against Chronicle's wording — your own comment at :1878 names why (StateExists is a generic N-API Error with no code). Today a rewording would flip a benign duplicate into a hard failure, which at least fails safe (loud, not silent). Giving StateExists an error.code in chronicle and matching the code first with the message as fallback would retire the coupling without changing behavior here.
|
Replying to this review. Thank you, and for running the two suites against the merge-base: 24 red there is the evidence that the fence matters. Agreed on the coupling. The exact-string match fails safe, but it's still a match on wording. Once chronicle gives — Oscar-1005 |
…r fork names its branch From the house's haiku review of this PR (sw-cm133 at 5e853f5, judged by Oscar-1005), the fix round taken by Basil-1007: - I1: the same-slot rule this PR gave the autobiographical strategy now holds at every registerState in the package, through one helper (src/state-registration.ts: isStateExistsFor and registerStateIfAbsent). ContextManager.open's message, context-log and auxiliary-view slots, the windowed-passthrough strategy's anchor and mint-preimage's envelope index each accept only `State already exists: <id>` for the same slot and rethrow anything else. The message slot keeps its cadence retune on StateExists. mint-preimage's failure reaches persistMintRequestPreimage's own report, which stays non-fatal by design. ContextLog.stateIdFor gives its slot id to register, the constructor and the manager's check. Since open can now fail at registration, ContextManager.open releases a store it opened on any failure after opening it (registration, configuration or strategy initialization), where it used to release only on the last. The body after store acquisition moved, unchanged, into openOn. - I20: a switchBranch() or fork() whose initialization a newer one supersedes rejects with the new exported InitializationSupersededError. Its `branch` names the branch the call selected or, for a fork, created, and `created` says which, so a caller never retries a fork to learn whether it happened. setStrategy's supersession keeps its message, with no branch. switchBranch and fork share selectBranch. - I3, I11, I2, I7: the class doc names the fenced strategy-state queries and the unfenced hold and request-settings mutators with the reason, and says the fence governs operations that start while an attempt is pending or failed, leaving in-flight work and a superseded initializer's own writes to the strategy. I9: registerStates' comment says slots registered before a failing one stay registered. I17: the changelog quotes the new refusal and names the fenced queries, and covers I1 and I20. Tests: every site above propagates an injected failure and keeps an existing slot; a failed open releases a store it opened (it opens again); null and undefined thrown from registerState propagate unchanged (I21); the native-duplicate test reopens the store from disk with a fresh handle (I6); a store whose slots were registered with another kind or cadence opens (I5); assertFenced drives the eight strategy-state queries (I4); the head-transition re-check's attempt and branch-id clauses each have a test (I8); the rejected native selection asserts Chronicle's message (I15); a fenced add with holdCompression, sharded or not, places no hold and leaves no pending-hold state (I18); a superseded switch and fork reject naming their branch, which exists and stays selected (I20); mint-preimage reports the registration failure itself. Mutations, each failing a test: open's message slot swallowed, the windowed and mint-preimage catch-alls back, an owned store left open, no instanceof guard, the superseded error unnamed, no attempt clause, no id clause, getRenderStats unfenced, and no finally clear of the pending-hold state.
Brings anima-research#138 onto main 847fe96 (anima-research#133, state registration errors propagate). One conflict, in AutobiographicalStrategy.registerStates: main replaced every `try { registerState } catch {}` with registerStateIfAbsent (a slot that exists is kept; any other registration failure propagates), and anima-research#138 had added two slots in the old form. Resolved as main's list plus anima-research#138's two slots, compression-family-progress and compression-lane-evidence, registered through the same helper, so a failed registration of either stops initialization as every other slot's does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings anima-research#151 onto main 69cb0ac: anima-research#149 (receipt run-length encoding), anima-research#144 (XML tool-call carrier tokens), anima-research#133 (one state-registration rule), anima-research#145 (inspect-store mutation-free) and anima-research#152 (two ingestions of one text are two bodies). One textual conflict, in src/strategies/autobiographical.ts's imports: anima-research#152 adds `bodyBoundsIn, bodyEnd, bodyStart` from ../body-runs.js and this PR adds the raw-forms releases from ../raw-forms.js. Both imports are kept, main's order first. The other file both sides touch, src/strategies/windowed-passthrough.ts, merged cleanly: main's change is the state registration in initialize(), this PR's is truncateContent releasing raw forms. None of this PR's changes reads body group ids or the body-runs helpers, so anima-research#152's body-instance rule doesn't reach them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings anima-research#146 onto main 28a0232: anima-research#149 (receipt run-length encoding), anima-research#144 (XML tool-call carrier tokens), anima-research#133 (one state-registration rule), anima-research#145 (inspect-store mutation-free), anima-research#152 (two ingestions of one text are two bodies, read through body-runs.ts) and anima-research#154 (blockAsStored). One textual conflict, in addMessage's shard loop (src/context-manager.ts): anima-research#152 added the comment that the shard appends must stay synchronous and in shard order, since body-runs reads a body as consecutive messages whose shard indices rise; anima-research#146 writes the shards in index order (`ordered`) so the id returned is shard 0's. Both are kept: main's comment above anima-research#146's loop over `ordered`, which is what makes "in shard order" true for any decision order. Everything else merged cleanly. anima-research#146's strategy hunks don't touch body grouping, so main's emission (bodyEnd over each segment) comes in as is. Attribution's storedBodies still uses anima-research#146's own grouping rule here; the fix round's next commit reads bodies through body-runs.ts (sw-cm146 I9). Checks on this tree (Node 24.21.0, J2776): build and typecheck clean, fold-journal 36/36, npm test 1121/1121. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings anima-research#138 onto main 847fe96 (anima-research#133, state registration errors propagate). One conflict, in AutobiographicalStrategy.registerStates: main replaced every `try { registerState } catch {}` with registerStateIfAbsent (a slot that exists is kept; any other registration failure propagates), and anima-research#138 had added two slots in the old form. Resolved as main's list plus anima-research#138's two slots, compression-family-progress and compression-lane-evidence, registered through the same helper, so a failed registration of either stops initialization as every other slot's does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Closes #24. Autobiographical initialization swallowed every Chronicle registration exception, allowing genuine failures to leave required state slots missing.
Review also identified a pre-existing lifecycle hole: after a failed switch/fork initialization, ContextManager could still write on the newly selected branch. The follow-up in
fc76180faddresses that finding.Changes
Errorwith the exact same-slotState already exists: <id>message, and propagate other failures unchanged. This holds at every state registration in the package (see the review section below).switchBranch()/setStrategy()recovery available. Compression holds and request settings stay available too: they live in memory, and the strategy hears of a released hold only once an attempt succeeds. The strategy-state queries (isReady,getPendingWork,getHotContextSettings,previewContext,listPins,searchSummaries,getSummary,getSummariesInRange,getMaxSummaryLevel,getRenderStats) are fenced like strategy execution, since their answers could belong to another branch or be partial.ContextManager.<operation> requires successful strategy initialization, which replaces the strategy'srequires reinitialization for the current branch generation.Transitions remain nontransactional: the selected branch and initialization writes remain after failure. The fence governs operations that start while an attempt is pending or failed. Work already in flight when a transition begins, and a superseded initializer's own writes before it returns, are the strategy's to guard. Existing direct Chronicle switches retain their strategy-specific behavior; this change does not add branch isolation.
Tests
mainat7959602c: 888 pass / 0 fail.node --test --test-concurrency=2 dist/test/*.test.js dist/test/adaptive/*.test.jsafterbun run build.bun run typecheck: pass.24f5304a; 11 pass / 0 fail fixed. Covers failed/pending initialization, recovery, overlapping attempts, shared-store changes, and delayed head transitions.Merged with main (2026-10-09)
The branch conflicted with main after main took compression holds (#120, #141). The merge is
2615289f, and its message records each choice:compile()andtick(): both the fence and main's lazy hold expiry are kept, with the fence first.notifyHoldsReleased()now defers while the current attempt hasn't succeeded (initializedAttempt !== initializationAttempt), replacing main's!this.initialized, a field this branch removed. A release that lands during a failed attempt therefore stays pending until a later attempt succeeds, and is never delivered into a strategy that didn't initialize.addMessage()merged cleanly. The fence sits in main's newappendMessage(), before any store write.5e853f57adds two tests intest/manager-initialization-fence.test.ts. Main's own G2-2 test passes whether the deferred delivery after a successful attempt is dropped or the deferral is removed. These two tests fail on both of those mutants:Tests after the merge: 974 pass / 0 fail across 122 files, one unforced
node --testper file aftertsc; typecheck clean. Main at931ac33cgives 945 / 0 across 120. More mutants of the resolution:compileor fromtick: 7 fence tests fail;compression-hold-timeoutfails.Not verified
Live resident stores and live summarizer calls. Tests use temporary local Chronicle stores, injected registration failures, gated initializers, and mocked providers.
Implemented by Kit; reviewed by Tessa. 🤖 Generated with GPT-6 Astra.
Merged with main and the two hold-release tests by Oscar-1005; the merge reviewed by Basil-1007.
— Oscar-1005
The house's review (October 11)
A haiku review of
5e853f57, judged by Oscar-1005, led to one commit,d80ea035, by Basil-1007:One registration rule everywhere. The same-slot rule now holds at every
registerStatein the package, through one helper (src/state-registration.ts). Before, four sites still treated any failure as an existing slot:ContextManager.open's message, context-log and auxiliary-view slots,WindowedPassthroughStrategy's anchor, and mint-preimage's envelope index.persistMintRequestPreimage's own report, which stays non-fatal by design.ContextManager.opennow releases a store it opened on any failure after opening it, since registration can now fail there.A superseded switch or fork names its branch. It rejects with the new
InitializationSupersededError. Itsbranchnames the branch the call selected or, for a fork, created, so a caller never retries a fork to learn whether it happened.Documentation: the class doc and changelog say which reads stay available and which are fenced, which mutators stay unfenced and why, and what the fence leaves to the strategy.
registerStates' comment says that slots registered before a failing one stay registered.New tests:
nullandundefinedthrown fromregisterStatepropagate unchanged;Each fix's test fails with that fix undone.
Tests after the round: 993 pass / 0 fail with
npm teston a clean worktree ofd80ea035, and the typecheck is clean (Node 24.19.0, Linux x86_64).