Repository navigation
Description
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.tsjoins concurrentrun()calls and turns activewake()calls into a pending doorbell.packages/core/src/session/execution.tsroutes production drains through that coordinator.packages/core/src/session/runner/llm.tsowns 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:
- Graph B starts a model request and loads history before graph A's shell call is visible.
- Graph A publishes
call_d197...as running and starts the long shell. - Graph B receives its response, creates the second assistant, and completes its own
readtool. - Graph B advances to its continuation and reloads current durable history.
- That history now contains graph A's shell in
runningstate but no shell result. packages/core/src/session/runner/to-llm-message.tsemits a tool-call part for a running local tool, buttoolResult()emits a result only forcompletedorerrorstates.packages/ai/src/protocols/open-responses.tslowers the call tofunction_call; it can emitfunction_call_outputonly when a tool-result message exists.- The provider rejects the unmatched function call.
- 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:
- Start graph B and gate its first provider response after it has passed drain entry and loaded history.
- Start graph A for the same Session and have its provider request a deliberately blocked local tool.
- Release graph B with a fast local tool call and let that tool complete.
- Make graph B's next provider call validate that every local tool call has a matching result.
- Assert that graph B cannot send a request while graph A owns the unresolved tool.
- 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:
- Run V2 through the managed background service with an OpenAI Responses-compatible model.
- Ask the model to perform work that starts a long-running built-in shell tool.
- While the shell is visibly running, submit a steering/correction message.
- Observe a second assistant/tool pipeline begin before the original shell settles.
- Observe a later provider request fail with
No tool output found for function call call_<id>. - 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
theshasha commentedon Oct 6, 2026
This also happens with two plain
opencode serveprocesses sharing a database. I tested on 2.0.22 on Windows 11, using different ports, a freshOPENCODE_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:
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: stopand 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_suspendedwhile 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.
jordigilh commentedon Oct 23, 2026
I’m seeing a similar failure in OpenCode v2.0.18 on macOS.
Reproduction context:
A parent OpenCode session is active in a Git checkout.
A pre-push closure plugin launches a child process:
The parent and child run concurrently under the same OpenCode service/configuration.
The reviewer performs normal repository read/search and shell tool calls.
The session then fails with:
The push is blocked because the child cannot complete its review approval.
Separate attempts also returned:
This resembles the concurrent application-graph/shared-database failure described here. I have not yet confirmed whether both processes used the same durable
OPENCODE_DBor Session ID; that is the next diagnostic step.opencodejr commentedon Oct 28, 2026
Thanks for the detailed analysis. I could reproduce this.
Setup: two
opencode serveprocesses on different ports with the same HOME, so they share oneopencode.db. A mock OpenAI Responses server rejects anyfunction_callthat has nofunction_call_output, the way OpenAI does. In one Session:readafter a 2 s delay;shell(sleep 8).At your reported commit (
643eed3): graph B's next request contained A's still-runningcall_longwith no output. It was rejected withNo 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:The script below reproduces it. It needs a small mock Responses server, described in the script's header.
Related:
opencode.dballocate message sequence numbers independentlyReproduction script (repro.sh)
Run it:
bash repro.sh