Skip to content

fix(stella-core): park the retry ladder under sustained rate limiting instead of aborting (#2677) - #2744

Merged
macanderson merged 1 commit into
mainfrom
fix/2677-budget-aware-retry-parking
Aug 10, 2026
Merged

fix(stella-core): park the retry ladder under sustained rate limiting instead of aborting (#2677)#2744
macanderson merged 1 commit into
mainfrom
fix/2677-budget-aware-retry-parking

Conversation

@macanderson

@macanderson macanderson commented Aug 10, 2026

Copy link
Copy Markdown
Owner

What & why

Under a sustained 429 brownout, RetryPolicy::standard()'s inline ladder (6 retries, ~16s total backoff) exhausted in seconds and a Retry-After past the fixed 120s ceiling failed Terminal outright — even with hundreds of seconds of task budget left. #2667 measured the loss: 7 attempts burned in 15s, killing a trial with 880s of its 900s budget remaining (8/16 cells in turnloop-cert-panel1). The comparator lineage retries 429s reset-aware for hours with chunked keep-alives.

Abort becomes recovery. A rate limit the inline ladder cannot absorb now parks instead of dying, bounded by real wall-clock headroom:

  • crates/stella-core/src/retry.rs — a new ParkSupervisor port (the same seam shape as Sleeper: the retry driver stays pure decision logic; clock, budget, events, and the soft-stop latch never leak in). The pure planner plan_rate_limit_park decides the wait: a server Retry-After that fits the allowance is honored verbatim (waiting less than the server asked guarantees a re-429, so an unaffordable hint still fails fast — the fail-fast the old fixed ceiling provided survives, now measured against real headroom); hint-less parks follow a doubling 30s→5min backoff, the comparator's cap. Long waits sleep in 30s chunks with a supervisor tick before each one. Exemplar for the port-with-no-op-default shape: tokio's injected-time seam / the crate's own Sleeper+ports::Clock discipline.
  • crates/stella-core/src/driver/rate_limit.rs (new sibling module, the settlement.rs pattern) — the whole attempt-ladder block moves out of god-file driver.rs (which shrinks by 76 lines), and the engine's supervisor lands beside it: allowance = task-deadline remaining minus a 30s wake reserve, capped at an absolute 6h; TurnParked/TurnWoken span (no new AgentEvent variant — the existing Parked waits: dedicated wire events (TurnParked/TurnWoken) and a TUI heartbeat instead of synthetic Text #1857 span fits and keeps this orthogonal to feat(stella-protocol): a signal-consumer ledger — every AgentEvent variant declares what reads it #2720's ledger) plus a per-chunk keep-alive Text narration for liveness; soft stop ends the park at the next chunk.
  • L-M4 and invariant 6 hold: RetryPolicy::deterministic() and every auxiliary call (accounted_call.rs — summarizer/triage/authoring) keep NoParking, matching the comparator's background-source posture; the park sits between model attempts, never mid-tool, and the budget's deadline bounds it from outside.

Anthropic's Retry-After already reaches RateLimited::retry_after_ms via parse_retry_after_ms/classify_http_status (crates/stella-model/src/http.rs, 429 arm — wiremock-covered there); this PR consumes that hint at the seam where the abort actually happened. stella-model does not link stella-core, so the recovery witness lives at the driver seam with a scripted provider.

Closes #2677
Closes #2667

The witness

  • This PR includes witness tests (fail on main, pass here)

Verified the artisanal way — git checkout origin/main -- crates/stella-core/src/{retry,driver,accounted_call}.rs && rm crates/stella-core/src/driver/rate_limit.rs with the test file kept (the driver witnesses use only pre-existing API), then cargo test -p stella-core --lib parked_wait:

  • driver::tests::parked_wait::sustained_rate_limiting_parks_within_budget_and_recoversfails on main (Aborted { reason: "model call failed: provider rate limited: throttled", kind: Failure }), passes here: 9 hint-less 429s → 6 inline retries + 3 parks → completed step, TurnParked/TurnWoken on the stream, no RetriesExhausted.
  • driver::tests::parked_wait::a_long_retry_after_hint_is_honored_by_parking_when_budget_allowsfails on main (Terminal: …asked to wait 300000ms… past this call's 120000ms server-hint ceiling), passes here: the 300s hint parks (span deadline 300–338s incl. jitter) and the step completes.
  • a_hint_past_the_remaining_deadline_still_fails_fast_without_parking pins the kept fail-fast half (60s deadline vs 300s hint → clean abort, no park).
  • Pure-logic coverage: retry::tests::sustained_rate_limiting_beyond_the_ladder_parks_and_recovers, an_abort_tick_ends_the_park_and_surfaces_the_error, the_deterministic_policy_never_parks_even_with_allowance, and property a_planned_park_never_exceeds_the_allowance_and_never_undercuts_the_hint (proptest, per the crate's pure-engine-logic testing idiom). All pre-existing retry tests pass unchanged — retry_with_backoff runs NoParking, preserving pre-CORRECTION: retry ladder fails fast under sustained rate limiting — make the Retry-After ceiling budget-aware with parked long waits #2677 behavior exactly for supervisor-less callers.

The gate

  • cargo fmt --check
  • cargo clippy --workspace --all-targets -- -D warnings (stella-core + all dependents checked)
  • cargo test -p stella-core (1089 passed) — full workspace runs in CI
  • Docs updated where behavior changed (module docs in retry.rs, max_server_hint_ms semantics, new module doc)
  • CLA signed
  • Closes #N appears both above and as commit trailers

Nothing left behind

Ground-rule check

  • No I/O added to stella-core (the supervisor is a port; the only clock read is Instant::now in the driver, matching the existing neighborhood); no new deps
  • No new outbound network calls
  • No new cross-boundary types (TurnParked/TurnWoken reused; nothing added to stella-protocol)

Anything reviewers should know?

  • retry_with_backoff_observed gained a required &mut dyn ParkSupervisor parameter (it is pub(crate)); the public retry_with_backoff signature is unchanged.
  • The exhausted-ladder error class is preserved: a rate limit that gives up hint-less still surfaces RateLimited (so RetriesExhausted::retryable stays truthful per RetriesExhausted is emitted when no retry was ever attempted #926); only a genuinely oversized-and-unaffordable hint keeps the explanatory Terminal, now naming hint, inline ceiling, and remaining headroom.
  • Deliberately no new AgentEvent variant to stay orthogonal to feat(stella-protocol): a signal-consumer ledger — every AgentEvent variant declares what reads it #2720's consumer ledger; if a typed per-chunk heartbeat is wanted later it should be added post-ledger.
  • Alternative rejected: a max_parked_wait_ms field on RetryPolicy — the allowance is dynamic (deadline-derived), so a policy constant would either lie or duplicate the supervisor's answer.

Summary by Sourcery

Add budget-aware parked waiting for sustained provider rate limiting and refactor the model-call retry ladder into a dedicated driver module.

Bug Fixes:

  • Prevent sustained 429 rate limiting from prematurely aborting turns when there is remaining wall-clock budget.
  • Ensure long Retry-After hints beyond the inline ceiling are honored via parked waits when affordable, and fail fast only when they cannot fit within the remaining deadline.

Enhancements:

  • Introduce a pluggable ParkSupervisor interface and pure park planning logic to bound and narrate long rate-limit waits without coupling retry logic to clocks or budgets.
  • Move the attempt ladder and its rate-limit handling out of driver.rs into a new driver::rate_limit module, reducing driver size and centralizing retry-related effects.
  • Keep auxiliary accounted calls and deterministic retry policy on a no-parking path so background and helper traffic continue to fail fast under rate limiting.

Tests:

  • Add unit and property tests for park planning behavior to guarantee waits respect allowances and server hints.
  • Add integration-style driver tests covering sustained 429 recovery via parking, honoring long Retry-After hints within budget, and fail-fast behavior when hints exceed remaining headroom.

@sourcery-ai sourcery-ai Bot left a comment

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.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
stella-cli-docs Ignored Ignored Preview Aug 10, 2026 9:34pm

@sourcery-ai

sourcery-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements supervised, budget-aware parking for sustained 429 rate limiting by extending the retry engine with a ParkSupervisor abstraction, wiring an engine-specific park supervisor into the model-call attempt ladder, and adding tests and docs to ensure deterministic policies and auxiliary calls keep their existing fail-fast behavior.

File-Level Changes

Change Details Files
Introduce a generic ParkSupervisor interface and pure park-planning logic in the retry engine to support supervised, chunked waiting under rate limiting.
  • Add ParkSupervisor trait, ParkDirective enum, NoParking implementation, and PARK_* constants to model parked waits as an injectable port alongside Sleeper.
  • Extend retry_with_backoff_observed to track parked time and streaks, consult ParkSupervisor for allowance, and drive chunked sleeps with abort support via ParkDirective.
  • Add plan_rate_limit_park and ParkPlan to compute park durations from Retry-After hints, backoff streak, and allowance, plus unit/property tests covering planning and behavior invariants.
  • Clarify RetryPolicy::max_server_hint_ms and DEFAULT_MAX_SERVER_HINT_MS semantics to treat oversized hints as escalations to the parked-wait path rather than inline-terminal failures.
  • Update retry_with_backoff to use retry_with_backoff_observed with NoParking so public callers retain pre-CORRECTION: retry ladder fails fast under sustained rate limiting — make the Retry-After ceiling budget-aware with parked long waits #2677 behavior without parking.
crates/stella-core/src/retry.rs
Refactor the driver’s model-call attempt ladder into a dedicated rate_limit module and attach an engine-specific ParkSupervisor that derives allowance from task deadlines, emits TurnParked/TurnWoken spans, and respects soft-stop signals.
  • Create driver/rate_limit.rs housing Engine::drive_attempt_ladder, encapsulating cancel guard setup, per-attempt UsageIncomplete events, rate-limit parking, and RetriesExhausted/Error emission.
  • Implement RateLimitPark as a ParkSupervisor that computes allowance from BudgetGuard deadline (with MAX_PARKED_WAIT_MS and PARK_DEADLINE_RESERVE_MS), opens TurnParked and TurnWoken AgentEvent spans, emits per-chunk liveness Text, and aborts on soft stop.
  • Wire the new module into driver.rs, replacing the inlined attempt ladder with a call to drive_attempt_ladder and removing unused types/imports.
  • Add MAX_PARKED_WAIT_MS and PARK_DEADLINE_RESERVE_MS constants in driver/rate_limit.rs to bound total parked time and preserve a wake reserve under deadlines.
crates/stella-core/src/driver.rs
crates/stella-core/src/driver/rate_limit.rs
Ensure auxiliary accounted calls and deterministic retry policies remain non-parking, preserving their existing fail-fast semantics under rate limiting.
  • Update run_accounted_call to construct and pass a NoParking supervisor into retry_with_backoff_observed so summarizer/triage/authoring calls never park on rate limits.
  • Add tests verifying the deterministic RetryPolicy never parks even with generous allowance, and that park aborts surface the original rate limit error.
  • Document L-M4 and invariant-6-related behavior around deterministic policies and park placement between attempts.
crates/stella-core/src/accounted_call.rs
crates/stella-core/src/retry.rs
Add driver-level integration tests that witness parked rate-limit recovery, honoring of long Retry-After hints when budget allows, and fail-fast behavior when hints exceed remaining deadlines.
  • Add throttled() helper for constructing RateLimited errors with no Retry-After to simulate provider brownouts.
  • Introduce sustained_rate_limiting_parks_within_budget_and_recovers test that scripts multiple 429s, validates TurnParked/TurnWoken events, and ensures Runs no longer abort with RetriesExhausted under sustained 429s with budget left.
  • Add a_long_retry_after_hint_is_honored_by_parking_when_budget_allows test to ensure large Retry-After hints past the inline ceiling are parked (chunked) and honored when headroom exists, with TurnParked span carrying the derived deadline.
  • Add a_hint_past_the_remaining_deadline_still_fails_fast_without_parking test to prove oversized hints against tight deadlines fail fast, do not open TurnParked spans, and do not retry into an unaffordable window.
crates/stella-core/src/driver/tests/parked_wait.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#2667 Change the 429 retry policy so that sustained rate limiting (including long Retry-After hints) results in an exponential backoff / parked wait bounded by task budget rather than a short fixed ladder that aborts the trial early, ensuring 429s pause the turn instead of killing it while budget remains.
#2667 Introduce a shared token bucket for 429 rate limiting across a match's concurrent trials to mitigate pressure from fan-out. The PR only modifies stella-core retry/driver logic to add parked waits and associated tests; it does not implement any shared rate-limit token bucket across concurrent trials. The PR body explicitly defers this to a follow-up issue (#2741).
#2667 Update the runner (arenabench) classification so that a 429-retries_exhausted exit is treated as an infrastructure failure rather than an agent loss. No runner/arenabench code is changed in this PR. The PR body notes that runner-side classification of 429 aborts is left for follow-up work (#2741), so this objective remains unimplemented here.
#2677 Make rate-limit retry ceiling budget-aware: instead of immediately failing terminal when Retry-After exceeds the fixed inline ceiling, use the parked-wait machinery to honor long Retry-After delays when remaining budget allows, and keep fail-fast behavior only when the hint exceeds budget.
#2677 During long parked waits under sustained rate limiting, emit periodic keep-alive / narration events per wait chunk so observers can see that the turn is still live.
#2677 Restrict parked-wait behavior to the foreground/standard retry mode while ensuring deterministic/background policies (e.g., RetryPolicy::deterministic and auxiliary calls) do not park but continue to fail fast under rate limits.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macanderson
macanderson force-pushed the fix/2677-budget-aware-retry-parking branch from 32f7f3c to 550d022 Compare August 10, 2026 21:07
@macanderson
macanderson enabled auto-merge (squash) August 10, 2026 21:08
… instead of aborting

RetryPolicy::standard()'s inline ladder (6 retries, ~16s of total backoff)
treated a minutes-long 429 brownout and a transient blip identically, and a
Retry-After past the 120s server-hint ceiling failed Terminal outright —
of budget left (8/16 cells in one panel).

Abort becomes recovery: a rate limit the inline ladder cannot absorb now
parks through a new ParkSupervisor port in stella-core::retry — pure
plan_rate_limit_park decides the wait (server hint honored verbatim when it
fits, doubling 30s→5min backoff when hint-less), the loop sleeps it in 30s
chunks through the existing Sleeper port, and the supervisor is consulted
per chunk. The engine's supervisor (driver/rate_limit.rs, split out of
driver.rs which shrinks by 76 lines) derives the allowance from
BudgetGuard's task deadline minus a 30s wake reserve, capped at 6h absolute;
emits the TurnParked/TurnWoken span plus per-chunk keep-alive narration; and
ends the park on a soft stop. A stated wait that cannot fit the remaining
budget still fails fast — waiting less than the server asked guarantees a
re-429. retry_with_backoff and every auxiliary call (accounted_call) keep
NoParking, so triage/summarizer paths and L-M4 are unchanged.

Witness tests (fail on main, pass here):
- driver::tests::parked_wait::sustained_rate_limiting_parks_within_budget_and_recovers
- driver::tests::parked_wait::a_long_retry_after_hint_is_honored_by_parking_when_budget_allows

Closes #2677
Closes #2667
@macanderson
macanderson force-pushed the fix/2677-budget-aware-retry-parking branch from 550d022 to e99cf50 Compare August 10, 2026 21:34
@macanderson
macanderson merged commit d7e6328 into main Aug 10, 2026
15 checks passed
@macanderson
macanderson deleted the fix/2677-budget-aware-retry-parking branch August 10, 2026 21:48
macanderson added a commit that referenced this pull request Aug 10, 2026
…t (torn comment, orphaned rate_limit module, doubled retry ladder) (#2759)

## What & why

**Main is red at `f83692c82`** — stella-core does not compile. The admin
merge of #2752 raced its in-flight rebase over #2744, and the squash
text-merged a pre-#2744 branch onto post-#2744 main. Three artifacts in
`crates/stella-core/src/driver.rs`:

1. A doc comment torn mid-word at line 1715–1716 (`// lives in `driver/`
+ a bare `.rs`.` line with no `//`) — the workspace-wide compile error.
2. `mod rate_limit;` was *replaced* by (instead of joined with)
`pub(crate) mod overflow_recovery;`, orphaning `driver/rate_limit.rs` —
the `check-module-reachability` failure.
3. `run_model_call` contained **both** retry ladders: #2744's extracted
`drive_attempt_ladder` call *and* the dead pre-#2744 inline
`retry_with_backoff_observed` block — and #2680's `ContextOverflow`
interception lived only in the dead copy.

## The fix

Single-ladder shape restored, with the two features composed where they
belong: `drive_attempt_ladder` (`driver/rate_limit.rs`) now returns
`ModelCallFailure`, and on a `ContextOverflow` withholds the terminal
events and the breaker feed (an oversized request is the engine's
accounting miss, not provider ill-health) so
`settle_model_call_failure`'s recovery rungs (#2680) work; every other
terminal error keeps the `RetriesExhausted`/`Error` pair +
`record_failure` and returns `Fatal`. `RateLimited` parking (#2677) is
untouched inside the ladder — the two recoveries are disjoint by error
class.

Net: −60 lines, driver.rs well under its ceiling.

## Evidence

- Both features' witnesses pass on the composition: `context_overflow`
(3/3) and `parked_wait` (6/6).
- `cargo test -p stella-core --lib`: 1124 passed / 0 failed.
- clippy `--all-targets -- -D warnings` clean; `cargo fmt --all`
applied; unpiped `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p
stella-core` exit 0.
- `check-module-reachability`: OK (954 files, all reachable).
`check-file-size`: OK.

## Companion

The stella-model half of the red main (adapter_sources collision + doc
link) is **PR #2754** — the two PRs touch different crates and compose;
both are needed for green.

## Deleted tests

None. (The deleted inline block was production code duplicated by the
merge, not tests.)

Refs #2752 #2744 #2680 #2677

## Summary by Sourcery

Restore stella-core’s model call retry ladder after a bad merge so main
compiles and error handling is correctly composed.

New Features:
- Propagate model call failures from the rate-limit ladder via
ModelCallFailure, including explicit ContextOverflow handling.

Bug Fixes:
- Fix a broken doc comment in driver.rs that prevented stella-core from
compiling.
- Reintroduce the rate_limit module alongside overflow_recovery to
restore module reachability.
- Ensure ContextOverflow and fatal error handling are wired through the
unified drive_attempt_ladder path instead of duplicated inline logic.

Enhancements:
- Centralize terminal event emission and provider outcome recording
inside the rate_limit ladder, reducing duplication and shrinking
driver.rs.
macanderson added a commit that referenced this pull request Aug 10, 2026
…resolve through the router, repair the transcript, continue the turn (#2769)

## What & why

The engine held one provider for the whole turn, so a retry ladder
exhausting against a wedged provider ended the turn `Aborted { Failure
}` even when a healthy fallback was configured and resolvable
(`driver.rs`'s abort path) — every completed step's work stranded. This
PR is the last ticket of the Phase 2 reliability chain: on
retries-exhausted the engine now re-resolves through the router and
continues the turn on the replacement.

**Trigger classes.** Everything that surfaces as the new
`ModelCallFailure::Exhausted` — transport, 5xx, auth, and rate limiting
that outlived #2744's parked recovery. `ContextOverflow` keeps its own
rung (#2752); #2748's stream fallback still runs below, inside the
attempt. The park composes rather than races: parking happens *inside*
the ladder, the fallback only after the ladder gives up, and a soft stop
typed during a park still wins at the next step boundary.

**Router interplay (the #2734 seam, used as designed).** The fallback is
a re-resolution via the new `stella_core::ports::FallbackResolver` port,
never a hardcoded list. `drive_attempt_ladder` feeds `record_failure`
**before** settlement asks for a fallback, so `Router::resolve` already
routes around the sick provider; a resolution landing back on the failed
provider is a refusal and the turn aborts exactly as before. The bare
CLI loops attach a router-backed `SessionFallback` (`agent/engine.rs`)
at both `run_turn` engine sites — the `session_router` doc's declared
destiny for #2679.

**Transcript repair, deterministic.** The failed call appended nothing,
so the engine's own path is already well-paired; caller-supplied history
with an orphaned `tool_use` is closed through the same
`close_open_tool_calls` repair the cancel/soft-stop exits use (stub
named for the swap, mirrored onto the event stream). Model-signed
thinking blocks — the other thing a naive switch replays into a 400 —
are structurally absent: `CompletionMessage` carries no reasoning
blocks, so there is nothing to strip; the module doc records that
argument.

**Latch/bound.** `Engine::provider_override` is a set-once cell and the
set IS the latch: at most one swap per engine, ever — two sick providers
cannot ping-pong. The override persists for the engine's remaining
turns; the breaker's cooldown/half-open cycle is what routes fresh
engines back to a recovered primary.

**What consumers see.** The ladder now *withholds* the terminal
`RetriesExhausted`/`Error` pair (mirroring #2752's overflow arm) and
settlement emits it only when no fallback fires — byte-identical to the
old shape, pinned by a control test. A latched swap emits the existing
`AgentEvent::ProviderFallback` (+ a retryable `Error` notice), so **no
new AgentEvent variant** and no new consumer-ledger row (invariant #10
satisfied by reuse); the variant's doc and the generated `docs/wire`
descriptions are updated (description-only diff, no shape change).

**Accounting.** Every burned attempt still bills through the per-attempt
`UsageIncomplete` observer — witnessed. No timings ride `ToolOutput`.

**God files.** New logic is in sibling modules
(`driver/model_fallback.rs`, the `overflow_recovery.rs`/`settlement.rs`
pattern; tests in `driver/tests/model_fallback.rs`). `agent.rs` sat
exactly at its ceiling, so the self-contained budget helpers moved to a
new `agent/budget.rs` (move, not rewrite; re-exported so every caller
path is unchanged) — agent.rs lands 40 lines *under* its inherited size.

**Parity (invariant #8's cross-surface sibling).** `stella-parity` gains
the `provider.midturn_fallback` row claiming `with_fallback_resolver`
(the entry-point sweep enforces this); CLI posture is
`ShippedUnwitnessed` with the gap cited (#2733 for the attachment
witness, #2765 for pipeline wiring), API `NotApplicable` for the same
reason as `provider.breaker_feedback`. `UNWITNESSED_BASELINE` 4 → 5 —
the declared-debt direction the ratchet exists to make visible, not an
expedient.

**Exemplar.** The module shape, withheld-events discipline, latch bound,
and test suite deliberately mirror `driver/overflow_recovery.rs` +
`driver/tests/context_overflow.rs` (#2752), this repo's canonical
recovery-rung shape.

Closes #2679
Refs #2733, #2734, #2744, #2748, #2752, #2765

## The witness

- [x] This PR includes a witness test (fails on `main`, passes here)

`crates/stella-core/src/driver/tests/model_fallback.rs`:

-
`exhausted_retries_swap_to_the_resolved_fallback_and_the_turn_completes`
— the headline #2679 witness: terminally failing primary, healthy
resolved fallback; on main the turn aborts, here it completes with the
terminal channels silent and the swap announced.
- `a_sick_fallback_is_never_swapped_again` — the bound: one ladder per
provider, the resolver asked exactly once, second exhaustion terminal
(no ping-pong).
- `the_transcript_handed_to_the_fallback_is_well_paired` — pairing
asserted on the transcript the replacement provider *actually received*,
orphan stubbed with the swap's wording.
- `every_attempt_before_the_swap_is_billed_through_usage_incomplete` — 1
attempt + 2 retries = 3 envelopes, then the rescue.
- Controls: `without_a_resolver_the_terminal_surfacing_is_unchanged`
(the relocated emission is byte-identical),
`a_resolution_back_onto_the_failed_provider_is_refused`.

**Flip evidence, artisanal (the ablation form, as in #2752 — the true
on-main run cannot compile because the port doesn't exist there):** with
the one interception main lacks disabled (`if false &&
self.attempt_provider_fallback(...)` in `settle_model_call_failure`),
the suite runs `2 passed; 4 failed` — all four behavior witnesses FAIL
(turn aborts, exactly main's behavior) while both abort-path controls
stay green; restored, `6 passed; 0 failed`.

## The gate

- [x] `cargo fmt --check` (via `make guards-fast`, exit 0 — all
toolchain-free guards incl. file-size, god-files, left-behind,
typed-errors, module-reachability)
- [x] `cargo clippy -p stella-core -p stella-cli --all-targets -- -D
warnings` — exit 0
- [x] `cargo test -p stella-core -p stella-cli -p stella-parity` — 1130
+ 1693 + 9 (+ integration targets), 0 failed; `cargo test -p
stella-pipeline -p stella-serve -p stella-protocol` — 0 failed
- [x] `make wire-schema` — regenerated, description-only diff (read
above)
- [x] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p stella-core -p
stella-cli -p stella-parity -p stella-protocol --document-private-items
--keep-going` — exit code 0, checked unpiped
- [x] Docs updated where behavior changed (module docs, event doc,
parity row, `session_router` doc un-staled)
- [x] CLA signed
- [x] `Closes #2679` appears both above and as a commit trailer

## Nothing left behind

- [x] Filed: #2765 — pipeline execute-stage (and serve/runtime) wiring
for the `FallbackResolver` port, written as a handoff. The
CLI-attachment witness gap was already tracked in #2733 (cited by the
parity row rather than duplicated).

## Ground-rule check

- [x] No I/O added to `stella-core` (the resolver is a port; the CLI
impl owns the adapter build); no new deps
- [x] No new outbound network calls
- [x] No new cross-boundary types — `ProviderFallback` reused;
`docs/wire` diff is description-only

## Anything reviewers should know?

- Terminal-event emission moved from `drive_attempt_ladder` into
`settle_model_call_failure` (a settlement decision now that exhaustion
has a recovery). The control test pins the observable shape; the one
ordering change is that the `model.request.failed` bus signal now
precedes the terminal pair — cross-channel (hook bus vs event stream),
so no consumer can order them anyway.
- `ModelCallFailure::Fatal` is renamed/retyped to `Exhausted { message,
attempt_reasons, retryable }` — it is `pub(crate)`, no external surface.
- Sub-agent child engines deliberately do NOT inherit the resolver: a
child's provider is the spec's explicit (possibly pinned cross-family)
choice; the commented decision is at `subagent.rs`'s engine assembly.
- The prompt cache is necessarily cold on the replacement provider — the
documented cost of finishing the turn at all.

## Summary by Sourcery

Introduce mid-turn provider fallback in the engine so exhausted retry
ladders can re-resolve through the router and continue the turn on a
replacement provider, while preserving existing terminal behavior when
no fallback is available.

New Features:
- Add a FallbackResolver port and mid-turn provider fallback mechanism
that re-resolves the worker role via the router after retries are
exhausted and continues the turn on a replacement provider.
- Wire bare CLI loops to attach a session-scoped router-backed
SessionFallback so non-pipeline runs can benefit from mid-turn provider
failover.

Bug Fixes:
- Ensure exhausted retry ladders no longer abort turns when a healthy
fallback provider is configured and resolvable, preventing work from
being stranded mid-turn.

Enhancements:
- Refine engine failure handling so retry exhaustion is settled in
Engine::settle_model_call_failure, enabling either provider fallback or
terminal surfacing with unchanged external shape.
- Ensure transcript repair and billing behavior around mid-turn provider
swaps keep tool-call pairing valid and preserve per-attempt
UsageIncomplete accounting.
- Add a one-swap-per-engine latch and route all dispatch/attribution
through the active provider, avoiding provider ping-pong and
mis-attribution after a fallback.
- Clarify AgentEvent::ProviderFallback semantics to cover both
resolution-time and mid-turn provider substitutions without changing the
wire shape.
- Keep sub-agent engines opt-out of fallback so their explicitly chosen
providers surface failures back to the parent tool call.

Documentation:
- Update wire schema docs, parity capabilities, and session_router
documentation to describe the new mid-turn fallback behavior and its
current CLI and API posture.

Tests:
- Add driver/model_fallback tests that witness successful mid-turn
fallback, enforce the one-swap bound, verify transcript repair and
accounting, and confirm terminal behavior remains byte-identical when no
fallback is attached.

Chores:
- Extract session budget helper functions from agent.rs into a dedicated
agent/budget.rs module to keep the god file under its size ceiling.
- Extend stella-parity capabilities with a provider.midturn_fallback
entry and bump the unwitnessed baseline to track the new unwitnessed CLI
posture.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant