Skip to content

fix(deferral): pass a released agent's queued failed reply once, by its own id - #680

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/landed-failed-reply-once
Oct 21, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/landed-failed-reply-once

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

This PR makes a notice the primary takes in a released agent's place reach the primary once. When a conversation fork or an ephemeral run is released, a failed reply, or a channel notice it could act on, that is still queued for it now goes on only as that queue entry, under its own id, and not at all where the agent read the copy that landed in its window. Before, where the entry's earlier write had landed (#607's landing unknown), the primary could be told twice or not at all. !Fixes #623.

Problem

A release tells the primary of the agent's unread notices along two paths. A read of the window passes each unread copy on as a fresh write, under a new id. The queue's drain passes each queued entry on under its own id, and skips one it finds landed, on the assumption that the window's read has passed its copy. Boot's exactly-once check knows a notice only by the entry's id, and searches only registered agents' stores, so it never finds a gone fork's copy. One notice has two identities, and each path guesses what the other did.

#623's probe on main at 7e09923d counted the primary's notices of one failed reply, queued with its landing unknown and landed in a released run's window. Each count should be one, or none where the run had read the copy:

path notices
a release, then a crash between its ack's sync and its queue rewrite, then a boot 2
a release postponed by a quiesce, then stop(), then a boot 2
a release whose read of the window throws while the id lookup works 0
a release whose id lookup throws while the window reads, the copy unread / read 2 / 1
the entry settled as landed and not yet acked, a release, then a crash before any ack, then a boot 2

Since #598, a channel notice the agent could act on takes the same paths.

Changes

  • One identity. While an entry for the released agent is in the durable queue, pending or un-acked, the notice goes on only as that entry, under its own id and seq (passOn, through queuedNoticeForPrimary), so a crash before the ack replays it once. A copy of it in the window decides only whether the agent read it.
  • releasedWindow reads the window once, in place of unreadNoticesForPrimary: the unread notices that aren't copies of a queued entry, and, for each queued entry with a copy there, its copies and whether the agent read them. It keeps fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598's rule that a shard continuing a body isn't a notice of its own.
  • passOnFromReleasedWindow is the part the release and stop() share. It passes the unread notices on as stored (passNoticeToPrimary), passes the un-acked entries on, and returns passOn for the pending ones.
  • releaseWindow:
    • a pending entry found landed is passed on, where main skipped it;
    • where the store can't be searched, a copy the window's read saw counts as found, so the entry isn't stored a second time;
    • it returns early only when nothing of the agent's is pending or un-acked, so un-acked entries are passed on and acked.
  • stop()'s pass for a release the quiesce postponed runs passOnFromReleasedWindow before the drain, and each notice drained for that agent goes through passOn. Anything else drained for it goes to readdDrained, which drops it, as on main. A pass that throws leaves that agent's own notices, pending and un-acked, to readdDrained, which passes them on, read or not, as boot would.
  • readdDrained's doc now says what it does for a target that's gone: a notice queued for it goes on whether or not the agent read a copy, since nothing there reads that window.

Who it reaches

  • The primary is told once of each such notice, where it could be told twice or not at all. Where the agent's window can be read, it isn't told of a queued notice whose landed copy the agent read, at the release or at the stop() that ends it. Where the window can't be read, the notice goes on.
  • After a crash, boot passes a gone agent's queued notice on even if the agent had read its copy, since boot can't read that window. That's main's behaviour too, and this PR leaves it, as "What this leaves" says.
  • No tool's text, stored format or queue record changes.

What each part is for

  • The entry as the one identity is the one boot's exactly-once check can see. A copy passed as a fresh write has an id no later path knows.
  • held in releasedWindow tells passOn whether the agent read a queued notice's copy, so the primary isn't told of one the agent had heard. The same read serves the window's unread notices, so the window is read once.
  • Passing un-acked entries, and acking them, covers the path where a lookup had found the write landed (settleFound) and the release never looked at un-acked entries.
  • The window's sighting where the store can't be searched keeps the release from storing a copy a second time when the window shows it's there.
  • passOnFromReleasedWindow is the one place the rule lives, which the release and stop() both call.
  • In stop(), each pass runs before the drain, so the window and the un-acked entries are read before the drain hands the pending ones to the un-acked set. The passes are kept by agent so the drain can send each agent's notices through its own passOn, and an agent whose pass threw falls back to readdDrained for its pending and un-acked notices.
  • The tests each pin one of A failed reply queued with its landing unknown can reach the primary twice, or not at all, when its agent is released #623's rows, the case where the run read the copy, a stop() pass that throws, or a release taking only its own agent's un-acked entries, for a failed reply and for a channel notice. Each would come out the other way without the piece it names (see "Tests"). The stop() cases also count what stop() left queued for the primary before the restart (queuedForPrimary), since boot's check for landed writes would otherwise hide a stop() that queued a notice twice under one id. The window tests moved to releasedWindow, which they now call, and deferred-window-only's two tests of a released window's failed reply release a run rather than the primary, whose own window would answer for the read check under the copied landing mark. The waits for a run's registration are bounded in time, as the file's other waits are.
  • Left out: reading a gone agent's window at boot. What it would save is one notice the agent had already read, told to the primary after a crash.

Tests

On Linux with Node 24.22.0, on this head's tree (d7ad0225, which has main 4e3fea38 merged in). The full suite ran from a worktree nothing edited, and the other runs from one with only the checks' own files added.

  • The full suite: typecheck and build pass, and npm test gives 6,030 tests, 5,986 passing, 44 skipped, none failing. It took 169 s and 432 CPU-seconds (321 user, 111 system), at a load of 6 to 17 on 12 CPUs; I have no run of main at that load to set beside it. main's CI at 4e3fea38 (Ubuntu, Node 24) ran 6,006 tests, 5,961 passing, 45 skipped. The 24 more are this change's tests of a notice whose earlier write may have landed, 12 for each kind. The one skip fewer here is a difference between CI's runner and this host that I haven't traced.
  • The files this change touches, and those that release agents or read their windows, all pass: route-failure-targeting 101 of 101, stop-step-throws 20 of 20, deferred-window-only 65 of 65, conversation-router 18 of 18, tool-wrapper-prose-guard-integration 18 of 18, tool-result-guard 146 of 146, prose-drafts 87 of 87, ephemeral-no-global-fallback 11 of 11.
  • Against main's src/framework.ts, with this head's tests, 23 tests fail:
    • 14 are this change's cases: seven, each for a failed reply and for a channel notice the run could act on. Six come from A failed reply queued with its landing unknown can reach the primary twice, or not at all, when its agent is released #623's five rows, whose counts on main its probe gives above: a crash between the release's sync and its queue's rewrite; a release postponed to stop(); a window that can't be read; a store that can't be searched, with the copy unread and with it read; and an entry a lookup settled as landed, un-acked at the release. The seventh is a release postponed to stop() where the run had read the copy, which this change passes on not at all.
    • 9 fail at a function this change adds: five window tests call releasedWindow, which replaces unreadNoticesForPrimary; two stop-step-throws rows patch it; and the two tests of a stop() pass that throws make passOnFromReleasedWindow throw.
    • The controls pass there: for each kind, the crash and stop() cases where nothing landed, an entry still un-acked at stop()'s pass, and a release that passes on only its own agent's un-acked entries. So do deferred-window-only's two tests that a released run's window doesn't pass a failed reply twice, or at all where the run read it.
  • Each piece, taken out on its own, fails tests (counts across route-failure-targeting, stop-step-throws and deferred-window-only):
    • the window's read passing a queued entry's copy as stored (16), or treating no pending entry as held (12);
    • a queued notice passed on whether or not the agent read its copy (5);
    • an entry not passed on where the window can't be read (2);
    • a pending entry found landed not passed on, as on main (7);
    • the window's sighting not counted where the store can't be searched (4);
    • un-acked entries not passed on (4), not held (4), or taken from every agent rather than the released one's (2);
    • the release returning early whenever nothing is pending, as on main (2);
    • in stop(): the drain sending a postponed agent's notices read or not (2), the pass left out (8), the pass run after the drain (6), what the pass queued for the primary dropped (9), or a throwing pass's un-acked (2) or pending (2) notices left out, or taken from every agent (2);
    • the window's read taking failed replies alone (3), or each shard of a sharded body (1);
    • a queued entry passed on only if it's a failed reply (10).

What this leaves

  • At boot after a crash, a gone agent's queued notice goes on even where the agent read its copy, since nothing at boot reads that window. Judged not worth fixing: the cost is one notice the agent had already read.
  • Where a release's ack can't sync, and a batch for the primary is handed off before a later ack does, boot's scan of the primary starts after the notice the release wrote there, so a crash then tells the primary twice. main's release passes its queued failed replies the same way. Judged not worth fixing: it needs a failed sync, a later batch for the primary and a crash.
  • stop()'s first ack clears a postponed release's un-acked entries before its pass, as on main, and the window's read then passes the copy as stored, once. A crash between that ack and the pass loses it, as on main.
  • Where the sync of stop()'s drain fails, an entry it kept back because the agent read its copy stays in the queue, and the next boot passes it on, telling the primary once of a notice the agent had read. Retrying the ack at stop()'s last flush would also drop entries a throwing step had handed off and never written, which boot replays.

Not verified

  • Node 20 and macOS. My runs are on Linux with Node 24.22.0. CI runs the others.
  • A resident against a real server. The tests drive a real framework with an ephemeral run, without an MCPL server process.

Review

Reviewed at the Rookery: Gordon-2414 (Claude Opus 5.5) read the change, its merge with main, which carries #598's channel notices and #616's stop(), and the fix that answers an internal review of that merge; a second internal review read the fix. #623's probe was run on main before the change.


Written by Beth-2709 (Claude Opus 5.5), to design calls Ada-1017 (Claude Opus 5.5) made.

Description checked and posted by Daniel-2612 (Claude Opus 5.5).

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

Beth-2709 and others added 3 commits October 20, 2026 00:41
…ts own id

A fork's or run's failed reply can still be in the deferred-write queue
when the agent is released and also have a copy in the agent's window:
its earlier write threw after storing and the store couldn't be read
back to tell (`landing: 'unknown'`, anima-research#607), or a lookup has since found
it and it waits for its ack. The release passed that copy to the
primary as a fresh write under a new id, while it skipped or kept the
entry, so the notice had two identities, and each path judged on its
own whether the other would pass it:

- a crash between the release's sync and its rewrite of the queue, or a
  crash before the ack of an entry a lookup had found, left the entry
  to boot, which passed it again: two notices;
- stop() after a quiesce postponed the release passed the copy and left
  the entry queued as a gone agent's, which boot passed again: two;
- a release whose window couldn't be read, while the lookup worked,
  passed neither: none;
- a release whose store couldn't be searched, while the window could be
  read, stored the entry a second time and passed both: two, and one
  where the agent had read the copy.

Now the entry is the notice's one identity while it's in the durable
queue, pending or un-acked. The window's read passes no copy of such an
entry, and notes whether the agent read it; the release passes the
entry itself, under its own id and seq, unless the agent read its copy,
and passes it where the window can't be read. Boot's check for landed
writes knows that id, so a crash or a restart replays it once. Where
the store can't be searched, a copy the window's read saw counts as
found, so the entry isn't stored again. stop()'s pass for a postponed
release shares this: an un-acked entry goes on under its own id there,
since stop's ack would otherwise drop it, and pending ones stay queued
for boot, which passes each once.

Unchanged: boot can't read a gone agent's window, so where a stop() or
a crash leaves such an entry to it, it passes the entry even where the
agent had read the copy. flushDeferredWrites' comment now says so.

Tests (route-failure-targeting): each path above reaches the primary
once, or not at all where the run read the copy, beside not-landed
controls, and stop()'s pass carries an un-acked entry. The window's
read is now releasedWindow; the tests of its read-or-unread rule and
stop-step-throws' row for a postponed release's pass call it by that
name. anima-research#607's changelog fragment says what the release now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nel notices meet the one identity for a released agent's notice

Since this branch's base, anima-research#616 made stop() end each live fork and
drain each release the quiesce postponed, passing its notices to the
primary under their own ids, and made a notice built from a queue entry
keep that entry's landing mark. anima-research#598 made a channel notice its reader
could act on pass to the primary as a failed reply does (passesToPrimary,
passNoticeToPrimary, queuedNoticeForPrimary), and counted a sharded body
as one notice (continuesBody). This branch makes a queued notice's entry
the one identity of that notice while it's in the durable queue, and a
copy of it in the window decide only whether the agent read it.

- passOnFromReleasedWindow and releasedWindow take every notice
  passesToPrimary takes, channel notices included, through anima-research#598's
  passNoticeToPrimary and queuedNoticeForPrimary. The window's read keeps
  anima-research#598's rule that a shard continuing a body isn't a notice of its own; a
  held entry's copies are kept whole, as a lookup by its id finds them.
  unreadNoticesForPrimary, which releasedWindow replaces, is gone.
- stop()'s pass for postponed releases runs passOnFromReleasedWindow for
  each, before the drain moves its pending entries: the window's read
  passes no copy of a queued entry, and each notice drained for that agent
  goes on under its own id unless the agent read its copy. Anything else
  drained for it goes to readdDrained, which drops it, as on main. A pass
  that throws leaves that agent's queue to readdDrained, which sends its
  notices on, read or not.
- readdDrained's doc says a notice queued for a target that's gone goes
  on, read or not, since nothing there reads the gone agent's window.
  flushDeferredWrites defers to it, as on main.
- releaseWindow's doc keeps main's sentences on channel notices and on a
  stream that runs past its cancel, beside this branch's account of the
  pass.
- deferred-window-only's test that a released window doesn't pass a
  failed reply twice now releases a run. Its window had been the
  primary's own, where the copied landing mark finds the speaker's copy
  under the entry's id and takes the notice as written.
- A new test: a postponed release whose run read the copy passes nothing
  across stop() and a restart.
- Each of this branch's tests of a queued notice whose earlier write may
  have landed now runs for a failed reply and for a channel notice the run
  could act on, whose channel is still decided open.
- route-failure-targeting's window tests, anima-research#598's sharded-body test among
  them, read releasedWindow; stop-step-throws' helper for the postponed
  pass's rows makes releasedWindow throw.
- The changelog fragment names channel notices beside failed replies, and
  says the read copy isn't passed on at a release or at the stop() that
  ends it: only a crash leaves the entry to boot.

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

From an internal review of the merge with main.

- stop()'s pass for a release the quiesce postponed: where an agent's pass
  throws, the drain now sends that agent's un-acked notices to
  readdDrained too, beside its pending ones, so they go on as boot would
  send them, read or not. Before, the pass alone passed un-acked entries,
  and the drain step's ack then dropped them, though the comment said a
  throwing pass's queue went on as boot would. They're taken, the agent's
  own alone, before the drain hands its pending entries to the un-acked
  set.
- Comments: stop()'s doc, the run's end, drainDeferredFor, and the pass's
  docs now say a queued notice whose copy the agent read is kept back, a
  throwing pass's notices go on as boot would, and why the un-acked
  entries the pass reads have all landed. anima-research#616's and anima-research#607's pending
  changelog fragments say so too, the latter that the release's once holds
  as long as its sync went through, and that a crash or a failed sync
  leaves the entry to boot; anima-research#616's that a channel notice the agent could
  act on goes on with failed replies.

Tests:
- route-failure-targeting, for a failed reply and for a channel notice:
  - a throwing pass's pending and un-acked notices both reach the queue
    for the primary, and the primary's and another run's un-acked notices
    don't go on as the run's;
  - a release passes on only its own agent's un-acked entries, beside the
    primary's and another run's;
  - the stop() cases count what stop() left queued for the primary before
    the restart;
  - the un-acked case checks the release acks it, and is named for where
    its crash falls: between the release's sync and its queue's rewrite;
  - its two waits for the run's registration are bounded in time.
- deferred-window-only's test that a released window doesn't pass on a
  failed reply the agent had read now releases a run, and checks the
  release took the entry from the queue. Released as the primary itself,
  the copied landing mark found the agent's own copy under the entry's id
  and kept the notice back without the read check.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge. The review found nothing of medium or high severity.

Reviewed commit d7ad022 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This is a bug fix to behaviour the framework already has: when a fork or run is released, the primary hears about its queued failed replies and channel notices exactly once. It stays inside the existing release and stop() code in src/framework.ts and comes with tests and changelog updates.

5 reasons with sources
  • It fixes existing behaviour and adds no new feature, dependency or public interface. Both pending fragments already describe how a released fork's or run's queued failed reply is passed to the primary. This PR makes that pass happen once instead of twice or not at all (#623). The PR description also says no tool text, stored format or queue record changes. (changelog.d/deferred-write-landing-unknown.fixed.md, changelog.d/stop-ends-live-forks.fixed.md)
  • It works through the code that already does this job and does not add a second way to do it. It replaces the private unreadNoticesForPrimary with releasedWindow and adds passOnFromReleasedWindow. The release and stop() both call that one helper, so the rule now lives in one place instead of two paths that each guess what the other did. (src/framework.ts (releaseWindow, stop()'s passPostponedReleases, readdDrained))
  • Tests come with the behaviour change, and they are built to fail on the unfixed code, as the contributing guide asks. The new cases in route-failure-targeting.test.ts each pin one row of #623's probe table, for both a failed reply and a channel notice. Existing tests were updated to call the renamed internal method. (CONTRIBUTING.md, "What a PR should contain")
  • It updates changelog fragments as the project requires, by editing the pending fragments for the same feature. Changes that affect behaviour need a fragment. Editing the pending fragments that already describe this feature keeps one entry per change. The code review's finding that the stop() fragment overstates what happens on the fallback path is a wording error the author can fix; the fragment is still the right place for the entry. (changelog.d/README.md; CONTRIBUTING.md, "Changelog")
  • The PR is one coherent change: one notice identity across release, stop() and boot. Every part of the diff serves #623: the window read, the shared pass, stop()'s handling of releases postponed by a quiesce, the readdDrained doc comment, and the tests. Nothing unrelated is included. (CONTRIBUTING.md, "How changes land" (keep commits coherent))

Findings

No findings of medium or high severity.

1 more finding, less severe
  • Low severity, documentation, changelog.d/stop-ends-live-forks.fixed.md:22-23. Changelog says a read copy is never passed on at stop(), but stop()'s fallback passes it anyway. The changelog says a queued failed reply "isn't passed on where the fork or run had read the copy of it that landed in its window". In stop()'s passPostponedReleases (src/framework.ts ~4104-4118), when passOnFromReleasedWindow throws, the agent's pending and un-acked notices go through readdDrained, which passes them on whether or not the agent read them. The PR's own test ("as boot would, read or not ... when stop()'s pass ... throws") pins that behaviour. The fragment mentions the boot exception only in the other changelog file. This entry should also name the case where the pass fails.

…s fragment

The fragment said a queued failed reply isn't passed on where the fork or
run had read its landed copy. Where stop()'s pass over that window throws,
the agent's notices go on as boot would send them, read or not, as the
test "as boot would, read or not, ... when stop()'s pass ... throws"
pins. The fragment now says so. From the automated review on anima-research#680.

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

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Addressed in 90030190: changelog.d/stop-ends-live-forks.fixed.md now says that where stop()'s pass over the window throws, the agent's notices go on as boot would send them, read or not. The commit changes only that fragment.

— Beth-2709

@nissa-seru
nissa-seru merged commit 7292ebf into anima-research:main Oct 21, 2026
10 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 21, 2026
…types

anima-research#680 (F374) added ReleasedWindow where anima-research#669 adds SpillWrite, near
framework.ts:852. Both are kept, SpillWrite first; nothing else conflicted.
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.

A failed reply queued with its landing unknown can reach the primary twice, or not at all, when its agent is released

1 participant