Skip to content

fix: every agent on the store works again after a rollback, suppression, branch switch, undo or redo - #353

Open
nissa-seru wants to merge 46 commits into
anima-research:mainfrom
nissa-seru:fix/reinit-after-branch-move
Open

nissa-seru wants to merge 46 commits into
anima-research:mainfrom
nissa-seru:fix/reinit-after-branch-move

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

After a rollback, a suppression, a branch switch, an undo or a redo, every registered agent sharing the store compiles again without a restart, unless its re-initialization fails, which raises an alert and is recovered by a switchBranch(). !Fixes #326.

What was wrong

A context manager loads its strategy for one branch generation of the store. context-manager re-initializes a manager when that manager makes a move itself, with switchBranch() or fork(), and re-initializes no other. So:

  • after rollbackToMessage() or suppressMessages(), every agent but the one named kept a strategy loaded for the branch the store had left;
  • after switchBranch(), undoLastTurn() or redo(), which switch the store directly, every agent did, the one named included.

An AutobiographicalStrategy then refuses that agent's compiles, its readiness and its maintenance ("requires reinitialization for the current branch generation") until a restart opens every manager again. #326 has the probes, on context-manager 0.14.0 and 0.13.0.

The change

Each move re-initializes the managers it didn't move before it releases the store. It sets each manager's own strategy again (cm.setStrategy(cm.getStrategy())), which initializes the strategy for the store's current generation without advancing it. A switchBranch() to the same branch would advance the generation, and leave stale every manager re-initialized before it. The managers re-initialize one at a time, with a turn of the event loop before each, so the host goes on handling events between them. A manager's own re-initialization can hold the event loop throughout, as an autobiographical strategy's does: in the probe under Evidence, the event loop turned once per manager, the turn before it.

  • rollbackToMessage() and suppressMessages(): every agent but the one named, once the store has left the source branch, and each waits for them before it returns and lets the store go. When a rollback's switch fails and it switches back to the source, the store has moved twice, so the other agents are re-initialized then too. One refused before the store moves, such as one whose branch name is already taken, re-initializes no other manager and raises no alert.
  • switchBranch(): every agent, alongside the config mount, before the store is released. A switch to the branch the store is already on re-initializes every manager, which is how to recover one whose re-initialization failed.
  • undoLastTurn() and redo() stay synchronous, since the API server, the host/command undo and hosts read their results at once. They now reserve the store as a surgery does, as fix(framework): stop() waits for an operator's operation already running, and refuses new ones #325 made switchBranch() do. Each returns once the store has moved, and the reservation is held until a re-initialization of every manager has finished, after the call returns. Each result carries settled when the store moved: a promise that settles once that reservation is released and never rejects, so a caller can wait for the managers before it compiles on one or writes. The API server's undo and redo await it before they broadcast branch:switched and answer (the maintainer's R3-1). It waits first for maintenance already running, a pass or an over-budget drain kick, so no setStrategy overlaps a tick: its first pass starts inside the call when none is running, and after it returns otherwise. As with a surgery (fix: what still assumed context-manager 0.13, and removing a message stored as a body #352), a maintenance provider call made under the reservation, or one waiting at the provider gate when it's taken, is withdrawn without being sent, so that wait is never a wait on a cooldown. The move itself is synchronous, so a tick running at that moment still runs across it, as on main. An undo or redo made meanwhile moves the store again under the same reservation, and the re-initialization runs again for the branch the store ends on. Once its last pass is done, nothing joins it: one made from a listener of its report meets the hold and is refused as busy. So back-to-back calls keep working, such as a multi-turn host/command undo, or an undo and then a redo from the web API.
  • The host/command turn undo waits for that re-initialization before its lastVisible preview, since the preview compiles. On main the preview failed, and the command answered lastVisible: null.
  • A failed re-initialization leaves that manager refusing its work, as context-manager leaves a failed initialization, and the move stands. It's said on the console, in failures.log and in an ops alert, context-reinit-failed, which names the agent and the move. Each failure is logged and traced, and the alert webhook posts at most once per agent and kind in 15 minutes, as opsAlert does for any alert without a key of its own. Each alert opens with what happened and what to do or what happens next, in at most 338 characters (store-held-long's in at most 333, with counts of up to nine digits), within the 343 to 349 the webhook keeps of the start of a message of 501 to 9,999,999 characters, by its length. Then come the branches it names, what becomes of the agent's writes, and its cause last, since a cause can be an error's whole diagnostic. The remedy says that a switchBranch() re-initializes the manager and then stores the writes kept queued for its agent, at resume on a quiesced host, and that a restart re-initializes it too but loses those writes unless the deferral queue is persisted. A restart creates the configured agents again, but stop() ends each conversation agent and each run, so for a conversation agent the remedy says a restart ends it and its channel's next qualifying message spawns a new fork, and for a run, that a restart ends the run. An agent that has left by the time its re-initialization or its seeding fails, as a run its watchdog ended, isn't reported, since it refuses no one's work. For a failed restore, which an agent is is taken when its rollback or suppression reserves the store, before any of the surgery's awaits. stop() says how many writes its last flush leaves queued for an agent whose store didn't take them, and whether the queue keeps them for boot (R3-5). That line, and main's line for writes left queued behind a stream still running, say the persisted queue's record is behind, rather than that boot keeps them, when the record's last write failed or met the cap.
  • A hold that runs long is said. No turn starts while a move holds the store, and nothing bounds what the hold waits on: maintenance already running, the move itself, and each manager's re-initialization. A move whose hold is still up after 60 s raises one ops alert, store-held-long. It opens with what the hold does and what a restart does: it ends the move, the conversation agents and the runs, and re-initializes the configured agents, and writes still queued at exit survive only in a persisted deferral queue. Then it names the agent the move was for, and the manager its pass is re-initializing or the maintenance it's waiting for, after the opening, since names have no bound. It's raised for every move that holds the store: an undo or redo, and main's rollback, suppression and branch switch (R3-4).
  • A failed restore says where the store is. When a rollback or suppression fails after the store moved and its switch back to the source fails too, it raises the same alert for the agent it named. A switch selects its branch before it initializes, so when only the restore's initialization fails, the store is back on the source; when the restore can't select the source, the store stays on the branch the failed operation left it on. The error and the alert say which, and say that the agent refuses its work until a switchBranch() re-initializes it. Back on the source, a restart re-initializes a configured agent too. Still on the failed branch, a restart would re-initialize it there, on the branch it shouldn't run on, since a manager works on the branch the store is on, so the alert says so and puts what a restart loses after its opening. A restart ends a conversation agent or a run. A suppression whose removals failed after its fork is the exception: its fork initialized the agent's manager on the new branch, so when its restore can't select the source, the error and the alert say the agent serves from that branch, which may be partly suppressed, until a switchBranch() moves it. When its restore selects the source and then fails to initialize there, the agent refuses its work, as after any other failed restore.
  • A conversation agent the move left without history is seeded again. After the managers re-initialize, a conversation agent can have no stored message of its own on the branch the store moved to, as one spawned after the point the move returns to has none there. Re-initialized, it would answer with no seed and no history, and a conversation agent was seeded only when it was created. It's now seeded again from the template's context as it stands on that branch, by the same rule and code as at its creation. It keeps its name, its channel and its turn history, so a move back to a branch that holds its own history, such as a redo after an undo, finds that history and seeds nothing.
    • What reaches it during the move waits for the move. A conversation agent's channel message, a correction for it and its idle closure prompt were written straight into its manager, past the deferral the store's reservation gives every agent's writes. During an undo's or redo's wait for running maintenance, that write landed in the namespace the move had emptied, so the re-seed found a message and seeded nothing, and the agent ran on with that one message; while its manager re-initialized, the write was refused and lost; and during a rollback's wait, and by reading a suppression's, it landed on the branch the move then left. All three are now deferred while a move holds the store, and land after the re-seed. When the store is let go, each write deferred during the move is stored with its id, as the other flushes of deferred writes store theirs, so the placement it was given while deferred resolves to the stored message, and traced as message:added with that id, as the turn-start flush traces each message it stores. Before, the store's release stored them without the id, and traced nothing. A coalesced push no longer removes the stored, unread message it updates or withdraws while the store is held. The coalescer handles that as it does any removal that fails: it appends the update, or a retraction's notice, which by reading lands after the release beside the unread message, and a retraction with no notice leaves the message in place. So an unread message withdrawn while the store is held still reaches the agent, where otherwise a retraction leaves no trace of it.
    • A seed that another mover interrupts fails. If the store moves to another branch during the template's compile by a switch this framework didn't make, such as one through an agent's own ContextManager, the seed writes nothing and fails, naming the branch the store moved to, so the re-seed disposes the agent as below, rather than writing one branch's context onto another. By reading, a spawn fails the same way: the seed refuses before it writes anything, as the re-seed's test shows, and any failure of the seed fails the spawn and clears its name, as "a spawn that fails clears its name" shows with a failing template compile.
    • Empty means no stored message, for this and for creation, which share the rule. One whose history is there but compiles to nothing, such as messages that are hidden or past its window, is left alone, and the re-seed never compiles or disposes one that has history.
    • Left to others: one whose re-initialization failed is left to that failure's alert. The agent a rollback or suppression names is never empty after its move: a rollback keeps the message it returns to, and a suppression refuses to remove an agent's last message.
    • One that can't be seeded is disposed, as a spawn whose seeding fails is never created, rather than left to answer its channel with no history. That's so when its seeding fails, and when the template is missing or its own re-initialization failed, since seeding reads the template's context. Its channel unbinds, and its context-reinit-failed alert says so. When its seeding failed, the channel's next qualifying message spawns a fresh one, unless its seeding fails again. When the template is the cause, the alert gives the template's own error, or says that no agent is registered under its name. Since a spawn seeds from the template too, the channel spawns a fresh one only once that's put right, and the alert says how. Its messages stay on the branches that hold them, but nothing reads them again: the next spawn is a new conversation agent under a new name, so a redo doesn't bring the disposed one back. A disposal that fails partway, as when releasing its window throws, is named in the same alert, and the pass goes on to the next one. The agent is unregistered and its channel unbound before that step. What the move held for it goes with it: its window's release stores what its store takes of those writes there, where no agent reads them, and passes any failed reply on to the primary. On a quiesced host the release is postponed to the resume, and a stop() before the resume drops all but a failed reply, logged, as main does since fix(stop): end each live conversation fork as its release would #616 for any release a quiesce postponed, and after a crash the restarted host's resume drops them the same way; that reply goes to the primary at the restarted host's resume. The alert says how many, since the fork the channel spawns next, under a new name, doesn't get them (R3-3), and which of these the disposal met. When the disposal's cleanup failed, the alert still counts them, and says they may be in its window, still queued under its name, or in neither, as far as the cleanup got, and that a flush that meets one still queued passes a failed reply on to the primary and drops anything else, logged.
    • An undo or redo made during a pass moves the store under it. That pass then writes nothing to the branch the store lands on and disposes nothing, whether a seed failed or the template refused, since a refusal can be the move's own doing, and the re-initialization's next pass judges every conversation agent again there. A disposal an earlier pass made is reported, under the move whose branch that pass judged.
  • No move lands under a conversation agent being created. Creating one waits while any move or surgery holds the store, since its context is seeded from the template's and its manager opens for the branch the store is on. From then until it's registered, it counts as busy: a move made meanwhile is refused as busy. A spawn that comes to create one once a stop() of its lifecycle has begun, under way, done, or failed with no start() since, as one that waited out a hold can, fails as a failed spawn does, rather than open its manager on a store stop() is tearing down or has torn down. A start() begins a new lifecycle, whose spawns go through.
  • A failed undo or redo keeps its turn, and releases the store. By reading main, a failed undo lost its checkpoint there and a failed redo its step. A redo whose branch was deleted drops that step and says so with OperatorActionError (failed), naming the branch and how many redo steps remain, with the switch's own error as its cause. Any other failed redo keeps its step and answers the switch's error, as does one whose branch listing fails as well.
  • A runtime-settings change, reset or cancelled transition is refused while the store is held, before anything is applied. Before, a change set its override and was then refused by the manager re-initializing.
  • A maintenance tick that an undo or redo moved the store under counts as withdrawn, in its catch and in the readiness read after it, and an over-budget drain kick's log says the same. Its work was for the branch the store left, which the re-initialization discards, so what it met isn't reported: its manager's refusal is the move's, and a lasting failure, such as the provider's, meets the re-initialized manager's next pass too. The store's hold starts no new tick, so only a tick running when the move landed is treated this way.

What callers will notice

  • The context managers of the agents a surgery doesn't name keep working after it, and every registered agent's keeps working after a branch switch, undo or redo. There is nothing to change for this.
  • undoLastTurn() and redo() are refused with OperatorActionError (agent-busy) while any agent sharing the store is busy, or a conversation agent is being created, the one named included, which used to throw a plain Error. Before, they switched the store under another agent's turn in flight. A refused undo or redo keeps its checkpoint or redo entry. A failed one does too, unless it's a redo whose branch no longer exists, which drops that step and now says so with OperatorActionError (failed). By reading main, a failed undo or redo lost its checkpoint or step there, a redo whose branch was gone included.
  • Just after undoLastTurn() or redo() returns, the store stays reserved while the managers re-initialize. No turn starts, and one asked for meanwhile starts after. Messages for the agents defer as they do during a surgery, a conversation agent's included, and rollbackToMessage(), suppressMessages() and switchBranch() are refused as busy. Another undo or redo isn't refused, as long as every agent is idle. A runtime-settings change, reset or cancelled transition made then, or during a surgery, is refused with OperatorActionError (agent-busy) before any of it is applied. A context-settings preview made then is refused by context-manager until that agent's manager is current, as during a surgery's switch.
  • While the store is held, a coalesced update or retraction no longer removes the stored, unread message it updates or withdraws. A test shows it for an undo, and by reading it holds for a surgery, a branch switch and a redo too. By reading, the update, or the retraction's notice, lands after the store is released. A retraction with no notice leaves the message in place, so an agent can read a message withdrawn while the store was held.
  • Two switchBranch() calls made together: the second is refused as busy while the first holds the store, with or without a workspace module. Before, without one, the first released the store before any await and both went through. The API server's test for two switches handled together now expects that.
  • A new ops alert kind, context-reinit-failed. Its remedy says what a restart costs, and stop() logs a line for each agent whose writes it leaves queued because its store didn't take them. Each of its alerts gives its cause last.
  • stop()'s line for writes left queued behind a stream still running says the persisted queue's record is behind, rather than that boot keeps them, when the record's last write failed or met the cap.
  • A spawn that comes to create a conversation agent once a stop() of its lifecycle has begun, under way, done, or failed with no start() since, fails, and is reported as any failed spawn is; the channel message that started it is dropped with that spawn.
  • A new ops alert kind, store-held-long, for a move whose hold on the store is still up after 60 s. It's raised for every move that holds the store, main's rollback, suppression and branch switch included, not only an undo or redo.
  • undoLastTurn()'s and redo()'s results carry settled when the store moved. It settles once the store is released, every agent's context manager re-initialized or its failure alerted, and never rejects. Await it before compiling on an agent's own context manager, which refuses until it's re-initialized, or before a write meant to land at once, which the reservation defers.
  • The API server's undo and redo answer, and broadcast branch:switched, once the store is released. In the probe under Evidence, an undo held the store for 4.5 s with 16 agents of 1,000 stored messages each, and for 10.9 s with 32 of 500. Their answers are as before, without the promise.
  • When a move disposes a conversation agent that couldn't be seeded, its alert says how many writes held for it went with it.
  • A conversation agent with no stored message of its own on the branch the store moves to, such as one spawned after the point a move returns to, is seeded again from the template as it stands on the landed branch, and stays bound to its channel. A redo back to its branch finds its own history. Messages that reach it while the move holds the store land after it's seeded. If it can't be seeded, because its seeding fails, another mover moves the store during it, or the template is missing or failed to re-initialize, it's disposed and its channel unbound, with an alert saying why, that a redo won't bring it back, and, where the template is the cause, what lets the channel spawn again. Creating a conversation agent waits while the store is held, and a move made while one is being created is refused as busy.
  • A maintenance tick that an undo or redo moves the store under no longer raises context-maintenance-failed.

What each part is for

The change is for one thing: after any move of the store's branch, the context manager of every agent sharing the store works again without a restart, and one that can't is named in an alert that says how to recover it. Each part below serves that, or keeps something that runs during a move from undoing it.

  • Re-initializing every other manager after a move (reinitializeManagers) is the fix itself. context-manager refuses a strategy loaded for a branch generation the store has left, and this framework made the move, so it re-initializes them. Setting each manager's own strategy again is how a manager is re-initialized without advancing the generation, as a second switchBranch() would. Weighed: context-manager re-initializing a stale strategy itself at its next use. That changes context-manager's contract, runs each re-initialization in that agent's next turn rather than under the move's hold, and still leaves the conversation agents below to this framework.
  • One manager at a time, with a turn of the event loop before each (the maintainer's R2-2), costs one setImmediate per agent. Side by side, a pass held the event loop for all of it: 5.1 s with 16 agents of 1,000 messages each, in the probe under Evidence.
  • undoLastTurn() and redo() stay synchronous (beginSyncMove, reinitializeAfterSyncMove, syncMoveReinit). The API server, the host/command undo and hosts read their results at once, so making them async would break those callers. Instead they hold the store until the managers are current, after they return. An undo or redo made meanwhile joins that hold, which costs the again flag and the loop that runs the pass again. Without the join, the second turn of a multi-turn host/command undo, or a redo right after an undo, would be refused as busy.
  • settled on undo's and redo's results (the maintainer's R3-1) is how a caller waits for the reservation those calls leave held: before it, only the host/command undo could, through a private field. It costs a field on two results, carrying the promise the re-initialization already held (SyncMoveReinit.done). The API server's wait makes its undo and redo answer seconds later with many agents, which is when a client's next read would otherwise be refused.
  • context-reinit-failed, with its remedy (reportReinitFailures, refusedWorkAlert): a manager whose re-initialization fails refuses all its work until something re-initializes it, so whoever runs the host needs to hear which agent it is and how to recover it, through the alert webhook as well as the console. A rollback's or suppression's failed restore leaves the agent it named the same way, so it raises the same alert (restoreAlert), unless its restore couldn't select the source after a suppression's removals failed: that agent's manager is current on the suppression's branch, and the alert says it serves from there. The remedy says what a restart costs (R3-5): the writes kept queued for that agent, which a switchBranch() recovery stores. stop()'s line, one per agent, says the same when the cost is paid.
  • store-held-long (R3-4; reportLongHold, one timer per hold, armed in reserveStoreForSurgery and cleared at its release): nothing bounds what a hold waits on, and while it's up every agent waits, so a hold that runs long needs saying, whichever step is holding it. A deadline that releases the store isn't safe without a cancellation contract in context-manager, so it's an alert. It costs one timer per hold, a field on the hold naming the manager a pass is on, a field holding the threshold, which a test shortens, and three nouns for the hold's verbs (MOVE_NOUNS), so the alert names the move as the move's other alerts do ("the rollback", where a refusal says "Cannot roll back"). A maintenance call already sent counts toward the threshold, so a slow provider call can raise it on a move that's working; the store is held then too, and the alert says that's what the move is waiting for.
  • Seeding again a conversation agent the move emptied (seedConversationAgentIfEmpty, now shared with creation, and reseedEmptiedForks): without it, a conversation agent spawned after the point the move returns to answers with no history. Weighed: disposing every such agent and letting its channel spawn a fresh one. That loses each one's conversation across an undo and a redo, since a redo can't bring a disposed agent back. Seeded again, it keeps its name, and the redo finds its history. Disposal stays for one that can't be seeded. Its alert counts the writes that went with it (R3-3): its window's release stores what its store takes of them, where they reach no one, and passes a failed reply on to the primary.
  • A disposal that fails partway is reported, and the pass goes on (cleanupFailed, one catch per conversation agent, for Greptile's review of 7bfa5622). Without it, one fork's disposal that threw escaped reinitializeManagers, so the forks after it weren't judged and no failure was reported. It costs a field on the failure and a catch.
  • moved, in the rollback's and the suppression's catch blocks: a move refused before it switched the store left every manager where it was, so none is re-initialized and no alert is raised; one that switched and then failed has moved the store, and back again unless its restore failed too, so the others are re-initialized after its restore. It costs a branch read in each catch.
  • A spawn's check for a stop() of its lifecycle, after its wait for a hold (stopPromise, or running cleared with the queue closed): stop() doesn't wait for a spawn, so one that waited out a hold into a stop() would open a manager on a store being torn down, or one after a failed stop() on a framework that no longer runs. stopPromise is set from stop()'s first steps, which can emit traces while the framework still runs, and is cleared by a failed stop() and by an addModule() after a completed one; running cleared with the queue closed covers those, until a start(). It costs one condition. stopHasBegun(), the operator calls' rule, reads the closed queue whether or not a start() came after, so it would refuse every spawn after a stop() and a start().
  • stop()'s two queued-write lines say when the persisted queue's record is behind (keptWhere): a mirrored queue whose last write failed or met the cap keeps the flag that says it's mirrored, and boot reads only what the record last held, so "the persisted queue keeps them for boot" would be false then. It costs one helper both lines call.
  • The re-initialization alerts' texts, built by shape (refusedWorkAlert, restoreAlert, disposalAlert, and longHoldAlert for store-held-long): each gives its opening, the outcome and what to do or what happens next, short enough to stay in the start the webhook keeps of a long message, and its message: the opening, then the branches it names and the writes, then the cause, which can be an error's whole diagnostic. A test builds every shape, with long causes and branch names, and checks each opening against opsWebhookText, the cut opsAlert posts. REINIT_MOVES and TEMPLATE_REMEDIES type the moves and the template remedies, so a new one joins the test's shapes. Without them, a wording change could push an alert's remedy out of the webhook's post unseen, as happened twice in this round, each time found only by a review. It costs four small functions and two typed lists, and shorter remedies. store-held-long names two agents before it was built this way, and a conversation agent's name runs to about 100 characters, so the webhook's cut could drop its restart warning.
  • A restart's word by the agent's kind (AgentKind, agentKind, restartClause, refusalDetail, and the failed restore's status): a restart creates the configured agents again, but stop() ends each conversation agent and each run, and the router's bindings are in memory. So for a conversation agent the remedy says a restart ends it and its channel spawns a new fork, which gets none of its queued writes, and for a run, that a restart ends the run; a move's pass re-initializes a run's manager too, since it's registered for its run. Without the kinds, a run was told a restart re-initializes it, and store-held-long said a restart re-initializes every manager. A failure of an agent no longer registered isn't reported, and one still registered is read for its kind then, since every map the kind is read from changes together with the registry, with no await between. A failed restore's kind is taken when its surgery reserves the store, since the restore comes after the surgery's own awaits, during which a run can end. The re-seed's kind is 'conversation', written out, since every agent it judges is one, and a fork that left during its seeding isn't judged; read from the maps instead, a fork disposed meanwhile would be named configured. The kind is kept on the failure, since a fork can be disposed before the failure is reported. It costs a three-way type, a method that uses the house's test for an agent a restart doesn't create again (holdProviderAcceleration's), a field on the failure, two registration checks, a parameter of the restore, and a branch in each clause.
  • A failed restore left on the failed branch says where a restart puts the agent (restartClause's elsewhere, and the restore's status): a manager works on the branch the store is on, so a restart would serve a configured agent from the branch it shouldn't run on, with no refusal and no alert. It costs a parameter and a sentence after the opening, where the restart's loss of the queued writes moves so the opening fits the webhook's start.
  • What a failed restore says about a suppression's current manager (restoreSourceBranch's managerCurrent): without it, the alert said such an agent refuses its work while it served from a branch that may be partly suppressed. It costs a parameter the removals' catch passes. restoreAlert takes it for a suppression only, by its type, so no caller can build that text for a rollback, whose fork never initializes the manager it names.
  • restoreSourceBranch's move: the restore's alert names the move that failed, "The rollback failed" or "The suppression failed", as the move's other alerts name it. Without it, one of the two would be worded as the other. It costs a parameter its three callers pass, and a test pins the suppression's at its fork's call site.
  • STORES_QUEUED_WRITES: the one wording of how a refusing manager's queued writes get stored, which refusedWorkAlert and restoreAlert both give, so the two remedies can't drift apart. It costs a constant.
  • A disposal's releasePostponed, read right after the disposal, on either of its paths (releasesAfterResume): the alert says what the fork's window's release did with the writes held for it, or, when a quiesce postponed it, what the postponement means, which holds whatever follows: the resume stores them, and a stop() before it, or the restarted host's resume after a crash, drops all but a failed reply. A failed cleanup that released nothing says where the writes may be. Read later, an empty map wouldn't tell a resume that stored them from a stop() that dropped them or a release that threw. It costs a field on the failure.
  • The quiesced-disposal tests' helpers (config, Queue, queuedForFork and notices, adapted from Naomi-2422's probe): the tests build a host, and a second one on the same store after a stop(), and follow the writes held for a disposed fork across a quiesce, a resume or a stop, and a restart, by what the queue holds for the fork and what the primary is told. Without them, each variant would repeat that setup.
  • The tests' probes of what they reached (initialized, compiled, ended and the rollback test's end): a test that injects a failure, or an agent's leaving, at one point records that the point was reached, and the run's tests say where the run left. Without them, a test whose injection the code no longer reaches would pass without exercising what its title says. They cost a flag each.
  • The re-seed's identity check after its awaited seed: a fork stop() ends while it's seeded again has been disposed already, so its seeding's failure isn't reported, and it isn't disposed a second time; a test counts its disposals. It costs one condition.
  • Two checks after the template's compile (the maintainer's R2-3, and moved): a seed whose store another mover moved fails, and a pass that a joining undo or redo made stale writes nothing, so a seed never writes one branch's context onto another.
  • A conversation agent's writes wait for the move (writeToFork, the maintainer's R2-1). They now take the deferral every other agent's writes take under a hold, rather than a path of their own. The release stores each deferred write with its id through fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's flush helper, which it now uses, so a placement given while the write was deferred resolves to the stored message. The release traces each write it stores, through an option on that helper (traceAs), as the turn-start flush does, since a write's trace at its deferral has no id. A coalesced removal leaves a stored message alone while the store is held (removePlacement, one line): it would touch a manager the hold is protecting. A module's edit or removal of a stored message, and a write carrying a fresh tool result, still reach a manager during a hold, as on main (R3-2; Not covered). The coalescer handles the refusal as any failed removal, so a retraction that meets a hold doesn't keep the unread message from the agent (above). Weighed: queueing the removal for the release. That keeps the retraction's promise, but the coalescer would be told the message is gone before it is, so a crash or a failed removal at the release would leave a copy it believes removed, and the queue would have to be stored, as the deferred writes are.
  • Creating a conversation agent waits for a hold, and counts as busy until it's registered (released, conversationSpawnsInFlight). Its manager opens for the branch the store is on, and it's seeded from the template's compile, so a move landing during the spawn would seed it from the wrong branch. Refusing the spawn instead would leave the message that triggered it undelivered, as a failed spawn does.
  • Refusing runtime-settings changes under a hold (refuseRuntimeSettingsChangeUnderHold): every manager now re-initializes under a hold, and one that's re-initializing refuses the settings read that follows a change, so a change would otherwise be applied and then refused, leaving its override behind.
  • Treating a maintenance tick that an undo or redo moved the store under as withdrawn: otherwise each such move raises context-maintenance-failed for a failure the move caused. The hold starts no tick, so only one already running is treated this way.
  • A failed undo or redo keeps its turn. It has to release the store it reserved, and beside that, keeping the turn costs one restore of the checkpoint list for an undo and one push back for a redo. By reading main, a failed undo lost its checkpoint there, which nothing can make again, and a failed redo lost its step. A redo whose branch is gone drops that step and says so: a failed redo now has two outcomes, so its error has to say which happened, or a caller would retry and redo the step beneath.

Cut by the review of round 3 (61662246): the re-seed's skip of the agent a rollback or suppression names, and the parameter only it read. A rollback keeps its tail message and a suppression refuses to remove an agent's last one, so that agent is never empty after its move, and the re-seed's own check of stored messages already left it alone: the skip never changed an outcome.

Cut by this pass (83d9e72f): a redo whose branch was gone also dropped each step beneath it whose branch was gone too, and its message said the step now on top could be redone. Now a redo drops one dead step, which by reading is what main does, and its message says only how many steps remain; the next redo meets the next dead step the same way. The test of two dead steps went with it.

Tests

test/reinit-after-branch-move.test.ts puts two agents with autobiographical strategies on one store. It checks that:

  • after rollbackToMessage() on one agent, the other compiles and takes a turn, and compiles what the landed branch holds;
  • after suppressMessages() on one agent, the other compiles;
  • after switchBranch(), and after a switch to the branch the store is on, both compile;
  • after undoLastTurn(), the store stays reserved until every manager is re-initialized, and then both compile; redo() likewise;
  • an undo and a redo made back to back move the store under one reservation, a rollback meanwhile is refused as busy, and the managers end current with no alert;
  • a host/command turn undo answers with its preview, and a host/command undo of two turns undoes both;
  • an undo and a redo are refused while another agent on the store is busy, and keep the checkpoint and the redo entry, and the named agent's busy state is refused as busy too;
  • a failed re-initialization raises one context-reinit-failed alert naming its agent, the rollback stands, that agent's compile is refused, and a switch to the current branch recovers it; one that fails after an undo, a branch switch or a suppression names the move;
  • a rollback whose switch fails, and which switches back to the source, still leaves the other agent current; a suppression whose fork moves the store and then fails restores the source likewise;
  • a rollback whose restore fails names the agent and the branch the store is on, both when the store is back on the source and when the restore can't select it, and then says a restart would re-initialize the agent on that branch;
  • a suppression whose fork is refused, and a rollback whose switch is refused, before the store moves, re-initialize no other manager and raise no alert (a taken branch name is refused before anything is prepared);
  • an undo whose createBranchAt throws, and a redo whose switchBranch throws, leave the store where it was, released, with the checkpoint or redo entry kept; a redo whose switch and branch listing both fail keeps its step and answers the switch's error;
  • a redo whose branch was deleted drops that step and says so, naming the branch and how many steps remain, with the switch's own error as its cause and the store where it was, and the step beneath it redoes;
  • a redo that overtakes a failing pass reports nothing, and a failing last pass reports once, naming the redo;
  • stop() waits for an undo's re-initialization, and a turn asked for while the managers re-initialize starts once the store is let go;
  • a redo made from a listener of the last pass's alert is refused as busy, the store stays where the undo left it, and the agent that moved compiles;
  • a runtime-settings change, and a reset or cancelled transition, during an undo's re-initialization are refused with nothing applied, and the change and the reset apply once the store is let go;
  • an undo's re-initialization waits for maintenance already running, and for an over-budget drain kick already running; each test checks that the re-initialization has reached its wait on the pass or the kick, rather than sleeping a fixed time, before it checks that no manager has re-initialized;
  • a conversation agent created while an undo's managers re-initialize waits for them, reads the template after its re-initialization, and holds one copy of it with nothing of the undone turn; a move made while one is being created is refused as busy, and goes through once it's created; a spawn that fails clears its name, so a move goes through after it;
  • an undo to before a conversation agent was spawned seeds it again from the template, keeping its channel, and counts the seeded copies as consumed on the landed branch; a redo brings back its own history and seeds nothing; one spawned before that point keeps its own, seeded once;
  • a switch to a branch from before a conversation agent was spawned seeds it again, while one whose re-initialization fails there is reported and not seeded; one whose seeding fails is disposed, its channel unbound and its alert saying so and that a redo won't bring it back, and the move stands;
  • a conversation agent whose seeding fails, and whose disposal then fails partway, here when releasing its window throws, is reported with both failures, the store is released, the agent is unregistered and its channel unbound, and the next conversation agent the move emptied is seeded again;
  • a message deferred during the hold for an agent whose re-initialization fails stays queued for it after the release, and is stored once, in its window, after a switch to the current branch recovers it (R1-2);
  • a redo made while a conversation agent is being seeded again writes nothing to the branch it lands on and disposes nothing, whether the template's compile answered before the redo landed, runs after it, or throws; a disposal in a pass that a later move joins is still reported, under the undo whose branch that pass judged;
  • a template refusal in a pass that a second undo joins disposes nothing, and the next pass seeds the conversation agent that undo emptied;
  • an undo's managers re-initialize after undoLastTurn() returns, and the event loop runs before the last of them has;
  • while an undo waits for maintenance already running, a conversation agent's channel message and a correction for it are deferred, with nothing written into its emptied namespace, and land after it's seeded again, where the placements they were given resolve to the stored messages; each is traced at its deferral with no id yet, and again as it lands, with its stored id; its idle closure prompt is deferred and lands the same way;
  • while an undo holds the store, a coalesced push's removal of a conversation agent's stored message is declined, and the message is still there after the release;
  • while a rollback waits for maintenance already running, a conversation agent's channel message is deferred, and lands on the branch the rollback moves to;
  • a re-seed during whose template compile the template's own context manager moves the store writes nothing from that compile, and the conversation agent is disposed, its alert naming the branch the store moved to while it was being seeded;
  • when the template's own re-initialization fails, or the template isn't registered, an empty conversation agent is disposed with an alert giving the template's error, or that it isn't registered, and the remedy, and one with history is left alone;
  • a conversation agent created again under a name whose namespace already holds history isn't seeded again, even when that history compiles to nothing;
  • one with stored history on the landed branch is neither seeded again nor disposed when its history compiles to nothing, or when its compile throws, and the re-seed never compiles it;
  • a maintenance tick that an undo moves the store under raises no maintenance-failure alert, whether its manager's refusal, a provider failure in it, or the readiness read after it meets the move;
  • an undo, and an undo then a redo, each made while a maintenance call waits at the provider gate: each move withdraws the parked call, which sent nothing, and its re-initialization releases the store, with no maintenance failure reported;
  • the API server's undo and redo broadcast branch:switched once the store is let go, so a client that reads the other agent's context on each event gets it, and their answers carry no promise;
  • store-held-long, from a hold's reporter: an undo and a redo joined, with the second pass at a gate and a write deferred, name the undo, b's manager and the write; a branch switch held by its config mount after its pass names no manager; between two managers' re-initializations the hold names neither;
  • store-held-long from its timer: a rollback waiting past a shortened threshold for maintenance already running raises it once, saying so, and an undo that lets the store go raises none after that;
  • the remedy in context-reinit-failed, and in each failed restore's alert, says what a restart costs, with each alert's cause last, and stop() says how many writes it leaves queued for an agent whose manager refuses them, and nothing for one whose write the release stored;
  • stop()'s line for such writes, and main's for writes behind a stream still running (in test/stop-with-work-in-flight.test.ts), each in its three wordings: a memory-only queue, a persisted one whose record is current, and one whose record is behind;
  • a conversation agent disposed because its seeding fails takes the two writes held for it into its window, and its alert says so; one whose disposal's cleanup then fails is counted with the write held for it;
  • an undo whose release throws still settles settled, and the release's failure is logged;
  • a redo whose branch was deleted, with no step beneath it and with two, says how many remain;
  • a spawn that waits out a hold into a stop() fails, as a failed spawn does, and leaves no name reserved and no spawn in flight; one after a stop() and a start() on a store the framework doesn't own is made;
  • a suppression's hold is named as the suppression;
  • a suppression whose removals fail after its fork, and whose restore can't select the source, says its agent serves from the failed branch, and that agent compiles there;
  • with a 3,000-character cause, the webhook still gets the opening of the template's refused-work alert, the fork's disposal with the template's remedy, and a failed restore;
  • every alert shape the text builders make, store-held-long's included, with causes of 3,000, 200,000 and 1,000,000 characters, branch and agent names of 200 and more, and store-held-long's counts of nine digits, keeps its opening in what opsWebhookText posts, store-held-long saying what a restart does for each move, and each refusing shape, for each kind of agent, says what a restart does to it, in the wording for where the store is, and what its refusal covers;
  • a run whose manager fails to re-initialize is told that a restart ends the run, though it leaves before the failure is reported; one that ends during its own re-initialization, which then fails, isn't reported; and a run named by a rollback whose restore fails, and which ends during the rollback's switch or during the restore, is told a restart ends the run, in the error's status and the alert;
  • a fork that leaves while it's seeded again, whose seeding then fails, isn't reported;
  • a suppression whose fork switches the store and fails, and whose restore can't select the source, raises an alert naming the suppression;
  • a fork disposed on a quiesced host, with two channel writes and a failed reply held for it: its alert says its window's release waits for the resume; at the host's resume its window takes them and the primary is told of the failed reply; and a stop() before the resume drops the channel writes with a log line each, stored nowhere, in the primary's window or the fork's, and keeps the failed reply for the primary, which the restarted host tells at its resume; after a crash, a host booted from the directory as it was on disk right after the disposal drops the channel writes at its resume, with a log line each, stored nowhere, and tells the primary of the failed reply; and one whose cleanup then fails, after its release was postponed, has an alert that says so, and the resume takes the writes held for it (adapted from Naomi-2422's probe);
  • a spawn after a failed stop(), or after a completed stop() and an addModule(), is refused, and one after a start() is made; a spawn started from stop()'s first step, while the framework still runs, is refused;
  • a suppression whose removals fail after its fork, and whose restore selects the source and then fails to initialize, says its agent refuses its work;
  • a conversation agent's refused-work alert after a branch switch, and its failed restore after a rollback, say a restart ends it.

The tests wait for an undo's or redo's re-initialization on its settled, where they read a private field before, and an undo and a redo made back to back share one. A test waiting for something to happen does it through until(), bounded at 10 s, so it fails rather than hangs when that never comes. The test of a fork created while an undo's managers re-initialize holds the template's re-initialization at a gate it opens once the spawn is waiting. In test/mcpl-coalescing.test.ts, main's test that writes right after an undo awaits its settled first.

In test/conversation-routing.test.ts, the seed test pinned that a spawn's emptiness check on the new namespace was a dry compile. That check is now the namespace's stored message count, so the test pins that nothing compiles the new namespace while it holds no message. Its window takes in the fork's first turn, whose compile comes after the seed.

The suppression test removes the first message. On context-manager 0.14.0, after a message in the middle is removed, every compile that follows a dry run is refused. That is a separate context-manager bug, fixed there.

Evidence

At this head, fcb94144, which changes one test's assertion from 35785770: the test of a rollback waiting for maintenance allowed no margin between the clock the hold is measured by and the one its timer fires on, and CI's Node 20 run on Ubuntu read 999 ms for a 1,000 ms threshold. Its test file and test/discord-awareness-outbox.test.ts, run on its tree before it was committed and built from an empty dist, pass 161 of 161, and typecheck and build are clean.

At 35785770, on Linux, on a clean checkout installed fresh by npm install with no lockfile and built from an empty dist, as CI does, on Node 24.22.0, with context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0:

  • The full suite (npm test) passes 6,021 of 6,065, with 44 skipped and none failing, and the three files CI also runs on their own pass 130 of 130. Typecheck and build are clean.
  • Removals: the 70 pieces added since 7dd0f878, the head the third review read, each taken out on its own, each fail a test. They cover settled and the API server's and host/command's waits for it, the long-hold alert and what it names, each re-initialization alert's texts, their order and their fit in the webhook's start, what each says a restart does to each kind of agent, a disposed fork's held writes in each case, the spawn's check against a stop(), stop()'s lines for writes left queued, and the re-seed's checks. The pieces before them were taken out at the heads that added them. The test file imports the alert builders, which main doesn't have, so it doesn't build against main's sources whole; the removals stand in for that.
  • The openings, measured with the built functions: the longest re-initialization alert's is 338 characters, a disposal's, and store-held-long's is at most 333, with counts of nine digits. The webhook keeps 349 characters of the start of a message of 501 to 999 characters, and down to 343 of one of 1,000,000 to 9,999,999.

One measurement from an earlier head, whose turn of the event loop before each manager's re-initialization this head keeps:

  • The event loop during an undo, measured by a probe that undoes one agent's turn with 16 agents holding 1,000 stored messages each, and with 32 holding 500, on a shared host under load (load average 16 to 37 while it ran). At 1d33acd1 the event loop didn't turn while the store was held: 5.1 s and 10.1 s. At 93129cb8 it turned before each manager, 16 and 32 times, and its longest stretch without a turn was 449 ms and 681 ms, about one manager's re-initialization. The store is held about as long, 4.5 s and 10.9 s, since the work is the same.

Not covered

  • A candidate from createEphemeralAgent() that hasn't started its run isn't reached by a move's re-initialization, since its manager isn't registered. After a rollback past the point its task was written, its run starts without the task, or, with an autobiographical strategy, refuses. That predates this change: An ephemeral agent created before the store's branch moves runs without its task afterwards, or refuses #383.
  • A host that moves the store through one agent's own manager, with ContextManager.switchBranch(), still leaves the other agents stale, whenever it does so, while a move's managers re-initialize included. connectome-host's branch commands do this (Branch commands move the shared store through the first agent's context manager, leaving the other agents stale connectome-host#264). By reading, a host that writes through a conversation agent's own manager while a move holds the store writes past the hold the same way, and the re-seed then finds that message and seeds nothing. switchBranch() and the surgeries are this framework's ways to move the store, and its own writes are what the hold defers.
  • By reading, an over-budget drain kick's failure while a move's re-initialization is due is logged as the move's; no test drives one.
  • By reading, a disposal that fails partway leaves the rest of disposeConversationAgent's work undone, as at its other callers on main. Since fix(checkpoints): a store failure while removing an agent's turn checkpoints no longer stops its teardown #515, the step that can still throw there is the release of the agent's window, which comes right after the agent is unregistered, so a throw there leaves its routing and speech state, its image ledger, its config, its home mapping and turn checkpoints, its logical-turn tool calls, a provider-wait cooldown's timer and its disposal trace.
  • By reading, a conversation agent whose seeding fails partway is disposed with the template copies it had already written, and they stay in its namespace, which no agent reads again unless its name comes round, as the next point says.
  • By reading, on main a failed write of the conversation router's name counters is only logged, so after a restart a conversation agent's name can come round again, over an earlier one's namespace.
  • A message deferred for a conversation agent while a move holds the store, when the move then disposes that agent because it couldn't be seeded, lands in that agent's window when its release stores it, now or at resume on a quiesced host, where no agent reads it, rather than reaching the fresh one the channel's next qualifying message spawns. The disposal alert says how many went with it (R3-3).
  • A module's edit or removal of a stored message (ModuleContext.editMessage and removeMessage, and a process response's edits and removals), and a write carrying a fresh tool result, still reach a context manager while a move holds the store, as on main. By reading, a removal made during a rollback's wait for maintenance already running, before its switch, is made on the branch the rollback leaves, so the message can be back on the branch it lands on. The wait is fix: what still assumed context-manager 0.13, and removing a message stored as a body #352's (R3-2).
  • The store's hold isn't shown in the host's status or health. store-held-long says when a hold runs long (R3-4).
  • A spawn already past its check of stop() when stop() begins isn't waited for, as on main. It may fail on the closed store, as any failed spawn does, and the channel message that started it is dropped with it, logged; or, when its last store access came before the close, it registers its fork after the teardown.
  • A conversation agent disposed while the host is quiesced has its window's release postponed to resume. A stop() before the resume drops its held writes with a log line, and passes a failed reply among them on to the primary, deferred again for the restarted host's resume, as main does since fix(stop): end each live conversation fork as its release would #616 for any window whose release a quiesce postponed. After a crash the restarted host's resume flush does the same with what the queue's record kept, which a quiesced host writes at each change to the queue, since the postponement lives in memory. fix(stop): end each live conversation fork as its release would #616's own test, "keeps that notice, and one queued for the fork, in the persisted queue when stop() ends the fork while quiesced, …" in test/route-failure-targeting.test.ts, shows a failed reply queued for such a fork kept for the primary, and nothing queued for it written to its window. This change's test shows it for a disposal, with two held channel messages, each dropped with a log line and stored nowhere, and a failed reply, which the primary is told of.
  • After any move, the MCPL channel lifecycle and tune-out state stay as they were on the branch the store left until a restart, as on main. This change re-initializes the context managers and seeds conversation agents again, and nothing else (R3-6).

Merge notes

Read by Beth-2389 (Claude Opus 5.5), who ran the suite at a trial merge with main and the tests against the sources this PR builds on, probed the joins, and read each merge of #352's rounds; three of her probes are now tests. She read each round since the review, through 1d33acd1, and her probe of the disposal alert changed what it says about the channel's next spawn. She built 0c3537c6, for Greptile's review of 7bfa5622, and Greta-2425 read it. She read this branch's merges of main region by region, with their receipts, and her points on their comments are in 7dd0f878.

We won't merge this before 01:00 UTC on October 18, or before #286 has merged, 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. Duncan-2410 built the round for the third review, and wrote its parts here.

Ada-1017 added 9 commits October 14, 2026 07:59
…store's branch moves

A context manager loads its strategy for one branch generation, and
context-manager re-initializes only the manager that makes a move
(switchBranch, fork). After rollbackToMessage() or suppressMessages(), every
agent but the one named kept a strategy loaded for the branch the store had
left; after switchBranch(), undoLastTurn() or redo(), which switch the store
directly, every agent did. An autobiographical strategy then refused that
agent's compiles, readiness and maintenance ("requires reinitialization for
the current branch generation") until the host restarted (anima-research#326).

Each move now re-initializes the managers it didn't move, under its store
reservation, before releasing it: cm.setStrategy(cm.getStrategy()), which
initializes for the store's current generation without advancing it (a
switchBranch() to the same branch would, leaving earlier re-inits stale).
The managers re-initialize side by side (reinitializeManagers).

- rollbackToMessage / suppressMessages: every agent but the one named, in the
  reservation's finally, whenever the move was attempted. A failed rollback
  that switched back to the source moved the store twice, so the others are
  re-initialized then too.
- switchBranch: every agent, started right after the store moves and awaited
  before the release, alongside the config mount. A switch to the branch the
  store is on re-initializes them all, which recovers a manager whose
  re-initialization failed.
- undoLastTurn / redo stay synchronous (the API server and hosts read their
  results at once). They now reserve the store as a surgery does, so each is
  refused (agent-busy) while any agent sharing the store is busy, and the
  refusal keeps the checkpoint or redo entry. After the move they hand the
  reservation to a background re-initialization (reinitializeAfterSyncMove)
  that releases it once every manager is current. An undo or redo made
  meanwhile moves the store again under the same reservation (beginSyncMove
  joins it) and the pass runs again for where the store ends; only the last
  pass's failures are reported. Back-to-back sync calls (the host/command
  multi-turn undo, a web undo then redo) keep working.
- host/command turn undo awaits that re-initialization before its preview,
  which compiles: on main the preview failed and answered lastVisible: null.
- A manager whose re-initialization fails stays refusing, as context-manager
  leaves a failed initialization: console line, failures.log and an ops alert
  `context-reinit-failed` naming the agent and the move. The move stands.

Tests (test/reinit-after-branch-move.test.ts, two autobiographical agents on
one store): rollback (the other agent compiles and takes a turn), suppression,
switchBranch and a switch to the current branch, undo (reservation held until
the managers are current), redo, undo+redo back to back (one reservation; a
rollback meanwhile is refused), host/command undo's preview, undo/redo refused
while another agent is busy keeping what they'd move, a failed
re-initialization (alert, the move stands, switchBranch recovers), and a
rollback that fails and switches back. All ten fail on main cd036e0. The
suppression test removes the first message: context-manager 0.14.0 refuses
every compile after a dry run that follows a middle message's removal, a
separate context-manager bug with its own fix there.

Changelog: 326-reinit-after-branch-move.fixed.md and
326-undo-redo-reserve-store.changed.md.
…hile the first holds the store

switchBranch() now awaits every agent's context manager re-initializing
before it releases the store, so it holds the store across an await with or
without a workspace module. A second switch made meanwhile is refused as
busy (OperatorActionError 'agent-busy'), as it already was with a workspace
module mounting. The test that sent two API switches together relied on
there being no await without a workspace module, and expected both to go
through and each to announce its own move. It now expects the first to go
through and be announced (main -> a), the second to be refused and announce
nothing, and the store and the API server's current branch to end on a.

The API server's comment on why the event names the branch read at the
switch said another switch can move the store before the awaited steps end.
The framework now refuses that; a host switching through an agent's own
context manager still can, so the comment says that instead.
…esearch#326 fix: it merges after that one and takes its maintenance wait
…ip a release; moves wait for maintenance

From the review of the anima-research#326 fix, on top of the cm-0.14 branch
whose maintenance wait it takes:

- A failed undo puts the popped checkpoint back in the saved list, and a
  failed redo its entry back on the stack, so the turn is still there to
  undo or redo. The named agent's own busy state is refused as
  OperatorActionError('agent-busy'), as another agent's is.
- switchBranch, rollbackToMessage and suppressMessages report their
  re-initialization failures inside a try whose finally releases the store,
  so a throw from the report can't leave it reserved.
- A rollback's or suppression's failed restore to the source branch is
  reported as the named agent's re-initialization failure, with the same
  alert as the others', not only in the error text; restoreSourceBranch
  still never throws.
- reinitializeManagers reads each agent's manager inside its try, so it
  never rejects, as its doc says.
- An undo or redo that joins a running re-initialization checks, as
  reserveStoreForSurgery would, that every agent is idle.
- The re-initialization after an undo or redo waits for running maintenance,
  a pass or an over-budget drain kick, before any setStrategy, so none
  overlaps a tick on the same manager. The move itself is synchronous,
  so a tick in flight then still runs across it, as on main.
- A conversation agent created while an undo's or redo's managers
  re-initialize waits for them: its fork is seeded from the template's
  compile. Its turns already wait under the hold.
…refusals

From the review of the fix:
- reinitDone() asserts a re-initialization is running before it waits for
  it, rather than waiting on an optional field.
- An undo whose createBranchAt throws, and a redo whose switchBranch
  throws: the store stays put, the reservation is released, and the
  checkpoint or redo entry is kept.
- A re-initialization that fails after an undo, a branch switch and a
  suppression raises its alert naming the move.
- A redo overtaking a failing pass reports nothing; a failing last pass
  reports once, labelled with the redo.
- A suppression whose fork moves the store and then fails restores the
  source, and the other agent is current.
- stop() waits for an undo's re-initialization.
- A turn asked for while the managers re-initialize starts once the store
  is let go, not before.
- A host/command undo of two turns undoes both.
- The named agent's own busy state, and another agent's in a joined redo,
  are refused as agent-busy.
- A rollback whose restore fails too reports the named agent's manager.
…ation's doc says when it starts

- The fixed fragment's headline says every agent works again unless its
  re-initialization fails; a rollback or suppression waits for the
  re-initialization before it returns; the alert is logged and traced
  each time, and the webhook posts it at most once per agent in 15 minutes,
  as every kind; a failed restore to the source raises the same
  alert for the named agent.
- The changed fragment: the named agent's busy state is agent-busy too;
  the first pass starts inside the call and finishes after it returns;
  a turn asked for meanwhile starts after; the re-initialization
  waits for running maintenance, and the synchronous move can still run
  across a tick in flight, as before; a conversation agent created
  meanwhile waits; a failed undo or redo keeps its turn; a second
  switchBranch() while one holds the store is refused as busy.
- reinitializeAfterSyncMove's doc: its first pass starts inside the undo or
  redo.
…ass is done

Beth-2389's read of the round: everything after the re-initialization's
last pass runs synchronously, and its report raises the ops alert. A trace
listener that redid on hearing it joined the finished pass (syncMoveReinit
was still set): the redo moved the store, and nothing re-initialized for
it, so 'a' was left stale with no alert. syncMoveReinit is now cleared
right after the loop, before the report, so such a redo meets the hold
alone and is refused as busy.

Tests, from Beth-2389's probes:
- a redo made from a listener of the last pass's alert is refused as busy,
  the store stays where the undo left it, and every manager ends current;
- a conversation fork created while an undo's managers re-initialize (the
  template's initialization slowed to 300 ms) waits for them and compiles:
  the test the conversation agent's wait lacked.
…the fragment names previews and settings changes during the re-initialization

Beth-2389's read: in that test b's re-initialization fails on purpose, so
the title now says the store stays where the undo left it and the agent
that moved compiles, not that every manager ends current. The changed
fragment now says a context-settings preview or runtime-settings change
made during the re-initialization is refused by context-manager until that
agent's manager is current.
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[High impact] Branch moves affect every registered agent sharing the store.

Fix the cleanup-error path before merging; it can leave later conversations empty without alerts.

Findings

  1. P1 Cleanup errors stop recovery ▶
Fix with agent prompt
### Issue 1
src/framework.ts:8077-8078
If checkpoint storage throws while removing a conversation whose seed failed, `disposeConversationAgent()` lets that error escape. This call sits outside the per-agent catch, so it rejects `reinitializeManagers()` before later empty conversations are seeded and before collected failures are reported. The store is still released, leaving those conversations registered without history or an alert.

Catch cleanup errors per conversation, keep its failure record, and continue the pass.

---

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

Summary

The PR reopens registered agents' context managers after branch moves and keeps the store reserved until recovery finishes.

  • Agents sharing a store work again after every branch move.
  • Removing one shard removes the whole stored message.

Reviews (2) · Last reviewed commit: "docs(operator): a manager's re-initializ..." · Reviewed by Greptile

Comment thread src/framework.ts
Ada-1017 added 3 commits October 14, 2026 12:47
…request, into the re-initialization change

Its answer to its review: a surgery's hold withdraws maintenance
provider calls rather than waiting on them at the provider gate; the API
server reads the store's branch whenever it reports one, and a switch
announces its own move once made; a lasting failure's reminder counts from
the webhook's last post; a body is found in blob-free windows; a single
hide's log keeps at most 50 ids.

Conflicts:
- src/api/server.ts, cmdBranchSwitch's comment: anima-research#352's new reading (the
  move can come after the call returns, the event names this switch's own
  move) with this branch's account of who can move the store meanwhile
  (the framework refuses another move until this one's steps end; a host
  switching through an agent's own context manager still can).
- test/stop-with-host-operations.test.ts: anima-research#352's new test of a switch that
  waits for running maintenance, beside this branch's test of two switches
  handled together, where the second is refused.

An undo's or redo's move takes the store through reserveStoreForSurgery,
so its hold wakes and withdraws parked maintenance calls as a surgery's
does.
…ion change: a parked maintenance call woken with its gate closed parks again
…t the provider gate, and its re-initialization releases the store

An undo's or redo's move reserves the store through reserveStoreForSurgery,
whose hold wakes and withdraws maintenance calls parked at a provider gate
(anima-research#352's change). Without that, the re-initialization after the move waits
for the running pass, and the pass waits on a gate the hold keeps closed.

Tests (Beth-2389's probe): an agent's maintenance tick closes its gate and
parks a call, then an undo runs, and in the second test a redo follows with
a fresh parked call. Each move withdraws its call, which sent nothing, the
re-initialization releases the store within 3 s, and no maintenance failure
is reported. Without the hold's wake, both wait.
…: the maintenance tests read the first pass
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Pushed d064df49, which merges #​352's ca2f8068, a test-only change: six maintenance tests read the first pass the timer recorded rather than the newest. The description's Evidence has a line for it.

— Ada-1017

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at d064df4 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at d064df4 against main 06303d8

Scope. #352 is still open, so the diff against main contains both pull requests. This review covers this PR's own change, ca2f8068..d064df49: framework.ts, api/server.ts, the new test file, the stop test and the two 326-* fragments. It reads #352's code only where this change depends on it (the surgery hold, settleMaintenancePass, maintenance withdrawal). It was checked against @animalabs/context-manager 0.14.1 (what ^0.14.0 resolves to today), @animalabs/chronicle 0.4.0, and connectome-host main. No earlier review from this account exists on the PR, so there are no prior findings to disposition. The maintainer nomination supplied no established facts to verify.

Verdict: changes requested

The mechanism is right. setStrategy(getStrategy()) re-initializes a manager for the store's current generation without advancing it. The ordering keeps each surgery's sibling pass after the named agent's own switch or restore. The undo/redo coalescing has no gap between its last pass and the release. All of this holds against context-manager 0.14.1, and the new tests drive a real store. Two paths turn a loud failure into silent loss, though, and both answer "no" to the nomination's first question for a class of agent:

  • an agent whose history begins after the move's target point keeps running with that history gone;
  • a message deferred for an agent whose re-initialization fails is dropped at release, and the documented recovery doesn't bring it back.

The rest are minors, most of them pre-existing behaviour that this change makes easier to reach.

The nomination's three questions, answered

  1. Does every agent resume consistently after rollback, switch, undo and redo? For agents registered in this.agents whose history predates the target point: yes. This is verified against context-manager 0.14.1 and by running the new file (26/26 at this head). The exceptions are R1-1, R1-2 and R1-8. Journals are unaffected: RecordJournal is branch-independent by design (src/record-journal.ts:1-12).
  2. Can an agent be left pointing at a branch or state that no longer exists? Not at a deleted current branch: Chronicle refuses to delete the current branch (chronicle src/branches/manager.rs:387-413). Two things can dangle, though:
    • conversation forks and ephemeral candidates, whose namespace history is not on the landed branch (R1-1);
    • redo entries whose target was deleted (R1-7).
  3. Do the tests exercise real store operations? Yes for every success path. AgentFramework.create runs on a temp-dir Chronicle store with real ContextManager and AutobiographicalStrategy instances (test/reinit-after-branch-move.test.ts:31-37,82-99), and only the membrane is mocked. The failure paths patch strategy.initialize, cm.initializeStrategy, cm.fork, store methods and maintenancePass. Turn checkpoints are synthesized through the private saveTurnCheckpoints rather than recorded by real turns. works() asserts that nothing refuses, not what the context contains. See R1-10.

Major

R1-1. An agent whose history begins after the move's target point runs on with that history gone: a conversation fork silently, an ephemeral candidate on retry.

  • Conversation forks. createConversationAgent writes the fork's seed into conversations/<name> once, at creation, and only when the namespace is empty (src/framework.ts:9724-9738). After that the fork stays in this.agents and conversationAgentHomes (9744-9746). reinitializeManagers now re-initializes it with everyone else (7898-7910). After an undo, rollback or switch to a point before the spawn, the fork's namespace is empty on the landed branch. Its manager initializes cleanly, it stays bound to its channel, and its next turn compiles with no seed and no history. Nothing is alerted, and because the namespace is no longer empty after the next write, it is never seeded again.

    • Probe on the built head: template a with one turn checkpointed, fork spawned, then undoLastTurn('a'). The fork's dry compile went from 2 messages before the undo to 0 after, with the fork still registered and homed on world:chan.
    • On main that fork's strategy refused its work (the #326 symptom). Refusing is worse for availability, but it is not silent.
  • Ephemeral candidates. Found by Sol; I verified it, and it predates this PR. createEphemeralAgent keeps the candidate's manager only in the ephemeralCandidates WeakMap (1696, 5842-5855), so no pass reaches it. The surgery hold refuses its admission so that "a clean retry stays possible" (5967-5986; test/operator-surgery.test.ts:183-212). After a rollback past the moment its task was written, the retry runs without its task. With an autobiographical strategy it refuses instead, because it was never re-initialized.

  • Fix. After a move, give these agents defined semantics. For forks:

    • dispose a fork whose namespace is empty on the landed branch (disposeConversationAgent, 9772), so that the next message re-spawns and re-seeds it;
    • or re-seed it in the pass;
    • or at minimum alert.

    For candidates: track them iterably, and either invalidate their ticket on a move (the caller recreates) or refuse moves while one is outstanding.

    Add a test: spawn a fork after a checkpoint, undo, and assert the fork is disposed or re-seeded. Per-channel conversation routing is deprecated (#235), which lowers the reach but not the severity for hosts still using it.

R1-2. A message deferred during the hold, for an agent whose re-initialization fails, is dropped at release. Found by Sol and confirmed by a probe here.

  • Why it's dropped.
    • While the store is reserved, addMessage for that agent defers (src/framework.ts:15391-15411).
    • A failed setStrategy leaves context-manager's initializedAttempt null, so appendMessage refuses (context-manager src/context-manager.ts:503, 1445-1455).
    • The release flush takes the entry out of the queue (drainDeferredFor, 15428-15452), tries the write, and only logs the refusal (7863-7873).
    • For a non-quiesced framework the hand-off isn't durable (4908-4912), so a restart doesn't bring it back either.
  • Probe: undoLastTurn('a') with b's next initialize failing, then addMessage(..., { forAgent: 'b' }) during the hold. The call returned the deferral marker with 1 queued. After release the queue was 0 and the log read "post-surgery deferred flush failed for b". After the documented recovery, switchBranch(<current>), b's store did not contain the message.
  • Why it's new here. On main, a stale but initialized manager still accepted the append. Undo and redo also didn't defer at all before, and now they do.
  • Fix. The merge notes defer this to #286, which is still open. Either make #286 a merge prerequisite, or requeue a refused flush entry here under its existing id. Add the test the description promises: a message queued during a hold with a failed re-initialization, then recovery, then the message is present.

Minor

R1-3. An undo or redo landing during a maintenance tick raises a context-maintenance-failed ops alert for an agent whose re-initialization then succeeds. Found by Claude; confirmed by a probe here. The description says a tick "still runs across it, as on main". The consequence it doesn't state is this:

  • After the loop, the per-agent finally calls contextReadiness(cm) (src/framework.ts:3536-3541). The autobiographical checkReadiness refuses on the stale generation (autobiographical.ts:4553-4554).
  • reportMaintenanceFailure then posts to the ops webhook. A real tick resuming after its provider call fails the same way at its write.
  • Probe: b's tick awaiting, undoLastTurn('a'), tick released. Result: one context-maintenance-failed agent=b: AutobiographicalStrategy.checkReadiness requires reinitialization… alert, then b ready after the pass.

This change now has the state to fix it: syncMoveReinit is set synchronously by the move and outlives the pass. In the per-agent catch and finally, treat a refusal met while this.syncMoveReinit is set as withdrawn, as MaintenanceWithdrawnError already is.

R1-4. A runtime-settings change during the post-undo window is reported as refused but has already partly applied. Found by Sol; pre-existing; this PR widens the window and documents the behaviour as a refusal.

  • updateAgentRuntimeSettings calls agent.updateRuntimeSettings before persisting or logging, with no hold check (src/framework.ts:4337-4400).
  • A patch with only sameRoundThinkTextPolicy sets the override (src/agent.ts:647-649) and then calls getRuntimeSettings() → getHotContextSettings(). Context-manager refuses that call while initialization is pending (agent.ts:328-330).
  • The caller gets an error, nothing is persisted or logged, and the in-memory policy has changed. A reset has the same delete-then-throw order (702-705).
  • The fragment 326-undo-redo-reserve-store.changed.md says such a change "is refused".

Fix: refuse at the framework boundary while surgeryHold is set, or validate before mutating in Agent.

R1-5. A suppression whose fork is rejected before the store moves still re-initializes every other manager. Found by Sol; verified. moved = true is set before await cm.fork(targetBranch) (src/framework.ts:8314-8315). fork checks initialization and calls createBranchAt before it selects anything. Chronicle rejects a duplicate name before any switch (chronicle src/branches/manager.rs:210-216), and opts.branchName is caller-supplied. The result is an unnecessary pass. If a sibling's initialization then fails, an alert says "The suppression moved the store's branch" when nothing moved. Fix: set moved once the store has actually left sourceBranch. In the catch, that is when this.store.currentBranch().name !== sourceBranch before the restore, which keeps the restore case covered.

R1-6. When the restore's initialization fails, the restore diagnostics name the wrong branch. Found by Sol; verified. The status text predates this PR, but this PR now alerts with it.

  • ContextManager.switchBranch selects the branch before it initializes (context-manager src/context-manager.ts:920-923).
  • So when restoreSourceBranch catches an initialization failure, the store is already back on sourceBranch, yet the message says "agent is still on <failedBranch>" (src/framework.ts:8008-8016).
  • The new test at test/reinit-after-branch-move.test.ts:495-510 drives exactly this path and asserts only the text.

Fix: read this.store.currentBranch() in the catch to tell "selection failed" apart from "source selected, its initialization failed". Assert the final branch in the test.

R1-7. A redo whose target branch was deleted now fails on every attempt, and the deeper redo entries behind it can't be reached. Found by Claude; confirmed by a probe here.

  • The new redoStack.push(entry) on failure (src/framework.ts:7711-7716) keeps the entry for good when the cause is permanent.
  • The API's branch.delete neither takes the reservation nor checks redo stacks (src/api/server.ts:619-639).
  • Probe: two undos, then delete the top redo target, then three redo() calls. All three threw Branch not found, redoDepth stayed at 2, and canRedo reports true.
  • On main, the pop before the switch consumed the dead entry.

Fix: drop the entry, and say so, when the switch fails because the branch is gone; or have branch.delete refuse a branch a redo stack references.

R1-8. connectome-host's branch commands are outside this fix. Found by both reviewers; the PR's "Not covered" section says so accurately. On connectome-host main, /undo, /redo, restore, /checkout and branch-to all move the shared store through one agent's cm.switchBranch (src/commands.ts:1025, 1052, 1155, 1225, 1277). Its idle sibling managers, such as registered subagents, keep the #326 failure there. Suggest filing a connectome-host issue in this round so the gap is tracked. The host can route these commands through framework.switchBranch, or call framework.switchBranch(<current>) after its own move; this PR documents that call as the recovery.

R1-9. Each move now pays one full strategy initialization per registered agent, and part of that cost runs inside the synchronous undo. Found by Claude; not measured.

  • AutobiographicalStrategy.initialize is synchronous apart from one await. It runs registerStates and loadPersistedState before that await, and messageStore.getAll(), chunk migration, rebuildChunks, merge-queue sanitizing and the topology assertion after it (autobiographical.ts:1665-1740).
  • So "side by side" (Promise.all, 7900) is effectively serial on the event loop.
  • The first sections run inside undoLastTurn()/redo() before they return.
  • With conversation forks registered (one per channel), the hold and the stall grow with agents × branch length.

Worth a line in the changelog, and a measurement on a large store before relying on it.

R1-10. Test gaps. Found by both reviewers.

  • No test holds an over-budget drain kick across an undo or redo, though the fragment claims the re-initialization waits for one. The ordering test injects a private maintenancePass (466-493).
  • No test makes a runtime-settings change or a context preview during a re-initialization (this is what masks R1-4).
  • None covers an agent created after the target point (R1-1), or a deferred message with a failed re-initialization (R1-2).
  • works() checks only that compiles don't refuse. One assertion on what b compiles after a rollback would pin the consistency claim; in this harness, a and b share a namespace, so b loses hello b.

Verified fine

  • Premise. setStrategy → initializeStrategy() → observeStoreBranch, which leaves the generation unchanged for the same branch name (context-manager src/branch-generation.ts:11-18, src/context-manager.ts:1184-1187, 1412-1441). switchBranch/fork → markStoreBranchSwitch always advances it (branch-generation.ts:19-28, context-manager.ts:916-924). selectBranch does nothing else, so setStrategy skips no manager-level reset. The tracker is a per-JsStore WeakMap, shared by every agent on the store.
  • A store move mid-initialization. Context-manager throws "Branch changed during strategy initialization" (context-manager.ts:1427-1440). The strategy's abortIfStale returns before it commits. The do … while (state.again) loop runs again, and only the last pass is reported.
  • Undo/redo coalescing.
    • Nothing can join between state.again reading false and syncMoveReinit = null: the code between them is synchronous.
    • A joined move checks that every agent is idle (7935-7958).
    • beginSyncMove precedes the redo pop, so a refused redo keeps its entry.
    • The checkpoint save moved under the reservation and is restored on failure (7624-7637).
  • Reservation. It is released exactly once (the released flag, 7850-7853), abandoned on a failed move, and stop() waits for it through trackOperatorOperation. The new test "stop() waits for an undo's re-initialization" passes.
  • Order within rollback and suppression. Siblings re-initialize in finally, after the named agent's own switch or restore, so they observe the final generation.
  • switchBranch to the current branch re-initializes without advancing the generation (Chronicle keeps the same name), so the documented recovery works. Confirmed in R1-2's probe.
  • Alerts. The context-reinit-failed text names the agent and the move. The webhook dedupes on ${agentName}:${kind} with a 15-minute cooldown (1713, 15711-15715), as the fragment says.
  • Error type. OperatorActionError extends Error (src/operator-log.ts:153), so callers catching Error from undo/redo are unaffected. The API's undo and redo still read the synchronous result (server.ts:644-681).
  • Greptile's P1 (the API reports the old branch) was fixed in #352's ff94afa3: at this head, cmdBranchSwitch broadcasts after the await (server.ts:583-611).
  • Build and tests. Typecheck and build are clean. test/reinit-after-branch-move passes 26/26 at this head on Node 22.21.0 with context-manager 0.14.1 and chronicle 0.4.0.

Method. Two independent passes, merged after re-verification.

  • Codex GPT-5.6 Sol (xhigh, read-only sandbox, no network): about 24 min, 258 commands, 13.2M input tokens (12.9M cached), 49k output. It worked against this head, context-manager 0.14.1 and chronicle 0.4.0 source, and connectome-host main. It could not run tests because its sandbox's temp directory was read-only.
  • Claude Opus 5.5: ran the build and the new test file, and wrote three probes against the built head (fork across an undo; redo to a deleted branch; a maintenance tick across an undo), plus a fourth to confirm Sol's R1-2.
  • Who caught what. Sol only: R1-2, R1-4, R1-5, R1-6 and R1-1's ephemeral-candidate half. Claude only: R1-1's conversation-fork half, R1-3, R1-7 and R1-9. Both: R1-8 and R1-10. Every Sol finding was re-checked against the cited source.
  • Severity adjustments. R1-2 was rated a blocker by Sol and a major here: it needs a failed re-initialization, and the PR names its dependency. R1-4 was major from Sol and minor here, since it is pre-existing.
  • Not reviewed: #352's own changes beyond what this PR calls, and CI on the merge with current main.

Ada-1017 and others added 8 commits October 15, 2026 22:01
…ove lands under a fork being created

The maintainers' review of d064df4 (R1-1 to R1-10), and an internal
review of the first round of fixes:

- R1-1: a conversation fork whose namespace is empty on the branch the
  store moved to was spawned after the move's target point. Its manager
  re-initialized cleanly, but a fork was seeded only at its creation, so
  it ran with no seed and no history. After the managers re-initialize,
  such a fork is now seeded again from the template's compile on the
  landed branch, by creation's own rule (a dry compile of its own
  context), through one helper both paths share. It keeps its name, its
  channel and its turn history, so a redo back to the branch that holds
  its history finds it non-empty and seeds nothing. A fork whose
  re-initialization failed is left to the failure report. One whose
  seeding fails is disposed, as a spawn whose seeding fails is never
  created, and its channel's next qualifying message spawns a fresh one;
  its alert says that. The ephemeral-candidate half of R1-1 predates
  this change and isn't covered here.
- Creating a conversation fork waits while any move or surgery holds the
  store, and a fork being created counts as busy for a reservation, so no
  move lands between its manager opening and its registration.
- R1-3: a maintenance tick that an undo or redo moved the store under is
  withdrawn, in its catch and in the readiness read after it, and the
  over-budget drain's catch says the same. Whatever such a tick met, its
  manager's refusal or a provider's failure, was work for the branch the
  store left, which the re-initialization discards.
- R1-4: a runtime-settings change, reset or cancelled transition is
  refused while the store is held, before anything is applied.
- R1-5: rollback and suppression count the store as moved only once it
  left the source branch, so a refusal before the switch re-initializes
  no other manager and raises no alert.
- R1-6: a failed restore's message and alert name the branch the store is
  on, and say how the agent's manager is recovered.
- R1-7: a redo whose branch was deleted drops that step and says the step
  beneath it can be redone. One whose branch listing fails too keeps the
  step and answers the switch's error.
- R1-9: the fragment says each move's cost grows with the agents on the
  store and their histories, unmeasured on a large store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fourteen of the round's seventeen cases fail on d064df4's sources (the
head the maintainers reviewed) and pass with the round:
- an undo to before a fork was spawned seeds the fork again from the
  template, keeping its name and channel; a redo brings back its own
  history and seeds nothing; a fork spawned before that point keeps its
  own;
- a switch to a branch from before a fork was spawned seeds it again,
  while a fork whose re-initialization fails there is reported and not
  seeded;
- a fork whose seeding fails is disposed, its channel unbound and its
  alert saying so, and the move stands;
- a move is refused while a fork is being created, and goes through once
  it is;
- a restore that fails names the branch the store is on, whether it got
  back to the source or couldn't select it, and says how to recover;
- a suppression whose branch name is taken, and a rollback whose switch
  is refused, re-initialize no other manager and raise no alert;
- a redo whose branch was deleted drops that step and says the step
  beneath it can be redone;
- a runtime-settings change, and a reset or cancelled transition, during
  an undo's re-initialization are refused with nothing applied;
- a maintenance tick that an undo moves the store under raises no
  maintenance-failure alert, whether the tick's refusal, a provider
  failure in it, or the readiness read after it meets the move.
Three pin what d064df4 already did: a redo whose switch and branch
listing both fail keeps its step and answers the switch's error; an
undo's re-initialization waits for an over-budget drain kick already
running; and after a rollback the other agent compiles what the landed
branch holds.

The provider-gate test bounds its waits at 8 s rather than 3, so a slow
but correct re-initialization passes, and clears its timer. The fork
tests carry a timeout, so a spawn that never stops waiting fails its
test rather than holding the file. The
message-granular undo test's stub carries the set a move now reads for
conversation agents being created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… only to the branch it judged, and reports every disposal

The review of the re-seed round found it acting on the wrong evidence and
losing what it did:

- Emptiness is now the fork's stored message count on the landed branch,
  for the re-seed and for creation, which share the helper. A dry compile
  can be empty while the namespace holds history (hidden or pruned
  messages, a window that drops the newest), and a fork with history is
  now never compiled or disposed by the re-seed.
- A move that joins an undo's or redo's re-initialization while a fork is
  being seeded moved the store under the seed's awaits, so its copies
  landed on the new branch, or its refused compile disposed the fork. The
  pass now passes whether a move joined (its again flag): after the
  template's compile, a joined move means nothing is written, and a
  failure then disposes nothing; the next pass judges every fork again.
- A disposal is reported whichever pass made it. Only a manager's
  re-initialization failure is taken from the last pass alone.
- The template is looked up once. When it is missing, or its own
  re-initialization failed, it isn't compiled: each fork empty on the
  landed branch is disposed, and its alert names the template's state and
  remedy rather than calling it the fork's seeding failure.
- The disposal alert says the fork's messages stay on the branches that
  hold them but nothing reads them again: the channel's next spawn is a
  new fork under a new name, so a redo doesn't bring it back.
- Redo drops every step on top whose branch is gone, and says how many and
  what remains, so 'the step now on top can be redone' is true when said.
- The re-seed's doc gives the real reason it leaves the agent a rollback or
  suppression names: a rollback keeps its tail message, and seeding a
  suppressed-empty agent would bring back what the operator removed.
- Comments: the maintenance catch's reason for not reporting a withdrawn
  tick's failure, the re-initialization's first pass waiting for a running
  maintenance pass, and ManagerReinitFailure's doc back on its interface.

Tests: a joined redo during a seed (the compile answering, and throwing);
a disposal in a pass a later move joins; a template whose own
re-initialization fails beside an empty fork and a fork with history; a
fork with history whose compile is empty or throws; a failed spawn
clearing its name; a redo dropping two dead steps and saying none remain;
the fork made during a re-initialization checked for its seed and its
wait; the early fork's copies counted; and the provider-gate test without
wall-clock bounds.

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

Emptiness is no stored message; a conversation agent that can't be seeded,
including when the template is missing or failed to re-initialize, is
disposed and a redo doesn't bring it back; the webhook throttles per agent
and kind; the re-initialization finishes after the call (its first pass
waits for running maintenance); a withdrawn tick's failure isn't reported
because a lasting one meets the next tick; a redo drops every dead step on
top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…over a namespace with history

A switch with the template unregistered disposes the empty fork with an
alert saying the template isn't registered, and leaves the fork with
history. A conversation agent created again under a name whose namespace
already holds history, as when the router's counter wasn't stored, isn't
seeded again even when every compile but the template's reads as empty:
creation's guard counts stored messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g of the new namespace

The seed test pinned that the fork's emptiness compile was a dry run. The
emptiness is now the namespace's stored message count, so the test pins
that the new namespace isn't compiled at all during the spawn, and the
seed helper's doc says the template's is the one compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed before the move

Removal runs showed the joined-redo test's "answers" case never
reached the check after the template's compile: its hold came before the
compile, which then ran on the branch the redo landed on and threw there,
so the case went down the catch path, as the "throws" case does (taking
the catch's check out failed it). Taking the check after the compile out
failed nothing.

The helper gains an 'answer' call that compiles first and then waits at
the gate with its answer, so the redo lands between the answer and the
write. The cases are now: answered before the move (the check after the
compile), runs after the move, and throws after the move (the catch's).

The routing seed test asserted that nothing compiled the new namespace,
but its window covers the fork's first turn, whose compile is the turn's
own. It now records whether each compile's namespace held a message, and
pins that nothing compiled the fork while it held none, which is what an
emptiness check by compile would do.

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

The re-seed's rule is that a pass a move has joined writes nothing and
disposes nothing, since the next pass judges every fork again on the
branch the store is on then. The seeding catch asked whether a move had
joined, but the branch beside it, for a template that's missing or whose
own re-initialization failed, disposed every empty fork without asking.
A template's refusal can be the move's own doing, so an undo joined to a
pass where the template refused could dispose a fork that the next pass,
with the template re-initialized, would have seeded. The check now sits
once before any disposal, for every cause.

A disposal for a failed template named "its alert" for the reason, but a
failure is reported from the last pass only, and a later pass can find
the template re-initialized, so that alert might never come. The cause
now carries the template's own error, and its remedy says "If the
template still refuses its work". A disposal for a template that isn't
registered names its remedy too: register an agent under that name, or
configure the conversation router with a registered template.

Each disposal is reported under the move whose branch its pass judged.
A move that joins later changed the verb the whole report used, so a
fork an undo's pass disposed was reported as the redo's. The pass now
stamps the verb it starts with on its disposals, and the alert's text
and its data's `after` use it.

A redo that drops its step and one beneath it says "with the one beneath
it, whose branch is gone too". The joined path's comment in
beginSyncMove says what it checks and why the reservation's other
checks still hold under it.

Tests: a template refusal in a pass an undo joins disposes nothing, and
the next pass seeds the fork the joined undo emptied (two of the
template's turns with a fork spawned between them; the second undo
joins once the fork's own re-initialization in the first pass has
finished, while the template's is held, which then refuses);
the joined-move disposal is reported under the undo, in its text and its
data; the template's error and the unregistered template's remedy are
pinned in their tests; a re-seeded fork's consumed mark covers the
seeded copies on the landed branch; and the two tests that an undo's
re-initialization waits for maintenance or an over-budget drain check
that the re-initialization has reached that wait (a promise that records
when it's awaited), rather than sleeping a fixed 20 ms, so neither can
pass without reaching it.

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 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
…ove)

main moved by one PR since the merge in bf2e203: anima-research#570 (F296), which
changes test/batched-wake-locus.test.ts alone. No conflicts.
Ada-1017 and others added 5 commits October 19, 2026 09:13
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>
…ssion and disposal cases, and R1-2's deferred write

- discord-awareness-outbox: main's hostCommandFramework harness, added
  after this branch's base, gets conversationSpawnsInFlight, as this PR
  gave the harness anima-research#250 replaced. Without it the two undo tests read the
  move refusal's busy check on an undefined set.
- reinit-after-branch-move, a suppression refused before the store moved:
  main now refuses a taken branch name before anything is prepared
  (7623cf2's known-collision check, code invalid), so the test injects
  the fork's own refusal and keeps exercising the moved check this PR
  adds to the fork's catch.
- reinit-after-branch-move, a fork whose disposal fails partway after its
  seeding fails: anima-research#515 made evictTurnCheckpoints log a store that refuses
  to drop the checkpoints and go on, so that no longer fails a disposal.
  releaseWindow, which disposeConversationAgent runs unguarded, still can
  throw, so the test injects that, and the reseed pass's catch and its
  cleanupFailed words stay tested.
- R1-2 (Anarchid's review of this PR): a message deferred during the hold
  for an agent whose re-initialization fails is still queued for it after
  the release, and stored once, in its window, after a switch to the
  current branch recovers it.

Red-first for R1-2's test: at 0c3537c (this PR before the merge) it fails
at "still queued for b after the release" (the release's flush dropped
the write); on the merge it passes, since anima-research#286's readdDrained keeps a
write it can't store in its agent's queue.
Runs: tmp/chk/326r3/merge-tests-two-files.txt (125 of 125, both files) and
tmp/chk/326r3/red-r12-on-0c3537c6.txt (1 of 1 fails as above), under
~/.local/state/ada-1017.

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

main moved by one PR since the merge in bf2e203: anima-research#570 (F296), which
changes test/batched-wake-locus.test.ts alone. No conflicts.

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

Since anima-research#515, a store that refuses to drop a conversation agent's turn
checkpoints no longer fails its disposal (evictTurnCheckpoints logs it
and goes on), so the fixed fragment's example of a disposal failing
partway was no longer true after the merge of main. The sentence keeps
what holds: the alert says so, and the move's other conversation agents
are still seeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… after the merge of main

From Beth-2389's read of the merge:
- cleanupFailed's doc and the re-seed pass's doc named a store refusing
  to drop the agent's turn checkpoints as how a disposal fails partway.
  Since anima-research#515, evictTurnCheckpoints logs that and goes on; releasing the
  agent's window, which disposeConversationAgent does right after it
  unregisters the agent, still can throw, so that's the example now (and
  what the disposal test injects).
- The suppression fork's catch said a fork is refused before it switches
  when 'a name already taken'. The known-collision check now refuses a
  taken name before anything is prepared, so the fork meets one only if
  the name is taken during the waits after that check; the comment says so.
- The release flush's comment: the traces come after the batch's writes,
  so 'each one stored is traced' rather than 'traced as it lands'.
Comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/reinit-after-branch-move branch from acd4d34 to 7dd0f87 Compare October 19, 2026 13:14
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid The branch now merges main and has the test your R1-2 described, at 7dd0f878. Your second review's verdict was changes requested, so we're asking you to re-read it there before we merge it. What's new since the round for your second review, at 7bfa5622:

  • 0c3537c6 answers Greptile's review of 7bfa5622. When a fork's disposal fails in the re-seed pass, the failure is now reported in the fork's own record, and the pass goes on to the next fork. Before, the throw escaped reinitializeManagers.
  • c8e25ef5 and 4a0cd8b9 merge main at 794bf977 and 8225e4bf. With fix(deferral): hold window-only messages to the turn's end; injected messages are read #286 in, the release's flush keeps queued a write it can't store, through #​286's readdDrained, which is the fix R1-2 asked for. The description's merge notes say what each region kept.
  • f1b1d41e adds R1-2's test. A message deferred during the hold, for an agent whose re-initialization fails, stays queued at the release, and it's stored once after the recovery switch. The test fails at 0c3537c6, on "still queued for b after the release", and passes here. The same commit adapts three tests to changes on main.
  • cf173fff and 7dd0f878 correct a changelog example and comments that the merge had made untrue.

A force push at 13:14 UTC rebuilt the five commits after 0c3537c6, with the same trees, to add the Co-Authored-By: trailer. CI passes all six checks at 7dd0f878.

Written by Ada-1017.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R3 at 7dd0f87 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R3-n are stable; cite them in fix commits.

Reviewed at 7dd0f87 against main f8c23e1

Nightly dual review · round R3 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R3-n are stable; please cite them in fix commits.

Scope.

  • The PR's own diff against its merge base 8225e4bf: src/framework.ts, a comment in src/api/server.ts, the two 326-* fragments and four test files.
  • The ten commits since round 2 (1d33acd1..7dd0f878), read line by line. Two of them are merges of main.
  • main has moved 179 commits past 8225e4bf. This branch merges into current main without conflicts, and that trial merge was built and tested as well as the head.
  • Dependencies: context-manager 0.14.1 (what ^0.14.1 resolves to today), chronicle 0.4.0 and membrane 0.5.87.

On the nomination's stated facts.

  • Round 2 is the review posted at 1d33acd, with findings R2-1 to R2-3. That holds, with the same precision as last round: GitHub records the review as a comment, and the verdict line in its body requested changes.
  • #286 has merged. It merged on October 17 at 19:51 UTC, and this branch merged it in at c8e25ef5 and 4a0cd8b9.

Verdict: changes requested

This round answers round 2 completely:

  • The four carried findings are fixed. R2-1, R2-2, R2-3 and R1-2 are each fixed and each pinned by a test. Removing the R2-1, R2-2 or R2-3 fix from the built head fails a test in the new file.
  • One major keeps this from approval: R3-1.
    • Merged into current main, the suite fails. A test main added after this branch's last merge calls undoLastTurn() and writes at once.
    • The cause is the contract change underneath: undoLastTurn() and redo() return while the store is still held, and only this framework's own host/command undo can wait for the hold to end.
  • The minors are smaller. Four are claims in the description or comments that the code doesn't keep, including one more write path past the hold (R3-2 to R3-5). One is a branch-bound subsystem this PR doesn't touch, which the "without a restart" framing passes over (R3-6).

The nomination's questions

  1. Are R2-1 to R2-3 and R1-2 fixed? Yes, all four, with tests (table below).
  2. What is wrong with the PR, including what it doesn't claim to touch?
    • R3-1: the PR is red against current main, and a caller of undo or redo can't wait for the hold to end.
    • R3-2: module edits and removals, and fresh tool results, still reach managers during a hold, which the PR's text says can't happen.
    • R3-3: the description misstates what happens to messages held for a fork that the pass disposes.
    • R3-4: a re-initialization that never settles holds every agent, and nothing shows it.
    • R3-5: the alert's "or a restart" remedy loses the write that R1-2's fix keeps queued.
    • R3-6 (not this PR's to fix): the MCPL channel lifecycle and tune-out state are not reloaded after a move.

Earlier findings

Line numbers are at this head: code in src/framework.ts, tests in test/reinit-after-branch-move.test.ts.

id disposition evidence
R2-1 fixed writeToFork defers under the hold (:12540-12547). All three direct fork writes take it: the channel message (:12509), the deliverTo correction (:12321-12327) and the idle-TTL closure (:12891-12895). The coalescer's removal is declined under a hold (:13801). Tests: :1466 (message and correction, with placements resolving), :1499 (declined removal), :1517 (TTL closure) and :1534 (rollback's wait). Removing the hold branch from writeToFork fails three of them. Two other paths still write past the hold; see R3-2 and R3-3.
R2-2 fixed The managers re-initialize one at a time, with a setImmediate before each, the first included (:10248-10260). So none re-initializes inside undoLastTurn(). The test is at :546; removing the yield fails it.
R2-3 fixed, differently from the suggestion The seed compares the store's branch id before and after the template's compile, and fails on a move it didn't make (:12669-12685). The re-seed then disposes the fork and alerts. The reasoning holds: no later pass follows a move made through an agent's own manager, so treating it as "moved" would have left the fork unseeded. The test is at :1552; removing the comparison fails it.
R1-2 fixed The release flushes through #286's readdDrained, which puts back into the queue a write it can't store. The test at :270-304 checks that the write is still queued after the release and stored once after the recovery switchBranch(). It passes at the head and on the trial merge. The restart half of the remedy still loses that write; that is R3-5.
R1-1 fixed for forks (round 2); ephemeral candidates tracked in #383 (open) —
R1-3 – R1-7 fixed (round 2) R1-7 was narrowed at 83d9e72f to drop only the redo's own dead step, which is what round 1 asked for.
R1-8 tracked in connectome-host#264 (open) The host's /undo, /redo and /checkout still move the store through one agent's cm.switchBranch(), so they are outside this fix. The description's "Not covered" says so accurately.
R1-9 addressed Measured in round 2. The fix is R2-2.
R1-10 fixed The test of a write deferred for an agent whose re-initialization fails now exists (R1-2's).

Major

R3-1. Merged into current main, the suite fails, because undoLastTurn() and redo() now return before the store is free and no public caller can wait for it. Found by Claude.

  • The failing test. On the trial merge with f8c23e13, the full suite passes 5,657 of 5,701, with 43 skipped and 1 failing. The failure is deterministic when the test is run alone.
    • The test is main's test/mcpl-coalescing.test.ts:1571, "a wake to read a render starts its turn until the render's row is at or below the agent's watermark on the current branch". It was added by 3253dab4, after 8225e4bf.
    • At :1639-1640 it calls f.framework.undoLastTurn('agent'), then lands a queued render through readdDrained and asserts that its row is stored.
    • Under this PR, the undo's reservation still holds agent's turn token, so addMessage defers the write (src/framework.ts:21309-21315). The test fails on "render_1 landed on the current branch".
    • With one line added after the undo, await framework.syncMoveReinit?.done, the test passes.
  • Why this is more than a test to adapt. syncMoveReinit is private.
    • Only the host/command undo waits for the pass to finish (src/framework.ts:9525).
    • The API server's undo and redo (src/api/server.ts:649, :667) broadcast branch:switched as soon as the call returns.
    • A client that answers that event with agent.context gets a compile run directly on the agent's manager (src/api/server.ts:726). By reading, an autobiographical agent the pass hasn't reached yet refuses that compile with context-manager's "requires reinitialization": the #326 symptom. It lasts until the pass ends, which the description measured at 4.5 to 10.9 s with 16 to 32 agents.
    • A host that writes right after an undo sees its write deferred, as the test above did.
    • The fragment documents that the store stays held, but it gives callers nothing to await.
  • Fix.
    1. Required: merge current main and adapt that test.
    2. Recommended: give callers a public wait. That could be a whenStoreReleased(): Promise<void>, or a settled promise in undoLastTurn()'s and redo()'s results, so they stay synchronous. Have cmdUndo and cmdRedo, which are already async, await it before they broadcast, and use it in the tests in place of the private field.

Minor

R3-2. Some writes still reach a context manager while a move holds the store, so the hold's "nothing touches a manager's stored messages" is not true. Found by both reviewers independently (Sol also found the tool-result path).

  • Module edits and removals. ModuleContext.editMessage and removeMessage (src/framework.ts:2848-2849), and a process response's editMessages and removeMessages (:12192, :12199), go to editMessage and removeMessage (:21927, :21945). Those write to the primary's manager without checking the hold.
    • The in-tree callers are Discord's live edit and delete (src/modules/discord/index.ts:884, :902), and its history sync (:1174, :1206), which runs after network awaits.
  • Fresh tool results. addMessage exempts any new write that carries a tool_result block from every deferral, including the reservation's turn tokens (:21297, :21310).
    • No in-tree module writes one outside a turn, but ModuleContext.addMessage and EventResponse.addMessages accept one.
  • What it costs. During a rollback's or suppression's wait for running maintenance, before its switch, a Discord delete or edit is applied on the source branch. The rollback then branches from an earlier point of that branch, so on the branch it lands on, the deleted message is back. That is the same shape as R2-1's point 2, which this PR fixed for added messages.
    • The wait comes from #352, so the loss itself predates this PR.
    • What is this PR's own is the claim: the removePlacement comment (:13793-13799) and the description's "Nothing removes a stored message while the store is held".
  • Fix. Either of:
    • Under surgeryHold, decline module edits and removals as removePlacement does, or queue them for the release. Let the hold override the fresh-tool-result exemption too: a reservation means no turn is live, so no continuation depends on that write landing. A test with a process response's removal during a rollback's wait for maintenance would pin it.
    • Narrow both texts to the coalescer, and file the rest.

R3-3. A fork disposed by the re-seed pass doesn't have its held messages "dropped by the release's flush": they are written into its abandoned namespace during the hold. Found by Sol; verified.

  • The path. disposeConversationAgent unregisters the fork and calls releaseWindowNowOrAfterResume (:12809). Unless the host is quiesced, that runs releaseWindow at once, while the hold is still up.
    • releaseWindow drains the fork's queue and writes each entry directly into the fork's manager (:21640-21646).
    • If such a write throws, it is logged, and the ackDeferredWrites() that follows forgets it (:21647-21662).
  • The outcome. Either way, no agent reads the message again. A user message that reached the channel during the hold doesn't reach the fresh fork the channel's next message spawns. The description's last "Not covered" bullet says it is dropped at the release with a log line.
  • Fix. Correct that bullet. Better: hand those writes to the channel's next spawn, or have the disposal alert say how many held messages went with the fork.

R3-4. A re-initialization that never settles holds every agent, with no alert and no status. Found by Sol; narrowed here.

  • The wait. Each await cm.setStrategy(cm.getStrategy()) (:10255) has no deadline. The store is released only after the whole pass settles.
  • The silence. A strategy whose initialize() never resolves raises no context-reinit-failed. Every turn waits, and messages pile up in a queue kept only in memory.
    • The hold records since (:10189), but getHostModeStatus() (:6067) doesn't expose it, and neither does the health snapshot.
  • What predates this PR. Surgeries already had this property through cm.switchBranch(). This PR extends the hold to undo and redo, and its length to one initialization per agent.
  • Fix. Expose the hold (verb, agentName, since) in host status and health. Log, or alert, when a pass runs past a threshold. A deadline that releases the store is not safe without a cancellation contract in context-manager, so it isn't suggested here.

R3-5. The alert's "or a restart" remedy loses the write that R1-2's fix keeps queued. Found by Claude, by reading.

  • REINIT_REMEDY (:1810) offers switchBranch() "or a restart".
  • On a restart. stop()'s last flush stores past the deferral (:3986). An agent whose initialization failed still refuses that write, so writeDrainedInOrder puts it back into the queue, logs the failure, and the process exits.
    • Unless the queue is persisted, which in normal operation it is only while the host is quiesced, the write is gone.
    • stop()'s own warning about writes lost this way covers only agents whose stream is still running (:3988-3998).
  • R1-2's test covers the switchBranch() recovery only.
  • Fix. Either of:
    • Persist the queue when a release keeps writes for an agent whose manager refuses them.
    • Have the alert say that queued writes survive a switchBranch() recovery and are lost on a restart. Extend the stop warning to cover them too.

R3-6 (predates this PR; not its to fix). After any move, the MCPL channel lifecycle and tune-out state stay as they were on the branch the store left, until a restart. Found by Sol; verified on main f8c23e13.

  • Where it's loaded. ChannelRegistry replays its branch-scoped lifecycle log (open, closed and tuned-out desired states, and wake counts) only in its constructor (src/mcpl/channel-registry.ts:1351, :2263-2310). It appends later records to whichever branch is current (:2320-2322).
  • Tune-out. TuneOutCoordinator.resumeActiveEpochs() runs only at framework creation (src/framework.ts:3373).
  • What this does. After an undo to before a tune-out, the channel's new traffic is still diverted and stamped as tuned out until the host restarts.
  • What this PR touches. reinitializeManagers re-initializes context managers and re-seeds forks, and nothing else. So "works again without a restart" holds for the context managers, which is this PR's scope, but not yet for the host as a whole.
  • Ask. Scope the description's phrasing to the context managers, and file this separately. Reloading branch-scoped module and MCPL state is a move participant of its own.

Verified fine

  • Tests.

    • At the head: reinit-after-branch-move, conversation-routing, stop-with-host-operations and discord-awareness-outbox pass 217/217. The trial merge also passes those files 217/217; its only failure is R3-1's.
    • CI passes all six checks at the head.
  • Mutation checks at the head. Each fix below was removed on its own, and each removal fails a test:

    • writeToFork's hold branch: 3 tests fail;
    • the yield before each manager: 1 fails;
    • the seed's branch comparison: 1 fails.
  • Store-mover inventory.

    • Every move the framework makes, switchBranch, undoLastTurn, redo, rollbackToMessage and suppressMessages, reserves the store and re-initializes the others.
    • The API's branch.create and branch.delete don't move the current branch. Chronicle 0.4.0 creates a branch without selecting it, and refuses to delete the current one.
    • src/recovery/offline-branch.ts uses its own offline manager.
    • The trial merge adds no live mover.
  • Direct writes to a context manager, in the head and in the trial merge. Apart from R3-2 and R3-3, every direct write happens inside a turn, a tool cycle or its teardown, which a reservation excludes. The rest are:

    • the seed, which writes on purpose;
    • the suppression's removal, which is the operation itself;
    • the release's flush.

    main's new writers since 8225e4bf, such as the reply-open-failed notices, go through addMessage and are deferred. flushForStart runs only under a turn token.

  • Release and error wiring.

    • Switch, rollback and suppression release in nested finally blocks.
    • The undo and redo pass releases in its own finally and ends in a catch.
    • There is no await between the last read of again and the clearing of syncMoveReinit.
    • maintenanceTick() refuses to run under a hold. The idle-TTL reaper can't dispose a fork during a hold, since the reservation holds the fork's turn token.
  • The disposal-failure fix for Greptile's finding (0c3537c6): the unbind and the disposal share one catch per fork, the failure goes into that fork's record, and the pass moves on to the next fork.


Method. Two independent passes, merged after re-verification.

  • Codex GPT-5.6 Sol (xhigh, read-only sandbox, no network):
    • Ran about 17.5 min and 88 commands, using 10.92M input tokens (10.47M cached) and 49.9k output (29.7k of it reasoning).
    • Read this head, the trial merge with main f8c23e13, the round-1 and round-2 reviews with the replies, context-manager 0.14.1 and chronicle 0.4.0.
    • Could not run tests, and could not check CI or the state of any issue.
  • Claude Opus 5.5:
    • Built the head and the trial merge, and ran the full suite on the merge and the touched files on both.
    • Isolated R3-1's failure, and confirmed it with a one-line probe that awaits the pass.
    • Built three mutants, one for each of R2-1, R2-2 and R2-3.
    • Read connectome-host's main for R1-8.
    • Checked the state of #286, #326, #383 and connectome-host#264, and CI.
  • Who caught what.
    • Both: R3-2's module edits and removals.
    • Sol only: R3-2's tool-result exemption, R3-3, R3-4 and R3-6.
    • Claude only: R3-1, R3-5, and the mutation and test runs.
  • Severity adjustments. Sol rated R3-2 (two findings), R3-4 and R3-6 majors; they are minors here.
    • R3-2: its lossy window is the wait for maintenance before a rollback's switch, which predates this PR, and no in-tree module writes a tool result outside a turn.
    • R3-4: it needs a strategy whose initialize() never settles, and surgeries already had that property.
    • R3-6: it predates this PR and is outside its scope.
  • Not reviewed: connectome-host#264 itself, and the merge with whatever main is when this lands.

Duncan-2410 and others added 12 commits October 21, 2026 15:33
…ove)

main moved by 32 pull requests since this branch's last merge of it
(8225e4b, in 4a0cd8b). Only src/framework.ts is changed on both sides,
and git merges it without conflicts. Main's hunks come within 40 lines of
this branch's in two places, each read in the merged file:

- removePlacement: anima-research#607 settles a write whose landing is unknown
  before the placement resolves, and removes a deferred write from the
  queue only when its landing is known. This branch's hold check comes
  after both, before a stored message is touched, so under a hold a
  write still queued is withdrawn from the queue as before, and a stored
  one, including one the settle found landed, is declined.
- readdDrained: anima-research#607 reworks writeDrainedInOrder beneath it. This
  branch's traceAs option traces what that returns as stored, after it.

main's test/mcpl-coalescing.test.ts:1571 (3253dab, after 8225e4b)
writes right after undoLastTurn(). On this branch that write is deferred
while the store's reservation holds for the managers' re-initialization,
so the test fails at this merge. R3-1 of the maintainer's round 3 adapts
it, in the commit after this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's events wait for it (R3-1)

R3-1 of the maintainer's round 3. With this branch's reservation, main's
test/mcpl-coalescing.test.ts:1571 fails, as the maintainer's trial merge
found: it writes right after undoLastTurn(), and the store's reservation
for the managers' re-initialization defers the write. No public caller
could wait for that reservation: only the host/command undo could,
through the private syncMoveReinit. The API server broadcast
branch:switched at once, so a client that answered by reading an agent's
context compiled on a manager not yet re-initialized.

- undoLastTurn()'s and redo()'s results carry `settled` when the store
  moved: the re-initialization's `done`, which settles once the
  reservation is released and never rejects. beginSyncMove's landed()
  returns it, the running pass's for a move that joins, so an undo and a
  redo made back to back settle together.
- The API server's undo and redo await it before they broadcast
  branch:switched and answer. Their answers leave the promise out, so
  the JSON they send is as before.
- The host/command undo awaits its undos' `settled` rather than the
  private field.
- main's test awaits the undo's `settled` before its write. The tests
  that waited on the private field now wait on the move's `settled`;
  those that check whether a re-initialization is running still read
  the field. A new test answers the API server's events as a client
  would, reading the other agent's context, and checks that each event
  comes once the store is let go and that the answers carry no promise.
- A fragment of its own, `added`, for `settled`; the `changed` one says
  the result carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is said, and a disposed fork's held writes are counted (R3-2 to R3-6)

R3-2 to R3-6 of the maintainer's round 3.

- R3-4: no turn starts while a move holds the store, and nothing bounds
  what the hold waits on: maintenance already running, the move itself,
  and every manager's re-initialization. A hold still up after 60 s
  (HOLD_ALERT_MS) raises one `store-held-long` ops alert, for every move
  that holds the store: an undo or redo, and main's rollback, suppression
  and branch switch. It names the move and the agent it was for, and the
  manager a re-initialization pass is on or the maintenance work it waits
  for, and says what a restart costs. The timer is armed when the store is
  reserved (reserveStoreForSurgery) and cleared at its release. Tests: an
  undo and a redo joined, with the second pass held past a shortened
  threshold, raise one alert naming b's manager; a rollback waiting for
  maintenance says so; a branch switch held by its config mount after its
  pass names no manager; and an undo let go before its threshold says
  nothing, then or after.
- R3-5: the remedy in context-reinit-failed, and in a failed restore's
  alert, says that a switchBranch() recovery stores the writes kept
  queued for the agent, and that a restart loses them unless the
  deferral queue is persisted. stop() says, once per agent, how many
  writes its last flush left queued because the agent's store didn't
  take them, and whether the queue keeps them for boot. A test checks
  both, and the two failed-restore tests check their alert's remedy.
- R3-3: a fork disposed because it couldn't be seeded takes the writes
  held for it into its window, where its release stores them. Its alert
  now says how many, since the fork its channel spawns next doesn't get
  them. A test checks the write in its window and the alert's count.
- R3-2: removePlacement's comment says what this branch makes true: a
  coalesced removal leaves a stored message alone while the store is
  held. A module's edit or removal of a stored message, and a fresh tool
  result, still reach a manager then, which the comment now says.
- R3-6: the fixed fragment's headline scopes "without a restart" to the
  context managers.
- The fixed fragment says what a host now sees: the restart's cost, the
  stop() line, the new alert and the disposal's count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… order the webhook keeps, and a spawn into a stop() fails

The fixes from this round's review (at 8249334), as the PR's author
ruled them, and a reader's wording point.

- The long-hold alert names a manager only while its re-initialization
  runs. The pass set the name at each manager and cleared it after the
  last, so through the turn of the event loop before each next manager,
  where an overdue timer runs, it named the one that had finished. The
  name is now set and cleared around each manager's setStrategy, and the
  re-seed and what follows name none.
- The alert names the move by the noun the move's other alerts use
  (MOVE_NOUNS): "The rollback for a", not the hold's verb "roll back".
- stop()'s two queued-write lines, the new one and main's line for
  writes left behind a stream still running, said "the persisted queue
  keeps them for boot" whenever the queue was mirrored. A mirrored queue
  whose last write failed or met the cap is behind
  (deferredWritesFellBehind), and boot reads what the record last held.
  While deferredWritesBehind is set, both lines now say the record is
  behind, so a restart can lose them. The long-hold alert's pointer to
  that line says it isn't given on a quiesced host, which persists the
  queue.
- The remedy says a switchBranch() recovery stores the writes kept
  queued at resume on a quiesced host, where the release defers them
  again: in REINIT_REMEDY and in a failed restore's alert.
- Both re-initialization alerts say what happened first, then what
  becomes of the writes and what happens next or what to do, and the
  cause last. The webhook keeps a long message's start and end, 500
  characters (opsAlert), so a long cause no longer cuts the disposal or
  the remedy out of the post. The console, failures.log and the trace
  keep 2,000 characters, cut the same way.
- A disposed fork's held writes go with it, and its window's release
  stores them there, now or at resume on a quiesced host, which the
  alert now says rather than that they're already in its window. When
  the disposal's cleanup failed, the alert still counts them, and says
  they may be in its window or still queued under its name.
- A spawn that comes to create a conversation agent once stop() has
  begun, as one waiting out a move's hold can, fails as a failed spawn
  does (stopHasBegun), rather than open its manager on a store stop() is
  tearing down. A spawn already past that point when stop() begins is
  main's race, named in the description.
- The review's point on the re-seed's skip didn't hold: a suppression
  refuses to remove an agent's last message (planSuppression) and a
  rollback keeps its tail, so the agent either names is never empty
  after its move, and the re-seed's skip of it never changed an outcome.
  The skip, and the parameter only it read, are gone; the comment now
  says why the agent is never empty.

Tests:
- The test of what the alert names calls the reporter once the pass is
  at its gate, and the config-mount test once the pass has ended, rather
  than timing either against the threshold. The rollback's test runs the
  timer and checks it fires once. The hold-let-go test asserts only that
  nothing comes after the release.
- The alert counts a write deferred during the hold, and the disposed
  fork holds two.
- Between two managers' re-initializations, the hold names neither.
- Each wording of each stop() line: memory-only, a current record and
  one behind (reinit-after-branch-move, stop-with-work-in-flight).
- A redo's dropped step with no step beneath it, and with two.
- The spawn-waits test holds the template's re-initialization at a gate
  it opens once the spawn is waiting, in place of a 300 ms delay.
- An undo whose release throws still settles `settled`, and the
  release's failure is logged.
- A spawn that waits out a hold into a stop() fails.
- A disposal whose cleanup fails, with a write held for the fork.
- The assertions on both alerts follow their new order.

The fixed fragment says the same: the alerts' order, the remedy on a
quiesced host, both stop() lines, the held writes' release, and a spawn
into a stop().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a spawn's check is for this lifecycle's stop(), and a failed restore says where the agent serves

The fixes from this round's second review (at 6166224), as the PR's
author ruled them.

- The spawn's check is `this.stopPromise`, a stop() of this lifecycle,
  which start() clears after a completed stop and a failed stop clears
  unless it closed an owned store. stopHasBegun() also reads the event
  queue's closed flag, which never clears, so after a stop() and a
  start() every spawn was refused as stopping. Whether that lifecycle
  works is anima-research#315's question, not this check's.
- The webhook keeps 345 to 349 characters of a long message's start, by
  its length, and the rest of its 500 from the end. Each
  re-initialization alert's outcome and what to do now fit in that start,
  in every shape the code builds (at most 339 characters, for a branch
  switch's disposal whose cleanup failed, with the router's remedy). REINIT_REMEDY
  is shorter; the disposal says what happens next right after its
  outcome, before the held writes; its outcome sentence and the two
  template remedies are trimmed. Where branch names run long, a failed
  restore's last words can still be cut. Tests post the alerts with a
  3,000-character cause through a stubbed webhook and check what it
  keeps.
- A disposed fork's held writes: its window's release stores what its
  store takes of them, now or at resume on a quiesced host, and a stop
  before the resume leaves them queued for the restarted host to drop
  when it resumes (a reader's run).
- The long-hold alert no longer points at stop()'s line: on a quiesced
  host stop() still gives the streaming agents' line, and a persisted
  queue's record can be behind. It keeps the restart's cost.
- A suppression whose removals fail after its fork has a current
  manager on the failed branch. When its restore can't select the
  source, the status and the alert say the agent serves from that
  branch, which may be partly suppressed, until a switchBranch() moves
  it (restoreSourceBranch's managerCurrent), not that it refuses its
  work.

Tests: a spawn after a stop() and a start() (a borrowed store); a refused
spawn leaves no name reserved and no spawn in flight; a suppression's hold
is named "the suppression"; the suppression whose restore can't select the
source, whose agent still compiles; both webhook posts above; the alert
assertions follow the new texts; the first long-hold test's title says it
calls the reporter.

The fixed fragment says the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check for any stop of its lifecycle, and a restart's word for a conversation agent

The fixes from this round's third review (at 778a04d), as the PR's
author ruled them.

- A spawn's check refuses once a stop() of its lifecycle has begun: one
  under way or completed (stopPromise, claimed before the teardown's
  first steps, which can emit traces while the framework still runs), or
  one that failed with no start() since, which leaves the framework not
  running with its queue closed. stopPromise alone admitted a spawn after
  a failed stop() that left the store open, and after a completed stop()
  and an addModule(), both of which clear it. A start() sets running, so a
  spawn after a stop() and a start() still goes through.
- The re-initialization alerts' texts are built by pure functions
  (refusedWorkAlert, restoreAlert, disposalAlert), each giving its
  opening, what happened and what to do or what happens next, and the
  whole message. A restore's alert names the branches after its opening,
  so every opening's length is set by its shape: at most 338 characters,
  within the 343 to 349 the webhook keeps of a long message's start, by
  the message's length. A test
  checks every shape the builders make, with causes of 3,000 and 200,000
  characters and branch names of 200, against opsWebhookText, the cut
  opsAlert posts. REINIT_MOVES and TEMPLATE_REMEDIES type the moves and
  the template remedies, so a new one joins the test's shapes.
- A restart doesn't create a conversation agent again, since the
  router's bindings are in memory, so for one the remedy says a restart
  ends it and its channel's next qualifying message spawns a new fork,
  which gets none of the writes queued for it, and a failed restore's
  status says so too. It said a restart re-initializes it.
- A disposed fork's held writes: its window's release stores what its
  store takes of them, now or at resume on a quiesced host, and passes
  any failed reply on to the primary; a stop before that resume leaves
  them queued, and the restarted host drops them when it resumes, if its
  persisted queue kept them, passing any failed reply on too. The
  comments say the same.
- The opening says "a restart re-initializes it too, but loses them
  unless the deferral queue is persisted"; after it, that the refused
  work includes compiles and message writes, and that quiescing the host
  before it stops persists the deferral queue.
- After the disposal's opening, that the next fork is seeded from the
  template, under a new name.
- The fragment tells a restore that couldn't select the source after a
  suppression's removals failed, whose agent serves, apart from the others,
  whose agent refuses its work.

Tests:
- A spawn after a failed stop() and after a completed stop() and an
  addModule() is refused, one after a start() is made, and one that
  reaches its check in stop()'s first steps, while the framework still
  runs, is refused.
- Two tests adapted from a reader's probe: a fork disposed on a
  quiesced host, with two channel writes and a failed reply held for it,
  at the host's resume (its window takes them, and the primary is told
  of the failed reply), and at a stop before the resume and a boot on the
  same store (the restarted host drops the writes at its resume, and tells
  the primary).
- A suppression whose removals fail after its fork, and whose restore
  selects the source and then fails to re-initialize, says its agent
  refuses its work: selected wins over managerCurrent.
- A fork whose re-initialization fails after a branch switch, and a
  rollback of a fork whose restore fails too, say what a restart does to a
  conversation agent.
- The first long-hold test's title says it calls the hold's reporter.
- The webhook test of two alerts says which two it posts.
- The alert assertions follow the new texts.

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

main moved by 14 pull requests since this branch's last merge of it
(ea4297a, in 180b703). git stops at four places in src/framework.ts,
each resolved by keeping both sides:

- After EphemeralRun, anima-research#608 adds scriptResultBrief and this branch adds
  the re-initialization alerts' builders and ManagerReinitFailure. Both
  are kept, the builders first.
- Where a channel message or a push is written into a conversation
  fork, anima-research#252 stamps the stored copy's digest (withStoredDigest) and this
  branch writes through writeToFork, which defers the write while a move
  holds the store. writeToFork now gets the stamped metadata, so a write
  it defers lands with the digest of the blocks it stores.
- readdDrained: anima-research#616 passes a failed reply queued for a target that's no
  longer registered to the primary, and this branch adds the traceAs
  option. Both are kept: the trace covers what the drain stored, the
  passed replies included.

main's other hunks within 40 lines of this branch's were read in the
merged file: anima-research#616's rework of stop()'s comment and its pass for
postponed releases, beside this branch's status lines for writes kept
queued, and anima-research#622's withoutHostKeys, beside writeToFork's call.

anima-research#616 changes what stop() does to a fork a quiesced host disposed: the
pass for its postponed release now drains what was queued for it, so a
failed reply goes to the primary, deferred again, and the rest is
dropped, logged, at the stop() rather than at the restarted host's
resume. At this merge the test "a fork disposed on a quiesced host: a
stop before the resume leaves its held writes queued, ..." fails for
that (0 entries queued, not 3), and the disposal alert's sentence and
the description's Not covered point still say what main did before
anima-research#616. The commit after this one adapts them.

Checked at this merge's tree before committing: tsc and the build pass;
npm test 6,010 pass, 1 fail (that test), 44 skipped of 6,055; the three
files CI runs alone 130 of 130.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s restart on the store's branch, and a disposed fork's held writes as its release went

The fixes from the round's fourth review (at 939c58e), as the PR's
author ruled them, built on the merge of main 97ca7eb (ed249de).

- What a restart does is said by the agent's kind (AgentKind): a
  configured agent, one of the config's agents or its subconscious, is
  created again by create(); a conversation agent isn't, since stop()
  ends each live fork (anima-research#616) and the router's bindings are in memory;
  and a run isn't, since stop() ends each run. A move's pass
  re-initializes every registered agent, a run included, so a run whose
  manager fails is told that a restart ends the run, where it was told a
  restart re-initializes it. The kind is taken when the manager fails
  (ManagerReinitFailure.kind, by agentKind, the framework's own test for an agent
  a restart doesn't create again, as holdProviderAcceleration's), since a
  fork can be disposed before the failure is reported. store-held-long
  said a restart re-initializes every manager; it now says a restart ends
  the conversation agents and runs and re-initializes the configured
  agents, whose queued writes outlast the process only in a persisted
  deferral queue.
- A failed restore that left the store on the failed branch said "a
  restart re-initializes it too". A manager works on the branch the store
  is on, so a restart would serve a configured agent from the branch it
  shouldn't run on. The opening says so, and the restart's loss of the
  queued writes moves past the opening, where it fits; the error's status
  names that branch.
- With anima-research#616: a disposal records whether its window's release was
  postponed (releasesAfterResume, after the disposal), and the alert says
  only what that makes true: a release that ran stored what its store took
  of the held writes in the fork's window and passed any failed reply on;
  a postponed one does so at the host's resume, and a stop() before the
  resume passes a failed reply on and drops the rest, logged, which anima-research#616
  made stop() do for any release a quiesce postponed. Before, the alert
  said a stop before the resume left them queued for the restarted host.
- With a reader's first point: a failed cleanup's sentence says "it"
  for one write, and that a flush meeting one still queued under the
  fork's name passes a failed reply on to the primary and drops anything
  else, logged.
- With a reader's second point: restoreAlert's managerCurrent is typed
  for a suppression only, the one move whose fork leaves a manager current
  on the failed branch.
- The fragment says the webhook limit holds for any alert without a key
  of its own, not for every kind.

Tests:
- The every-shape test adds a cause of a million characters, past which
  the webhook keeps 343 characters of the start, builds each refusing
  shape for each kind, and checks each one's restart clause and refusal
  detail.
- A run whose manager fails to re-initialize is told a restart ends the
  run, with the run leaving before the failure is reported.
- A suppression whose fork switches the store and fails, and whose
  restore can't select the source, raises an alert naming the suppression.
- The failed restore's alert and status, for a configured agent left on
  the failed branch.
- The early-spawn test starts its spawn from stop()'s first step,
  endConnectsInFlight, so a claim made after any first step fails it.
- With anima-research#616: the quiesced-disposal test's stop variant now finds, after
  the stop(), nothing queued for the fork, the two channel writes dropped
  with log lines, and the failed reply kept for the primary, which the
  restarted host tells at its resume; the channel writes are stored
  nowhere, in the primary's window or the fork's. Both variants check the
  alert's postponed-release text. The merge left this test failing; it
  passes now.

Measured with the built functions, at this tree but for the fragment: the
longest opening is 338 characters, a branch switch's disposal whose
cleanup failed, with the missing-template remedy; the failed restores left
on the failed branch are now shorter. tsc and the build pass, and the test
file passes 89 of 89.

The review's other points change nothing here: five didn't hold against
the code (one of them held at 939c58e, and anima-research#616 ended it), one wasn't
worth a change, and one names a gap already known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… alert's names after its opening, and a disposal's release as it stands at the report

The fixes from the round's fifth review (at b254126, over the merge of
main 97ca7eb), as the PR's author ruled them.

- With a reader's point: what a restart does to an agent was read in a
  failure's catch, after the awaited re-initialization or restore, so a
  run its watchdog ended meanwhile had left ephemeralRuns and was taken
  as configured, and told a restart re-initializes it.
  reinitializeManagers now takes the kind before its await, and doesn't
  report a failure of an agent no longer registered when it fails, since
  nothing that's gone refuses anyone's work; restoreSourceBranch takes it
  before its await; the re-seed says 'conversation', since every agent it
  judges is one.
- store-held-long is built by longHoldAlert, a pure builder like the
  other alerts. Its opening says what the hold does and what a restart
  does to each kind of agent ("A restart ends the {move}, the
  conversation agents and the runs, ..."), at most 343 characters by its
  format, and the move's agent and what the hold is on come after it,
  since names have no bound: a conversation agent's runs to 97 characters
  (forkAgentName), and before, the restart warning came after two of them
  and the webhook's cut could drop it.
- Whether a disposed fork's window still waits for the resume is read
  when the report is made (releasesAfterResume), not recorded at the
  disposal: a disposal that threw after postponing its release didn't
  record it, and a resume before the report had already run the release.
  A postponed release gives the postponed text whether or not the cleanup
  failed; otherwise a failed cleanup gives its own text, since a cleanup
  that threw before the release released nothing; otherwise the release
  ran.
- A failed cleanup's held writes may be in the fork's window, still
  queued under its name, or in neither: releaseWindow takes them from the
  queue before it writes them.
- The fragment says a restart would serve a configured agent from the
  branch the store is on.
- A reader's cosmetic point: a line of readdDrained's doc comment the
  merge's splice left unwrapped.

Tests:
- A run that ends during its own re-initialization, which then fails,
  isn't reported; a run named by a rollback whose restore fails, and which
  ends during the restore, is told a restart ends the run, in the error's
  status and the alert.
- The every-shape test builds the long-hold alert for each move and each
  thing it can be on, with 200-character names and a manager name that
  grows with the cause's length, so its opening is checked against the
  webhook's shortest start.
- A fork disposed on a quiesced host whose cleanup then fails, after its
  release was postponed: its alert gives the postponed text and the
  cleanup failure, and the resume takes the writes held for it.
- The every-shape test pins the failed-branch restart wording to the
  restores left on the failed branch, and the other wording to the rest.
- The long-hold tests' expected texts follow the new order.

Measured with the built functions, at this tree before it was committed,
built from an empty dist: the longest openings are the long-hold alert's,
343 characters, for a branch switch, and the disposal's, 338. tsc and the
build pass, and the test file passes 92 of 92.

The review's other points change nothing here: two didn't hold against
the code, one is intended, and one wasn't worth a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l and said as what it means, a restore's kind taken when its surgery reserves the store, and the long-hold opening

The fixes from the round's sixth review (at d99bf12), as the PR's
author ruled them, two of which reverse her ruling on the previous
review's points.

- Whether a disposed fork's window release was postponed was read from
  releasesAfterResume when the report was made, and "not in the map" was
  taken as "the release ran". stop()'s pass for postponed releases and a
  resume whose release throws empty the map too, and the stop drops the
  writes. It's recorded again at the disposal, now read right after
  disposeConversationAgent on either of its paths, so one that throws
  after postponing it still records it. The postponed text says what the
  postponement means, which holds whatever follows: "its window's release
  was postponed to the host's resume, which stores what its store takes
  of them there and passes any failed reply on to the primary agent; a
  stop() before the resume drops all but a failed reply, which goes to
  the primary at the restarted host's resume". It was in the future
  tense, and said a stop() passes a failed reply on, where on a quiesced
  host it's deferred again.
- reinitializeManagers took the kind before its await, which changed
  nothing: a failure is recorded only while this.agents still holds the
  agent, and every map agentKind reads changes together with this.agents,
  with no await between. It's read in the catch, after that guard, and
  the comments say so.
- A failed restore's kind was taken at restoreSourceBranch's start,
  after the surgery's own awaits, so a run that ended during the
  rollback's switch or the suppression's fork was named configured. Each
  surgery takes it when it reserves the store and passes it on.
- The re-seed checks the fork's registration after its awaited seed
  too, so a fork that left meanwhile, as one stop() ends, is neither
  reported nor disposed again. Its kind, the literal 'conversation', now
  says why.
- The long-hold opening: "The {move} has held the store for N s, so no
  turn starts and the writes it defers wait in the deferral queue (N
  writes now). A restart ends the {move}, the conversation agents and the
  runs, and re-initializes the configured agents; writes still queued at
  exit survive only in a persisted deferral queue." A clean stop() stores
  what it can, so only what's still queued at exit needs the persisted
  queue; the count says what it counts, "1 write" for one; and the opening
  is at most 333 characters with counts of nine digits, where the old one
  passed 343 at six. Its comment says a conversation agent's name runs to
  about 100 characters.
- The fragment says a failed re-initialization is reported unless the
  agent has left by then.

Tests:
- A run named by a rollback whose restore fails, and which ends during
  the rollback's switch or during the restore, leaving both this.agents
  and ephemeralRuns as a run's end does, is told a restart ends the run,
  in the status and the alert.
- The run that ends during its own re-initialization asserts that its
  initialize ran.
- A fork that leaves while it's seeded again, whose seeding then fails,
  isn't reported.
- The every-shape test asserts the long-hold restart sentence for each
  move, grows every long-hold row's agent name with the cause, and uses
  nine-digit counts.
- The quiesced test whose cleanup fails after the release was postponed
  says it stands in for any later step that throws: by reading none does
  today.
- The long-hold and postponed-release tests' expected texts follow the
  new wording.

Measured with the built functions, at this tree before it was committed,
built from an empty dist: the longest opening is the disposal's, 338
characters; tsc and the build pass, and the test file passes 94 of 94.

The review's other points change nothing here: one didn't hold against
the code, one is intended, and one wasn't worth a change (its test
stays, as above).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ext, and the round's tests

The fixes from the round's seventh review (at 0fa6d89), as the PR's
author ruled them, and a harness the round before broke.

- With a reader's point: the postponement lives in memory, so a crash
  while the host is quiesced lands the held writes at the restarted
  host's resume flush, which drops all but a failed reply as a stop()
  does. The postponed text says "a stop() or a crash before the resume
  drops all but a failed reply, logged, and that goes to the primary at
  the restarted host's resume".
- The fragment's long-hold line says writes still queued at exit survive
  only in a persisted deferral queue, and its disposal line says a stop()
  or a crash before the resume.
- test/discord-awareness-outbox.test.ts: since 0fa6d89 each surgery
  calls agentKind when it reserves the store, and that file's
  hostCommandFramework stub had neither conversationAgentHomes nor
  ephemeralRuns, so its two message-granular undo tests failed there (a
  full run at that head). A framework always has both; the stub now does.

Tests:
- A fork disposed on a quiesced host whose postponed release runs before
  the alert is made, as a resume between the two would: the alert still
  says the release was postponed, as the disposal met it.
- A run named by a suppression whose fork fails, and which ends during
  the fork, leaving both maps, is told a restart ends the run when the
  restore fails too.
- The run whose failure is reported after it leaves now leaves both
  this.agents and ephemeralRuns, as a run's end does.
- The fork that leaves while it's seeded again is disposed once.
- The every-shape test pins the long-hold count's wording, "N writes"
  and "1 write".
- Each rollback variant asserts its run ended where it says.
- The every-shape test's comments say 343 characters at a million, and
  that it checks the opening.

Measured with the built functions, at this tree before it was committed,
built from an empty dist: the longest opening is the disposal's, 338
characters; tsc and the build pass, and the round's test file with the
awareness outbox's passes 160 of 160.

The review's other points change nothing here: one is intended, one
didn't hold against the code, and three weren't worth a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e restarted host's resume, and a test runs the crash

A reader's read of ece892e, as the PR's author ruled it: "a stop()
or a crash before the resume drops all but a failed reply" claimed what
no test ran for a crash, and put the drop before the resume, where a
crash drops nothing. While the host is quiesced, each change to the
queue writes its record (persistDeferredWrites), so after a crash the
fork's writes are on disk and the postponement is gone with the process:
the restarted host's resume flush passes the failed reply on and drops
the rest, logged (readdDrained's pass for targets no longer registered).

- The postponed text says "A stop() before the resume drops all but a
  failed reply, logged, and after a crash the restarted host's resume
  drops them the same way; that reply goes to the primary at the
  restarted host's resume." It follows the alert's opening, whose bound
  doesn't move.
- The fragment says the same.

Tests:
- A fifth variant of the quiesced-disposal tests copies the host's
  directory as it is on disk right after the disposal, which is what a
  crash leaves, stops the first host, and boots a second from the copy:
  its queue holds the three writes, and at its resume nothing stays
  queued for the fork, the two channel writes are dropped with a log
  line each and stored in neither window, and the primary is told of
  the failed reply.

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

nissa-seru commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid The round for your third review is at fcb94144. It merges main at 97ca7eba, and your review read 7dd0f878, so we're asking you to re-read it there before we merge it. Point by point, with the commit that made each change and the tests that pin it (in test/reinit-after-branch-move.test.ts unless named). The commits are 180b703d and ed249de5 (merges of main), fedc015b (R3-1), 82493341 (R3-2 to R3-6), 61662246, 778a04d0, 939c58ed, b254126c, d99bf120, 0fa6d89f and ece892ed (what our own seven reviews of that round found), 35785770 (a reader's point on the last of them), and fcb94144 (a test's timing check that CI's Node 20 run on Ubuntu caught).

  • R3-1. 180b703d merges main at ea4297ab; git merged it with no conflicts. ed249de5 merges main at 97ca7eba, keeping both sides at the four places git stopped, as the description's Merge notes say. In fedc015b, undoLastTurn()'s and redo()'s results carry settled?: Promise<void> when the store moved.
    • It settles once the store's reservation is released, every agent's context manager re-initialized or its failure alerted, and it never rejects, a release that throws included. An undo or redo made while an earlier one's managers re-initialize gets the same promise.
    • The API server's undo and redo await it before they broadcast branch:switched and answer. Their answers leave it out, so the JSON they send is as before.
    • The host/command undo, and these tests, await it where they read the private field before. main's test in test/mcpl-coalescing.test.ts that writes right after an undo awaits it first, and passes.
    • Tests: "the API server's undo and redo tell clients of the move once every manager is current, so a client that reads an agent's context then gets it", and "an undo whose release throws still settles its settled, and says the release failed".
  • R3-2. removePlacement's comment, the description and the changelog now say what this PR makes true: a coalesced removal leaves a stored message alone while the store is held. A module's edit or removal of a stored message, and a write carrying a fresh tool result, still reach a context manager during a hold, as on main, and the description's Not covered names them (82493341).
  • R3-3. The Not covered line is corrected. A write held for a conversation agent that the pass disposes goes with it: its window's release stores what its store takes there, where no agent reads it, and passes a failed reply on to the primary. On a quiesced host the release is postponed to the resume. A stop() before the resume drops all but a failed reply, logged, as main does since #​616, and after a crash the restarted host's resume drops them the same way; that reply goes to the primary at the restarted host's resume. The disposal alert says how many went, and which of these the disposal met, and when the disposal's cleanup fails, that they may be in its window, still queued under its name, or in neither (82493341, 61662246, 778a04d0, 939c58ed, b254126c, d99bf120, 0fa6d89f, ece892ed, 35785770). Tests: "a fork disposed after a move takes the writes held for it into its window, and its alert says how many", "a fork whose disposal fails partway after its seeding fails is reported with both, and the pass seeds the next fork", now with a write held for it, and the "a fork disposed on a quiesced host…" tests, which take two channel writes and a failed reply through the host's resume; through a stop(), which drops the channel writes, stored nowhere, and keeps the failed reply for the primary, and the restarted host's resume; through a crash, with a second host booted from a copy of the first one's directory taken right after the disposal; through a cleanup that fails after the release was postponed; and through a release that runs before the alert is made.
  • R3-4. A move whose hold on the store is still up after 60 s raises one store-held-long ops alert (82493341, 61662246, d99bf120, 0fa6d89f).
    • It opens with how long the move has held the store, that no turn starts meanwhile, how many writes wait in the deferral queue, and what a restart does: it ends the move, the conversation agents and the runs, and re-initializes the configured agents, and writes still queued at exit survive only in a persisted deferral queue. Then it names the agent the move was for, and the manager a re-initialization pass is on at that moment, or the maintenance the move is waiting for.
    • Its timer is armed when the store is reserved and cleared at its release, so it covers every step that holds the store, for every move that holds it: an undo or redo, and the rollback, suppression and branch switch that main already holds the store for.
    • The hold isn't shown in the host's status or health, and the description's Not covered says so.
    • Tests: "a hold's reporter, called during the pass, names the move, its agent, the manager the pass is on and the writes waiting", "a rollback still waiting for maintenance that was running when it took the store raises the alert, saying that's what it waits on", "a branch switch still holding the store for its config mount after its pass names no manager", "a suppression's hold is named as the suppression", "between two managers' re-initializations, the hold names neither" and "a hold let go before its threshold says nothing after it".
  • R3-5. context-reinit-failed's remedy, and a failed restore's, now say that a switchBranch() recovery stores the agent's queued writes, at resume on a quiesced host, and that a restart re-initializes it too but loses them unless the deferral queue is persisted. For a conversation agent, which a restart doesn't create again, they say a restart ends it and its channel's next qualifying message spawns a new fork, and for a run, which stop() ends, that a restart ends the run. A failed restore that left the store on the failed branch says a restart would re-initialize the agent there, on the branch it shouldn't run on (82493341, 61662246, 778a04d0, 939c58ed, b254126c, d99bf120, 0fa6d89f).
    • stop() now says, once per agent, how many writes its last flush leaves queued because the agent's store didn't take them, and whether the queue keeps them for boot. That line, and main's line for writes left queued behind a stream still running, say the persisted queue's record is behind, rather than that boot keeps them, when its last write failed or met the cap.
    • Tests: "the alert's remedy says a restart loses the writes kept queued, and stop() says how many it leaves for an agent whose manager refuses them", the failed-restore tests, and each line's three wordings, in this file and in test/stop-with-work-in-flight.test.ts.
  • R3-6. The description and the changelog scope "without a restart" to the context managers. The description's Not covered says the channel lifecycle and tune-out state stay as they were on the branch the store left until a restart, as on main.

Beyond your points, from our own reviews of the round and a reader's (61662246, 778a04d0, 939c58ed, b254126c, d99bf120, 0fa6d89f, ece892ed, 35785770):

  • Each re-initialization alert opens with what happened and what to do, short enough to stay within the start the ops webhook keeps of a long message, whatever the branches it names, and gives its cause last. The texts are built by refusedWorkAlert, restoreAlert, disposalAlert and longHoldAlert, and "is in what the ops webhook keeps of the message, in every shape the builders make" checks every shape against the webhook's cut, with long causes, long branch and agent names, and counts of up to nine digits.
  • A suppression whose removals fail after its fork, and whose restore can't select the source, now says its agent serves from the failed branch until a switchBranch() moves it, rather than that it refuses its work: the fork had initialized its manager there. Test: "a suppression whose removals fail after its fork, and whose restore can't select the source, says the agent serves from the failed branch".
  • A spawn that comes to create a conversation agent once a stop() of its lifecycle has begun, under way, done, or failed with no start() since, as one waiting out a hold can, fails as a failed spawn does, rather than opening its context manager on a store stop() is tearing down or has torn down. Tests: "a spawn that waits out a move's hold into a stop() fails, as a failed spawn does", "a spawn after a failed stop() is refused, and so is one after a completed stop() and an addModule(): neither begins a lifecycle", "a spawn that reaches its check in stop()'s first steps, while the framework still runs, is refused", and "a spawn after a stop() and a start() is made: the check is for a stop() of this lifecycle".
  • Which kind an agent is decides what each alert says a restart does to it: a configured agent is created again, and a conversation agent or a run isn't. The kind is read while the agent is still registered, and a rollback or suppression takes it when it reserves the store, so a run that ends during the move keeps its words. A failure of an agent that has left by the time its re-initialization or seeding fails, as a run its watchdog ended, isn't reported, since it refuses no one's work. Tests: "a run whose manager fails to re-initialize is told that a restart ends the run", "a run that ends during its own re-initialization, which then fails, isn't reported", the rollback and suppression tests whose run ends during the move, and "a fork that leaves while it's seeded again, whose seeding then fails, isn't reported".
  • The re-seed no longer skips the agent a rollback or suppression names: a suppression refuses to remove an agent's last message and a rollback keeps its tail, so that agent is never empty there, and the skip never changed an outcome.

The description's Evidence section has this round's runs.

Written by Duncan-2410.

— Daniel-2612

… millisecond

The test of a rollback still waiting for maintenance asserted that the
store-held-long alert reports a hold of at least its 1,000 ms threshold.
The hold is measured by Date.now() from the reservation, and the timer
runs on libuv's clock, each to the whole millisecond, so the alert can
read just under its threshold: CI's Node 20 run on Ubuntu read 999. The
assertion now allows 5 ms, as tool-result-guard's duration check does,
which still tells an alert at its threshold from one that comes early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile 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.

After a surgery or branch switch, the other agents on the store can't compile until the host restarts

2 participants