diff --git a/packages/opencode/src/session/run-observability/recorder.ts b/packages/opencode/src/session/run-observability/recorder.ts index 8bb234c94..a74f74bda 100644 --- a/packages/opencode/src/session/run-observability/recorder.ts +++ b/packages/opencode/src/session/run-observability/recorder.ts @@ -675,6 +675,7 @@ export function createRecorder(input: RecorderInput): Recorder { reasoningOutputStarted: terminalAttempt?.reasoning_output_started ?? incident?.facts.reasoning_output_started ?? false, toolExecutionStarted: terminalAttempt?.tool_execution_started ?? toolExecutionStarted, unsafeSideEffectStarted: terminalAttempt?.unsafe_side_effect_started ?? unsafeSideEffectStarted, + retryable: failure?.type === "transport" ? failure.retryable : undefined, }) const completedAt = final.completedAt const failureMonotonicMs = failure?.monotonicMs @@ -868,6 +869,7 @@ function retrySafetyFor(input: { reasoningOutputStarted: boolean toolExecutionStarted: boolean unsafeSideEffectStarted: boolean + retryable?: boolean }): Summary["retry_safety"] { const base = { safety_scope: "user_visible_and_tool_side_effects" as const } if (input.classification === "success") { @@ -883,6 +885,9 @@ function retrySafetyFor(input: { return { ...base, recommendation: "ask_user", confidence: "medium", reason: "tool_execution_started" } } if (input.classification === "external_stream_disconnect") { + if (input.retryable === false) { + return { ...base, recommendation: "do_not_auto_retry", confidence: "high", reason: "provider_terminal_failure" } + } if (input.reasoningOutputStarted && input.visibleOutputSeen) { return { ...base, diff --git a/packages/opencode/src/session/run-observability/types.ts b/packages/opencode/src/session/run-observability/types.ts index dcc01ac62..28cc6ec54 100644 --- a/packages/opencode/src/session/run-observability/types.ts +++ b/packages/opencode/src/session/run-observability/types.ts @@ -40,6 +40,7 @@ export type RetrySafety = { | "tool_execution_started" | "unsafe_side_effect_started" | "local_abort_or_lifecycle_close" + | "provider_terminal_failure" | "unknown" safety_scope: "user_visible_and_tool_side_effects" } diff --git a/packages/opencode/test/session/run-observability.test.ts b/packages/opencode/test/session/run-observability.test.ts index 6bc78b371..dc2849881 100644 --- a/packages/opencode/test/session/run-observability.test.ts +++ b/packages/opencode/test/session/run-observability.test.ts @@ -660,6 +660,80 @@ describe("RunObservability", () => { expect(summary.durations_ms.last_event_to_failure).toBe(130) }) + test("does not recommend auto-retry for a terminal provider failure on external stream disconnect", () => { + const recorder = RunObservability.createRecorder({ + runID: RunObservability.RunID.make("run_terminal_provider"), + traceID: MessageID.make("msg_terminal_provider"), + sessionID: SessionID.make("ses_terminal_provider"), + messageID: MessageID.make("msg_terminal_provider"), + providerID: "openai", + modelID: "gpt-5.5", + createdAt: 10, + monotonicStartMs: 100, + }) + + const attempt = recorder.beginAttempt({ attemptIndex: 1, at: 11, monotonicMs: 110 }) + recorder.recordProviderProgress({ attemptID: attempt.attemptID, at: 12, monotonicMs: 120 }) + recorder.recordTransportFailure({ + attemptID: attempt.attemptID, + at: 25, + monotonicMs: 250, + error: { + name: "TypeError", + message: "terminated", + cause: { name: "SocketError", message: "other side closed", code: "UND_ERR_SOCKET" }, + }, + evidence: ["provider_progress_seen", "iterator_error"], + retryable: false, + }) + + const summary = recorder.finalize({ completedAt: 26, monotonicMs: 260 }) + expect(summary.classification).toBe("external_stream_disconnect") + expect(summary.retry_safety).toEqual({ + recommendation: "do_not_auto_retry", + confidence: "high", + reason: "provider_terminal_failure", + safety_scope: "user_visible_and_tool_side_effects", + }) + }) + + test("keeps candidate auto-retry for a retryable external stream disconnect", () => { + const recorder = RunObservability.createRecorder({ + runID: RunObservability.RunID.make("run_retryable_transport"), + traceID: MessageID.make("msg_retryable_transport"), + sessionID: SessionID.make("ses_retryable_transport"), + messageID: MessageID.make("msg_retryable_transport"), + providerID: "openai", + modelID: "gpt-5.5", + createdAt: 10, + monotonicStartMs: 100, + }) + + const attempt = recorder.beginAttempt({ attemptIndex: 1, at: 11, monotonicMs: 110 }) + recorder.recordProviderProgress({ attemptID: attempt.attemptID, at: 12, monotonicMs: 120 }) + recorder.recordTransportFailure({ + attemptID: attempt.attemptID, + at: 25, + monotonicMs: 250, + error: { + name: "TypeError", + message: "terminated", + cause: { name: "SocketError", message: "other side closed", code: "UND_ERR_SOCKET" }, + }, + evidence: ["provider_progress_seen", "iterator_error"], + retryable: true, + }) + + const summary = recorder.finalize({ completedAt: 26, monotonicMs: 260 }) + expect(summary.classification).toBe("external_stream_disconnect") + expect(summary.retry_safety).toEqual({ + recommendation: "candidate_safe_auto_retry", + confidence: "medium", + reason: "no_visible_output_or_tool_execution", + safety_scope: "user_visible_and_tool_side_effects", + }) + }) + test("derives provider transport incident during partial tool input instead of tool failure", () => { const recorder = RunObservability.createRecorder({ runID: RunObservability.RunID.make("run_partial_tool_input_disconnect"),