Skip to content

fix(coding-agent): retry provider stream stalls on the shared bounded budget - #845

Merged
code-yeongyu merged 7 commits into
mainfrom
feat/stall-shared-retry
Aug 13, 2026
Merged

fix(coding-agent): retry provider stream stalls on the shared bounded budget#845
code-yeongyu merged 7 commits into
mainfrom
feat/stall-shared-retry

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Problem

A session hitting a hung provider ended the turn after a single same-model retry:

Error: Provider stream start timed out after 90000ms
Error: Provider stream start timed out after 30000ms
Error: Retry failed after 1 attempts: Provider stream start timed out after 30000ms

Two behaviors combined to produce this, and both are reversed here.

1. Stalls bypassed the shared retry budget. agent-session.ts tracked consecutive
provider-stream stalls and escalated the second one straight to the fallback chain,
skipping settings.retry.maxRetries. With no fallback chain configured the retry loop
surrendered - hence Retry failed after 1 attempts while maxRetries was 3.

2. Retries shrank the configured timeouts. provider-timeout-retry.ts clamped both
provider guards with Math.min(configured, retry.provider.streamRetryTimeoutMs) (default
30s), so a configured 90s stream-start budget became 30s on the retry. That is the second
error line: the retry was judged dead on a deadline the operator never configured.

Change

  • Provider-stream stalls are ordinary transient failures. isRetryableErrorMessage already
    classifies both watchdog wordings as retryable, so they now consume the same bounded
    same-model budget as every other transient class and escalate to the fallback chain only on
    exhaustion. The streak counter and its escalation branch are gone.
  • The retry request keeps the configured timeoutMs/streamStartTimeoutMs. No timeout value
    is reduced. streamRetryTimeoutMs still bounds the retry continuation
    (runBoundedRetryContinuation), so a wedged retry is still cancelled - without lying to the
    provider about its deadline. Disabled guards are still never re-enabled.

This intentionally reverses the two 2026-07-29 entries in src/changes.md; both are recorded
in the new entry.

Verification

Failing-first, then green (TDD).

Proof Before After
retry-fallback-stall-shared-budget.test.ts RED: callCount 2, expected 4 4/4 pass
provider-timeout-retry.test.ts RED: streamStartTimeoutMs 90000 -> 30000 4/4 pass

Regression sweep - 313 tests green: test/suite/retry-fallback (17 files, 196),
agent-session-retry-events (21), packages/ai/test/retry.test.ts (45), agent stream-watchdog
suites (51), settings-manager (69), provider-idle-{recovery,steering} (6).

Real-CLI QA (channel 3). mock-loop-stall-fallback.mjs drives the built CLI against an HTTP
server that writes SSE headers and then sends nothing:

[PASS] the real CLI spends the shared same-model retry budget on stalls before falling back
       code=0 marker=1 primary=4 (expected 4) fallback=1 elapsedMs=14490
[PASS] real auth unchanged

primary=4 is 1 initial request + 3 retries; it was 2 before this change.
mock-loop-stream-retry.mjs also passes 2/2.

Gates. Root npm run check rc=0; changelog gate PASS.

Note: mock-loop-transport-timeout-recovery.mjs fails identically on untouched main
(it mkdirs process.execPath from a stray argv flag) - pre-existing, untouched by this PR.

Tests updated, with reasons

  • retry-fallback-stall-escalation.test.ts -> retry-fallback-stall-shared-budget.test.ts:
    the old file pinned the 2-stall escalation this PR removes. The contract worth keeping
    (full same-model budget) is asserted for both stall wordings in the replacement.
  • provider-idle-{recovery,steering}.test.ts: only the capped-timeout arithmetic changed
    (30000 -> the configured value). Every steering, queue-retention, and continuation-bound
    assertion is unchanged - the recovery suite still proves the continuation expires at 30s
    while the provider request keeps its full 300s/90s guards.

Plan: .omo/plans/stall-shared-retry.md


Summary by cubic

Retries provider-stream stalls on the shared same‑model budget and preserves configured provider timeouts, avoiding early surrender and misreported 30s retries. Previously the second consecutive stall bypassed retry.maxRetries, and retries shrank timeoutMs/streamStartTimeoutMs to retry.provider.streamRetryTimeoutMs (30s).

  • Review notes

    • packages/coding-agent/src/core/agent-session.ts: remove stall streak/escalation; treat stalls as ordinary transient errors; exclude from 429-tier routing; fallback only when the shared same-model budget is exhausted.
    • packages/coding-agent/src/core/provider-timeout-retry.ts: forward configured timeoutMs/streamStartTimeoutMs; never re-enable disabled guards; set watchdogTimeoutMs = streamRetryTimeoutMs to bound the retry continuation only.
    • Tests: replace stall-escalation suite with stall-shared-budget; add provider-timeout-retry unit; update steering/recovery/recompaction regressions to expect unchanged provider guards; real-CLI QA script asserts 1 + maxRetries primary requests before fallback.
    • Docs: update CHANGELOG.md and src/changes.md.
  • Rollout

    • If you relied on early stall escalation, reduce retry.maxRetries or configure a fallback chain. Otherwise no action; expect up to 1 + retry.maxRetries primary attempts before fallback on stalls.

Written for commit c5f894c. Summary will update on new commits.

Review in cubic

Provider stream watchdog failures ("Provider stream start timed out after
<n>ms" and "Idle timeout waiting for provider stream after <n>ms") were
special-cased: the second consecutive stall escalated straight to the
fallback chain, bypassing settings.retry.maxRetries. Without a configured
chain the turn surrendered after a single same-model probe and surfaced
"Retry failed after 1 attempts".

Stalls are ordinary transient failures (isRetryableErrorMessage already
classifies them so). They now consume the same bounded same-model budget as
every other retryable class and escalate to the chain only on exhaustion.
The retry path capped both provider guards with
retry.provider.streamRetryTimeoutMs (default 30s), so a configured 90s
stream-start budget expired after 30s on the retry - the reported
"Provider stream start timed out after 30000ms" that immediately followed a
90000ms stall. A slow-but-alive provider was judged dead on a deadline it
was never given.

Retries now send the configured timeouts unchanged. streamRetryTimeoutMs
keeps bounding the retry continuation itself, which still cancels a wedged
retry without shortening the provider's own guards.
Adds the fork-change entry and the CHANGELOG [Unreleased] note for the two
retry fixes, and updates the real-CLI stall QA driver to assert the shared
same-model budget (1 + maxRetries primary requests) before fallback.
@code-yeongyu

Copy link
Copy Markdown
Owner Author

CI triage: all 9 failures are pre-existing on main, not introduced by this PR

The same CI workflow run on main (run 31672084580, which predates this branch) fails on the identical job set, with macOS green in both:

Job Cause Also red on main
Terminal tools (ubuntu, windows) Cannot find native binding -> @rolldown/binding-* (npm/cli#4828) yes
Inspector handoff (ubuntu, windows) same rolldown binding yes
Test (coding-agent 1/3, 2/3, 3/3) same rolldown binding yes
Test (workspaces + scripts) scripts/build-all.test.mjs:32 expects a stale package build order (protocol/client placement) yes, byte-identical diff

The rolldown failures are vitest Startup Errors - no test file executes at all, so no assertion from this PR is involved. The build-all.test.mjs assertion is about workspace build ordering; this PR touches no scripts/ file.

Local verification on this branch (macOS, where the binding resolves):

  • retry-fallback suites: 17 files, 196 tests green
  • agent-session-retry-events: 21 green · packages/ai/test/retry.test.ts: 45 green
  • agent stream-watchdog suites: 51 green · settings-manager: 69 green
  • new + updated suites: retry-fallback-stall-shared-budget 4, provider-timeout-retry 4, provider-idle-{recovery,steering} 6
  • root npm run check: rc=0 · Changelog gate: PASS
  • real-CLI QA: primary=4 (expected 4) fallback=1, exit 0

Both main failures need their own fix (a lockfile/optional-dependency repair and a build-all.test.mjs order update); they are out of scope here.

…ompaction

provider-retry-recompaction pinned the retry request to the 30s
streamRetryTimeoutMs cap that this branch removes. The queue-first
assertions this regression exists for - queueAwareContinue call count,
per-request user texts, and the absence of continuation_error - are
unchanged; only the two capped-timeout expectations now read the
configured guards.
@code-yeongyu

Copy link
Copy Markdown
Owner Author

Merge rationale: this PR introduces zero new failures

main is currently red on its own. Comparing this PR's failing set against the main baseline
(run 31675414499 @ 3431f1511) vs this PR
(run 31676125218):

failing test files
main baseline claude-sdk-oauth-auth-lane, claude-sdk-oauth-diagnostic-render, claude-sdk-oauth-observability, claude-sdk-oauth-stream, 494-claude-sdk-oauth-installed-sdk-hook-stop, 6784-claude-sdk-oauth-default-lane
this PR the same six, identical

New failures introduced by this PR: none. Those six claude-sdk-oauth suites have been failing on
main since the v0.84.1 sync; they were invisible until #849 restored the Rolldown platform bindings and
let the Linux jobs actually execute tests. They need their own fix, unrelated to retry policy.

One failure genuinely was mine and is fixed here: provider-retry-recompaction.test.ts pinned the 30s
streamRetryTimeoutMs cap this branch removes (53f5dbf). Its queue-first assertions - queueAwareContinue
call count, per-request user texts, absence of continuation_error - are untouched; only the two capped-timeout
expectations now read the configured guards. It no longer appears in the failing set.

Green on this PR

Static checks · Changelog gate · Cubic · GitGuardian · Terminal tools (macOS/Linux/Windows) ·
Inspector handoff (macOS/Linux/Windows) · Test (workspaces + scripts)

Local evidence

  • 5 touched suites: 18/18 green
  • full packages/coding-agent suite: 7449 passed / 7 failed (RPC + session-lifecycle flakes, none in any suite this PR touches)
  • adjacent regression sweep: retry-fallback 196 · agent-session-retry-events 21 · ai/retry 45 · agent watchdog 51 · settings-manager 69
  • real-CLI QA: primary=4 (expected 4) fallback=1, exit 0

@code-yeongyu
code-yeongyu merged commit 87165ac into main Aug 13, 2026
11 of 15 checks passed
@code-yeongyu
code-yeongyu deleted the feat/stall-shared-retry branch August 13, 2026 07:11
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.

1 participant