Skip to content

fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) - #172

Open
slimepriestess wants to merge 1 commit into
anima-research:mainfrom
slimepriestess:fix/user-interrupt-not-failure-v2
Open

slimepriestess wants to merge 1 commit into
anima-research:mainfrom
slimepriestess:fix/user-interrupt-not-failure-v2

Conversation

@slimepriestess

Copy link
Copy Markdown
Contributor

Supersedes #134 (Lari's "user interrupt is a cancellation, not a failure"), rebuilt on current main after 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 user exactly 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 attachment marker attributed to the user. For a resident whose transcript is memory, that accumulates as false self-knowledge. Now: inference:aborted, lifecycle aborted, no streak, and a marker that says what happened:

[turn-interrupted] Your previous turn was cancelled mid-stream — a deliberate stop, not a failure. Whatever you were still producing when it stopped was cut off there.

The wording is deliberate on two points from the review. No actor: user names the call, not who made it (connectome-host's zombie reclaim and subagent cancel both go through agent.cancelStream()), so the marker never says "by the user". No delivery claim: earlier rounds of the turn may already have been live-routed via enqueueSpeech, 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 with takeCancelReason() 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 own inference:aborted for 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() records shutdown in frameworkCancelledStreams before cancelling, the way endTurn, budget restarts and quiesce already do. Both abort twins (aborted and error, since a cancel can surface as either) settle it modelled on quiesce_abandoned: abortAgentScript + reset + settleAgent + inference-log terminal + one inference:aborted with reason shutdown, no marker. The settle is what the first cut (c23ce76) lacked — it returned after the trace, so an in-flight runEphemeralToCompletion stayed 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". kind stays discord-send-failed for downstream consumers (comment softened per review minor 9: no in-repo consumer keys on metadata.kind today, but surfaces may). "Reached no channel" is exact: other dispatchSpeech handlers are not claimed either way.

5. Membrane floor ^0.5.81. Before it, user could 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. main already 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:

tsc --noEmit                                                      clean
node --import tsx --test  (the two new suites)                    5 / 5
npm run build && npm test  (full suite)                           963 tests, 959 pass, 0 fail, 4 skipped

Revert goes red. Same two suites against main's src/framework.ts + src/agent.ts: 4 fail / 1 pass (the pass is the provider-abort control, which asserts the failure pipeline that main already has).

Mutations, each restored after:

shutdown branch returns without settling (the c23ce76 bug)   1 red: "stop() settles an in-flight ephemeral run promptly"
marker text says "by the user" / "was not delivered"         2 red: both cancel cases
abortInference emits its own trace again                     1 red: "ONE inference:aborted carrying the caller's reason"

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

…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 Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 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-manager 0.10.0, @animalabs/membrane 0.5.85, @animalabs/chronicle 0.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 because api-admin-gate.test.js cannot bind 127.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants