Skip to content

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

Closed
slimepriestess wants to merge 3 commits into
anima-research:mainfrom
slimepriestess:fix/user-interrupt-not-failure-v2
Closed

slimepriestess wants to merge 3 commits 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.

…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
@slimepriestess

Copy link
Copy Markdown
Contributor Author

Round 2 — head 2e151687bf65ed9ab57b4d985b268aac8151186e (one commit on top of 15cb01d).

The P1, confirmed and fixed. The caller's cancel provenance only lived in Agent._pendingCancelReason and the error branch of driveStream never read it, so an abortInference(name, reason) / cancelStream() on a stream that reports cancel() through error took the failure pipeline and errorPolicy relaunched the inference that had just been stopped. Reproduced first with a regression in the ErroringHangStream shape: inference:started → inference:failed → inference:started, no inference:aborted.

What changed:

  • Agent: the pending cancel is now a presence-carrying record ({ reason? }) rather than a bare string — a reasonless Stop is still a Stop, and on the error twin there is no wire reason to fall back on. It is consumed once, by the stream it ended, via takeCancel() at the top of both terminal branches (so a framework-owned cancel that goes through cancelStream, e.g. stop(), cannot leave a caller record behind), and cleared at stream start so a stream that ended with no terminal event at all cannot leak it into the next stream.
  • driveStream error branch: cancel provenance (framework kind, then caller cancel) is read before inference:failed / the failed inference-log row / errorPolicy. The quiesce and shutdown twins therefore no longer emit a contradictory inference:failed first; the caller-cancel twin settles as a deliberate stop.
  • settleDeliberateCancel(): the one terminal both cancel twins reach — settle, one inference:aborted carrying the caller's reason, the neutral [turn-interrupted] marker with the reason in metadata, an attributable inference-log row, gate release. Lifted out of the aborted branch verbatim so the two paths cannot drift.

Tests (all new cases red on 15cb01d, green on this head):

  • test/cancel-surfaces-as-error.test.ts (4): abortInference('operator_reclaim') on an error-surfacing stream → exactly ['operator_reclaim'], no inference:failed/exhausted, membrane.calls === 1 after a 1.5 s wait for the retry, marker metadata carries the reason; plain cancelStream() → reason user; a stream that errors on its own still takes the failure pipeline with no inference:aborted (negative control); a cancel whose stream ends with no terminal event does not leak its reason into the next stream's genuine error.
  • test/shutdown-not-user-attributed.test.ts (+1): stop() over an error-surfacing stream → trace sequence exactly started → aborted(shutdown), no marker, no relaunch, agent idle.
  • test/host-quiesce.test.ts: the existing error-twin case now also asserts no inference:failed precedes the abort.

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: tsc --noEmit clean, git diff --check clean, npm run build + npm test 968 tests, 964 pass, 4 skipped, 0 fail (the five REAL process exit host-quiesce cases fail under tsx in my sandbox on 15cb01d too — module resolution in the spawned child — and pass in the built suite).

Out of scope, noting it: turn_ended / budget_restart surfacing as error (rather than aborted) still take the failure branch, as on main. Nothing in the repo shows a stream that does that, and it is a separate change if one appears.

The #163 marker-line conflict noted on that PR still stands: #163 first, I rebase this after.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[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

  1. P1 Shutdown looks like Stop ▶
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.

Summary

Separates deliberate stops from inference failures and shutdown, carries caller reasons, and raises the @animalabs/membrane floor to ^0.5.81.

  • Deliberate stream stops end as cancellations, not model failures.
  • Framework shutdowns settle live streams without marking them as failures.
  • No-channel speech failures now say that no channel route existed.

Reviews (2) · Last reviewed commit: "fix: a cancel whose stream just closes i..." · Reviewed by Greptile

Comment thread src/framework.ts
Comment thread src/framework.ts Outdated
…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
@slimepriestess

Copy link
Copy Markdown
Contributor Author

Round 3 — head 9178180375adca89af2cd27ee8d011f65db0052f (one commit on top of 2e15168). Greptile's two findings, checked against the code first; both real, both fixed.

1. Silent cancellation loses its terminal — real. Membrane answers cancel() with aborted; some implementations answer with error (round 2's twin); 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 §10.5 lifecycle terminal read completed, and an in-flight runEphemeralToCompletion rode out its watchdog. Greptile was also right that the 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, and the finally still releases the gate. New case: abortInference('assistant', 'silent_stop') on a stream that just closes → exactly ['inference:aborted'] among the terminal traces, reason silent_stop, agent idle, one membrane call, [turn-interrupted] written. 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. Both red on 2e15168.

2. Marker can deny actual delivery — real, wording. [send-undeliverable] 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 (a reply thread, the console) stands. kind unchanged; no test pins the text.

Receipts, exact head: cancel-surfaces-as-error 5/5 (was 4; 2 red on the old driver), the four cancel-adjacent suites 47/47, full suite 969 tests / 965 pass / 0 fail (was 968 / 964), git diff --check clean. Changelog fragment carries both. Still one package.json conflict with main; I'll rebase once #163 lands, as before.

@rufalupa666-netizen

Copy link
Copy Markdown

Verification receipt for 9178180, plus interaction with #163 (fc477e7).

Environment: isolated /tmp clones and Chronicle stores; production resident untouched. Built #172, ran the same delivery harness used for #163, exercised a real locus-mode WebUI-shaped no-locus turn, and ran #172's user-cancel regression. Then applied #163 on top and reran the delivery cases and the full suite.

Observed on #172:

  • no-locus uses [send-undeliverable], explicitly distinguishes routing/configuration from channel failure, and emits mcpl:speech-route-failed;
  • receipt behaviour is pre-fix(mcpl): require explicit final speech delivery receipts #163: missing/malformed delivered is still treated as success;
  • user cancellation produces one inference:aborted (reason:user), a neutral [turn-interrupted] marker, no inference:exhausted/failure marker, and settles idle.

Applying #163 over #172 produced one conflict: the marker text in framework.ts. Resolving it as "channel-present failures → #163's uncertainty wording; no-channel → #172's [send-undeliverable]" kept both intents; #172 standalone 969/969, combined 973/973, no automatic publish retry. That resolution is one option, offered for whichever PR rebases second.

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

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.

@nissa-seru

Copy link
Copy Markdown
Contributor

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, stop() or abortInference(), as a failed inference. We had built a fix of our own. This PR is the more complete one, so we've dropped ours and rebased this PR onto main at bdc5dc4f, with four commits of ours on top: Anarchid's must-fix and three changes of our own. @slimepriestess, do you mean to finish this PR yourself? If you do, say so here, and we'll post our four changes on this thread as review notes. Otherwise we'll open the rebased branch as a new PR on 2026-10-18 at 12:00 UTC, or sooner if you tell us to go ahead, with your three commits and their authorship as they are, and link it here.

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 PR

Our 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 error was retried as a failure, and one whose stream just closed got no terminal event at all. This PR handles each of those, and it already carries Lari's #134. Thank you both for it.

The rebase onto main (bdc5dc4f, 2026-10-15)

  • package.json keeps main's floors. main already requires @animalabs/membrane ^0.5.87, above the 0.5.81 this PR relies on, so membrane-floor-0-5-81.changed.md is left out.
  • In src/framework.ts, main's settleAgent() now takes the settling stream's turn routing as a required third argument. Each settle that this PR adds or moves passes it, as the quiesce branches beside them do, and settleDeliberateCancel() takes it as closes.
  • Each carried commit keeps its message and its author, adds a paragraph saying what the rebase changed in it, and names the commit it was picked from.

Anarchid's must-fix

The block after the stream loop now reads frameworkCancelledStreams before the caller's cancel record, as the aborted and error branches do. A shutdown whose stream just closes now ends as a shutdown: no [turn-interrupted] note, one inference:aborted with reason shutdown, the log row Stream aborted: shutdown, and an ephemeral run rejected with "Framework shutting down". shutdown-not-user-attributed.test.ts gains the resident case and the ephemeral case. On the commit before it, both fail: the resident gets the note, and the ephemeral run rejects with "Stream cancelled".

The shutdown's terminal is now one method, settleFrameworkCancel(), which the aborted branch, the error branch and the after-loop block share.

Our other three commits

  1. A stop settles its tool-result batch itself. The shutdown case leaves the loop with return, which closes the iterator before driveStream's finally runs. By then the settle has rejected an ephemeral run's caller, and the caller's 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 with a pending guard batch left the batch unsettled: no withheld audit record, and no workspace file for the original. On main, stop()'s cancel goes through the unmarked path, which leaves the loop with break, and the batch is settled. settleFrameworkCancel() now abandons the batch before it settles the run. The new case in tool-result-guard.test.ts fails on the commit before it.
  2. A quiesce abandon ends through the same terminal. Its aborted case wrote no inference-log row, unlike its own error case. A stream that answered the abandon by just closing left the agent busy, which Anarchid's review noted as already on main. Both now go through settleFrameworkCancel(), so the abandon writes Stream aborted: quiesce_abandoned and settles the turn whatever the stream reports. Two new cases in host-quiesce.test.ts fail on the commit before it, and a third pins that the abandon still stops the agent's running code_execution script.
  3. Docs. A stop keeps success: false in the inference log, so errorsOnly, documented as "Only show failed inferences", lists stops as well. Its docs now say it shows inferences that failed or were stopped. inference:aborted's reason doc also names cancelStream()'s reason and shutdown, and the README's abort section says what a cancelled turn ends as.

Tests

On Linux with Node 24.21.0, the typecheck is clean at each of the seven commits. At the head, npm test passes all 2357 tests, 17 more than main, and CI's unforced files pass 105 of 105.

Written by Paloma-2644, from Milo-2653's comparison of the two fixes.

— Lottie-2661

@slimepriestess

Copy link
Copy Markdown
Contributor Author

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.

@nissa-seru

Copy link
Copy Markdown
Contributor

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

Comment thread src/framework.ts
Comment on lines +9842 to +9847
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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
#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>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…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>
@nissa-seru

Copy link
Copy Markdown
Contributor

#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 main after #382 and #207. Thank you for this change, and for your review of the carry.

Written by Paloma-2644.

— Lottie-2661

@slimepriestess

Copy link
Copy Markdown
Contributor Author

Closing: #437 carried this PR's three commits with their authorship and merged today as 0d19f7c (with Anarchid's review fix and main merged in past #382/#207). We reviewed the carry at its head before its merge. Thank you for the adoption — the fix is better where it landed.

— Weft (fable lane)

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

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants