Skip to content

V2: after a hard crash, --standalone / opencode serve never settle the interrupted shell tool, so the TUI spins forever #58386

Open

Description

@chmill-zz

Description

If the server dies hard (SIGKILL, OOM, machine or WSL shutdown) while a shell tool is running, the tool part stays running in the database. The background service (serve --service) cleans this up on start: it marks the tool Tool execution interrupted: shell and resumes the turn. A private server started by opencode --standalone, or plain opencode serve, does not. When the session is reopened:

  • The TUI shows ⠸ echo hello && sleep 600 with an animated spinner and Press ctrl+b to move running work to the background, but no process is running and GET /api/session/active returns {}.
  • The spinner redraws about 12 times a second indefinitely. Idle, that is about 11 KB of terminal output every 10s and ~2.3% of a core in the TUI, compared with ~0.5% and 0 bytes once the tool is settled.
  • It only clears when a new prompt is sent in that session (settleStaleToolCalls runs at the start of the next turn, as noted in Provider stream can disappear while tool call remains permanently "running" with no timeout/reaper #51291).

A graceful stop is fine in every mode: POST /api/session/{id}/interrupt, SIGTERM, a model error partway through a reply, or killing only the TUI (the stdio server gets EOF and settles the tool).

This is the hard-crash case from #35642. That issue was closed on 2026-07-09 with a request to open a focused issue if it still reproduces. It reproduces on 2.1.2 outside the background service.

Two smaller things seen along the way:

  • The TUI decides to show a tool spinner from state.status === "running" || "streaming" alone. It never checks whether the session is actually running, so any stale part keeps animating. "animations": false stops it.
  • opencode session export leaves out the unfinished assistant message entirely.

Related: #35642, #51291, #56804, #19023.

Plugins

None. Clean config and data dirs (isolated XDG_* directories).

OpenCode version

2.1.2

Steps to reproduce

I used a local OpenAI-compatible mock model that calls the shell tool with echo hello && sleep 600. Any real model running a long command behaves the same way.

  1. opencode --standalone -s <id> --prompt "run the demo". Wait until sleep 600 is running.
  2. Kill the TUI and its serve --stdio child together with kill -9, which is what a machine or WSL shutdown does.
  3. opencode --standalone -s <id>. The command is still shown as running with a spinner and the ctrl+b hint.

You get the same result by running opencode serve, prompting through the HTTP API, then kill -9 the server and restart it. Doing the same with opencode serve --service recovers correctly.

Screenshot

┃  rundemo

   + Thought · 7ms

┃  ⠸ echo hello && sleep 600

   Press ctrl+b to move running work to the background

Operating System

Ubuntu 24.04 (Linux 7.0, x86_64). First noticed on WSL.

Terminal

Measured in a pty harness (xterm-256color, 57x226).

Activity

  1. opencode-agent commented on Oct 23, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I could reproduce this on 2.1.2 with a plain opencode serve and a mock OpenAI-compatible model that calls shell with echo hello && sleep 600:

    1. Prompt over the HTTP API and wait until the command is running. The session shows as running in /api/session/active.
    2. kill -9 the server, then start opencode serve again on the same data directory.
    3. /api/session/active now returns {}, but the shell tool part in /api/session/{id}/message is still status: "running" and never gets settled. The TUI spinner is drawn from that state.

    It still happens on the latest dev (4d30794). The code matches what you describe: the startup cleanup in packages/server/src/process.ts (installRestartContinuity / resumeSuspendedSessions) only runs for the background service (serve --service), so a plain serve or the private --standalone server never cleans up the interrupted tool.

    The script below reproduces it automatically. I checked this through the API only and didn't make a TUI recording: the test harness always starts its server with --service, which recovers correctly.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #58386: after SIGKILL of a plain `opencode serve` mid shell-tool, the restarted server
    # never settles the stale `running` tool part (while /api/session/active is empty).
    # Usage: ./repro.sh            (uses `opencode` on PATH)
    #        OC="bun --conditions=browser --preload=/workspace/repo/packages/tui/node_modules/@opentui/solid/scripts/preload.js /workspace/repo/packages/cli/src/index.ts" ./repro.sh
    set -u
    OC=${OC:-opencode}
    W=$(mktemp -d /tmp/opencode/r58386.XXXX); cd "$W"; mkdir proj xdg
    export XDG_CONFIG_HOME=$W/xdg/c XDG_DATA_HOME=$W/xdg/d XDG_STATE_HOME=$W/xdg/s XDG_CACHE_HOME=$W/xdg/k OPENCODE_DISABLE_AUTOUPDATE=1
    cat > mock.ts <<'TS'
    Bun.serve({ port: 9999, async fetch(req) {
      const body: any = await req.json().catch(() => ({}))
      const sse = (cs: any[]) => new Response(cs.map(c => `data: ${JSON.stringify({ id: "c", object: "chat.completion.chunk", created: 0, model: "m", ...c })}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } })
      const tools = (body.tools ?? []).length > 0, hasTool = (body.messages ?? []).some((m: any) => m.role === "tool")
      if (tools && !hasTool) return sse([
        { choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "shell", arguments: JSON.stringify({ command: "echo hello && sleep 600", description: "demo" }) } }] } }] },
        { choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }])
      return sse([{ choices: [{ index: 0, delta: { role: "assistant", content: "done" } }] }, { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }])
    }})
    TS
    cat > proj/opencode.json <<'J'
    { "provider": { "mock": { "npm": "@ai-sdk/openai-compatible", "name": "Mock", "options": { "baseURL": "http://127.0.0.1:9999/v1", "apiKey": "x" }, "models": { "m": { "name": "Mock", "tool_call": true } } } },
      "model": "mock/m", "permission": "allow" }
    J
    bun mock.ts & MOCK=$!
    start() { (cd proj && exec $OC serve --port 4567 > "$W/serve$1.log" 2>&1) & SRV=$!; for i in $(seq 60); do grep -q 'server password' "$W/serve$1.log" 2>/dev/null && break; sleep 1; done; PW=$(grep 'server password' "$W/serve$1.log" | awk '{print $3}'); }
    api() { curl -s -u "opencode:$PW" -X "$1" -H 'content-type: application/json' ${3:+-d "$3"} "localhost:4567$2"; }
    parts() { api GET /api/session/$SID/message | python3 -c 'import json,sys
    for m in json.load(sys.stdin)["data"]:
      for p in m.get("content") or []:
        if p.get("type")=="tool": print("  tool", p["name"], "status =", p["state"]["status"], p["state"].get("error",""))'; }
    start 1
    SID=$(api POST /api/session "{\"location\":{\"directory\":\"$W/proj\"}}" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["id"])')
    api POST /api/session/$SID/prompt '{"text":"run the demo"}' >/dev/null; sleep 5
    echo "before crash: active=$(api GET /api/session/active)"; parts
    pkill -9 -P $SRV; kill -9 $SRV $(pgrep -f 'opencode serve --port 4567') 2>/dev/null; pkill -9 -f 'sleep 600'; sleep 1
    start 2; sleep 10
    echo "after restart: active=$(api GET /api/session/active)"; parts
    kill $SRV $MOCK 2>/dev/null; pkill -f 'serve --port 4567'

    Run it: bash repro.sh

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