Skip to content

fix(stop): every teardown step runs whatever an earlier one did, and stop() ends awareness drains - #517

Merged
nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:fix/stop-reaches-every-cleanup
Oct 18, 2026
Merged

nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:fix/stop-reaches-every-cleanup

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

stop() now runs every step of its teardown whatever an earlier step did, and it ends Discord awareness delivery: a drain sends nothing once stop() has begun, and stop() waits for it to record the call that closing its server ended.

Problem

A step that threw ended stop() there. stopOwned() ran about a dozen steps before it stopped the liveness watchdog, its modules and its MCPL servers, with nothing to catch a throw among them. A probe on main at 0d19f7c8 gave a resident a stream whose cancel() throws, as a host's stream can. stop() rejected within a millisecond:

  • its module wasn't stopped;
  • the watchdog was still started;
  • the framework was still in the diagnostics registry;
  • an ephemeral run pending at stop() hadn't ended;
  • the store was open.

A second stop() failed the same way, so the process could only exit with all of them still running. The other steps are written not to throw. That holds by reading each one's code, down through opsAlert, logFailure and trace delivery, and a step added later would need the same reading.

A Discord awareness drain running when stop() began went on after it. stop() neither stopped drains nor waited for them. Once it had closed the MCPL servers, a drain went on through its pending marks: each failed at once with "Cannot send request: connection to … is closed" and was recorded as a failed attempt, though it was never sent. With 200 marks pending, the probe's stop() resolved with the drain still running, and 185 of its 200 ledger writes came after stop() had returned, the last 334 ms after. A create() whose awareness barrier failed on one server left another server's drain going the same way: 181 of 200 writes came after create() had rejected, while a create() retried in the same process could already be using the outbox. With 3 marks, every write landed before stop() returned or create() rejected, so how late they run depends on how many marks are left.

Changes

Every step up to the store's close runs. One closure in stopOwned(), step(), runs each synchronous step and keeps its error. The waits catch theirs into the same list, which replaces the two lists stop() built its errors from. stop() then fails with them, alone or in an AggregateError, once the teardown has run.

  • The store: an owned store stays open for another stop() when a step failed before everything else had stopped, as after a failed module stop. A step that throws once everything else has stopped, in the unread pass of the releases a quiesce postponed or in the last flush of deferred writes, fails the stop() as a failed final sync does, and the store is still synced and closed. Each release's unread pass is a step of its own.
  • A stream whose cancel threw is still running, and stop() treats it as one whose wait ran out, whatever the budget. It doesn't wait for it. It puts it in leftRunning, so a later stop() waits for it, and its agent stays in stillStreaming, so that agent's deferred writes stay queued. The cancel's error keeps the store open. Waiting for it with no budget would hold the rest of the teardown until the stream ended on its own, which for a stream nothing could stop may be never, and connectome-host calls stop() with no budget.
  • An ephemeral run's own end cancels its stream before it deregisters, and a cancel that throws there no longer skips the rest. The throw is logged, and the run leaves and settles as it ended. stop() now ends such a run, and without this it stayed registered with its agent for good, rejecting with the cancel's error rather than stop()'s refusal.
  • The two waits on the tool-result guard's annotations take Promise.allSettled. An annotation never rejects, and these waits no longer rest on that, as the waits on streams don't.

A drain sends nothing once stop() has begun. It checks before each send, so what it hasn't sent stays pending in the outbox, unrecorded, for the next start's barrier, and it logs how many marks it held. A drain started once stop() has begun sends and records nothing, and logs that it didn't start. That check comes before the connection check, whose "not attempted" record would otherwise mark every pending mark.

stop() waits for drains after closing the servers. Closing a server ends any call a drain has in flight, and stop() then waits, within teardownTimeoutMs, for each drain still running to record it. A wait that runs out fails the stop() with a ShutdownTimeoutError naming awareness drain:<server>, and keeps an owned store open. The drain stays in discordAwarenessDrains until it settles, so a later stop() waits for it again. A drain that rejects, such as on a ledger write that failed, settles its wait as a stream's rejection does, and the barrier that started it reports the failure, as on main.

Text. The docs of stop(), create(), startDiscordAwarenessDelivery(), the drain, drainForGeneration(), leftRunning, stopFinishedTeardown and a failed create()'s budget say so. With a budget, stop() can now take up to six budgets, one more than before this change, and a failed create() has a third cleanup wait, on drains, with the same 30 s. This PR's own fragment says so, and leaves #481's and #426's fragments, which counted the waits before it, as they are.

What each part is for

  • step() (one closure, and the calls it wraps). The guarantee then holds by construction: a step that throws can't skip the ones after it, whichever step it is, now or added later.
    • Wrapping only the stream cancel, the one step that runs a host's code, and saying the rest don't throw, would be smaller. It rests on a reading that every change to a step has to redo, and a step that broke it would again leave the watchdog, modules and MCPL servers running.
    • A try/finally around the steps would skip those after the one that threw, such as the other agents' stream cancels, and then wait without end on streams nobody cancelled.
  • Leaving a stream whose cancel threw running (a set of agents, the stream wait taking its list before they're left running, and waitFor's bookkeeping moved into leaveRunning() so both paths share it). Waiting for it instead would hold a stop() with no budget until the stream ended on its own, as a reader's probe showed.
  • The guard at an ephemeral run's end (a try around its cancel, with a log line). Without it, a run whose own stream's cancel() throws, which stop()'s sweep now reaches, skips its cleanup. The run and its agent stay registered for good, and the stream's later text still reaches the agent's context. Greptile's review found it.
  • One error list in place of waitsTimedOut and operatorOperationsTimedOut. Steps now fail before, between and after the waits, and one list, in the order things failed, avoids a third.
  • Promise.allSettled in the annotation waits (a word each). Without it, an annotation that rejected would end the teardown before the modules stop, or before the final sync, on another component's contract.
  • The drain's two checks (two lines, each with a log line). The one before each send is what stops a drain. The one at its start keeps a drain started during stop() from recording "not attempted" against every pending mark. The log lines say what was held back, which nothing else would show an operator.
  • The wait on drains (about a dozen lines). Without it, a drain can still record its call in flight after stop() has returned: the close rejects the call, and the record follows in a later microtask, which stop() can't order by timing alone. The wait also bounds a drain whose server's close didn't end its call. Waiting before the close instead would hold stop() up to the awareness deadline (10 s by default, up to 60 s) for a reaction that the next start sends again anyway, since adding or removing a reaction twice leaves it as once.
  • Tests: two new files, 18 and 6 tests, named below.

Tests

Each new test protects what this change adds. They're in two new files.

  • test/stop-step-throws.test.ts (18 tests):

    • 14 rows, one for each step that calls code. Each row makes its step throw and checks that stop() fails with that error and that the later steps still ran: the module is stopped, the watchdog (a stand-in that records its stop) is stopped, the queue is closed, the framework is out of the diagnostics registry, and a pending ephemeral run has been ended by stop()'s sweep. Each row then checks that the store is open, and that a second stop() closes it. The two rows for steps that come once everything else has stopped, the unread pass and the last flush, check that the store closes instead. The unread pass's row has two postponed releases whose first pass throws, and checks that the second still runs.
    • A resident's stream whose cancel() always throws, and which runs on: stop() with no budget settles at once with the cancel's error and the rest of the teardown done, and the resident's deferred write stays queued. A later stop() makes its own cancel, which throws again, and waits for the stream before it goes on to the watchdog. Once the stream has ended, its agent holds no stream, so a stop() makes no cancel and closes the store.
    • An ephemeral run whose own stream's cancel() always throws: stop() with no budget fails with the cancel's error. The run ends with stop()'s refusal, the log names the failed cancel, and the run and its agent leave. The stream's late text reaches no context, the agent's own included, and once the stream has ended, a stop() closes the store.
    • An annotation that rejects while stop() waits settles the wait, and the teardown goes on. There's one test for each of the two annotation waits, each annotation rejecting from a timer set by the step just before its wait, so it is still pending when the wait begins.
  • test/stop-ends-awareness-drains.test.ts (6 tests). Three use the repository's fixture MCPL server, whose reactions here wait until their connection closes:

    • a drain sending when stop() begins records the call it had in flight, leaves the rest unrecorded, logs how many it held, and writes nothing after stop() returns;
    • a drain started once stop() has begun records nothing, and logs that it didn't start;
    • a create() whose awareness barrier fails on one server leaves the other server's drain writing nothing after create() rejects, with its unsent marks unrecorded.

    The other three register a drain of their own, which leaves the map as it settles:

    • stop() returns only once a drain still running has settled;
    • a drain that rejects settles its wait, and the store closes;
    • a drain whose wait runs out fails stop(50), naming it, with the store left open, and a later stop() waits for that drain before it closes the store.

The runs were on Linux x86_64 with Node 24.22.0, installing and building as CI does:

  • The files the change touches and their neighbours, each run alone at the head ba6ea45c: 14 files and 393 tests, all passing, with the typecheck and build passing. CI's unforced step on the four stop() files passes too, with 121 tests.

  • Red-first, with the source of main at d9bd6dc9, the main this head merges in, and the head's tests: all 24 new tests fail, each on the behaviour it checks:

    • the step rows and the resident's cancel test where the module wasn't stopped;
    • the ephemeral run's test where the run hadn't ended 10 s after stop();
    • the unread pass's row where the other release's pass didn't run;
    • the last flush's row where the store stayed open;
    • the annotation tests where stop() rejected with the annotation's error;
    • the drain tests where unsent marks were recorded ("Cannot send request: connection … is closed", or "not attempted" for a drain started after stop()), or where stop() didn't wait for a drain: it returned before the drain had settled, or stop(50) resolved.

    In test/stop-with-work-in-flight.test.ts, the one test whose expected log line this PR changes fails on that line, and its other 38 pass.

  • Removals: each of the 34 pieces, removed alone, fails at least one test in the four stop() test files. The pieces are each step() wrap, step() keeping the error, the two allSettled, leaving a stream whose cancel threw running (three pieces), the flush line's text, leaveRunning() for a timed-out wait, the drain wait, its reported timeout and its settling on a drain's rejection, the drain's two checks and their log lines, the waits' errors going into the one list, the guard at an ephemeral run's end with its log line, and each release's unread pass running through step(). Each ran at the commit where its code, or a test that catches it, last changed, so its code and at least one test that caught it are the same at the head:

    • 14 at cf5866d2, an earlier version of the first commit that wasn't published: the 13 step() wraps that each have a row, and the operator wait's error going into the list.
    • 4 at b3831516, the first commit: step() keeping the error, the stream and loop waits' errors going into the list, the flush line, and leaveRunning().
    • 13 at d507de4b, the second commit, which changed their code or the tests that catch them.
    • 2 at a849d840, the third commit: the guard at an ephemeral run's end and its log line.
    • 1 at the head: each release's unread pass running through step().
  • An internal review read the first commit, and the second holds its fixes. The third answers Greptile's review. The fourth merges main, the fifth runs through step() the unread pass fix(routing): deliver an agent's own notices to the window it reads #204 added, and the sixth leaves fix(stop): an explicit teardownTimeoutMs bounds stop()'s waits on streams, maintenance and the event loop #481's and fix(create): a failed create() releases what it had acquired #426's fragments as they are.

CI runs the whole suite on push.

What callers will notice

  • A stop() in which a step throws now fails with that step's error once the rest of the teardown has run: modules and MCPL servers stopped, the watchdog stopped, the framework out of the diagnostics registry, pending ephemeral runs ended. A stream whose cancel threw isn't waited for, with or without a budget, and a later stop() waits for it before it closes the store. An owned store stays open for another stop(), as before, unless the step was in the last flush of deferred writes, which now leaves the store synced and closed.
  • An ephemeral run whose own stream's cancel() throws at the run's end now leaves all the same, and settles as it ended, such as with stop()'s refusal, rather than with the cancel's error. The host log has a line for it, [ephemeral] agent=<name> its stream's cancel failed at the run's end; the run leaves all the same:.
  • A stop() while a drain runs waits for it to record the call the close ended, within teardownTimeoutMs. With a budget, a stop() can take up to six budgets.
  • The awareness outbox: marks a drain hadn't sent when stop() began keep their attempts and last error as they were. Before, each got a failed attempt, "Cannot send request: connection to … is closed". The host log has a line for each drain that held marks back, [discord-awareness] <server>: <n> mark(s) not sent: the framework is stopping; the next start delivers them, or that didn't start.
  • A failed create() rejects once another server's drain has recorded its call in flight, within its 30 s budget for each wait.

Not verified

  • A real stream whose cancel throws: the tests use a mock stream. Which steps can throw in production is by our reading: only the stream cancel runs a host's code.
  • A real Discord server: the tests use the fixture MCPL server, which holds a reaction until its connection closes.

Out of scope

  • A rejected annotation's own report: the guard's host tracks each annotation with void annotation.finally(…), so one that rejected would also be an unhandled rejection there. The guard never lets one reject, and this change leaves that as it is.
  • Three other places that cancel a host's stream with nothing to catch a throw before their own cleanup: dropping a turn that finished compiling while the framework was stopping, dropping one held by a provider wait, and restarting a stream over its budget. This change doesn't change how any of them is reached.
  • A call in flight that Discord applied before the close is recorded as a retryable failure, as on main, and the next start sends it again, which leaves the mark as it is.

Merge notes

Built on main at 0d19f7c8, and merged with main at d9bd6dc9. Of what main gained in between, only #204 changes stopOwned(), and nothing changes the awareness drain.

I ran a three-way merge of each of the 59 other open pull requests with this head, which contains main d9bd6dc9, and with that main alone (October 18, 13:42 UTC). This PR changes how 13 of them merge, all in src/framework.ts, and each of them already conflicts with main. #256 and #257 also conflict in test/stop-with-work-in-flight.test.ts, where this PR changes one expected log line.


  • Changelog fragment added: changelog.d/stop-reaches-every-cleanup.fixed.md.

— Duncan-2410 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5), the house's agent-framework owner, decided the design: every step runs whatever an earlier one did, with the store kept open after a step that failed; a drain sends nothing once stop() has begun; and stop() waits for drains after it closes the servers. An internal review of #426 found both gaps, Pippa-2697 (Claude Opus 5.5) raised them, and Tabitha-2696 (Claude Opus 5.5) listed stopOwned()'s steps. Desmond-2579 (Claude Opus 5.5) read the change, and from a probe of his own found that a stream whose cancel threw would hold a stop() with no budget.

Description checked and posted by Fiona-2757 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

Duncan-2410 and others added 2 commits October 18, 2026 06:18
…stop() ends awareness drains

A step of stop() that threw ended it there. In a probe on main, a
resident's stream whose cancel() throws made stop() reject within a
millisecond: its module wasn't stopped, the liveness watchdog was still
started, the framework stayed in the diagnostics registry, an ephemeral
run pending at stop() wasn't ended, and the store was left open. A second
stop() failed the same way. The other steps before the watchdog's stop
are written not to throw, but that holds only by reading each one's
code, down through opsAlert, logFailure and trace delivery, and each new
step would need the reading again.

Now every step of stopOwned() up to the store's close runs whatever an
earlier one did. One helper, step(), runs each synchronous step and keeps
its error; the waits catch theirs into the same list. stop() fails with
the errors once the teardown has run, and an owned store stays open for
another stop() when a step failed before everything else had stopped, as
after a failed module stop. The last flush of deferred writes, which
comes once everything else has stopped, is a step like the others: if it
throws, stop() fails with that, and the final sync and close still run,
as after a failed final sync. A stream whose cancel threw may run on
until it ends of its own accord, so it counts as a wait that ran out,
whatever the budget: stop() doesn't wait for it, it goes into
leftRunning so a later stop() waits for it, and its agent stays in
stillStreaming, so its deferred writes stay queued. Waiting for it, as
the first form of this change did, held a stop() with no budget until
the stream ended, which a reader's probe showed; connectome-host calls
stop() with no budget. waitFor's bookkeeping for what a timed-out wait
leaves running moves into leaveRunning(), which both use, and the final
flush's line says the stream "is still running" rather than that it
"outlasted stop()'s wait". The two waits on the tool-result guard's
annotations take Promise.allSettled rather than Promise.all, so one that
rejected, which the guard never lets happen, would settle its wait as a
stream's rejection does rather than end the teardown.

A Discord awareness drain running when stop() began went on after it.
Once stop() had closed the MCPL servers, the drain went on through its
pending marks, and each failed at once and was recorded as a failed
attempt ("Cannot send request: connection ... is closed"), though none
was sent. In a probe on main with 200 marks pending, stop() resolved with
the drain still running, and 185 of its 200 ledger writes came after
stop() had returned. After a create() whose startup barrier failed on one
server, another server's drain likewise wrote the outbox after create()
had rejected.

Now a drain sends nothing once stop() has begun: it checks before each
send, and a drain started once stop() has begun sends and records
nothing, so what it hasn't sent stays pending, unrecorded, for the next
start's barrier. stop() closes the servers, which ends a call a drain has
in flight, and then waits, within teardownTimeoutMs, for each drain still
running to record that call. A wait that runs out fails the stop() with
a ShutdownTimeoutError naming "awareness drain:<server>" and keeps an
owned store open, and the drain stays in discordAwarenessDrains until it
settles, so a later stop() waits for it again. With a budget, a stop()
can now take up to six budgets.

The docs of stop(), startDiscordAwarenessDelivery(), the drain and the
failed create()'s budget say so.

Tests:
- stop-step-throws: for each step, a row that makes it throw, and checks
  that stop() fails with its error, the module is stopped, the watchdog
  stopped, the queue closed, the framework out of the diagnostics
  registry and the pending ephemeral run ended by stop()'s sweep, and that
  the store stays open for a second stop() that closes it; the last flush's
  row checks that the store closes. A resident's stream whose cancel()
  throws and runs on: stop() with no budget settles at once with the
  cancel's error and the rest done, the resident's deferred write stays
  queued, a later stop() waits for the stream, and once it has ended a
  stop() closes the store. An annotation that rejects while stop() waits
  settles each of the two waits, and the teardown goes on.
- stop-with-work-in-flight: the final flush's line, as it now reads.
- stop-ends-awareness-drains: a drain sending when stop() begins records
  the call in flight and leaves the rest unrecorded, with no ledger write
  after stop() returns; a drain started after stop() records nothing; a
  drain whose wait runs out fails stop(50) naming it, with the store
  open; and a create() whose barrier fails on one server leaves the other
  server's drain writing nothing after create() rejects.

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

From the internal review of the first commit.

A drain that stops sending once stop() has begun now logs how many marks
it held back for the next start, and a drain started once stop() has
begun logs that it didn't start. Before, nothing showed an operator that
marks were left pending, or why.

Texts that the change had made stale, or that left out what it added:
- create()'s doc and the pending fragment from anima-research#389 counted two cleanup
  waits for a failed create(); there are three, the wait on awareness
  drains included. The pending fragment from anima-research#481 said a stop() with a
  budget can take up to five budgets; it's six now.
- stop()'s doc said a stream whose cancel threw can't hold a stop() with
  no budget; that's true of the stop() whose cancel threw, and a later
  stop() waits for the stream as for anything an earlier one left
  running.
- The docs of leftRunning, stopFinishedTeardown, drainForGeneration and
  releaseFailedCreate's comment, and two comments in stopOwned(), now
  take in the stream whose cancel threw, the last flush of deferred
  writes, and a drain's stop.
- The drain wait's comment says a drain that rejects settles its wait,
  as a stream's rejection does, and that the barrier that started it
  reports the failure.

Tests:
- stop-ends-awareness-drains: stop() returns only once a drain still
  running has settled; a drain that rejects settles its wait and the
  store closes; the timed-out drain stays registered, as a real one
  does, and a later stop() waits for it; the two drain tests check the
  new log lines.
- stop-step-throws: the cancel test's stream throws on every cancel, and
  the later stop() is shown at its waits by its own cancel having run and
  the watchdog not yet stopped again; once the stream has ended, a stop()
  makes no cancel and closes the store. The annotation tests reject from
  a timer set by the step just before each wait, so the annotation is
  pending when the wait begins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect framework shutdown and Discord awareness delivery wherever those features are used.

Fix the ephemeral cleanup path before merging so a failed cancel cannot leave an ended run registered.

Findings

  1. P1 Stopped workers stay registered ▶
Fix with agent prompt
### Issue 1
src/framework.ts:3094
When an ephemeral stream's `cancel()` keeps throwing, this catch lets `stopOwned()` continue to reject the run. But `runEphemeralToCompletion()` calls `agent.cancelStream()` again in `finally`, before removing the run and agent. That second throw skips their removal, the queued-message flush, and turn cleanup. Later stream events still pass `driveStream()`'s owner check instead of being dropped. Let the run finish its cleanup even when cancellation throws, and add a test with a running ephemeral stream.

---

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

Summary

stop() now catches individual cleanup failures, continues teardown, and reports the collected errors afterward. Discord awareness drains stop sending new marks and are awaited after their connections close.

  • Stopping the framework now finishes cleanup after a step throws.
  • Discord awareness drains stop sending and finish before shutdown returns.

Reviews (1) · Last reviewed commit: "fix(stop): the internal review's round: ..." · Reviewed by Greptile

Comment thread src/framework.ts
if ('stream' in agent.state && agent.state.stream) {
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
agent.cancelStream();
if (!step(() => agent.cancelStream())) uncancelled.add(agent.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stopped workers stay registered

When an ephemeral stream's cancel() keeps throwing, this catch lets stopOwned() continue to reject the run. But runEphemeralToCompletion() calls agent.cancelStream() again in finally, before removing the run and agent. That second throw skips their removal, the queued-message flush, and turn cleanup. Later stream events still pass driveStream()'s owner check instead of being dropped. Let the run finish its cleanup even when cancellation throws, and add a test with a running ephemeral stream.

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

Comment:
**Stopped workers stay registered**

When an ephemeral stream's `cancel()` keeps throwing, this catch lets `stopOwned()` continue to reject the run. But `runEphemeralToCompletion()` calls `agent.cancelStream()` again in `finally`, before removing the run and agent. That second throw skips their removal, the queued-message flush, and turn cleanup. Later stream events still pass `driveStream()`'s owner check instead of being dropped. Let the run finish its cleanup even when cancellation throws, and add a test with a running ephemeral stream.

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed by a probe at d507de4b. With an ephemeral run's own stream's cancel() always throwing, stop() failed with the cancel's error and its sweep rejected the run, and the run's finally then threw on its second cancel. The run rejected with the cancel's error rather than stop()'s refusal, the run and its agent stayed registered after the stream ended, and the stream's later text reached the agent's context. On main at 0d19f7c8, stop() failed at the first cancel and never reached the sweep, so the run stayed pending until its stream ended, and then left.

Fixed in a849d840: the run's finally catches and logs the cancel's throw and runs the rest of its cleanup, so the run leaves, settles as it ended, and its stream's later events are dropped. stop-step-throws adds a test with a running ephemeral stream whose cancel always throws.

— Duncan-2410

… at its end

From Greptile's review of anima-research#517.

When an ephemeral run's own stream has a cancel() that throws, stop() now
reaches its sweep and rejects the run, and the run's finally cancels the
stream again. That cancel was unguarded, so its throw skipped the rest of
the run's cleanup: the run and its agent stayed registered for good, the
run rejected with the cancel's error rather than stop()'s refusal, and the
stream's later text still reached the agent's context. The finally now
catches the cancel's throw, logs it, and runs the rest, so the run leaves,
its agent is deregistered and the stream's later events are dropped, and
the run settles as it ended. A stop() that found the stream has already
reported the failure from its own loop, and left the stream for a later
stop() to wait for.

Test: stop-step-throws gains an ephemeral run whose own stream's cancel
always throws. stop() with no budget fails with the cancel's error, the
run ends with stop()'s refusal, the log names the failed cancel, the run
and its agent leave, the stream's late text reaches no context, and once
the stream has ended a stop() closes the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Duncan-2410 and others added 3 commits October 18, 2026 09:32
main has moved past 0.22.3's cut. One conflict, in stopOwned()'s last
flush of deferred writes: anima-research#204 added the unread pass of the releases a
quiesce postponed (releasesAfterResume) just before the flush, where this
branch runs the flush through step(). Resolved by keeping both, in main's
order: this branch's comment on stillStreaming, anima-research#204's unread pass as main
has it, then the flush through step(). The unread pass runs outside step()
here; the next commit runs it through step(), as this branch's rule asks
of every step.

The rest merged on its own. In runEphemeralToCompletion's finally, anima-research#509's
settling of a pending tool-result batch sits before the cancel this branch
guards there.

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

anima-research#204 added a step to stop()'s finishing steps: the unread pass of each
release a quiesce postponed, which passes each failed reply stored unread
in that window to the primary. Merged in as main has it, it ran outside
step(), so a pass that threw ended stopOwned() there, skipping the other
releases' passes, the last flush of deferred writes, the final sync and
the store's close. Each release's pass is now a step of its own: one that
throws fails the stop(), and the others, the flush, the sync and the close
still run, as for the other finishing steps.

Test: stop-step-throws gains a row for it, with two postponed releases
whose first pass throws. stop() fails with that error, the second
release's pass still runs, the postponed releases are cleared, and the
store closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nge's own says what changes

0.22.3 is cut, and its folded fragments leave main when the release
merges back, so this change no longer edits the two that counted stop()'s
waits (stop-budget-bounds-every-wait, 389-failed-create-releases). Its own
fragment says what changes from 0.22.3: up to six budgets where 0.22.3
took up to five, and a failed create()'s third cleanup wait, on drains,
with the same 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 9e3e407 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main took anima-research#518 (a reference from a server with no address says so),
anima-research#220 (agent allowedTools enforced at dispatch), anima-research#521 (a framework cancel
ends one way) and anima-research#517 (every stop() step runs; stop() ends awareness
drains) since d9bd6dc.

Conflicts, all five in src/framework.ts and all in code this branch
replaced, take this branch's side: startDiscordAwarenessDelivery's doc
(removed here), the outbox drain's doc, its start and its per-operation
loop (this branch's drain claims from the journal, one dispatch at a
time), and drainForGeneration's doc (removed here).

Where anima-research#517 meets this branch outside the hunks:
- stopOwned: git kept both waits on awareness drains, anima-research#517's bounded
  waitForShutdown before teardownSucceeded and this branch's unbounded
  allSettled after it, so a wait that ran out would have been followed by
  an endless one on the same drain. anima-research#517's stays, and this branch's goes:
  a drain here claims nothing once stop() has begun, and closing its
  server rejects the request it has on the wire before awaiting the
  transport (server-connection.ts close()), so the wait settles once the
  servers have closed, and the budget holds when it doesn't. A wait that
  runs out fails stop() with ShutdownTimeoutError naming
  `awareness drain:<server>` and keeps an owned store open, as anima-research#517 set.
  Its comment now speaks of this branch's journal, and of where a drain's
  failure is reported here (a background delivery's log and alert, a
  sync's caller).
- runDiscordAwarenessDrain: a pass that stops because stop() has begun
  logs anima-research#517's line, "N mark(s) not sent: the framework is stopping; the
  next start delivers them", counting the dispatches still due that this
  pass hasn't sent. It checks stop() before the route, so a pass whose
  call the close ended says it too.
- FAILED_CREATE_TEARDOWN_TIMEOUT_MS's and create()'s docs: the third wait
  is on awareness drains still running, such as the start's delivery,
  since no barrier starts one here. deliverDiscordAwarenessInBackground's
  doc says stop() waits for running drains once it has closed their
  servers, within its budget.
- test/stop-ends-awareness-drains.test.ts (anima-research#517's) is ported to the
  journal: marks are queued in the store before create(), the start's
  delivery sends them to surgery-marks-mcpl-server.mjs (anima-research#517's barrier
  fixture is gone here), and journal writes are timed at recordDispatching
  and recordOutcome. Five of its six cases stay. The failed create() fails
  on a tools refresh once its delivery has a call in flight, since a
  drain's failure doesn't fail a start here. "A drain started once stop()
  has begun sends and records nothing" is cut: no path starts one here,
  since deliverDiscordAwarenessInBackground and syncDiscordAwarenessMarkers
  both refuse first, which stop-with-host-operations tests.

Changelog, by Ada-1017's rule for unfolded fragments (room-203 #145258):
the PR that merges second makes the pending fragments it touches true
against the merged code.
- anima-research#517's stop-reaches-every-cleanup.fixed.md: the marks a drain hasn't
  sent "stay queued in the awareness journal, unrecorded, for the next
  start", where it said "pending in the outbox … for the next start's
  barrier".
- surgery-marks-delivery-at-stop.changed.md is cut: main makes that change
  now, and anima-research#517's entry says 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 18, 2026
…nima-research#521 and anima-research#517 (trains V and W); anima-research#517's ledger assertion takes W4a's text

Brings in main's first-parent merges since 8a32054: anima-research#522, anima-research#204, anima-research#523 (on
d9bd6dc), anima-research#518, anima-research#220, anima-research#521 and anima-research#517. Git merged every file on its own.

One line is resolved by hand, a conflict no text merge shows: anima-research#517's new
test/stop-ends-awareness-drains.test.ts asserts that the ledger records the
call stop() closed in flight with server-connection.ts's old text,
/closed while awaiting response/. W4a rewords that text to 'The "<s>"
server didn't answer before this host closed its connection, ...', and
train X broke on it (Basil-1007, room-203 #145317). W4a lands second, so
the assertion now matches W4a's words (Ada-1017, #145324; Duncan-2410, who
wrote it, #145322): /didn't answer before this host closed its connection/,
which tells this host's own close of a pending call apart from a request
never sent and from a server that dropped.
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
main moved 62 commits since 8a32054, among them trains V and W: #518,
#220, #521, #517, and the route-failure work (#204) that passes a gone
speaker's failed reply to the primary.

Resolutions:
- src/framework.ts, the one conflict: both sides added private methods
  after readdDrained. Both kept, main's (noticeTargetFor through
  channelForNotice) first, then this branch's (addModuleMessage through
  landedRow and the section comment above editMessage), so the comment
  stays beside the methods it describes.
- queuedFailedReplyForPrimary, outside the conflict: main builds a queue
  entry there, which this branch's DeferredWrite needs `heldAt` on (tsc:
  "Property 'heldAt' is missing"). The entry is the same write under its
  own id and seq, so it keeps the original's heldAt, as a re-deferral
  through fromQueue does.
- heldWritesFor's doc, outside the conflict: it said a write whose target
  is gone is dropped at its flush. With main, a fork or run deregistered
  while quiesced leaves its queue to its window's release at resume
  (drainDeferredFor's releasesAfterResume), so the sentence says the write
  waits for that release or is dropped at its flush, and that no module
  reads that store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 18, 2026
…eld writes for modules and the modern MCP client meet the registry

What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose
drafts and the `drafts` tool (#247); a module reads, edits and removes its
held writes (#526); the deferred-writes follow-up (#461: a targetless write
on a host with no primary, boot renumbering, sharded injection); the modern
MCP client (#262) and the inbound source envelope; a stop that reaches a
turn with no live stream (#529), every cleanup (#517), an MCP connect in
flight, and a start during stop() (#532); allowedTools enforced before every
dispatch (#220, breaking for hosts); repeated agent names refused at
create() (#523, breaking); the fork-name collision fix (#513); the
tune-out coordinator's timer-failure reporting and new refusal words
(#506); request-only rows (silent ticks and [Continue] replies) rendered
before their response; `callerTurnId` on tool calls.

Conflicts (36 hunks, 7 files), and what was decided:

- src/framework.ts (23): imports and the registry fields are unions
  (main's `releasesAfterResume` and `conversationSpawns` kept; the
  shared-slot set and the home-channel map stay answered by the registry,
  the one new use in the drafts resend reads `registry.homeChannel`).
  `isAddressedMessage` stays tag-only; main's call sites pass tags alone.
  Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as
  main types it) plus the branch's `addressedElsewhere`. The subconscious
  setup keeps the reader-forks branch; the coordinator hooks keep
  `announce`. `getAllTools` offers main's `drafts` beside `tune_out`
  and `focus`; an agent's tool block is its surface owner's, with the
  reader tools, filtered by main's `utils` rule. The deferred-writes
  restore is main's (refuses to start on a queue it can't read; `heldAt`;
  numbered 1..N) with the branch's re-wake for a restored tune-out release.
  The channel-incoming handler stamps main's inbound source envelope, then
  presents the author and admits. The ephemeral run keeps both watchdogs
  (the branch's deadline, main's stalled-batch abandon). Dispatch sets
  `callerTurnId` then applies the branch's deny-at-dispatch.
  `endEphemeralRunRefused` is gone: main's #532 refuses the start itself.
  `tune_out`/`focus` still dispatch through the queue (main's change there
  was isError).
- src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except
  types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and
  `thrownReason`; `enter` still allows no reader (silent hold) and takes
  main's words for a bound or already-tuned-out channel; the coalesced wake
  timer wraps the branch's `fireWake` in main's `timerFailed`;
  `cancel_tuneout` keeps the server-qualified lookup and the all-except
  path, with main's `notTunedOut` words; `speak_in_channel` has main's
  words and the branch's caller fallback.
- src/agent.ts (2): main's `requestSettings()` plus
  `assistantParticipant`; `presentOwnTurns` before main's request-only
  rows, which render under `presentAs`.
- src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its
  transport confirmation; the locus is admission's. `onRouteFailure` is
  main's type (with `outcome`).
- src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions.

Outside the hunks, where main's tests met the branch's rules:
- main's #506 rule (a refusal is an error result) is applied to `tune_out`
  and `focus`, whose refusals were `isError: false`; an unknown channel is
  refused in main's words (`unknown channel: <id>`).
- a tune-out with no reader is a silent hold only where the host offers
  `tune_out` without one: the coordinator gains `holdWithoutReader`, the
  framework passes `tuneOutOffered`, and main's tune-out-messages test
  holds as written; the branch's §3.3 row opts its host in.
- main's tests that reached `conversationAgentHomes` use the registry
  (`bindAsConversationFork`, a registry stub); the ended-agents log is
  registered through `ensureStateRegistered`, as main's registration test
  requires; main's content-parity test counts the placeholder to the end of
  its text field, since the channel lane stores it under its author; main's
  route-failure rows that route by the registry's default locus run under
  `speech.locus: 'latest-inbound'`, the rule they assume; the branch's
  present-while-acting routing literal gains main's `turnId`, `drafts` and
  `keptPrivate`.
- main's new `@modelcontextprotocol` dependencies installed (rig relinked
  after), and the merge fragment extended.

Composed rig (the branch chronicle at 03b7bc1 and context-manager at
bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests,
4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher
rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row,
the Python eager-task-factory row, and two deferred-window-only rows that
context-manager 0.15 makes moot. Typecheck clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
Six conflicts, all in src/framework.ts:

- stopOwned: anima-research#517 runs each step of the teardown through step(), so one
  that throws can't skip the ones after it. cancelProviderAdmission is
  main's step, and the hold's drop runs through step() too, one step per
  hold, as stopOwned's comment asks of a step added there.
- sweepExpiredConversations: the reaper's comment is anima-research#204's, with the
  hold's sentence after it.
- startAgentStream's catch: anima-research#521's event-gate window end and the hold's
  settle both run, the window end first.
- beginAgentTurn's injections: anima-research#284 removed the silent-heartbeat
  injection, and the hold's note stays.
- beginAgentTurn's hand-off to driveStream: the hold's settle and anima-research#529's
  window close both run.
- beginAgentTurn's catch: anima-research#529's check for a stop the failed start kept
  comes before the budget hold, as it does before the provider hold, the
  stated wait and the error policy. A stop abortInference() reported as
  taken then ends the turn, rather than going into the hold and running
  when the hold ends.

Beyond the conflicts, anima-research#529's endStoppedStart ends a start that a stop
reached before it sent anything, at its checks after the hooks, after the
compile, at a failure and after a wait to retry. A hold's record of a
released turn ended there now goes too (settleBudgetHold), as at the
start's other ends.

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
Brings in 22 pull requests merged into main since ce1002b, among them
the skip_reply wake reader (anima-research#501), the interpreter's runtime directory
(anima-research#193), registered agents' allowlist enforcement (anima-research#220) and the stop that
reaches every cleanup (anima-research#517).

Conflicts:
- src/framework.ts:
  - The wake reader replaced requestedSelfWakeSeconds. This branch's
    changesRouting and ROUTING_TOOLS stay.
  - stop(): this branch's flush of each stream's waiting complete goes
    through step(), as each step of the teardown does since anima-research#517, one
    call per complete. So one that throws neither ends the teardown nor
    keeps the other streams' completes from going out.
  - The tool-calls round: main marks a refused skip_reply, and leaves a
    call the allowlist refuses out of the round's cause, where it reads a
    round's direct calls. Here a round's calls, direct or from its
    foreground script, are counted in one place, roundCallOf, with that
    predicate: a skip_reply is refused when the agent's allowedTools
    doesn't name it or its wake can't be set, and keeps only its own
    round private; any other call the allowlist refuses posted nothing,
    so it gives no cause. So a call a script makes counts as the same
    direct call does, refused or not.
- src/code-execution/py-runner.ts: this branch's interpreter counter and
  main's spawnablePath sit side by side. The interpreter's number is
  still set where it's spawned.
- test/skip-reply-wake-xml-mode.test.ts: both sides' helper changes, and
  the gated case passes { gate: true }.

Texts:
- skipReplyRefusal's doc names a script's calls too.
- The allowlist fragment no longer leaves out a skip_reply a script makes.
- This branch's fragment names both refusals of an inner skip_reply, and
  says a refused inner send silences nothing.

Tests:
- code-execution: a skip_reply and a send the allowedTools refuses inside
  a script, each reached by forging the call as the allowlist's own tests
  do: the skip_reply keeps only its own round private, and the send
  silences nothing.
- stop-step-throws: a stream's waiting complete that throws.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant