fix(agents): distinguish terminal aborts from retryable failures (#60388)#62682
Conversation
Greptile SummaryThis PR adds terminal abort detection to the model fallback chain to prevent wasteful API retries when a run-budget timeout or HTTP client disconnect has already terminated the run. It introduces Confidence Score: 5/5Safe to merge — the new abortSignal parameter is optional, all existing callers are unaffected, and the terminal-abort path is gated behind an explicit signal.reason name check. All remaining findings are P2 or below. The implementation is well-scoped, the double isTerminalAbort guard (pre- and post-coerceToFailoverError) is defensive but not harmful, tests cover all advertised scenarios including the failover-normalization race, and backward compatibility is preserved by design. No files require special attention. Reviews (2): Last reviewed commit: "fix(agents): distinguish terminal aborts..." | Re-trigger Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 537fee730e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
537fee7 to
7c59102
Compare
Edge case flagged by greptile on openclaw#62682: if the signal is aborted with a terminal reason (run-budget timeout) but the thrown error also matches a failover-normalizable shape (e.g. Google Vertex RESOURCE_EXHAUSTED), the `shouldRethrowAbort && !normalizedFailover` guard falls through and the chain tries the next candidate anyway. Fix: check isTerminalAbort(signal) before running coerceToFailoverError so the terminal signal cannot be masked by rate-limit normalization. Adds a regression test that locks the contract: aborted signal with TimeoutError reason + 429/AbortError thrown => first candidate runs, error rethrown, no further candidates tried. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c59102df7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@greptileai review |
7c59102 to
fa41f56
Compare
Flagged by codex review on openclaw#62682: `isTerminalAbort` only handled `reason instanceof Error`, but `src/cron/service/timer.ts:90` aborts the run controller with a plain string (`timeoutErrorMessage()`), so cron timeouts were falling through to fallback retries. Fix: add a known-terminal-strings set (currently just "cron: job execution timed out") and match string reasons against it. Also match Error objects whose `.message` equals a known terminal string, to cover callers that wrap the message in `new Error(...)` before passing it to `abort()`. Three new regression tests cover: string reason match, wrapped-in-Error match, and back-compat (unrelated strings still flow through fallback). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
fa41f56 to
235912a
Compare
|
checks-node-test failed with an OOM crash (FATAL ERROR: Reached heap limit) unrelated to this PR. Could a maintainer re-run the check? |
235912a to
2281256
Compare
|
check-additional failures are pre-existing upstream lint debt unrelated to this PR: lint:tmp:no-random-messaging — os.tmpdir() usage in extensions/active-memory, browser, memory-core, etc. (being addressed by #63902) |
2281256 to
59e763c
Compare
|
Codex review: needs maintainer review before merge. Reviewed July 6, 2026, 8:03 AM ET / 12:03 UTC. Summary PR surface: Source +136, Tests +655. Total +791 across 16 files. Reproducibility: yes. Source inspection shows current main lacks the PR's private run-budget/cause-chain/cron-string coverage, and the PR discussion includes live proof where current main made real provider fallback calls after a private terminal abort while the PR head stopped before fallback. Review metrics: 1 noteworthy metric.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land the PR only with provider/fallback-owner acceptance of the terminal-abort boundary, keeping provider-owned timeouts retryable via the wrapper-marker guard and regression tests. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main lacks the PR's private run-budget/cause-chain/cron-string coverage, and the PR discussion includes live proof where current main made real provider fallback calls after a private terminal abort while the PR head stopped before fallback. Is this the best way to solve the issue? Yes. The PR is the best bounded fix because it centralizes terminal-abort classification in model fallback and marks OpenClaw's private abort wrappers, instead of treating every provider AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against f532323edfcb. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +136, Tests +655. Total +791 across 16 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
Review history (1 earlier review cycle)
|
|
Rebased onto current upstream/main (commit
Conflicts resolved across 4 files ( Local validation: Re: the 2 CI failures ( Both surface the same root cause — The original CI failures from Apr 9 ( Ready for review. |
59e763c to
72ae084
Compare
72ae084 to
8cd3c1a
Compare
Edge case flagged by greptile on openclaw#62682: if the signal is aborted with a terminal reason (run-budget timeout) but the thrown error also matches a failover-normalizable shape (e.g. Google Vertex RESOURCE_EXHAUSTED), the `shouldRethrowAbort && !normalizedFailover` guard falls through and the chain tries the next candidate anyway. Fix: check isTerminalAbort(signal) before running coerceToFailoverError so the terminal signal cannot be masked by rate-limit normalization. Adds a regression test that locks the contract: aborted signal with TimeoutError reason + 429/AbortError thrown => first candidate runs, error rethrown, no further candidates tried. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
Updated for current head Trimmed (now provided by #87085): the base Kept (coverage #87085 does not include):
Dropped the release-owned Refreshed exact-head runtime proof (PR body → "Detailed evidence"): built CI is re-running on the new head. |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@BunsDev — your #87085 landed (thanks!), and per your reply on that PR ("keep them in #62682 / #60388 rather than folding them into this P1 PR") I rebased #62682 onto current main + the
Current head Four advisory checks are red on this PR ( |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@steipete — this is ready for maintainer look (ClawSweeper 🐚 platinum hermit) on head It distinguishes terminal aborts (run-budget timeout, HTTP client-disconnect, cron timeout-string) from retryable failures so the model-fallback chain isn't wasted retrying after the whole run is already over (closes #60388). It's reconciled with current main's Would you mind taking a look when you have a moment? Happy to adjust if the terminal-vs-retryable abort contract should be coordinated with the adjacent fallback PRs (#90908 / #52365 / #95632). |
|
cc @vincentkoc and @altaywtf — flagging you as well since you've both merged in this exact area recently ( |
Live behavior proofI reran the terminal-abort/fallback repro against exact refs using remote Crabbox on Refs tested:
OpenAI fallback proof:
Anthropic fallback proof:
Conclusion: this is a real behavior bug, not just synthetic unit coverage. The PR preserves retryable provider-owned fallback while stopping OpenClaw-owned terminal aborts before they waste another live provider call. 🤖 AI-assisted via Codex, GPT-5.5, high reasoning |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
Dependency graph guard clearedThis PR no longer has blocked dependency graph changes. A future dependency graph change requires a fresh
|
|
This assigned pull request has been automatically marked as stale after being open for 27 days. |
|
Thanks @altaywtf — really appreciate you jumping in with the ClawSweeper now rates the head (
Since you own the adjacent provider-side abort behavior (#90908), you're well placed to make that call — does the terminal-vs-retryable boundary here look acceptable to you as-is? Happy to adjust if you'd prefer it coordinated with #52365 (cron fallback budget) or #95632 (stream-abort classification). (Minor: the barnacle bot auto-marked this stale despite the fresh activity — if it's easy on your end, a |
|
Hi! Small heads-up: this PR may have been affected by a short-lived ClawSweeper label/comment sync bug. Your latest ClawSweeper review comment appears to be for the current PR head, but the current labels may not match that review. This one is less certain than the other affected PRs, so the safest next step is a fresh author-triggered ClawSweeper review. Could you please post a new comment asking ClawSweeper to re-review? Use the bot mention followed by Thanks, and sorry for the noise. |
|
This assigned pull request has been automatically marked as stale after being open for 27 days. |
…alAbort (closes openclaw#60388) PR openclaw#87085 landed the base isTerminalAbort(signal) check (TimeoutError / ClientDisconnectError on signal.reason) plus abortSignal threading through the model-fallback and chat-side callers. This change adds the coverage that PR openclaw#87085 did not include: - ClientDisconnectError class + wiring in http-common.ts so the reason.name === "ClientDisconnectError" branch PR openclaw#87085 added is actually reachable (upstream watchClientDisconnect still aborts bare). - cron run-budget string reasons (prefix match) — cron timer aborts with a plain string, which the Error-only base check skips. - .cause-chain walking + isTerminalAbortFromError gated on the OPENCLAW_ABORTABLE_WRAPPER marker, for the embedded run-budget timer that aborts a private controller (not the caller signal). - compaction-path abortSignal forwarding (compactEmbeddedPiSessionDirect). - timedOutByRunBudget plumbing through attempt/failover-policy/assistant-failover so run-budget timeouts skip the fallback chain and wasted compaction.
|
@clawsweeper re-review 🤖 AI-assisted via Codex, GPT-5.5, high reasoning |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Merged via squash.
|
Addresses #60388 (complementary to #52365, see "Relationship to PR #52365" below)
Today the fallback layer cannot tell the difference between two very different aborts:
Two situations where the run is over and retrying with another model wastes resources:
Run-budget timeout (Don't trigger model fallback when abort reason is the run's own timeout budget #60388): The embedded runner's
scheduleAbortTimerfiresrunAbortController.abort(makeTimeoutAbortReason())after the configuredagents.defaults.timeoutSeconds. The budget is exhausted -- giving the next candidate ~0 ms remaining is guaranteed to fail and wastes API calls. The user issue description (Don't trigger model fallback when abort reason is the run's own timeout budget #60388) says "On a fleet of ~100 cron jobs: 30+ model-fallback events per day, almost all triggered by run timeouts."HTTP client disconnect: When a client closes its connection mid-request,
watchClientDisconnectaborts the controller. No caller is left to receive a response, so any tokens spent on a fallback model are wasted.Both already abort the controller; what's missing is a reason attached to the signal that the fallback layer can recognize.
Summary
AbortSignal.reasonfor two terminal markers (name === "TimeoutError"for run-budget,name === "ClientDisconnectError"for client disconnect) and short-circuits the chain when found.Change Type
Scope
Linked Issue
All models failed (4): ... aborted ... aborted ...is exactly the pre-fix behavior this PR replaces with a singledecision=surface_error)Root Cause
shouldRethrowAbort()inmodel-fallback.tschecksisFallbackAbortError(err) && !isTimeoutError(err)-- which means timeout errors are intentionally not rethrown, so the fallback chain runs them. This is correct for per-provider timeouts (provider is slow, try another), but wrong for run-budget timeouts (whole run is out of time, no point retrying). The two cannot be told apart from the error alone.signal.reasoncarryingTimeoutError(set bypi-embedded-runner/run/attempt.ts:1382-1386viamakeTimeoutAbortReason) was never inspected.Regression Test Plan
src/agents/model-fallback.test.tsdescribe("terminal abort propagation (closes #60388)"):signal.reasonwithname === "TimeoutError"-> first candidate runs, no retry, error rethrownsignal.reasonwithname === "ClientDisconnectError"-> sameTimeoutErrornested ascauseof an outerAbortError-> still detected (coverspi-embedded-runner'smakeAbortErrorwrapping pattern)signal.reasonwith a generic error -> fallback runs normally (non-terminal)abortSignalpassed -> fallback runs normally (back-compat for existing callers)abortSignalprovided but not aborted -> fallback runs normally (live-signal back-compat)run.mock.calls.length === 1to verify the chain stopped. No need for a full E2E because the contract is purely about howmodel-fallback.tsinterpretsAbortSignal.reason.User-visible / Behavior Changes
When a run-budget timeout fires (the agent run exceeds
agents.defaults.timeoutSeconds), the model fallback chain now stops immediately instead of trying further candidates. The user-facing error is the same (the originalAbortError), but the lane is freed faster and no further API calls are made.When an HTTP client disconnects from
/v1/responsesor/v1/chat/completionsmid-request, the fallback chain also stops immediately (no caller is left to receive the response).Existing callers that don't pass
abortSignaltorunWithModelFallbacksee no change in behavior -- the new check is gated onsignal !== undefined && signal.aborted.Diagram
Security Impact
Repro + Verification
Environment
Steps
agents.defaults.timeoutSeconds(e.g. 5s) and one or more model fallbacksExpected (after fix)
[model-fallback] candidate_failedentries for fallback candidates beyond the firstActual (before fix)
Real behavior proof (required for external PRs)
External contributors must show after-fix evidence from a real OpenClaw setup. Built and validated from this PR's exact head SHA, then run inside a Docker container.
Behavior or issue addressed: HTTP client disconnect mid-request, embedded run-budget timeout, and cron timeout strings fired into
model-fallback.tsas if they were retryable provider failures — the gateway kept trying additional fallback candidates against a caller that no longer existed (issue Don't trigger model fallback when abort reason is the run's own timeout budget #60388 reports 30+ events/day on a fleet of ~100 cron jobs). After this PR (rebased onto current upstream/main, which now carries fix(gateway): stop chat timeout fallback cascade #87085's baseisTerminalAbort(signal)), the terminal-abort coverage adds four pathways fix(gateway): stop chat timeout fallback cascade #87085 does not include:ClientDisconnectErroractually constructed + tagged on the HTTP-disconnect abort (fix(gateway): stop chat timeout fallback cascade #87085 added thereason.name === "ClientDisconnectError"check butwatchClientDisconnectstill aborts bare, so it was unreachable); cron string reasons including prefix-matched phase-suffixed variants (TERMINAL_ABORT_REASON_PREFIXES);TimeoutErrornested as.causeof an outerAbortErrorfrom the embedded runner's privaterunAbortController(isTerminalAbortFromError, gated on theOPENCLAW_ABORTABLE_WRAPPERmarker so provider/SDKAbortError(cause: TimeoutError)stays retryable); and thetimedOutByRunBudgetfailover-policy state that skips assistant rotation when the whole-run deadline is exhausted.Real environment tested: built fresh from current PR head
5cee8140adinside a clean throwawaynode:22container — agit archivesource snapshotdocker cp'd in (no bind mount), thenpnpm install --frozen-lockfile+node scripts/tsdown-build.mjsrun inside the container — producing the compiled ESM bundlesdist/model-fallback-yd7bSf92.jsanddist/compact-De7lUD76.jsthat the driver imports. Re-captured against this head after rebasing onto current upstream/main (which now carries fix(gateway): stop chat timeout fallback cascade #87085's baseisTerminalAbort) and dropping the release-ownedCHANGELOG.mdentry per ClawSweeper [P3].Exact steps or command run after this patch (on head
5cee8140ad): Node driver importsrunWithModelFallbackfrom the freshly-compileddist/model-fallback-yd7bSf92.js(resolved byfn.name === "runWithModelFallback"; the bundle minifies the export alias toa) and exercises 5 cases against the deployed bundle — ATimeoutErroronsignal.reason; B no-signal retryable cascade; C live-signal-not-aborted; D cron string reason"cron: job execution timed out (last phase: model_call_started)"onsignal.reason; E thrownAbortError(cause: TimeoutError)carrying theOPENCLAW_ABORTABLE_WRAPPERmarker (Symbol.for("openclaw.abortable.wrapper")). Bundle grep confirmsTERMINAL_ABORT_REASON_PREFIXES,isTerminalAbortFromError,OPENCLAW_ABORTABLE_WRAPPER,isOpenClawAbortableWrappercompiled intodist/model-fallback-yd7bSf92.js;abortSignal: params.abortSignalindist/compact-De7lUD76.js;timedOutByRunBudgetacross 3 downstream bundles.Evidence after fix (terminal capture, redacted): On head
5cee8140ad, cases D and E — the two paths fix(gateway): stop chat timeout fallback cascade #87085's baseisTerminalAbortdoes NOT cover (it returns false for a non-Error string reason, and only inspectssignal.reasonnot the thrown error's cause-chain) — each droverun()exactly 1 time with terminal short-circuit and zerodecision=candidate_failedlog lines. Contrast case B (no signal):run()called 4 times, emitting the exact four[model-fallback/decision] decision=candidate_failed ... reason=rate_limit next=...log lines cascading through every candidate and ending inFallbackSummaryError: All models failed (4). Full terminal capture in the "Detailed evidence" section below.Observed result after fix (head
5cee8140ad): All 5 cases PASS — Arun()=1, Brun()=4, Crun()=4, Drun()=1, Erun()=1. D proves theTERMINAL_ABORT_REASON_PREFIXEScron-string matcher and E proves marker-gatedisTerminalAbortFromError; both short-circuit the fallback chain, while the non-terminal cascade (B) and never-aborted back-compat (C) are preserved.What was not tested: End-to-end cron job that actually fires
scheduleAbortTimeris impractical in a short-lived test container (requires scheduled cron entry + waiting for timeoutSeconds elapse). The compiled-bundle test exercises the same code path with the same string/error shapes thatsrc/cron/service/timer.tswould emit. Per-provider HTTP timeouts (AbortSignal.timeout()from minimax-vlm / pdf-native-providers throwing bareDOMExceptionwithname === "TimeoutError") are intentionally unchanged — non-terminal cascade preserved (tested via RateLimitError + the marker-less AbortError shape).Update for head
0d2d9d1834(after rebase onto current upstream/main + @Lellansin's compaction-signal fix): 7-commit branch rebased clean. Compaction fix verified live at this head:compactEmbeddedPiSessionDirectpassesabortSignal: params.abortSignalinto the surroundingrunWithModelFallbackcall (src/agents/pi-embedded-runner/compact.ts:436). Local tests:npx vitest run src/agents/model-fallback.test.ts src/agents/pi-embedded-runner/run/failover-policy.test.ts src/agents/pi-embedded-runner/compact.abort-signal.test.tsreturns 191/191 passed (includes 80 model-fallback tests covering all 4 prefix-match variants + non-terminal cascade, plus 2 new compaction signal-threading regression tests).Update for head
5cee8140ad(2026-06-03 rebase onto upstream7c1a83ff2e, addresses the CI red on the prior head): single-commit replay was clean (17 files, +933/-9, zero conflicts — upstream didn't touch any of the 17 files in this PR's diff). Builtdist/from5cee8140adinnode:22-bookworm-slimarm64-native container; re-ran the 5-case in-process driver against the freshly-compiled bundle (dist/model-fallback-yd7bSf92.js+dist/compact-De7lUD76.js): ALL 5 PASS — Aattempts=0(terminal signal short-circuit), Battempts=2, Cattempts=3(non-terminal cascade through all 3 candidates), Dattempts=0(cron string short-circuit), Eattempts=0(marker-gatedisTerminalAbortFromErrorshort-circuit). Bundle-presence sanity at head5cee8140ad:dist/model-fallback-yd7bSf92.jscontainsTERMINAL_ABORT_REASON_PREFIXES,isOpenClawAbortableWrapper,isTerminalAbortFromError, andOPENCLAW_ABORTABLE_WRAPPER = Symbol.for("openclaw.abortable.wrapper"). The 4 advisory CI reds on the prior head (build-artifacts,check-additional-extension-bundled,check-lint,check-test-types) all PASS on upstream7c1a83ff2e(CI run id26866394690jobs section) — this rebase is expected to clear them on the new head.Latest-head runtime proof for the compaction signal path (addresses ClawSweeper's "Refresh Latest-Head Runtime Proof" rank-up move): built
dist/fresh from0d2d9d1834viapnpm install+pnpm build:dockerand ran an in-process Node driver that imports the compiledrunWithModelFallbackfromdist/model-fallback-*.jsand calls it with the EXACT parameter shapecompact.ts:436uses, then captures the runtime[model-fallback/decision]log lines. Three cases driven against the deployed bundle:AbortControlleraborted withname === "TimeoutError", then passed asabortSignal):run()called exactly 1 time,TimeoutErrorsurfaced immediately. No[model-fallback/decision] candidate_failedlog lines emitted. This is the post-fix path that compact.ts:436 now triggers.abortSignal(pre-Lellansin-fix state ofcompactEmbeddedPiSessionDirect):run()called 4 times, cascading through all 3 compaction fallbacks. Runtime emitted the exact[model-fallback/decision] decision=candidate_failed ... reason=rate_limit next=...log lines per candidate — the EXACT shape of the wasted-API-call cascadecompact.ts:436now prevents.abortSignalnot aborted:run()called 4 times, cascading. Back-compat preserved for callers that pass a never-aborted signal.Bundle-presence sanity at head
0d2d9d1834:dist/compact-DooE9UQD.jscontainsabortSignal: params.abortSignal,(the compact.ts:436 wiring).dist/model-fallback-*.jscontainsTERMINAL_ABORT_REASON_PREFIXESandisTerminalAbortFromError.timedOutByRunBudgetis compiled into 3 downstream bundles (command-export,pi-embedded,selection) — the consumers inattempt.ts/failover-policy.ts/assistant-failover.ts.Latest-head runtime proof for the cron pre-execution stall prefix (head
f5182213bb, addresses ClawSweeper's "Refresh proof for the current head" rank-up move on commitf5182213bb): rebuiltdist/fresh, ran in-process driver against the compiled bundle, captured[model-fallback/decision]log lines. Five cases:cron: isolated agent run stalled before execution start:run()called 1 time,AbortErrorsurfaced. Nodecision=candidate_failedlog lines.... (last phase: runner_ready):run()called 1 time. Phase-suffix variant short-circuits as well.cron: job execution timed out(regression sanity): still 1 call.cron: isolated agent setup timed out before runner start (last phase: workspace_provision)(regression sanity): still 1 call.RateLimitError(cascade-preservation check):run()called 4 times, runtime emitted the exact four[model-fallback/decision] decision=candidate_failed ... reason=rate_limit next=...log lines. Provider retry semantics preserved.Bundle-presence sanity at head
f5182213bb:dist/model-fallback-GKGkpeHu.jscontains the new prefix"cron: isolated agent run stalled before execution start"alongside the two prior prefixes ("cron: job execution timed out","cron: isolated agent setup timed out before runner start"). The detection functionisTerminalAbortReasonStringPREFIX-matches both bare and phase-suffixed shapes (persrc/cron/service/timer.ts:381/383preExecutionTimeoutErrorMessage()emission).Latest-head runtime proof for the abortable() wrapper marker (head
8f484bcde5, addresses ClawSweeper's [P1] "Keep wrapped provider timeouts retryable" concern about distinguishing run-budget aborts from hypothetical provider/SDK timeouts that share the sameAbortError(cause: TimeoutError)shape): rebuiltdist/from8f484bcde5, ran in-process driver against the compiled bundle, captured[model-fallback/decision]log lines. Seven cases driven; two are the key new contrast:AbortError(cause: TimeoutError)WITH theOPENCLAW_ABORTABLE_WRAPPERmarker (i.e., whatpi-embedded-runner/run/abortable.tsmakeAbortError()produces — the only call site that creates this shape in upstream, and only for terminal signals):run()called 1 time, terminal short-circuit. Nodecision=candidate_failedlog lines.AbortError(cause: TimeoutError)WITHOUT the marker (provider-style wrapper — the hypothetical SDK shape the bot warned about):run()called 4 times, cascading through all 3 fallbacks. Runtime emitted four[model-fallback/decision] decision=candidate_failed ... reason=timeout next=...log lines — confirming retryable per-provider timeout semantics are preserved for any future SDK that wraps timeouts this way.Bundle-presence sanity at head
8f484bcde5:dist/model-fallback-*.jscontains bothOPENCLAW_ABORTABLE_WRAPPERandisOpenClawAbortableWrapper(imported frompi-embedded-runner/run/abortable.ts). The marker isSymbol.for("openclaw.abortable.wrapper")so test/proof callers can recreate the same symbol value to construct realistic wrappers.Before evidence (optional but encouraged): Same
curl --max-time 3against unpatched OpenClaw2026.4.xwould emitdecision=fallback_model reason=timeoutfor each fallback candidate followed bydecision=candidate_failed reason=timeout next=...between each — the exact shape captured in the in-process RateLimitError cascade test above (4 cascading decisions). Phase-suffixed cron timeouts onsimon/production(which carries commits 1-3 but not commit 5) likewise still cascade because the exact-string Set match in commit 3 missed phase-suffixed variants — the prefix-match in commit 5 fixes this.Detailed evidence (terminal captures)
Current-head 5-case runtime proof (against freshly-rebuilt
5cee8140ad):Build: clean throwaway
node:22container,pnpm install --frozen-lockfile+node scripts/tsdown-build.mjsfrom5cee8140ad→dist/model-fallback-yd7bSf92.js,dist/compact-De7lUD76.js. Driverdocker cp'd in, imports the compiledrunWithModelFallback, drives 5 cases (4 fallback candidates each:openai/gpt-x-primary+ 3 fallbacks).run()throws a non-abort429for the cascade cases; cases A/D/E carry a terminal signal/error shape.Real
[model-fallback/decision]lines emitted by the bundle during the case B cascade (caseC identical):Cases D and E emit no
decision=candidate_failedlines — they short-circuit before the fallback loop continues, which is exactly the wasted-cascade #60388 reports. Bundle-presence sanity at5cee8140ad:dist/model-fallback-yd7bSf92.jscontainsTERMINAL_ABORT_REASON_PREFIXES,isTerminalAbortFromError,OPENCLAW_ABORTABLE_WRAPPER,isOpenClawAbortableWrapper;dist/compact-De7lUD76.jscontainsabortSignal: params.abortSignal;timedOutByRunBudgetcompiled into 3 downstream bundles.Live HTTP gateway disconnect (against rebuilt
e7f4576935):In-process call into compiled
runWithModelFallback:Bundle-presence sanity:
Latest-head compaction-path runtime proof (against rebuilt
0d2d9d1834):Driver: in-process Node, imports
runWithModelFallbackfromdist/model-fallback-*.js, calls it with the EXACT parameter shapecompact.ts:436uses. Captures real runtime[model-fallback/decision]log lines emitted by the deployed code path.The contrast between A and B is the runtime delta the compact.ts:436 fix produces: when a terminal abort fires during compaction, the model-fallback chain stops at one call instead of emitting four
decision=candidate_failedlog lines against compaction-fallback models the caller no longer cares about.Latest-head cron pre-execution stall proof (against rebuilt
f5182213bb):The new prefix added in commit
f5182213bbcoverspreExecutionTimeoutErrorMessage()(src/cron/service/timer.ts:381/383) — the isolated-agent pre-execution watchdog fires after setup completes but before the runner starts consuming the run budget. Both bare and phase-suffixed shapes short-circuit; non-terminal provider errors still cascade per the existing model-fallback contract.Latest-head Docker proof for the abortable() wrapper marker (head
8f484bcde5):Setup: fresh container
openclaw-62682-proof-8f484bcdfrom base imageopenclaw:main(linux/amd64), source mounted at/workspaceat PR head8f484bcde5,dist/built viapnpm build:docker. Gateway banner inside the container confirms the head SHA:In-container proof driver (runs INSIDE the openclaw-62682-proof-8f484bcd container, imports
runWithModelFallbackfrom the deployed/workspace/dist/model-fallback-*.js):The A5/B2 contrast is the key new evidence for the marker fix: both shapes are byte-identical except for the
OPENCLAW_ABORTABLE_WRAPPERsymbol marker. A5 (marker present) → terminal short-circuit. B2 (marker absent, simulating a provider SDK wrapper) → emits the exact 4[model-fallback/decision] decision=candidate_failed reason=timeout next=...log lines, cascading through every configured candidate just like before this PR. Per-provider retryable timeout semantics are preserved by design, not by accident.Human Verification
model-fallback.test.tspass after the changemodel-fallback.probe.test.tspassagent-command.live-model-switch.test.tspass/v1/responsesproduces the expected[openresponses] client disconnected, aborting streaming run runId=...log line and the agent run terminates within ~1s (the upstreamwatchClientDisconnectplumbing already inaad3bbeddworks correctly with the newClientDisconnectErrorreason tag)TimeoutError/ClientDisconnectErrorwrapped inside an outerAbortError-- this matchespi-embedded-runner/run/attempt.ts:1387'smakeAbortErrorpatterntimeoutSecondsto a very small value; the unit tests cover the contract)runWithImageModelFallback) -- it gets the newabortSignal?parameter for consistency but no callers currently pass a signalCompatibility / Migration
abortSignal?parameter is optional. Existing callers that don't pass it continue to behave exactly as before.Risks and Mitigations
signal.reasonhappens to havename === "TimeoutError"for an unrelated reason, accidentally short-circuiting the fallback chain when they didn't want to.TimeoutErrorandClientDisconnectErrormatch. Both names are already reserved for run-budget timeout (set bymakeTimeoutAbortReasoninpi-embedded-runner/run/attempt.ts) and HTTP client disconnect (set bywatchClientDisconnectingateway/http-common.tsafter this PR).TimeoutErrorfrom a per-provider request timeout (not run-budget) gets mistaken for a run-budget timeout and skips fallback when it shouldn't.runAbortController.abort(). OnlyrunAbortControlleris tagged withTimeoutErrorviamakeTimeoutAbortReason.Out of scope (for separate PRs)
executeJobCoreWithTimeout), needs per-attempt AbortController isolation. PR fix(cron): per-attempt AbortControllers and deferred execution timeout #42482 already addresses this.requestLiveSessionModelSwitch), needs coordination state between live-model-switch and fallback layersRelationship to PR #52365
PR #52365 (fix(cron): stop fallback attempts when cron budget is exhausted) addresses the same underlying problem from #60388 but with a different, complementary mechanism:
beforeAttempthook inrunWithModelFallbackthat lets the cron layer check its remaining budget before each attempt and stop the chain if budget is too low.isTerminalAbort(signal)check inshouldRethrowAbortthat inspectssignal.reasonafter an attempt aborts to decide whether to rethrow or retry.These are complementary, not exclusive. #52365's
beforeAttempthook stops the chain before wasting an attempt when budget is known to be low. This PR's check stops the chain after the first attempt aborts with a terminal reason -- which covers both the cron-timeout case (redundantly with #52365) AND the HTTP client disconnect case (which #52365 does not address).Notable differences:
model-fallback.ts,http-common.ts,agent-command.ts, 3 auto-reply callers, and one cron caller. fix(cron): stop fallback attempts when cron budget is exhausted #52365 is 47 files / ~1900 lines (includes unrelated slack/plugin-sdk changes)./v1/responsesand/v1/chat/completionsclient-disconnect case by tagging the abort withClientDisconnectErrorinwatchClientDisconnect. fix(cron): stop fallback attempts when cron budget is exhausted #52365 does not touch the gateway HTTP path.signal.reasonwhich is a generic mechanism usable by any caller (including future non-cron sources). fix(cron): stop fallback attempts when cron budget is exhausted #52365'sbeforeAttempthook requires each caller to implement its own budget-check logic.Either PR alone fixes the #60388 cron-timeout case. Both merged together would give defense-in-depth:
beforeAttemptstops before wasting an attempt when budget is known to be low, andisTerminalAbortstops after any attempt aborts with a terminal reason (including client disconnects that the cron-aware hook doesn't see).If the maintainers prefer #52365's approach and would rather not have two overlapping mechanisms, I'd suggest retargeting this PR to cover only the
ClientDisconnectErrorbranch ofisTerminalAbort(the unique coverage) and letting #52365 handle the cron-timeout case via its proactive hook.