This was generated by AI during triage.
Bug type
Incorrect recovery behavior / session-state cleanup
Summary
Generic diagnostic stuck-session recovery loses the fact that an abort was initiated by the internal watchdog before the active embedded attempt decides retry/failover semantics.
On current main, recoverStuckDiagnosticSession() calls abortAndDrainEmbeddedAgentRun() with reason: "stuck_recovery", but the active-run/reply-run abort path does not preserve that reason to the attempt. The attempt therefore observes a generic external abort. A broad workaround that maps every stuck recovery to an idle timeout is also unsafe: if a potentially side-effecting tool is active, automatic retry/fallback can replay external effects.
The recovery path needs to classify the active phase before failover sees the abort, and its reply-run cancellation must preserve the existing abortability and terminal-settle cleanup invariants.
Why this is independent from #93610 / #93965
#93965 fixed the original opencode-go incident at the provider-owned raw SSE boundary by terminating stalled opencode-go streams and routing them through failover. That provider-specific fix correctly closed #93610.
This issue is downstream and provider-agnostic. It applies whenever the generic diagnostic watchdog reclaims an active embedded run, regardless of which provider or transport caused the run to stop making progress. It does not require changes to opencode-go, OpenAI-compatible stream parsing, provider payloads, or model routing.
Canonical reachability
- A scheduled, interactive, or provider-backed embedded-agent session remains active without diagnostic progress.
- The diagnostic stuck-session watchdog resolves the canonical active embedded or reply-owned run by
sessionId / sessionKey.
recoverStuckDiagnosticSession() invokes abortAndDrainEmbeddedAgentRun() with reason: "stuck_recovery".
- The embedded-run and reply-run registries abort the active attempt.
- The attempt must classify the currently active phase before setting
idleTimedOut, timedOutDuringToolExecution, or externalAbort.
- The existing failover policy uses those flags to decide whether rotate/fallback is allowed.
Current main drops the distinct stuck-recovery abort reason between steps 3 and 5, so the attempt cannot make a phase-safe decision.
Expected behavior
- An active model-call stall with no active potentially side-effecting tool is classified as a model idle timeout and may use the existing retry/fallback policy.
- An active potentially side-effecting tool stall is classified as a tool-execution timeout and must not enter automatic retry/fallback.
- Pre-model, post-model, stale, or otherwise unknown stuck recovery remains external-abort-like and must not enter an automatic replay path.
- Finalizing or otherwise non-abortable reply operations reject stuck-recovery cancellation just as other protected abort paths do.
- A running reply operation aborted for stuck recovery schedules bounded terminal settlement, so registry/lane state is released even if backend owner cleanup never arrives.
Actual behavior
- The diagnostic layer records
reason: "stuck_recovery", but the active attempt receives a generic abort and sets externalAbort=true.
- Model-call stalls can surface
Request was aborted instead of using configured fallback.
- Treating all recovered stalls as model idle timeouts would make side-effecting tool replay possible.
- A distinct stuck-recovery reply abort that does not share the existing terminal-settle contract can leave the reply operation registered indefinitely if owner cleanup does not complete.
Deterministic reproduction
A source/runtime reproduction does not require a live provider outage:
- Register a running embedded/reply operation with an abort callback that records its cancel reason.
- Invoke the canonical abort/drain path with
reason: "stuck_recovery".
- Observe on current
main that the active attempt does not receive a distinct stuck-recovery reason and follows generic external-abort semantics.
- For cleanup, create a running reply operation, abort it through the stuck-recovery path, omit owner cleanup, and advance the terminal-settle timeout; the operation must no longer remain active.
Acceptance criteria
- Preserve a typed
stuck_recovery reason through diagnostic recovery, embedded-run registry, and reply-run fallback paths.
- Classify recovery from active model-call state and active potentially side-effecting tool state before failover observes abort semantics.
- Give active potentially side-effecting tools precedence over model-call retry eligibility.
- Keep unknown/stale phases out of retry/fallback.
- Reuse the existing reply-operation abortability guard.
- Schedule terminal settlement for non-queued running stuck-recovery aborts, with a focused fake-timer regression.
- Add regression coverage for model-call stalls, side-effecting tool stalls, unknown/stale recovery, reply-run reason propagation, abortability, and terminal cleanup.
Scope boundary
Out of scope:
- opencode-go or other provider stream parsing
- provider-specific timeout wrappers
- cron timeout-budget policy
- fallback model selection policy
- public configuration, schema, protocol, or persisted-data changes
Bug type
Incorrect recovery behavior / session-state cleanup
Summary
Generic diagnostic stuck-session recovery loses the fact that an abort was initiated by the internal watchdog before the active embedded attempt decides retry/failover semantics.
On current
main,recoverStuckDiagnosticSession()callsabortAndDrainEmbeddedAgentRun()withreason: "stuck_recovery", but the active-run/reply-run abort path does not preserve that reason to the attempt. The attempt therefore observes a generic external abort. A broad workaround that maps every stuck recovery to an idle timeout is also unsafe: if a potentially side-effecting tool is active, automatic retry/fallback can replay external effects.The recovery path needs to classify the active phase before failover sees the abort, and its reply-run cancellation must preserve the existing abortability and terminal-settle cleanup invariants.
Why this is independent from #93610 / #93965
#93965 fixed the original opencode-go incident at the provider-owned raw SSE boundary by terminating stalled opencode-go streams and routing them through failover. That provider-specific fix correctly closed #93610.
This issue is downstream and provider-agnostic. It applies whenever the generic diagnostic watchdog reclaims an active embedded run, regardless of which provider or transport caused the run to stop making progress. It does not require changes to opencode-go, OpenAI-compatible stream parsing, provider payloads, or model routing.
Canonical reachability
sessionId/sessionKey.recoverStuckDiagnosticSession()invokesabortAndDrainEmbeddedAgentRun()withreason: "stuck_recovery".idleTimedOut,timedOutDuringToolExecution, orexternalAbort.Current
maindrops the distinct stuck-recovery abort reason between steps 3 and 5, so the attempt cannot make a phase-safe decision.Expected behavior
Actual behavior
reason: "stuck_recovery", but the active attempt receives a generic abort and setsexternalAbort=true.Request was abortedinstead of using configured fallback.Deterministic reproduction
A source/runtime reproduction does not require a live provider outage:
reason: "stuck_recovery".mainthat the active attempt does not receive a distinct stuck-recovery reason and follows generic external-abort semantics.Acceptance criteria
stuck_recoveryreason through diagnostic recovery, embedded-run registry, and reply-run fallback paths.Scope boundary
Out of scope: