Skip to content

fix(session): retry message-only first-event timeouts under bare defaults - #3908

Merged
Yeachan-Heo merged 6 commits into
devfrom
fix/provider-timeout-should-retry
Aug 7, 2026
Merged

fix(session): retry message-only first-event timeouts under bare defaults#3908
Yeachan-Heo merged 6 commits into
devfrom
fix/provider-timeout-should-retry

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Summary

Fixes: "Error: Provider stream timed out while waiting for the first event" should be retryable and retried like other provider non-critical errors — including in anthropic and openai-responses.

Under the default configuration (no explicit retry.* keys), AgentSession.#handleRetryableError gated first-event timeout retries behind typed transport facts (providerCode=stream_first_event_timeout). Message-only watchdog prose — the wrapped canonical "Error: Provider stream timed out while waiting for the first event", the bare canonical form, and the per-provider "Anthropic stream timed out…" / "OpenAI responses stream timed out…" variants — was rejected by the !isTypedFirstEventTimeout term in the scoped bare-default gate once the run had observable activity (e.g. a prior tool execution in the same turn), so the turn died with the surfaced timeout instead of retrying. Typed first-event timeouts already retried in this situation.

Change

  • Add isBareDefaultMessageOnlyFirstEventTimeout(message) — admits content-free, canonical message-only watchdog prose (wrapped canonical form or a bare per-provider variant matching BARE_DEFAULT_WATCHDOG_ERROR) with no conflicting structured facts.
  • Thread it through the bare-default scoped gate and replay-safety disjunct in #handleRetryableError, mirroring the existing typed admission.

This is replay-safe: the watchdog fired before any observable output, so re-issuing the request cannot duplicate side effects.

Fail-closed protections preserved (existing tests stay green)

Tests

  • New: bare-default message-only first-event timeout (wrapped canonical + anthropic variant + openai-responses variant) retried after an earlier tool execution in the same run — verified to fail on dev and pass here.
  • New: wrapped canonical first-event timeout retried on a clean bare-default epoch — verified to fail on dev and pass here.
  • Updated the lifecycle-hook test's onPayload invocation to pass the attempt scope, matching production wiring (sdk/session.ts); this exercises the real scope-dirtying mechanism instead of the blanket !typed gate.
  • bun test: resilient-retry (54) + fallback-upstream-count e2e + provider-retry-status + fallback-attempt-transaction + auto-compaction-oversized (91 total) all pass.
  • bun --cwd=packages/coding-agent run check (biome + tsc) clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c9487fd1a1

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return (
!hasBareDefaultRetryDisqualifyingFacts(message) &&
(message.errorMessage === WRAPPED_PROVIDER_FIRST_EVENT_TIMEOUT_ERROR ||
BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict message-only admission to first-event timeouts

When a bare-default run has already dirtied its replay epoch—for example, after an earlier tool execution—and the next request reports a content-free Anthropic stream stalled while waiting for the next event, this regex branch also sets messageOnlyWatchdogTimeout, bypassing the clean-epoch guard at lines 15115–15118. An idle stall proves that at least one stream event occurred, unlike the replay-safe first-event timeout described here, and because it remains classified as an unbounded transient failure the session can repeatedly reissue a billable request where the previous code surfaced the error; match only the timed out while waiting for the first event alternative.

Useful? React with 👍 / 👎.

Comment on lines +15047 to +15051
// Content-free message-only watchdog prose (wrapped canonical or bare
// per-provider variants) is admitted like the typed path: it is
// replay-safe, so a bare-default retry may re-issue the request even
// after earlier observable activity in the same run.
const messageOnlyWatchdogTimeout = isBareDefaultMessageOnlyFirstEventTimeout(message);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the coding-agent changelog entry

This changes externally observable retry behavior in packages/coding-agent, but the commit leaves packages/coding-agent/CHANGELOG.md unchanged; add the fix under that package's ## [Unreleased] section so it is included in the release record.

AGENTS.md reference: AGENTS.md:L178-L178

Useful? React with 👍 / 👎.

@yazzang-homelab yazzang-homelab 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.

Independent architect review.

I went in expecting to find the replay-safety claim unbacked, and traced it before writing. It holds — but not where the comment says it does, and that gap is worth closing.

The claim and where it is actually enforced. isBareDefaultMessageOnlyFirstEventTimeout (agent-session.ts:942-956) admits on two conditions only:

!hasBareDefaultRetryDisqualifyingFacts(message) &&
(message.errorMessage === WRAPPED_PROVIDER_FIRST_EVENT_TIMEOUT_ERROR ||
 BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))

and hasBareDefaultRetryDisqualifyingFacts (921-927) checks only structured transport factsstatus, providerCode, anthropicErrorType, openaiErrorCode, headers. No content check. So the predicate's own doc comment —

replay-safe: the watchdog fired before the stream produced any observable output

— is a property the predicate does not establish. It is established by the callers:

  • 15050-15062: (firstEventTimeout || messageOnlyWatchdogTimeout) && (assistantMessageHasVisibleOrToolContent(message) || (this.#retryAttempt > 0 && !this.#hasCleanRetryReplaySafety) || ...) → terminal / false.
  • 15092: if (!managedFallback && assistantMessageHasVisibleOrToolContent(message)) return false; before the bare-default admissions.

Both gates fire before any retry, so a watchdog message that also carries visible or tool content cannot be replayed. The fail-closed protection the commit message advertises is real.

The maintainability problem is the asymmetry with its immediate neighbour. isBareDefaultCodexOverload (957-963), twelve lines below, bundles !assistantMessageHasVisibleOrToolContent(message) into the predicate itself. So two adjacent admission predicates for the same gate carry the content requirement at different layers, and the one that does not carry it is the one whose comment asserts it. A future caller reaching for isBareDefaultMessageOnlyFirstEventTimeout outside this method gets an unguarded replay admission and a comment telling them it is safe.

Either move !assistantMessageHasVisibleOrToolContent(message) into the predicate — matching the Codex sibling, and cheap since the callers already short-circuit — or reword the comment to say the caller enforces content-freedom. The first is better; it makes the invariant local to the thing that claims it.

What is well done:

  • Structured facts fail closed. A message carrying status: 503 alongside watchdog prose is not "provably a content-free watchdog abort", and the code says exactly that. This is the right default for prose-based classification.
  • The near-miss coverage is the part that makes prose matching defensible — no-article, trailing-period, and TypeError prefix variants explicitly excluded. Regex classification of provider errors rots silently without adversarial cases pinning the boundary.
  • The typed and message-only paths are now symmetric. The original bug is a good catch: typed first-event timeouts already retried after prior tool activity, message-only prose did not, purely because !isTypedFirstEventTimeout gated it. Two representations of the same provider condition behaving differently is the kind of thing that produces irreproducible bug reports.
  • #hasCleanRetryReplaySafety gating re-retry at 15053 means the second attempt is not admitted on the same reasoning as the first.
  • The test matrix runs both Anthropic and OpenAI models through a real tool call before the failure, which is precisely the "observable activity in the same turn" precondition from the bug report — not a synthetic first-message timeout that would pass regardless.

Non-blocking overall; the content check is enforced today. Please close the layering gap so it stays enforced.

gajae.pr-review-verdict.v1 merge-approved sha256:c52eaed0331453ed3a99d1d7377a10f0e3057617 reviewer:architect evidence:read of agent-session.ts:921-956 and callers at 15050-15062, 15076, 15092-15110 at this head

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/provider-timeout-should-retry branch from c52eaed to eea8481 Compare August 6, 2026 11:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eea84810a1

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -1,1717 +1 @@
# Changelog

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the coding-agent changelog history

The target diff does not merely omit this fix's entry: it deletes the entire 1,716-line changelog, including every released section, and leaves the packaged CHANGELOG.md empty. Consequently, scripts/release.ts skips this package because it can no longer find ## [Unreleased], and published packages lose all historical release notes. This is fresh evidence beyond the earlier missing-entry comment: restore the existing history and add this change only under ## [Unreleased].

AGENTS.md reference: AGENTS.md:L178-L178

Useful? React with 👍 / 👎.

@yazzang-homelab

Copy link
Copy Markdown
Contributor

경고 — 이 PR의 현재 head가 CHANGELOG 전체를 삭제한다

머지하면 안 된다. 확인된 사실:

$ git cat-file -s <이 PR head>:<해당 CHANGELOG 경로>
1

1바이트 — 개행 하나만 남았다. dev의 같은 파일은 312,259 bytes(coding-agent) / 244,785 bytes(ai) / 45,275 bytes(agent)다. 릴리스 이력 전체가 사라진 상태다.

원인은 내 쪽이다

#3932(11:25:32Z 머지)가 .gitattributes에서 packages/*/CHANGELOG.md merge=union을 제거했다. 제거 자체는 근거가 있었다 — union은 충돌을 내지 않고 양쪽을 이어붙여서 이미 릴리스된 섹션에 항목을 조용히 밀어넣고 있었다(#3929, 실측 35건).

그런데 그 결과 리베이스에서 CHANGELOG가 처음으로 진짜 충돌을 내기 시작했고, 그 충돌을 해소하는 과정에서 파일이 비워졌다. 시간대가 명확하다:

시각 (UTC) 사건
11:25:32 #3932 머지 (union 제거)
11:29:29 ~ 11:35:02 #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873작성자 6명, 10개 PR이 전부 1바이트 CHANGELOG로 갱신됨

전환 비용을 예고하지 못한 건 내 잘못이다. 미안하다.

복구

git fetch origin
git checkout origin/dev -- packages/coding-agent/CHANGELOG.md   # 해당 패키지 경로로
# 그 다음 ## [Unreleased] 아래에 이 PR의 항목만 다시 추가
git add packages/coding-agent/CHANGELOG.md
git commit --amend --no-edit    # 또는 새 커밋

앞으로 리베이스에서 CHANGELOG 충돌이 나면 양쪽 항목을 모두 ## [Unreleased] 아래에 남기는 것이 올바른 해소다. 이미 릴리스된 ## [X.Y.Z] 섹션은 손대지 않는다. CONTRIBUTING.md의 "Rebasing onto dev" 절에 적어두었다.

푸시 전에 다음으로 자가 점검할 수 있다:

git cat-file -s HEAD:packages/coding-agent/CHANGELOG.md   # 30만 바이트 근처여야 정상

@yazzang-homelab yazzang-homelab 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.

앞선 내 승인을 철회한다. 이 head는 머지하면 안 된다.

내가 승인한 시점 이후 head가 force-push됐고, 새 head에서 CHANGELOG가 파괴됐다. 승인은 그 이전 커밋에 대한 것이었으므로 현재 head에는 유효하지 않다.

코드 리뷰 내용 자체는 그대로 유효하다 — 로직에 대한 판단은 바뀌지 않았다. 되돌리는 것은 머지 가능 판정뿐이다.

원인은 내가 머지한 #3932다(union 드라이버 제거). 상세와 복구 명령은 이 PR에 이미 남긴 코멘트와 #3942 에 있다. CHANGELOG를 origin/dev에서 복원하고 이 PR의 항목만 다시 넣은 뒤 푸시하면, 그 head에 대해 즉시 재승인하겠다.

#3941(CI 가드)이 머지되면 이 상태는 CI에서 자동으로 걸린다.

Yeachan-Heo pushed a commit that referenced this pull request Aug 6, 2026
Removing `packages/*/CHANGELOG.md merge=union` in #3932 was correct --
union never conflicts, it concatenates both sides of an overlapping hunk,
which silently filed entries into versions that had already shipped (35
such entries audited on dev, #3929). What it did not account for is the
transition: these files now conflict on rebase for the first time, and a
bad resolution drops the whole history with no marker.

That is not hypothetical. #3932 merged at 11:25:32Z. Between 11:29:29Z
and 11:35:02Z, ten open pull requests across six authors force-pushed
heads whose CHANGELOG was a single newline -- every released section
gone. #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873.
Nothing caught it: the files still parse, no test reads them, and the
loss looks like a large deletion inside an otherwise legitimate diff.

The guard asserts the one property that matters and nothing more: every
`## [X.Y.Z]` heading present at the merge base must still be present at
the head. Additions pass, rewording passes, and a release commit that
consumes `## [Unreleased]` into a new version passes. Only losing a
released section fails, and the message names the recovery command.

Runs in `affected-plan`, which already checks out full history and
carries the immutable event base sha, so it costs one bun invocation and
needs no new job.

Constraint: a release bump must still be able to add a version heading
Constraint: must not depend on byte-size heuristics -- a legitimately
  small changelog is not a violation
Rejected: threshold on deleted line count | fires on large legitimate
  edits and misses a small changelog emptied completely
Rejected: restore merge=union | reinstates the silent misfiling this
  replaced, and GitHub ignores the driver anyway
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: bun test scripts/changelog-history-guard.test.ts (11 pass);
  guard run against the three real broken heads (#3873 #3920 #3869)
  exits 1 and names the lost sections; clean range exits 0;
  bun run check:tools exit 0
Not-tested: a real release-bump PR end to end
Yeachan-Heo pushed a commit that referenced this pull request Aug 6, 2026
Removing `packages/*/CHANGELOG.md merge=union` in #3932 was correct --
union never conflicts, it concatenates both sides of an overlapping hunk,
which silently filed entries into versions that had already shipped (35
such entries audited on dev, #3929). What it did not account for is the
transition: these files now conflict on rebase for the first time, and a
bad resolution drops the whole history with no marker.

That is not hypothetical. #3932 merged at 11:25:32Z. Between 11:29:29Z
and 11:35:02Z, ten open pull requests across six authors force-pushed
heads whose CHANGELOG was a single newline -- every released section
gone. #3920 #3697 #3870 #3908 #3887 #3864 #3729 #3869 #3866 #3873.
Nothing caught it: the files still parse, no test reads them, and the
loss looks like a large deletion inside an otherwise legitimate diff.

The guard asserts the one property that matters and nothing more: every
`## [X.Y.Z]` heading present at the merge base must still be present at
the head. Additions pass, rewording passes, and a release commit that
consumes `## [Unreleased]` into a new version passes. Only losing a
released section fails, and the message names the recovery command.

Runs in `affected-plan`, which already checks out full history and
carries the immutable event base sha, so it costs one bun invocation and
needs no new job.

Constraint: a release bump must still be able to add a version heading
Constraint: must not depend on byte-size heuristics -- a legitimately
  small changelog is not a violation
Rejected: threshold on deleted line count | fires on large legitimate
  edits and misses a small changelog emptied completely
Rejected: restore merge=union | reinstates the silent misfiling this
  replaced, and GitHub ignores the driver anyway
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: bun test scripts/changelog-history-guard.test.ts (11 pass);
  guard run against the three real broken heads (#3873 #3920 #3869)
  exits 1 and names the lost sections; clean range exits 0;
  bun run check:tools exit 0
Not-tested: a real release-bump PR end to end
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/provider-timeout-should-retry branch from 5e0c1de to 872b6aa Compare August 6, 2026 13:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 872b6aa82a

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const requiresScopedFirstEventTimeout = managedFallback || !legacyRetryConfigured;
if (
firstEventTimeout &&
(firstEventTimeout || messageOnlyWatchdogTimeout) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep subsequent clean watchdog retries eligible

When a prior tool execution has dirtied the run epoch and the provider returns this message-only first-event timeout twice in succession, the first failure is now retried, but the second enters this expanded branch with #retryAttempt > 0 and #hasCleanRetryReplaySafety === false, so it is surfaced even though the second attempt's scope is clean. This makes the new behavior tolerate only one watchdog failure after earlier activity and prematurely ends transient Anthropic/OpenAI outages that otherwise use unbounded retries; use the clean current attempt scope for every admitted message-only first-event timeout rather than reapplying the run-wide dirty-epoch check after retry one.

Useful? React with 👍 / 👎.

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/provider-timeout-should-retry branch 2 times, most recently from 92177aa to e2d4dcf Compare August 6, 2026 21:23
@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

Re-open to re-trigger exact-head Dev CI after rebase onto origin/dev 3832188 (provider baselines).

@Yeachan-Heo Yeachan-Heo closed this Aug 6, 2026
@Yeachan-Heo Yeachan-Heo reopened this Aug 6, 2026
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/provider-timeout-should-retry branch from e08c209 to 2ed0a9c Compare August 6, 2026 23:26
Yeachan-Heo and others added 6 commits August 7, 2026 01:33
…ults

Under the default config (no explicit retry.* keys), the scoped
first-event-timeout gate in #handleRetryableError required typed
transport facts (providerCode=stream_first_event_timeout). Message-only
watchdog prose — the wrapped "Error: Provider stream timed out while
waiting for the first event", the bare canonical form, and the
per-provider "Anthropic stream timed out..." / "OpenAI responses stream
timed out..." variants — was blocked by the !isTypedFirstEventTimeout
term once the run had observable activity (a prior tool execution in the
same turn), so the turn died with the surfaced timeout instead of
retrying like other provider non-critical errors. Typed first-event
timeouts already retried in this situation.

Admit content-free message-only watchdog prose in the bare-default
scoped gate and replay-safety disjunct, mirroring the typed admission.
The prose is replay-safe: the watchdog fired before any observable
output, so re-issuing cannot duplicate side effects. Fail-closed
protections are preserved: visible content, conflicting structured
facts (status 503), near-miss prose (no article / trailing period /
TypeError prefix), extension-hook scope participation, Alibaba/Kimi
terminal policy, ollama-cloud bounded retry, and legacy-config unbounded
first-party transient behavior (#713).

Constraint: preserve typed-only admission when scope tracking is unavailable
Constraint: never retry when visible content or extension hooks participated
Rejected: reclassify per-provider variants to first_event_timeout | flips legacy unbounded retry to bounded (#713 scope guard)
Rejected: blanket hasCleanRetryReplaySafety for prose | over-blocks after prior tool results (the very case being fixed)
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: agent-session-resilient-retry (54) + fallback-upstream-count e2e + provider-retry-status + fallback-attempt-transaction + auto-compaction-oversized (91 total) + coding-agent check
Not-tested: live provider first-event timeout e2e against a real API
…out retry

Adds the Unreleased changelog entry for the bare-default first-event
timeout retry fix.

Confidence: high
Scope-risk: none
Reversibility: easy
isBareDefaultWrappedFirstEventTimeout is subsumed by
isBareDefaultMessageOnlyFirstEventTimeout (wrapped canonical prose plus
the BARE_DEFAULT_WATCHDOG_ERROR variants); the bare-default admission
disjunct now reads the shared messageOnlyWatchdogTimeout predicate.

Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: resilient-retry + fallback-upstream-count e2e + provider-retry-status + fallback-attempt-transaction + auto-compaction-oversized (91) + coding-agent check
…treamingSession

The OpenAI test case in the message-only first-event timeout regression
test resolves credentials through AgentSession's modelRegistry/authStorage
path, which only had anthropic set in beforeEach. buildBareStreamingSession
now sets the runtime key for the model's provider like buildStatusErrorSession
and buildModelSession already do.

Lore-id: test-credential-isolation-3908
Confidence: high
Scope-risk: narrow
Reversibility: trivial
Tested: targeted test suite will run in CI
Not-tested: local bun test (node-pty native build unavailable in this environment)
Exact-head CI must re-run after the post-provider-baseline rebase.
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/provider-timeout-should-retry branch from 2ed0a9c to 58913bb Compare August 7, 2026 01:34
@Yeachan-Heo
Yeachan-Heo merged commit ce3eedc into dev Aug 7, 2026
33 checks passed
@Yeachan-Heo
Yeachan-Heo deleted the fix/provider-timeout-should-retry branch August 11, 2026 23:01
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.

2 participants