Skip to content

fix(claude-sdk-oauth): fork stream-start-timeout retries at the pre-turn boundary (fixes #723) - #1034

Merged
code-yeongyu merged 8 commits into
mainfrom
fix/issue-723-timeout-retry-continuity
Aug 20, 2026
Merged

fix(claude-sdk-oauth): fork stream-start-timeout retries at the pre-turn boundary (fixes #723)#1034
code-yeongyu merged 8 commits into
mainfrom
fix/issue-723-timeout-retry-continuity

Conversation

@code-yeongyu

Copy link
Copy Markdown
Owner

Summary

Fixes the retry-storm re-billing from #723: a stream-start timeout abort pushed the turn's user payload into the resident SDK lineage but never advanced the continuity binding, so the same-turn retry re-attached and re-appended the identical user message — the transcript grew ~8K per attempt and the whole conversation was re-billed at full price on every retry (measured $25/6min; $1,084 over 3 days on worker dispatch, per the issue comment).

  • session-turn-attempt.ts: an attempt that ends aborted/failed/discarded now remembers an in-memory retry checkpoint — the binding anchored at the pre-turn boundary plus the attempted turn's full sent-stream digest (unansweredTurnDigest).
  • session-continuity.ts: when the SAME turn retries (full-turn digest matches), decideFromBinding forks at the pre-turn assistant boundary instead of re-attaching, so the retry's request byte-layout matches the failed attempt and rides the prefix cache; observed as continuity reason timeout_retry. A stalled first turn with no boundary re-seeds byte-identically (cache read after the first write).
  • Core identity invariant pinned: 723-provider-timeout-retry-request-identity proves the plain retry path sends context.messages deep-equal to the failed attempt (with mutation proof), and the stale streamRetryTimeoutMs docstring now matches the reconciled watchdog semantics.
  • QA harness repair: claude-sdk-oauth-fullstack-harness.mjs predates the feat(auth): make ambient Claude and Cursor CLI lanes explicit opt-in #969 ambient opt-in gate — it now seeds the sandbox CLAUDE_CONFIG_DIR/.credentials.json and sets the enabled opt-in, and gains stallNextResponse() (headers flushed, first SSE event withheld). New probe claude-sdk-oauth-stream-stall-retry-probe.mjs.

Root cause

Layer Behavior before After
Mid-conversation stall retry re-attach → append same user msg again (transcript grows per attempt, cache never reads) fork at pre-turn assistant boundary, delta-only re-send, prefix cache read
First-turn stall retry fresh full bootstrap per attempt byte-identical bounded re-seed (cache read after first write)
Plain API retry already byte-identical (undocumented, unguarded) regression test + mutation proof

QA & Evidence

  • Failing-first regression: 723-claude-sdk-oauth-timeout-abort-retry-continuity.test.ts — RED captured (red-c1.txt: maxSendsPerLineage 2 !== 1, double cold-seed), GREEN after fix (green-c1.txt, 2/2).
  • Guard: 723-provider-timeout-retry-request-identity.test.ts (GREEN; mutation proof red-c2-mutation.txt fails the assertion when the removal is disabled).
  • New unit coverage: claude-sdk-oauth-continuity-retry-checkpoint.test.ts (checkpoint branch fires only for the exact unanswered turn; fail-closed ordering preserved); decision-table + binding-store round-trip cases extended.
  • Suites: all claude-sdk-oauth-*.test.ts + retry suites 54 files / 430 tests pass; test/suite/regressions/ 150 files / 465 tests pass; tsc --noEmit clean.
  • Real two-process loopback surface (deterministic ×2):
turn 1        -> bootstrap / registry_miss
turn 2 stall  -> stream-start watchdog fires, auto_retry_start
turn 2 retry  -> fork (deltaMessages == 1) — no flatten, no bootstrap re-send
providerRequests == 3, stderr empty

Command:

node .agents/skills/senpi-qa/scripts/claude-sdk-oauth-stream-stall-retry-probe.mjs --evidence issue-723-retry-continuity

The probe uses an isolated agent dir, a 127.0.0.1-only loopback SSE server, a seeded dummy credential store, and cleans up its subprocesses, server, and sandbox.

Risks & Residuals

Related Issues

…urn boundary (fixes #723)

A stream-start-timeout abort pushed the turn user payload into the resident SDK
lineage but recordSyncedStream never ran, so the in-memory binding kept pointing
at the pre-turn boundary. The same-turn retry then re-attached and re-appended
the identical user message, growing the transcript ~8K per attempt and re-billing
the whole conversation every retry.

Remember an in-memory retry checkpoint (the attempted turn full sent-stream
digest) on aborted/failed/discarded attempts. When the retry re-sends that exact
turn, decideFromBinding forks at the pre-turn assistant boundary instead of
reattaching, so the retry byte-layout matches the failed attempt and rides the
prefix cache. A stalled first turn with no boundary re-seeds byte-identically.
Observed as continuity reason timeout_retry.
…#723)

The plain retry path already removes the terminal error assistant before
re-sending, so failed and retried requests carry identical context.messages.
Pin that invariant as a regression guard with a mutation proof (disabling the
removal flips the assertion).
…watchdog

The comment claimed the setting clamps the stream-start guard; since the
2026-08-18 reconciliation it caps only the retry-continuation watchdog at
max(cap, streamStartTimeoutMs). Comment-only.
…th (issue #723)

The fullstack harness gains stallNextResponse() (headers flushed, first SSE
event withheld) so a turn can stall at stream start against the loopback server.
The new probe stalls a continuation turn, lets auto-retry fire, and asserts every
post-stall continuity decision forks with deltaMessages == 1 — no flatten, no
re-bill. Also repairs the harness for the post-#969 ambient opt-in gate: seeds
the sandbox CLAUDE_CONFIG_DIR credential store and sets the enabled opt-in.
…-retry-continuity

# Conflicts:
#	packages/coding-agent/CHANGELOG.md
#	packages/coding-agent/src/core/changes.md
…-retry-continuity

# Conflicts:
#	packages/coding-agent/src/core/changes.md
…-retry-continuity

# Conflicts:
#	packages/coding-agent/CHANGELOG.md
@code-yeongyu

Copy link
Copy Markdown
Owner Author

Maintainer note — GitHub Actions never delivered events for this PR. Zero workflow runs exist for every head SHA of this branch (94a067864, 91678d61f, 5115348d53, f52cdeb8e; verified via actions/runs?head_sha=), while sibling PRs (#1033, #1035, cursor-exec) triggered CI/Changelog-gate normally in the same window. Close+reopen did not retrigger. Repo Actions are enabled with allowed_actions: all.

Local equivalents of the required gates, run at the final HEAD f52cdeb8e:

  • Check and test: npm run check exit 0 (biome, pinned-deps, ts-imports, shrinkwrap, install-lock, claude-sdk platform-lock, tsc --noEmit, browser-smoke) + full coding-agent vitest suite (see summary below) + tui-adjacent suites untouched by this diff.
  • Changelog gate: node scripts/check-pr-changelog.mjs --base origin/mainPASS - changes.md coverage complete; changelog entry updated (packages/coding-agent/CHANGELOG.md).
  • External checks: cubic AI reviewer ✓, GitGuardian ✓.
  • Real-surface probe (issue Retry after provider stream-start timeout re-bills the full conversation (cache_control dropped / breakpoints moved) — measured 5.7x cost #723 stall→retry scenario): deterministic PASS ×3 — stalled continuation retries fork at the pre-turn boundary with deltaMessages == 1, no flatten/bootstrap re-send.
  • Failing-first: red-c1.txt (RED: duplicate delta append + double cold-seed) → green-c1.txt (GREEN after fix).

Merging with --admin (merge commit) because the required checks cannot report on a branch GitHub never scheduled, and the equivalents above are green on the exact merged tree.

…-retry-continuity

# Conflicts:
#	packages/coding-agent/CHANGELOG.md
@code-yeongyu
code-yeongyu merged commit f2b6d58 into main Aug 20, 2026
14 checks passed
@code-yeongyu
code-yeongyu deleted the fix/issue-723-timeout-retry-continuity branch August 20, 2026 08:09
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.

Retry after provider stream-start timeout re-bills the full conversation (cache_control dropped / breakpoints moved) — measured 5.7x cost

1 participant