Skip to content

opencode run (v2) exits 1 after a transient provider error that the runner retried and recovered from #50062

Open

Description

@amegalaa

Description

We run opencode run non-interactively in automation with --format json. When the upstream model provider is flaky and a step fails mid-stream, opencode reports a provider.transport error (for example ECONNRESET: The socket connection was closed unexpectedly, with HTTP status 200 because the response headers had already arrived), the runner retries the step, and the session completes normally with finish reason stop. The process still exits with code 1. stderr is empty; the only trace is the earlier error event in the JSON stream.

Cause in the v2 runner, packages/cli/src/run/noninteractive.ts:

The file is identical from v2.0.0 through v2.0.10. In v1.18.31 the exit code is set only on startup errors, and a retried step does not affect it.

Observed: any non-interactive run in which one step failed transiently and was retried exits 1, even when the run finishes with reason stop and a complete answer.

Expected: exit 0 when the session completes successfully. The exit code should report the outcome of the run, not whether a recoverable fault happened along the way. Comparable tools (curl --retry, wget, pip, rsync) exit 0 when a retry succeeds and keep the fault in their logs.

Why it matters: anything scripting opencode run trusts the exit code. With this behavior a caller must either treat 1 as "maybe fine, parse the event stream" or discard good output.

Suggested fix: do not set process.exitCode = 1 on session.step.failed when the step is going to be retried, or clear it on session.execution.succeeded. If "completed, but errors were emitted" is worth surfacing, use a distinct documented exit code or an opt-in strict flag rather than 1.

Plugins

None.

OpenCode version

v2.0.10 (git tag; the v2 line is on the @opencode-ai/cli beta channel, 0.0.0-beta-*). Also v2.0.8. Not reproducible on v1.18.31 (opencode-ai@latest).

Steps to reproduce

  1. Run opencode run --format json "<any multi-step prompt>" against a provider that drops one streaming response mid-body (a proxy that closes the socket after the headers and a few SSE events reproduces it; opencode run exits 1 after auto-compaction recovers from a provider 413 #39573 mentions a test-LLM harness that can inject a failure followed by successful replies).
  2. Observe in the JSON stream: an error event with error.type == "provider.transport", followed by further steps and a final step_finish with reason stop.
  3. echo $? prints 1.

Screenshot and/or share link

Not applicable (non-interactive; JSON event stream shows the error event followed by a normal finish).

Operating System

Linux (x86_64).

Terminal

Not applicable (headless).

Related: #39573 reports the same symptom class on the v1 runner (exit 1 after a recovered provider 413 during auto-compaction) through a different code path.

Activity

  1. rajmehta53 commented on Oct 16, 2026

    @rajmehta53

    Still reproduces on v2.0.30, macOS arm64 (latest channel). Our variant is
    provider.invalid-output (OpenAI Chat stream ended without finish_reason, HTTP 200)
    rather than a transport error, but the outcome is identical.

    One automation run of:

    opencode run --dangerously-skip-permissions --agent build --model openrouter/z-ai/glm-5.3-flash "<prompt>"
    

    Timeline (local time):

    • 02:54:17 provider stream dies mid-run
    • 02:54:56 runner auto-retries, agent resumes and completes all work
    • 02:57:00 commit lands, tests pass, session finalizes outcome: "succeeded"
    • 02:57:02 CLI exits 1, stderr empty after the retry

    Checked the v2.0.30 tag of packages/cli/src/run/noninteractive.ts:
    session.step.failed still sets emittedError = true; process.exitCode = 1,
    session.execution.succeeded is still just return, and nothing in the file resets
    the exit code after a success.

    Downstream: our runner gates on exit code, so the successful attempt was recorded as a
    failure, and the next attempt found the work already committed, so retries could never
    produce a new commit. DB rows and server log available if useful.
    Watching #50068 and #52885.

  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I was able to reproduce this on v2.0.10, and it still happens on the latest dev (62ae125).

    Setup: a small OpenAI-compatible mock provider. On the agent's first step request it sends 200, the SSE headers and a couple of text deltas, then drops the socket. Every later request gets a normal answer ending with finish_reason: "stop". Running opencode run --format json -m mock/mock "Say hello" against it:

    • the stream shows the partial text, then an error event with provider.transport (ECONNRESET: The socket connection was closed unexpectedly, status 200);
    • the step is retried and returns the full answer; the session ends with finish stop and outcome succeeded;
    • stderr is empty and the exit code is 1.

    The cause matches what you describe. Core publishes session.step.failed for a step it then retries, and the run command's handler for that event sets process.exitCode = 1 without checking for a retry. A later success never resets it. The script below reproduces it, either against the installed opencode or against a source checkout with OC_DEV=<checkout>.

    Related: #39573 (the same symptom on the v1 runner, through a different code path).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #50062: `opencode run` exits 1 after a step that failed mid-stream was retried and the run finished with "stop".
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/path/to/opencode-checkout ./repro.sh   (run the CLI from source)
    set -u
    OC=${OC:-opencode}
    WORK=$(mktemp -d /tmp/opencode/repro50062.XXXX)
    PORT=${PORT:-18765}
    cat > "$WORK/mock.mjs" <<'JS'
    // OpenAI-compatible mock (node:http). Request #1: 200 + headers + two SSE deltas, then the socket is destroyed
    // (client sees ECONNRESET / "socket connection was closed unexpectedly"). Later requests: a normal answer, finish "stop".
    import http from "node:http"
    let n = 0 // agent step requests seen
    const chunk = (delta, finish = null) =>
      `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 0, model: "mock", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`
    http.createServer((req, res) => {
      console.error(`[mock] ${req.method} ${req.url}`)
      if (req.url.endsWith("/models")) { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ object: "list", data: [{ id: "mock" }] })) }
      let raw = ""
      req.on("data", (d) => (raw += d))
      req.on("end", () => {
        const body = JSON.parse(raw || "{}")
        // Only the agent's own step requests carry tools; title generation etc. do not.
        const agentStep = Array.isArray(body.tools) && body.tools.length > 0
        if (agentStep) n++
        console.error(`[mock] request agentStep=${agentStep} #${n}`)
        res.writeHead(200, { "content-type": "text/event-stream" })
        if (agentStep && n === 1) {
          res.write(chunk({ role: "assistant", content: "Partial " }))
          res.write(chunk({ content: "answ" }))
          setTimeout(() => res.socket.destroy(), 300)
          return
        }
        res.write(chunk({ role: "assistant", content: "Hello, " }))
        res.write(chunk({ content: "world." }))
        res.write(chunk({}, "stop"))
        res.write(`data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 0, model: "mock", choices: [], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } })}\n\n`)
        res.end("data: [DONE]\n\n")
      })
    }).listen(Number(process.env.PORT), () => console.error("[mock] listening"))
    JS
    PORT=$PORT node "$WORK/mock.mjs" 2> "$WORK/mock.log" &
    MOCK=$!
    sleep 1
    mkdir -p "$WORK/project" && cd "$WORK/project" && git init -q
    cat > opencode.json <<JSON
    {
      "\$schema": "https://opencode.ai/config.json",
      "autoupdate": false,
      "provider": {
        "mock": {
          "npm": "@ai-sdk/openai-compatible",
          "name": "Mock",
          "options": { "baseURL": "http://127.0.0.1:$PORT/v1", "apiKey": "x" },
          "models": { "mock": { "name": "Mock" } }
        }
      }
    }
    JSON
    # From source (OC_DEV=/path/to/checkout): the spawned server child needs the Solid preload too.
    if [ -n "${OC_DEV:-}" ]; then
      OC="bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts"
      printf 'preload = ["%s"]\n' "$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" > bunfig.toml
    fi
    export XDG_DATA_HOME=$WORK/data XDG_CONFIG_HOME=$WORK/config XDG_STATE_HOME=$WORK/state XDG_CACHE_HOME=$WORK/cache
    $OC --version
    timeout 40 $OC run --format json -m mock/mock "Say hello" > "$WORK/out.jsonl" 2> "$WORK/err.txt"
    CODE=$?
    echo "--- JSON events (type / error / reason):"
    cat "$WORK/out.jsonl"
    echo "--- stderr:"; cat "$WORK/err.txt"
    echo "--- mock:"; cat "$WORK/mock.log"
    echo "EXIT CODE: $CODE"
    kill $MOCK 2>/dev/null
    # opencode run leaves a background service process; stop it so the next run starts fresh.
    pkill -f "[s]erve --service" 2>/dev/null || true

    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