Skip to content

fix(opencode): stop recommending auto-retry for terminal provider failures - #1118

Merged
Astro-Han merged 1 commit into
devfrom
claude/i1105-diag-consistency
Jun 3, 2026
Merged

fix(opencode): stop recommending auto-retry for terminal provider failures#1118
Astro-Han merged 1 commit into
devfrom
claude/i1105-diag-consistency

Conversation

@Astro-Han

Copy link
Copy Markdown
Owner

Why

Part of the #1105 provider-failure classification spine. Earlier slices made classifyRetry read providerFailure.kind, so terminal provider failures (auth / invalid_request / quota_exhausted) are no longer retried. But the run-observability diagnostics layer never got that memo: those failures are recorded through the transport-failure path and classified as external_stream_disconnect, whose retry_safety branch unconditionally returned candidate_safe_auto_retry.

That left two diagnostics signals contradicting each other for the same failure: the recovery decision already records technical_retryable=false, while retry_safety recommended a safe auto-retry.

What

  • Thread the recorded transport failure's retryable flag into retrySafetyFor.
  • In the external_stream_disconnect branch, when the failure is non-retryable (retryable === false), return do_not_auto_retry with a new provider_terminal_failure reason — checked first, before the existing candidate_safe_auto_retry returns.
  • The run-phase classification label (external_stream_disconnect) is intentionally left unchanged. Only the retry-safety recommendation is corrected; this is a diagnostics-layer fix, not a re-taxonomy.

retryable === true and the undefined fallback keep their prior behavior, so genuine stream disconnects still surface as candidate_safe_auto_retry.

End to end

The production wiring already passes retryable through recordAttemptFailureAndDeriveRecovery: retrySignalFor returns {retryable: false} for terminal classifications (when classifyRetry returns undefined or retryAction === "stop"). So the guard fires for real terminal provider failures, closing the inconsistency end to end. halt()'s fallback recordTransportFailure does not set retryable, but it only writes via failure ??= after the attempt loop has already recorded the failure, so the flag is preserved.

Tests

  • New: terminal provider failure (retryable: false) on external_stream_disconnectdo_not_auto_retry / provider_terminal_failure, classification unchanged.
  • New: retryable disconnect (retryable: true) still → candidate_safe_auto_retry / no_visible_output_or_tool_execution.
  • Full run-observability.test.ts suite green (70 pass), tsgo --noEmit clean.

Stays within packages/opencode. No schema field is removed; RetrySafety.reason gains one optional union member. Sessions persist as JSON and this only widens an existing string union, so back-compat holds.

…lures

Terminal provider failures (auth, invalid_request, quota_exhausted) are
recorded through the transport-failure path and classified as
external_stream_disconnect, whose retry_safety branch unconditionally
returned candidate_safe_auto_retry. That contradicted the recovery
decision, which already marks these technical_retryable=false, so the two
diagnostics signals disagreed for the same failure.

Thread the recorded failure's retryable flag into retrySafetyFor and, when
a transport failure is non-retryable, return do_not_auto_retry with the new
provider_terminal_failure reason. The run-phase classification label
(external_stream_disconnect) is left unchanged; only the retry-safety
recommendation is corrected. retryable=true and the undefined fallback keep
their prior behavior, so genuine stream disconnects still surface as
candidate_safe_auto_retry.

The production wiring already passes retryable through
recordAttemptFailureAndDeriveRecovery (retrySignalFor returns
{retryable:false} for terminal classifications), so this closes the
inconsistency end to end.
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@Astro-Han, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 35 minutes and 11 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e8fb093a-dea0-477d-92ef-22544a7dd1ca

📥 Commits

Reviewing files that changed from the base of the PR and between 802bcb8 and f72d77e.

📒 Files selected for processing (3)
  • packages/opencode/src/session/run-observability/recorder.ts
  • packages/opencode/src/session/run-observability/types.ts
  • packages/opencode/test/session/run-observability.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/i1105-diag-consistency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority labels Jun 3, 2026

@github-actions github-actions 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.

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@Astro-Han Astro-Han added tech-debt Supplemental cleanup, maintainability, architecture, test, or quality debt context task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work labels Jun 3, 2026

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request adds support for handling non-retryable transport failures during external stream disconnects. It updates the observability recorder to propagate the retryable status of a failure, recommending do_not_auto_retry when a terminal provider failure occurs. Corresponding types and unit tests have been added to verify this behavior. No review comments were provided, and the implementation is clean and well-tested.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@Astro-Han
Astro-Han merged commit 4f55a10 into dev Jun 3, 2026
34 of 37 checks passed
@Astro-Han
Astro-Han deleted the claude/i1105-diag-consistency branch June 3, 2026 08:23
Astro-Han added a commit that referenced this pull request Jun 23, 2026
…manent DNS failures (#1467)

Transport-disconnect errors were collapsed into one retryable bucket, so a
permanent DNS failure (ENOTFOUND) was retried like a transient blip, and the
real errno never informed retry safety.

Change boundary (opencode session classification):
- Broaden transport errno coverage: EAI_AGAIN, EPIPE, ECONNABORTED,
  EHOSTUNREACH, ENETUNREACH, ENOTFOUND, UND_ERR_CONNECT / HEADERS /
  BODY_TIMEOUT.
- Per-errno retryability: `TransportDisconnect.retryable` becomes a boolean —
  ENOTFOUND (permanent name resolution failure) is non-retryable; EAI_AGAIN and
  other transient transport errnos still retry.
- `fromError` passes `retryable` through; `classifyRetry` gives
  `transport_disconnect` its own branch that reads `isRetryable` (removed from
  RETRY_TRANSIENT_KINDS). `retrySignal.retryable` also feeds run-observability
  retry_safety, so ENOTFOUND routes to #1118's provider_terminal_failure.
- `statusCode` short-circuit on top: an error carrying an HTTP status is judged
  as an API error by status and is no longer mis-grabbed by a transport-coded
  cause or a message match (e.g. "socket hang up").

Review follow-ups (codex, 2 rounds, final clean): message-fallback mis-grabbing
HTTP "socket hang up" and a transport-coded cause mis-grabbing HTTP — both fixed
by the statusCode short-circuit.

Verification: opencode suite 1328 pass / 0 fail; `tsgo --noEmit` clean; full CI
green (43 checks). Labels bug + harness + P2.

Refs #1123, #1105, #1118. Part of the classify->passthrough->render series
(PR1 #1466 merged; #1468 run-incident terminal cause, #1469 frontend decode
still open).

Residual: none specific to this PR.
Astro-Han added a commit that referenced this pull request Jun 23, 2026
…nd stop overwriting their message

A DeepSeek direct account in arrears returns 402 "Insufficient Balance",
but it surfaced as "Connection lost. Please check whether the last
operation completed before resending." Two of the three root-cause layers
live here (the third, 402 classification, is PR #1466):

1. run-incident had only watchdog/transport terminal causes, so every
   provider API rejection was recorded as provider_transport_disconnect.
2. halt() overwrote the real provider message with a generic connection-lost
   recovery string.

Changes:
- New provider_api_error TerminalCause (run-incident) and Classification
  (run-observability), subcategory from providerFailure.kind. Bumps both
  RUN_INCIDENT_SCHEMA_VERSION and RunObservability SCHEMA_VERSION to 2;
  export.test.ts schema/version assertions updated.
- The processor parses the failure once and passes providerFailure (with
  HTTP evidence) to the recorder, which routes a real provider API rejection
  to provider_api_error instead of defaulting it to a transport disconnect.
  classificationForIncident and retrySafetyFor gain provider_api_error
  branches (retryable=false -> provider_terminal_failure, aligned with #1118).
- recoveryFor: a terminal provider API error stops with reason
  provider_api_error (out of the connection-lost set); a retryable one
  (rate_limit / server_overload) flows through the existing auto-retry tree.
- The terminal halt no longer overwrites a provider API rejection's real
  message with the connection-lost recovery string. Lifecycle-close and
  user-cancel halts keep their authoritative interruption messages.
- isProviderApiError gates the catch-all "unknown" kind on HTTP evidence
  (status code or response body) so a wrapped connection failure is not
  mislabeled a provider API error.

Combined with PR #1466 (402 -> quota_exhausted), a billing failure now
surfaces with its real provider message instead of "Connection lost".

Refs #1105, #1123.

Claude-Session: https://claude.ai/code/session_015bW9JQSkuB156gkNQdxCzi
Astro-Han added a commit that referenced this pull request Jun 23, 2026
…rrors after a side effect

P1 (review): the halt suppressed its recovery interruption message for ANY
provider-API kind, but only a *terminal* rejection should pass its own message
through. A retryable rate_limit / server_overload that exhausted its retries
after a tool ran or an unsafe side effect started would lose its safety hint
("check whether the last operation completed before resending"), risking a
repeated side-effecting operation. Gate suppression on reason
"provider_api_error" (the terminal reason — retryable provider errors never get
it; see run-incident/policy.ts) and extract the choice into a documented,
unit-tested haltInterruptionMessage helper.

P3 (review): collapse ProviderApiErrorKind's parallel type/runtime lists into a
single literal tuple source of truth — the type is derived from it and the
runtime check reads it directly — so a new ProviderFailureKind can't drift
between the two.

Refs #1105, #1118.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM
Astro-Han added a commit that referenced this pull request Jun 23, 2026
… errors too

P1 (review round 2): the previous fix only covered retryable provider errors.
A *terminal* provider rejection (e.g. a 402 "Insufficient Balance",
retryable=false) that lands after a tool ran still short-circuited to reason
"provider_api_error" before the side-effect gate, so haltInterruptionMessage
passed the provider text through verbatim and dropped the "check external state"
warning — a user who fixed their balance and resent could silently re-run a
side-effecting operation.

policy.recoveryFor: a terminal provider rejection now only keeps the pure
"provider_api_error" passthrough reason when there is no side-effect risk; once a
tool ran, an unsafe side effect started, or side-effect facts are incomplete it
surfaces that side-effect reason instead (still recommendation do_not_retry, so
no retry/observability behavior changes — only the reason, which solely drives
message selection). haltInterruptionMessage then combines the provider's real
message with the bare safety hint (no "Connection lost." framing, which would
mislabel the rejection). This also makes the retryable case surface both.

Tests: recoveryFor terminal-provider matrix (no-risk→provider_api_error;
tool/unsafe/incomplete→safety reason, do_not_retry) and haltInterruptionMessage
combine assertions (provider text + hint, no "Connection lost"). Both verified to
fail under the pre-fix logic.

Refs #1105, #1118.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM
Astro-Han added a commit that referenced this pull request Jun 23, 2026
…side effect

Adds the precise check the review asked for: record a tool execution, then drive
recordAttemptFailureAndDeriveRecovery with a terminal quota_exhausted
(retryable=false), and assert the derived recovery carries the side-effect reason
(tool_execution_started, do_not_retry) and the final halt message keeps BOTH the
provider reason ("Insufficient Balance") and the "check whether the last
operation completed" safety hint (and not the "Connection lost" framing).

Exercises the recorder→derive→message plumbing the prior unit tests covered only
in isolation; verified to fail under the pre-fix policy (reason stayed
"provider_api_error"). The fix itself landed in 4ec4b1c.

Refs #1105, #1118.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM
Astro-Han added a commit that referenced this pull request Jun 23, 2026
…nd stop overwriting their message (#1468)

* fix(opencode): classify provider API rejections as a terminal cause and stop overwriting their message

A DeepSeek direct account in arrears returns 402 "Insufficient Balance",
but it surfaced as "Connection lost. Please check whether the last
operation completed before resending." Two of the three root-cause layers
live here (the third, 402 classification, is PR #1466):

1. run-incident had only watchdog/transport terminal causes, so every
   provider API rejection was recorded as provider_transport_disconnect.
2. halt() overwrote the real provider message with a generic connection-lost
   recovery string.

Changes:
- New provider_api_error TerminalCause (run-incident) and Classification
  (run-observability), subcategory from providerFailure.kind. Bumps both
  RUN_INCIDENT_SCHEMA_VERSION and RunObservability SCHEMA_VERSION to 2;
  export.test.ts schema/version assertions updated.
- The processor parses the failure once and passes providerFailure (with
  HTTP evidence) to the recorder, which routes a real provider API rejection
  to provider_api_error instead of defaulting it to a transport disconnect.
  classificationForIncident and retrySafetyFor gain provider_api_error
  branches (retryable=false -> provider_terminal_failure, aligned with #1118).
- recoveryFor: a terminal provider API error stops with reason
  provider_api_error (out of the connection-lost set); a retryable one
  (rate_limit / server_overload) flows through the existing auto-retry tree.
- The terminal halt no longer overwrites a provider API rejection's real
  message with the connection-lost recovery string. Lifecycle-close and
  user-cancel halts keep their authoritative interruption messages.
- isProviderApiError gates the catch-all "unknown" kind on HTTP evidence
  (status code or response body) so a wrapped connection failure is not
  mislabeled a provider API error.

Combined with PR #1466 (402 -> quota_exhausted), a billing failure now
surfaces with its real provider message instead of "Connection lost".

Refs #1105, #1123.

Claude-Session: https://claude.ai/code/session_015bW9JQSkuB156gkNQdxCzi

* fix(opencode): keep the recovery safety hint for retryable provider errors after a side effect

P1 (review): the halt suppressed its recovery interruption message for ANY
provider-API kind, but only a *terminal* rejection should pass its own message
through. A retryable rate_limit / server_overload that exhausted its retries
after a tool ran or an unsafe side effect started would lose its safety hint
("check whether the last operation completed before resending"), risking a
repeated side-effecting operation. Gate suppression on reason
"provider_api_error" (the terminal reason — retryable provider errors never get
it; see run-incident/policy.ts) and extract the choice into a documented,
unit-tested haltInterruptionMessage helper.

P3 (review): collapse ProviderApiErrorKind's parallel type/runtime lists into a
single literal tuple source of truth — the type is derived from it and the
runtime check reads it directly — so a new ProviderFailureKind can't drift
between the two.

Refs #1105, #1118.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM

* fix(opencode): keep the side-effect safety hint for terminal provider errors too

P1 (review round 2): the previous fix only covered retryable provider errors.
A *terminal* provider rejection (e.g. a 402 "Insufficient Balance",
retryable=false) that lands after a tool ran still short-circuited to reason
"provider_api_error" before the side-effect gate, so haltInterruptionMessage
passed the provider text through verbatim and dropped the "check external state"
warning — a user who fixed their balance and resent could silently re-run a
side-effecting operation.

policy.recoveryFor: a terminal provider rejection now only keeps the pure
"provider_api_error" passthrough reason when there is no side-effect risk; once a
tool ran, an unsafe side effect started, or side-effect facts are incomplete it
surfaces that side-effect reason instead (still recommendation do_not_retry, so
no retry/observability behavior changes — only the reason, which solely drives
message selection). haltInterruptionMessage then combines the provider's real
message with the bare safety hint (no "Connection lost." framing, which would
mislabel the rejection). This also makes the retryable case surface both.

Tests: recoveryFor terminal-provider matrix (no-risk→provider_api_error;
tool/unsafe/incomplete→safety reason, do_not_retry) and haltInterruptionMessage
combine assertions (provider text + hint, no "Connection lost"). Both verified to
fail under the pre-fix logic.

Refs #1105, #1118.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM

* test(opencode): end-to-end guard for terminal provider error after a side effect

Adds the precise check the review asked for: record a tool execution, then drive
recordAttemptFailureAndDeriveRecovery with a terminal quota_exhausted
(retryable=false), and assert the derived recovery carries the side-effect reason
(tool_execution_started, do_not_retry) and the final halt message keeps BOTH the
provider reason ("Insufficient Balance") and the "check whether the last
operation completed" safety hint (and not the "Connection lost" framing).

Exercises the recorder→derive→message plumbing the prior unit tests covered only
in isolation; verified to fail under the pre-fix policy (reason stayed
"provider_api_error"). The fix itself landed in 4ec4b1c.

Refs #1105, #1118.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM

* refactor(opencode): source provider halt message from retrySignalFor, drop second parse

The halt path re-parsed result.error only to read data.message for the
provider-rejection passthrough, duplicating the parse boundary that
retrySignalFor() already crosses and risking drift from its
classification. Surface providerMessage alongside providerFailure from
the single parse in retrySignalFor() and pass retrySignal.providerMessage
to haltInterruptionMessage(), removing parsedForMessage and its isRecord
sniff.

Behavior-preserving: terminal-provider-after-tool, pure-402 passthrough,
and retryable safety-hint tests all unchanged.

Claude-Session: https://claude.ai/code/session_012743rGkjEzqaUMKy2nvYMM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work tech-debt Supplemental cleanup, maintainability, architecture, test, or quality debt context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant