Tell the model it can go parallel — a lock keeps the floor
What the model sees, what actually runs, and what history records are three different orders. One flag lets a response emit many calls. One read-write lock decides who can stack in.
- build_prompt writes the parallel flag as trueturn.rs L1321
- AND it with the Lite flag when building the requestclient.rs L952
- The router looks up the registry; missing means falserouter.rs L137
- Hidden is serial even if it claims parallelregistry.rs L472
- Spawn first, wait ready, then take the lockparallel.rs L144
- Read if parallel, otherwise writeparallel.rs L152
- Results book in FuturesOrdered insert orderturn.rs L2135
You ask the model to read src/main.rs and src/lib.rs at once, then apply a patch. On screen the two reads almost progress together. The patch beat pauses.
If the runtime treats “can go parallel” as these calls overlapping, two apply_patch hits would edit the shared diff ledger together and scramble it. If the calls go head-to-tail, even two file reads queue and you burn another wall-clock round. The request-side flag only answers whether the model is willing to emit several boxes at once. It cannot say who may overlap when those boxes land.
The flag sent to the model lives on Prompt. The field defaults to false. The main sample path walks build_prompt and writes it true. When the Responses body is built, it is ANDed with whether the model is Responses Lite. On Lite the flag turns off. The two compact pack paths also hard-code true, so they match the main sample request’s shape. Websocket reuse compares field by field, and this flag is one of them.
Source:codex-rs/core/src/session/turn.rs lines 1312–1328 · codex-rs/core/src/client.rs lines 946–953
The exec side has another table. ToolExecutor’s supports_parallel_tool_calls defaults to false. Miss the override and you take the write lock. exec_command, view_image, and tool_search override to true. apply_patch does not. The router looks up the registry first; missing means false. Hidden stays serial even if the handler itself returns true. MCP still needs a server switch or a read-only hint; the default is serial.
Source:codex-rs/tools/src/tool_executor.rs lines 122–124 · codex-rs/core/src/tools/registry.rs lines 470–473
The model only sees parallel_tool_calls as true, or false on Lite. It cannot see who can actually overlap. The tool list does not drop an item just because that tool will take the write lock.
The promise to the model can split from host scheduling. That travels across languages. The request side only answers whether it is willing to emit several calls at once. The exec side only answers whether this one may overlap. Lite tests turn the flag off, so the authors accept losing that hint on some model paths. The gate stays. If the model still emits two calls in one response, both tasks still spawn and still take the lock from the local table.
Classify right and someone still has to watch the door. Two readers can coexist; one writer must be exclusive. Grab the lock first and then wait for an MCP server to connect, and one cold start will stall a neighboring exec_command that is already ready.
Then fairness. If a later reader can climb over a waiting writer, a patch may never get the lock. Swap in the stdlib RwLock and priority depends on the OS — writers can starve.
One sample shares a single tokio::sync::RwLock<()>. The guarded value is the unit type — no business data, just a gate. Tasks spawn first, optionally wait ready, then take the lock. Parallel takes read; otherwise write. Readiness waits outside the lock. An MCP server that has not connected yet will not sit on the write lock and stall a neighboring shell.
Source:codex-rs/core/src/tools/parallel.rs lines 144–156
This lock’s priority is fair, also called write-preferring. Until a queued write is acquired and released, later read locks are not issued. A view_image is still running, apply_patch is already outside, and a new exec_command could overlap with view_image — yet it must line up behind the patch. Fairness buys “writers do not starve.” The cost is later readers being separated by the writer.
Classify grain is the tool instance; it cannot see this call’s arguments. exec_command takes the read lock whether it runs ls or rm. apply_patch takes the write lock no matter how large the patch is. Two unrelated serial tools also block each other. Search the business code: there is no capacity cap. Ten shells can walk in together. Capacity is left to the process, the sandbox, and the OS.
The question is whether anyone is exclusive right now. A restaurant can have many people at the menu; only one enters the kitchen. Fairness lives in the lock implementation. Business code only asks parallel or exclusive. Swap in a lock that lets new readers cut in, and writers can starve. Waiting for ready outside the lock is the same judgment: if you are not ready, do not occupy the door.
Two exec_commands overlap and the later emit may finish first. Book history by finish order and the next round’s results will not match the calls the model emitted. Building a future on each OutputItemDone in the stream governs when work starts. This layer governs who may overlap after start, and in what order results book.
The sample loop pushes tool_future into FuturesOrdered in arrival order. drain pops in insert order, then writes the session. The read lock lets two shells overlap; booking still follows emit order. First emitted, first booked — even if it actually finished later.
Source:codex-rs/core/src/session/turn.rs lines 2130–2140
Observation order and execution overlap are split in the structure. Parallel only changes wall-clock, not the ledger. When you build an Agent, write those two queues separately. Tell the model it can go parallel, consult the local table when tools run, and still accept results in the original call-list order.
DeepSeek Harness: classify by args, exclusive as a barrier
DSH lets each tool provide isConcurrencySafe(args). Only a literal true joins the parallel set. Missing declaration, bad args, or a throwing classifier: exclusive. bash has no classifier, so the whole tool is exclusive. The scheduler waits for the full message, packs consecutive parallel calls into a group, and treats each exclusive call as its own barrier group. Inside a group it rolls a pool, default cap 10.
Codex can skip that grouping because it compresses the decision into a per-tool boolean, then simulates a barrier with one lock. DSH can keep even read-only bash serial and give up some concurrency. Codex can overlap two ls — and two rm as well.
Claude Code: batch by args; only read-only bash goes parallel
Claude’s isConcurrencySafe defaults to false. BashTool hands parallel to isReadOnly: the command returns true only if it passes a read-only constraint. ls can join a parallel batch; commands with write side effects join a serial batch. Consecutive safe calls pack into one concurrent batch; each unsafe call is its own batch, and even inside a batch they go one after another. The cap comes from an env var; parse failure yields 10.
Codex’s exec_command skips command parsing; even write commands take the read lock. All three hide scheduling metadata in the host; they close the door in different places. Codex closes on the default and Hidden, and is loosest on shell. DSH closes on the classifier, so bash is fully serial. Claude sits in the middle.
Can a late reader cut in?
view_image is still running; apply_patch is already queued outside. The model emits another exec_command. In the demo switch to Late reader, step through, and match the three questions below.
exec_command overlap with the still-running view_image?apply_patch tile to a read badge, will the gate still hold it alone?