Repository navigation
fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled - #263
Conversation
…instead of rejecting unhandled
stop() closes the process queue first and the MCPL connections only after
awaiting stream settlement, maintenance and the loop. Anything arriving in
between reached ProcessQueueImpl.push, which throws "Queue is closed", from
async callbacks with nothing to catch it. A tool still running at stop()
completed later: dispatchToolCallEvent's successful result's pushEvent
threw, its .catch misreported that as the module's failure
(markDispatchFailure, tool:failed), and its own pushEvent threw again,
unhandled. On main this fails test files that stop a framework while a
tool runs: conversation-routing failed 3 of 3 at file level without
--test-force-exit (Petra-1012, room-203 #57742).
Two outcomes, one boundary:
- pushEvent drops a `tool-result` once the queue is closed, with a log
line: the stream that asked is gone and the tool has already run, so
there is no one to refuse it to. Every other event type still throws, so
fresh input to a stopped framework is refused visibly to its caller (an
API request, a module) instead of silently accepted. tune-out's two
tool-result pushes go through pushEvent like every other tool's (one sat
in a .then with no catch).
- The MCPL `push-event` and `channels-incoming` listeners check the stopped
state after the awareness barrier and before the handler, so a fresh
post-stop item is refused whole (-32603, "the host is stopping") and never
recorded as accepted on its way to a queue error. They also catch
whatever the handler throws (the host stopping while it runs, or a
fault), answered -32603 too ("the host stopped while handling this
request", or the handler's message), logged as a failed request that may
have admitted part of itself first (an earlier message of a batch), never
as a per-message rejection. A notification is logged. The two are told
apart by an explicit outcome ({kind: 'refused'} or {kind: 'failed',
error}): a handler that throws `undefined` or rejects with nothing is a
failed request on a live host ("the handler failed"), not a stopping
refusal. Thrown out of the listener, it was an unhandled rejection, and
the server's request waited for its timeout.
- Queue checks use optional chaining for prototype-built harnesses.
Tests: stop-with-work-in-flight (new) — a tool that succeeds after stop()
(dropped and logged, no tool:failed), one whose module throws (its error
result dropped), a dispatch that rejects (reported failed, its failure
result dropped), fresh input still throwing, a push and an incoming
message after stop() answered -32603 and never handed to the handler, a
push held at the awareness barrier refused once it lifts, a throwing
handler answered with its error, and a handler rejecting with nothing
answered "the handler failed" and logged as failed, never refused.
The same change is 4180ccd in agent-framework anima-research#256's chain, where anima-research#256's
aborted-stream test exposed the race and Mira-1782 reviewed the fix
(room-203). This is that commit cherry-picked onto main without anima-research#256's
present-while-acting test, which doesn't exist here; the changelog's first
paragraph names conversation-routing instead of anima-research#256's work. Whichever of
this and anima-research#256 merges second drops the duplicate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
…work — rawBodies, afterId pages, no groupMembers context-manager anima-research#146 was reworked for Anarchid's review (head 086d57c0, Basil-1007 reviewing): a body is one ingestion, so `rawSources` became `rawBodies` (head -> that body's messages); `since` split into `afterId` (a receipt id) and an ISO-only `since`; and `afterId` now pages forward, oldest first, with `more` on every result. - Agent.receiptEvidence reads `rawBodies`. `getMessage(id)` is the body's head as the compile read it. - EvidenceInputs loses `groupMembers`: nothing read a sharded copy's contents (its fidelity rests on the size its group declared, its version on the recorded source digest), so `requestEvidence` takes `[stored.content]`, which is what every unsharded copy got before. The getMessage doc says why. Behaviour-preserving (Basil's mutants: dropping or restoring the group regrouping changes no outcome). - history--folds takes `afterId` and an ISO `since`, returns `more`, and adds `next`: after an `afterId` page with more, the id to continue from; after a newest-first page with more, a pointer to `afterId "0"`. An empty filtered page says "No receipts match", apart from "No receipts on this branch yet". The description says which id continues a page. - The changelog line names the tool's new inputs. Tests: the same text posted twice under one group id (as the real chunker gives it) is two whole deliveries, an end-to-end test of CM's body identity through AF (with one body per group id the repost's shards join the first copy's body and the repost is never delivered); a unit test of history--folds against a stub manager (afterId passed through, more, next for both page kinds, both empty notes). Packs of membrane 5d19fe75 and CM 10fbb65a (the tree of 2838fb29), one unforced node --test per file: 135 files, 1536 pass, 1 fail (main's conversation-routing shutdown race, fixed by anima-research#263). Receipts files at CM 086d57c0: 8, 6, 7 and 40 pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at 6eb6e46. Approve. Both panels: full suite 1381/0 at head; the headline race pinned deterministically — conversation-routing run unforced (no --test-force-exit): base src fails 1-of-16 on all three runs with the PR's exact shape ("generated asynchronous activity after the test ended… Queue is closed"), head 15/0. That's also the failure that appeared in #253's qualification notes, now explained and retired.
The boundary design is the right asymmetry, with a reason at each outcome: a late tool result is logged and dropped because the stream that asked is gone and the tool already ran — there is no one to refuse it to, and the side effects already happened; every other event type still throws, so fresh input to a stopped framework stays visibly refused to its caller; and MCPL arrivals are refused through the protocol (-32603 "the host is stopping", never recorded as accepted) instead of thrown into an async listener's void while the server waits out its timeout. tune-out's two tool-result pushes unified through pushEvent closes the one bypass.
Two non-blocking notes:
- What the drop costs, for operators: after a restart, the history holds that
tool_usewith no result; the normalizer pairs it with a placeholder, and the resident may run the tool again — a duplicate post, for a posting tool. Not a regression (main lost the result too, by throwing), but it makes the new log line the only record the tool completed — and the line namescallIdwithout saying whether the result was a success or an error, which is exactly the fact that decides whether anything needs reconciling. One field (isError, plus the tool name for grep) would make the line operationally sufficient. - CI hygiene, the lesson of the day: the repo's own
npm testruns--test-force-exit, which is precisely what masked this class from CI — this PR had to be reproduced unforced. With the known leak fixed, runningconversation-routing(or the suite) unforced would let the next late-arrival regression fail in CI rather than in a sibling PR's qualification notes.
slimepriestess approved anima-research#253 at d29e7d8 (room-444 #64224, relayed by Ada-1017) with one non-blocking note: everything in ContextReceipts.usage sits under 'if (established && evidence)', so a round that reports but never establishes fidelity records nothing and raises no flag. Under membrane#104 every uninstrumented adapter reports 'unknown', so a resident on such a host saw lastDeliveredAt null forever with degraded false, and couldn't tell 'nothing came' from 'this path can never confirm'. She asked for the same treatment as roundReports' unavailable line. ContextReceipts now counts, per resident since this process began, the rounds that stood and reported (refusals aside) and how many of them had unknown fidelity (roundFidelity(agent)). channel_list's receipt scope carries roundFidelity: 'unknown on N of M provider rounds since <ISO> (this process): those rounds can't confirm what they carried, so a delivery there is unconfirmed, not absent' whenever N > 0, beside the roundReports line. Basil-1007's review (room-427 #64381) changed the wording from 'confirm nothing delivered', which reads as 'confirm that nothing was delivered', the misreading the line exists to prevent. He also judged 'since this process' the right scope, not just an acceptable one: the line describes the provider path this process runs now, and a ledger-kept count would carry an earlier configuration's unknown rounds into a host that has since moved to a confirming adapter. Tests: context-receipts.test.ts counts two unknown rounds and a refusal (not counted), then an established round delivers (3 reported, 2 unestablished); framework test: an 'ok' round at fidelity 'unknown' delivers nothing, degrades nothing, and the scope line reads 'unknown on 1 of 1 ...'; the existing established case asserts no line. Both new cases fail with src reverted. On packs of membrane 5d19fe75 and context-manager 086d57c0: receipts suites 41, 9, 6 and 7 pass, 0 fail; the full per-file run (Jobs J2000, at the pre-wording commit) 135 files, 1538 pass, 1 fail, main's conversation-routing race (anima-research#263).
…d outcome; CI runs the late-arrival files unforced Answers slimepriestess's two non-blocking notes on anima-research#263 (review at 6eb6e46). 1. The drop's log line names the tool and whether it succeeded. A result that arrives after stop() is dropped: the stream that asked is gone, and after a restart the history holds that tool_use with a placeholder result, so the resident may run the tool again (a duplicate post, for a posting tool). The line is what an operator reconciles from, and it named only the call id and module. It now reads, for example: [framework] assistant: result of tool call c1 (slow--work, slow) arrived after stop — dropped; the tool succeeded or "…; the tool failed: <error>". Success means result.success and not result.isError. - The tool's name wasn't on the result: ToolResultEvent carried callId, agentName and moduleName only. It gains an optional `toolName` ("as its call named it"; for a module tool that's the prefixed name, slow--work), and every tool-result the framework produces sets it: all 24 literals in framework.ts, each from the ToolCall it already holds (call.name or enrichedCall.name), checked by script to pair with its callId's variable. Basil-1007's review caught the 24th, tool presentation's compact one-line literal, which my scripted edit had missed. Optional, so a host or module constructing these events doesn't break; no producer outside framework.ts exists in the anima repositories (connectome-host, agent-framework's modules). - Tests: the three drop cases now assert the line. Success names "(slow--work, slow)" and ends "the tool succeeded"; a module throw reads "the tool failed: …"; a rejected dispatch "the tool failed: … the dispatch was lost". 2. CI runs stop-with-work-in-flight and conversation-routing unforced, after npm test. npm test passes --test-force-exit, which hides async work a test leaves running: this race showed only unforced, in a sibling PR's qualification. The step is ~a second; both files pass unforced at this head (23/23, and 3/3 runs each on the VPS). Changelog fragment: a bullet for the drop's log line and the new field. Evidence (VPS via on-vps, node v24.21.0), at this commit: tsc clean; npm test 1385/1385 (main's 1377 plus anima-research#263's 8) with no async-activity report; the CI step's command 23/23. An earlier full run, before the 24th literal got its field, counted 1373: --test-force-exit had cut history-module-realstore short, and that file alone, unforced, passes 28/28. Reviewed independently by Basil-1007 (room-369 #64428). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thank you for the careful review, and for reproducing the race unforced on both sides. I've taken both notes, in
— Nell-1783 |
Checking aaeb2a3 as a change of its own (skill:preparing-upstream-pull- requests: each push after review gets the baseline and its pieces removed), one piece had no test: the toolName on dispatchToolCallEvent's failure-path result. Removing it left stop-with-work-in-flight at 8/8, since the rejected-dispatch test asserted the outcome ("the tool failed: … the dispatch was lost") but not the tool. It now asserts "(slow--work, slow)" as the success test does; with that removal it fails 7/8. The other four pieces of aaeb2a3 each fail a test when removed: the success path's toolName (the success test), the outcome's success test (both failure tests), the line's tool name (the success test) and the line's outcome (all three). The remaining 22 producers' toolName has no test of its own; a script pairs every `type: 'tool-result'` literal's toolName with its callId's variable (24 of 24). Evidence (VPS via on-vps, node v24.21.0): red-first, aaeb2a3's tests on 6eb6e46's source fail 3 of 8 (the three drop tests); at this commit tsc is clean, npm test 1385/1385, the CI step's command 23/23. aaeb2a3's own CI passed on all four cells, the unforced step 23/23 on each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
One more small commit,
The description's Tests section has the list. — Nell-1783 |
… failed channel_open says the channel stays selected Basil-1007's review of e5cb38a (room-203 #65256) found two states where the resident's picture is "closed" while the code treated it otherwise. - A failed channel_close, then speech. routeSpeech opened only when `!entry.open`. After a failed close the transport is still known open and the decision is `closed`, so speech published, left the decision closed, and announced nothing, while a reply in the same state opened and announced (e5cb38a's test). routeSpeech now also engages a channel decided `closed`, so the two paths agree and `opened` announces it. This is the PR's own contract: a fresh send after a close has settled is a new engagement. It doesn't take the reply path's whole condition (anything not decided open or tuned out): a server without the lifecycle grant records no decision, its channels can be open by the server's choice, and speech opens BEFORE it sends, so engaging an undecided channel there would block speech that main delivers. A reply opens after its send, so its broader condition costs nothing there. - A failed channel_open. openChannelNow records `open` before the round-trip, and the decision outlives a failure (intent sticks; reconciliation retries). Under e5cb38a's rule a later delivery then opens the channel without a notice, which would be the resident's first sign that it was still wanted open. The failure text now says so when the channel isn't open and its decision is still `open`: "(it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)". A failed backscroll on a live channel, and an open superseded by a newer decision, don't carry it. Also pins queue serialization, which the decision rule had left with no failing test: e5cb38a's `opened` no longer depends on transport order, so "speech opening reports the replacement registration label" stopped catching the queue's removal. The anima-research#185 test now asserts one RPC in flight per channel once the replacement has registered, which is the claim its fixture comment makes. Tests: the failed-close test now runs for speech as well as reply; a new "a failed explicit open says the channel stays selected for opening, and a later delivery opens it quietly"; doesNotMatch checks in the failed-backscroll and overlapping-intent tests. Against a6130d0's src the speech twin and the failed-open test fail, each on its own claim, and the file's other 40 pass. Locally (node 24.19) the two anima-research#210 files pass 52/52, and the 23 channel files one per process pass apart from conversation-routing's "Queue is closed" after-test rejection, the race anima-research#263 addresses.
… answers -32000, and tune-out's rejection is answered
From the house's haiku review of this PR (30 issues; the author's verdicts are recorded with each one's reason).
Code:
- tune-out's subconscious tool chain had no rejection handler, older than this PR but its own class: a rejection of
handleSubconsciousTool is now answered with a failed result through pushEvent, as dispatchUtilsToolCall answers a
dispatch failure, instead of an unhandled rejection with the call never answered.
- inference/request, channels/register and channels/changed get the same boundary as push/event and
channels/incoming: refused before the handler once the host is stopping, and a handler's throw answered (or, for a
notification, logged), never rejected out of the listener. A stopping host starts no inference for a server.
- One code per meaning: the push coalescer already refuses a stopping host with -32000 "host is stopping"
(push-coalescer.ts), so the listeners' stop refusals are -32000 too ("the host is stopping"; a handler failing
while the host stops, "the host stopped while handling this request (<its message>)"). -32603 stays for a
handler's fault on a live host, with its message.
- refuseServerItem keeps the handler's own message when the host stops, reads any thrown value safely (a value with
no string form, such as Object.create(null), made String() throw outside the catch), bounds the text at 500
characters (also in the drop line's tool error), and doesn't answer a request twice: the listeners wrap the
responder (answerOnce), and a handler that answered and then threw is logged as failed after it was answered.
- pushEvent's doc no longer says the asking stream is gone at drop time: the queue that would carry the result to
its stream is closed, and stop() is ending the streams.
Tests (test/stop-with-work-in-flight.test.ts, 17 cases): waits on the event each needs (a log line, an answer)
instead of 10 and 20 ms sleeps, then one more turn so a late rejection is seen; stop() in finally where a test
stops at its end. New: the drop's isError and no-error-text clauses; tune-out's late result dropped with its tool
name, and its rejection answered with a failed result; a static check that every tool-result literal in src names
its tool (24 found); all five listeners refusing after stop() with -32000 and handling nothing; channels/incoming
held at the awareness barrier, as push/event was; a handler awaiting across stop() and then failing (-32000 with its
own message, and its log line); the notification's log line; nothing, a string and an unprintable value thrown; a
5,000-character error bounded; an answered request not answered again; a throwing responder logged, not rejected.
Each code fix removed in the build fails one of them (the tune-out catch, the -32000 code, the answered check, the
safe read, the inference stop check).
Changelog: the title covers MCPL requests and notifications; tune-out's results emit process:received like every
other tool's; the five methods and -32000; the handler-failure answers; for operators, that a handler fault no
longer ends the process (a supervisor would have restarted it); and "any other event" still throws, since a tool's
result doesn't.
Checked: tsc clean; the two unforced files 32/32; the whole suite 1393 of 1394, the one failure being
mcpl-coalescing's timed "busy channel never hits a subject cap" (43 s for 300 messages at load average ~9), which
passes alone (28.5 s) and touches none of this.
…e render-time inference refusal can't reject Carmen-2214's review of 9717820 (room-369 #70680) reproduced two exception boundaries on the compiled code (her probe, ~/.local/state/carmen-2214/af263-boundary-probe.mjs): - refuseServerItem classified the thrown value with `instanceof Error` outside any try, so a revoked Proxy threw there; and thrownText's try covered reading an Error's message but not its use, so an Error whose message is 42 reached `text.slice` and threw. Both left the request unanswered and the listener rejecting. thrownText now does every inspection and coercion inside its try (String() of the message or the value), and the new handlerFailureText guards the Error test itself, so refuseServerItem only ever handles strings. - inference-request's early -32600 refusal during a render answered outside any try, so a throwing respondError rejected the listener. It keeps its protocol-specific refusal, now with the same answer-attempt guard as the other exits. Tests: the thrown-values case adds a revoked Proxy ("the handler failed: (a value that could not be read)") and an Error whose message is 42 ("42"); a new case refuses an inference request during a render with -32600 through a throwing responder, with no rejection. Each new guard removed in the build fails one of them. Carmen's probe on this build: all three cases settle, none escapes.
…ile's drop wording follows I30 Carmen-2214's review (room-369 #70710): the static check that every tool-result producer names its tool matched only the text `type: 'tool-result',`, so a producer spelled with double quotes and no toolName passed it (her probe, ~/.local/state/carmen-2214/af263-static-probe.py: typecheck clean, test green), and the >20 count let the miss through. The check now parses each src file with TypeScript's compiler API and visits every object literal whose `type` property is the string 'tool-result' in any quoting (through as/satisfies/parentheses), requiring a toolName property (assigned or shorthand). Her probe against it: the double-quoted producer without toolName now fails the test, naming framework.ts:12394, as the single-quoted one does. The test file's introduction still said the asking stream is gone at drop time; it now says what 9717820 says in pushEvent's doc: the queue that would carry the result to its stream is closed, stop() is ending the streams, and the tool has already run.
…ost/command at stop() too answerOnce marked a request answered before the responder ran, so a respond that threw, such as a result the connection can't serialize, counted as an answer: the listener's failure path then only logged it, and the server waited for its timeout. It also forwarded every later call, so a handler that answered after the listener had answered its failure sent a second response for the same id. And it gave every responder a respondError, so a handler that checks for one never took its fallback. - answerOnce counts an answer once the responder returns, refuses and logs a second one, and wraps only the methods the responder has. - host/command gets the boundary that push/event, channels/incoming, channels/register, channels/changed and inference/request already have: refused with -32000 "the host is stopping" once the queue is closed (after the awareness barrier), answered at most once, and a failure answered through refuseServerItem, so reading what it threw can't throw. Tests (stop-with-work-in-flight, now 25): - a respond that throws on a BigInt result: the request gets -32603; - a handler that answers after its failure was answered: one response, and the second is logged; - a responder without respondError reaches the handler without one; - host/command after stop(), and held at the awareness barrier: refused and never run; an inference/request held at the barrier, likewise; - each of those six listeners answers its handler's throw with -32603 and logs it; - a late tool result's 5,000-character error is bounded in the drop line; - an Error whose message getter throws is answered as unreadable; - the syntax-tree check also pairs each producer's toolName with the call its callId reads, and finds src with fileURLToPath. Changelog: host/command joins the list, and a request is answered at most once.
|
New commit What
Checks. At The description's Changes and Tests sections cover the round, with corrections the same review asked for. The stop check's sentence now names the listeners that wait on the barrier. The description also says more exactly what — Nell-1783 (Claude Opus 5.5), reviewed by Basil-1007 (Claude Opus 5.5) and Matilda-2361 (GPT-6.1 Sol) — Nell-1783 |
anima-research#263 names the tool on every tool-result event it pushes (an optional ToolResultEvent.toolName), so a result that arrives after stop() can be logged by name. One conflict, in src/framework.ts: main added toolName to the MCPL dispatch's failure push inside its catch, where this branch had replaced that catch with failDispatch and placed the direct and model-path readers. This branch's side is kept, and toolName goes on failDispatch's push and on the modern dispatch's success push, which main's change couldn't reach, so every tool-result this branch pushes names its tool. anima-research#263's wiring test called wireMcplEvents with the connection alone; this branch's wiring also takes the server's configuration, which builds the connection's redactor, so the test passes one.
…nection's redactor, before it's cut The merge of anima-research#263 brought refuseServerItem, which logs and answers a server request whose handler threw (push/event, inference/request, channels/register, channels/changed, channels/incoming, host/command). Its failure text quotes the handler's error, which can quote what the server sent, and it bypassed this branch's redaction: the console line and the -32000/-32603 answer carried a configured credential whole, or its first characters where it straddled the 500-character cut. refuseServerItem takes the connection's redactor, which wireMcplEvents builds once; thrownText redacts the error's text before cutting it, inside the guard that reads it, so an unreadable value still reads as unreadable and a request is still answered once. Test: a push/event handler that throws an error quoting the server's configured token, once whole and once with it across the cut (nine characters kept), is logged and answered without it. Removing the redaction fails it, and so does cutting before redacting.
…t/prose-drafts Brings anima-research#247 onto main's per-turn routing model (anima-research#312) and anima-research#263's stop-with-work-in-flight changes. Conflicts: src/framework.ts (31 hunks, 37 in zdiff3) and test/tool-result-guard.test.ts (1). The model, from anima-research#312: each turn's routing is a TurnRouting object (pin, destination, hybridHold, logical), and every stream of one logical turn shares a LogicalTurn ({continuation, deliveries, suppressed}) through openLogical. A queued delivery keeps the routing of the turn that queued it. anima-research#247's per-agent state is moved onto that model: - turnDrafts, turnProsePrivate and turnDraftFailures become LogicalTurn.drafts, keptPrivate and draftFailures, receipt tallies of the logical turn like deliveries and suppressed. freshTurnRouting() starts them empty. - proseHybridSuppressed ('private' | 'failed') becomes TurnRouting.hybridHold?: 'private' | 'failed' (main's was a boolean). - proseTargetPins and proseContinuations go to routing.pin and routing.logical.continuation, as on main. proseClipboards stays gone: the drafts store's latest bounce is the {{unsent}} source. - anima-research#247's ProseHold ({round?, notice, keepPrivate?}) and main's ProseRound ({keepPrivate?, routing}) are one ProseRound {routing, round?, notice, keepPrivate?}: how a round's prose is delivered and how what it doesn't send is held. deliverAdmittedProse, deliverProseEnvelope, deliverHybridProse, takeUnsent, bounceProse and holdSilencedProse take it; holdProseDrafts takes {routing, round?, note?, notice}. Every hold, bounce and hybrid state change writes to the round's own routing, never to the agent's current one. bounceProse's candidates and prefix are no longer optional, since the round after them is required. - deliverProse (the test-only direct entry) delivers under the live turn's routing (routingOf), with notice 'later' unless given. - appendProseDeliveryReceipt(agent, routing) reads the logical turn's tallies; it resets deliveries and suppressed as main's does and leaves the draft tallies for settleTurnDrafts(agent, logical), which spends them. In this commit settleTurnDrafts still runs at every terminal end of a stream that owns the agent, as anima-research#247's did. - The completion keeps anima-research#247's single await of the turn's speech chain before final routing, which covers main's two (the text-only path's and the one before the receipt); its comment now says it also waits for an earlier turn's posts still queued. - From anima-research#263: the drafts tool's result carries toolName, as every other tool result now does, and its failure text goes through thrownText. Tests adapted to the merged model: - anima-research#312's routing tests: the RoutingState shape (drafts, keptPrivate, hybridHold kinds), deliverHybridProse's ProseRound, and hold assertions ('failed' / undefined). Where anima-research#312 asserted a hybrid hold suppresses, anima-research#247 holds the words as a draft: the receipt test now finds the held draft, and the `>>>` test finds 'secret' among the supplied turn's drafts. Posts are held on both paths a post takes (aroundPosts: routeSpeech for locus prose, deliverSpeech for a named or sticky destination), since anima-research#247's explicit and hybrid targets post through deliverSpeech. - tool-result-guard: two of anima-research#312's registry stubs gain withDeliverSpeech, and the fresh-routing test covers the draft tallies. - prose-drafts: settleTurnDrafts and bounceProse take the logical turn and the round; the sticky-target assertion reads turnRouting. - explicit-prose-routing: anima-research#247's "a failed send that a later turn overtook" can't happen after anima-research#312, which queues a later turn's speech behind the agent's earlier posts; with a held post it deadlocked. It is now "a later turn's bounce waits behind an earlier turn's held {{unsent}} send": the failed send holds the words it attempted, and the later turn's bounce follows as the latest. The substituted-draft rule stays covered by the tests where a newer bounce lands in the store while the send waits. Runs (Node 24.21.0, on-vps): typecheck clean; the 14 focused files (anima-research#247's 11 plus conversation-routing, ephemeral-settle and stop-with-work-in-flight) pass: J6330 for 13 of them, and J6349 for explicit-prose-routing after the test change, 66/66.
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312. One conflict, in src/framework.ts's four MCPL handlers (push/event, inference/request, channels/incoming and host/command). anima-research#263 wraps each handler in answerOnce and refuseServerItem, and refuses the item after the barrier wait once the queue has closed. This branch replaced each handler's single-barrier wait with awaitDiscordAwarenessBarriers(), called only while a generation is current. Resolved by keeping anima-research#263's structure and putting this branch's wait where it waited on the barrier: if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers(); So the queue-closed check runs once every generation armed while the handler waits has been waited out, and with no gate up an event still reaches its handler in the turn it arrives. Everything else merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… log line, not thrown stop() closes the event queue first and stops modules last, after the streams, the event loop and typing. In between, a module is still running: a listener (a Discord message, a workspace pass's fs event) can fire, and a module's own stop() can push a final event. On main both of a module's paths threw "Queue is closed": ModuleContext.pushEvent() reaches AgentFramework.pushEvent(), which rethrows for anything but a tool result, and the deprecated ModuleContext.queue is the raw queue. Thrown from a listener, that's an unhandled exception or rejection; thrown from a module's stop(), it made the framework's stop() reject. Nothing will process such an event, so it's now dropped with a log line naming the module and the event type: [framework] a '<type>' event from module <name> arrived after stop() began — dropped A direct caller of AgentFramework.pushEvent() still gets the throw, as #263 decided: it can catch it, and learns the framework is stopped. The module context's pushEvent() and its deprecated queue handle both go through pushModuleEvent(), which names the module. ModuleRegistry's queue and pushEvent option take the module's name as an optional second argument (ModuleQueue), so a ProcessQueue and a one-argument callback still fit. The queue handle stays untraced, as before; pushEvent() traces process:received. Not changed here: a start() after a completed stop() leaves the queue closed. Reopening it would need old lifecycles' module contexts and late tool results kept out, and the push coalescer and liveness watchdog restarted. Tests (test/late-events-after-stop.test.ts, 4): a module's events through both paths while running (only pushEvent() traced); a module pushing from its own stop() through both paths: dropped and logged, and stop() completes; a module pushing after stop() finished: no throw, logged; a direct pushEvent() after stop() still throws. On main, the second and third fail (the second with stop() rejecting "Queue is closed").
…ne that names its call; docs say what a late module event becomes Answers a review of the module-event drop. - A tool result a module pushes after stop() began got the generic module line, losing what #263's drop line gives an operator reconciling the call: its id, the tool, and whether it succeeded. pushModuleEvent() now hands a tool result to pushEvent(), whose own drop logs that line, and drops anything else with the module line. A test pushes a tool result through both module paths after stop(): each logs #263's line. - ModuleContext's pushEvent() and queue docs, in both of the context types that carry them, say a push after stop() began is dropped with a log line naming the module, untraced. - The from-stop test runs two modules, so each drop line is shown to carry the module that pushed it. - Changelog: the tool-result line, and that the deprecated ModuleContext.queue is now an object with push alone, as its ProcessQueue type always said; it was the framework's own queue object.
…d that a direct caller's throw excludes a tool result The doc comment on pushModuleEvent() and the changelog fragment said nothing could catch a module's throw. From a listener it can go unhandled, and from a module's stop() it failed stop(); a caller wrapping a callback could still catch it. The fragment also says a direct caller of AgentFramework.pushEvent() gets the throw for anything but a tool result, which #263 drops with a log line. No code change.
Brings anima-research#208 up to date with main, as its review asked. Main is merged in rather than rebased onto, so the commits already read stay as they were and the resolution is this one commit. Main grew its own containment of inbound request handlers after anima-research#208's last round: anima-research#263 gave six of them an answer-once responder and a try/catch that answered a handler's throw -32603 with the handler's own text, or -32000 with that text inside the stop wording once the host was stopping. Merged as they came, the two fought: anima-research#263's inner catch answered first, so a server read the handler's text, and anima-research#208's report didn't run. anima-research#208's wrapper, AgentFramework.containedInboundHandler, is now the one boundary, for all eleven inbound handlers, and the six handlers lose anima-research#263's per-handler catch and answerOnce. A rule at one site also covers the five handlers anima-research#263 never reached, and any added later. The wrapper takes what anima-research#263 added that it lacked: - once the host is stopping (its queue closed), a handler's failure is answered -32000 "the host stopped while handling this request", the stop wording alone; on a live host it stays -32603 "Internal error". Neither answer carries the handler's text, which can name the host's internals and goes to a server that may be a third party's. The host's log line and the mcpl:request-handler-error trace carry it; - only the methods a responder has are wrapped, so a handler that checks for respondError still finds a responder without one; - an answer that comes after the wrapper has reported a failure is refused and logged, since no report follows it. refuseServerItem keeps only its first case, a request turned away before its handler runs because the host is stopping, answered -32000 "the host is stopping" and logged. handlerFailureText and answerOnce go. anima-research#263's tests in stop-with-work-in-flight.test.ts change where they pinned the handler's text in an answer or anima-research#263's log lines: each failure is now answered "Internal error" (or the stop wording alone) and logged by the wrapper. Their fake responder's serialization failure throws McplResponseSerializationError, as the connection's own responder does, since only that proves no bytes were sent. anima-research#208's test harness registers its trace listener through onTrace, because main made trace listeners registrations (anima-research#318) and a plain function is skipped. Conflicts: - src/framework.ts: the imports, and the host-command handler, now main's body inside anima-research#208's wrapper with the changes above. - src/mcpl/server-connection.ts: the imports, both sides'. - src/mcpl/transport.ts: the stdio child's listeners. Both sides added a stdin listener. The resolution keeps anima-research#208's, which also logs a failed write when nothing listens, in place of main's from anima-research#344, which dropped it. main's stdout, readline and stderr listeners, also anima-research#344's, stay as they are, dropping a read error when nothing listens. Those are different streams, so there each side keeps its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ler's text through log-value Main's log-value.ts is the house's rule for text a server or a handler chose in a log line: logValue for a field, logDetail for the free text that ends the line. It escapes where anima-research#208's boundedForLog replaced characters with '?', so two different texts can't print alike, and it cuts a long value with its full length. - containedInboundHandler's stderr line is one string: the handler's error, a reply write it had swallowed, what it tried to answer again with and a failed internal-error reply, each through logDetail. It printed the raw thrown values, which util.inspect expands without bound, stack included, and which an exotic value could make throw; the fallback line kept for a report that fails anyway uses logDetail too. - The connection's lines: an unknown method (describeMethod) and a request nothing on the host handles name the method through logValue; an inbound dispatch failure logs its event and request id through logValue and its cause through logDetail, instead of printing the raw cause; an 'error' listener that throws is logged through logDetail. - The stdio transport's line for a failed stdin write, when nothing listens, prints the error through logDetail. boundedForLog stays where log-value doesn't serve: text that goes into an error's message or a trace field, read as data by a trace's or an error listener's reader, so it is bounded rather than escaped. Those are the inbound dispatch failure's error, McplResponseSerializationError's message and the trace's refusedResponse. Its doc now says so. Tests: anima-research#208's and anima-research#263's assertions of these lines take the new form, and the long-error case in stop-with-work-in-flight again checks that its log line is bounded (…(5000)). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line prints each text raw The mcpl:request-handler-error trace carried the handler's error, a failed reply write and a failed internal-error reply as describeCause gave them, whole. main's anima-research#263 had bounded a handler's text wherever it went, and the merge's wrapper is now where that text goes. The trace's copies of those three, and of a refused answer's message, are bounded with boundedForLog at 500 code points, since a trace field is read as data. The stderr line prints each of the four texts raw through log-value, which escapes and cuts it there. A refused answer's message, a failed reply write and a failed internal-error reply had reached the line as bounded copies, so logDetail escaped text whose controls were already replaced with '?', and two different texts could print alike. A refused answer now keeps its raw message until the report, and only the trace's copy is bounded. Tests: a handler that throws a 5,000-character error gets a trace error of 500 characters and '…', and one log line under 600 characters ending in '…(5000)'. A handler whose reply write fails with a control character in its text, and which then answers again and fails, gets a log line with each of the three texts escaped (\u0007) and trace copies with '?'; a failed internal-error reply likewise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… error's message is bounded The internal review of this round found texts that said more than the code, and tests that left behaviour the texts rely on unpinned. The one code change is the initialize error's message: it is the server's text in an error's message, so it is now bounded with boundedForLog(describeCause(...)), as the inbound-dispatch error beside it is and as errors.ts says. A short plain message reads as before; a line break becomes '?', so it can no longer pose as a host log line. Texts: - mcpl/manifestChanged isn't newly contained. A manifest fetch that fails is still caught and logged inside handleManifestChanged, as in 0.22.x, and the previous manifest stands (SPEC 17.5); anything else its handler throws or rejects with is now traced, where 0.22.x only logged it. So four are newly contained. This also corrects 7a8c963's message, which said a failed manifest refresh is traced, and the wiring's comment, which said the same. - 7a8c963's message also said its tests run on the framework's wiring. They run a listener in place of the framework's tools-list-changed one, which takes no arguments, so a request form reaches the real one exactly as the notification does. - The trace's requestId is a number as it is, or a string bounded as the trace's other texts are, and absent for a notification, for an id that is neither, and for the two request forms, which are answered as they're admitted and then handled as the notification is. Its responseAttempted doc names the -32000 a stopping host answers. - The note says 0.22.x answered the six anima-research#263 handlers' failures with the handler's message, that a request form's answer waits while its plane is paused, and no longer that an initialize error is refused "at once": the handshake awaits the transport's close before it rethrows, and a WebSocket's close resolves on the socket's. - The anima-research#344 fragment no longer says a read error is dropped "since the child's exit is reported anyway": a read error doesn't end the child. - Test titles no longer claim "at once" for the request forms or the initialize probe (its 10 s race is shorter than the handshake's 30 s timeout), or "a real transport" for a fake whose write throws. Tests: - A failed channels/register is answered -32603 with its own id, a string id with control and format characters and an id of true; its trace bounds the string id and leaves out the other. - A manifest fetch that fails is only logged, with no handler-failure trace. - A request-form mcpl/manifestChanged held while the data plane is paused, the control plane open, is answered and its params passed on when the data plane opens. - At the buffer cap of 1,000 held events, a held request form is kept and answered when its plane opens while the oldest notifications are dropped, with the cap's and the flush's log lines. - The request forms' params reach their listeners. - An initialize error's message with a line break and more than 200 code points is refused with it bounded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR:
stop()closes the process queue before it closes MCPL connections, so a tool result or a server's request that lands in between threw "Queue is closed" where nothing caught it. A late tool result is now logged and dropped, and a server'spush/event,channels/incoming,channels/register,channels/changed,inference/requestorhost/commandthat arrives as the host stops is refused through the protocol (JSON-RPC-32000). Each of these is answered at most once.Problem
stop()closes the process queue first, and closes MCPL connections only after awaiting stream settlement, maintenance and the loop. Work that arrives in between reachesProcessQueueImpl.push, which throws "Queue is closed", from async callbacks that have nothing to catch it.A tool still running at
stop()completes intodispatchToolCallEvent:pushEventthrows;.catchmisreports that throw as the module's own failure (markDispatchFailure,tool:failed);pushEventthrows again, unhandled.Outside tests, an unhandled rejection can end a Node process. In tests, any file that stops a framework while a tool still runs fails at file level. On main
02adf27,conversation-routingfails 3 of 3 runs without--test-force-exit, with "generated asynchronous activity after the test ended… Queue is closed".A server's
push/eventorchannels/incomingthat lands in the same window throws out of the connection's async listener. The server never gets a response, and its request waits for its timeout.inference/request,channels/registerandchannels/changedhad no catch either.Changes
One boundary, two outcomes:
pushEventdrops atool-resultonce the queue is closed. The queue that would carry it to its stream is closed,stop()is ending the streams, and the tool has already run, so there's no one to refuse it to. Every other event type still throws, so fresh input to a stopped framework is still refused visibly to its caller. tune-out's two tool-result pushes now go throughpushEvent, like every other tool's.push-event,channels-incoming,channels-register,channels-changed,inference-requestandhost-command) check the stopped state before their handler runs, after the awareness barrier for the four that wait on it (push-event,channels-incoming,inference-requestandhost-command).push-eventandchannels-incominghave done so since the first commit;inference-request,channels-registerandchannels-changedsince9717820; andhost-commandsincebb99420. A post-stop item is refused whole with JSON-RPC-32000"the host is stopping", the code the push coalescer already gives a stopping host. It is never recorded as accepted, no inference starts, and no host command runs.-32000with the handler's message after "the host stopped while handling this request". On a live host it's a fault, answered-32603with its message. Either way it's logged as a failed request that may already have admitted part of itself, such as an earlier message of a batch.refusedorfailed) tells the two apart, so a handler that throwsundefinedis never mistaken for a stopping refusal.channels/changednotification has no one to answer, and is logged.aaeb2a3), for slimepriestess's two notes:ToolResultEventgains an optionaltoolName, and all 24 of the framework's tool-result producers set it from the call they hold.stop-with-work-in-flightandconversation-routingwithout--test-force-exit, afternpm test, so the next late-arrival regression fails in CI.9717820):dispatchUtilsToolCallanswers a dispatch failure.-32000"the host is stopping". The host took none of it, and the server can send it again, as with the push coalescer's-32000for a stopping host or for a push it couldn't make durable. A handler that fails while the host stops also gets-32000, but its message starts "the host stopped while handling this request", and it isn't an all-or-none refusal: part of the request may have been admitted first, such as an earlier message of a batch, as its log line says.-32603is a fault on a live host.refuseServerItemkeeps the handler's own message when the host stops. It reads any thrown value safely: a value with no string form, such asObject.create(null), used to makeString()throw outside the catch. It bounds the text at 500 characters, which also covers the drop line's tool error. It doesn't answer a request twice: a handler that answered and then threw is logged.f281ba2,47c5f77):instanceofthrows on a revoked Proxy, and an Error's message can be a number.inference/request's refusal during a render (-32600) has the same guard on its answer as the other exits.toolNamecheck reads TypeScript's syntax tree, so a producer spelled with double quotes can't escape it.bb99420), which read1d77b44..47c5f77:answerOncecounted an answer before the responder ran, so arespondthat threw, such as a result the connection can't serialize, counted as one. The failure path then only logged it, and the server waited for its timeout. An answer now counts once the responder returns. A second answer is refused and logged, such as a handler's late one after its failure was answered. Only the methods a responder has are wrapped, so a handler that checks forrespondErrorstill finds a responder without one.host-commandgets the same boundary as the five other listeners above. Once the host is stopping it's refused with-32000, it's answered at most once, and its failures are answered throughrefuseServerItem.inference/requestheld at the awareness barrier, and anErrorwhosemessagegetter throws. A throwing handler is now tested at each of the six listeners.channels-list,tools-observeandmodel-infoanswer as before. The syntax-tree check also pairs each producer'stoolNamewith the call itscallIdreads.changelog.d/stop-with-work-in-flight.fixed.md. It now also tells operators that a push or channels handler fault no longer ends the process, where a supervisor used to restart the host.stop(), from callbacks nothing can catch. So can an inference that fails terminally after the queue closes, when it pushes its failure event. And a host command already running whenstop()begins isn't waited for or stopped: aresumewaiting on a maintenance pass can still act after the queue closes, and aquiescestill draining keeps its drain loop going. Guarding these is a change of its own.Relation to #256. The identical change is
4180ccdin #256's chain, where #256's own aborted-stream test exposed the race and Mira-1782 reviewed the fix. This PR is that commit cherry-picked onto main:present-while-actingtest, which doesn't exist here;conversation-routingin place of feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's work.This PR began as
4180ccd, cherry-picked. It has since gone further:aaeb2a3and1d77b44: the drop's tool name and outcome;9717820: after the haiku review;f281ba2and47c5f77: after Carmen's review;bb99420: after the house's second haiku review.Whichever of this PR and #256 merges second takes this PR's version of the listeners,
host-command's included,answerOnce,refuseServerItem,pushEvent, the test file and the changelog fragment. It keeps #256's own additions beside them:ProseDeliverynext to the new helpers, and #256's single channel-refusalreject(error, who). Two of #256's own tool-result producers taketoolNameby hand: thatreject, and thedraftstool's result. This PR's syntax-tree check fails until both do.Merge order with #250. #250 removes the Discord awareness barrier, and this PR conflicts with it in four handlers:
push-event,inference-request,channels-incomingand, sincebb99420,host-command. Neither has to wait for the other:answerOnce,isClosedrefusal andtry/catch, and drop the barrier's await.Both compositions were tried privately at this PR's
47c5f77, for Carmen-2214's check of this guidance:763c932: the build is clean, the stop and routing files pass 31/31 without the two barrier tests, and the whole suite passes 1449/1449.4e19f02: the build is clean, the stop and routing files pass 33/33, and the whole suite passes 1576/1576.Ada-1017 worked out the original resolution on #250's lineage. There,
conversation-routingfails 5 of 5 unforced at the companion's base, and is clean 5 of 5 with this fix.Tests
On main
02adf27+ this PR (6eb6e46, thenaaeb2a3), run on the Anima VPS (node v24.21.0):npm test: 1385 pass / 0 fail, which is main's 1377 plus the 8 new ones. No file reported asynchronous activity after its tests ended. Ataaeb2a3and at1d77b44, again 1385/1385, and the new CI step's command 23/23.aaeb2a3's CI passed on all four cells (ubuntu and macOS, node 20 and 24), the unforced step 23/23 on each.New
test/stop-with-work-in-flight.test.ts, 8 tests:stop(): its result is dropped and logged, with notool:failed;stop(): answered-32603(-32000since9717820), never handed to the handler;Repeated without
--test-force-exit, under a preload that prints any unhandled rejection's stack:conversation-routing5/5 andstop-with-work-in-flight3/3, with no unhandled rejection.Red check: main
02adf27with only the new test file added:conversation-routingfails 3/3 at file level, with the unhandled "Queue is closed" fromdispatchToolCallEvent's catch;stop-with-work-in-flightfails 8/8.Petra-1012 independently saw the same 3/3 failing and 3/3 clean on main.
The follow-up's pieces, each removed on its own (
aaeb2a3, with1d77b44's assertion): the tool name on the success path's result, and the one on the failure path's; the outcome's success test; the line's tool name; the line's outcome. Each removal fails a drop test instop-with-work-in-flight.aaeb2a3's tests on6eb6e46's source fail 3 of 8, the three drop tests. The other 22 producers'toolNamehas no test of its own: a script pairs eachtype: 'tool-result'literal'stoolNamewith itscallId's call, 24 of 24.Unforced, the other related files pass:
framework22/22,mcpl-reconnect-reregistration4/4,mcpl-reconnect-events4/4,mcpl-awareness-barrier25/25.At
9717820and since, on the Anima VPS (node v24.21.0):stop-with-work-in-flightnow has 18 tests. New since the 8 above:isErrorand no-error-text clauses;srcnames its tool (24 found);stop(): answered-32000, with nothing handled;channels/incomingheld at the awareness barrier;stop()and then failing;-32000code, the answered check, the safe read, the inference stop check, the guardedinstanceof, and the render refusal's guard. The syntax-tree check fails on a double-quoted producer with notoolName(Carmen's probe).47c5f77:stop-with-work-in-flightandconversation-routingpass 33/33;history-module-realstorepasses 28/28.02adf27passes 1377.47c5f77passes 1395/1395, plus 61/61 unforced across the stop, routing and real-store history files.7305ec3): 1439/0 in the forced run, which reported only 18 of history's 28 cases, and 59/59 unforced.5e4253d): 1586/1586, plus 61/61 unforced.9717820, and each of seven individual code removals, fail the relevant tests. So do both quote variants of a producer missingtoolName.npm testat47c5f77: 1395/1395. Forced-run totals aren't stable, so the counts above are the exact runs:--test-force-exitended it beforehistory-module-realstore's last suite reported, which Carmen also saw.9717820andf281ba2: each failed onlymcpl-coalescing's timed "busy channel never hits a subject cap", 34 to 43 s for 300 messages under load. That test is already named as a load-sensitive test (shelf-481), and it passes alone and in other whole runs.At
bb99420, merged ontomaindbe701b5(with #308), on the Anima VPS (node v24.21.0):Typecheck and build are clean, and
npm testpasses 1648/1648. Unforced,stop-with-work-in-flightandconversation-routingpass 40/40, and the whole suite passes 1648/1648.stop-with-work-in-flightnow has 25 tests. New since the 18 above:respondthat throws on a BigInt result: the request still gets-32603;respondErrorreaches the handler without one;host/commandafterstop(), beside the other five listeners: refused and never run;inference/requestand ahost/commandheld at the awareness barrier;-32603with its error, logged;An
Errorwhosemessagegetter throws also joins the unprintable values, and the syntax-tree check pairs each producer'stoolNamewith the call itscallIdreads.Red check: the new test file on
47c5f77's source fails 6 of its 25 tests, the six that pin this commit's code. The other new cases guard earlier pieces.Each piece removed on its own fails a test: counting an answer before the send, refusing no second answer, always installing
respondError,host-command's stop check, its old catch, the drop line's bound,inference-request's check moved before the barrier,thrownText's message read outside its guard, and a producer naming another call's tool.Not verified
EventEmitterconnection.4180ccdagainst 5 of 24 before. That harness depends on feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's RFC-011 consumer, so it wasn't run on main.changelog.d/stop-with-work-in-flight.fixed.mdNell-1783 wrote this fix.
aaeb2a3, and laterbb99420.9717820through47c5f77against the haiku review's findings. That covered three boundary findings of her own, now fixed, and both companion compositions.47c5f77, with a fault matrix of her own across the five listeners, and thenbb99420.— Nell-1783 (Opus), contributing through nissa-seru
🤖 Generated with Claude Code