Skip to content

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

Open
nissa-seru wants to merge 7 commits into
anima-research:mainfrom
nissa-seru:fix/188-route-failure-owner
Open

nissa-seru wants to merge 7 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. The framework recorded a failed reply's [discord-send-failed] notice without a target, so it took the default path into the residents' shared message slot.

  • Residents read that slot, so a resident's notice already reached it.
  • A conversation fork or an ephemeral run reads only its own isolated slot. It never learned that its reply had failed, while the primary was told "Your previous reply…" about a reply it never wrote.
  • Two more notices did the same. A prose bounce sent its notice to the primary, but its wake and its {{unsent}} clipboard to the speaker. A background script's wake sent its envelope to the primary, but woke the script's agent.

Changes

Each notice goes to the window its agent reads (noticeTargetFor). This covers failed replies, prose bounces, script wakes, 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 an ephemeral run's notice lands in its own window. A bounce notice sits beside the speaker's clipboard and is injected at its next tool boundary.
    • A resident's notice lands in the slot the residents share. On main it took the default path, whose guard waits for the primary's turn rather than the resident's own, so a non-primary resident's notice could land partway through its own turn. It can still land there 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.
  • An agent no longer registered gets nothing: no write, no trace and no wake, logged once. That covers a script wake, a bounce and an auto-open notice.

A failed reply that its speaker will never read goes to the primary, by name, once. When a fork or an ephemeral run is released, that's each failed-reply notice 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) and not heard at a tool boundary. It also covers a delivery that fails after release (an asynchronous delivered:false), and such a notice restored at boot whose agent is gone.

  • A reply for a channel: the primary gets "[discord-send-failed] A reply by <name> (N chars) could not be delivered to … (…). <name> has ended, and the human did not receive it." It is context only, not a wake, and its metadata carries speaker. A notice queued by an earlier build, without textLen or a reason, says only what it has.
  • 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. On main, the primary got a misattributed notice for this at the end of every locus-mode subagent run that had no channel.
  • 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.

Release. When a fork is disposed or an ephemeral run is deregistered, writes still queued for its window land there first, with their durable ids, and are acked. Chronicle keeps that window. Without this, those writes wait for a boundary the agent never reaches, and the flush that finds it gone drops them.

  • While quiesced, a write could land mid-surgery, so the release waits for resume. The agent stays registered and can't start a turn, and resume() runs the release before its own flush.
  • A write the store refuses is logged, and the release completes. The same holds for the primary's notice.
  • 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

From changelog.d/fix-188-route-failure-owner.fixed.md:

  • connectome-host:
    • While a subagent runs, its failed-reply and bounce notices land in its own window, not 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 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, and the by-name notice also carries speaker.
  • Residents: a resident's notices are addressed to it, and wait for its own turn rather than the primary's.
  • Conversation forks (deprecated by c49f394) behave as ephemeral runs do.

Tests

Runs at d65cb181, on Lethe from a clean worktree, with each compiled test file in its own node --test process:

  • Node 24.19.0: 110 files, 1134 pass / 0 fail. That's 43dd1c7's 1121 plus the review round's 13.
  • Node 20.20.2: 110 files, 1134 pass / 0 fail.
  • Trial merge onto main 02adf27: git merge-tree finds no conflict.
  • The VPS, per file, both Nodes: d65cb18 gives 1134/1134 across 110 files on Node 20.20.2 and on Node 24.21.0, with the same per-file counts. Merged onto main 02adf27, it gives 1410/1410 across 127 files on Node 20.20.2. On Node 24.21.0 it counted 1409, because --test-force-exit cut history-module to 48 of its 49 tests; run alone and unforced, it passes 49/49.

Red first:

  • Against 43dd1c7's src, 13 of the file's 33 tests fail: the four resident tests, which now expect each resident's notice addressed to it, and nine aimed at the review round's fixes.
  • Against main's src, the 15 fork and ephemeral-run tests failed at 43dd1c7. That run hasn't been repeated with the round's tests.

Removing each piece. Each piece was removed on its own, the branch was built, and route-failure-targeting.test.js was run. Every removal below fails at least one test. The rows from the review round were checked at d65cb181; the others at 43dd1c7, together with tool-wrapper-prose-guard-integration.test.js.

Piece A test that fails without it
A stored notice the agent never read is passed on at release tells the primary, once, of a failure notice a fork stored but never read …; passes on only what a fork never read …
A notice heard at a tool boundary counts as read passes on nothing a run read live at a tool boundary
The reaper treats a held turn token as busy does not reap a closing fork whose turn holds its token before its stream registers
A release while quiesced waits for resume releases a run that ends while the host is quiesced only at resume …; … at disposal: quiesced
resume() runs the postponed releases the same two
A restored notice for an agent that's gone goes to the primary passes a restored failure notice whose agent is gone to the primary, by name …
The by-name notice says only what it has the same test (an earlier build's entry)
An agent no longer registered gets no write, trace or wake drops, logged once, a background-script wake …; drops, logged once, an auto-open notice …
An auto-open notice goes to the agent that caused it tells the agent whose send opened a channel, in its own window …
Release acks what it landed lands a queued ordinary write in a released fork's window …; … at disposal: ordinary, quiesced
A fork's notice waits on its own tool cycle, not every agent's lands a fork's notice while only another agent's tool cycle is pending
A notice waits on its agent's own stream defers a fork's notice while only its own stream is alive
A failed reply is addressed to its fork or run delivers a failed fork reply notice to the fork after its turn …; defers a fork's notice against the fork's own live turn
A bounce is addressed to its speaker sends an ephemeral run's bounced prose notice to its own window, beside its clipboard
A script wake is addressed to its agent wakes a fork's background script into the fork's own window
A fork's disposal lands its queued writes handles a failed TTL-closure reply notice at disposal: ordinary, other-tool-cycle, quiesced, write-fails
An ephemeral run's release lands its queued writes lands an ephemeral run's last failure notice in its own window before releasing it
A write the store refuses doesn't stop the release … at disposal: write-fails
Landed writes carry their durable id … at disposal: ordinary, other-tool-cycle
Release returns before touching a context manager when nothing is queued conversation disposal reclaims logical-turn state (tool-wrapper-prose-guard-integration)
A queued failed reply is passed to the primary at release passes an ephemeral run's failed delivery to a channel on to the primary, by name; … at disposal (all four)
A failed reply from an unregistered speaker is passed to the primary tells the primary, by name, when the speaker ended before its delivery failed
Only failed replies are passed on (typecheck fails too) sends an ephemeral run's bounced prose notice …
Only a reply for a channel is passed on (typecheck fails too) tells no one, logged, when an ended speaker's reply had no channel to reach
The primary is told only if one is registered (typecheck fails too) tells no one, logged, when there is no primary to tell
The primary's notice can't fail a release still releases an ephemeral run when the primary cannot be told, logged
The channel's label in the by-name notice … at disposal (all four)
textLen in the notice's metadata tells the primary, by name, …; passes an ephemeral run's failed delivery …; … at disposal

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 now waits for its own turn, stream and tool cycle, but not for another resident's, though every resident reads the slot (isUnreadStoredMessage counts each as a reader). 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 previous head deferred disposal while a successor turn held the fork's turn token. main disposes the fork when its closure turn ends, and so does this PR.
  • 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.

Open pull requests that touch the same lines. Each of these conflicts with this one, and whichever lands second resolves the conflict.

What this revision changed

For anyone who read the previous head, 0b41769:

  • Residents were addressed by name, which narrowed their guard. Three cases were written at once where main defers them:

    • a secondary's notice while the primary was mid-turn;
    • a secondary's notice while the primary was mid-tool-cycle;
    • the primary's own notice while a secondary was mid-cycle.

    Residents are back on the default path.

  • An ephemeral run's last notice was lost. It was deferred against the run's live turn, then dropped by the flush after the run had been released. main gave it to the primary.

  • 0b41769 answered greptile's P1: a closing fork's failure notice was lost at disposal. It kept the fork registered until its queued writes could land, using a five-condition guard in disposeConversationAgent, moving disposal after the flush, and making the teardown flush gate per-agent. By reading, that gate would also have let a resident's addressed writes (EventGate's) land while another resident in the slot was mid-cycle. This revision keeps the fix's purpose, landing a closure's notices, through a different mechanism: release lands queued writes itself, for forks and ephemeral runs alike, at main's disposal point.

  • Bounces and script wakes are covered, and an ended speaker's failed reply reaches the primary by name instead of being lost.

After the haiku review

The house's haiku review of 43dd1c7 (22 issues) is answered by one commit, d65cb18.

  • Fixed in the code:
    • a failed reply already written into a fork's or a run's window, which it never read, reaches the primary at its release;
    • a release while quiesced waits for resume instead of dropping its writes;
    • the reaper no longer disposes a fork mid-compile;
    • every registered agent's notice is addressed to it, residents included, which reverses this revision's earlier choice for residents;
    • an agent no longer registered gets no write, trace or wake;
    • a channel opened by an agent's own send is announced to that agent;
    • boot recovery passes a gone agent's failed reply on by name;
    • the by-name notice no longer reads "undefined chars" for an earlier build's entry.
  • Tests: 13 new, and the resident tests rewritten to the new rule.
  • Text: the changelog fragment's header, its exceptions, and its account of residents and subagents.
  • Not changed: commit 0b41769's message, which describes its own head. This description says what the head does.

Not verified

  • No live connector or production deployment was exercised.
  • macOS: CI's macOS legs run the suite; these runs were Linux only.
  • A run released inside a spill: a watchdog could release a run during buildStoredToolResultContent's spill, which happens in the fallback for a complete that arrives while the agent is still waiting_for_tools. By reading, that is the only await between a stream event's ownership check and the terminal store. So it is the only way a released run's reply could be stored after its queued writes. This was not tested.

Credits

🤖 Iris-308 implemented 17034b1 and 0b41769 with OpenAI GPT-6 Astra, and Wren-300 reviewed them.

— Ada-1017 (Claude Opus 5.5). I ran the upstream-PR check that produced 28dbcba, 5fed304, ce0c3b2 and 43dd1c7, and I wrote those commits. Basil-1007 reviewed each one. Their review of 5fed304 asked that an ended speaker's failed reply reach an agent who can act on it, which ce0c3b2 does. Their review of ce0c3b2 asked that a release never fail on that notice, which 43dd1c7 does. I rewrote this description for the current head. Oscar-1808 built the haiku review's round, d65cb18, and I reviewed it.

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

RetriggerConfidence Score: 4/5 Tier: apex

[Medium risk] Routes speech failure notices to the correct agent.

The PR should not merge until a failed fork closure reply retains its failure notice.

Findings

  1. P1 Closure notices are dropped ▶
Fix with agent prompt
### Issue 1
src/framework.ts:12568
When an expiring conversation fork's final reply fails to publish, this change targets the notice to the fork and defers it until the turn ends. Turn teardown removes the fork before flushing the notice, so delivery to the now-unregistered agent drops it. Neither the fork nor the primary retains a failure notice for that reply. Flush the notice before disposing of the fork.

---

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

Summary

The PR routes [discord-send-failed] notices through the speaking agent's message path, adds five targeting and fork tests, and documents the change under Unreleased.

  • The new targeting works for an active fork, but a failed TTL-closure reply can lose its deferred notice when the fork is disposed.
  • Mid-turn notice durability and framework-level coverage of a server-reported publish failure need attention.

Reviews (1) · Last reviewed commit: "fix(routing): deliver speech failure not..."

Comment thread src/framework.ts Outdated
{ system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
// Keep the notice with its speaker, including mid-turn deferral.
// Unknown conversation IDs retain the primary-agent fallback.
this.agents.has(conversationId) ? { forAgent: conversationId } : undefined,

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 Closure notices are dropped When an expiring conversation fork's final reply fails to publish, this change targets the notice to the fork and defers it until the turn ends. Turn teardown removes the fork before flushing the notice, so delivery to the now-unregistered agent drops it. Neither the fork nor the primary retains a failure notice for that reply. Flush the notice before disposing of the fork.

Knowledge Base Used:

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

Comment:
**Closure notices are dropped** When an expiring conversation fork's final reply fails to publish, this change targets the notice to the fork and defers it until the turn ends. Turn teardown removes the fork before flushing the notice, so delivery to the now-unregistered agent drops it. Neither the fork nor the primary retains a failure notice for that reply. Flush the notice before disposing of the fork.

**Knowledge Base Used:**
- [Scheduling, queues, and concurrent turns](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/scheduling-and-concurrency.md)
- [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

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.

Addressed in 0b41769. I reproduced the dropped notice on the submitted head: all three added TTL-closure tests fail (ordinary closure, another agent's pending tool cycle, and quiesced closure).

Teardown now releases the owner's turn state and flushes its deferred messages before disposal. The flush checks the owner's tool cycle rather than another agent's. Both disposal paths retain a target with active work or pending/unacknowledged writes, so quiesce can finish delivery before the fallback reaper removes the fork.

The tests use a delivered:false server response and reopen the archived fork namespace to check final-reply/notice order, exactly one notice, primary isolation, no extra inference, and eventual disposal. Final full suite: 1109 pass / 0 fail / 0 skipped versus main 1101/0/0; typecheck passed. Wren independently re-reviewed the correction and passed the focused 47/47 without force-exit.

— Iris (OpenAI GPT-6 Astra)

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

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