You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A long agent turn died on a transient mid-stream ECONNRESET even though the failure was classified retryable, because the plain retry gate counts streamed thinking as observable output — while the idle-watchdog recovery path allows exactly that state to recover.
Both paths in packages/runtime/src/ai-sdk-backend.ts guard the same invariant (my paraphrase, not a code comment): never splice or duplicate content the user has already seen. But they encode different judgments about what is safely recoverable:
attemptCanRecoverFromIdleTimeout() (line 2228) notably omitsattemptSawThinking: a thinking-only attempt may flush its partial thinking via flushStep() (sealed as its own message) and retry into a fresh message id. This behavior is pinned by the committed test retries one idle watchdog timeout after preserving partial thinking (commit 86bb419, fix(runtime): classify provider capacity errors #3365).
attemptHasNoObservableOutput() (line 2222) includes!attemptSawThinking, and the gate (lines 2586–2592) requires it for the plain retryable branch — so the same thinking-only attempt is refused a retry when the failure arrives as a provider error instead of a local watchdog timeout.
Recovery safety depends on the attempt's state (what was emitted), not on which side detected the failure (local timer vs. remote error). The watchdog path proves thinking-only is safe to seal-and-retry; refusing the same state on the plain path protects nothing additional. The practical consequence: long streaming turns — precisely the ones most exposed to gateway resets — fail terminally on transient network errors the runtime already knows how to recover from.
Real-world incident (2026-08-28, dev build from source; records from that workspace's runtime.sqlite): the 13th model call of a code-review turn (kimi-k3 via an OpenAI-compatible connection) streamed thinking from TTFT 8.9s, then the connection went silent. The gateway's ECONNRESET arrived at 121.6s — seconds before the 120s idle watchdog (DEFAULT_STREAM_IDLE_TIMEOUT_MS, stream-watchdog.ts:21) would have fired. Result: failureClass: network, no retry, run_failed with partial thinking retained. Had the provider simply hung instead of resetting, the watchdog would have fired seconds later and the turn would have auto-recovered — the same network fault, opposite outcome, decided by which detector noticed it first.
How to reproduce
Unit-level (deterministic, reproduced on current main 8c491e64b): in packages/runtime/src/__tests__/ai-sdk-backend.test.ts, stream reasoning deltas, then fail the stream with an ECONNRESET-shaped error after the thinking has been consumed:
The control case — the same error raised before any output — retries and completes, isolating the boundary to streamed thinking. (The stream must fail after the reasoning chunks are consumed: controller.error() discards queued-but-unread chunks.)
End-to-end (manual): point an OpenAI-compatible connection at a local mock that streams a few reasoning_content chunks, then either destroys the socket (network path) or stays silent past 120s so the idle watchdog fires (recovery path; the mock answers the retried request normally):
Mock behavior
Runtime outcome
reasoning chunks → socket destroyed
failed / network, no retry
reasoning chunks → 60s silence → destroy
failed / network, no retry (the incident shape)
reasoning chunks → silence ≥120s
watchdog timeout → flush + retry → completed
Verification gotcha: provider_retry is a live-only session event and is not persisted (agent-run.ts:608 returns before the runtime-event write), so durable verification should count provider_request_attempt_recorded rows per run in core_agent_run_events (1 = no retry, 2 = retried) together with the run's status.
Environment
Maka commit: 8c491e64b (reproduced from source; the incident occurred on an Aug 25 build whose retry-gate predicates are byte-identical to this commit)
OS: macOS 14 (darwin 23.2.0, x64)
Surface: Runtime Host (packages/runtime)
Node.js: v24.18.0
Logs, screenshots, or additional context
Incident records (2026-08-28, runtime.sqlite): final attempt of run 35cab2d6 — step 12, attempt 0, TTFT 8,944ms, latency 121,602ms, providerCode: ECONNRESET, run failureClass: network, turn_state: failed with partialOutputRetained: true. The turn had already completed 13 provider calls (~1.35M input tokens, 89% cache hit).
Suggested fix: let the plain retryable branch accept thinking-only attempts with the same predicate shape as attemptCanRecoverFromIdleTimeout(), reusing its existing flushStep() + fresh-message-id machinery and its own bounded budget. With the fix, the unit repro above flips to calls === 2 and the turn completes. Notably, no currently committed test pins the no-retry behavior (the repro above was written for this report), so the fix requires flipping no existing test — only adding coverage.
Scope note / open question: whether streamed text (answer) output should also become seal-and-retry is a separate product decision — two adjacent, divergent answers on the primary reading surface may be worse than a clean failure. Tool activity and provider continuation metadata should remain non-retryable regardless (side effects cannot be deduplicated; continuation identity cannot be replayed).
What happened
A long agent turn died on a transient mid-stream
ECONNRESETeven though the failure was classified retryable, because the plain retry gate counts streamed thinking as observable output — while the idle-watchdog recovery path allows exactly that state to recover.Both paths in
packages/runtime/src/ai-sdk-backend.tsguard the same invariant (my paraphrase, not a code comment): never splice or duplicate content the user has already seen. But they encode different judgments about what is safely recoverable:attemptCanRecoverFromIdleTimeout()(line 2228) notably omitsattemptSawThinking: a thinking-only attempt may flush its partial thinking viaflushStep()(sealed as its own message) and retry into a fresh message id. This behavior is pinned by the committed testretries one idle watchdog timeout after preserving partial thinking(commit 86bb419, fix(runtime): classify provider capacity errors #3365).attemptHasNoObservableOutput()(line 2222) includes!attemptSawThinking, and the gate (lines 2586–2592) requires it for the plain retryable branch — so the same thinking-only attempt is refused a retry when the failure arrives as a provider error instead of a local watchdog timeout.Recovery safety depends on the attempt's state (what was emitted), not on which side detected the failure (local timer vs. remote error). The watchdog path proves thinking-only is safe to seal-and-retry; refusing the same state on the plain path protects nothing additional. The practical consequence: long streaming turns — precisely the ones most exposed to gateway resets — fail terminally on transient network errors the runtime already knows how to recover from.
Real-world incident (2026-08-28, dev build from source; records from that workspace's
runtime.sqlite): the 13th model call of a code-review turn (kimi-k3 via an OpenAI-compatible connection) streamed thinking from TTFT 8.9s, then the connection went silent. The gateway'sECONNRESETarrived at 121.6s — seconds before the 120s idle watchdog (DEFAULT_STREAM_IDLE_TIMEOUT_MS,stream-watchdog.ts:21) would have fired. Result:failureClass: network, no retry,run_failedwith partial thinking retained. Had the provider simply hung instead of resetting, the watchdog would have fired seconds later and the turn would have auto-recovered — the same network fault, opposite outcome, decided by which detector noticed it first.How to reproduce
Unit-level (deterministic, reproduced on current main
8c491e64b): inpackages/runtime/src/__tests__/ai-sdk-backend.test.ts, stream reasoning deltas, then fail the stream with an ECONNRESET-shaped error after the thinking has been consumed:The control case — the same error raised before any output — retries and completes, isolating the boundary to streamed thinking. (The stream must fail after the reasoning chunks are consumed:
controller.error()discards queued-but-unread chunks.)End-to-end (manual): point an OpenAI-compatible connection at a local mock that streams a few
reasoning_contentchunks, then either destroys the socket (network path) or stays silent past 120s so the idle watchdog fires (recovery path; the mock answers the retried request normally):network, no retrynetwork, no retry (the incident shape)Verification gotcha:
provider_retryis a live-only session event and is not persisted (agent-run.ts:608returns before the runtime-event write), so durable verification should countprovider_request_attempt_recordedrows per run incore_agent_run_events(1 = no retry, 2 = retried) together with the run'sstatus.Environment
8c491e64b(reproduced from source; the incident occurred on an Aug 25 build whose retry-gate predicates are byte-identical to this commit)packages/runtime)Logs, screenshots, or additional context
Incident records (2026-08-28,
runtime.sqlite): final attempt of run35cab2d6— step 12, attempt 0, TTFT 8,944ms, latency 121,602ms,providerCode: ECONNRESET, runfailureClass: network,turn_state: failedwithpartialOutputRetained: true. The turn had already completed 13 provider calls (~1.35M input tokens, 89% cache hit).Suggested fix: let the plain retryable branch accept thinking-only attempts with the same predicate shape as
attemptCanRecoverFromIdleTimeout(), reusing its existingflushStep()+ fresh-message-id machinery and its own bounded budget. With the fix, the unit repro above flips tocalls === 2and the turn completes. Notably, no currently committed test pins the no-retry behavior (the repro above was written for this report), so the fix requires flipping no existing test — only adding coverage.Scope note / open question: whether streamed text (answer) output should also become seal-and-retry is a separate product decision — two adjacent, divergent answers on the primary reading surface may be worse than a clean failure. Tool activity and provider continuation metadata should remain non-retryable regardless (side effects cannot be deduplicated; continuation identity cannot be replayed).