Repository navigation
fix(framework): stop() waits for an operator's operation already running, and refuses new ones - #325
Conversation
stop() refuses a host/command that arrives once it has begun, but an operation admitted before that kept running while stop() tore down what it used. A resume waiting on a maintenance pass or its feasibility previews went on to leave quiesce while the host stopped: it cleared the durable quiesce mark, opened the event gate and reopened the MCPL data planes that stop() was closing, and answered ok. A quiesce kept polling its drain for up to its ten-minute window after stop() returned. A store surgery (rollbackToMessage, suppressMessages, message-granular undo) could find the store closed while it synced awareness markers after its branch switch, and a host/command could answer after the connections it answers on had gone. stop() now waits, once the event loop has ended and before the event gate, modules, MCPL connections and store go, for the operations it tracks: quiesce(), resume(), each host/command, a store surgery until it releases the store, and syncDiscordAwarenessMarkers(), which an operator's undo, redo or branch switch runs. Each stops waiting once the queue is closed. A quiesce leaves its drain loop and an abandon's grace, so the host stays quiesced and boots quiesced. A resume refuses if stop() began before it left quiesce; one already past that point finishes before the final sync. quiesce() and resume() called once stop() has begun are refused, as a host/command already was. A failed awareness drain no longer recycles the MCPL connections of a host that is stopping. teardownTimeoutMs bounds this wait as it bounds module and MCPL cleanup: an operation that outlasts it fails stop() with a ShutdownTimeoutError naming it, and the store stays open so that stop() can be called again. test/stop-with-host-operations.test.ts holds each kind of operation across stop() and checks the order of its end against the modules' teardown, so the verdicts don't rest on timing. CI runs the file unforced with the other work-in-flight files.
…operations set The message-granular undo test builds its framework with Object.create(AgentFramework.prototype) and sets the fields the live rollback path uses. That path now counts its host/command and its store surgery as operations stop() waits for, so the stub needs the set they go into, as it already has the turn tokens and deferred writes the reservation uses.
…re stop Refuse quiesce, resume, rollback, suppression, undo, redo, direct awareness sync, and MCPL lifecycle calls once the queue closes. Surgery checks precede validation and the catch that records an operator action, so a late request neither reads a closed store nor appends a failure record. Resume checks admission even when it would otherwise return an already-serving status, and retains the check after its awaited preparation. Register each operation before invoking its body in the same stack. The previous promise-argument form ran the body up to its first await before registration; an undo completion trace could synchronously start stop(), which then saw an empty set and tore down before the undo's held awareness sync answered. The regression observes this order through the real host-command listener. Drain the operation set until empty, with one monotonic deadline across rounds. Admitted work can start config materialization after the first snapshot. Undo, redo, rollback and suppression now share a tracked materialization helper while preserving fire-and-forget behavior, so a surgery releases its reservation without holding it through file IO. Already-admitted surgeries and host undo use the private awareness continuation, and an admitted restart uses private disconnect/connect continuations. A successful barrier retires without readying a data plane during stop; the existing failure path still records the failure while skipping recycling. Keep the explicit bounded-stop contract: when the operator wait times out, teardown proceeds while pending work may still act on torn-down components. The store stays open, another still-held retry can time out, and a retry after settlement can close it. Timeout diagnostics name current pending work and the original budget. Text now distinguishes cancelled streams from turns with no stream, drain interruption from abandon-grace interruption, and a single timeout from combined cleanup errors. Resume's stop-time log says parked wakes will not run. Replace the fixed-delay ordering release with observation of a nonempty wait or module teardown. An empty set is not evidence of waiting: treating it as one allowed the untracked MCPL and materialization cases to pass the first fixture. Add late-admission, continuation, reentrant-registration, successful-barrier, child-work and controlled-clock deadline cases. The constructor-bypassing MCPL reconnection fixture receives the operation set that real instances initialize. VPS checks before commit, with Bun's transpiler cache disabled: the six requested files passed 138 tests; MCPL lifecycle/reconnection/coalescing consumers passed 77. The expanded operator file passed 35 cases, and the final three-case deadline matrix passed after adding later-round timer expiry. Final frozen-tree, main-source red and removal receipts follow this commit. No native-platform or live-provider claim is made by these controlled fixtures. Co-authored-by: GPT-6 <noreply@openai.com>
Wait for the positive quiesce-settlement or module-teardown observation before awaiting stop. If neither occurs, fail within the test's own safety bound and release the artificial token in finally, then await cleanup. Removing the drain's closure condition can otherwise leave the test callback awaiting stop until the runner cancels it, without executing that cleanup. The successful path retains the same order and trace assertions. The focused case passes on Node 24. Production source is unchanged; final main-source and removal checks use this fixture. Co-authored-by: GPT-6 <noreply@openai.com>
…failed barriers while stopping, admit puppet and maintain A further round on the admission change, from a review of it. - syncDiscordAwarenessMarkers() is no longer refused once stop() has begun. Its callers have already changed the store when they call it: the API server's branch switch moves the branch itself, and its undo and redo call it after undoLastTurn() or redo(), whose own trace can start stop(). Refusing it left a move that had happened unreconciled and reported as failed. It stays tracked, so stop() waits for one that starts while stop() is waiting. One that starts later can fail to deliver, which leaves that work in the durable outbox for the next start, whose startup barrier reconciles with the branch. - completeMcplDataPlaneGate no longer returns early while the host stops. Keeping the planes paused stranded what the pause had buffered, server requests included, until the transport closed. Readied, those requests reach the listeners' stopping refusal (-32000), which is how the listeners answer traffic during stop. The quiesce hold still applies. This also removes the early return's "true", which let a reconnect announce itself without readying anything. - failMcplDataPlaneGate, while the host stops, still reports the failure and recycles nothing, and now releases the barrier through completeMcplDataPlaneGate. A rejected barrier left installed made later inbound requests await it and answer as failed instead of refused. - puppetToolCall() and maintenanceTick() refuse once the queue is closed, like the other operator entry points. puppetToolCall() is also tracked, so stop() waits for a puppet call already running, whose pair could otherwise be stored after the store closed. A maintain already running needs no tracking: stop() awaits the maintenance pass itself. - The config materialization's catch logs a failure instead of discarding it. - Comments and the changelog fragment now say which entry points refuse and why the sync doesn't, what stop() waits for, that it doesn't wait for a turn holding its token with no stream, what a multi-turn host/command undo does when its own trace starts stop(), and what a barrier does while the host stops. Tests: the refusal cases gain maintain and puppet and lose the sync. New cases cover a sync called while stop() waits, a puppet call held in its tool, and a failed materialization's log line. The reopen cases check that the barrier is released and the plane readied, and that the failure is still traced. The materialization and MCPL lifecycle cases assert the whole order, and the test names lose an internal prefix. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; stop-with-host-operations 40/40 unforced. With this commit's tests and the previous commit's src, 7 of 40 fail, each at its own assertion; with main's src, all 40 fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns set The puppet tests build their framework with Object.create(AgentFramework.prototype) and set the fields the real path uses. puppetToolCall() now counts as an operation stop() waits for, so the stub needs the set it goes into, as it already has the turn tokens and deferred writes. At the previous commit the file's ten tests failed on Node 20 and 24 with a TypeError in trackOperatorOperation; with the field they pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Active voice where the fragment said what the framework does in the passive (the refusals, the logged materialization, the released barrier, the abandon a cut-short drain leaves to stop()), and a plain sentence for the trace listener that calls stop(). No change in what it says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wareness sync once stop() has finished waiting From the review of the previous round, and two readers' reads of it. - New: AgentFramework.switchBranch(name) switches the store to an existing branch, reconciles the Discord awareness markers with it, and re-materializes the config mount, then resolves. Those are the steps the API server's branch switch ran itself, on the store, outside the framework, so a switch requested once stop() had begun moved the branch, mounted its config after the modules had stopped and the store had closed, and answered success. As an operator operation, switchBranch() refuses once stop() has begun, and stop() waits for one already running. The API server's cmdBranchSwitch now calls it and keeps only its own bookkeeping and broadcast. Its current branch follows the store even when a later step fails after the store has moved. Branch create and delete stay in the API server: each is one synchronous store write that either completes or throws. - syncDiscordAwarenessMarkers() is admitted once stop() has begun only until stop() has finished waiting on the operator set. stop() can wait for a sync admitted while it waits, which is what its callers need, having already changed the store. One that starts after that can't be waited for, so it's refused instead of running its barrier against connections being closed. stopOwned marks the point (operatorWaitEnded), whether the wait completed or timed out. - The surgeries' and host/command undo's awareness syncs call the public syncDiscordAwarenessMarkers() again, as on main. With the sync no longer refused while stop() waits, the private continuation they used behaved the same, and its removal probes showed no test that told them apart. A restart keeps its private disconnect and connect. - Text: the changelog fragment and comments say which entry points refuse, and when the sync is the exception. They say that stop() waits for puppet calls among the operations, though not for other turns that hold a token with no stream. They say that a puppet call holds stop() for as long as its tool runs, and that a budget is given to the operator wait and then again to the modules' and MCPL's teardown. A separate changelog.d fragment announces switchBranch(). Tests: the refusal cases gain switch branch. New cases cover an API branch switch held in its config mount, which stop() waits for; one requested once stop() has begun, which is refused with the branch left where it was; the API server's current branch after a switch fails past the store's move; and a sync called once stop() has finished waiting, which is refused and installs no barrier. The puppet wait case now asserts that the stored pair's two writes land before the modules stop. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; stop-with-host-operations 45/45 unforced. With these tests and the previous commit's src, 4 of 45 fail, each at its own assertion; the bookkeeping case passes there, because the old code set the API server's branch before its awaits. Removing each of this commit's seven pieces makes a test fail: the late-sync refusal, marking the wait's end, the switch's refusal, its tracking, its awaited mount, the API server's delegation, and its bookkeeping after a failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s run their own awareness sync
From a read of the previous commit.
- An operation already admitted calls the private runDiscordAwarenessSync()
again, and only the public syncDiscordAwarenessMarkers() is refused once
stop() has finished waiting. That covers a surgery's sync, a
host/command undo's, and switchBranch()'s. With the previous commit, a
rollback or suppression admitted before a stop() whose budget ran out
reached its sync after the wait had ended, and was refused there, after
its branch had moved. The operator saw a failure for a change that had
happened. The private call is covered by the enclosing operation's
tracking. The host/command undo and switchBranch() call their sync with
no await before it, so it can't reach the refusal today. They call the
private one too, so that there's one rule and an await added later
can't reintroduce the failure.
- switchBranch() reserves the store as rollbackToMessage() and
suppressMessages() do, since it moves the branch under every agent
sharing the store. Every agent must be idle, or it refuses with
OperatorActionError('agent-busy'), and no turn starts until the
switch, the sync and the mount are done. The reservation is also what
stop() waits for, replacing the separate tracked wrapper. The API
server's branch switch, which calls it, now refuses during a live turn
instead of moving the branch beneath it. Its old path had no gate.
Tests: a rollback and a suppression whose branch move is held past
stop(100)'s budget still finish, their sync included, and the next stop()
closes the store. A branch switch during a live turn is refused, both
directly and through the API server, and the branch stays.
Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build
clean; stop-with-host-operations 48/48 unforced; operator-surgery 18/18,
discord-awareness-outbox 6/6, host-quiesce 31/31. With these tests and the
previous commit's src, the three new cases fail, each at its own assertion
(the two timeout cases with "awareness sync refused", the live-turn case
with a missing rejection).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…API server's branch in step with a switch From the review of the switchBranch() round. - The awareness sync's admission is now a window for each stop() attempt (stopWaitPending), not a one-way latch. Each attempt opens it at its start, and closes it in the same step as its wait's last look at the operator set (a finally in waitForOperatorOperations), whether the wait completed or timed out. Before, the latch was set by the first attempt and never cleared, so a stop() retried after a timeout refused a sync its own wait would have covered. It was also set one await after the loop's last check, so a sync could join the set unseen in that step. The sync is refused while the queue is closed and no attempt's wait is open, as the other entry points are after a failed or completed stop(). No test pins the single-step gap: making it observable would mean scheduling a microtask between two awaits inside stop(). - switchBranch()'s doc now states what it guarantees: the store has moved by the time the call returns, before its promise settles, a refusal moves nothing, and if the sync or the mount fails it rejects with the store moved. The API server's branch switch reads the store's branch right after the call. Before, it read it only once the whole switch had settled, so during the awaited sync and mount it reported the old branch, and its delete guard would pass for the branch the store was on. It also broadcasts branch:switched whenever the store moved, even if a later step failed, as well as on success. - Text: the added fragment says the switch can reject with the branch moved, and how the API server's current branch and broadcast follow it. The fixed fragment says each stop() attempt admits the sync. A test name and a test comment no longer describe the API's switch as calling the sync itself. Tests: a stop() retried after a timeout admits a sync during its own wait and waits for it. While a switch is held in its mount, the API server reports the new branch and refuses to delete it. A switch whose mount fails leaves the API on the new branch, and clients hear of the move. switchBranch() reconciles the markers on the branch it moved to. The API's refused switch is refused with the store closed too. The failure-path test stops its framework in a finally. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; unforced, stop-with-host-operations 51/51, operator-surgery 18/18, discord-awareness-outbox 6/6, host-quiesce 31/31, puppet-tool-call 10/10, api-admin-gate 4/4. With these tests and the previous commit's src, three fail, each at its own assertion: the retried stop()'s sync is refused, the API reports the old branch during the switch, and no broadcast follows a failed switch. The markers and closed-store cases pass there, since they pin behaviour that commit already had. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#310 (a module's tool result that can't be stored becomes an error naming the tool). It merges without conflicts; nothing in it touches the stop() or operator-operation code.
…spect the store hold; a host undo finishes its turns From the review of the previous round. - The awareness sync's window is now a set of open stop() waits (openStopWaits), one per attempt. stop() adds its attempt's entry before it starts. The attempt's wait removes it in the same step as its last look at the operator set. If the attempt fails before reaching its wait, its rejection handler removes it. Before, a stop() that threw after closing the queue but before its wait left the window open, so a sync was admitted that no stop() would wait for. One shared flag also let a second attempt, started by a trace listener inside the first one's synchronous start, close the window while the first still waited. - undoLastTurn() and redo() refuse while a surgery or a branch switch holds the store, as puppet calls and ephemeral agents already do. Before, an undo or redo could move the branch while a switch awaited its sync or mount, or while a rollback awaited its branch switch. - A multi-turn host/command undo calls a private runUndoLastTurn() for its turns, so a stop() begun by one turn's trace doesn't refuse the next. The undo, already admitted, finishes every turn it was asked for, and stop() waits for it. Before, it stopped after the first turn. - The API server's branch switch announces branch:switched only when its own call moved the store or succeeded, comparing the store's branch before and after the call, and names the store's branch as "from". Before, a refused switch whose cached branch was stale (after a rollback made outside the API server) announced a move that hadn't happened. The switch promise gets a handler before the bookkeeping runs, so a throw there can't leave its rejection unhandled. - Text: the fragments say that undo and redo refuse during a hold, that a multi-turn host undo finishes, that a refused switch announces nothing, and that a create with switchTo creates the branch before a switch that can be refused. Tests: a sync after a stop() that failed before its wait is refused. A two-turn host/command undo whose first turn starts stop() undoes both turns. Undo and redo are refused while a switch holds the store. A refused switch with a stale cache broadcasts nothing. After a timed-out wait, with no retry running, a sync is refused. The rollback and suppression timeout cases now check that their sync ran. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; stop-with-host-operations 53/53 unforced. With these tests and the previous commit's src, four fail, each at its own assertion: the stale broadcast, the one-turn undo, the admitted sync after a failed stop(), and the undo during a switch. The timed-out sync and the surgeries' sync count pass there, since they pin behaviour that commit already had. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unning attempt stop() now creates its attempt's promise and assigns it to stopPromise before it starts stopOwned. It still starts the body in the same call, so the queue still closes before stop() returns. The body's first step, the push coalescer's suspend(), emits an mcpl:coalescing trace for a render in flight, synchronously. Before, stopPromise was assigned only after stopOwned returned at its first await, so a trace listener that called stop() there found no attempt and started a second teardown. The inner one closed the store, the outer one's final sync then failed, and stopPromise stayed on the failed outer attempt, kept there because the owned store was closed. Every later stop() then rejected with "Store has been closed" for a framework that had stopped. main does the same (found with a probe by a reader of the previous commit). It's the rule this change applies to operator operations, register before the body runs, now applied to stop() itself. openStopWaits stays a set: one attempt runs at a time now, but each keeps its own entry so that cleaning up after one can't close another's window, and its comment says so. Test: a coalescer whose suspend() emits a trace, and a listener that calls stop() on it. The two calls get the same promise, one teardown runs, and a later stop() resolves. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; unforced, stop-with-host-operations 54/54, stop-with-work-in-flight 25/25, shutdown-errors 28/28, host-quiesce 31/31, conversation-routing 15/15, operator-surgery 18/18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#281 (the tool-result guard: withheld batches, a lifecycle generation, and in stopOwned the guard's annotation drains and spill close). It merges without conflicts. The merged stopOwned waits on the operator operations before the annotation drain and the spill close, so an operation stop() admitted finishes before the guard's last writes.
The check that a public awareness sync is refused once a timed-out stop() has stopped waiting awaited the sync's rejection directly. With the refusal removed, the sync was admitted and waited on a barrier of its own that nothing released, so the whole file hung rather than failing. It now races the sync's outcome against a one-second timer (unref'd) and asserts the refusal's text, so a missing refusal fails in about a second. Checks (VPS, Node 24.21.0, transpiler cache off): stop-with-host-operations 54/54 unforced. With the refusal removed from syncDiscordAwarenessMarkers, the file exits in 7 s with 3 failures (this test among them) instead of hanging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#322 (MCPL close info), the 0.22.0 release (membrane ^0.5.87, context-manager ^0.14.0) and anima-research#283 (a withheld result keeps its file lists in its stub). It merges without conflicts.
…p the sync window open past stop()'s own call
From the review of the previous round.
- refuseOperatorAfterStop() refuses once a stop() attempt is in flight
(stopPromise, now set before the attempt's body runs) as well as once
the queue is closed. Before, a call made from a trace emitted inside
stopOwned's synchronous start (the push coalescer's suspend() emits one
for a render in flight) came before the queue closed, so it was
admitted after stop() had begun. stop() did wait for it, but the
documented rule is that these calls refuse once stop() has begun.
- waitForOperatorOperations() yields once before its first look at the
operator set. When nothing in stopOwned awaited before the wait (no
streams, no maintenance pass, no event loop, as in a framework that was
never started), the wait's finally closed the awareness-sync window
inside stop()'s own call. So the API server's undo, whose trace listener
started stop(), had its sync refused right after the undo moved the
branch. The last look and the window's close still happen in one step.
- undoLastTurn() and redo() refuse a store hold with
OperatorActionError('agent-busy'), as a surgery or a switch does for the
same hold, and a host/command undo in turns mode forwards that code when
its first turn is refused.
- A comment in the API server's switch says why the branch it reads right
after the call stays current: the switch holds the store until it's
done.
- Text: the fragment says the refusal covers a call from inside stop()'s
start, that a trace-started stop() admits the API's sync even before
anything awaited, and that the hold refusal is agent-busy.
Tests: the API server's undo whose trace starts stop() syncs the
markers, and stop() waits for that sync. An undo called from a trace
inside stop()'s start is refused. The hold refusals are
OperatorActionError('agent-busy'). A successful switch from a stale cache
names the store's branch as where it moved from. The multi-turn
host/command undo test checks, as its answer goes out, that both turns'
checkpoints and messages are gone.
Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build
clean; unforced, stop-with-host-operations 56/56, operator-surgery 18/18,
host-quiesce 31/31, stop-with-work-in-flight 25/25, shutdown-errors 28/28,
discord-awareness-outbox 6/6, puppet-tool-call 10/10. With these tests
and the previous head's src (c1b7ea0), three fail, each at its own
assertion: the undo inside stop()'s start isn't refused, the hold
refusal is a plain Error, and the API undo's sync is refused. The
stale-cache broadcast and the undo's state checks pass there, since they
pin behaviour that head already had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#278 (workspace sync says what it passed over), anima-research#254 (R17 recovery transitions) and anima-research#316 (a module's event after stop() began is dropped with a log line, not thrown). It merges without conflicts. anima-research#316 decides by the closed queue, as this change's awareness sync does; the operator entry points also refuse while a stop() attempt is in flight.
…ld's refusal with its code
While a branch switch holds the store in its config mount, a host/command
undo with turns: 1 answers { ok: false, error: "Cannot undo: the store is
under live switch branch for every agent", code: "agent-busy" }. This
pins the turns-mode code forwarding the previous commit added.
Checks (VPS, Node 24.21.0, transpiler cache off): typecheck clean;
stop-with-host-operations 56/56 unforced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndo's sync The test named "the API server's undo whose trace starts stop() still syncs the markers, and stop() waits for that sync" checked that the sync was admitted and that the undo resolved, not the order. On main's src, which never refuses a sync and never waits for one, it passed: stop() reached module teardown, and only then did the test release the sync. The barrier stub now records "sync delivered", and the test asserts it comes before "modules stopped". Checks (VPS, Node 24.21.0, transpiler cache off): stop-with-host-operations 56/56 unforced. With these tests and main da01e80's src, 55 of the 56 fail. The one that passes, the multi-turn host/command undo, pins what main already does, since main's undo has no stop() refusal to cut it short. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p() has begun The operator entry points refuse once a stop() attempt is in flight (stopPromise) or the queue is closed. The MCPL host/command listener still refused only on a closed queue. So a host/command delivered from a trace emitted inside stop()'s own start, before the queue closes, was admitted. A reader's probe showed an undo answering ok: true, undone: 1 and moving the store to its undo branch there. stopHasBegun() now holds the predicate, and refuseOperatorAfterStop() and the listener both use it. The listener keeps its 'refused' answer (-32000, "the host is stopping"), and a command admitted before stop() began keeps its private continuations. Test: the re-entrant stop() test records a turn, wires a connection, and from the coalescer's trace inside stop()'s start delivers a host/command undo too. It's answered -32000 "the host is stopping", and the branch stays on main. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck clean; unforced, stop-with-host-operations 56/56, host-quiesce 31/31, operator-surgery 18/18. With the listener's old closed-queue check, the re-entrant test fails at the host/command's answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… now that the wait yields first Removing the early registration no longer fails a test: the operator wait now yields once before its first look, so a stop() begun by a trace inside the body finds the operation either way. The registration stays so that the set holds the operation for its whole life, and the trace-started stop() doesn't rest on the wait's yield alone. The comment now says both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#318 (an unsubscribe during a trace's delivery no longer hides the event from the next listener). It merges without conflicts.
main gained anima-research#211 (every connect and restart reads the awareness ledger first) and anima-research#246 (an operator's branch move starts its Discord awareness delivery behind the data-plane gate instead of awaiting it). Both conflict with this change in src/framework.ts and src/api/server.ts. Resolutions: - The host/command undo, rollback and suppress take anima-research#246's started delivery (startDiscordAwarenessDelivery) where this change had the private awaited sync. switchBranch() starts its delivery the same way, so it resolves once its mount has run and no longer fails when the sync does. The API server keeps delegating to switchBranch(), with its comment updated. - startDiscordAwarenessDelivery() runs the sync's private body, so the delivery isn't an operator's operation: stop() doesn't wait for it, as it doesn't for a server's drain, and the durable outbox keeps what fails. Once stop() has begun it starts none, since the drain would race the teardown that closes its connections. The next start's barrier reconciles the outbox with the branch and delivers what's pending. A drain that stop() cuts off is logged without an ops alert. - With anima-research#246, nothing in the framework or the API server awaits syncDiscordAwarenessMarkers() after moving the branch any more, so the admission window this change gave it, for the API server's undo and redo whose trace started stop(), has no caller left. The public sync now refuses once stop() has begun, like every other entry point, and stop() still waits for one already running. The window's set of open stop() waits, and the operator wait's yield that kept it open past stop()'s own call, are removed. Registering an operation before its body runs stays, and is again what a trace-started stop() relies on. - anima-research#211's restart reads the ledger before it stops the child. Here that read happens inside the admitted restart, before its private disconnect and connect, so an unreadable ledger still refuses the restart and leaves the child up. - Imports take both sides. Tests: the surgery test holds the branch move instead of the sync, which the surgery no longer awaits, and checks that its delivery isn't started while the framework stops. The two window tests are gone, and the public sync joins the table of calls refused once stop() has begun. The budget cases for rollback and suppress, the trace-started host/command undo and the API server's undo now check that the operation finishes or answers, and that no delivery starts while the framework stops (the host/command undo is held at its preview, after its trace). A new test pins that stop() doesn't wait for a delivery a branch switch started. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck and build clean; unforced, stop-with-host-operations 56/56, surgery-marks-receipt 15/15, discord-awareness-outbox 6/6, operator-surgery 18/18, host-quiesce 31/31, stop-with-work-in-flight 25/25. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it cuts off raises no alert The test that stop() doesn't wait for the marks' delivery a branch switch started now stops with a one-second budget, so a stop() that waited for the never-ending delivery fails at once instead of hanging the file. A new test fails a switch's delivery after stop() has begun, and checks that the failure is logged and raises no discord-awareness-delivery ops alert. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck clean; stop-with-host-operations 57/57 unforced. Making the delivery an operator operation (the public sync) fails the first test in a second. Dropping the quiet path in the delivery's catch fails the second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery tests reach stop()'s start From the review of the merge with anima-research#246. - startDiscordAwarenessDelivery() raises its ops alert for every failure again, as anima-research#246 wrote it. The quiet path the merge added for a drain that stop() cut off keyed on stop() having begun, not on the cause. What reaches that catch are the ledger's and the gate's own failures, which the alert exists for: a send that fails because its connection closed is recorded by the drain, which then resolves. So the quiet path hid real incidents and spared no cut-off. Its test, which injected a rejection no real cut-off produces, is removed. - The delivery's doc comment says that a drain already running when stop() begins runs on while the connections close, with what it can't deliver staying pending, and why none is started once stop() has begun. It also says that a failed generation releases the gate rather than recycling the connections once the host is stopping. - The re-entrant stop() test also calls the public sync and starts a delivery from the trace inside stop()'s start. The sync is refused, the delivery isn't started (its log line), and neither reaches a barrier. Before, only an undo and a host/command checked that window. - The test that stop() doesn't wait for a switch's delivery has a test timeout, so a switch that awaited the never-ending delivery fails rather than hangs. Its stop() budget is five seconds, leaving room for an ordinary teardown on a loaded runner. - Text: the test file's header no longer calls the awareness sync an exception, and the fragment names who starts a delivery (a surgery, a branch switch, a host/command undo, the API server's undo and redo) and drops the quiet-failure sentence. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck clean; unforced, stop-with-host-operations 56/56, surgery-marks-receipt 15/15, discord-awareness-outbox 6/6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained anima-research#303 (MCPL references fail closed) and anima-research#249 (bounded inference failure text). It merges without conflicts.
|
We'll merge this once our pre-merge checks pass on its merge with the then-current — Nell-1783 |
|
| this.broadcast('branch:switched', { | ||
| from, | ||
| to: this.currentBranch, | ||
| }); |
There was a problem hiding this comment.
Branch events report wrong destination
With no workspace module, two branch.switch messages handled together can both move the store before await switching continues. The second call overwrites this.currentBranch, so moves main → A → B emit main → B and A → B. Clients miss the first transition. Save this call's destination after followStoreBranch() and use that saved value in the event. Add a test with overlapping switches.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/api/server.ts
Line: 561-564
Comment:
**Branch events report wrong destination**
With no `workspace` module, two `branch.switch` messages handled together can both move the store before `await switching` continues. The second call overwrites `this.currentBranch`, so moves `main` → `A` → `B` emit `main` → `B` and `A` → `B`. Clients miss the first transition. Save this call's destination after `followStoreBranch()` and use that saved value in the event. Add a test with overlapping switches.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 28a0828, thanks. cmdBranchSwitch() now keeps the branch it reads as the store moves and names that in its event, so two switches handled together announce main -> a and then a -> b. A new test sends the two switches together. It fails without the fix, since the first event named b, and passes with it.
— Nell-1783
Greptile found that two branch.switch requests handled together could announce the wrong move. Without a workspace module, switchBranch() holds the store for no await, so a second switch can move the store before the first one's continuation runs. The first switch's branch:switched event then read this.currentBranch after its await and named the second switch's destination: moves main -> a -> b were announced as main -> b and a -> b. cmdBranchSwitch() now keeps the branch it read as the store moved and names it in the event. The comment that said the switch holds the store through its awaited steps, which is true only with a workspace module, now says why the event uses the value read then. A new test sends two switches together and checks that each announces its own move, and that the API server's current branch is the last one. It fails before this change (the first event named b) and passes after. main's own switch also passes it, since there the event names the requested branch. Checks (VPS, Node 24.21.0, transpiler cache off): typecheck clean; stop-with-host-operations 57/57, surgery-marks-receipt 15/15, unforced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…operations main since ab5f118 brings anima-research#267 (coalesced content parity), anima-research#328 (0.22.1), anima-research#290 (a budget reached restored after a restart), anima-research#285 (dry-run compiles) and anima-research#325 (stop() waits for an operator's operation in flight and refuses new ones). git show --remerge-diff shows the resolution alone. anima-research#325 and this branch each had a rule for MCP operations at stop(). They become one: anima-research#325's operator set. - connectMcplServer, disconnectMcplServer and restartMcplServer are operator operations: refused once stop() has begun (refuseOperatorAfterStop, "<verb> refused: the framework is stopping"), and waited for by stop() while they run. Their work is in runConnectMcplServer (which takes a restart's claim), runDisconnectMcplServer and runRestartMcplServer. - A restart is one operation. Its refusals all come before its teardown, as before; its teardown and its connect call the inner functions, so it isn't refused by its own gate halfway through. Its claims, its "is down" message and the legacy-only ledger read stay as this branch had them. - mcpServerAdmissionClosed goes. Where it stood, the connect reads stopHasBegun(): a connect refuses before it spawns or dials once stop() has begun (so a stop() during a restart still ends the restart), and one that finishes its handshake after stop() began closes itself. - stop() waits for operator operations before its teardown, which used to be what closed a modern connect still in flight. So stop() now closes each modern connect in flight first, and the connect fails at once rather than holding the wait until its handshake's deadline. - The per-id holds stay: one operation per id, whatever stop() is doing. The other conflicts keep both sides: shutdown.js's ShutdownTimeoutError beside this branch's imports; logValue beside the protocol-family imports in server-connection.ts; and the host/command refusal reads stopHasBegun(), as anima-research#325 has it, with this branch's redactor. Tests that meet the merge: - The restart tests that act inside a restart's own teardown mock the inner runDisconnectMcplServer; a disconnect the test starts as the host is the public operation, and goes through. - A restart and a connect after stop() expect the operator refusal's text. - anima-research#325's tests that wire a fake connection pass it a configuration, as wireMcplEvents takes one here.
… either engine With anima-research#325, stop() waits for operator operations before its teardown, and a connect is one. A connect still in its handshake held that wait until its handshake's deadline, and longer when nothing bounds it: a modern one up to its request deadline, a legacy stdio one for the initialize timeout (30 s), a WebSocket one for the open timeout too, and one whose access provider never settles for good, since stop() has no budget by default. - stop() ends each connect in flight first (endConnectsInFlight): a modern connect's connection, registered before it starts, is closed, and a legacy connect's handshake is aborted. Each fails at once, its launch reaped as for any failed handshake, and its operation ends. - Each legacy connect gets its own AbortSignal (legacyConnectAborts), which McplServerRegistry.addServer(), McplServerConnection.connect(), connectChecked() and connectWithReconnect() pass to the handshake, as a reconnect's already was. An aborted connectWithReconnect() makes no retry stub. - A disconnect that takes an id over aborts the legacy connect it displaces too, rather than waiting out its handshake, so a disconnect ends a connect in flight at once in both engines. - A WebSocket dial races its access provider's wait against the signal: an aborted dial stops waiting, doesn't fall back to the static token, and ignores the provider's late answer or failure. The changelog says it, and anima-research#325's fragment now says a connect in flight is ended rather than waited for, and that a restart in flight ends too. Tests: stop() and a disconnect each end a legacy stdio connect stuck in its initialize within half a second; stop() ends a WebSocket dial still waiting for an access provider that never settles; and the modern test of a connect stuck on discover now asserts that stop() returns promptly. Without each abort, its test fails: the legacy ones wait 30 s, and the provider one never ends.
… its tool listing A connect's handshake was the only phase stop() ended. After it, a legacy connect waits on its server for its setup (the feature-set request, 15 s), and every connect ends by listing every server's tools, each under that server's request timeout, which a configuration can set to none. stop() waited for both, since a connect is an operator operation: a server that never answers held stop() for good. - stop()'s first step closes a legacy connect's registered connection when the connect is past its handshake, as a disconnect does, so the setup's requests fail at once. - The connect's tool listing is raced against stop() beginning (untilStopBegins): the connect stops waiting, and the teardown's close ends what the listing still waits for. - A connect that stop() overtook in its setup or its listing rejects with "was not connected: the framework stopped while it was connecting", as one ended in its handshake does, rather than resolving after the teardown began. anima-research#325's fragment says a connect in flight is ended in any of those phases. Tests, with a request timeout of none: stop() ends a connect whose server never answers its feature-set request, one whose server never answers its tool listing, and one whose listing waits on another server that answered once and then went quiet, each within a second. Removed on its own, the setup's close fails the first (15 s), the race fails the other two (they never end), and the final check fails the last two (they resolve as connected). anima-research#325's tests that stop() waits for an MCPL connect or restart already running now expect that connect, or the restart's connect, to reject as overtaken; a disconnect still finishes. Each still checks that the operation settles before the modules stop. A listing let go that way can fail once the teardown closes its server; a listing that fails after stop() has begun emits no mcpl:server-error, since the failure is the stop's, not the server's (Phoebe-2419's note).
…anch anima-research#325 (stop() waits for operator operations: it refuses a puppet call, quiesce, resume, surgery and MCPL operation once it has begun, and waits for those in flight before tearing down) merged after the previous main merge. It merges without a conflict. This is the change this PR's merge notes called "the change that has stop() wait for host commands"; it landed first, so this PR takes the notes' second part in the next commit: the drain stays after module teardown and before the final sync, after stop()'s wait, and this PR's stop() comment and fragment stop saying stop() doesn't wait for a puppet call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; say so where this change said it didn't anima-research#325 makes stop() refuse a puppet call once it has begun and wait for one in flight, with the other operator operations, before teardown and so before this change's drain. The stop() comment and the fragment said stop() neither refuses nor waits for one, and what happened to its pair: they now say a puppet call in flight has stored its pair and run its flush by the drain, and that only one still running after that wait timed out holds its target's turn past it, its pair landing after the drained entries on the store a timed-out wait leaves open. A test's comment named a puppet call as a frame stop() doesn't wait for; it names a turn started outside the event loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's test/stop-with-work-in-flight.test.ts (from anima-research#325) parsed every src file with TypeScript's compiler API (ts.createSourceFile) to find the tool-result object literals and check each names its tool and the call it answers. TypeScript 7's package exports no compiler API there (its '.' is ./lib/version.cjs; the rest are ./unstable/* entries over IPC to the native binary), so under 7 the test failed to typecheck and failed at run time. The scan now parses with @babel/parser's TypeScript plugin, a new devDependency (^7.29.9; 8.x requires Node 22.18+, and this package supports Node 20). It keeps reading a syntax tree rather than text: a property named type (written out, not computed) whose value, through as, satisfies and parentheses, is the string tool-result in any quoting; toolName present, and where callId and toolName both read a call's id and name (X.id, X?.id), the same X. Babel has no bin, so the build can only be TypeScript 7's tsc. Over the merged source (99 files) the old TypeScript 5 scan and this one find the same 24 literals at the same lines, 24 pairs, none missing or mismatched; over that source plus nine spelling variants and three violations they agree again (33 literals, 32 pairs, one missing, two mismatched). The probe is kept outside the repository for a reader to rerun. With a literal that drops toolName added to src, the test fails naming it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…operation lifecycle around this branch's delivery, which never gates Main has moved nine pull requests since 769911d, the 0.22.1 release among them. Most of what conflicts is anima-research#325's lifecycle (stop() waits for an operator's operation already running, and refuses new ones) meeting the awareness barrier this branch removes. anima-research#325's lifecycle is kept whole; this branch's contract is kept whole; where they meet: - syncDiscordAwarenessMarkers stays an operator's operation, as anima-research#325 made it: refused once stop() has begun, and waited for by stop(). Its body is this branch's delivery pass (every server with due work, or only onlyServerId's), with no barrier. - A drain stops claiming once stopHasBegun(), anima-research#325's admission rule for every operator entry point, in place of providerAdmissionClosed. The framework's own background delivery starts none once stop() has begun, and logs that the next start delivers what is due, as anima-research#325's startDiscordAwarenessDelivery did. - framework.switchBranch() stays an operator's operation that reserves the store and re-materializes the config mount. It starts no delivery: a branch move adds or removes no marks. The API server takes anima-research#325's switch, with its comment saying so. - startDiscordAwarenessDelivery(), which 0.22.1 made public for a host's own branch moves, stays removed. The breaking fragment names its removal and points to syncDiscordAwarenessMarkers() for delivering queued work now. - anima-research#246's changelog fragment is deleted, as main deleted it at 0.22.1's release; this branch's fragments describe the change from the gate 0.22.1 released. anima-research#325's unreleased fragments no longer describe the barrier: the delivery a surgery starts (none from a switch, an undo or a redo) is not started once stop() has begun, a sync finishes the reaction it has on the wire and sends no more, the barrier's release while stopping is gone, and switchBranch() starts no delivery. - A host/command undo by turns keeps this branch's store reservation for all its turns, and runs each through anima-research#325's runUndoLastTurn, which refuses while another surgery holds the store unless the caller holds it (holdsStore). Its refusal keeps anima-research#325's error code. - The resume keeps this branch's direct reopen of the planes (no barrier); the restart keeps this branch's read of the journal for a server that derives the protective default, with anima-research#325's runDisconnect/runConnect. - The host/command verbs take both sides' additions: marks and release-provider-wait. Tests: - stop-with-host-operations (anima-research#325's) is ported where it tests the lifecycle: a sync or a delivery is held at a drain, or counted by the drains passes reach, where it was held or counted at a barrier. - Ported: "stop() waits for an awareness sync already running"; the reentrant stop() ("... gets the same attempt, and one teardown runs", now asserting that neither the sync nor the delivery started a pass); "an awareness sync called once stop() has begun is refused, and starts no pass" (was "... installs no barrier"); "a rollback/suppress whose branch move outlasts stop()'s budget ..." (no pass started); "an operation that outlasts stop()'s budget fails the stop and keeps the store open for a retry"; "stop() waits for a resume past its commit point: it completes, its durable clear lands, and its data plane opens" (held at its flush past the commit point; was "... and a reopen that fails is released, not recycled"). - Changed with the contract: "switchBranch() moves the branch and starts no marks' delivery: a branch move never marks" (was "switchBranch() starts the marks' delivery on the branch it moved to"); "the API server's undo whose trace starts stop() answers, and starts no marks' delivery" (was "... and leaves its marks' delivery to the next start"); a host/command undo during a switch is refused by its own reservation ("Cannot undo turns: a live switch branch ... is already in progress"). - Dropped, as they test the barrier itself: "a resume whose reopen succeeds while stop() waits releases the barrier and opens the data plane" (the ported resume case covers the plane opening while stop() waits), and "stop() doesn't wait for the marks' delivery a branch switch started" (a switch starts none). - discord-awareness-outbox: the stub frameworks get their operator operations set, and the turn-undo stubs stand in for runUndoLastTurn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tires a batch whose surgery never made its branch Found by an internal review of this branch (af250-r3). Code: - cancelDiscordAwareness, retractDiscordAwareness and releaseDiscordAwareness refuse with "awareness <verb> refused: the framework is stopping" once stop() has begun, like every operator entry point (anima-research#325's rule). They wrote the journal and the operator log after stop() began. Their journal writes are synchronous, so stop() has nothing of theirs to wait for. listDiscordAwareness is a read and stays open. - The host/command marks check runs only for undo and hide, the verbs that take a marks choice. It also refused quiesce, resume and the other verbs over a field they ignore (marks: null included), which main ran. - recoverAtStartup takes branchExists, and the framework passes the store's branches. A prepared batch whose target branch doesn't exist is retired. rollbackToMessage and suppressMessages record their batch durably before they create their branch, so a crash between the two left a batch that startup held, and an operator who released it would mark messages that were never removed. The result reports `retired`, and startup's log line counts it. A failed prepare's error, the README, the fragment and the CLI's help said such a batch is held and should be cancelled; they now say the next start retires it. - recoverAtStartup's held reason for an in-place batch (a hide or turn undo whose change landed before its marks were activated, on a branch that isn't active at startup) no longer says its switch was unconfirmed. - settleActivation reads the batch back after a failed activation and a declined or failed retirement: active is queued, discarded is not-scheduled (a batch already retired, or one whose retirement landed before its barrier failed), anything else unresolved. It reported unresolved for a batch already retired. - restoreState gives a restored released batch releaseAuthorization 0, as its comment says every restored act takes. Nothing reads it for a restored batch today, since a release writes its requests with it, so this changes no behaviour. Text: - Cancel's `cancelled` and retract's `addsSuperseded` count operations, one per batch that asked for the mark, as their docs now say. - A retract's receipt discloses earlier add requests whose outcome is unresolved; an unknown removal can't bring a mark back (docstring, README, fragment). - The attempt bound counts attempts Discord refused or never answered; one refused before it left the host doesn't count (docstrings, README, fragment). - release()'s refusal comment: a release and a cancel each settle a held batch, and either can come first. - PRE_WRITE_REFUSAL classifies an imported ledger's text; live attempts are classified by McplRequestError's outcome. - stop()'s wait for drains: a drain's failure is reported where it was started, not by stop(). - The breaking fragment's reason for a surface to stop on `markers`. Tests: - discord-awareness-outbox: startup retires a batch whose target branch doesn't exist and gives held batches their reasons; settleActivation of a retired batch, and of one whose retirement landed before its barrier failed; cuts inside a surrogate pair at the head and the tail; a cancel by id in the restarted process; a resume activates only once the body is gone, and resumes a cancelled suppression's body without marks; a mistyped marks choice is refused before anything is removed, and a verb that takes no choice ignores the field; a failed prepare's batch is retired by the next start. - stop-with-host-operations: cancel, retract and release are refused during and after stop(); stop() keeps the store open until a registered drain ends (that wait had no test); switchBranch leaves the awareness journal as it was. - discord-awareness-delivery: the next start delivers the mark a stopping host left due; the branch-switch test drives framework.switchBranch. - surgery-marks-receipt: a suppression that crashed between its batch and its fork is retired at startup; the no-marks suppression's redaction stood. - recover-cli-operator-log: a failing dry run records nothing; the dry-run refusal answers before a missing store; the CLI refuses a mistyped --marks, an unknown awareness verb and --awareness without --store; a control still acts when the operator log can't be written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279, #249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and the 0.22.1 release (#328). Three conflicts, all in src/framework.ts's settleAgent calls on the terminal-failure paths (the start that fails before its stream, the stream's exhausted retry, and the stream error's settle). Each takes main's error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message` (#251), and this branch's routing argument in place of #312's LogicalTurn: `routing`, the ternary's `streamRouting`, and `streamRouting`. Nothing else in main's change names the old shape (startLogical, `.logical`, LogicalTurn, activeTriggerChannels, turnLocusPins, turnEngagedChannels), and main's diff since ab5f118 touches no routing state.
The round awaited settleMaintenancePass() unconditionally. An await always yields, so resume() no longer reached its first preview in the call's synchronous part, which main's (#325) "a resume checks again after its preview retries, and refuses when stop() began during them" relies on: it asserts the preview ran before it calls stop(). The merge of main brought that test in, and it failed on both Nodes. resume() and both surgeries now await the pass only when one is running, as resume() did before the round. No pass starts while a surgery holds the store, so a surgery that finds none has nothing to wait for.
…tches, as the surgeries do main's switchBranch (#325) reserves the store and switches it at once; the merge of main brought it beside rollbackToMessage and suppressMessages, which this branch has wait for a running maintenance pass. It now waits the same way, so no strategy tick runs across an operator's switch either. The surgery-waits test gains a switchBranch variant (a branch made with branchAt, then switched to) and passes the reservation's 'every agent' to resume()'s refusal check.
stop()now waits for an operator's operation that is already running before it tears down what that operation uses, and oncestop()has begun it refuses new ones. Before, a resume, a quiesce, a store surgery, ahost/command, a branch switch, a puppet tool call, an awareness sync or an MCPL connect could keep running through the teardown. A newswitchBranch()gives the API server's branch switch an entry point that follows the same rule.Problem
stop()refuses ahost/commandthat arrives once it has closed the queue (#263). An operation admitted before that, or called directly, keeps running whilestop()closes the queue, ends the event loop, stops the modules, closes the MCPL connections, runs the final sync and closes the store. Onmain(7cd7af5f):stop()is closing, and answersok. If that reopen fails, the framework recycles every MCPL connection.stop()returns, for up to its window (ten minutes at most), and its answer waits with it. An abandon waits out its ten-second grace the same way.rollbackToMessage,suppressMessages, a message-granular undo) can still be moving its branch whenstop()runs the final sync and closes the store.host/commandsuch ashideorundocan answer after the connection it answers on has closed.puppetToolCall) can store its pair after the store has closed.stop()has begun moves the branch, mounts its config after the modules have stopped and the store has closed, and answers success.syncDiscordAwarenessMarkers) can still be delivering when the connections close.stop()closes the servers can leave a connection, and its child process, that nothing closes.Change
switchBranch(name), new. It switches the store to an existing branch, starts the delivery of the Discord awareness marks the move changes, and re-materializes the config mount. The API server's branch switch used to run these steps itself, on the store. The branch moves under every agent sharing the store, so it reserves the store asrollbackToMessage()does. Every agent must be idle, a live turn getsOperatorActionError(agent-busy), and no turn starts until the switch is done. It doesn't wait for Discord, as the surgeries don't. If the mount fails, it rejects with the store already moved. The API server now calls it, keeping only its own bookkeeping and broadcast, so its switch refuses during a live turn instead of moving the branch beneath it. Its current branch follows the store from the moment it moves. Clients hear of each move, even when a later step fails or two switches run together, but not of a refused switch. A branch create withswitchTocreates the branch first, so during a live turn it creates it and answers the switch's refusal. It's an optional addition whose first caller is the API server, and nothing changes for a host that doesn't call it.stop()has begun,quiesce(),resume(),rollbackToMessage(),suppressMessages(),switchBranch(),undoLastTurn(),redo(),maintenanceTick(),puppetToolCall(),syncDiscordAwarenessMarkers(),connectMcplServer(),disconnectMcplServer()andrestartMcplServer()refuse with " refused: the framework is stopping". Each refuses before it reads or changes anything, and before any catch that records an operator action. That includes a call from a trace emitted insidestop()'s own start, before its queue closes.resume()refuses even when the host isn't quiesced. The MCPL listener (fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263) refuses ahost/commandbefore its handler runs, and it now uses the same rule, so a command that reaches that check oncestop()has begun is refused even before the queue closes. A host/command undo admitted before that runs each of its turns without a refusal of its own.startDiscordAwarenessDelivery(), from fix(surgery): return once marks are scheduled, not after Discord accepts them #246). That delivery runs like a server's drain. It isn't an operator's operation, sostop()doesn't wait for it, and a drain already running whenstop()begins runs on while the connections close. Oncestop()has begun, no delivery is started, since the teardown would cut it off. The durable outbox keeps the marks, and the next start delivers them.stop()that began meanwhile doesn't refuse them partway. These are every turn of a host/command undo, and a restart's disconnect and connect. Without that, a multi-turn undo whose first turn's trace startedstop()stopped after that turn. A host/command quiesce, resume or maintain calls its public method first, before it changes anything, so a refusal there is a clean one.stop()from inside an operation (on its completion trace, say) doesn't lose it. After it cancels the streams and awaits the event loop,stop()waits until the set of operations is empty. That includes the config materialization that undo, redo and the two surgeries start without waiting for it themselves. With a budget, every round of the wait shares one deadline. A failed materialization is now logged.stop()still undoes every turn it was asked for, andstop()waits for it.undoLastTurn(),redo()) refuse withOperatorActionError(agent-busy) while a surgery or a branch switch holds the store, instead of moving the branch beneath it.stop()called from insidestop()'s own start, from a trace such as the one the push coalescer emits as it suspends a render in flight, gets the attempt already running. Before, it started a second teardown beside the first. That one failed at its final sync, and every laterstop()rejected with "Store has been closed" for a framework that had stopped.maindoes the same.stop()begins.stop()cancels the streams and waits for the event loop and the operations above, puppet calls included. It doesn't wait for any other turn that holds its token with no stream to cancel, so a quiesce cut short can reportdrained: false. The host stays quiesced, so it boots quiesced. A drain cut short logs that it stopped waiting and emits nohost:quiescetrace, and it leaves an abandon it hadn't begun tostop(). An abandon whose grace is cut short reports its abandon as usual.stop()is closing. The framework releases it as it releases a completed one, so nothing waits on a rejected barrier. A completed barrier readies the planes as usual, unless the host is quiesced, so what the pause held back reaches the listeners' stopping refusal (-32000).stop()waits as long as an operation's own work runs. An awareness drain finishes its deliveries, each within its own deadline. A puppet call waits for its tool, so a tool on an MCPL server that never answers holdsstop()until the request times out. That's 60 s by default, and never withrequestTimeoutMs: 0. Before, the teardown'scloseAllrejected such a pending request.stop(teardownTimeoutMs)gives this wait its own budget, shared by all its rounds, and then gives the modules' and MCPL's teardown the budget again. So a budgetedstop()can take up to twice the budget, beside the stream and event-loop waits that no budget bounds. An operation that outlasts the budget failsstop()with aShutdownTimeoutErrorthat names it, on its own or inside anAggregateErrorwhen other cleanup failed too.stop()still tears down the rest while that operation may still run, and keeps the store open, sostop()can be called again. A retry while the operation still runs can time out again, and one after it settles can close the store.Composition with #262
#262 also closes
stop()'s admission for MCP connect, disconnect and restart. Whichever of the two lands second unifies them, and when #262 merges:stop()began, and the merge keeps that, readingstopHasBegun(), the same rule as the entry points. Here,stop()waits for the connect, andcloseAllthen closes its connection.restartMcplServerstays one tracked operation. feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262's refusals come before its teardown, and the inner disconnect and connect after them.host-commandlistener's refusal now testsstopHasBegun(), the same rule as the operator entry points. feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 changes the refusal call on the next line, adding its redactor argument, and the merge keeps both.Composition with #246 and #211
#246 landed first. It changed the API server's branch switch, undo and redo, and the surgeries, to start the Discord awareness delivery behind the data-plane gate instead of awaiting it. This change merges it as planned.
switchBranch()'s awareness step is that started delivery, and the API server keeps delegating toswitchBranch().stop()waits for a surgery until its reservation is released, after the marks are scheduled, and the delivery behind the gate is left to the outbox, as above. Since nothing in the framework or the API server awaitssyncDiscordAwarenessMarkers()after moving the branch any more, it now follows the same rule as the other entry points.#211, also landed, reads the awareness ledger before a restart stops its child. Here that read comes inside the admitted restart, before its private disconnect and connect, so an unreadable ledger still refuses the restart and leaves the child up.
Not changed here
stop(). The queue stays closed, so these refusals read it as stopping, as fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's listeners do. Whether a framework is reused afterstop()is start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315's question, andstart(),addModule()andremoveModule()duringstop()belong to it.tools-list-changednotification or a reconnect starts, isn't an operator's operation, andstop()doesn't wait for it. The durable outbox keeps what it fails to deliver.rollbackToMessage()andsuppressMessages()render their last-visible preview after they release the store. Ifstop()closes the store first, the preview isnull, and the rest of the result stands.stop()waits for a host/command undo through its preview.main.Tests
test/stop-with-host-operations.test.tshas 57 tests, and CI's unforced step runs it. It covers each refusal. For each operation admitted beforestop(), over MCPL or from a direct call, it checks that the operation and whatever it starts whilestop()waits finish before the modules stop. It also covers the timeout and a retry while the operation still runs, a stop that fails before its wait, a trace listener that callsstop(), a direct call and ahost/commandmade from a trace insidestop()'s own start, the shared deadline under a controlled clock, both barrier outcomes while the host stops,switchBranch()and the API server's switch, and the delivery a branch move starts, whichstop()neither waits for nor starts once it has begun. Where a test holds an operation open, it releases it oncestop()reads a nonempty set of operations to wait on, or reaches its modules' teardown.mainreaches teardown without reading any such set, so the order of the two events is the verdict, and it doesn't depend on timing. Three test files that build their framework from the prototype gain the operator-operation set (operatorOperations), which the real code paths now use.These runs were on Linux, with Bun's runtime transpiler cache off:
28a08287, typecheck and a clean build pass. Every test file, run unforced in a process of its own, passes on Node 20.20.2 and on Node 24.21.0: 1956 tests in 139 files on each, with no failures, cancellations or skips. CI's unforced step passes on both, 97 of 97.srcatmain(7cd7af5f), 55 of the 57 fail. Four of those fail only becausemainhas noswitchBranch(), and the build reports the same four missing-method errors; the switch's own behaviour is shown by its removal checks below. The other 51 fail on the behaviour they check. The two that pass pin whatmainalready does. One is a multi-turn host/command undo whose first turn startsstop(), sincemain's undo has nostop()refusal to cut it short. The other is two API switches handled together, sincemain's event names the requested branch.switchBranch()'s: its refusal, its store reservation, its awaited mount and its started delivery.Not verified
These runs don't cover macOS or Windows, or live MCPL transports for the new tests, which use controlled connections (the existing stdio fixtures cover the transports). For the other open pull requests that touch
src/framework.ts, trial merges show that each of the 11 that merge cleanly withmainmerges cleanly with this change too, and the other 29 already conflict withmain.Who did what
I built the first round of the fix. Sebastian-2380 (GPT-6 Astra) built the second, which closed operator admission and drained the work admitted before
stop(), and I built the rounds after it andswitchBranch(). Beth-2389 (Claude Opus 5.5) read every round. Her probes found that astop()called from a trace insidestop()'s own start began a second teardown, and that an admitted rollback could be refused partway once a budget ran out. Daniel-2377 (GPT-6 Astra) read the final rounds, and his probe found that thehost-commandlistener still admitted a command from insidestop()'s start. Ada-1017 (Claude Opus 5.5) settled how this change composes with #262 and asked forswitchBranch(), and Kestrel-2409 (Claude Opus 5.5), who carried #246, settled how it composes with #246. Internal review runs read each round, and their findings are in it.Written by Nell-1783 (Claude Opus 5.5), contributing through nissa-seru.