🤖 fix: serialize sibling tool execution per stream#2906
Conversation
Serialize sibling tool execute() handlers one-at-a-time within a single stream while preserving provider-level parallel tool call planning. - add a stream-local sequential execution wrapper for tool maps - apply the wrapper in StreamManager request construction - cover provider options and ordering behavior with regression tests --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `high` • Cost: `$4.81`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=high costs=4.81 -->
|
@codex review |
|
Codex Review: Didn't find any major issues. 🎉 ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Keep analytics ETL imports safe in Bun-based unit tests by lazy-loading the DuckDB runtime only when ETL append logic actually needs it and skipping the native DuckDB-backed ETL cases under Bun. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `high` • Cost: `$4.81`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=high costs=4.81 -->
|
@codex review |
|
Codex Review: Didn't find any major issues. 🎉 ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Drop the follow-up DuckDB ETL changes so this branch only contains the tool execution serialization work originally requested. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `high` • Cost: `$4.81`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=high costs=4.81 -->
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0b530184e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Please take another look. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f827469445
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
f827469 to
e83e99e
Compare
|
@codex review Please take another look. |
Make sequential tool execution abort-aware so queued sibling tool calls do not acquire the lock and run side effects after a stream interruption. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `high` • Cost: `$4.81`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=high costs=4.81 -->
e83e99e to
2be2fac
Compare
|
@codex review Please take another look. |
|
Codex Review: Didn't find any major issues. You're on a roll. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Exempt only provably-isolated `task` tool calls from the per-stream sequential-execution mutex, restoring parallel sub-agent launches without reintroducing shared-working-tree races. #2906 serializes every sibling tool execute() in a stream behind one mutex to prevent races on shared mutable state. That also serializes parallel `task` launches: only the first runs, the rest block on the long foreground waitForAgentReport. A forking `task` runs in its own isolated checkout and shares no mutable state with sibling tools, so it is safe to run in parallel. But a `task` that runs in the PARENT checkout (local runtime, or worktree/ssh with isolation:"none") shares the working tree and must stay serialized. Decide per call, by runtime mode + isolation arg, via the new taskCallSharesParentWorkspace() helper. runtimeMode is threaded (optionally, defaulting to serialize) from the AIService send path into the stream-request builder. All other tools remain fully serialized.
Exempt only provably-isolated `task` tool calls from the per-stream sequential-execution mutex, restoring parallel sub-agent launches without reintroducing shared-working-tree races. #2906 serializes every sibling tool execute() in a stream behind one mutex to prevent races on shared mutable state. That also serializes parallel `task` launches: only the first runs, the rest block on the long foreground waitForAgentReport. A forking `task` runs in its own isolated checkout and shares no mutable state with sibling tools, so it is safe to run in parallel. But a `task` that runs in the PARENT checkout (local runtime, or worktree/ssh with isolation:"none") shares the working tree and must stay serialized. Decide per call, by runtime mode + isolation arg, via the new taskCallSharesParentWorkspace() helper. runtimeMode is threaded (optionally, defaulting to serialize) from the AIService send path into the stream-request builder. All other tools remain fully serialized.
Let parallel `task` tool calls launch concurrently again while keeping shared-working-tree access serialized, via a per-stream read/write lock. #2906 serializes every sibling tool execute() in a stream behind one mutex to prevent races on the parent checkout. That also serializes parallel `task` launches: only the first runs, the rest block on the long foreground waitForAgentReport. The real shared resource is the parent workspace checkout, so model it as a read/write lock: - Writers (exclusive): all non-task tools, plus shared-checkout task calls (local runtime, or worktree/ssh with isolation:"none"). They may mutate or depend on exclusive access to the parent tree. - Readers (shared): forked task calls (worktree/ssh default/"fork", or docker/devcontainer). TaskService.create() still reads the parent checkout briefly (agent frontmatter + current branch), so a forked task must exclude sibling writers, but two forked tasks share no mutable checkout state and may overlap. This restores task||task parallelism while keeping task||bash and task||file-write mutually exclusive (addresses Codex review). Runtime mode is threaded from the AIService send path and is undefined-safe (treated as shared -> write/serialize). Classification is decided per call via taskCallSharesParentWorkspace(). A foreground forked task still holds the read lock across its full execute chain (incl. waitForAgentReport), conservatively blocking later writers for its duration -- same scope as the original mutex; narrow to create()'s critical section later if it matters.
#2906 wrapped every tool's execute() in a per-stream AsyncMutex to guard against sibling tool-call races. It had no linked issue or repro, and its own plan notes the real race (bash background output) was already fixed at the runtime boundary via backgroundProcessManager's outputLock. The blanket mutex serialized all foreground sibling tool calls — most visibly parallel 'task' calls, where the lock is held across waitForAgentReport — defeating provider-level parallel tool planning. The genuinely-shared resources are already guarded at the resource level (bash outputLock, TaskService.create()'s own mutex, config FIFO queue; file_read is read-only), so remove the redundant stream-level lock instead of making it concurrency-aware.
Keep the per-stream serialization mutex from #2906 intact for all mutating tools. Only built-in `task` calls targeting agentId "explore" without isolation:"none" may overlap, and only with each other, via a fair reader-writer lock. This restores parallel sub-agent exploration without re-opening the shared-checkout / background-process races that broad parallelism would.
Keep the per-stream serialization mutex from #2906 intact for all mutating tools. Only built-in `task` calls targeting agentId "explore" without isolation:"none" may overlap, and only with each other, via a fair reader-writer lock. This restores parallel sub-agent exploration without re-opening the shared-checkout / background-process races that broad parallelism would.
## Summary Sub-agent exploration regained its parallelism: built-in `task` calls to the `explore` agent now run concurrently with each other again, while every mutating tool stays strictly serialized as before. The agent could emit several sibling tool calls intending parallel execution, but only the first actually ran — the rest serialized despite the provider advertising `parallelToolCalls: true`. ## Background PR coder#2906 (commit `386346261`) wrapped every tool's `execute()` in a single shared per-stream `AsyncMutex` (`withSequentialExecution`). That made all sibling tool calls run one-at-a-time, which is the safe default for mutating tools (file edits, bash, config writes) but also defeated parallel sub-agent fan-out — the most visible casualty being the `task` tool spawning `explore` sub-agents. A full revert was rejected because broad parallelism re-introduces real races that coder#2906 quietly fixed: shared-checkout task collisions, background-process ID collisions, and concurrent config read-modify-write. This change keeps all of those protections and only carves out the one case that is provably safe to overlap. ## Implementation The per-stream mutex is replaced with a fair reader-writer lock (`SharedExecutionLock`): - **Writers (exclusive):** every tool except the narrow exception below. Mutating tools keep their original one-at-a-time guarantee, and a writer never overlaps a reader. - **Readers (shared):** only built-in `task` calls that target `agentId: "explore"` and do **not** request `isolation: "none"`. These overlap with each other but never with a writer. The exception is gated on three independent conditions so it can't widen accidentally: 1. The tool is the *built-in* `task` tool, identified by a non-forgeable `Symbol` marker (`markBuiltInTaskTool` / `isBuiltInTaskTool`) rather than by tool name. A user/MCP tool that happens to be named `task` does not qualify. 2. The requested agent is `explore` (read-only by contract). 3. The call does not use a shared workspace (`isolation: "none"`), so forked-checkout explore tasks can't race on a shared working tree. The lock uses a turnstile pattern (turnstile + room-empty + reader-count mutexes) so writers can't be starved by a steady stream of readers and ordering stays fair. Abort handling reuses the existing `acquireLockOrAbort` path. ## Risks Scoped to tool-call scheduling within a single stream. The only behavioral change is that two-or-more concurrent built-in `explore` task calls may now overlap; all mutating tools retain identical serialized behavior. Because the exception excludes shared-checkout tasks and is keyed off a symbol (not a name), the shared-checkout and background-process-ID races that motivated coder#2906 remain closed. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-8` • Thinking: `xhigh` • Cost: `$29.64`_ <!-- mux-attribution: model=anthropic:claude-opus-4-8 thinking=xhigh costs=29.64 -->
Summary
Serialize sibling tool execution within a single stream without changing provider-level parallel tool planning.
Background
Mux currently leaves provider parallel tool-call behavior enabled, but sibling tool execute() handlers can still overlap inside a single stream. That creates race windows for shared mutable state even when the provider behavior is intentional and useful.
Implementation
Add a stream-local sequential execution wrapper that clones each tool before overriding execute(), then guards the full execute chain with an AsyncMutex. Apply that wrapper in StreamManager after tool decoration is complete so the AI SDK still sees the same tool surface while Mux runs sibling executes one-at-a-time.
Validation
make testmake static-checkbun test src/common/utils/ai/providerOptions.test.tsbun test src/node/services/tools/withSequentialExecution.test.tsbun test src/node/services/streamManager.test.tsmake typecheckmake lintRisks
This changes per-stream tool scheduling in the execution layer, so regressions would most likely affect multi-tool conversations that depend on overlapping side effects. The scope is limited to a single returned tool map per stream so unrelated workspaces and separate streams can still run concurrently.
📋 Implementation Plan
Plan: Serialize multi-tool execution top-to-bottom without disabling provider parallelism
Target invariant
Providers and SDKs may still advertise or request parallel tool calls. Inside Mux, once sibling tool
execute()handlers are invoked for a single stream, they should run one at a time in call order so shared state never races.Evidence from the repo
src/common/utils/ai/providerOptions.tsintentionally sets OpenAIparallelToolCalls: true; that is the provider-level behavior the user wants to preserve.src/node/services/streamManager.tsfinalizesfinalToolsinbuildStreamRequestConfig()and then passesrequest.toolsstraight intostreamText()insidecreateStreamResult(), so the execution policy has to be enforced on the tool map before it reaches the SDK.src/node/services/streamManager.tsalready consumesfullStreamsequentially inprocessStreamWithCleanup(). That loop is downstream of tool execution, so it should remain a consumer rather than becoming the scheduler.toolCallId/abortSignal(for example insrc/node/services/tools/task.ts,ask_user_question.ts, andbash.ts), which gives us a clean future fallback if we ever need stricter ordering than wrapper-invocation order.src/node/services/backgroundProcessManager.tsalready uses anAsyncMutex(outputLock) to prevent race conditions triggered by parallel tool calls, which is good prior art for fixing the problem at the runtime boundary.src/node/services/mock/mockAiStreamAdapter.tsemitstool-start/tool-endsequentially by construction, so it is not a reliable place to prove concurrency behavior; direct wrapper and stream-manager tests are the right validation seam.Recommended approach: stream-local sequential executor, provider options unchanged
Net LoC estimate (product code only): +45 to +75
This keeps provider/model behavior intact while making Mux execution deterministic.
1) Leave provider-level parallel tool call settings alone
src/common/utils/ai/providerOptions.tssrc/common/utils/ai/providerOptions.test.tsparallelToolCalls: truefor OpenAI.2) Wrap the final per-stream tool map with a sequential executor
src/node/services/streamManager.tssrc/node/services/tools/withSequentialExecution.ts(or a local helper instreamManager.tsif that produces the smaller diff)buildStreamRequestConfig()afterfinalToolshas been fully assembled/decorated for the request, but before the returnedrequest.toolsreachescreateStreamResult()/streamText().AsyncMutexper stream/request;cloneToolPreservingDescriptorsso cached tools are not mutated in place and wrappers do not stack across repeated requests;executeuntouched;this,args, andoptionswhen delegating to the original execute handler;3) Keep the scope at execution-time serialization only
processStreamWithCleanup()or the chat event model in this pass.tool-callparts up front, the UI may still show several pending calls. That is acceptable as long as actual tool side effects and completions do not overlap.4) Validate “top-to-bottom” at the stream boundary
streamManager.test.tsrequest helpers andsetupStreamTextSpy()seam to verify the wrapped tool map actually reachesstreamText().execute()handlers viaPromise.all, and use deferred promises to prove the second handler cannot enter before the first fully exits.start Aend Astart Bend Bstart Cend C5) Carry one explicit fallback if the SDK invocation order is surprising
AsyncMutex; that preserves the order in which wrappedexecute()calls are invoked.options.toolCallIdplus observedtool-callorder from the stream.Small implementation sketch
Validation plan
Provider behavior guard
src/common/utils/ai/providerOptions.test.tsparallelToolCalls: true.Wrapper-level ordering test
src/node/services/tools/withSequentialExecution.test.tsexecute()handlers back-to-back without awaiting the previous one.Stream integration smoke test
src/node/services/streamManager.test.tstoolsobject passed tostreamText().Promise.alland verify the later calls do not enter before earlier ones finish.Targeted runtime checks after implementation
bun test src/common/utils/ai/providerOptions.test.tsbun test src/node/services/tools/withSequentialExecution.test.tsbun test src/node/services/streamManager.test.tsmake typecheckAlternatives
Alternative A: keep the helper local inside
streamManager.tsNet LoC estimate (product code only): +25 to +45
withSequentialExecution.ts.Alternative B: toolCallId-aware ordered sequencer
Net LoC estimate (product code only): +70 to +110
options.toolCallIdplus observedtool-callorder to enforce exact stream order even if the SDK invokes execute handlers in a surprising order.Alternative C: disable provider parallel tool calls
Net LoC estimate (product code only): +3 to +8
parallelToolCallstofalse.Recommended acceptance criteria
execute()handlers do not overlap.Generated with
mux• Model:openai:gpt-5.4• Thinking:high• Cost:$4.81