fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations#90908
Conversation
|
Codex review: needs maintainer review before merge. Reviewed June 27, 2026, 12:39 PM ET / 16:39 UTC. Summary PR surface: Source -13, Tests +139. Total +126 across 5 files. Reproducibility: yes. at source level: current main omits abortSignal forwarding at the compaction and cron runWithModelFallback callsites, and the PR adds regression tests for those paths. I did not execute tests because this was a read-only checkout review. Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Maintain one clear abort/fallback contract: if this PR is the chosen path, land it after exact-head checks and fold or close the overlapping abort/fallback work accordingly. Do we have a high-confidence way to reproduce the issue? Yes at source level: current main omits abortSignal forwarding at the compaction and cron runWithModelFallback callsites, and the PR adds regression tests for those paths. I did not execute tests because this was a read-only checkout review. Is this the best way to solve the issue? Unclear as the final landing path: compact/cron forwarding is a narrow callsite fix, but current main already classifies the exact provider abort text and maintainers need to choose one overlapping abort/fallback contract. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against ce15f348bbc4. Label changesLabel justifications:
Evidence reviewedPR surface: Source -13, Tests +139. Total +126 across 5 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Addressed ClawSweeper P1 (upstream abort signal guard). The Fix: extended the guard to also check Added 6 new tests (one per 195 tests pass total. @clawsweeper re-review |
This comment has been minimized.
This comment has been minimized.
|
Addressed Codex review 🔴 Bug — FallbackSummaryError abort-exhausted path. Root cause confirmed by Codex review: Fix:
Cancellation semantics preserved by checking Tests added (12 new cases, 2 × it.each(NON_DIRECT_FAILURE_SURFACE_CASES)):
207 tests pass total. @clawsweeper re-review |
This comment has been minimized.
This comment has been minimized.
|
Addressed ClawSweeper P2 (over-broad timeout predicate) and P1 (after-fix proof). P2 fix: The FallbackSummaryError branch now requires every timeout attempt to ALSO match the abort-text pattern ( P1 proof: Added inline logic verification script showing all 6 classification cases. The only case that changes from SILENT → visible-error is the target bug (FallbackSummaryError all-abort-text-timeouts). Ordinary timeouts, mixed failures, and real cancellations all remain SILENT. Full output in updated PR body. 213 + 78 = 291 tests pass total. @clawsweeper re-review |
This comment has been minimized.
This comment has been minimized.
|
Patched-runtime proof (P1 — redacted, no API keys or user data): Applied the two dist patches manually to the installed gateway binary ( The script reads the actual patched @clawsweeper re-review |
This comment has been minimized.
This comment has been minimized.
|
@clawsweeper re-review PR body updated with correct field format (Behavior addressed, Real environment tested, Exact steps, Evidence with terminal output, Observed result, What was not tested) as inline fields under ## Real behavior proof section. |
This comment has been minimized.
This comment has been minimized.
|
@clawsweeper re-review Changes since last review:
3 files changed, +66/-8. 79/79 tests pass. |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
1 similar comment
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
…cancellations
When the LLM API closes the connection mid-stream, the fetch layer
surfaces AbortError("This operation was aborted") with no external
abort signal triggered. The old guard `shouldRethrowAbort()` returned
false for these errors (because isTimeoutError matched the message),
so they fell through to the fallback loop but were never retried —
the error propagated up and produced SILENT_REPLY_TOKEN in group
sessions, permanently silencing the topic.
Replace the guard with a direct check: only rethrow AbortError when
the external abort signal is actually set (user/gateway cancellation).
Provider-side AbortErrors without an external signal now fall through
to the next fallback candidate, giving the system a chance to recover.
Thread the cron executor's abort signal into the shared runWithModelFallback call so that cron timeouts and cancellations stop the fallback chain instead of retrying with the next candidate. Previously, the run callback checked params.abortSignal?.aborted and threw, but runWithModelFallback itself had no signal — so the new guard in model-fallback.ts could not distinguish a caller abort from a provider-side AbortError and would retry silently. Also adds a focused regression test verifying the signal is forwarded.
|
Merged via squash.
|
Problem
When the LLM API closes the connection mid-stream, the fetch layer surfaces
AbortError("This operation was aborted")with no external abort signal triggered ("externalAbort: false"in trajectory). The existing guardshouldRethrowAbort()returnedfalsefor these errors (becauseisTimeoutErrormatched the "operation was aborted" message), so they fell through to the fallback loop but were never retried — the error propagated up and producedSILENT_REPLY_TOKENin group/channel sessions, permanently silencing the topic.Fix
coerceToFailoverErrorinrunFallbackCandidate: if the error is anAbortErrorand the external abort signal is already aborted, rethrow immediately (user/gateway cancellation). Provider-sideAbortErrors (no external signal) fall through to the next fallback candidate.compact.ts: Thread
params.abortSignalinto therunWithModelFallbackcall so compaction cancellations are correctly forwarded to the new guard.run-executor.ts (cron): Thread
params.abortSignalinto the cron executor'srunWithModelFallbackcall. Previously, the cronruncallback checkedparams.abortSignal?.abortedand threw, butrunWithModelFallbackitself had no signal — so the new guard in model-fallback.ts could not distinguish a caller abort from a provider-side AbortError and would retry silently.Also removes the unused
shouldRethrowAborthelper andisTimeoutErrorimport.Scope
model-fallback.ts: guard moved before normalization (core fix)compact.ts: abortSignal forwarding (P2 fix)run-executor.ts: cron abortSignal forwarding (P1 fix — addresses ClawSweeper review finding)model-fallback.test.ts: 3 regression testsrun.cron-model-override-forwarding.test.ts: 1 regression test verifying cron abort signal forwardingReal behavior proof
Behavior addressed: Provider-side
AbortError("This operation was aborted")(no external abort signal) was misclassified byshouldRethrowAbort()→isTimeoutError()matching the message, causing the fallback loop to silently swallow the error. In group/channel sessions this producedSILENT_REPLY_TOKEN, permanently silencing the topic with zero visible response.Real environment tested: macOS (Apple Silicon), Node v22, patched OpenClaw dist at
/opt/homebrew/lib/node_modules/openclaw/dist/model-fallback--w47Y_pS.js. Gateway restarted with patched dist. Guard logic verified against the actual production module code.Exact steps or command run after this patch:
Observed result after fix:
coerceToFailoverError→ falls through to next fallback candidate → topic recoversabortSignal.aborted = true) → rethrows immediately → no wasted provider callsisTerminalAbortcatches first → rethrows immediatelyabortSignalforwarded torunWithModelFallback→ new guard correctly stops on user cancellationabortSignalforwarded torunWithModelFallback→ new guard correctly stops on cron timeout/cancellationWhat was not tested: Live gateway end-to-end with a real reverse-proxy idle eviction (guard logic verified against production dist module; full network-layer round-trip not driven).
agent-runner-execution.tsSILENT_REPLY_TOKEN routing (separate follow-up if needed).