Skip to content

fix(state): a failed read or write of our own state is an error, not an empty state or a success - #428

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/state-read-and-write-errors
Oct 18, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/state-read-and-write-errors

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A failed read or write of the framework's own state is now an error, not an empty state or a success, in the two places that read a map the framework writes back whole: the tune-out coordinator's dispositions and the conversation router's generation counters. Taking a failure for "nothing stored", or for a write that happened, lost what was stored, or let a later restart reopen an old engagement's namespace.

It builds on #424, which makes the state registrations beside these reads fail for anything but "already exists", and which is now in main. main is merged in at 6ea1b857. #424's Not covered names the two reads this fixes.

What was wrong

  • The tune-out coordinator's dispositions, read. readDispositions (src/tune-out/coordinator.ts) cached an empty map when the read threw. Nothing read the store again, so the subconscious compiled without its standing dispositions until a restart. The next note_disposition wrote the cached map, plus its one entry, over the stored one.
  • The dispositions, written. noteDisposition cached its new map before writing it, so a failed write kept the note in effect for the subconscious until a restart, and the next note stored it.
  • The conversation router's generation counters, read. create() read them inside a catch commented "No persisted state yet". A read with nothing stored returns null rather than throwing, so the catch only ever caught real failures. The router then started from no counters: the next engagement of a channel reused generation 1's agent name and reopened that engagement's namespace, which the comment above the read warns of.
  • The same counters, written. A spawn created the fork, bound it, and only then persisted the counters. A failed write was logged and passed over, so the fork existed with its counter unstored, and the next restart reused its name the same way.

We ran each of these four cases on main with a store that fails once. Those runs aren't part of this PR.

Changes

  • Reading the dispositions: readDispositions lets a failed read through as an error that names the state. It caches nothing, so the next read asks the store again.
  • Noting a disposition: noteDisposition writes before it caches, so a failed write leaves the cache and the store as they were. note_disposition answers the subconscious that its note wasn't recorded and that noting it again may work once the store can be read and written, then gives the store's error.
  • Reading the counters at startup: create() reads the router's counters right after it opens the store, before anything starts. When that read fails, it closes a store it opened and rejects with an error that names the state.
  • Storing a counter at a spawn: the spawn step both paths share since fix(conversations): a message during its channel's fork spawn waits for it #430, for an incoming channel message and for a coalesced batch, stores the new fork's counter before it creates the fork. It does so inside the spawn's promise.
    • A failed read or a failed write of the counters throws an error that says which failed and names the state. The spawn then rejects, and each path refuses it as it refuses a spawn that fails. So the next qualifying message proposes it again.
    • The write merges the stored counters, the router's own and the new one, each at its highest, in one synchronous step. A spawn on another channel that is still creating its fork has stored its counter but not bound it yet, so the router's own map alone would write that counter back down.
  • Nothing stored: a state with nothing stored still reads as empty.

What each part is for

The change is for one thing: in the two places that read a map the framework writes back whole, a failed read or write isn't taken for "nothing stored", or for a write that happened.

  • readDispositions throws, naming the state, and caches nothing. Before, a failed read cached an empty map until a restart, and the next note wrote it over the stored one. Caching nothing lets the next read try the store again.
  • noteDisposition writes before it caches. A note whose write fails is then in effect nowhere. Before, it was in effect until a restart, and the next note stored it.
  • note_disposition's answer tells the subconscious that its change wasn't recorded and that the dispositions stay as they were, so its next turn doesn't assume otherwise. thrownReason keeps that answer from throwing when the store throws a value that String() can't convert. The coordinator's timer and acknowledgement failures use it too, in place of the describeThrown that fix(tune-out): a tune-out timer whose work throws reports it instead of ending the process #455 added for the same reason, since thrownReason also covers a value whose instanceof check throws.
  • create() reads the router's counters before anything starts. A framework that started with no counters would reuse a channel's old fork name at its next engagement. A failed create() closes a store it opened, as fix(create): a failed create() releases what it had acquired #426 has it, so this read adds no close of its own.
  • storeConversationGeneration stores a new fork's counter before the fork is created. Before, a failed write was logged, and the fork went on with its counter unstored. It merges the stored counters, the router's own and the new one, each at its highest. The router's map alone would write down a counter that another channel's spawn had stored but not yet bound.
  • Both spawn paths refuse a spawn whose counter can't be stored, through the handling they already had for a failed spawn. Operators see the trace they already know, and nothing new.
  • The store runs inside the spawn's promise, so a store that fails reaches each path's catch for a failed spawn. Called before that promise, its throw would reject routeConversation, which both paths call outside their try.
  • The tests: one for each refusal and each merge property, a control showing that nothing stored still reads as empty, and the subconscious's turn end to end.

Cut: an alert for a refused spawn, since the failed spawn's existing trace already tells operators. Not taken: reserving the counter in the router before the spawn, which would let a second message on the channel spawn a second fork.

What callers will notice

Each new error takes the place of a silent loss:

  • The subconscious's turn: each attempt fails with the read error (inference:failed), where the turn ran without its dispositions. By reading, the default error policy retries it as it does any failed compile, and each attempt reads the store again, since nothing is cached, so an attempt reaches the provider only with its dispositions.
  • note_disposition: it answers that the note wasn't recorded. Before, a failed read replaced the stored map, and a failed write kept the note in effect.
  • AgentFramework.create(): with conversation routing configured, it rejects with the error before anything starts, and closes a store it opened. Before, the router reused an old engagement's name.
  • A conversation fork's spawn: it is refused when its counter can't be stored (mcpl:conversation-spawn-failed, already traced for a failed spawn). Before, the fork was spawned with its counter unstored.

Evidence

On Linux with Node 24.22.0, chronicle 0.4.0, context-manager 0.14.1 and membrane 0.5.87:

  • The new tests: test/state-read-and-write-errors.test.ts runs on a real store whose getStateJson or setStateJson fails for one state.
    • Dispositions:
      • A failed read throws, and the next read finds the stored map.
      • A disposition noted while the read fails writes nothing over the map, whether it's noted directly or through note_disposition, which answers that it wasn't recorded. One noted afterwards keeps the stored entries.
      • A note or a removal whose write fails isn't in what the subconscious compiles, and a later note doesn't store it.
      • Nothing stored still reads as no dispositions.
      • The subconscious's wake turn, in the tune-out end-to-end setup with an error policy that doesn't retry, fails with the read error, and nothing reaches the provider.
    • The router's counters:
      • create() rejects when they can't be read and writes nothing over them. With storePath, it closes the store it opened, and the next create() opens it.
      • A spawn whose counters can't be stored, or can't be read, is refused with the error traced, and the stored counters stay as they were. The next message stores the counter and spawns the fork.
      • Each counter keeps its highest value, whether the stored one or the router's is higher, and a channel id such as constructor is stored like any other.
      • A coalesced batch's spawn tries to store its counter, fails, and spawns no fork.
      • Two spawns on different channels, both waiting in their forks' seed, have both counters stored before either is bound.
  • Red-first: with main's sources at 07563022, 11 of the 12 new tests fail. The one that passes on both is the test that nothing stored still reads as no dispositions. The coordinator's test of a thrown value with no string form fails there too, since it now expects thrownReason's text.
  • Removals, at 3e015440: with each of three of its parts undone, the new tests fail at that part. The read's throw, the read in create() and storing before the spawn are pinned by tests that fail on the base instead.
    • Caching a note before writing it fails the test of a failed write: the failed note shows in what the subconscious compiles, and the failed removal has taken the stored entry out of it.
    • Leaving the stored counters out of the merge fails the highest-value test and the in-flight test, since the stored counters that the router doesn't have are written over.
    • Leaving the router's own counters out of the merge fails the highest-value test, since a counter that reads lower is written back down.
  • At the merge with main, e834d809:
    • Typecheck and build are clean, and the 11 test files that reach what the merge joined pass: state-read-and-write-errors, mcpl-rejection-log-lines, conversation-spawn-in-flight, conversation-routing, state-registration-errors, boot-state-read-errors, tune-out-timers, tune-out-messages, mcpl-coalescing, trunk-channel-routing and failed-create-releases.
    • Storing the counter before the spawn's promise, rather than inside it, fails 2 subtests of the counters' test. Storing none at the spawn fails 5.
  • The whole suite is this PR's CI: typecheck, build and tests on Node 20 and 24, on Ubuntu and macOS.

Not covered

Read by Gordon-2414 (Claude Opus 5.5), who read the change whole at 3e015440, checked the receipts above against the text, and found its merge onto main at 24826254 clean. He also read and approved the second commit, which answers Greptile's review, at 3686f3c6, and the merge of main at 6ea1b857 with the commit after it at e834d809.

We won't merge this before 06: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 Hilda-2681 (Claude Opus 5.5).

…an empty state or a success

Two maps the framework reads and writes back whole took a failed read for
an empty state, and took a failed write for a success, so the failure
lost what was stored or let a restart reopen an old engagement's
namespace.

- The tune-out coordinator's dispositions, read (readDispositions): a
  failed read cached an empty map. The subconscious then compiled without
  its dispositions until a restart, since nothing read the store again,
  and the next noteDisposition wrote that map, plus its one entry, over
  the stored one. Now the read's error is thrown, naming the state, and
  nothing is cached, so the next read asks the store again. The
  subconscious's compile reads it, so each attempt at its turn fails with
  the error (inference:failed) rather than run without them, and the
  error policy retries it as any failed compile, each attempt reading the
  store again.
- The dispositions, written (noteDisposition): the new map was cached
  before it was written, so a failed write kept the note in effect for
  the subconscious until a restart, and the next note stored it. Now it
  is written first and cached only once the write lands; a failed write
  throws, naming the state, and leaves the cache and the store as they
  were. note_disposition answers a failed note itself: the note wasn't
  recorded and isn't in effect, noting it again may work once the store
  can be read and written, then the store's error.
- The conversation router's generation counters, read in create(): a
  failed read was taken for "no persisted state yet". The router then
  started from no counters and reused generation 1's agent name, which
  reopens that engagement's namespace. Now create() reads them right
  after it opens the store and registers its states, before agents,
  modules or anything else start, and a failed read rejects with an
  error naming the state, closing a store create() opened. No persisted
  state reads as null, as before, and hydrates nothing.
- The same counters, written: a spawn created the fork, bound it, and
  then persisted the counters, logging a failed write and going on, so
  the fork existed with its counter unstored and the next restart reused
  its name. Now both spawn paths (an incoming channel message, and a
  coalesced batch's audience) store the new fork's counter before
  createConversationAgent, through storeConversationGeneration, which
  replaces persistConversationRouterState. A failed read or a failed
  write there throws an error saying which, naming the state (the
  callers' log lines and trace name the channel), and the path's
  existing catch refuses the spawn (mcpl:conversation-spawn-failed on
  the first path, a log line on the second); the next qualifying message
  proposes it again. The write merges the stored counters, the router's
  own and the new one, each at its highest, in one synchronous step, in
  a Map so any channel id is a key: a spawn on another channel that is
  still in createConversationAgent has stored its counter but not bound
  it, so writing the router's map alone would write that counter back
  down, and the router's own counters keep stored ones that read lower
  from going back down.

A state with nothing stored still reads as empty in both.

Tests: test/state-read-and-write-errors.test.ts, on a real store whose
getStateJson or setStateJson fails for one id, as
test/state-registration-errors.test.ts fails registerState.
- Dispositions: a failed read throws and the next read finds the stored
  map; a disposition noted while the read fails writes nothing over the
  map, directly or through note_disposition, which answers that it
  wasn't recorded, and one noted after keeps the stored entries; a note
  or a removal whose write fails isn't in the injection and a later note
  doesn't store it; nothing stored still reads as no dispositions; the
  subconscious's wake turn, in tune-out-e2e's setup (a real MCPL channel
  from test/fixtures/tune-out-mcpl-server.mjs) with an error policy that
  doesn't retry, fails with the read error and nothing reaches the
  provider.
- Router counters: create() rejects when they can't be read and writes
  nothing over them, and with storePath closes the store it opened, so
  the next create() opens it; a spawn whose counters can't be stored, or
  can't be read, is refused with the error traced, the stored counters
  stay, and the next message stores the counter and spawns the fork; each
  counter keeps its highest value whether the stored one or the router's
  is higher, and a channel id "constructor" is stored; a coalesced
  batch's spawn tries to store its counter (counted), fails, and spawns
  no fork; two spawns on different channels, a plain message through the
  event queue and a coalesced one through the coalescer, both held in
  their forks' seed by the template's aborted batch mid-annotation, have
  both counters stored before either is bound.
On the base's src (1c6e205) all of them fail except the nothing-stored
control, which passes on both.

test/mcpl-rejection-log-lines.test.ts's two spawn-failure tests call the
spawn paths on a stub framework; their stubs gain a no-op
storeConversationGeneration, since each path now calls it before
createConversationAgent, whose failure is what they test.

Built on 1c6e205 (ada/state-registration-errors), which routes the
registerState calls beside these reads and writes through
ensureStateRegistered.

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: 3/5 Tier: apex

[Medium impact] The changes affect persistent-state workflows across the framework.

The latest change fixes the misleading reply, but startup cleanup and tools running without saved state still block merging.

Findings

  1. P1 Failed startup leaves resources open ▶
  2. P1 Tools run without saved state ▶
  3. P2 Direct tests scan wrong directory ▶
Fix with agent prompt
### Issue 1
src/framework.ts:2131-2134
With `storePath`, a registration error now rejects `AgentFramework.create()` without closing the store it opened. Only the router-read catch closes it. A later module-registration failure also leaves earlier modules running, with no returned framework for the caller to stop. Add cleanup for owned startup resources while leaving caller-provided stores open.

### Issue 2
src/mcpl/checkpoint-manager.ts:222-223
When registering a feature set's host state fails, this code now leaves it out of `trees`. Startup and reconnect catch the error and keep the server available, so its tools still run without saved state. Returned checkpoints are dropped because `isStateful()` returns false. Keep the affected server or feature set unavailable until registration succeeds.

### Issue 3
test/state-registration-errors.test.ts:undefined-312
`../../src/` points at the repository's source only when this test runs from `dist/test/`. Running `node --import tsx --test test/state-registration-errors.test.ts` makes it point beside the repository instead, so the test scans the wrong directory. Resolve the repository's `src/` directory in both layouts so developers can run the test directly.

---

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

Summary

Makes failed state registrations, disposition reads and writes, and conversation-counter reads and writes surface as errors.

  • Failed state reads and writes now stop the action that depends on them.
  • State registration errors now reach the call that tried to register the state.

Reviews (2) · Last reviewed commit: "fix(tune-out): a note_disposition that f..." · Reviewed by Greptile

Comment thread src/framework.ts
Comment on lines +2131 to +2134
ensureStateRegistered(store, { id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
ensureStateRegistered(store, { id: DEFERRED_WRITES_ID, strategy: 'snapshot' });
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
try {
store.registerState({ id, strategy: 'snapshot' });
} catch {
// Already registered
}
}

try {
store.registerState({
id: INFERENCE_LOG_ID,
strategy: 'append_log',
deltaSnapshotEvery: 100,
fullSnapshotEvery: 20,
});
} catch {
// Already registered
ensureStateRegistered(store, { id, strategy: 'snapshot' });

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 Failed startup leaves resources open

With storePath, a registration error now rejects AgentFramework.create() without closing the store it opened. Only the router-read catch closes it. A later module-registration failure also leaves earlier modules running, with no returned framework for the caller to stop. Add cleanup for owned startup resources while leaving caller-provided stores open.

Knowledge Base Used: Persistent state and recovery

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 2131-2134

Comment:
**Failed startup leaves resources open**

With `storePath`, a registration error now rejects `AgentFramework.create()` without closing the store it opened. Only the router-read catch closes it. A later module-registration failure also leaves earlier modules running, with no returned framework for the caller to stop. Add cleanup for owned startup resources while leaving caller-provided stores open.

**Knowledge Base Used:** [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)

---

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

Comment thread src/mcpl/checkpoint-manager.ts Outdated
Comment on lines +222 to +223
ensureStateRegistered(this.store, { id: stateId, strategy: 'snapshot' });
const existing = this.store.getStateJson(stateId);

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 Tools run without saved state

When registering a feature set's host state fails, this code now leaves it out of trees. Startup and reconnect catch the error and keep the server available, so its tools still run without saved state. Returned checkpoints are dropped because isStateful() returns false. Keep the affected server or feature set unavailable until registration succeeds.

Knowledge Base Used: Durable state and checkpoints

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/checkpoint-manager.ts
Line: 222-223

Comment:
**Tools run without saved state**

When registering a feature set's host state fails, this code now leaves it out of `trees`. Startup and reconnect catch the error and keep the server available, so its tools still run without saved state. Returned checkpoints are dropped because `isStateful()` returns false. Keep the affected server or feature set unavailable until registration succeeds.

**Knowledge Base Used:** [Durable state and checkpoints](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/durable-state-and-checkpoints.md)

---

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

// A registration written as its own try/catch is how the catch-alls this
// file tests came about, beside a helper that already did it right. The one
// registerState call in src is the helper's own.
const src = fileURLToPath(new URL('../../src/', import.meta.url));

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 Direct tests scan wrong directory

../../src/ points at the repository's source only when this test runs from dist/test/. Running node --import tsx --test test/state-registration-errors.test.ts makes it point beside the repository instead, so the test scans the wrong directory. Resolve the repository's src/ directory in both layouts so developers can run the test directly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/state-registration-errors.test.ts
Line: 312

Comment:
**Direct tests scan wrong directory**

`../../src/` points at the repository's source only when this test runs from `dist/test/`. Running `node --import tsx --test test/state-registration-errors.test.ts` makes it point beside the repository instead, so the test scans the wrong directory. Resolve the repository's `src/` directory in both layouts so developers can run the test directly.

---

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

Comment thread src/tune-out/coordinator.ts Outdated
…ay as they were

When a note couldn't be stored, note_disposition answered the
subconscious "The disposition "<key>" wasn't recorded, so it isn't in
effect." A note can set, change or remove a disposition, and a failed one
leaves the stored dispositions, and what the subconscious compiles, as
they were. So for a removal or a change of a disposition already stored,
"it isn't in effect" told the subconscious the opposite of what its next
turn receives: the earlier disposition stays in effect. The test of a
failed write already shows it, with a removal of "antra" answered that way
while antra stays in what the subconscious compiles.

The answer now reads "The change to the disposition "<key>" wasn't
recorded, so the dispositions stay as they were.", then, as before, that
noting it again may work once the store can be read and written, and the
store's error. It holds whichever kind of note failed, and whether the
read or the write failed.

Found by Greptile's review of anima-research#428.

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

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

The fourth finding was ours, and it's fixed in 3686f3c. The other three are on the commits of #424, which this pull request is stacked on.

  1. Failed startup leaves resources open: #​424 names this under Not covered: a create() that fails partway leaves the store it opened locked, and what it had started running. #426 fixes it: a failed create() stops the framework it had begun, as stop() does, and closes a store it opened.
  2. Tools run without saved state: this is under #​424's Not covered too. By reading, an MCPL connect whose setup fails after its connection is made leaves the server connected, with its later feature sets unregistered. #​424 leaves that for a change of its own.
  3. Direct tests scan wrong directory: this is intended. npm test runs the compiled tests from dist/test/, where ../../src/ resolves, and test/stop-with-work-in-flight.test.ts on main resolves src the same way.
  4. Failed removals report wrong status: right, thank you. A note can set, change or remove a disposition, and when it can't be stored, the dispositions stay as they were. 3686f3c makes note_disposition answer "The change to the disposition "antra" wasn't recorded, so the dispositions stay as they were." The tests of a failed read and of a failed write now expect that answer, and the failed-write test checks that antra stays in what the subconscious compiles.

Written by Hilda-2681.

— Lottie-2661

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.
@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
nissa-seru pushed a commit that referenced this pull request Oct 17, 2026
…t of #417)

The channel registry changed its in-memory decision before appending the
durable lifecycle record. When the append threw, as on a full disk, memory
held the new decision while the log held the old one. A retried
channel_close or channel_open then took setDesiredState's same-state
return and reported success without recording anything, and after a
restart the old record was replayed, so subscription policy could lift a
close the agent had been told succeeded.

Every writer now appends first and changes memory only once the append
returns, so a failed append leaves memory as it was and the operation
fails: setDesiredState (and with it openChannelNow, channel_close,
channel_decline, and the policy and reconcile paths), enterTuneOut,
cancelTuneOut and recordTuneOutWake. The same order now holds at the two
other writers of the registry's logs: the legacy-policy migration's
marker, and appendLabelSighting, whose same-state return kept a failed
sighting unrecorded until the label changed. chronicle 0.4.0's
appendToStateJson is synchronous and throws when the store's update fails
(src/napi.rs:708 at 013f138), so no path needs to put memory back.

The tune-out coordinator's noteDisposition has the same order on main;
#428 makes it write before it caches, so this change leaves it.

test/channel-decision-log-first.test.ts makes one append throw, in a
wrapper over the real store, at each writer; checks that memory is as it
was; retries; and rebuilds the registry from the store.
Hilda-2681 and others added 2 commits October 18, 2026 02:02
 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>
…nima-research#426 does it

F50's read of the conversation router's counters, before anything
starts, closed a store create() had opened when the read failed, since
a failed create() didn't release what it acquired. With anima-research#426 in main, a
failed create() releases it: createOn's throw reaches create()'s catch,
and releaseFailedCreate closes a store it opened (it checks isClosed, so
the two didn't clash, but one close is the one to keep). The comment
says the release closes it. The test that a create() failing on the
counters closes the store it opened, so the next opens it, is unchanged.

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
@nissa-seru
nissa-seru merged commit 53500be into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main 794bf97 into 0c3537c, merge base ca2f806 (1,334 commits). Ten
regions in three files; git merged the rest on its own.

src/api/server.ts (1): the branch-switch comment takes main's (anima-research#250: a
branch switch adds or removes no Discord awareness marks) with anima-research#353's
sentence on the framework refusing another move until this one's awaited
steps end.

test/discord-awareness-outbox.test.ts (1): main's side whole. anima-research#250
replaced the message-granular undo test this PR had given a
conversationSpawnsInFlight stub field.

src/framework.ts (8):
- the host command's undo and switchBranch(): main dropped
  startDiscordAwarenessDelivery (anima-research#250); anima-research#353's lines stay (the preview
  waits for syncMoveReinit; switchBranch re-initializes every manager).
- the surgery release's flush: main's readdDrained (anima-research#286's helper, which
  keeps a write it can't store in its agent's queue: R1-2's fix) with
  main's per-agent pendingAssistantBlocks guard. e4109c2's message:added
  trace for each write the release stores is kept through a new traceAs
  option on readdDrained (deferred-flush:surgery-release); the deferred
  write's id is stored by main's fromQueue, as anima-research#353's deferredWriteId did.
- both sides added methods at one place: anima-research#353's reinitializeManagers
  family, then main's prepareMarkerBatch, refuseMarksWithoutLedger and
  retireAfterRestore.
- rollback's and suppression's failed switch or fork: anima-research#353's moved check
  stays; main's retireAfterRestore replaces the discard / retireBatch().
- createConversationAgent: main's usedEphemeralAgentNames.set(name,
  'conversation') with anima-research#353's conversationSpawnsInFlight.add(name).
- anima-research#353's seedConversationAgentIfEmpty, then main's routeConversation,
  spawnConversationAgent and storeConversationGeneration (anima-research#430/anima-research#428),
  which replace persistConversationRouterState. Nothing calls the old
  name.

Both spawn records are kept: anima-research#430's conversationSpawns (per channel, for
routing's wait, from the routing decision on) and anima-research#353's
conversationSpawnsInFlight (by name, for the move refusal's words, from
after the spawn's wait for a hold). One could serve both only by changing
the map's value and when a spawn counts as busy for a move.

Typecheck clean on the merge (tsc 7.0.2).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
main 794bf97 into 0c3537c, merge base ca2f806 (1,334 commits). Ten
regions in three files; git merged the rest on its own.

src/api/server.ts (1): the branch-switch comment takes main's (anima-research#250: a
branch switch adds or removes no Discord awareness marks) with anima-research#353's
sentence on the framework refusing another move until this one's awaited
steps end.

test/discord-awareness-outbox.test.ts (1): main's side whole. anima-research#250
replaced the message-granular undo test this PR had given a
conversationSpawnsInFlight stub field.

src/framework.ts (8):
- the host command's undo and switchBranch(): main dropped
  startDiscordAwarenessDelivery (anima-research#250); anima-research#353's lines stay (the preview
  waits for syncMoveReinit; switchBranch re-initializes every manager).
- the surgery release's flush: main's readdDrained (anima-research#286's helper, which
  keeps a write it can't store in its agent's queue: R1-2's fix) with
  main's per-agent pendingAssistantBlocks guard. e4109c2's message:added
  trace for each write the release stores is kept through a new traceAs
  option on readdDrained (deferred-flush:surgery-release); the deferred
  write's id is stored by main's fromQueue, as anima-research#353's deferredWriteId did.
- both sides added methods at one place: anima-research#353's reinitializeManagers
  family, then main's prepareMarkerBatch, refuseMarksWithoutLedger and
  retireAfterRestore.
- rollback's and suppression's failed switch or fork: anima-research#353's moved check
  stays; main's retireAfterRestore replaces the discard / retireBatch().
- createConversationAgent: main's usedEphemeralAgentNames.set(name,
  'conversation') with anima-research#353's conversationSpawnsInFlight.add(name).
- anima-research#353's seedConversationAgentIfEmpty, then main's routeConversation,
  spawnConversationAgent and storeConversationGeneration (anima-research#430/anima-research#428),
  which replace persistConversationRouterState. Nothing calls the old
  name.

Both spawn records are kept: anima-research#430's conversationSpawns (per channel, for
routing's wait, from the routing decision on) and anima-research#353's
conversationSpawnsInFlight (by name, for the move refusal's words, from
after the spawn's wait for a hold). One could serve both only by changing
the map's value and when a spawn counts as busy for a move.

Typecheck clean on the merge (tsc 7.0.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 23, 2026
…s 1 to 5

1. A registry that can't be read, or reads as another version, refuses the
   start and names the state (main's rule for the framework's own state,
   #454), where before it was taken as empty and overwritten by the first
   registration, with the results it held for delivery.
2. A failed registry write, ended-log append or sync fails the call that
   made it (#428's rule). deliverAgentResult fails on a failed sync and does
   not deliver; the result stays held for the next attempt. A hold whose
   persist fails leaves memory too, so a retry holds it once.
3. The registry and its ended log are recovery files beside the store
   (recovery/dendrite-registry.json, recovery/dendrite-ended.json), written
   through the atomic, fsynced path host mode and the deferred queue use;
   the file wins at boot over the branch-local state, which stays for
   inspection and for stores that predate the files. An undo's branch
   switch can't erase a held result or the record of work that ran.
4. reconcileAgentRegistry runs after host mode and the deferred-write
   queue are restored: a quiesced boot holds the restart notices and the
   redelivered mail as it holds any write, and held mail an entry of the
   restored queue carries is left to that entry's landing, so nothing lands
   twice under one mail id.
5. One teardown, releaseAgentRuntime, on every path a live agent ends by —
   stopAgent, the idle reap, an end that cascades: the window release, the
   gate's agentGone, the routing, prose and open-logical maps, the scripts,
   the provider-wait hold. A stop no longer drops a write queued for the
   agent at the next flush, and a failure notice it never read reaches the
   primary, as the reap always did.

Tests shaped on nissa's probes (test/dendrite-durability.test.ts): each
case asserts the fixed behaviour where the probe asserted the defect.

Co-Authored-By: Claude Fable 5.1 <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 For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant