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.
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