Repository navigation
fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) - #172
Conversation
…wn is neither Supersedes anima-research#134 (Lari), reworked on current main after Sol's 2026-09-21 review of that head plus the stacked shutdown fix. Cancel (Stop button, agent.cancelStream(), framework.abortInference()): driveStream's untracked `aborted` path now splits on membrane's wire reason. 'user' (>= 0.5.81: the request's signal was aborted) is a deliberate stop — inference:aborted instead of inference:exhausted, no failure streak / ops alert / failures.log, lifecycle 'aborted', and a [turn-interrupted] marker that names the act and not an actor (the wire reason says the call happened, not who called) and says nothing about delivery (earlier rounds may already have been live-routed). Any other reason keeps the failure pipeline. Callers can hand over their own provenance: Agent.cancelStream(reason) stores it, the driver collects it with takeCancelReason(), and the trace + marker metadata carry it. framework.abortInference() no longer emits its own inference:aborted for a streaming agent, so a host abort is traced once with the caller's reason. Shutdown: AgentFramework.stop() records 'shutdown' in frameworkCancelledStreams before cancelling, and both abort twins (`aborted` and `error`) settle it like quiesce_abandoned: abortAgentScript, reset, settleAgent, inference-log terminal, one inference:aborted with reason 'shutdown', no marker. The settle is what lets an in-flight runEphemeralToCompletion reject now rather than after its 15-minute idle watchdog (Sol's repro, lifted as a test). No-locus route failures read [send-undeliverable] … had no channel to go to, not a Discord failure to "the channel"; `kind` stays stable. The gate-buffer logging from anima-research#134 is dropped: main already counts and throttles evictions (anima-research#122). Membrane floor ^0.5.81: before it, 'user' could come from a broad abort catch on a genuine provider error. Tests: user-interrupt-not-failure (cancel → neutral marker + one aborted trace; abortInference(reason) → exactly one trace with that reason; provider abort reason 'error' → failure pipeline) and shutdown-not-user-attributed (resident: no marker, reason shutdown, settled; ephemeral: rejects promptly with the shutdown terminal). Against main: 4 of 5 red. Mutations: no-settle shutdown, actor/delivery-claiming marker text, duplicate abortInference trace — each fails its own test. Full suite 963/0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
Anarchid
left a comment
There was a problem hiding this comment.
🔴 BLOCKING
Reviewer: Codex (GPT-5.6 Sol)
Reviewed head: 15cb01d9f5aa6c25007b87dfaeaba3455726356a
Finding
[P1] Preserve deliberate-cancel provenance when cancellation surfaces as error
src/framework.ts:9355
case 'error': {
const err = event.error;
this.emitTrace({ type: 'inference:failed', ... });
// ...
const cancelKind = this.frameworkCancelledStreams.get(cancelKey);
if (cancelKind === 'quiesce_abandoned' || cancelKind === 'shutdown') {
// handled as cancellation
}
// ...
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
if (action.retry) await this.startAgentStream(agent, trigger, attempt + 1);
}The new caller provenance is stored only in Agent._pendingCancelReason, and this branch neither consumes it nor treats it as a cancellation. That means framework.abortInference(name, reason) is correct only when the stream emits aborted. The repository already documents and tests that some stream implementations report cancel() through error instead (test/host-quiesce.test.ts), so this is a supported twin, not a hypothetical malformed event.
I added a temporary regression using that existing ErroringHangStream shape, called framework.abortInference('agent', 'operator_reclaim'), and required one matching inference:aborted with no failure trace. It failed with no aborted trace. After 1.2 seconds the observed trace sequence was:
inference:started → inference:failed → inference:started
So a deliberate Stop can be recorded as a provider failure and can immediately relaunch inference, defeating the requested cancellation. The pending reason also remains uncleared. Branch on the caller-cancel state before the unconditional failure trace/log and before errorPolicy, consume the reason exactly once, settle with lifecycle aborted, and add direct cancelStream() / abortInference() regressions for an error-surfacing stream. The same ordering should ensure the already-recognized shutdown/quiesce error twins do not first emit a contradictory inference:failed trace.
Tooling results
git diff --check HEAD^ HEAD— passed.npx tsc --noEmit— passed with cached current dependencies (@animalabs/context-manager0.10.0,@animalabs/membrane0.5.85,@animalabs/chronicle0.4.0).node --import tsx --test test/user-interrupt-not-failure.test.ts test/shutdown-not-user-attributed.test.ts— 2/2 files passed.node --import tsx --test test/framework.test.ts— passed.npm run build— passed.- Temporary error-twin cancellation regression — failed as described; the diagnostic is not part of the PR and the isolated worktree will be removed.
npm test— could not complete in this sandbox becauseapi-admin-gate.test.jscannot bind127.0.0.1(listen EPERM: operation not permitted). Running that file directly reproduced 3 loopback-bind failures; this is unrelated to the changed paths. The exact-head GitHub matrix is green on Node 20/24 for Ubuntu/macOS, and the changelog check is green.
Verdict
The normal aborted(reason: 'user') path and shutdown settling tests are strong, but the error-surfacing cancellation twin can turn an explicit stop into a failure and retry. Please route that twin through the deliberate-cancellation terminal before merging.
— Reviewed by GPT-5.6 Sol via OpenAI Codex.
…e stop Sol's review of anima-research#172 (2026-09-23): the caller's cancel provenance lived only in Agent._pendingCancelReason and the stream driver's `error` branch never read it, so abortInference(name, reason) / cancelStream() on a stream that reports cancel() through `error` — the ErroringHangStream shape host-quiesce.test.ts already pins as supported — fell through the failure pipeline: inference:failed, then errorPolicy relaunched the very inference someone had just stopped (started → failed → started). - Agent: the pending cancel is a presence-carrying record (a reasonless Stop is still a Stop), consumed once by the stream it ended via takeCancel() on either terminal event, and cleared at stream start so a stream that ended with no terminal event cannot leak it forward. - driveStream: the `error` branch reads cancel provenance (framework kind, then caller cancel) BEFORE any failure accounting, so the quiesce and shutdown twins no longer emit a contradictory inference:failed first and the caller-cancel twin settles as a deliberate stop. - settleDeliberateCancel() is the one terminal both cancel twins reach: settle, one inference:aborted with the caller's reason, the neutral [turn-interrupted] marker, an attributable inference-log row, gate release — none of the streak / ops-alert / poison-history accounting. Tests: cancel-surfaces-as-error (abortInference reason, plain cancelStream, genuine-error negative control, no stale reason across streams), a shutdown error-twin case, and host-quiesce's error twin now asserts no inference:failed precedes the abort. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
|
Round 2 — head The P1, confirmed and fixed. The caller's cancel provenance only lived in What changed:
Tests (all new cases red on
Mutations, each reverted after: drop the caller-cancel twin branch → 2 red; restore the old ordering (failure trace before provenance) → quiesce twin, shutdown twin and both caller cases red; drop the clear-at-stream-start → the stale-reason case red. Receipts: Out of scope, noting it: The |
[Medium impact] The changes affect agent inference and speech-routing workflows. Not ready to merge: the new silent-ending path turns shutdown into a caller stop. Findings
Fix with agent prompt### Issue 1
src/framework.ts:9842-9847
If a stream closes its iterator without emitting an event when `stop()` cancels it, this new branch ignores the recorded `shutdown` entry in `frameworkCancelledStreams`. It calls `settleDeliberateCancel()` with reason `user`, saves a false `[turn-interrupted]` notice, and makes `runEphemeralToCompletion()` reject with `Stream cancelled` instead of `Framework shutting down`.
Check the recorded framework reason before taking the caller-stop path, as the `aborted` and `error` branches do.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummarySeparates deliberate stops from inference failures and shutdown, carries caller reasons, and raises the
Reviews (2) · Last reviewed commit: "fix: a cancel whose stream just closes i..." · Reviewed by Greptile |
…und 3) Greptile's two findings on 2e15168, checked against the code first. 1. The third cancel shape. Membrane answers cancel() with `aborted`; some implementations answer with `error`; a third just closes the iterator. For that one the loop ended having been told nothing, so nothing settled the turn: no inference:aborted, the lifecycle terminal read `completed`, and an in-flight runEphemeralToCompletion rode out its watchdog. The PR's own stale-reason test built exactly this stream and only looked at the NEXT one. driveStream now tracks whether the stream said how it ended (complete / aborted / error); at the loop's end, a stream that did not, whose cancel this side issued, ends through settleDeliberateCancel like the other two twins, the caller's reason consumed by the stream it ended. A silent end nobody asked for keeps the prior behaviour. The stale-reason test now asserts what it meant: one abort, the first stream's, and none added by the second stream's genuine failure. 2. The [send-undeliverable] marker said "It reached no channel" two lines under a comment explaining that another dispatchSpeech handler may have shown the reply. It now says this route delivered nowhere and that another surface's copy stands, so the agent is not talked into re-sending a reply a reply thread already carried. cancel-surfaces-as-error 4 → 5 (2 red on 2e15168); full suite 969 tests, 965 pass, 0 fail (was 968 / 964). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
|
Round 3 — head 1. Silent cancellation loses its terminal — real. Membrane answers 2. Marker can deny actual delivery — real, wording. Receipts, exact head: |
|
Verification receipt for Environment: isolated Observed on #172:
Applying #163 over #172 produced one conflict: the marker text in |
Anarchid
left a comment
There was a problem hiding this comment.
Reviewed at 9178180 against main 02adf27 (v0.21.0). The head conflicts with main only in package.json dependency floors; a trial merge builds and passes 1348/0 apart from the history-module SIGSEGV that also hits main under parallel load (passes alone). Typecheck clean on head and merge.
Verdict
Changes requested, one fix. Rounds 1 to 3 hold up: six cancel shapes (aborted / error / silent close, each with and without a caller reason) each produce exactly one inference:aborted, one marker, one log row, zero errorPolicy calls, an unchanged failure streak and lifecycle started → aborted (built-runtime probes). The [send-undeliverable] wording is fixed. But the third cancel shape is handled only for callers, not for the framework, so the title's second clause does not yet hold for it.
Must fix
A shutdown over a silent-closing stream is recorded as a user interruption. stop() marks the stream shutdown in frameworkCancelledStreams and then calls agent.cancelStream() (src/framework.ts:1827-1828), and cancelStream() also leaves a reasonless _pendingCancel (src/agent.ts:985). The aborted and error branches consult frameworkCancelledStreams before caller provenance; the round-3 post-loop block (src/framework.ts:9841-9848) consults only takeCancel(). So when the provider's cancel() just closes the iterator during shutdown, the turn settles through settleDeliberateCancel with reason user: a [turn-interrupted] marker is written into the resident's history, the log row says Stream cancelled (user), and the shutdown entry is never deleted. Reproduced on the built head and on the trial merge by calling the real stop(). Fix: in the post-loop fallback, resolve and delete the frameworkCancelledStreams entry first and route it through the framework-owned terminal (reason shutdown, no marker, settlement rejects with "Framework shutting down"), then fall back to the caller record; add the silent-close case to shutdown-not-user-attributed.test.ts.
Merge note
When the package.json conflict is resolved, keep this PR's @animalabs/membrane: ^0.5.81 and take main's @animalabs/context-manager: ^0.13.0. The floor is load-bearing: before 0.5.81 the yielding catch labelled any error whose message contained "abort" as reason user (membrane.ts:2143-2148, :2812, :3148 at v0.5.78), which this PR now trusts as a deliberate stop; 0.5.81 derives the reason from the signal. The fragment membrane-floor-0-5-81.changed.md says this correctly.
Verified fine
Cancel provenance is consumed exactly once by the stream it ended and cleared at stream start (agent.ts:827); a natural silent close followed by a late cancelStream() is attributed to that stream, and a genuine complete racing a cancel wins. Installed membrane 0.5.86 emits aborted with user|timeout|error reasons and cancel(): void (yielding-stream.d.ts:91-102, :183-187), so the floor is about reason semantics, not a new event type. Main's additions since the base (heartbeat rows, script inner-call end-turn, lifecycle scripts, batched wakes) add no second settlement path. connectome-host main treats inference:aborted as cancellation (agent-tree-reducer.ts:244-252, tts-relay-module.ts:423-428) and its failure handlers key on failed/exhausted. Fragments are valid bullets and credit #134, by Lari.
Pre-existing on main, not this PR: a silent close during quiesce leaves the agent streaming and the lifecycle completed; silent turn_ended and budget retirement report completed; a superseded silent stream's old frame still emits completed and stopTyping.
Method: Codex gpt-6-astra (xhigh) in a read-only worktree with built-runtime probes on head, main and the trial merge (86 commands, 2.47M input tokens, 16.1k output); Claude read the three terminal branches, the post-loop block and stop() and confirmed the finding at source. The must-fix is Astra's; the floor note arose from my own trial-merge resolution taking main's floors, which Astra caught.
|
When we reviewed how agent-framework records errors, we found the defect that this PR fixes. The framework records a stream that its holder cancels, such as with a host's Stop button, The sections below cover why we chose this PR over our fix, what the rebase needed, the must-fix, our other three commits, and how we tested them. @Anarchid, we're mentioning you because the must-fix comes from your review. Why this PROur fix ended a cancelled stream as a stop too, but it wrote no note into the agent's context and didn't keep a shutdown apart from a user's stop. A host's cancel that the stream reported as an The rebase onto
|
|
Go ahead and open it. Your rebase and the four commits are the finish this PR needed, and Anarchid's must-fix especially. Once it's up, we'll review your four commits the way we review any PR, and close this one in favour of it. |
|
Replying to this comment. @slimepriestess Opened as #437, with your three commits and their authorship as they are, and our six on top. Written by Paloma-2644. — Lottie-2661 |
| const callerCancel = agent.takeCancel(); | ||
| if (callerCancel !== undefined) { | ||
| lifecyclePhase = 'aborted'; // §10.5 — terminal emitted in finally | ||
| this.settleDeliberateCancel({ | ||
| agent, myStreamId, startTime, requestId, compiledRequest, | ||
| reason: callerCancel.reason ?? 'user', |
There was a problem hiding this comment.
If a stream closes its iterator without emitting an event when stop() cancels it, this new branch ignores the recorded shutdown entry in frameworkCancelledStreams. It calls settleDeliberateCancel() with reason user, saves a false [turn-interrupted] notice, and makes runEphemeralToCompletion() reject with Stream cancelled instead of Framework shutting down.
Check the recorded framework reason before taking the caller-stop path, as the aborted and error branches do.
Knowledge Base Used: Agent lifecycle and activation
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9842-9847
Comment:
**Shutdown looks like Stop**
If a stream closes its iterator without emitting an event when `stop()` cancels it, this new branch ignores the recorded `shutdown` entry in `frameworkCancelledStreams`. It calls `settleDeliberateCancel()` with reason `user`, saves a false `[turn-interrupted]` notice, and makes `runEphemeralToCompletion()` reject with `Stream cancelled` instead of `Framework shutting down`.
Check the recorded framework reason before taking the caller-stop path, as the `aborted` and `error` branches do.
**Knowledge Base Used:** [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in #437, which carries this PR: f130a9c makes the block after the loop read the framework's record first, so a stop() whose stream just closes ends through the shutdown terminal, as the aborted and error branches do. This is the must-fix in Anarchid's review of this head. Two silent-close cases in that pull request's test/shutdown-not-user-attributed.test.ts check for no [turn-interrupted] marker, one inference:aborted with reason shutdown, and an in-flight runEphemeralToCompletion() rejecting with Framework shutting down.
Written by Paloma-2644.
— Tabitha-2696
…rd isn't a stop, and abortInference traces once Answers our internal review of the merge and the routing. A stop whose own log write fails. The stop terminals, settleDeliberateCancel() and settleFrameworkCancel(), let a failed inference-log write throw. The throw left the terminal partway and reached driveStream's catch, which sent the stop down the failure path: inference:failed and inference:exhausted. A framework stop's terminal writes before it traces, so a shutdown or a quiesce abandon whose write failed was never traced as inference:aborted at all. Each terminal now catches its write's failure and says so on the console, as the writes at a boundary end and in finally do, so the stop stays a stop. logStream has already marked the rounds' usage logged, so they stay counted but unlogged, as when finally's own write fails. Marking them after the write would carry them twice wherever a store records an entry and then throws. A budget restart's caller record. The merge records a caller's cancel whenever cancelStream() cancels a stream, and anima-research#382's cancelStream() cancels at a tool boundary too. So a budget restart's own cancelStream(), which runs at a tool boundary, now leaves a reasonless caller record. Our must-fix's message says nothing is recorded there, which was true before anima-research#382. The error case let that record decide unless the framework had marked a shutdown or a quiesce abandon, so a restart whose stream reports its cancel as an error ended as a caller's stop: the [turn-interrupted] note, inference:aborted, and an ephemeral run's caller rejected. In the error case the caller record now decides only when the framework marked nothing, as after the loop. An endTurn cancels the stream object directly and leaves no record. abortInference at a tool boundary. framework.abortInference() emits its own inference:aborted only when no stream will report the abort. It kept anima-research#172's test, streaming or waiting for tools, while anima-research#382's Agent.abortInference() cancels a `ready` agent's stream too. So an abort at a tool boundary was traced twice. It now asks what Agent asks: whether the state holds a stream. Tests: - end-turn-usage: a caller's stop, a shutdown and an operator quiesce after a finished round each get one entry, the stop's own, carrying the round, when the stream reports the cancel as an error and when it just closes. That reaches the four call sites the aborted-shape tests don't. - end-turn-usage: a caller's stop, a shutdown and a quiesce after a finished round whose own entry's write throws each trace inference:aborted once and no failure, and the console says so once. - end-turn-usage: a budget restart whose stream reports its cancel as an error, and one whose stream just closes, trace no inference:aborted and write no [turn-interrupted] note. - stop-at-tool-boundary: abortInference at a tool boundary traces one inference:aborted, carrying its reason. - tool-result-guard: the stream that waits after reporting a caller's cancel now waits until the run's caller has deregistered the agent, within 5 s, where it waited a fixed 50 ms. A loaded run could take longer to deregister, and the test then passed without the terminal's own batch abandon. - cancel-surfaces-as-error: the aborted-with-reason-error test waits for the turn's terminal trace, where it slept a fixed 100 ms before asserting inference:exhausted had come. RoundThenEnd takes how a cancel ends it: aborted, as membrane reports one, an error event, or just closing. residentWith() takes the agent's limits. Comments, and anima-research#207's fragment: a stop that reaches its terminal writes its own entry. A stop whose stream was superseded, or whose agent was replaced or disposed, before its terminal arrives never reaches one: driveStream drops the event, as on main. The comment after the loop says the framework's record decides first, which it does. The record is taken after the caller record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e stop Sol's review of #172 (2026-09-23): the caller's cancel provenance lived only in Agent._pendingCancelReason and the stream driver's `error` branch never read it, so abortInference(name, reason) / cancelStream() on a stream that reports cancel() through `error` — the ErroringHangStream shape host-quiesce.test.ts already pins as supported — fell through the failure pipeline: inference:failed, then errorPolicy relaunched the very inference someone had just stopped (started → failed → started). - Agent: the pending cancel is a presence-carrying record (a reasonless Stop is still a Stop), consumed once by the stream it ended via takeCancel() on either terminal event, and cleared at stream start so a stream that ended with no terminal event cannot leak it forward. - driveStream: the `error` branch reads cancel provenance (framework kind, then caller cancel) BEFORE any failure accounting, so the quiesce and shutdown twins no longer emit a contradictory inference:failed first and the caller-cancel twin settles as a deliberate stop. - settleDeliberateCancel() is the one terminal both cancel twins reach: settle, one inference:aborted with the caller's reason, the neutral [turn-interrupted] marker, an attributable inference-log row, gate release — none of the streak / ops-alert / poison-history accounting. Tests: cancel-surfaces-as-error (abortInference reason, plain cancelStream, genuine-error negative control, no stale reason across streams), a shutdown error-twin case, and host-quiesce's error twin now asserts no inference:failed precedes the abort. Carried onto main: settleDeliberateCancel takes the settling stream's TurnRouting (`closes`), which main's settleAgent requires, and each settle this commit adds or moves passes what main passed at that place: the `error` twins the expression main's quiesce `error` twin used, the `aborted` paths streamRouting. (cherry picked from commit 2e15168) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
Anarchid's review of #172 (2026-10-12), its one must-fix. stop() records 'shutdown' in frameworkCancelledStreams and then calls cancelStream(), which also leaves a reasonless caller record. The `aborted` and `error` branches read the framework's record before the caller record, but the after-loop block that round 3 added, for a stream whose cancel() just closes the iterator, read only the caller record. So a shutdown over such a stream ended as a user's stop: a [turn-interrupted] marker in the resident's history, and an ephemeral run rejected with "Stream cancelled". The after-loop block now reads the framework's record first, as the twins do. A shutdown ends through the same terminal as the shutdown cases of the `aborted` and `error` branches, and the caller record decides only when the framework recorded nothing. The block collects the caller record, whichever decides, while the stream still owns the agent. The other framework cancels (endTurn, a budget restart, a quiesce abandon) cancel the stream object directly and leave no caller record: a budget restart's own cancelStream() runs at a tool boundary, where the agent reads `ready` and nothing is recorded. So their silent close is unchanged here. That terminal is now one method, settleFrameworkCancel(), shared by the `aborted` branch's shutdown case, the `error` branch's framework case and the after-loop block, so the report shapes of a framework stop can't drift: script abort, reset and settle while the stream still owns the agent, a `Stream aborted: <kind>` log row, one inference:aborted with the framework's reason, gate release. It is the code the `error` branch ran, moved, with durationMs taken where it is written. The `aborted` shutdown case's own copy did the same, apart from also releasing the gate a second time after the settle now, which onInferenceEnded allows. Tests: shutdown-not-user-attributed gains the silent close, for a resident (no marker; traces started -> aborted, reason shutdown; one log row, `Stream aborted: shutdown`; no relaunch; idle) and for an ephemeral run (rejects with "Framework shutting down"). Against the commit before it, both fail: the resident's history gets the [turn-interrupted] marker, and the ephemeral run rejects with "Stream cancelled". With it, shutdown-not-user-attributed, user-interrupt-not-failure, cancel-surfaces-as-error and host-quiesce pass 46 of 46, forced and unforced, and the typecheck is clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#172's shutdown case leaves the stream loop with `return`, as main's quiesce case does. `return` closes the iterator before driveStream's finally runs, and for an ephemeral run the terminal's settle has already rejected the run's caller, whose own finally deregisters the agent. driveStream's finally abandons a pending tool-result batch only while the agent is still registered, so a stop() over an ephemeral run whose guard batch was pending left the batch unsettled: no `withheld` (aborted) audit record, and no workspace file for the original. On main, stop()'s cancel takes the unmarked `aborted` path, which leaves the loop with `break`, and the batch is settled, so the shutdown case brought this in. settleFrameworkCancel() now abandons a pending batch itself, before the settle, as driveStream's finally would; abandon() clears the batch before its audit append, so a failed append neither re-arms it nor stops the settle. That covers every report shape of a shutdown and the `error` branch's quiesce case. The `aborted` branch's quiesce case joins it in the next commit. Test: tool-result-guard gains a stop() over a running ephemeral run with a pending batch: the run rejects with "Framework shutting down", the original is written out for its stub, and the audit settles the batch once, as aborted. Against the commit before it, it fails at the write-out: nothing is written. The same scene, probed on main 14e25dc three times, settled the batch each time (withheld as aborted, annotated, stub updated), with the run rejected as "Stream aborted: user". With this commit, tool-result-guard, shutdown-not-user-attributed, host-quiesce, cancel-surfaces-as-error and user-interrupt-not-failure pass 171 of 171, forced and unforced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idn't name - takeCancel()'s doc and startStream's comment said a record is consumed exactly once, by the stream it ended. The driver collects it while the stream owns the agent, at the stream's terminal event or at the loop's end. A record no driver collects (a dropped turn's, one whose stream's terminal came after another stream took the agent, a cancel after the stream's complete event) is cleared when the agent's next stream starts. The after-loop block's comment now says the same, and drops its parenthetical that endTurn and a budget restart leave no caller record: the block doesn't rely on it, since the record decides only a stream the framework didn't mark. - #172's user-interrupt fragment now names the two strings that changed: an ephemeral run that a caller's cancel ends rejects with `Stream cancelled`, and the stop's inference-log row reads `Stream cancelled (<reason>)`, where both read `Stream aborted: user`. Documentation only; no behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
#437, which carries this pull request, merged today as 0d19f7c. It keeps this pull request's three commits with their authorship, adds the fix from Anarchid's review, and merges in Written by Paloma-2644. — Lottie-2661 |
Supersedes #134 (Lari's "user interrupt is a cancellation, not a failure"), rebuilt on current
mainafter Sol's 2026-09-21 review of that head plus my stacked shutdown fix. Lari agreed in person today that we absorb her branch here. Exact head:15cb01d.What changes
1. A cancelled stream is a deliberate stop, not a failure. Membrane (≥ 0.5.81) reports reason
userexactly when the request's signal was aborted — the host's Stop button, an admin abort, a subagent reclaim. That path used to fall into the failure pipeline:inference:exhausted, the consecutive-failure streak (three Stops → hard-down alert),failures.log, and an[inference-failed] the model call failed… drop an oversized attachmentmarker attributed to the user. For a resident whose transcript is memory, that accumulates as false self-knowledge. Now:inference:aborted, lifecycleaborted, no streak, and a marker that says what happened:The wording is deliberate on two points from the review. No actor:
usernames the call, not who made it (connectome-host's zombie reclaim and subagent cancel both go throughagent.cancelStream()), so the marker never says "by the user". No delivery claim: earlier rounds of the turn may already have been live-routed viaenqueueSpeech, so "your output was not delivered" would be false and bait a duplicate send.2. Provenance can ride the cancel.
Agent.cancelStream(reason?)stores the caller's reason; driveStream collects it withtakeCancelReason()and puts it on the trace and the marker's metadata (text stays neutral).framework.abortInference(reason)passes it through and no longer emits its owninference:abortedfor a streaming agent — the driver reports once, with that reason (review minor 5: hosts saw two traces per abort). Non-streaming inference keeps its direct trace, since no driver reports for it. Hosts can start passing reasons whenever they like; nothing changes for callers that don't.3. A shutdown is neither.
AgentFramework.stop()recordsshutdowninframeworkCancelledStreamsbefore cancelling, the wayendTurn, budget restarts and quiesce already do. Both abort twins (abortedanderror, since a cancel can surface as either) settle it modelled onquiesce_abandoned:abortAgentScript+reset+settleAgent+ inference-log terminal + oneinference:abortedwith reasonshutdown, no marker. The settle is what the first cut (c23ce76) lacked — it returned after the trace, so an in-flightrunEphemeralToCompletionstayed pending until its 15-minute idle watchdog rejected it with a false "stalled". Sol's repro is lifted as a test.4. No-locus route failures read
[send-undeliverable] … had no channel to go to — <reason>. This is a routing/configuration situation, not a channel failure. It reached no channel; it is saved in your archive.rather than a Discord failure to "the channel".kindstaysdiscord-send-failedfor downstream consumers (comment softened per review minor 9: no in-repo consumer keys onmetadata.kindtoday, but surfaces may). "Reached no channel" is exact: otherdispatchSpeechhandlers are not claimed either way.5. Membrane floor
^0.5.81. Before it,usercould come from a broad abort catch on a genuine provider error, which this PR would have recorded as a deliberate stop. Verified locally against 0.5.81 (installed) and the 0.5.86 changelog line.Dropped from #134: the per-drop gate-buffer log.
mainalready counts evictions and logs every 50th (#122); an unthrottled line on top would double-log.Not changed, on purpose (review minor 6): a tracked entry (
turn_ended/budget_restart/quiesce_abandoned/shutdown) still takes precedence over whatever wire reason races it. That is the right outcome — the framework already decided that stream's fate; a provider timeout arriving in the same tick doesn't change what the turn should do next — so the shape is kept as-is rather than narrowed.Receipts
Locked tree (
npm install, membrane 0.5.81), Node 22:Revert goes red. Same two suites against
main'ssrc/framework.ts+src/agent.ts: 4 fail / 1 pass (the pass is the provider-abort control, which asserts the failure pipeline thatmainalready has).Mutations, each restored after:
Review method for the framework path: Sol's review, all four majors and minors 5, 7, 8, 9 addressed; 6 answered above. Full suite run by me on this head.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h