Skip to content

fix(opencode): recover turns after event stream EOF - #2272

Closed
timigod wants to merge 3 commits into
getpaseo:mainfrom
timigod:fix/opencode-stream-eof-recovery
Closed

fix(opencode): recover turns after event stream EOF#2272
timigod wants to merge 3 commits into
getpaseo:mainfrom
timigod:fix/opencode-stream-eof-recovery

Conversation

@timigod

@timigod timigod commented Jul 20, 2026

Copy link
Copy Markdown

Linked issue

Closes #2271

Type of change

  • Bug fix
  • New feature (with prior issue + design alignment)
  • Refactor / code improvement
  • Docs

What does this PR do

Since #916, the OpenCode adapter fails the active foreground turn the moment the global event stream (client.global.event, sseMaxRetryAttempts: 0) ends or errors — one dropped SSE connection kills an otherwise healthy turn, even though the OpenCode session keeps running and usually completes. Details and evidence in #2271.

This PR makes stream EOF recoverable instead of fatal:

  • On EOF with an active turn, reconcile against session.status, session.messages, and pending question/permission requests before deciding anything.
  • If OpenCode is still busy (or has pending permission/question requests), reconnect the stream and keep going. Reconnects back off exponentially (100 ms → 5 s cap) when consecutive connects yield zero events, so a dead server can't turn the loop into a tight spin.
  • Assistant parts that streamed while disconnected are bridged exactly once from the messages API (text suffix, reasoning suffix, tool-call state), deduplicated against what was already emitted.
  • If the session completed while disconnected, the turn is recovered as completed, including usage/cost accounting.
  • If state is genuinely unresolvable (e.g. server gone and no completed assistant message), the turn still fails with the original error — no silent hangs.

The stream remains the source of truth. The messages API is consulted once per disconnect to bridge the gap — this is not a return to the pre-#916 polling path.

How did you verify it

Reproducing the bug: on my daemon (Paseo 0.1.110, OpenCode 1.18.3, macOS arm64), host load reliably triggers it: 9 concurrent OpenCode agents on one opencode serve produced 9 turn failures in ~5 minutes, all provider.opencode.stream.eof traces, while session.status still reported the sessions busy (log excerpts in #2271).

Unit tests:

npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --bail=1
# Test Files  1 passed (1)
# Tests  76 passed (76)

New coverage includes: reconnect while busy (stream EOF mid-turn → turn completes with full text), recovery of the missing assistant suffix when EOF finds the session idle, pending-permission recovery across EOF, failure when reconciliation is impossible, usage recovery, and reconnecting through consecutive failed connects (backoff path).

Production soak: this change (cherry-picked onto v0.1.110) has been running my real daemon workload — ~19 OpenCode agents, same load pattern that triggered the failures — since 2026-07-20. No EOF turn failures since deployment.

Also ran: server-package npm run typecheck (clean), npm run lint on changed files (clean), npm run format:check (clean). I did not run the *.real.e2e suites (they need live model access).

Disclosure: this patch was developed with AI assistance (Claude Code), then verified as described above — the test suite, the load repro, and the production deployment are real runs on my machine, not generated claims.

Checklist

  • One focused change. Unrelated cleanups split out.
  • npm run typecheck passes
  • npm run lint passes
  • npm run format ran (Biome)
  • UI changes include screenshots or video for every affected platform (n/a — daemon only)
  • Tests added or updated where it made sense

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where a dropped SSE connection on the global event stream (client.global.event) would immediately fail an otherwise-healthy foreground turn. The root cause was that the stream had sseMaxRetryAttempts: 0, so a single EOF or network hiccup killed the turn even when the OpenCode session was still running.

  • The single-pass stream path is replaced with an exponential-backoff reconnect loop (100 ms → 5 s cap). On EOF with an active turn, reconcileForegroundTurnAfterStreamEnd queries session.status and session.messages to bridge any content missed during the disconnect — emitting text suffix, reasoning suffix, and tool-call state exactly once via deduplication maps, then completing or failing the turn based on persisted state.
  • Permission and question requests that arrived while the stream was down are recovered via readForegroundPendingRequestEvents; live-stream-delivered permissions are deduplicated using seenPermissionRequestIds to prevent double emission.
  • The test suite adds 14 new unit tests exercising reconnect, backoff, suffix recovery, pending-permission recovery, deduplication, multi-message bridging, and usage accounting.

Confidence Score: 4/5

The reconnect loop and reconciliation logic are well-reasoned and well-tested; the main residual gap with recovered permissions is a transient edge case that self-corrects once the reconnected stream fires.

The core reconnect loop, exponential backoff, text/reasoning/tool-call deduplication, and turn completion/failure paths are covered by 14 focused unit tests. The recovered-permission handling in recoverForegroundPendingRequests does not call tryAutoApproveToolPermission or update pendingPermissions, which can briefly surface a permission to subscribers that should be silently auto-approved; it self-corrects when the reconnected stream fires. No data loss or silent-hang risk was found.

packages/server/src/server/agent/providers/opencode-agent.ts — specifically the recoverForegroundPendingRequests method and its interaction with pendingPermissions and tryAutoApproveToolPermission.

Important Files Changed

Filename Overview
packages/server/src/server/agent/providers/opencode-agent.ts Replaces the single-pass stream-abort path with an exponential-backoff reconnect loop and adds REST-based reconciliation for bridging content missed during disconnects; adds ~10 new private fields and ~15 new private methods to OpenCodeAgentSession. One minor gap: recovered permissions bypass tryAutoApproveToolPermission and pendingPermissions tracking.
packages/server/src/server/agent/providers/opencode-agent.test.ts Adds 14 new unit tests covering reconnect-while-busy, consecutive failed connects with backoff, missing suffix recovery, idle-state recovery, pending question/permission recovery, deduplication of replayed deltas, multi-message bridging, compaction-summary exclusion, todowrite exclusion, error propagation, and usage reconciliation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Paseo as Paseo startTurn
    participant ReconnectLoop as Reconnect Loop
    participant SSE as SSE Stream
    participant Reconcile as reconcile
    participant REST as REST APIs

    Paseo->>ReconnectLoop: enter while not aborted
    ReconnectLoop->>SSE: client.global.event
    SSE-->>ReconnectLoop: stream events
    SSE--xReconnectLoop: EOF or error

    ReconnectLoop->>Reconcile: reconcileForegroundTurnAfterStreamEnd
    Reconcile->>REST: session.status and session.messages in parallel
    REST-->>Reconcile: status busy, messages list

    alt status busy or hasPendingRequest
        Reconcile-->>ReconnectLoop: reconnect
        ReconnectLoop->>ReconnectLoop: sleep backoff, increment zero-event counter
        ReconnectLoop->>SSE: client.global.event replacement stream
        SSE-->>ReconnectLoop: buffered and new events
        Note over ReconnectLoop: applyForegroundAssistantTextDedup suppresses replayed deltas
    else session idle and assistant completed
        Reconcile->>Reconcile: emitRecoveredAssistantParts
        Reconcile-->>ReconnectLoop: handled
        ReconnectLoop-->>Paseo: finishForegroundTurn completed
    else unresolvable
        Reconcile-->>ReconnectLoop: unresolved
        ReconnectLoop-->>Paseo: finishForegroundTurn failed
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Paseo as Paseo startTurn
    participant ReconnectLoop as Reconnect Loop
    participant SSE as SSE Stream
    participant Reconcile as reconcile
    participant REST as REST APIs

    Paseo->>ReconnectLoop: enter while not aborted
    ReconnectLoop->>SSE: client.global.event
    SSE-->>ReconnectLoop: stream events
    SSE--xReconnectLoop: EOF or error

    ReconnectLoop->>Reconcile: reconcileForegroundTurnAfterStreamEnd
    Reconcile->>REST: session.status and session.messages in parallel
    REST-->>Reconcile: status busy, messages list

    alt status busy or hasPendingRequest
        Reconcile-->>ReconnectLoop: reconnect
        ReconnectLoop->>ReconnectLoop: sleep backoff, increment zero-event counter
        ReconnectLoop->>SSE: client.global.event replacement stream
        SSE-->>ReconnectLoop: buffered and new events
        Note over ReconnectLoop: applyForegroundAssistantTextDedup suppresses replayed deltas
    else session idle and assistant completed
        Reconcile->>Reconcile: emitRecoveredAssistantParts
        Reconcile-->>ReconnectLoop: handled
        ReconnectLoop-->>Paseo: finishForegroundTurn completed
    else unresolvable
        Reconcile-->>ReconnectLoop: unresolved
        ReconnectLoop-->>Paseo: finishForegroundTurn failed
    end
Loading

Reviews (2): Last reviewed commit: "fix(opencode): harden stream recovery af..." | Re-trigger Greptile

Comment on lines +3800 to +3814
private async recoverForegroundPendingRequests(turnId: string): Promise<boolean> {
const requestEvents = await this.readForegroundPendingRequestEvents();
let hasPendingRequest = false;
for (const event of requestEvents) {
const translated = await this.translateEvent(event);
for (const translatedEvent of translated) {
if (isOpenCodeProviderInternalEvent(translatedEvent)) {
this.notifySubscribers(translatedEvent, null);
continue;
}
this.notifySubscribers(translatedEvent, turnId);
}
hasPendingRequest ||= this.pendingPermissions.has(event.properties.id);
}
return hasPendingRequest;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Permission requests double-emitted on stream reconnect

recoverForegroundPendingRequests calls notifySubscribers for every pending permission it finds without checking seenPermissionRequestIds. When a permission was already delivered via the live stream (which adds the ID to seenPermissionRequestIds), and the stream then drops, this recovery path emits it again — once for the bottom-of-loop reconcile call and again for the top-of-loop reconcile on the reconnected stream. Subscribers receive the same permission_requested event two or three times. The check added to the regular stream path (in consumeOpenCodeStreamEvent) should also be applied here before calling notifySubscribers.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the code and this one isn't reachable: the seenPermissionRequestIds dedup lives inside translateEvent itself (not consumeOpenCodeStreamEvent), and recoverForegroundPendingRequests routes every re-listed request through translateEvent, so a permission already delivered on the live stream produces an empty translated list during recovery — notifySubscribers is never reached for it. The reconnect decision is unaffected because hasPendingRequest reads pendingPermissions, not the emitted events. Added an explicit regression test for the exact scenario (permission delivered live, stream drops, permission.list returns it again): "does not re-emit a live-delivered permission re-listed during stream recovery" asserts exactly one permission_requested across the turn.

Comment thread packages/server/src/server/agent/providers/opencode-agent.ts
Comment thread packages/server/src/server/agent/providers/opencode-agent.ts
- trace + warn when a post-reconnect reconcile is unresolved (review)
- document why seenPermissionRequestIds is session-lifetime (review)
- settle eventStreamReady when close aborts the initial connect
- abort-aware reconnect backoff and reconcile reads so close() is not blocked
- per-part text bookkeeping: suppress buffered deltas replaying recovered
  text, and bridge every assistant message in multi-message turns
- route recovered tool parts through subagent registration
- reset accumulatedUsage after recovered completions like live ones
- serialize stream errors in trace payloads so pino keeps name/message
- snapshot pre-turn message ids after turn activation to keep terminal
  events consumed during startTurn from being dropped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@timigod

timigod commented Jul 31, 2026

Copy link
Copy Markdown
Author

Superseded by #2684, which preserves the bounded EOF-recovery outcome but is rebuilt directly from the latest stable release (v0.2.5), split from unrelated fleet/material-progress work, and independently re-reviewed. I am closing this older branch without rewriting its history so the original discussion and evidence remain available.

@timigod timigod closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenCode turns still fail with "event stream ended before the turn reached a terminal state" when the global SSE stream drops mid-turn

1 participant