Repository navigation
Explore subagent hangs indefinitely with Anthropic Claude Opus 4.6 -- no timeout or recovery #13841
Description
Bug Description
When OpenCode spawns an Explore Task subagent using Anthropic Claude Opus 4.6, the subagent frequently stalls after completing tool calls. The spinner keeps animating in the TUI but no new tool calls are made and no LLM response arrives. The session hangs forever with no timeout, retry, or error surfaced to the user.
Environment
- OpenCode 1.2.0
- macOS darwin arm64
- Provider: Anthropic Claude Opus 4.6
Reproduction Steps
- Use OpenCode 1.2.0 with Anthropic Claude Opus 4.6 as provider.
- Send a prompt that triggers an Explore Task subagent (e.g., "check some-repo for how X works").
- The subagent starts, makes some tool calls successfully.
- After a
step-finish, a newstep-startfires but the LLM API call never returns. - The TUI shows the spinner animating but
escinterrupt orpaction menu are the only escape -- no error, no timeout, no automatic recovery.
Observed Behavior (from session database)
- Subagent received user prompt at 10:05:48.
- Completed 2 steps with 8 tool calls by 10:06:00.
- Third
step-startfired at 10:06:02 with an empty text part. - No
step-finishever arrived -- the subagent hung for over 1 hour. - Spinner kept animating but no progress was made (toolcall count frozen at 8, then 0 after restart).
- After
Ctrl+Cand session resume, the same pattern repeated -- new Explore Task spawned but stalled again at 0 toolcalls.
Expected Behavior
- Subagent API calls should have a timeout (e.g. 2-5 minutes). On timeout, OpenCode should retry the API call or surface an error to the user.
- The TUI should distinguish "spinner animating but no progress" from "actively receiving data."
Root Cause Analysis
Investigation of the codebase reveals four compounding gaps that allow a stalled LLM stream to hang the session forever.
1. No default fetch-level timeout on LLM API requests
The provider-level fetch wrapper at provider.ts:1063-1101 only applies AbortSignal.timeout() when options["timeout"] is explicitly set:
// provider.ts:1068
if (options["timeout"] !== undefined && options["timeout"] !== null) {
// ... apply AbortSignal.timeout()
}The config schema at config.ts:981-995 documents a default of 300000ms (5 min), but this is documentation only -- the field is .optional() with no .default() call, and no default is applied during config normalization. When omitted, options["timeout"] is undefined and the guard skips entirely.
Critically, Bun's own socket-level timeout is also explicitly disabled (timeout: false at provider.ts:1099), so there is no fallback timeout of any kind when the user has not configured one.
2. No stream-idle watchdog (distinct from total request timeout)
Even if a total request timeout were applied via fix (1), it would not detect the specific failure mode observed here: a connection that stays open but stops delivering SSE chunks mid-stream.
The LLM.stream() call at llm.ts:211 passes only the session's AbortSignal -- there is no streamIdleTimeout or chunk-activity watchdog. Individual tools each have their own abortAfter() timeouts (bash: 2min, webfetch: 30-120s, websearch: 25s via abort.ts), but the LLM stream itself has none.
The heartbeat code in server.ts:520-532 is for SSE connections to the TUI WebView, not for LLM API streams.
A stream-idle timeout should reset on each received chunk, while a total request timeout is a separate hard ceiling. Both are needed.
3. No max-retry cap on session-level retries
There are two retry layers in the codebase, and their interaction matters:
- Provider-level retries (
maxRetries: input.retries ?? 0atllm.ts:228) -- capped at 0 for normal streaming calls. This layer is fine. - Session-level retries in
processor.ts:350-378--attempt++with no ceiling. As long asSessionRetry.retryable()returns a message, the loop continues forever with exponential backoff (2s base, 2x factor, capped at 30s per attempt).
Manual cancellation via SessionRetry.sleep(delay, input.abort) is the only escape. If a StreamIdleTimeoutError were introduced (fix 2) and marked isRetryable, this unbounded loop would cause repeated hangs rather than surfacing the error. See related #12234.
4. Subagent has no independent timeout
The Task tool at task.ts:128 calls SessionPrompt.prompt() and awaits the entire subagent run with no timeout wrapper. The abort cascade (task.ts:121-124) only fires when the parent session is manually aborted by the user. The subagent does not inherit any tool-level timeout from the tool executor.
Suggested Fix Direction
-
Apply the documented 300s default timeout at config normalization -- During config loading/merging, set
options.timeout = 300_000when the field isundefined(not when it isfalse). This ensuresAbortSignal.timeout()is always applied unless explicitly disabled. Prefer normalizing defaults in config over patching the fetch wrapper guard. -
Add a stream-idle timeout (separate from total request timeout) -- Implement a chunk-activity watchdog in
LLM.stream()or at the provider fetch wrapper level that resets on each SSE chunk. If no chunk arrives for e.g. 60-120s, throw a retryable error. This is distinct from the total request timeout in fix (1) and should be a separate configurable setting. -
Cap session-level retry attempts -- Add a
MAX_RETRY_ATTEMPTSconstant (e.g. 5-10) or amaxRetryMselapsed-time budget inprocessor.ts. Document the interaction with provider-levelmaxRetriesto avoid compounding. After the cap is reached, surface the error to the user instead of retrying. -
Add a subagent-level timeout -- Wrap the
SessionPrompt.prompt()call intask.tswithabortAfterAny(timeout, ctx.abort)(e.g. 10 min default, configurable). This ensures a hung subagent cannot block the parent session indefinitely without requiring manual intervention. -
TUI stall indicator (UX improvement, not root-cause fix) -- Track the timestamp of the last received SSE chunk and change the spinner style or show a warning after prolonged inactivity (e.g. "> 60s since last data"). This is complementary to the above fixes, not a substitute.
Related Issues
- Tasks/Subagents with Codex / OpenAI are frequently getting stuck with no timeout/retry, which then hangs the session forever #11865 -- Same symptom with Codex/OpenAI; confirms this is provider-agnostic
- Subagent AI_APICallError is silently swallowed, causing session to hang #13395 -- Subagent
AI_APICallErrorsilently swallowed, causing parent to hang - main agent hangs because of subagent (explore) #9003 -- Main agent hangs because of subagent (explore)
- TUI: Parent session appears stuck "loading" when subagent is blocked (waiting user input / hanging tool call); lack of visibility and recovery UX #10802 -- TUI parent session appears stuck "loading" when subagent is blocked
- Infinite retry loop when StreamIdleTimeoutError occurs during tool input generation #12234 -- Infinite retry loop on
StreamIdleTimeoutError(no max retry cap) - Task Tool Timeouts & Early Termination in Multi-Agent Conductor Pattern #6792 -- Task tool timeouts & early termination in multi-agent conductor pattern
Activity
github-actions commentedon Feb 16, 2026
This issue might be a duplicate of or closely related to existing issues. Please check:
These issues share the same timeout/hang symptom and may benefit from consolidated fixes.
Disable explore subagent in spawned opencode sessions
yehudacohen commentedon Mar 8, 2026
I traced issue 1 to two gaps that compound. In
config.ts, theoptions.timeoutfield is declared.optional()with no.default()call, so any provider config that includes anoptionsblock but omitstimeoutresults inundefined. Inprovider.ts, theAbortSignal.timeout()guard checksoptions["timeout"] !== undefinedbefore applying any timeout, soundefinedsilently falls through with no timeout applied. Bun's socket-level timeout is also explicitly disabled on the same line (timeout: false), which means when the Zod default is missing there is genuinely no fallback of any kind.There is also a second gap: a provider loaded from an env variable with no config entry at all has
optionsinitialised as{}byfromModelsDevProvider, so even the schema default would not apply in that path. The fix handles both cases:.default(300000)on the schema for the config path, andoptions["timeout"] ??= 300000ingetSDKfor the env-only path.PR at #16599.
fix: add timeout and retry recovery for subagent LLM streams
mlerner27 commentedon Apr 28, 2026
Same failure mode reproduces on OpenCode 1.3.13 server (Linux, hosted in a Modal sandbox), Anthropic Sonnet 4.6 — so this isn't Opus-4.6-specific.
Today's incident: a
task-spawned@generalsubagent parsing an.xlsrent-roll hung at exactly the pattern @timvw described —step-startwithparts=0step-finishever arrived (verified for 30+ min viaGET /session/{id}/message)Sibling subagents in the same parent session continued running fine, so the hang is per sub-session — consistent with @yehudacohen's gap-1 analysis: the model-API call inside that single subagent's turn never returned, no timeout caught it.
We worked around the missing default by setting
provider.<name>.options.timeout = 300000in our config foranthropic/openai/google— that turns this from a multi-hour hang into a 5-minute fail (which we can retry). Useful interim mitigation for anyone else hitting this.PR #16599 looks like the right schema-level fix. Adding 👍 there. The other gaps (env-only providers,
fromModelsDevProvider{}path, Bun'stimeout: false) are still live until those follow-ups land.phantomwhale commentedon May 18, 2026
Adding a confirmation on opencode 1.14.30 with anthropic/claude-opus-4-7 (primary agent, TUI session, not a subagent this time).
Symptom: Mid-stream stall, identical signature to what's described here.
Session loop reached step=1, LLM stream started normally.
Got 3× message.part.delta events at 01:47:11–01:47:12, then dead silence.
No further session.prompt step=N loop entries, no LLM calls, no errors logged.
From that point on the log contains only file.watcher.updated heartbeats every 60s — confirmed for 24+ minutes before I killed it.
Process was alive at 51% CPU / 717MB RSS the whole time (i.e. busy-spinning, not parked on a clean await).
So this isn't the empty-iteration loop in Bug: OpenCode enters infinite loop after tool calls complete (Zen/big-pickle) #26220 — there are zero LLM calls after the stall. Looks like the SSE stream silently ended mid-token-emission and the session loop has no timeout/abort path to detect it.
A stream-stall timeout (e.g. "no delta in N seconds → abort") would catch this according to LLM; I don't have enough code familiarity to verify this however, so include it with that caveat attached.
fix(opencode): add provider timeout + chunkTimeout to prevent silent …
gf-mse commentedon Jun 20, 2026
#15080 might also help
trentshaines commentedon Aug 6, 2026
please fix!
neriousy commentedon Oct 1, 2026
Closing as a duplicate of #11865, which tracks subagents getting stuck with no timeout or retry and leaving the session hanging. Please add your provider and model details there.