Skip to content

Explore subagent hangs indefinitely with Anthropic Claude Opus 4.6 -- no timeout or recovery #13841

Closed as duplicate

Description

@timvw

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

  1. Use OpenCode 1.2.0 with Anthropic Claude Opus 4.6 as provider.
  2. Send a prompt that triggers an Explore Task subagent (e.g., "check some-repo for how X works").
  3. The subagent starts, makes some tool calls successfully.
  4. After a step-finish, a new step-start fires but the LLM API call never returns.
  5. The TUI shows the spinner animating but esc interrupt or p action 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-start fired at 10:06:02 with an empty text part.
  • No step-finish ever 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+C and 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 ?? 0 at llm.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 as SessionRetry.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

  1. Apply the documented 300s default timeout at config normalization -- During config loading/merging, set options.timeout = 300_000 when the field is undefined (not when it is false). This ensures AbortSignal.timeout() is always applied unless explicitly disabled. Prefer normalizing defaults in config over patching the fetch wrapper guard.

  2. 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.

  3. Cap session-level retry attempts -- Add a MAX_RETRY_ATTEMPTS constant (e.g. 5-10) or a maxRetryMs elapsed-time budget in processor.ts. Document the interaction with provider-level maxRetries to avoid compounding. After the cap is reached, surface the error to the user instead of retrying.

  4. Add a subagent-level timeout -- Wrap the SessionPrompt.prompt() call in task.ts with abortAfterAny(timeout, ctx.abort) (e.g. 10 min default, configurable). This ensures a hung subagent cannot block the parent session indefinitely without requiring manual intervention.

  5. 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

Activity

  1. added
    perfIndicates a performance issue or need for optimization
    on Feb 16, 2026
  2. github-actions commented on Feb 16, 2026

    @github-actions
    Contributor

    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.

  3. yehudacohen commented on Mar 8, 2026

    @yehudacohen

    I traced issue 1 to two gaps that compound. In config.ts, the options.timeout field is declared .optional() with no .default() call, so any provider config that includes an options block but omits timeout results in undefined. In provider.ts, the AbortSignal.timeout() guard checks options["timeout"] !== undefined before applying any timeout, so undefined silently 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 options initialised as {} by fromModelsDevProvider, 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, and options["timeout"] ??= 300000 in getSDK for the env-only path.

    PR at #16599.

  4. assigned and unassigned on Apr 26, 2026
  5. mlerner27 commented on Apr 28, 2026

    @mlerner27

    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 @general subagent parsing an .xls rent-roll hung at exactly the pattern @timvw described —

    • 22:07:11 task delegated to subagent
    • 22:07:14 → 22:08:22 normal tool turns (read, bash, edit, retries)
    • 22:08:27 message 10 emits a step-start with parts=0
    • No step-finish ever arrived (verified for 30+ min via GET /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 = 300000 in our config for anthropic / 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's timeout: false) are still live until those follow-ups land.

  6. removed
    perfIndicates a performance issue or need for optimization
    on May 3, 2026
  7. phantomwhale commented on May 18, 2026

    @phantomwhale

    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.

  8. gf-mse commented on Jun 20, 2026

    @gf-mse

    #15080 might also help

  9. trentshaines commented on Aug 6, 2026

    @trentshaines

    please fix!

  10. neriousy commented on Oct 1, 2026

    @neriousy
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions