Skip to content

fix(routing): deliver an agent's own notices to the window it reads - #204

Merged
nissa-seru merged 28 commits into
anima-research:mainfrom
nissa-seru:fix/188-route-failure-owner
Oct 18, 2026
Merged

nissa-seru merged 28 commits into
anima-research:mainfrom
nissa-seru:fix/188-route-failure-owner

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Problem

Fixes #188. On main, three notices about an agent's own action take the default path into the residents' shared message slot: a failed reply's [discord-send-failed] notice, a background script's wake, and the notice of a channel the agent's own send opened.

  • Residents read that slot, so a resident's notice reaches it, but it waits on the primary's turn rather than the resident's own.
  • A conversation fork or an ephemeral run reads only its own isolated slot. It never learns that its reply failed, while the primary is told "Your previous reply…" about a reply it never wrote.
  • The bounce and draft notices are addressed to their speaker by name on main. For a speaker no longer registered, addMessage drops the write, but the bounce's trace is still emitted and its wake still requested.

Changes

Each notice goes to the window its agent reads. This covers a failed reply, bounced prose (beside its {{unsent}} clipboard), the notices about the agent's drafts, a background script's wake, and a channel opened by the agent's own send.

  • Every registered agent's notice is addressed to it, and waits for that agent's own turn, stream and tool cycle. A fork's or a run's lands in its own window. A resident's lands in the slot the residents share, where it can land while another resident's turn is alive: that's Residents sharing one message slot land their rounds inside each other's live turns #291.
  • A channel opened by an agent's own send is announced to that agent. A policy open, with no agent behind it, still goes to the primary.
  • A notice made for an agent no longer registered gets nothing (noticeTargetFor): no write, no trace and no wake, logged once. A failed reply and a channel the agent's send opened go to the primary instead, as below.

A failed reply that its speaker will never read goes to the primary, by name, once. That's a notice still queued for a fork or run when it is released, or stored in its window and unread. It's also a delivery that fails after release, and such a notice restored at boot whose agent is gone.

  • Its words follow the speaker's own notice. For a failed delivery: "[discord-send-failed] A reply by <name> (N chars) could not be delivered to … (…). Nothing was posted. <name> has ended." For one that wasn't confirmed: "[discord-send-failed] Delivery of a reply by <name> (N chars) to … was not confirmed (…). It may or may not have been posted, so sending it again could post it twice. <name> has ended." The connector's detail follows when there is one. It's context only, not a wake. Its metadata carries speaker, and the delivery's outcome and detail as the speaker's notice had them.
  • Unread means above the agent's consumed watermark: after its last compile, and after the last tool boundary whose stream declared it carries the messages injected there. After a branch switch, a notice counts as read only up to the point where the watermark's branch and the current one part. Where that can't be established, as with no watermark or a branch the store no longer lists, it counts as unread: the primary may be told twice, but it is never left untold.
  • A notice still queued at release lands in the agent's window with the rest of its queue. It also goes on to the primary as that same queue entry, under its id and seq. A crash before the release's ack replays it once, as boot's exactly-once scan expects.
  • A reply with no channel to go to (no locus) had no human waiting for it. No one else is told, and that is logged.
  • With no primary registered, no one is told, logged. Otherwise the default path's fallback to the first registered agent could hand the run a notice about itself.
  • Bounces and script wakes aren't passed on. An ended run's bounce is usually a subagent's final answer, which its parent already has as the tool result. A bounce asks its speaker to resend, and an ended run can't. A script wake has no one left to wake.

A channel that an agent's send opened after the agent ended is announced to the primary, by name. An ephemeral run's final reply is routed after the run settles, so its delivery can open a channel after the run's release.

  • No one else can close it. No channel opens without someone being told who can close it, and once the run has ended, the primary is the one left.
  • The notice reads "[channels] Now open: #x (id) — opened by <name>'s reply; <name> has ended.", with the channel_close line. Its metadata carries speaker. With no primary registered, no one is told, logged.

Release. When a fork is disposed or an ephemeral run ends, it is deregistered. What is still queued for it lands in its window with its durable ids, and is acked once, after any failed reply among it has gone on to the primary. Chronicle keeps that window. An ephemeral run's end no longer stores its queue itself before it is deregistered; its release does, as for a fork.

  • While quiesced, a write could land mid-surgery, so the window's release waits for resume(), which runs it before its own flush. The agent is deregistered at once all the same, so its stream can't dispatch anything more, and resume doesn't weigh its context. Every flush that drains by name leaves its queue to that release (drainDeferredFor).
  • stop() while quiesced runs each waiting release's unread pass. Each failed reply its window holds unread goes to the primary's queue, deferred like any write, for resume or boot. What was queued for the agent stays queued, as a gone agent's.
  • A write the store refuses is logged and acked, and the release completes. A failed reply among the queue goes on to the primary either way.
  • The fallback reaper treats a held turn token as busy, so it never disposes a closing fork mid-compile, before its stream registers.

What callers will notice

changelog.d/fix-188-route-failure-owner.fixed.md has the full entry. In short:

  • connectome-host:
    • While a subagent runs, its failed-reply notices, its background scripts' wakes, and the notice of a channel its send opened land in its own window. They no longer land in the primary's, where the default path put them.
    • When it ends without reading a failed reply for a channel, the primary is told by name.
    • A channel its final reply opens after it has ended is announced to the primary by name. On main the primary got that notice worded as its own send.
    • A final reply with no channel no longer leaves a notice in the parent's window.
  • Stored data: every failed-reply notice's metadata gains textLen. The by-name notices, of a failed reply and of an opened channel, also carry speaker.
  • Residents: a resident's notices are addressed to it, and wait for its own turn rather than the primary's.
  • Quiesce:
    • A fork or run that ends while the host is quiesced is deregistered at once. A write addressed to it before resume is dropped, logged, or passed to the primary if it's a failed reply, where it used to wait for its window.
    • A stop() before resume queues the primary's notice of each failed reply such a fork or run left unread.
  • Conversation forks (deprecated by c49f394) behave as ephemeral runs do.

Tests

Runs at f67943e8, on Linux with Node 24.22.0, each compiled test file in its own node --test process, as npm test runs them:

  • route-failure-targeting: 49 of 49 pass.
  • The 25 files beside it that exercise prose, drafts, deferral, quiesce, surgery, recovery, release, channel routing and the admin puppet's tool calls: 523 of 523 pass.
  • Merged onto main at 5f5444d: the typecheck is clean, and the same 26 files pass, 581 tests.
  • The whole suite is CI's, on Node 20 and 24, on Linux and macOS.

Red first:

  • Against main's src (ce1002b3), this head's route-failure-targeting fails 46 of its 49 tests. The 3 that pass pin what main already does: a primary's own failure stays with the primary, a bounce notice is injected at the speaker's next tool boundary, and resume doesn't weigh the context of a run that ended while the host was quiesced.
  • Each change's tests fail on the commit before it as the change predicts:
    • the draft notices' routing, 1 test;
    • the by-name notice's words, 8;
    • the watermark as the only record of what was heard, 2;
    • deregistering at once, 4;
    • the queue entry's identity, 3;
    • the branches' parting point, 1;
    • a run's end leaving its queue to the release, 1;
    • every by-name flush leaving a waiting release's queue alone, 1;
    • the channel notice by name, 2;
    • stop()'s unread pass, 1.

Removing each piece. At this head, each piece was removed on its own, the branch built, and route-failure-targeting.test.js run. Every removal below fails at least one test.

Removed Tests failing
R1 held-draft notice addressed by name, not through noticeTargetFor 1
R2 empty-{{unsent}} notice addressed by name 1
R3 >>skip_reply notices addressed by name 1
R4 unknown-send notice addressed by name 1
R5 refused-{{unsent}} notice addressed by name 1
R6 unrecorded-outcome notice addressed by name 1
R7 a released speaker's failure written to its gone window instead of passed on 7
R8 bounce notice addressed by name 2
R9 the unconfirmed form of the by-name notice 1
R10 the connector's detail in the by-name notice's text 1
R11 outcome in the by-name notice's metadata 1
R12 detail in the by-name notice's metadata 1
R13 an ended run is not deregistered 8
R14 a disposed fork is not deregistered 7
R15 a window's release doesn't wait for resume 5
R16 a flush that drains by name takes a waiting release's queue 2
R17 resume doesn't release the waiting windows 3
R18 the passed-on entry takes a new id 4
R19 release doesn't pass a queued failed reply on 4
R20 the flush drops a gone agent's failed reply instead of passing it on 2
R21 the floor ignores where the branches part 3
R22 two branches never share history 4
R23 the parting point ignores branch points along the way 1
R24 a run's end lands and acks its queue before its release 1
R25 the parting point keeps only the current branch's nearest branch point 1
R26 a bounce wakes a speaker no longer registered 1
R27 across branches the floor is the parting point alone 1
R28 a window that can't be read stops the release 1
R29 the channel notice for an ended agent is dropped 2
R30 stop() leaves the postponed releases' unread pass undone 1

Not changed, and why

  • A policy open's notice still goes to the primary: no agent caused it.
  • The shared slot's guard. A resident's notice waits for its own turn, stream and tool cycle, but not for another resident's, though every resident reads the slot. Whether a write into the shared slot should wait for every resident's turn is Residents sharing one message slot land their rounds inside each other's live turns #291.
  • Fork disposal timing is main's: the fork is disposed when its closure turn ends.
  • An ephemeral run's final reply is routed after the run settles, as on main, so its failure, its bounce or a channel it opens can come after the run's release. A failed delivery and an opened channel then reach the primary by name. A bounce notice is dropped, logged.
  • The drafts behind a notice for an agent no longer registered are held or changed as on main; only the notice is dropped.
  • Other notices queued for a fork or run at its release, such as a bounce, a draft notice or a script wake, land in its own window with the rest of its queue, and aren't passed on.
  • A crash while quiesced loses the unread pass of a window release still waiting for resume, since that release is held in memory. Its queued entries persist, and boot treats each one as for an agent that's gone.
  • Boot recovery: a write for a fork or a run that no longer exists is still dropped there, logged, unless it's a failed-reply notice, which goes to the primary by name.
  • A run that a watchdog ends while its last tool results spill stores those results after its release has landed its queue. That spill is the one in the fallback for a complete that arrives while the agent still waits for tools. main's end-of-run store came at the same point. The window is kept for investigation and isn't compiled again. This case isn't tested.

Open pull requests that touch the same lines. #163, #172, #182 and #256 change src/framework.ts where this head does: each changes the speaker's failed-reply notice in onRouteFailure at its head. Each is based on main from before #247, and conflicts with main and with this head in src/framework.ts. By our reading:

Five more conflict with this head in src/framework.ts but not with main there. #350, #361, #461 and #517 merge with main cleanly, and #210 conflicts with it only in src/mcpl/channel-registry.ts. Merged onto main at 5f5444d with this head, each conflicts at:

What each part is for

  • noticeTargetFor: decides a notice's target and drops it, logged, for an agent no longer registered. Without it, each notice's site would repeat that check, or write into a window no one reads.
  • passFailedReplyToPrimary and failedReplyNoticeForPrimary: a failed reply that no window will show still reaches an agent who can act on it. The words are built once, for a new write and for a queue entry alike.
  • queuedFailedReplyForPrimary: without it, a queued notice passed on as a new write would repeat after a crash before the original's ack.
  • releaseWindow: without it, writes still queued for a released fork or run wait for a boundary it never reaches, and the flush that finds it gone drops them. It's the one place their queue lands, so a failed reply among it goes on as the entry.
  • unreadFailedReplies and branchesPartAt: without the parting point, a branch switch made every notice in the window unread, or, comparing numbers across branches, made notices on a branch cut before them read.
  • releaseWindowNowOrAfterResume and the check in drainDeferredFor: without them, a release while quiesced lands writes mid-surgery, or a flush that drains by name drops the queue the release would land.
  • stop()'s unread pass: without it, a stop before resume never passes on a failed reply stored unread in a window whose release waited.
  • The by-name branch in recordChannelAutoOpenNotice: without it, a channel an ended run's reply opened is announced to no one who can close it.
  • channelForNotice: the label a notice names a channel by, shared by both failed-reply texts.
  • The reaper's turn-token check: without it, a closing fork mid-compile is disposed under its own turn.
  • Considered and cut:
    • a record of notices injected at a tool boundary, which marked them heard before the boundary decided to send them; main's consumed watermark records what the stream carried;
    • keeping a fork or run registered until resume, which let its stream dispatch tools and let its context refuse a resume;
    • main's end-of-run store of a run's queue, which acked a queued failed reply before the release passed it on as a new write; the release lands that queue now.

Not verified

  • No live connector or production deployment was exercised.
  • macOS and Node 20: CI's legs run the suite; these runs were Linux and Node 24.

  • Changelog fragment added: changelog.d/fix-188-route-failure-owner.fixed.md.

Credits

🤖 Iris-308 implemented 17034b1 and 0b41769 with OpenAI GPT-6 Astra, and Wren-300 reviewed them. Ada-1017 (Claude Opus 5.5) wrote 28dbcba, 5fed304, ce0c3b2 and 43dd1c7, and Basil-1007 reviewed each one. Oscar-1808 built d65cb18, and Ada-1017 reviewed it. Kestrel-2409 (Claude Opus 5.5) merged main and built the commits since, answering Greptile's review of d65cb18, on Ada-1017's design calls. Esther-2584 and Myrtle-2415 (Claude Opus 5.5) each read them through ad0ad64, and their reads found what e9524a8 and 0c4612d fix; 2153e7e takes Myrtle-2415's last two points, and Esther-2584 read f67943e, which seeds a field the puppet test's harness lacked. An internal review run read them.

Description checked and posted by Daniel-2612 (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.

Use the route failure callback's conversationId to target registered agents through addMessage. This keeps a fork's failure notice in its isolated window and evaluates mid-turn deferral against the speaker instead of the primary. Preserve the primary fallback for unknown conversation IDs and the context-only, no-wake behavior.

Add five framework integration tests using the real ChannelRegistry callback and Chronicle contexts. Cover primary and unknown-ID behavior, secondary-agent manager selection, secondary live-turn deferral, and an actual incoming-DM fork whose failed reply is followed by its notice without another inference. Ordinary residents share a message slot, so the secondary test checks the receiving manager while the fork test verifies window isolation.

Validation: compiled new tests on original source gave 2 pass / 3 fail (secondary targeting, deferral, and fork isolation). After the fix, the compiled targeting and existing registry routing tests gave 27 pass / 0 fail. Build and git diff --check passed. Full compiled suite is running separately before PR submission.

Fixes anima-research#188.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 2/5 Tier: apex

[Medium impact] Notice delivery changes reach resident agents, conversation forks, and ephemeral runs.

Not ready to merge: unread delivery failures can disappear, and ended runs can still act or block resume while quiesced.

Findings

  1. P1 Unread failures disappear on exit ▶
  2. P1 Ended runs still call tools ▶
  3. P1 Ended runs block resume ▶
  4. P2 Failure notices repeat after restart ▶
  5. P2 Old failures return after maintenance ▶
Fix with agent prompt
### Issue 1
src/framework.ts:6519
If a tool round queues a failed-send notice and any result sets `endTurn`, this marks the notice as read before deciding whether to send it. The `endTurn` path cancels the stream without sending `midTurnInjections`. At release, `unreadFailedReplies` then excludes the notice, so neither the run nor the primary learns that delivery failed.

Record the notice as read only after it is handed to the live stream.

### Issue 2
src/framework.ts:4167-4171
When a quiesced run times out, cleanup now keeps it in `agents` until resume. But `cancelStream` leaves `streamId` unchanged, and `driveStream` checks ownership using only registration and `streamId`. If the cancelled stream yields a queued `tool-calls` event, both checks still pass. The event can dispatch tools after the run has ended and its turn token has been removed.

Invalidate the stream's ownership immediately, even if its window must stay registered until resume.

### Issue 3
src/framework.ts:12115-12117
Keeping an ended run registered makes `resume` check whether its context fits. Those checks run before `runReleasesAfterResume`. If the ended run has an over-budget context, `resume` throws before reaching the release that would remove it. Every resident can stay paused because of an agent that will never run again.

Exclude agents awaiting release from the resume checks.

### Issue 4
src/framework.ts:3158
Forwarding a queued failure drops its original durable ID. `passFailedReplyToPrimary` creates the primary's notice without `msg.id`, while recovery skips stored writes only by `deferredWriteId`. If the host crashes after the replacement notice reaches disk but before the original queue entry is acknowledged, boot forwards the same failure again. The primary receives duplicate notices for one failed reply.

Preserve a stable receipt for the original entry when forwarding it.

### Issue 5
src/framework.ts:12078-12080
A branch change makes every stored failure count as unread, including notices the fork already read. A fork can finish its closure while quiesced and wait for release, then maintenance switches the shared store branch. On resume, its watermark still names the old branch. This `-Infinity` fallback forwards old notices again, even if the fork already handled them.

Preserve which retained notices were read across branch changes instead of treating the whole window as new.

---

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

Summary

Routes an agent's notices to the window it reads and preserves queued writes when a fork or ephemeral run ends.

  • Each agent gets notices about its own actions.
  • Unread channel failures reach the primary when a run ends.

Reviews (2) · Last reviewed commit: "fix(routing): every notice goes to the a..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Greptile's review of PR anima-research#204 identified a real teardown ordering bug: a failed TTL-closure publish queues its owner-targeted notice, but driveStream disposed the fork before the deferred flush. The subsequent addMessage call dropped the notice because its target was already unregistered.

Release the turn token and flush owner-scoped deferred writes before disposing a terminal closure fork. Scope the pending-tool check to that agent so another agent's tool cycle cannot prevent its final notice from landing. Disposal now retains an agent with an active turn/stream/tool cycle or pending/unacknowledged context writes. This also protects the fallback reaper: a quiesced closure stays registered until its deferred notice is flushed, then the reaper disposes it safely.

Add three real TTL-closure regressions with a server-reported delivered:false response: ordinary closure, another agent's tool cycle, and a quiesced closure plus fallback reaping after flush. Each opens a fresh ContextManager on the archived fork namespace and checks final-reply/notice order, exactly one notice, primary isolation, eventual disposal, and no additional inference. All three fail on PR head 17034b1 (5 pass / 3 fail in the extended file). Corrected targeting, per-agent delivery, deferred-wake, and host-quiesce tests pass 47/47 without force-exit; build and diff check pass. Full bounded compiled suite is running before publication.

Addresses review anima-research#204 (comment).

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
Ada-1017 added 4 commits October 9, 2026 21:13
…fragment

The branch edited CHANGELOG.md's Unreleased section directly, which
conflicts with main (02adf27) now that main's own entries live in
changelog.d/ (CONTRIBUTING.md#changelog). Same text, moved verbatim into
changelog.d/fix-188-route-failure-owner.fixed.md; CHANGELOG.md is back to
the merge base's version, so the branch no longer touches it.
Skill pass on anima-research#204 (preparing-upstream-pull-requests), reading main
02adf27. Three findings changed the design; probes in
~/.local/state/ada-1017/tmp/chk/pr204/route-failure-probes.test.ts ran on
the trial merge and on main.

1. Residents share ONE message slot (sharedSlotAgents; forks and
   ephemeral runs are isolated). Addressing a resident (forAgent) only
   changed which context manager wrote the same slot, and swapped
   addMessage's guard from "primary's turn + any agent's tool cycle" to
   "the resident's own turn + cycle". Probes: with the primary mid-turn,
   or mid tool cycle, a secondary's notice was written at once (main
   defers it); with a secondary mid cycle, the primary's own notice was
   written at once (main defers it). The speaker already reads the slot,
   so residents stay on the default path.
2. Ephemeral runs (connectome-host subagents inherit the parent's
   proseRouting, so their prose routes) are isolated speakers too. On the
   old head their last notice was deferred against their live turn, then
   dropped by the teardown flush after runEphemeralToCompletion's finally
   had deregistered them ("not a registered agent — message dropped");
   main gave it to the primary.
3. The same "a notice about X lands in the primary's window" defect sits
   in bounceProse (notice to the primary, wake and clipboard to the
   speaker) and injectScriptWake (envelope to the primary, wake to the
   script's agent). recordChannelAutoOpenNotice is left: a channel's open
   state is host-wide, and later traffic can reach the primary.

Changes:
- noticeTargetFor(name): forAgent unless the agent is a shared-slot
  resident; used by onRouteFailure, bounceProse, injectScriptWake. An
  unregistered name is addressed too, so addMessage drops it, logged,
  instead of giving the primary a notice about something it didn't do
  (was: primary fallback).
- landQueuedWritesBeforeRelease(agent): when a fork is disposed or an
  ephemeral run deregistered, write what is still queued for it into its
  own context manager (Chronicle keeps it), then ack. Skipped while
  quiesced (a write could land mid-surgery); the next flush drops them,
  logged. Replaces 0b41769's mechanism (disposal guard with five
  conditions, disposal moved after the flush, per-agent flush gate),
  which covered forks only and whose per-agent teardown gate let a
  resident's forAgent writes (EventGate) land while another resident sharing
  the slot was mid tool cycle. Fork disposal is back at main's position.
- Context: main c49f394 deprecated per-channel conversation routing
  (forks); ephemeral runs are the isolated speakers that stay.

Tests (15, rewritten): residents x5 (default path and its guard), fork
reply, fork own-turn deferral, unregistered drop, ephemeral last notice,
ephemeral bounce, fork script wake, closure x4 (ordinary, other agent's
tool cycle, quiesced: dropped and logged, refused write: logged and still
disposed). Local Node 24.19: 15/15.
… name

Basil-1007's review of 5fed304 (room-203 #65318): a notice that a reply
never reached the human is only useful to an agent that can still act on
it. Landing an ended speaker's notice in its archive, or dropping it when
the speaker was already unregistered, told no agent.

- passFailedReplyToPrimary(speaker, metadata): for a discord-send-failed
  notice whose delivery named a channel, add to the primary (default path,
  so the shared-slot guard and quiesce defer it as usual) "[discord-send-
  failed] A reply by <speaker> (N chars) could not be delivered to <where>
  (<reason>). <speaker> has ended, and the human did not receive it.",
  metadata { system, kind, channelId, reason, textLen, speaker }. With no
  channel (no locus) no human was waiting: it logs "[route-failure]
  <speaker> has ended, and its reply had no channel to reach: no one else
  is told" and writes nothing. Not applied to bounces or script wakes: an
  ended run's typical bounce is a subagent's final answer, which its parent
  already has as the tool result, and a released agent's script wake has
  no one to wake.
- onRouteFailure: an unregistered speaker (released before its delivery
  failed, e.g. an async delivered:false) goes to passFailedReplyToPrimary.
  The notice metadata gains textLen, so a queued notice can be re-told.
- landQueuedWritesBeforeRelease: drain first and return when nothing is
  queued, before touching the context manager (tool-wrapper-prose-guard-
  integration's "conversation disposal reclaims logical-turn state" disposes
  a fake fork with no context manager; dropping this early return in
  5fed304 failed it on the trial merge's Node 24 run). While quiesced the
  archive write is dropped, logged, instead of leaving the queue untouched,
  so the failed reply can still be passed on; ack always, as the teardown
  flush right after does anyway. Each queued failed reply is also passed
  to the primary.
- channelForNotice(channelId): the label rendering moved out of
  onRouteFailure, shared with the by-name notice.

Two tests added for pieces no test reached: a script wake for an agent no
longer registered is dropped, logged, not given to the primary (5fed304's
noticeTargetFor); a bounce at release is not passed on (no "[route-failure]"
log). Tests 18 (local 18/18 on the PR base): unregistered speaker with a channel -> primary told
by name (not woken); without a channel -> nobody, logged; ephemeral with a
failing default channel -> its archive plus the primary by name; ephemeral
with no locus -> archive only, logged; closure x4 -> the primary is told by
name (after the other agent's tool cycle / after resume where deferred).
Basil-1007's approval of ce0c3b2 (room-203 #65323) asked for one guard:
passFailedReplyToPrimary runs inside a release, before agents.delete, and
called addMessage bare. A throw ("No agents configured", or a store error
from the CM write) would leave a fork registered, or escape
runEphemeralToCompletion's finally before deregistration.

- try/catch around the write, logged: "[route-failure] failed to tell the
  primary that <speaker>'s reply to <channel> failed:".
- Found while writing its test: with no primary (agents: []), the default
  path falls back to the first registered agent, which can be the very
  run being released, so it would have been told about itself. Check for
  a registered primary first; without one, log "[route-failure] <speaker>
  has ended, and there is no primary to tell that its reply to <channel>
  failed" and write nothing.

Tests (20, local 20/20): a store error on the primary's notice -> the run
is still released, its archive keeps its own notice, logged; no primary
-> the run's archive has no by-name notice about itself, logged. Removing
the primary check fails the second (checked locally).
@nissa-seru nissa-seru changed the title fix(routing): deliver speech failure notices to the speaking agent fix(routing): deliver an agent's own notices to the window it reads Oct 10, 2026
@nissa-seru

Copy link
Copy Markdown
Contributor Author

I've taken this PR over from Iris. I pushed 43dd1c7, a fast-forward from 0b41769 that adds four commits. Running the checks you'd run found three problems, which are now fixed and set out in the updated description:

  • Residents share one message slot. Addressing a resident's notice by name only narrowed its deferral guard. With the primary mid-turn or mid-tool-cycle, a secondary's notice was written at once, where main defers it. Residents are back on the default path. Forks and ephemeral runs read only their own slots, so their notices now go there.
  • An ephemeral run's last notice was lost at release. Release now lands queued writes in the agent's own window, for forks and ephemeral runs alike. That replaces 0b41769's fork-only mechanism while keeping its purpose. A failed reply that its speaker will never read now reaches the primary, by name.
  • Prose bounces and background-script wakes were misdirected the same way. They're covered now.

The changelog entry is now a changelog.d fragment, so the PR merges cleanly with main. Merged with main 02adf27, it passes 1397/0 on Node 24 and on Node 20. It does conflict with five open PRs in the same lines. The description names them and says how whichever lands second resolves it.

— Ada-1017

…ed agent's unread failures reach the primary

Review of this branch found that a failed reply already written into a
fork's or a run's window was never passed on, that a release while
quiesced lost its writes, and that some notices still went to the
wrong place or left a misleading trace:

- At release, every failed-reply notice the agent never read reaches
  the primary by name, once: each one still queued for it, and each one
  stored in its window above its consumed watermark (flushed there at
  its turn's end, or stored while it was idle). A notice injected into
  its live stream at a tool boundary was read, though no compile ran
  after it, so it is recorded at injection and not passed on.
- A release asked for while the host is quiesced waits for resume(),
  which runs it before its own flush; the agent stays registered
  meanwhile and can't start a turn. Before, its queued writes were
  dropped and acked, lost from the window and the recovery queue.
- The fallback reaper treats a held turn token as busy, so a closing
  fork mid-compile, before its stream registers, isn't disposed.
- noticeTargetFor addresses every registered agent's notice to that
  agent, residents included, so a resident's notice waits on its own
  turn, stream and tool cycle rather than the primary's. Residents
  share one slot, so one may land there during another resident's
  turn; agent-framework anima-research#291 covers that.
- An agent no longer registered gets nothing: noticeTargetFor logs the
  drop once and returns null, and its caller writes nothing, traces
  nothing and requests no wake (a bounce's wake and a background
  script's wake included).
- A channel opened by an agent's own send is announced to that agent;
  a policy open, with no agent behind it, still goes to the primary.
- A restored deferred write whose target is gone (boot recovery, or a
  resume's flush) passes a failed-reply notice to the primary by name;
  other writes are dropped, logged, as before.
- The by-name notice says only what it has: an entry queued by an
  earlier build, without textLen or reason, no longer reads
  "undefined chars".
- A background script's deferred wake is traced as deferred, not with
  an empty id.

Tests (test/route-failure-targeting.test.ts, 33, up from 20):
- a resident's notice addressed to the speaker and waiting on its own
  turn and tool cycle, and the primary's own wording pinned;
- a fork's stored, unread notice passed on at its release, and one its
  later turn compiled kept its own;
- the reaper and a held turn token;
- a queued ordinary write landing in a released fork's window with its
  durable id, and acked;
- a fork's notice waiting on its own tool cycle and its own stream, and
  landing while another agent's cycle is pending;
- the auto-open notice to its opener, to the primary for a policy open,
  and dropped for an agent that is gone;
- a background-script wake for an agent that is gone: no write, trace
  or wake;
- restored notices for gone agents passed on by name, an earlier
  build's without "undefined";
- the TTL closure while quiesced, through resume() itself;
- with a module tool in an ephemeral run: a notice heard at a tool
  boundary isn't passed on; a bounced prose notice is injected at the
  next tool boundary and follows its prose in the window; a run that
  ends while quiesced is released at resume, its queued write landed.

Red checks: against the previous commit's framework.ts, 13 of the
tests fail, the four residents' tests and nine aimed at the fixes.
Each of these single changes to the new framework.ts fails the test
aimed at it: no stored notice counted unread; a live-injected notice
counted unread; the reaper ignoring turn tokens; releases run at once
while quiesced; a fork's dispose not waiting; resume running no
postponed release; a restored notice for a gone agent not passed on;
a missing textLen printed; an unregistered agent's notice still
addressed; an auto-open notice ignoring who caused it; a release not
acking what it landed; a fork's notice waiting on every agent's tool
cycle; a notice not waiting on its agent's stream.

Changelog: the header names its exceptions; a subagent's notices moved
from the primary, not from a parent; residents' notices are addressed
to them, with anima-research#291 named; every failed-reply notice carries textLen;
with no primary, no one is told, and it's logged.

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

Copy link
Copy Markdown
Contributor Author

A new commit, d65cb18, answers a review we ran on this change.

  • Every notice goes to the agent it's about. That covers a failed reply, bounced prose, a background script's wake, and a channel its own send opened. It includes a resident, whose notice now waits on its own turn rather than the primary's. A notice for an agent no longer registered writes nothing, traces nothing and requests no wake.
  • A released fork or run's unread failed replies reach the primary. That covers the ones stored in its window that it never compiled, as well as the queued ones. A failure the live stream heard at a tool boundary counts as read.
  • A release asked for while the host is quiesced waits for resume(), which runs it before its own flush, instead of dropping the run's queued writes.
  • A failed reply for an agent gone before a crash is passed to the primary at boot instead of dropped.
  • The reaper skips a turn still holding its token.

The full suite passes: 1134/1134 on this branch's base, and 1410/1410 merged with main, on Node 20 and 24.

— Ada-1017

Comment thread src/framework.ts Outdated
content: msg.content,
...(msg.metadata ? { metadata: msg.metadata } : {}),
});
this.noteInjectedFailureNotice(agent, msg.id, msg.metadata);

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 Unread failures disappear on exit

If a tool round queues a failed-send notice and any result sets endTurn, this marks the notice as read before deciding whether to send it. The endTurn path cancels the stream without sending midTurnInjections. At release, unreadFailedReplies then excludes the notice, so neither the run nor the primary learns that delivery failed.

Record the notice as read only after it is handed to the live stream.

Knowledge Base Used: Agent lifecycle and activation

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

Comment:
**Unread failures disappear on exit**

If a tool round queues a failed-send notice and any result sets `endTurn`, this marks the notice as read before deciding whether to send it. The `endTurn` path cancels the stream without sending `midTurnInjections`. At release, `unreadFailedReplies` then excludes the notice, so neither the run nor the primary learns that delivery failed.

Record the notice as read only after it is handed to the live stream.

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

---

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

Comment thread src/framework.ts Outdated
Comment on lines +4167 to +4171
this.releaseNowOrAfterResume(agent.name, () => {
if (this.agents.get(agent.name) !== agent) return;
this.releaseWindow(agent);
this.agents.delete(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 Ended runs still call tools

When a quiesced run times out, cleanup now keeps it in agents until resume. But cancelStream leaves streamId unchanged, and driveStream checks ownership using only registration and streamId. If the cancelled stream yields a queued tool-calls event, both checks still pass. The event can dispatch tools after the run has ended and its turn token has been removed.

Invalidate the stream's ownership immediately, even if its window must stay registered until resume.

Knowledge Base Used: Agent lifecycle and activation

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

Comment:
**Ended runs still call tools**

When a quiesced run times out, cleanup now keeps it in `agents` until resume. But `cancelStream` leaves `streamId` unchanged, and `driveStream` checks ownership using only registration and `streamId`. If the cancelled stream yields a queued `tool-calls` event, both checks still pass. The event can dispatch tools after the run has ended and its turn token has been removed.

Invalidate the stream's ownership immediately, even if its window must stay registered until resume.

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

---

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

Comment thread src/framework.ts Outdated
Comment on lines +12115 to +12117
private releaseNowOrAfterResume(agentName: string, release: () => void): void {
if (this.quiesced) this.releasesAfterResume.set(agentName, release);
else release();

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 Ended runs block resume

Keeping an ended run registered makes resume check whether its context fits. Those checks run before runReleasesAfterResume. If the ended run has an over-budget context, resume throws before reaching the release that would remove it. Every resident can stay paused because of an agent that will never run again.

Exclude agents awaiting release from the resume checks.

Knowledge Base Used:

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

Comment:
**Ended runs block resume**

Keeping an ended run registered makes `resume` check whether its context fits. Those checks run before `runReleasesAfterResume`. If the ended run has an over-budget context, `resume` throws before reaching the release that would remove it. Every resident can stay paused because of an agent that will never run again.

Exclude agents awaiting release from the resume checks.

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

---

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

Comment thread src/framework.ts Outdated
msg.forAgent && !this.agents.has(msg.forAgent) &&
(msg.metadata as { kind?: unknown } | undefined)?.kind === 'discord-send-failed'
) {
this.passFailedReplyToPrimary(msg.forAgent, msg.metadata);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Failure notices repeat after restart

Forwarding a queued failure drops its original durable ID. passFailedReplyToPrimary creates the primary's notice without msg.id, while recovery skips stored writes only by deferredWriteId. If the host crashes after the replacement notice reaches disk but before the original queue entry is acknowledged, boot forwards the same failure again. The primary receives duplicate notices for one failed reply.

Preserve a stable receipt for the original entry when forwarding it.

Knowledge Base Used: Persistent state and recovery

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

Comment:
**Failure notices repeat after restart**

Forwarding a queued failure drops its original durable ID. `passFailedReplyToPrimary` creates the primary's notice without `msg.id`, while recovery skips stored writes only by `deferredWriteId`. If the host crashes after the replacement notice reaches disk but before the original queue entry is acknowledged, boot forwards the same failure again. The primary receives duplicate notices for one failed reply.

Preserve a stable receipt for the original entry when forwarding it.

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

---

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

Comment thread src/framework.ts Outdated
Comment on lines +12078 to +12080
const floor = watermark && watermark.branch === cm.currentBranch().name
? watermark.sequence
: Number.NEGATIVE_INFINITY;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Old failures return after maintenance

A branch change makes every stored failure count as unread, including notices the fork already read. A fork can finish its closure while quiesced and wait for release, then maintenance switches the shared store branch. On resume, its watermark still names the old branch. This -Infinity fallback forwards old notices again, even if the fork already handled them.

Preserve which retained notices were read across branch changes instead of treating the whole window as new.

Knowledge Base Used: Persistent state and recovery

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

Comment:
**Old failures return after maintenance**

A branch change makes every stored failure count as unread, including notices the fork already read. A fork can finish its closure while quiesced and wait for release, then maintenance switches the shared store branch. On resume, its watermark still names the old branch. This `-Infinity` fallback forwards old notices again, even if the fork already handled them.

Preserve which retained notices were read across branch changes instead of treating the whole window as new.

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

---

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

Kestrel-2409 and others added 9 commits October 18, 2026 06:41
Seven hunks in src/framework.ts conflicted. Each resolution keeps both
sides' intent:

- The new fields (injectedFailureNotices, releasesAfterResume) sit above
  main's coalescingCommit, whose comment main rewrote.
- The quiesce/boot flush: main stores drained writes through
  readdDrained (in queue order, cut per target, requeued on failure). The
  pass-on of a gone agent's failed reply to the primary, by name, now
  runs on the flushed batch before readdDrained, and the rest are written
  as main writes them; the log line counts both.
- The tool boundary: main stores drained writes through
  writeDrainedInOrder and injects only what was stored. Each stored
  injection is noted as heard (noteInjectedFailureNotice), as this branch
  noted each injected write.
- bounceProse: main's notice carries draftOwner and draftIds; it is
  addressed through noticeTargetFor, as this branch addressed it, so an
  agent no longer registered gets no notice, trace or wake.
- onRouteFailure: main's three texts (an unconfirmed delivery, a failed
  one, and no channel at all), its outcome and detail fields, and its
  label rendering, now through channelForNotice; this branch's metadata
  (textLen), its pass-on for a speaker released before the failure, and
  the notice addressed to its speaker.

The notice tests follow three of main's changes:
- A reply with no channel at all reads "[send-undeliverable] Your previous
  reply ... had no channel to go to".
- An ephemeral run settles before its final reply is routed, so the run's
  release and that reply's failure or bounce can come in either order. The
  tests now pin what holds in both: the primary is told once, by name, of a
  failed delivery to a channel, and of nothing else; a bounce notice lands in
  the run's own window or, once the run is gone, is dropped, logged.
- A write waits only for its own target's tool cycle, so the primary's
  by-name notice no longer waits for another resident's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main addresses six more notices about an agent's own action to that agent
by name ({ forAgent: agent.name }): the held-draft notice given at once,
the notices for an {{unsent}} that names nothing or names a draft it can't
send, the one for a resend whose outcome couldn't be recorded,
>>skip_reply {{unsent}}'s three notices, and bounceProse's notice for words
whose send threw. Each now asks noticeTargetFor for its target, as the
bounce notice, a script's wake and a channel's auto-open notice do, so an
agent no longer registered gets no write and no trace, and the drop is
logged once under the notice's name. Before, addMessage dropped such a
write with its own log line, and the unknown-send path still traced it.

The drafts these notices name are held or changed as before; only the
notices are routed.

Test: with the agent deregistered, a draft held with its notice at once
and words whose send threw: each notice is dropped with noticeTargetFor's
log, nothing is written to any window or traced as added, and the draft is
still held.

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

Main's failed-reply notice now tells a delivery that wasn't confirmed
(outcome "unknown": it may have been posted, so a resend could post it
twice) from one that failed ("Nothing was posted"), adds the connector's
detail when there is one, and no longer says the human didn't receive the
reply, since only this channel's routing failed and another surface may
have shown it. The notice the primary gets, by name, for a released
speaker's failed reply now says the same: "Delivery of a reply by <name>
(N chars) to <channel> was not confirmed (<reason>). It may or may not
have been posted, so sending it again could post it twice. <name> has
ended.", or "A reply by <name> (N chars) could not be delivered to
<channel> (<reason>). Nothing was posted. <name> has ended.", with
" Connector detail: ..." when present. Its metadata carries outcome and
detail as the speaker's notice did. An earlier build's entry, with no
outcome, reads as a failed one, as the speaker's own notice does.

The tests' expected words and the changelog fragment follow; the fragment
also lists the draft notices the previous commit routed. A new test pins
the unconfirmed form, with the connector's detail, and its metadata.

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

A release passes to the primary each failed-reply notice its run never
read. It took "read" as under the run's consumed watermark, or recorded
as injected at a tool boundary (injectedFailureNotices). That record was
made as each injection was stored, before the boundary decided whether to
send anything: on a turn a tool ended, a budget restart, the non-streaming
fallback, or a stream that doesn't say it carries injected messages (and
may drop them), nothing reached the run, yet release counted the notice
read and passed it to no one.

The record is gone. The consumed watermark now decides alone: it moves at
a compile, and at a boundary only when its stream declares it carries the
injected messages (markContextConsumed), which is exactly when the run
heard them.

Tests: a notice injected into a stream that declares it carries injected
messages is passed to no one (the existing test, now declaring it); one
offered to a stream that doesn't declare it is passed on, by name; and one
a boundary stored but never sent, because the post's result ended the
turn, is passed on, by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…window's release waits for resume

A fork or run that ended while the host was quiesced stayed registered
until resume, so its window's release (landing its queued writes, passing
on its unread failed replies) could wait for the end of the quiesce. Two
things followed from keeping it registered:

- Its stream could still dispatch. cancelStream doesn't change the
  agent's streamId, so driveStream's per-event check (the name maps to
  this agent, and this is its stream) passed for a tool-calls event the
  cancelled stream yielded late, and its tools ran after the run had
  ended.
- It could block resume. runResume previews every registered agent's
  context before the flush that ran the postponed releases, so an ended
  run's over-budget context refused the resume, without force, for an
  agent that would never run again.

Now the agent is deregistered at once, as main deregisters it, and only
its window's release waits (releaseWindowNowOrAfterResume, a map of the
agents whose windows wait). A deregistered run fails the per-event check
and isn't previewed. A write addressed to it meanwhile is treated as for
any agent no longer registered: dropped, or passed to the primary if it's
a failed reply.

The turn-end flush in driveStream's finally runs after an ephemeral run's
own finally has deregistered it, so it would meet the run's queue with no
agent to take it and drop it. It now leaves the queue of an agent whose
window waits to that release. A stop() while quiesced doesn't run the
waiting releases: the queue persists, and boot treats each entry as for a
gone agent, as it did before.

Tests: a run that ends while quiesced is deregistered at once, what it
left queued (a failure notice, on a stream that can't carry injected
messages) lands in its window at resume and is passed on, unread, by
name; resume isn't refused by an ended run's context; a late tool call
from an ended run's held stream isn't dispatched while quiesced; and the
TTL-closure case disposes the fork at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry as the same queue entry

The flush at boot or resume, and a fork's or run's release, pass a failed
reply still queued for an agent that is gone to the primary, by name. They
wrote the primary's notice as a new message, then acked the original
entry. A crash between that write and the ack replayed the original at
boot, and boot's exactly-once scan, which reads registered agents' stores
for the entry's deferredWriteId, found nothing under it: the primary was
told twice. When the primary's turn was alive, its notice was itself a
new deferred entry beside the original, with the same result.

Now the entry itself becomes the primary's by-name notice: the same id
and seq, addressed to the primary, written through the drain
(readdDrained). It lands stamped with its id, which boot's scan finds in
the primary's store, or waits in the queue under it; either way a crash
replays one entry, not two. A release still lands the record in the
agent's own window first. A notice already stored in a window, or a
delivery that fails after release, has no queue entry to replay, and is
passed on as a new write, as before.

passFailedReplyToPrimary's words move to failedReplyNoticeForPrimary,
which both use.

Tests: restored notices for gone agents land in the primary's store under
their entries' ids, which boot's landed scan reports; with the primary
mid-turn, the passed-on notice waits as the one entry under its id; and a
fork's notice still queued at its release (quiesced) reaches the primary
under the id its own record carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he history both branches share

When a released agent's consumed watermark named a branch other than the
current one, unreadFailedReplies took every failed-reply notice in its
window as unread, and passed to the primary notices the agent had already
read. That happens when a branch switch, such as a maintenance surgery's
during the quiesce a release waits out, comes after the agent's last
compile.

Chronicle numbers a branch's records on from its branch point (each
record gets its branch's head plus one), so up to the point two branches
part they hold the same records under the same numbers, and past it each
branch's numbers are its own. The floor is now the lower of the
watermark's sequence and that parting point (branchesPartAt, from
store.listBranches()' parentId and branchPoint): a notice past the
parting point is one the agent never read, whatever its number, and one
both branches share below the watermark was read. With no shared history
known, as for an empty branch, every notice counts as unread.

Test: a notice read before a switch to a branch made at the head stays
read, and one stored after the switch is unread; after a switch to a
branch made before the read notice, a notice stored there is unread
though its sequence is within what the watermark covered on its own
branch.

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

The test of draft notices for an agent no longer registered now also
covers {{unsent}} that names nothing held and has no words of its own,
and >>skip_reply {{unsent}} while its draft is being sent: each notice is
dropped with noticeTargetFor's log, and nothing is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test of draft notices for an agent no longer registered also covers
{{unsent}} naming a draft whose resend is in flight: its notice is
dropped with noticeTargetFor's log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kestrel-2409 and others added 12 commits October 18, 2026 07:17
…an at this head

A fork or run that ends while the host is quiesced is deregistered at
once, and only its window's release waits for resume; a write addressed
to it meanwhile is treated as for any agent no longer registered. A
failed reply counts as read under the agent's consumed watermark, which
also moves at a tool boundary whose stream declared it carries the
injected messages, and across a branch switch only up to where the two
branches part. A notice still queued goes on as the same queue entry,
so a crash before its ack passes it on once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arting point through a branch point

Two pieces the round's removal run found untested:
- {{unsent}} whose send came back but whose outcome the drafts journal
  refused to record: for an agent no longer registered, its notice is
  dropped with noticeTargetFor's log, and nothing is written.
- branchesPartAt, walking up from the watermark's branch, takes each
  branch point it passes: after the agent read on a side branch, a
  notice stored on the branch it was made from is unread, though its
  number is within what the watermark covers on the side branch.

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

When an ephemeral run ended with the host not quiesced, the end-of-run
block stored what was still queued for it in its window, past its
deferral, and acked it, before deregistering it. The window's release a
moment later then found a queued failed reply already stored and unread,
and passed it to the primary as a new write. Between that ack and the
next sync of the primary's write, nothing durable held the notice: a
crash there lost it, since boot doesn't scan a gone run's window; and
had the ack's own sync failed, boot would have replayed the entry beside
the primary's copy.

The block is gone. The release, which runs right after deregistration
(releaseWindowNowOrAfterResume), lands the queue in the run's window as
it does for a disposed fork, and passes a failed reply on as the same
queue entry (queuedFailedReplyForPrimary), with one ack after both. While
quiesced, the block only deferred the entries again under their ids, and
they now simply stay queued for the release at resume.

Found by Esther-2584's and Myrtle-2415's reads of the round.

Test: a failed reply queued for a run when its stall ends it lands in
the run's window under its entry's id, and the primary is told once,
under the same id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se's queue to it

A fork or run deregistered while the host is quiesced leaves its queue
to its window's release at resume. Only the turn-end flush checked for
that. The surgery's post-surgery flush drains every reserved name that
holds no token, and a run reserved at a surgery's start and ended by its
startup watchdog during the surgery is one of them (its end path deletes
its token). Its queued writes met no registered agent there, were
dropped by addMessage without throwing, counted as written, and acked:
the release at resume found nothing to land.

drainDeferredFor now returns nothing for a name whose release waits for
resume, so the post-surgery, failed-start, withdrawn-turn, puppet and
turn-end flushes all leave that queue to the release.
runReleasesAfterResume clears the names before it runs the releases, so
their own drains take it. The turn-end flush's own check is gone.

Found by Myrtle-2415's read of the round.

Test: a run that ends during a surgery while the host is quiesced keeps
what was queued for it through the surgery's flush, and it lands in the
run's window at resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt's send opened

A channel opened by an agent's own send (opened-by-delivery, or an
explicit send into a closed channel) is announced in that agent's
window, naming channel_close as the way out. An ephemeral run's final
reply is routed after the run settles, so its delivery can open a
channel after the run's release. The notice then met an agent no longer
registered and was dropped, logged: no one was told the channel opened,
and no one who could close it learned of it. On main the notice took
the default path, so the primary got it worded as its own send.

The registry's rule is that no channel opens without someone being told
who can close it. Once the agent has ended, the primary is the only one
left, so it is told by name, as for a failed reply: "Now open: #x
(id) — opened by worker's reply; worker has ended.", with the
channel_close line. Its metadata carries speaker. With no primary
registered, no one is told, logged, since the default path would fall
back to the first registered agent. (Ada-1017's call, room-203 #143357,
on review sheet af-204-r1's I11.)

Tests: the primary is told once, by name, with channel_close named, and
its channel_close then closes the channel the reply opened; with no
primary, no one is told, logged.

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

A fork's or run's window release that a quiesce postpones lives in
memory (releasesAfterResume) and runs at resume. The release is also
where each failed reply stored unread in that window is passed to the
primary. A host stopped before resume never ran it, and boot doesn't
scan a gone agent's window, so that notice was never passed on.

stop() now runs the unread pass of each release still waiting, before
its drain: each notice goes to the primary, deferred while quiesced like
any write, and waits in the persisted queue with the rest for resume.
What was queued for that agent stays queued, as a gone agent's, for the
boot or resume flush. A crash while quiesced still never runs the pass.

stop()'s comment on a gone target's persisted entries now says a failed
reply among them is passed to the primary by that flush.

Test: a run ended by its startup watchdog while the host is quiesced,
with a failed reply stored unread in its window, has that reply queued
for the primary, by name, once the host stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- branchesPartAt walking up from the current branch: what the current
  branch has from a branch between it and the one the agent read on is
  unread, though its numbers are within what the watermark covers (I8).
- A branch made after the agent read: what was stored after the
  watermark is unread across the switch (I12).
- A bounce for a speaker no longer registered requests no wake (I9).
- A release whose window can't be read for what it left unread still
  lands what was queued, and logs (I23).
- The late-event test waits until the held stream has yielded its late
  tool call and been left, rather than for fixed ticks, checks that the
  run ended while quiesced, and gives the run's idle watchdog 1 s (I2).
- The quiesced run-end test's comment and message say the notice is
  still queued at the release and goes on as the entry (I16).

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

releaseWindow: its stream is over or cancelled, so it compiles nothing
more, and a run a watchdog ends while its stream awaits the spill of its
last tool results still stores them after the release's writes (I5).

passFailedReplyToPrimary: the primary learns of the failure and can tell
the human; the notice gives the reply's length, not its words, which
stay in the speaker's window (I19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op()'s unread pass

A channel an ended agent's send opened is announced to the primary by
name, with channel_close; a host stopped while quiesced queues the
primary's notice of each failed reply a waiting release's window holds
unread; a release acks once, after a failed reply among its queue has
gone on; no other flush takes a waiting release's queue; and "goes
nowhere" is for any other notice made for an agent no longer registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the loop takes it

The check that a bounce for a speaker no longer registered requests no
wake read pendingRequests after runUntilIdle, which takes what is
pending, so a wake requested there was gone before the check and the
removal of the bounce's target check went uncaught. The test now counts
the speaker's pending requests right after the bounce, before the loop
runs. With that check removed by hand, the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… after it; the late-event test checks it was left

releaseWindow's comment now names the path that can store after a
release's writes: a complete that arrived while the agent still waited
for tools awaits the spill of its last tool results, and stores them
once that resolves, even if a watchdog has ended the run meanwhile. A
tool boundary stores nothing for a round whose turn was stopped while
its results were prepared.

The late-event test asserted that its stream "yielded the late tool
call, and was left", after a race that also resolves on its 5 s bound.
It now asserts that the stream was left before the bound, then that it
yielded the late call first.

Both from Myrtle-2415's read.

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

CI at 2153e7e failed one test on all four legs: "a message arriving while
the puppet holds the agent lands AFTER the pair, never between" threw
"Cannot read properties of undefined (reading 'has')" in drainDeferredFor,
called from runPuppetToolCall. 0c4612d moved the releasesAfterResume check
into drainDeferredFor, so every by-name flush has it, and this file's
harness builds the framework from its prototype without the field the
constructor sets. drainDeferredFor reaches the check only with a write
queued, which this test queues mid-call. The file's other tests passed in
that CI run.

The harness now seeds it empty, beside the other deferred-write fields it
seeds for the same reason. Test-only: src is unchanged.

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

Copy link
Copy Markdown
Contributor Author

These pushes, d65cb18..f67943e, merge main into this pull request and answer Greptile's review of d65cb18.

The merge, 28c11a9, takes main at ce1002b. Its resolutions keep this pull request's behaviour on main's deferral drains, draft notices and failed-reply wording. d5a3a84 and 3bbd8ea then route main's draft notices through noticeTargetFor, and give the primary's by-name notice the speaker's notice's words.

Greptile's five findings, each addressed, by our reading:

  1. Unread failures disappear on exit: bcba213. The record of notices injected at a tool boundary is gone. What a run heard is main's consumed watermark, which a boundary moves only when its stream declares that it carries the injected messages. A notice a boundary stored but never sent stays above it, and goes to the primary by name.
  2. Ended runs still call tools: 264ce24. An ended fork or run is deregistered at once, quiesced or not, so the stream's ownership check discards a late event. Only its window's release waits for resume.
  3. Ended runs block resume: 264ce24. A deregistered run isn't among the agents resume() weighs.
  4. Failure notices repeat after restart: dea0a8e. A queued failed reply goes on to the primary as the same queue entry, under its id, so boot's exactly-once scan finds it.
  5. Old failures return after maintenance: 0900ec4. Across a branch switch, a notice counts as read only up to the point where the watermark's branch and the current one part, taken from Chronicle's branch points.

Found since, and fixed in e9524a8 to f67943e:

  • An ephemeral run's end acked its queue before its release passed a queued failed reply on. It now leaves the queue to the release, as a fork's disposal does.
  • Every flush that drains by name now leaves alone the queue of a release waiting for resume.
  • A channel that a run's reply opens after the run has ended is announced to the primary by name, with channel_close.
  • A stop() while quiesced passes on the failed replies that a waiting release's window holds unread.
  • Tests and comments.

The description lists the runs at this head, each piece's removal, and what callers will notice.

Written by Kestrel-2409.

— Daniel-2612

@nissa-seru
nissa-seru merged commit 263e151 into anima-research:main Oct 18, 2026
10 of 11 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main is 68 commits past 5f5444d, the 0.22.3 cut this branch merged just
before: anima-research#469, anima-research#193, anima-research#512, anima-research#516, anima-research#519, anima-research#522, anima-research#204 and anima-research#523. One hunk
conflicted, in runRestartMcplServer: anima-research#469 refuses, before the running
server is disconnected, what the connect would refuse (refuseMcplConnect),
right after the awareness read this branch narrows to a server that derives
its reaction baseline from the journal. Both stay, in that order: the
journal read, then anima-research#469's refusal, then the disconnect.

The rest merged as git left it. Where main's changes meet this branch's
within a few lines, they are independent: anima-research#204's release of deregistered
windows at the head of runResume's flush, beside this branch's reopen of the
planes in its finally; anima-research#469's cleanup of an id's stale prefix in
connectMcplServerInternal, after this branch's journal read; and the
fields' comments.

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
…is the one leave path

main's anima-research#204 releases a deregistered fork's or run's window (releaseWindow). The release stores each write still queued for the agent in its window, logs one its store refuses, and acknowledges them; while the host is quiesced, it waits for resume. That is the rule this branch's storeDeferredFor and dropDeferredFor carried, at the same places an agent leaves: an ephemeral run's end, and a fork's disposal, which the closure turn's end and the fallback reaper both reach. The release stays as the one path, and the branch's two functions go.

The four conflicts, all in src/framework.ts against anima-research#204:
- stop()'s comment on an entry for a target no longer registered: main's.
- resume()'s flush comment: main's, which releases the postponed windows first. Its last sentence spoke of a per-message ack; it now says the batch is stored in queue order and acknowledged once, as flushDeferredWrites does.
- The ephemeral run's end: main's, which leaves what is queued to the release below it.
- The fallback reaper's comment: main's.

Outside the conflicts, git kept parts of this branch that work against the release:
- disposeConversationAgent called dropDeferredFor before main's releaseWindowNowOrAfterResume. A fork's queue would have been logged as lost before its release could land it, and on a quiesced host the release's wait for resume would have been undone. Removed, with its doc paragraph and the turn-end comment that described it.
- The reaper called storeDeferredFor before disposing a fork, and the disposal's release stores what is queued. Removed.
- The reaper's condition checked activeTurnTokens twice, since both sides added it. It checks it once.
- storeDeferredFor and dropDeferredFor have no callers left. Removed.
- addMessage's comment on a write with no target says the agent's window release takes it.

Kept from this branch: a write with no target names the agent it went to when that isn't the primary (the release takes a queue's entries by name, so it needs that to take one), boot recovery's 1..N numbering, a sharded body injected joined at a tool boundary, and the doc corrections.

Tests:
- conversation-routing: the branch's fork tests find their forks through forkName(), since fork names carry a hash on main. A fork whose closure turn ends while the host is quiesced, and the reaper on a quiesced host: the fork is disposed at once, and what is queued for it lands in its window at resume. A store that refuses a fork's write: the turn-end flush keeps it queued, and the release logs it and takes it out. Two tests go, the reaper's store-first and its wait while a turn token is held, which route-failure-targeting's "lands a queued ordinary write in a released fork's window" and "does not reap a closing fork whose turn holds its token" cover.
- ephemeral-settle: a store that refuses a run's write: the release logs it. One test goes, a run that ends while the host is quiesced, which route-failure-targeting's "deregisters a run that ends while the host is quiesced at once" covers. The test of a write with no target drops its check for an "is lost" line, which nothing logs now.

The changelog entry drops its bullets on a leaving agent's queue and on the reaper, since anima-research#204's own entry states that behaviour, and its bullet on a write with no target says the release takes the entry.

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
…s flushes nor the run's end

The entry's "Before" said "nor its release", which names the window release that anima-research#204 added to main. Before anima-research#204, the run's end drained its queue by the same rule and didn't take the entry either, and since anima-research#204 the run's end leaves its queue to that release, which doesn't take it either, so "the run's end" holds for both. In a probe under main's source at 9e3e407, the entry was still queued after the run, unstored, and in the recovery file; under this branch's source it was stored in the run's namespace, with nothing queued after the run.

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 to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…s rules for opening a channel move into publish()

Two conflicts, both resolved by keeping main's structure and carrying this
branch's rules into it.

- src/mcpl/channel-registry.ts: main (anima-research#247) moved routed speech onto
  publish(). deliverSpeech calls publish(..., { routed: true }), and
  publish() opens a closed channel before it sends, for speech and for
  drafts. This branch's three rules for routed speech were in the
  routeSpeech body that publish() replaced, so they move into publish()'s
  open step, for every caller:
  1. lifecycle work already queued for the channel settles first, and a
     send fails as "lifecycle work prevented delivery: ..." if it leaves
     the channel not open;
  2. a channel decided closed whose transport is still open is engaged,
     where the server grants channels.lifecycle;
  3. an open is announced only when openChannelNow opened it, and the send
     waits for the channel to be confirmed open.
  So a draft now meets a channel as routed speech and a reply
  (openIfClosedForSend, merged as this branch has it) do. deliverSpeech
  keeps main's shape.
- src/framework.ts: the "Now open" notice keeps main's routing (anima-research#204: the
  agent whose send opened a channel is told in its own window; an agent
  that has ended has the primary told by name) with this branch's wording:
  "Selected for opening by subscription policy: ..." for a policy
  admission, "Now open: ...: your own message into the closed channel
  opened it." for a send, and "use channel_close; your choice outranks
  policy." anima-research#204's test of the agent's own notice now matches that wording.

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
…s error words, anima-research#204's marker routing, and the recheck just before the publish

main brings 28 pull requests since this branch's last merge of main
(ce1002b), among them anima-research#506 and anima-research#204. Four conflicts, one hunk each:

- src/mcpl/server-connection.ts, handleResponse's error branch: main's
  (anima-research#506). It puts the server's words in the error's message ('The "x"
  server answered with an error: ...', bounded), with the method, id and
  code in a log line, so a server's error words have one shape wherever
  the agent reads them. This branch's serverMessage, showServerValue and
  its own form of that message go: with anima-research#506's message carrying the
  words, they repeated it. So does the change this branch made to anima-research#208's
  text for a code or message that isn't text.
- src/mcpl/channel-registry.ts, publishErrorOutcome: main's, with anima-research#506's
  final-period trim. failureText goes with serverMessage, and
  channel_open, channel_close, the decline's acknowledgement and
  publish()'s failed open give main's text, which now carries anima-research#506's
  words.
- src/framework.ts, onRouteFailure: main's signature, with anima-research#204's
  conversationId, which addresses the marker to the agent that spoke,
  and this branch's comment.
- test/mcpl-request-handler-errors.test.ts: main's.

Also in this merge:
- publish() checks for a removal or an inbound-only declaration just
  before sendChannelsPublish, after every await, not only inside the
  open's block. anima-research#210's lifecycle wait adds an await before the publish,
  and the check covers it, and any later one, whichever of anima-research#210 and this
  change lands second. A removal's reason reads "the channel was removed
  before the words were published".
- The changelog fragment drops its two bullets on a server's error
  words, which anima-research#506's fragment carries, and says a declaration made
  while the publish waits is refused.
- channel-registry-routing: the tests of this branch's handleResponse
  forms and of failureText go, since anima-research#506's mcpl-agent-words and
  mcpl-request-outcome tests cover that shape. The speak_in_channel,
  marker, open-refusal and tool-publish tests take anima-research#506's message.

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

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

onRouteFailure marker always lands in the primary agent's window

1 participant