Skip to content

Automatic retry/fallback lacks a universal replay-safety gate after observable stream progress #3791

Description

@grantjayy

Summary

GJC applies replay-safety checks to a few narrow recovery paths, but not to every automatic retry path.

A Responses-compatible proxy exposed the gap with this terminal stream event after partial output had crossed the proxy's public stream boundary:

response.failed
error.code = upstream_stream_error
error.message = stream interrupted before terminal response event

GJC surfaced:

upstream request failed: stream interrupted before terminal response event

Under bare defaults, GJC fails closed. With explicit legacy retry.* settings, the same unclassified failure enters the bounded unknown retry path. That path does not require the current attempt to be replay-safe and does not reject assistant text, thinking, or tool-call content before continuing.

I first considered a narrow fix that would make this exact proxy error terminal. I do not think that is the right product contract. The proxy error is one example of a general problem: retry classification and replay safety are separate decisions, but GJC does not enforce the second decision across every automatic recovery path.

The correct fix may span packages/ai, packages/agent, and packages/coding-agent, so I am filing this for maintainer direction instead of opening a speculative PR.

Observed behavior

The observed incident used a proxy that emits upstream_stream_error only after semantic output has been written or its bridge has committed. The proxy does not replay that interrupted request.

GJC received the terminal failure after the stream had started. No duplicate output was captured in the incident because the active GJC mode failed closed. The unsafe replay is a verified source path, not a measured duplicate-output incident.

The mode-dependent behavior on current dev is:

  • Bare model with no explicit retry configuration: generic unknown failures fail closed.
  • Explicit legacy retry.* configuration: generic unknown failures retry within retry.maxRetries.
  • Managed model fallback: provider output remains provisional inside ManagedAttemptTransaction. A typed retryable transport failure may be safely discarded before the transaction is published.
  • Manual /retry: the user explicitly authorizes replay and the errored assistant tail is removed before continuing.

The missing contract is the distinction between a provisional provider attempt and an attempt that GJC has already made observable.

Source trace

Verified against dev at 38e026c785968e722e5b3a1b8025cadfc54c8c84.

Configured legacy retry can replay an unsafe attempt

AgentSession.#classifyErrorForRetry returns unknown for the proxy message. #isTransientTransportErrorMessage does not match interrupted or upstream_stream_error.

AgentSession.#handleRetryableError applies visible-content and current-scope checks to first_event_timeout. The bare-default path also checks #hasCleanRetryReplaySafety. The configured general unknown path has neither gate and proceeds into bounded retry accounting.

Existing tests pin both sides of the current behavior:

  • agent-session-resilient-retry.test.ts: retries unknown / no-code errors within retry.maxRetries
  • agent-session-resilient-retry.test.ts: still fails closed on generic unknown errors under a bare default config

assistantMessageHasVisibleOrToolContent, #markRetryReplayUnsafe, and per-attempt AttemptScope records already capture much of the evidence needed for a shared safety decision. The evidence is currently consulted only by selected retry classes.

Managed fallback has a real provisional boundary

ManagedAttemptTransaction in packages/agent/src/agent-loop.ts buffers agent events above the public event stream. It can discard a failed attempt before any session listener, extension consumer, or tool sees the buffered events.

That behavior should remain retryable when the failure is typed and the transaction is still provisional. A blanket rule based only on "the provider emitted content" would incorrectly disable safe managed fallback.

managedRetryableFailure admits only structured transport facts. transportFailureFacts currently drops arbitrary provider codes at its existence gate. Codes such as upstream_stream_error therefore may be unavailable to managed fallback even when the transaction is still safe to discard.

Why an exact error special-case is insufficient

Making upstream_stream_error or its current message terminal would stop this one unsafe legacy retry. The same bug class could return under another provider code or message.

A code-only terminal rule also loses the managed distinction:

  • The same transient stream failure is safe to retry while all output is still buffered.
  • The same failure is unsafe to replay after GJC publishes text, reasoning, or a tool call.

Transport metadata should classify the failure. GJC's commit state should decide whether replay is allowed.

Proposed product contract

  1. Every automatic retry or fallback must pass two independent checks:

    • The failure is eligible for retry or fallback.
    • Repeating the current request is replay-safe.
  2. GJC should fail closed when replay provenance is absent or stale.

  3. Automatic replay should be rejected after any current-attempt effect becomes observable, including:

    • non-empty assistant text
    • thinking or redacted-thinking content
    • tool-call content
    • tool execution
    • extension or provider lifecycle participation that can produce external effects
  4. A managed attempt may be discarded and retried while its transaction is still provisional and its current scope is clean.

  5. Typed, content-free failures from a clean current attempt may retain their existing bounded recovery. Existing narrow exceptions, such as credential rotation and bare Codex overload handling, should remain explicit.

  6. Manual /retry should remain user-authorized replay rather than being blocked by the automatic replay gate.

  7. Post-commit interruption should preserve the partial assistant output and surface the error. Automatic continuation from the committed partial response should be a separate product decision, not an implicit retry.

  8. Exact message matching should be limited to compatibility with older providers or persisted sessions. Structured provider codes should drive failure classification where available.

Suggested implementation seams

This is intentionally a design outline, not a requested patch shape.

  • Extract a shared automatic replay-safety decision from the current first-event and bare-default checks in AgentSession.
  • Apply the shared decision to configured legacy retry and managed fallback after accounting for the managed transaction's provisional state.
  • Preserve relevant structured stream-failure codes in TransportFailureFacts without treating a code alone as replay authorization.
  • Ensure a committed managed transaction cannot later re-enter discard-and-retry handling.
  • Keep provider-internal retries limited to attempts with no published semantic output.

Acceptance cases

  • Configured legacy retry plus partial text, thinking, or tool-call output: no automatic retry; partial output remains visible.
  • Configured legacy retry plus content-free clean transient failure: existing bounded or unbounded policy remains unchanged.
  • Managed fallback plus typed retryable failure before transaction publication: discard and retry or rotate.
  • Managed fallback after transaction publication: no discard-and-replay.
  • Current-attempt extension or tool effect: no automatic replay.
  • Missing or stale attempt provenance: fail closed.
  • Content-free credential rotation and current bare Codex overload behavior: unchanged.
  • Manual /retry: unchanged.
  • A provider message near-match cannot authorize retry by prose alone.

Risks and open questions

  • A universal fail-closed check may surface more mid-stream errors that configured retry currently hides. That is preferable to silently duplicating semantic output or future tool effects.
  • Extension lifecycle safety needs an explicit contract. Handler registration alone may be too strict, while handler execution after attempt start is a real side-effect boundary.
  • GJC needs one authoritative definition of "published" across direct sessions, managed fallback, subagents, headless execution, and extension consumers.
  • Automatic continuation from a committed text-only partial response may improve recovery, but it should be designed separately. Incomplete tool calls and cross-provider history make continuation materially different from retry.

Related work and duplicate check

I searched open and closed issues for upstream_stream_error, the exact interruption message, post-visible retry, partial-output fallback, and replay safety. I did not find an issue covering this general contract.

Related but narrower work:

Environment

  • macOS arm64
  • GJC source analysis: dev at 38e026c785968e722e5b3a1b8025cadfc54c8c84
  • Provider surface: OpenAI Responses-compatible streaming through a proxy
  • Reproduction frequency: intermittent
  • Deterministic regression should be possible by emitting partial assistant events followed by a terminal response.failed stream event under explicit legacy retry settings

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions