Skip to content

v2: concurrent application graphs can send unmatched tool calls for one Session #43648

Open

Description

@bbartels

Description

OpenCode V2 can run overlapping model/tool pipelines for the same durable Session when independent application graphs share its database. One pipeline can then construct an OpenAI Responses request containing another pipeline's still-running local tool call without the corresponding tool result. The provider correctly rejects that malformed history with:

No tool output found for function call call_<id>.

The tool itself does not necessarily fail. In the clearest occurrence, the original shell command continued running and completed normally after the concurrent provider request had already been rejected.

Observed timeline

The following timestamps came from a sanitized Session export:

Event Time relative to shell start
First assistant message created -31 ms
Shell tool call_d197... started 0 ms
Second assistant message created +183 ms
Second assistant's built-in read completed +263 ms
Provider rejected a request missing call_d197... output +1.324 s
Original shell tool completed successfully +27.163 s

The persisted shell state eventually contained both ran and completed timestamps and successful output. A different assistant message recorded the provider error with finish: "error" before the shell completed.

A second occurrence happened around a managed-service restart. Two assistant messages were created about 90 ms apart and both launched tools. One tool result was durable by the time the provider error was observed, which is consistent with the rejected request having been materialized from an earlier history snapshot.

Why this does not appear to be a normal same-process steer

At the observed V2 revision, one SessionRunCoordinator serializes execution per Session within an application graph:

  • packages/core/src/session/run-coordinator.ts joins concurrent run() calls and turns active wake() calls into a pending doorbell.
  • packages/core/src/session/execution.ts routes production drains through that coordinator.
  • packages/core/src/session/runner/llm.ts owns every local tool fiber and awaits all of them before advancing to another model step.

Therefore, one normal coordinator cannot start the second assistant 183 ms after the first while still owning the first assistant's shell for another 27 seconds. The timing strongly indicates that at least two independent SessionExecution/SessionRunner graphs were operating on the same Session.

The supplied evidence does not prove exactly how the second graph was started. Possible sources include overlapping managed services on different endpoints, a managed service plus a standalone/foreground server, an embedded server graph, or independently configured servers sharing OPENCODE_DB.

How the malformed request is produced

The likely sequence is:

  1. Graph B starts a model request and loads history before graph A's shell call is visible.
  2. Graph A publishes call_d197... as running and starts the long shell.
  3. Graph B receives its response, creates the second assistant, and completes its own read tool.
  4. Graph B advances to its continuation and reloads current durable history.
  5. That history now contains graph A's shell in running state but no shell result.
  6. packages/core/src/session/runner/to-llm-message.ts emits a tool-call part for a running local tool, but toolResult() emits a result only for completed or error states.
  7. packages/ai/src/protocols/open-responses.ts lowers the call to function_call; it can emit function_call_output only when a tool-result message exists.
  8. The provider rejects the unmatched function call.
  9. Graph A later completes the shell and updates durable state, but that cannot change graph B's already-built or already-sent request.

This also explains why a Session can look valid when inspected after the incident even though the provider received malformed history earlier.

Cross-graph ownership gap

The durable execution claim does not enforce ownership. SessionStore.claim() updates time_suspended only when it is null, but the result is not used as a compare-and-acquire decision. The claim has no owner ID, lease, or fencing token. A second graph can affect zero rows and still begin its drain.

Restart recovery in packages/core/src/session/execution/restart.ts excludes Sessions found in the current graph's process-local active set. It cannot detect that another process or graph still owns a claimed Session, so it can treat a live claim as orphaned and resume it.

Managed-service registration is also a lease rather than an exclusion lock. Same-host/same-port contenders are normally excluded by the TCP bind before the application graph is built. If contenders use different endpoints, however, both can bind. The newer process can replace the shared registration while the previous process remains active until its five-second registration ownership check notices the replacement.

The stale-tool sweep does not make cross-graph execution safe. It runs once at drain entry. Graph B can pass that sweep before graph A publishes its tool, then encounter the running tool on a later continuation. If the sweep instead sees a tool still owned by graph A, marking it stale would incorrectly abort live work.

Expected behavior

Only one application graph should be allowed to drive a durable Session at a time.

If multiple graphs sharing a database are unsupported, OpenCode should prevent the second graph from building/running Session recovery. If they are supported, Session execution needs durable ownership and fencing.

Request assembly must never send an OpenAI-compatible provider an assistant function call without its required function output. Encountering another execution's unresolved local tool should delay or fail the conflicting execution rather than constructing malformed provider history.

Current V2 status

The occurrence was observed on opencode2 v0.0.0-dev-17604, corresponding to V2 commit 643eed300dc34b15fd8742243298fa882d6d9199.

The relevant failure class is still present at V2 commit 838d74751412e0f9a38a2e3283fd867a36d76900:

  • Session coordination remains application/process local.
  • Durable claims remain non-exclusive recovery markers.
  • Restart recovery still checks only local active execution.
  • Running local tools are still lowered as calls without results.
  • Managed registration is still replacement followed by a five-second ownership poll.

Recent service lifecycle changes make normal registered-process replacement safer, but they do not provide per-database or per-Session fencing across independent graphs.

Suggested remediation

First decide and enforce the supported ownership boundary:

  • If one application graph per database is required, acquire a database-scoped service lock before building the graph or running restart recovery, and hold it for the graph lifetime. Endpoint-specific TCP binding is not sufficient.
  • If multiple graphs per database are supported, add durable per-Session ownership with an owner identity and fencing token. Every continuation and terminal write must verify the current fence.
  • Treat unresolved local tool calls from another execution as an ownership conflict. Do not omit the result and send malformed provider history.
  • Run stale-tool recovery only after ownership of the Session has been established.

Suggested regression coverage

Add a test using two complete application graphs over one temporary database and a fake provider:

  1. Start graph B and gate its first provider response after it has passed drain entry and loaded history.
  2. Start graph A for the same Session and have its provider request a deliberately blocked local tool.
  3. Release graph B with a fast local tool call and let that tool complete.
  4. Make graph B's next provider call validate that every local tool call has a matching result.
  5. Assert that graph B cannot send a request while graph A owns the unresolved tool.
  6. Release graph A's tool and assert exactly one valid continuation with one result per call.

Also add a managed-service integration test in which two endpoints share one database and registration location. Assert that only one graph may perform restart recovery or drain a claimed Session, not merely that one registration eventually wins.

Plugins

No response

OpenCode version

Observed on opencode2 v0.0.0-dev-17604 / V2 commit 643eed300dc34b15fd8742243298fa882d6d9199. Source audit confirms the failure class remains at V2 commit 838d74751412e0f9a38a2e3283fd867a36d76900.

Steps to reproduce

The observed user-level sequence was:

  1. Run V2 through the managed background service with an OpenAI Responses-compatible model.
  2. Ask the model to perform work that starts a long-running built-in shell tool.
  3. While the shell is visibly running, submit a steering/correction message.
  4. Observe a second assistant/tool pipeline begin before the original shell settles.
  5. Observe a later provider request fail with No tool output found for function call call_<id>.
  6. Export the Session and compare assistant creation, tool start/completion, and provider-error timestamps.

The steering message alone should not create overlap in one application graph. A deterministic reproduction should use the two-graph fake-provider test described above, because the remaining unknown is how the second graph was present in the live occurrence.

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

  1. theshasha commented on Oct 6, 2026

    @theshasha

    This also happens with two plain opencode serve processes sharing a database. I tested on 2.0.22 on Windows 11, using different ports, a fresh OPENCODE_DB, and a local fake model endpoint so I could hold each response open. No tools were called, and the test used separate config and working directories from my normal setup.

    Here's the sequence:

    1. Send a prompt through server A and keep the model response streaming.
    2. Send a follow-up through A to the same session. It waits while the first response is still open.
    3. Send another prompt through server B to that session. B starts its own model request while A is still running.

    I captured B's request. It contained the original prompt, the follow-up sent through A, and the new prompt sent through B. It did not contain A's unfinished assistant text. A's existing request still had only the original prompt. So B had picked up the input waiting for A, and both servers were generating replies from different versions of the conversation.

    I then let both responses finish normally, once with A finishing first and once with B finishing first. Both replies were saved as separate assistant messages in the same session, with finish: stop and no errors. A did not make another request for its follow-up during the one-second observation after completion. B had already consumed that input before I released either response, so changing the finish order didn't change who handled it.

    That matches inbox.ts:496-539: pending steering inputs are selected by session ID, with no record of which server received them. runner/llm.ts:163-179 consumes them before loading the model context, and message-updater.ts:214-254 writes the new assistant message into the shared session.

    One other thing showed up in both runs: whichever server finished first cleared time_suspended while the other was still generating. This field records unfinished execution for restart recovery. The success handler in execution.ts:119-126 calls store.release, which clears it by session ID without checking the execution owner. The other request keeps running, but if its process then dies, the recovery query would miss that session. I checked the early clearing, not the subsequent crash/recovery case.

    I didn't test the missing-tool-output error reported here. These tests confirm how two foreground servers can overlap and write into the same history. The source links are from 2.0.24; the binary I tested was 2.0.22.

  2. jordigilh commented on Oct 23, 2026

    @jordigilh

    I’m seeing a similar failure in OpenCode v2.0.18 on macOS.

    Reproduction context:

    1. A parent OpenCode session is active in a Git checkout.

    2. A pre-push closure plugin launches a child process:

      opencode run --model openai/gpt-5.6-luna#max \
        --agent pr-review-closure --format json --auto ...
      
    3. The parent and child run concurrently under the same OpenCode service/configuration.

    4. The reviewer performs normal repository read/search and shell tool calls.

    5. The session then fails with:

      No tool output found for function call call_EHEC399CgFOFAJtsrfaZHajR
      
    6. The push is blocked because the child cannot complete its review approval.

    7. Separate attempts also returned:

      Upstream request failed: Endpoint is unavailable.
      

    This resembles the concurrent application-graph/shared-database failure described here. I have not yet confirmed whether both processes used the same durable OPENCODE_DB or Session ID; that is the next diagnostic step.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed analysis. I could reproduce this.

    Setup: two opencode serve processes on different ports with the same HOME, so they share one opencode.db. A mock OpenAI Responses server rejects any function_call that has no function_call_output, the way OpenAI does. In one Session:

    • graph B's model calls read after a 2 s delay;
    • graph A's model meanwhile starts a long shell (sleep 8).

    At your reported commit (643eed3): graph B's next request contained A's still-running call_long with no output. It was rejected with No tool output found for function call call_long. The shell itself finished normally a few seconds later, the same as your timeline.

    On the latest dev (a882cf9, also the 2.2.0 release): the provider no longer rejects the request. Since #52421, tool calls with no result get a placeholder error result, "Tool result missing". The cross-graph problem is still there, though:

    • graph B sends its continuation while A's shell is still running, telling the model that shell failed with "Tool result missing";
    • graph A then sends its own continuation with the real output, so the same turn is answered twice.

    The script below reproduces it. It needs a small mock Responses server, described in the script's header.

    Related:

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for anomalyco/opencode#43648: two independent OpenCode application graphs sharing one
    # database drive the same Session at the same time.
    #
    # Needs: `bun mock.ts` running (OpenAI Responses mock on :4999 that answers 400
    # "No tool output found for function call X" like OpenAI when a function_call has no output),
    # and $PROJ/opencode.json with provider "mock" -> http://127.0.0.1:4999/v1, models "a" and "b":
    #   { "providers": { "mock": { "package": "@opencode/ai/providers/openai/responses",
    #       "settings": { "baseURL": "http://127.0.0.1:4999/v1", "apiKey": "x" }, "models": { "a": {}, "b": {} } } } }
    #
    # Graph selection:
    #   default: two `opencode run --standalone` processes (two private servers, same HOME => same opencode.db)
    #   SERVER_A=http://... SERVER_B=http://...: two already-running `opencode serve --port N` processes sharing one DB
    set -u
    OC=${OC:-opencode}
    export HOME=${HOME_DIR:-/tmp/opencode/home}
    cd "${PROJ:-$(dirname "$0")/proj}"
    target() { if [ -n "${1:-}" ]; then echo "--server $1"; else echo "--standalone"; fi; }
    # create a fresh Session to share (model "seed" just answers "ok"), then both graphs continue it with -c
    $OC run $(target "${SERVER_B:-}") -m mock/seed --title shared "seed" >/dev/null 2>&1
    # graph B: mock holds model b's first response 2s, then it calls the fast `read` tool
    $OC run $(target "${SERVER_B:-}") --auto -c -m mock/b "STEER: read README" > /tmp/opencode/b.out 2>&1 &
    sleep 0.5
    # graph A: mock makes model a call a long-running `shell` (sleep 8) immediately
    $OC run $(target "${SERVER_A:-}") --auto -c -m mock/a "LONG: run the long command" > /tmp/opencode/a.out 2>&1 &
    wait
    echo "--- graph A ---"; cat /tmp/opencode/a.out
    echo "--- graph B ---"; cat /tmp/opencode/b.out

    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions