Skip to content

TUI fails to recover session history after a transient read error #59542

Open

Description

@x0c

Description

Resuming a populated session can return to home or leave an empty transcript after a transient read error. An initial session metadata error navigates home; an initial message-history error is ignored. Neither read is retried after the failure, even when subsequent requests would succeed. The stored messages remain intact.

The TUI should recover when the server becomes available, or show a loading error.

Plugins

None (isolated reproduction).

OpenCode version

2.1.4 (dev at d5bb993278)

Steps to reproduce

  1. Resume a populated session with opencode -s <session-id>, allowing the CLI's preflight session lookup to succeed.
  2. Return HTTP 503 for the TUI's next metadata request (/api/session/<id>) or initial history request (/api/session/<id>/message), then restore normal responses.
  3. Keep the connection open: the TUI returns home in the first case, or stays empty in the second.

Reproduced with controlled HTTP failures; no concurrent writer is needed.

Screenshot

Synthetic session; one initial history HTTP 503, then normal responses.

Before | After

Operating System

macOS 27.2

Terminal

Terminal + tmux

Activity

  1. opencode-agent commented on Oct 27, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced the empty-transcript case on 2.1.4, and it still happens on the latest dev (f993a89).

    What I did:

    1. Created a session with one prompt and reply (simulated model).
    2. Resumed it with opencode -s <id> through a small HTTP proxy.
    3. The proxy returned HTTP 503 once for the TUI's first GET /api/session/<id>/message?limit=20&order=desc, then forwarded everything normally.

    The resumed TUI shows the session tab and prompt, but the transcript stays empty. The message list is never requested again and no error is shown, while the server still has the messages. Without the 503, the history renders normally. The recording below shows the empty case, and the script below reproduces it (set MODE=history).

    The other case, where a single 503 on the TUI's own GET /api/session/<id> (after the CLI's lookup) sends you home, did not reproduce in my setup: the TUI stayed on the session and showed its history.

    Related: #36582 (broader audit of TUI API failures being swallowed) and #39419 (a closed, unmerged PR that would have navigated home only when the session is actually missing).

    recording.mp4
    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#59542: one transient HTTP 503 on the resumed
    // session's initial history (MODE=history, default) or metadata (MODE=metadata)
    // read is never retried. Requires opencode-wrapper.sh next to this file.
    import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const MODE = process.env.MODE ?? "history"
    const D = "/tmp/opencode/repro-59542"
    rmSync(D, { recursive: true, force: true })
    mkdirSync(D, { recursive: true })
    
    let sessionID = ""
    let metadataReads = 0
    let historyFailed = false
    const log: string[] = []
    const proxy = Bun.serve({
      port: 0,
      idleTimeout: 0,
      async fetch(req) {
        const url = new URL(req.url)
        const upstream = readFileSync(`${D}/upstream`, "utf8").trim()
        let fail = false
        if (sessionID && req.method === "GET") {
          if (url.pathname === `/api/session/${sessionID}`) {
            metadataReads++
            // read 1 = CLI preflight lookup; read 2 = the TUI's own metadata read
            if (MODE === "metadata" && metadataReads === 2) fail = true
          }
          if (MODE === "history" && !historyFailed && url.pathname === `/api/session/${sessionID}/message`) {
            historyFailed = true
            fail = true
          }
        }
        log.push(`${fail ? "503" : "ok "} ${req.method} ${url.pathname}${url.search}`)
        if (fail) return new Response("Service Unavailable", { status: 503 })
        const headers = new Headers(req.headers)
        headers.delete("host")
        const res = await fetch(upstream.replace(/\/$/, "") + url.pathname + url.search, {
          method: req.method,
          headers,
          body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
          redirect: "manual",
          decompress: false,
        } as RequestInit)
        return new Response(res.body, { status: res.status, headers: res.headers })
      },
    })
    writeFileSync(`${D}/proxy`, `http://127.0.0.1:${proxy.port}`)
    
    export default OpenCodeDriver.use(
      {
        opencode: { command: ["/workspace/repro/opencode-wrapper.sh"] },
        project: { git: true },
        tui: { viewport: { cols: 110, rows: 32 } },
      },
      ({ ui, llm, opencode, tuis }) =>
        Effect.gen(function* () {
          // 1. Populate a session through the normal TUI.
          // the title request is served first, then the reply
          yield* llm.queue(Llm.text("Seeded session"))
          yield* llm.queue(Llm.text("PERSISTED-ANSWER: the stored history is intact."))
          yield* ui.submit("PERSISTED-QUESTION: remember this")
          yield* ui.waitFor("PERSISTED-ANSWER")
          yield* Effect.sleep("3 seconds")
          const list: any = yield* opencode.session.list()
          const items: any[] = list.items ?? list.data ?? list.sessions ?? list
          sessionID = items[0].id
          yield* Effect.log(`session ${sessionID}`)
    
          // 2. Resume it in a new TUI (`opencode -s <id>`) through the fault proxy.
          writeFileSync(`${D}/resume`, sessionID)
          const resumed = yield* tuis.launch("resumed", { recording: true, viewport: { cols: 110, rows: 32 } })
          yield* Effect.sleep("8 seconds") // proxy serves everything normally after the single 503
          yield* resumed.ui.screenshot(`resumed-${MODE}`)
          const frame: any = yield* resumed.ui.capture()
          const text = JSON.stringify(frame)
          const has = text.includes("PERSISTED-QUESTION") && text.includes("PERSISTED-ANSWER")
          yield* Effect.log(`MODE=${MODE} transcript shows stored history: ${has}`)
          yield* Effect.log(`recording: ${resumed.recording?.path}`)
          yield* Effect.log("proxy log:\n" + log.join("\n"))
          // Confirm the history is still stored server-side.
          const msgs: any = yield* opencode.message.list({ sessionID } as any)
          yield* Effect.log(`stored messages: ${JSON.stringify(msgs).includes("PERSISTED-QUESTION")}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH.

  2. RuriRyan commented on Nov 2, 2026

    @RuriRyan

    I hit the same bug on v2.2.5, macOS, when the shared background service restarts with about 25 TUI clients attached. The clients reconnect, and a transient failure during the session re-sync on connected (ECONNRESET / ECONNREFUSED) closes the tabs or sends them home.

    At 2026-11-01T17:47:53Z, about 25 clients logged event stream disconnected with ECONNRESET within 3 ms, followed a few seconds later by ECONNREFUSED and Timed out connecting to server. The toast is not logged, so I cannot match a specific closed tab to that timestamp.

    My report is #61243; PR #59543 retries transient session reads in the same block and would cover this reconnect case too.

  3. romainfd-arrakis commented on Nov 3, 2026

    @romainfd-arrakis

    Thanks to both of you for the fixes here.

    We hit this in practice and found a natural trigger, with no injected errors: several TUIs reconnecting together to a session with many subagent sessions.

    Reproduction: one root session with 200 child sessions created through the API, 5 TUIs attached to it, the server and all TUIs paused together for 75 s and then resumed. macOS 27.2 arm64, kern.ipc.somaxconn 128.

    build TUIs kept on the session
    v2.2.5 0/15 (3 runs)
    dev 67139d6827 0/15 (3 runs)
    dev 67139d6827 + #59543 (d80f0fab1c) 15/15 (3 runs)
    dev 67139d6827 + #60925 (931ed78b26) 15/15 (3 runs)

    With 0 children, all TUIs are kept. Each PR fixes this case on its own: #60925 removes the per-subagent read burst, #59543 survives the failed read. We did not trace which request fails.

    Rerun recipe
    # isolated HOME/XDG, cwd outside any repo, no providers
    export OPENCODE_SERVER_PASSWORD=repro            # TUIs use OPENCODE_PASSWORD=repro
    opencode serve --hostname 127.0.0.1 --port 5196 & SERVER=$!
    A="-u opencode:repro -H content-type:application/json"
    ROOT=$(curl -s $A -d '{"title":"root"}' http://127.0.0.1:5196/api/session | jq -r .data.id)
    for i in $(seq 200); do   # sequential: a parallel burst here hits the same backlog
      curl -s $A -d "{\"parentID\":\"$ROOT\",\"title\":\"child $i\"}" http://127.0.0.1:5196/api/session >/dev/null
    done
    tmux -L repro new-session -d -s r -x 220 -y 51
    for i in 1 2 3 4 5; do
      # Don't make opencode the pane command itself (or `exec` it): tmux sends SIGCONT
      # to a stopped pane process at once. Keep a shell in between and stop only its child.
      tmux -L repro new-window -d -t r -n tui-$i \
        "sh -c 'OPENCODE_PASSWORD=repro opencode --server http://127.0.0.1:5196 -s $ROOT; true'"
    done
    sleep 15                                         # all 5 show the session
    TUIS=$(for p in $(tmux -L repro list-panes -s -t r -F '#{pane_pid}'); do pgrep -P $p; done | xargs)
    kill -STOP $SERVER $TUIS; ps -o stat= -p "$(echo $SERVER $TUIS | tr ' ' ,)"   # every line starts with T
    sleep 75
    kill -CONT $SERVER; kill -CONT $TUIS
    sleep 60
    for i in 1 2 3 4 5; do tmux -L repro capture-pane -p -t r:tui-$i | head -1; done
    # root title in the tab row = kept, a blank line = the session tab is gone
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions