Repository navigation
fix(store): let every state registration fail for anything but "exists" - #424
Merged
nissa-seru merged 4 commits intoOct 18, 2026
Merged
Conversation
Twelve of the seventeen places that register a state wrapped registerState in a catch that swallowed every failure as "already registered". Only a state that already exists is safe to swallow (a reopened store holds it). isStateExistsError said so, three sites used it and two more tested its message inline, but the twelve also swallowed a closed store or an I/O error. Each such failure then surfaced at a later read or write of that state, under that call's name. ensureStateRegistered, beside isStateExistsError, registers a state and rethrows every failure but "exists", and every registration in src now goes through it: - create(): the framework, deferred-write, coalescing, inference-log, turn-checkpoint, process-log and conversation-router states; - ModuleRegistry.addModule() and a module's registerLogState(); - CheckpointManager's checkpoint state and a feature set's host state; - ChannelRegistry's lifecycle and label-history logs (which tested the message inline) and the tune-out coordinator's state; - the workspace's tree and intent states; - the tool-result guard's audit log. A failure is now thrown where it happened. Chronicle's error codes (chronicle#31) aren't released, so the helper still matches the message; when they are, it changes in one place. Tests: each entry point fails with an injected I/O error for its state; the helper accepts an existing state and rethrows anything else; and a scan of src finds no registerState call outside the helper.
…ls leave nothing behind From our internal review of the first commit. The helper's rethrow now names the state, "Registering state '<id>' failed: <the store's message>", with the store's error kept as its cause. Before, create(), addModule() and initStore() rejected with the store's bare message, which named no state. CheckpointManager.registerFeatureSet registers a feature set's host-state slot, and reads its stored value, before it records the feature set. It recorded it first, so a registration that failed left the record behind, and every later call, a reconnect's re-run included, returned at the already-registered check without registering the slot or loading the stored state. The workspace's initStore registers every mount's states before it takes the store or any mount. It took them one mount at a time, so a failure at a later mount left the store and the earlier mounts set, with no agreement, saved state or watchers. registerLogState's doc says its cadence applies when the state is first registered, and that a failed registration leaves the name unregistered. Tests: the helper names the state and keeps the cause; a real closed store's failure is thrown, not taken for "already exists"; the store's own answer to a second registration is the one the helper takes as done; a feature set registered after a failed call, with its stored state; an initStore that fails takes no mount, and the next takes them all; a module's log state and the tool-result guard's audit state, each registered on first use, register on the next use after a failure. The fragment says which two calls leave nothing behind, that the tune-out coordinator's state fails where it's written, and that a failure surfaced later only if a later call came. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no new blocking issue was found. SummaryRoutes state registrations through
Tests were inspected, not run. Reviews (2) · Last reviewed commit: "fix(store): a failed registration names ..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to ajaniramon/agent-framework
that referenced
this pull request
Oct 17, 2026
…ir and the anchor for them The internal review of this push found texts that still describe the old marker set or say markers never influence routing, and pieces no test pinned. - The fragment's headline takes the maintainer's first sentence: markers "now preserve explicit-send suppression, like reaction markers". "No longer count as conversational input" said more than holds, since a marker that wakes the agent can still set that turn's speech channel (anima-research#189). - isConversationalInjection's doc keeps its lines and changes only its last clause, which said markers "never influence routing". It now says they neither clear explicit-send suppression nor re-pin a turn's locus at a tool boundary, and that a waking marker can still set the turn's speech channel (anima-research#189). - channel_publish's description and its channelId parameter say an omitted channel means the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion, which is what markAccepted tracks. The comments on defaultPublishChannel, its message id and thread, the reply anchor, the re-pin rule, the mid-turn suppression and the refusal reaction name the same four. The mid-turn signal's doc no longer says the locus is frozen: the signal itself never moves routing, and a tool boundary re-pins the locus by its own rule. - The tags.js import moves below log-value.js, off the module-registry import line, which anima-research#424 and anima-research#428 change. - Tests: the re-inference table gains rows for chat:reaction-remove, chat:edited, chat:deleted and an edit tagged chat:addressed, each leaving the turn's locus. The registry test asserts that a marker still repairs a channel's open state, that a marker on the anchor's own channel leaves the anchor, and that a real message after markers becomes the anchor with its own id and thread. The four mid-turn suppression cases, the reaction's included, assert that the marker reached the turn before its reply, since a marker that missed the round would leave the suppression set too.
Two conflicts, both where main moved code this change also edits: - src/mcpl/checkpoint-manager.ts: main (anima-research#454) builds a feature set's tree and reads its host-managed state through loadHostState before holding it. This change's registration-first order is kept by that structure: registerFeatureSet takes main's form, and loadHostState registers the state's slot through ensureStateRegistered instead of main's catch-all, so a failed registration throws naming the state and leaves no tree, at registration and before a connect alike. - src/modules/workspace/index.ts: the import lines. main's observe.js names (anima-research#460) with this change's ensureStateRegistered import. After the merge every registerState call in src goes through ensureStateRegistered (17 sites, as before: main added none). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…what that stops loadHostState's refusal says what a failed read of a host-managed state stops: before a connect, that create() and connectMcplServer() don't go on; at registration, that the server's connect fails. Its registration ran ahead of that, so a failed registration threw only the helper's "Registering state '...' failed: ...", naming the state without saying what it stopped. It now runs inside the same try, and the refusal reads "could not be registered or read", with the helper's error as its cause and in its text. The two tests that pin the refusal follow: the host-state registration tests now check the refusal and its cause, and the read refusal's expected text says "registered or read". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main's anima-research#424 (state registrations) imports ensureStateRegistered from the module registry in workspace/index.ts, beside this change's longer import from observe.js. The one conflict is that import pair, and the resolution takes both sides' names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
and anima-research#430 main now holds anima-research#424 (F34), which this branch was stacked on, and anima-research#430 (F62), which shares one spawn step between the two spawn paths. Three files conflicted: - src/framework.ts: both spawn paths take anima-research#430's `await decision.spawned`. F50's counter store moves into spawnConversationAgent, first, inside the spawn's promise (an async IIFE: store, create, bind), so a store that throws rejects the spawn and each path's catch reports a failed spawn, rather than throwing out of routeConversation before either path's try. persistConversationRouterState has no caller left and goes. - src/tune-out/coordinator.ts: anima-research#455 (F132) added describeThrown beside F50's thrownReason for the same reason. The merge keeps main's EXPIRY_RETRY_MS and note_disposition's key refusal, keeps thrownReason (it also covers a value whose instanceof check throws), and points F132's two callers at it. - test/mcpl-rejection-log-lines.test.ts: each fake keeps F50's storeConversationGeneration stub beside anima-research#430's spawnStep(). test/tune-out-timers.test.ts merged cleanly, and its test of a value with no string form now expects thrownReason's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…search#429's text main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429 rewords agent-framework's channel texts to say only what holds on every MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's text, as both pull requests' descriptions say. Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words under this change's rule (speechOpens decides what a reply does): - The closed-channel invitation: anima-research#429's opening ("a channel you haven't opened. Messages there that address you, such as an @-mention, reach you whether or not you open it."), its option 2 ("Open the channel") and its `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a reply opens the channel it says so in anima-research#429's words, "which opens the channel", then how to reply without opening it (decline first). Where the agent closed or declined the channel itself, it says "and the channel stays closed", with no follow-ups note: what else reaches the agent there is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for discord-mcpl). The no-target hint keeps this change's send-tool advice, in the same words. - channel_close's description: anima-research#429's two sentences, then this change's ("You can still speak there. It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it."). - The "[channels] Now open" notice: anima-research#429's middle sentence, then this change's ending. - Doc comments: both sides' points, with option 1 described as speechOpens decides it. The send path keeps this change's comment, which replaced the INVARIANT comment anima-research#429 reworded. - trunk-channel-routing.test.ts: both sides' tests. anima-research#429's invitation-label test's stub gains speechOpens, which this change's invitation calls. Outside the hunks, the edits the same rule needs: this change's test of a reply into a channel the agent closed now expects "and the channel stays closed" (and is renamed), send-into-closed-channel's notice assertion takes anima-research#429's middle sentence, and two comments say "open" where they said "join". Train G's other merges touched framework.ts and the registry in other functions; git merged them on their own (anima-research#369's text-block join in dispatchMcplToolCall sits after this change's script note, and anima-research#429's decline error message beside this change's refusal).
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main had taken 24 merges since 86a3683, trains F and G among them: anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors), anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369 (silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430 (one conversation spawn). Three conflicts: - src/framework.ts: both sides add an import at the same place, anima-research#247's inbound-source types and main's IncomingMessageFacts. Both are kept. anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which initializeMcpl now builds before anything else, and installPushCoalescer. anima-research#247's inbound-acceptance block stays just before it, as it stood before initializePushCoalescer, and anima-research#247's accepted hook lands in restorePushCoalescer's options unchanged. - src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for the durability commit (one fsync per acceptance, under the lock). Both are kept. - src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent, which push-handler no longer uses. One test needed the merge: anima-research#429's invitation test stands in a channel registry with the four methods main's push lane called. anima-research#247's push lane also names the channel in the message's source envelope, through getChannelLabel, which anima-research#247 added to the registry, so the stand-in gains it, reading the same labels as its getDescriptor. None of main's new lines reads or writes anima-research#247's turn silence, its cause, its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral runs in stop() and its cancelled provider retries through abortDroppedTurn, and has the event gate take a continuing turn's entry again (onInferenceResumed) without touching a pending self-wake. anima-research#481 bounds stop()'s waits and keeps a still-streaming agent's deferred writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced keeps to tool-result-guard retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main at d9bd6dc brings anima-research#460, anima-research#462, anima-research#466, anima-research#424, anima-research#445 and anima-research#425 into code this change rewrites. The resolutions: - src/modules/workspace/index.ts: - anima-research#460: editablePath parses a path with parseFilePath, so edit and edit_many refuse a path in a nested mount as every file tool does, and applyEdits' pass names the paths it settles ({ named: true }), so a file the mount passes over by design is taken in as a named sync takes it. - anima-research#462: commitToolChanges answers each path's kept mode beside its refusal, as commitToolChange does for one path. edit's answer carries it as modeKept, and edit_many's in each file's entry. applyEdits leaves a mode taken in alone unsaid, as handleEdit did, and the edits keep the entry's mode, as before. - anima-research#466: applyEdits already words the conflict note's advice by what disk holds now, so this change's side stands. - anima-research#424: the imports keep both sides. - src/framework.ts, src/module-registry.ts and src/types/events.ts: anima-research#445's callerTurnId beside this change's resultMaxChars. The utils call carries both, the registry passes the call on whole, callerTurnId included, and events.ts documents both. anima-research#425's comment on a script's result keeps this change's SCRIPT_TOOL_RESULT_MAX_CHARS. - test/workspace-file-tools.test.ts: this change's expectations, which already carry anima-research#466's advice in the refusal's new wording. Also in this commit, as Anarchid's review asked (R1-4): edit_many cases in the two nested-mount tests, which assert the nested-mount refusal's exact text for edit and edit_many, since the named pass's note names the inner mount too, a test of edit_many naming files the mount passes over by design, and edit_many in the test of a push that keeps disk's mode, all in workspace-three-way. anima-research#460's ignore-list adoption test and anima-research#466's three deletion tests now expect the edit's refusal wording. The read-back's mode test calls the commit directly, since an overwrite now keeps the entry's mode and no tool sets the same bytes under another mode. planFile's doc gives the text's length as how long it is, not its characters, and the changelog says edit_many carries modeKept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Registering a state now fails for anything but "already exists" (#385). Twelve of the seventeen places that register a state swallowed every failure, so a closed store or an I/O error went unnoticed there, and the caller met it later, at another call, under that call's name. This puts every registration through one helper that treats only "already exists" as done, and names the state in the error it throws.
!Fixes #385
What was wrong
isStateExistsError(src/module-registry.ts) says that only "already exists" is safe to swallow on an idempotent registration: "Anything else (closed store, IO error) must propagate". Three registrations used it, and the channel registry's two logs tested its message inline. The other twelve wrappedregisterStatein a barecatch. These are the eight sites #385 names and four more: the conversation router's state increate(), the tune-out coordinator's state read and write in the channel registry, and the workspace's tree and intent states.Changes
ensureStateRegistered(store, registration), besideisStateExistsError, registers a state and rethrows every failure but "already exists" asRegistering state '<id>' failed: <the store's message>, with the store's error as its cause.create(): the framework, deferred-write, coalescing, inference-log, turn-checkpoint, process-log and conversation-router states;ModuleRegistry.addModule()'s module state, and a module'sregisterLogState();CheckpointManager's checkpoint state, and a feature set's host state;ChannelRegistry's lifecycle and label-history logs, and the tune-out coordinator's state;initStore();tryas the read of its stored value, inCheckpointManager'sloadHostState. So a registration that fails gets the refusal a failed read gets: it names the state, says what the failure stops, before the server connects or when the feature set registers, and carries the helper's error as its cause and in its text. As onmain, the feature set's tree is held only after both, so the next call tries again.initStore()registers every mount's states before it takes the store or any mount. It took them one mount at a time, so a failure at a later mount left the earlier mounts set, with no agreement, saved state or watchers.registerLogState()'s doc says its cadence applies when the state is first registered.isStateExistsErroralone.What callers will notice
A failure other than "already exists" is now thrown by the call that registers the state, as an error that names it:
AgentFramework.create()rejects with it. Acreate()that fails partway stops what it had started and closes a store it opened, as onmainsince fix(create): a failed create() releases what it had acquired #426.addModule()rejects with it.initStore()throws it, and leaves the module as it was. By reading, connectome-host callsinitStore()without a catch (src/index.ts:678at itsmain,38b55da), so its startup fails with the error.create()rejects.create()refuses for a configured server, andconnectMcplServer()for a later one, with the refusal a failed read of it gets.connectMcplServer()rejects. In a reconnect, it's logged and the reconnect goes on.By reading chronicle 0.4.0, which this release depends on, the one failure these registrations swallowed there is a closed store, and the tests show a closed store's failure thrown. Before, the next call on that state failed under its own name, if one came. A registration the store answers with "already exists", as on any restart with an existing store, is still taken as done. By reading, a chronicle whose
registerStatecan fail an existing state on checkpoint I/O (anima-research/chronicle#22) would make a restart fail atcreate(),addModule(),initStore()or an MCPL registration, wheremainswallowed it.What each part is for
The change is for one thing: a state registration that fails for anything but "already exists" fails at the call that registers it, naming the state, instead of surfacing later at another call under that call's name.
ensureStateRegistered, one helper for all seventeen registrations: the ruleisStateExistsErroralready stated lives in one place, and when chronicle's error codes are released, the change is toisStateExistsErroralone. Weighed: puttingisStateExistsErrorinto each of the twelve catch-alls. That fixes the same sites, but leaves seventeen copies of one rule. The twelve came about that way, beside the five that already did it right.initStore()): it took each mount before registering the next mount's states, a registration that swallowed its failure, so the order changed nothing. Now a failure there would leave earlier mounts taken with no agreement. Registering first lets the next call try again. A feature set's host state was already registered and read before its tree was held onmain. Its registration moves into the read'stry, so that its failure says what it stops, as a failed read does. The two latches,registerLogState()'s and the tool-result guard's, already registered first and rethrew onmain, and their removals now pin that order.registerLogState()'s doc says its cadence applies when the state is first registered, and that a registration that fails throws, naming the state, and leaves the name unregistered.srchas noregisterStatecall outside the helper: it reads the source rather than behaviour, and would fail a registration written correctly another way. It's kept because it's the only check that reaches a registration added later, which is how the catch-alls came about.Cut before this pass: the round's first build also opened the workspace's disk agreement before
initStore()took the store, so that a failed read of it left the module as it was too. No test pinned it, so it came out. Cut by this pass: nothing.Evidence
On Linux, from a fresh install with chronicle 0.4.0, context-manager 0.14.1 and membrane 0.5.87.
At
5a8192983, this head. It mergesmainat77066360asbf27f3be1, then makes the host-state refusal change above:boot-state-read-errors, the checkpoint, workspace, tool-result-guard and code-execution files among them) pass 784/784 atbf27f3be1, on Node 24.22.0 and on Node 20.20.2 with--test-force-exit. At5a8192983,state-registration-errors,boot-state-read-errors,checkpoint-attributionandmcpl-reconnect-reregistrationpass 71/71 on both. Typecheck and build are clean at both.bf27f3be1'scheckpoint-manager.tsand this head's tests, the two host-state registration tests fail, and so do the three that read the host-state refusal's text.npm test, and the packed-install check, all passed.At
1c6e2058, before the merge, after an internal review of13ddda9c:test/state-registration-errors.test.tspassed 25/25 on Node 24.22.0 and on Node 20.20.2. It injects an I/O error into one registration at a time through each entry point above, and checks that the error names the state. It also checks the helper on both paths, against the store's own answer to a second registration and against a real closed store; that a feature set, a module's log state and the guard's audit state each register on the next call after a failed one; that a failedinitStore()takes no mount; and thatsrchas noregisterStatecall outside the helper.registerLogState()'s and the guard's, set only after their registrations; and neither of those two registrations swallowing its failure.--test-force-exit, Node 20.20.2 also counted 2,397/2,397, and Node 24.22.0 counted 2,379/2,379: it cuthistory-module-realstore(20 of 28) andhistory-module-ux(16 of 26) short, each of which passes whole unforced (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294). Typecheck and build were clean.At
13ddda9c, the first commit:main's sources and only the helper added, 14 of the 20 failed. The six that passed pinned whatmainalready did right: the helper's two tests, and the turn-checkpoint, module log, channel lifecycle and label-history registrations.try, failed the source check. The helper swallowing everything failed 18, and the helper refusing "already exists" failed its own test.Not covered
readDispositionscaches{}after any failed read, and its next write puts that back. With this change a read's failed registration reaches that catch too; on chronicle 0.4.0 that failure is a closed store, wheremain's read failed as well. The conversation router's generation read swallows a failure too. Both are left for a change of their own.addModule()keeps a module whosestart()rejects, as it does for anystart()failure. That's left for a change of its own.Read by Gordon-2414 (Claude Opus 5.5). He read the change whole, the first commit and the round, at
1c6e2058, and checked the receipts above against the text. Then he read the merge ofmain(bf27f3be1, the remerge diff and this change againstmain) and the refusal change (5a8192983).We won't merge this before 05:00 UTC on October 18, or before our pre-merge checks pass on its merge with the then-current
main, and a line here asking us to wait holds it.Written by Ada-1017.