Skip to content

feat(stella-model): streaming→non-streaming fallback on hung or empty streams, with a first-byte deadline (#2686) - #2748

Merged
macanderson merged 3 commits into
mainfrom
worktree-agent-a7b66091415f33682
Aug 10, 2026
Merged

feat(stella-model): streaming→non-streaming fallback on hung or empty streams, with a first-byte deadline (#2686)#2748
macanderson merged 3 commits into
mainfrom
worktree-agent-a7b66091415f33682

Conversation

@macanderson

@macanderson macanderson commented Aug 10, 2026

Copy link
Copy Markdown
Owner

What & why

A provider stream that hangs before its first byte (a proxy buffering the SSE body) or comes back as a 200 with an empty stream used to burn the whole retry budget re-issuing the identical streaming request into the same broken pipe: the per-chunk idle bound only fired after 120s per attempt, classified as retryable Transport, and every retry streamed again — a provider that would answer fine over a non-streaming request was treated as down. This is #2686's zappy-style "reactive resilience" fallback, adapted to Stella's accounting discipline.

Three pieces, all in stella-model (the engine is untouched):

  • A first-byte deadline, distinct from the idle bound. http::FIRST_BYTE_TIMEOUT (90s, matching the comparator's stream watchdog) bounds only the FIRST body read; any chunk at all moves the stream onto the ordinary 120s STREAM_IDLE_TIMEOUT. It composes with — rather than duplicates — the engine's 816s idle-based model deadline: a hang now surfaces in 90s instead of 120s/816s. http::next_stream_read (the new StreamRead enum) keeps Idle distinct from Failed; next_with_timeout is reimplemented on top of it so there is one truth.
  • A latched, bounded recovery (src/stream_recovery.rs): Streaming → Probing → Confirmed. A fallback-eligible fault (hung before the first byte, or the stream died having delivered no completion signal whatsoever) arms the probe; the probe's unary success confirms the latch for the session (a buffering proxy buffers deterministically); a failed probe reverts to streaming, so one unproven fault never condemns the streaming path. The retried attempt re-issues the byte-identical body with stream: false (zai/unary.rs) through http::unary_client's 600s read bound (stella-model: Bedrock's 120s read timeout bounds the entire generation, and the failure is retryable #547's lesson — a unary call has no first token to reset the clock).
  • Accounting rides the existing rails. The recovery is deliberately split across two attempts rather than hidden inside one provider call: the faulted streaming attempt fails as retryable Transport, so the engine's retry ladder stays the single owner of retries and bills the discarded attempt through its existing per-attempt UsageIncomplete observer (driver.rs::run_model_call) — nothing arrived by construction of the eligibility rule, so partial is honestly None, exactly http::partial_usage's "attach nothing rather than a zeroed envelope" discipline. A mid-stream death WITH salvage is deliberately ineligible: it keeps its retry-as-a-stream path and its attach_partial accounting, so there is no partial transcript to tombstone, ever.

Invariant 8 (parity, declared not assumed): the matrix gains its third axis, StreamFallbackPosture. The shared chat-completions adapter's identities (zai, openrouter, xai, deepseek, local, settings-defined gateways) declare UnaryFallback with named witnesses; anthropic/openai/gemini/vertex declare StreamingOnly with the gap tracked in #2746; bedrock is AlwaysUnary (it calls Converse — there is no stream to fall back from). Enforced from both sides like the other two axes: stella-cli's config test fails a seeded provider with no row, and the parity tests fail a row whose witness rotted. AGENTS.md invariant 8 and the crate README are updated in the same PR (stale "two axes" prose is a bug).

Exemplar: the latch follows zappy's watchdog-and-fallback shape; the split-across-attempts structure follows this repo's own retry.rs/UsageIncomplete pattern rather than an internal hidden resend, so accounting and attempt-bounding stay owned by one place.

God files: zai.rs was at its 1565-line ceiling, so the SSE aggregation half moved to zai/stream.rs (the driver/settlement.rs split pattern) and the unary path landed in zai/unary.rs; zai.rs is now under 1500 and its baseline entry is retired (minimal baseline edit — deliberately NOT a full file-size-update regen, to avoid the parallel-merge retighten skew). zai/tests.rs was also at its ceiling, so the three reasoning_aware_max_tokens tests moved to zai/tests/zai_effort.rs (tests moved, not deleted — same names, new file). The streaming and unary paths share one set of assembly helpers (tool_call_input, promote_reasoning_as_text, fold_usage, final_finish_reason) so they cannot drift.

Closes #2686

The witness

  • This PR includes a witness test (fails on main, passes here)

crates/stella-model/src/zai/tests/stream_fallback.rs:

  • a_stream_hung_before_its_first_byte_falls_back_to_a_non_streaming_request — the headline IMPROVEMENT: streaming→non-streaming fallback on hung or empty streams, with a separate first-byte deadline #2686 witness. Wiremock cannot hold a response body open (its delays cover the whole response), so this one uses a minimal hand-rolled std-TCP server that answers a "stream":true request with headers and not one body byte, and a "stream":false request with a real completion. First call faults retryably at the first-byte deadline naming the switch; second call completes unary.
  • an_empty_stream_falls_back_to_a_non_streaming_request — the other broken shape (200 + EOF before any data), wiremock-based; the unary response carries a tool call, proving the fallback parses the whole dialect.
  • Controls: a_healthy_stream_never_arms_the_fallback (every request keeps stream:true), a_stream_that_died_after_content_is_retried_as_a_stream_not_unary (eligibility boundary), a_failed_unary_probe_reverts_the_session_to_streaming (wire sequence stream → unary → stream proves the reversion), plus latch state-machine units in stream_recovery.rs.

Flip evidence, run the artisanal way (git checkout origin/main -- crates/stella-model, inject the empty-stream witness, cargo test -p stella-model an_empty_stream_falls_back):

test zai::tests::an_empty_stream_falls_back_to_a_non_streaming_request ... FAILED
panicked at crates/stella-model/src/zai/tests.rs:1933:
provider transport error: Z.ai stream ended before [DONE] — connection closed mid-response

— on main the fault carries no fallback and the retry re-streams; with the change the same suite is green (382 passed). The hung-first-byte witness additionally cannot compile on main (with_first_byte_deadline and the latch don't exist).

The gate

  • cargo fmt --check
  • cargo clippy -p stella-model -p stella-cli --all-targets -- -D warnings (exit 0)
  • cargo test -p stella-model (382 passed) and stella-cli's seeded-provider parity tests (3 passed) after rebasing onto origin/main at feat(stella-protocol): a signal-consumer ledger — every AgentEvent variant declares what reads it #2720's ledger merge
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p stella-model -p stella-cli — exit 0, checked unpiped
  • Docs updated: AGENTS.md invariant 8 (three axes), crates/stella-model/README.md (layout + god-file list), module docs
  • Closes #2686 appears both here and as a commit trailer

Nothing left behind

Summary by Sourcery

Introduce a streaming→unary fallback in the shared OpenAI-compatible adapter when streams hang before the first byte or return empty, backed by a per-session latch and a first-byte timeout, while keeping retry and usage accounting unchanged.

New Features:

  • Add a per-session streaming recovery latch that can reroute retried attempts from streaming to unary when a stream is hung or empty.
  • Provide a non-streaming (unary) completion path for the shared chat-completions adapter that mirrors the streaming path’s assembly semantics.
  • Define a first-byte timeout for streaming responses to detect hung streams separately from inter-chunk idle time.

Enhancements:

  • Refactor the Zai adapter into separate streaming and unary modules sharing common assembly helpers to prevent divergence between delivery paths.
  • Extend the provider parity matrix with a StreamFallbackPosture axis, declaring each provider’s streaming recovery behavior and enforcing completeness via tests.
  • Add a generalized stream-read helper that distinguishes idle, end-of-stream, and transport failures for more precise handling of streaming faults.

Documentation:

  • Update AGENTS.md and the stella-model README to describe the new StreamFallbackPosture axis and the split Zai streaming/unary modules, including stream-recovery and first-byte timeout plumbing.

Tests:

  • Add a dedicated stream_fallback test suite for Zai covering hung-first-byte, empty-stream, healthy-stream control, mid-stream death, and failed unary probe behaviors.
  • Update parity and CLI config tests to require StreamFallbackPosture rows and witness tests for providers that claim a unary fallback.
  • Move reasoning-aware max-tokens tests into a separate zai_effort test module to stay within file-size limits.

@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 10:00pm

@sourcery-ai

sourcery-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements a streaming→unary fallback for Zai-based providers when streams hang before the first byte or are empty, adds a first-byte timeout to HTTP streaming, introduces a shared stream recovery latch, and wires a new StreamFallbackPosture parity axis and tests, while splitting Zai’s streaming/unary logic into dedicated modules and updating docs and baselines.

File-Level Changes

Change Details Files
Zai provider now supports a per-session streaming→non-streaming fallback path driven by a shared stream recovery latch and refactored streaming/unary aggregation logic.
  • Add StreamRecovery latch and absorb_stream_fault logic to ZaiProvider, including unary_client and first_byte_deadline fields.
  • Route complete_attempt through either streaming aggregation or unary path based on recovery state, reusing a shared request body builder and dispatch helper.
  • Split SSE aggregation and unary parsing into new zai/stream.rs and zai/unary.rs modules with shared helpers for usage folding, tool-call input handling, reasoning promotion, and finish reason computation.
crates/stella-model/src/zai.rs
crates/stella-model/src/zai/stream.rs
crates/stella-model/src/zai/unary.rs
HTTP streaming utilities gain a distinct first-byte timeout and a richer StreamRead classification used by the new fallback logic.
  • Introduce FIRST_BYTE_TIMEOUT constant for first chunk deadline.
  • Add StreamRead enum and next_stream_read helper to distinguish item, end, idle, and failed outcomes.
  • Refactor next_with_timeout to delegate to next_stream_read while preserving existing error mapping for callers that don’t need detailed classification.
crates/stella-model/src/http.rs
A new stream_recovery module defines the shared streaming→unary fallback state machine and eligibility classification for stream faults.
  • Implement StreamFault type with fallback_eligible flag and From for ineligible faults.
  • Implement StreamRecovery with STREAMING/PROBING/CONFIRMED states, use_unary, note_stream_fault, and note_unary_outcome methods.
  • Add unit tests validating state transitions and stickiness behavior of the latch.
crates/stella-model/src/stream_recovery.rs
Provider parity gains a StreamFallbackPosture axis with matrix rows, witness enforcement tests, and axis completeness checks that integrate with stella-cli config tests.
  • Define StreamFallbackPosture enum (UnaryFallback, StreamingOnly, AlwaysUnary) and STREAM_FALLBACK_POSTURE matrix with entries for anthropic, bedrock, openrouter, openai, gemini, vertex, zai, xai, deepseek, and local.
  • Expose stream_fallback_posture lookup and add tests to ensure witness functions exist, provider ids are unique, and all axes share the same set of provider ids.
  • Extend adapter_sources to include new Zai stream_fallback test module.
crates/stella-model/src/provider_parity.rs
Config and documentation are updated to enforce and describe the new stream fallback axis and Zai’s refactoring, including god-file and module layout changes.
  • Add stella-cli config test requiring every seeded provider to declare a StreamFallbackPosture.
  • Update AGENTS.md invariant 8 to describe three axes and mention stream fallback behavior and witness tests, and adjust god-file list for stella-model.
  • Update stella-model README god-file and module descriptions to include zai/stream.rs, zai/unary.rs, stream_recovery.rs, and expanded http.rs role.
crates/stella-cli/src/config/tests.rs
AGENTS.md
crates/stella-model/README.md
crates/stella-model/src/lib.rs
Zai test layout is adjusted to accommodate file size limits and to add explicit witness tests for the new fallback behavior.
  • Add zai/tests/stream_fallback.rs with witnesses for hung-first-byte fallback, empty-stream fallback, healthy-stream control, mid-stream death behavior, and failed unary probe reversion.
  • Register the new stream_fallback test module in zai/tests.rs and move reasoning_aware_max_tokens tests into zai/tests/zai_effort.rs to relieve size pressure.
  • Update file-size baseline data accordingly.
crates/stella-model/src/zai/tests.rs
crates/stella-model/src/zai/tests/stream_fallback.rs
crates/stella-model/src/zai/tests/zai_effort.rs
scripts/file-size-baseline.txt

Assessment against linked issues

Issue Objective Addressed Explanation
#2686 Implement a streaming→non-streaming fallback when the streaming path is broken (stream hangs before first byte or completes as an empty stream), such that the streaming failure is classified as retryable and the retried attempt is issued once via the existing non-streaming path, with partial attempt state discarded.
#2686 Introduce a distinct, shorter first-byte deadline for streaming responses, separate from the inter-fragment idle timeout, to detect buffering proxies quickly.

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 enabled auto-merge (squash) August 10, 2026 21:37
Comment thread crates/stella-model/src/zai/unary.rs Outdated
@macanderson
macanderson disabled auto-merge August 10, 2026 21:47
… streams, with a first-byte deadline

A provider stream that hangs before its first byte (a proxy buffering the
SSE body) or comes back as a 200 with an empty stream used to burn the
whole retry budget re-issuing the identical streaming request into the
same broken pipe — the idle bound only fired after 120s per attempt, and
nothing ever tried the transport that would have worked. Now the shared
chat-completions adapter bounds the FIRST body read by a distinct 90s
first-byte deadline (http::FIRST_BYTE_TIMEOUT), classifies the two
nothing-arrived shapes as fallback-eligible, and arms a bounded
per-session latch (stream_recovery::StreamRecovery) so the caller's
ordinary retry of the faulted attempt re-issues the byte-identical
payload with stream: false through the unary read bound.

The recovery is deliberately split across two attempts rather than hidden
inside one provider call: the faulted attempt fails retryably, so the
engine's retry ladder stays the single owner of retries and bills the
discarded attempt through its existing UsageIncomplete observer. The
latch confirms only on unary evidence (Streaming → Probing → Confirmed),
reverts on a failed probe, and never arms for a mid-stream death with
salvage — partially streamed content keeps its retry-as-a-stream path and
partial-usage accounting, so there is nothing to tombstone.

Provider parity (invariant 8) gains its third axis, StreamFallbackPosture:
the zai-family ids declare UnaryFallback with wiremock witnesses, the
other streaming dialects declare the gap, Bedrock is AlwaysUnary.
zai.rs's aggregation half moves to zai/stream.rs (shared assembly rules
with zai/unary.rs), retiring its god-file baseline entry.

Closes #2686
…, not retryable Transport

Review catch on #2748: the fallback's unary dispatch mapped every send
error to retryable Transport, so once the latch was Confirmed a
generation longer than UNARY_READ_TIMEOUT would be re-issued identically
until the retry budget died — the exact #547 storm, one adapter over.
The unary path now routes send errors through
http::classify_unary_dispatch_error, exactly as bedrock.rs does: a
read-timeout consumed the whole generation bound and is Terminal; a
connect failure generated nothing and stays retryable; the streaming
dispatch's classification is untouched (there the same expiry is a
header stall the next attempt may clear).

Witnessed by a_unary_read_timeout_is_terminal_never_a_retry_storm,
which drives dispatch with a millisecond read bound against a stalled
mock and pins both directions.

Refs #2686
@macanderson
macanderson force-pushed the worktree-agent-a7b66091415f33682 branch from ccb753c to 0ee24e6 Compare August 10, 2026 21:53
…0s read-timeout to a retryable `Transport` error, reintroducing the #547 retry-storm even after the `.send()` path was fixed.

This commit fixes the issue reported at crates/stella-model/src/zai/unary.rs:100

## Status after commit `0ee24e6`

The commit landed a partial fix that matches most of the original suggestion:

- `ZaiProvider::dispatch` now takes a `unary: bool` flag (`zai.rs:1232`) and classifies the `.send()` failure with `http::classify_unary_dispatch_error` when `unary == true` (`zai.rs:1242`), keeping the streaming path's send timeout retryable (`false`).
- Callers were updated: streaming passes `false` (`zai.rs:1085`), unary passes `true` (`unary.rs:97`).

## Remaining bug

The **body read** in `complete_unary_attempt` was left unchanged and still uses the retryable mapping:

```rust
let payload = response
    .text()
    .await
    .map_err(|e| ProviderError::transport(e.to_string()))?;
```

The unary client is built with `http::unary_client()`, whose `.read_timeout(UNARY_READ_TIMEOUT)` = **600s** bounds the *entire* response — head *and* body arrive within that single read window. If the connection succeeds but the body streams too slowly (a generation slower than 600s, or an LB that accepts then black-holes mid-body), `response.text()` yields an `is_timeout()` error.

**Failure mode / concrete trigger:** a Z.ai unary-fallback completion whose body does not fully arrive within 600s. The timeout surfaces as retryable `Transport`, the driver re-issues the *identical* request, and it times out again — repeating until the retry budget is drained. This is exactly the #547 failure mode the `dispatch` send-path fix now guards against; the body read is the last unguarded seam of the same 600s read bound.

## Fix

Classify the `response.text()` error with `http::classify_unary_dispatch_error` (timeout → `Terminal`, connect/ordinary transport → retryable `Transport`), matching the already-fixed `dispatch` send path and `bedrock.rs:768`.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: macanderson <mac@macanderson.com>
@macanderson
macanderson merged commit 3b6abe9 into main Aug 10, 2026
12 checks passed
@macanderson
macanderson deleted the worktree-agent-a7b66091415f33682 branch August 10, 2026 22:00
macanderson added a commit that referenced this pull request Aug 10, 2026
…stream split dangled

The doc-warnings gate documents private items
(--document-private-items), so complete_inner's intra-doc link to a
type #2748 moved into the private zai::stream module fails
rustdoc -D warnings on every push to main, while a plain
`cargo doc --no-deps` stays green locally — which is how it slipped
through. Plain code-font reference with the file named instead.

Verify: RUSTDOCFLAGS="-D warnings" cargo doc -p stella-model --no-deps \
  --document-private-items --keep-going

Refs #2686
macanderson added a commit that referenced this pull request Aug 10, 2026
…2748 and #2752 collided on

Two green PRs composed into a red main: #2748 added
zai/tests/stream_fallback.rs to adapter_sources (bumping the array to
15) and #2752 added http.rs (its bump to 15 from the other side); the
textual merge kept both entries and one length, so
provider_parity.rs no longer compiles under --all-targets and every
cargo test on main is red. The length was a shared cell every
source-adding PR had to write — the same shape that removed the
spelled-out total from GATE_STEPS (#1883) — so the fix returns a slice
and deletes the cell rather than writing 16 into it.

Refs #2686, #2748, #2752
@macanderson

Copy link
Copy Markdown
Owner Author

Audit trail: tests renamed/moved by this PR (the record check-deleted-tests asked for on the merge check — the PR merged before the description could carry it):

  • Renamed (this is the one name the guard flagged): both_axes_cover_the_same_provider_idsall_axes_cover_the_same_provider_ids in crates/stella-model/src/provider_parity.rs, widened to cover the new third axis (StreamFallbackPosture) alongside cache and reasoning.
  • Moved, names unchanged (tree-wide the guard is quiet on these; listed for completeness): an_uncapped_reasoning_turn_gets_thinking_headroom, a_pinned_cap_is_honored_whatever_the_reasoning_setting, a_non_reasoning_turn_still_sends_no_cap — from crates/stella-model/src/zai/tests.rs to crates/stella-model/src/zai/tests/zai_effort.rs (the parent is a god file at its ceiling; the move made room for mod stream_fallback;).
  • No test was deleted.

Also, for anyone reading that merge-check log: the an_empty_stream_falls_back_to_a_non_streaming_request ... FAILED / panicked at zai/tests.rs:1933 lines inside the check-deleted-tests step are not a test run — they are this PR description's own flip-evidence block (the fail-on-main proof) echoed by the guard while scanning the PR body. On merged main the real witness passes: cargo test -p stella-model is green once #2754's adapter_sources repair lands (main is currently red from a #2748×#2752 merge composition unrelated to the fallback behavior).

macanderson added a commit that referenced this pull request Aug 10, 2026
×#2752) + the dangling private doc link (#2754)

## What & why

**Main (f83692c) is red**, from two independent residues of #2748's
admin-merge, and this PR repairs both:

1. **Compile error under `--all-targets`** — a merge composition, not a
bug in either parent: #2748 added `zai/tests/stream_fallback.rs` to
`provider_parity.rs::adapter_sources` (bumping the array length to 15)
and #2752 added `include_str!("http.rs")` from the other side; the
textual merge kept **both entries and one length**, so the array literal
has 16 items behind a `[&'static str; 15]` return type and `cargo
test`/`clippy --all-targets` fail on main (`E0308` at
`provider_parity.rs:617`). The length was a shared cell every
source-adding PR had to write — the exact shape that removed the
spelled-out total from `GATE_STEPS` (#1883) — so the fix returns
`&'static [&'static str]` and **deletes the cell** instead of writing 16
into it, with a comment naming the incident.
2. **rustdoc `-D warnings` failure** — #2748 moved `ToolCallAccumulator`
into the private `zai::stream` module but left `complete_inner`'s
intra-doc link pointing at it. The doc-warnings gate documents private
items (`Makefile:247`, `--document-private-items`), so main fails with
`unresolved link to 'ToolCallAccumulator::announced'` while a plain
`cargo doc --no-deps` stays green — which is how it slipped through
#2748's local check. Repointed as plain code font naming the file.

## The witness

- [ ] Witness test
- [x] No witness needed — both are build/doc repairs whose oracle is the
gate itself, red on `main` and green here:

```
cargo test -p stella-model              # main: E0308, could not compile → here: 390 passed
RUSTDOCFLAGS="-D warnings" cargo doc -p stella-model --no-deps \
  --document-private-items --keep-going # main: unresolved link, exit 101 → here: exit 0
```

## The gate

- [x] `cargo fmt --check`
- [x] `cargo clippy -p stella-model --all-targets -- -D warnings` (exit
0)
- [x] `cargo test -p stella-model` — 390 passed, including #2748's
fallback witnesses and #2752's overflow witnesses together
- [x] gate-identical rustdoc (above), exit 0

## Nothing left behind

Nothing new; the remaining-dialects extension of #2686 stays tracked in
#2746.

Refs #2686, #2748, #2752
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

Development

Successfully merging this pull request may close these issues.

IMPROVEMENT: streaming→non-streaming fallback on hung or empty streams, with a separate first-byte deadline

1 participant