Repository navigation
fix(deferral): pass a released agent's queued failed reply once, by its own id - #680
Conversation
…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>
There was a problem hiding this comment.
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>
|
Replying to this review. Addressed in — Beth-2709 |
…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.
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
mainat7e09923dcounted 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:stop(), then a bootSince #598, a channel notice the agent could act on takes the same paths.
Changes
passOn, throughqueuedNoticeForPrimary), so a crash before the ack replays it once. A copy of it in the window decides only whether the agent read it.releasedWindowreads the window once, in place ofunreadNoticesForPrimary: 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.passOnFromReleasedWindowis the part the release andstop()share. It passes the unread notices on as stored (passNoticeToPrimary), passes the un-acked entries on, and returnspassOnfor the pending ones.releaseWindow:mainskipped it;stop()'s pass for a release the quiesce postponed runspassOnFromReleasedWindowbefore the drain, and each notice drained for that agent goes throughpassOn. Anything else drained for it goes toreaddDrained, which drops it, as onmain. A pass that throws leaves that agent's own notices, pending and un-acked, toreaddDrained, 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
stop()that ends it. Where the window can't be read, the notice goes on.main's behaviour too, and this PR leaves it, as "What this leaves" says.What each part is for
heldinreleasedWindowtellspassOnwhether 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.settleFound) and the release never looked at un-acked entries.passOnFromReleasedWindowis the one place the rule lives, which the release andstop()both call.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 ownpassOn, and an agent whose pass threw falls back toreaddDrainedfor its pending and un-acked notices.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"). Thestop()cases also count whatstop()left queued for the primary before the restart (queuedForPrimary), since boot's check for landed writes would otherwise hide astop()that queued a notice twice under one id. The window tests moved toreleasedWindow, which they now call, anddeferred-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.Tests
On Linux with Node 24.22.0, on this head's tree (
d7ad0225, which hasmain4e3fea38merged in). The full suite ran from a worktree nothing edited, and the other runs from one with only the checks' own files added.npm testgives 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 ofmainat that load to set beside it.main's CI at4e3fea38(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.route-failure-targeting101 of 101,stop-step-throws20 of 20,deferred-window-only65 of 65,conversation-router18 of 18,tool-wrapper-prose-guard-integration18 of 18,tool-result-guard146 of 146,prose-drafts87 of 87,ephemeral-no-global-fallback11 of 11.main'ssrc/framework.ts, with this head's tests, 23 tests fail:mainits probe gives above: a crash between the release's sync and its queue's rewrite; a release postponed tostop(); 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 tostop()where the run had read the copy, which this change passes on not at all.releasedWindow, which replacesunreadNoticesForPrimary; twostop-step-throwsrows patch it; and the two tests of astop()pass that throws makepassOnFromReleasedWindowthrow.stop()cases where nothing landed, an entry still un-acked atstop()'s pass, and a release that passes on only its own agent's un-acked entries. So dodeferred-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.route-failure-targeting,stop-step-throwsanddeferred-window-only):main(7);main(2);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);What this leaves
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 onmain, and the window's read then passes the copy as stored, once. A crash between that ack and the pass loses it, as onmain.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 atstop()'s last flush would also drop entries a throwing step had handed off and never written, which boot replays.Not verified
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'sstop(), and the fix that answers an internal review of that merge; a second internal review read the fix. #623's probe was run onmainbefore the change.changelog.d/deferred-write-landing-unknown.fixed.mdand fix(stop): end each live conversation fork as its release would #616'schangelog.d/stop-ends-live-forks.fixed.md, to say what a released agent's queued notices now do.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-seruaccount.