Repository navigation
app-server: dynamic tool request is broadcast to all thread subscribers and first response wins #35894
Description
What version of Codex are you using?
codex-cli 0.145.0- Source verified against tag
rust-v0.145.0, commit25af12f7e61572b0bc18ddb1008be543b91519b0 - App Server experimental API with
dynamicTools
What platform is your computer?
macOS, Apple Silicon.
What issue are you seeing?
When two app-server connections subscribe to the same thread, a dynamic tool request is sent to every subscriber with the same JSON-RPC request ID. The pending callback is keyed only by request ID, and response processing does not preserve or validate the responding connection ID. The first response or error therefore wins.
If one subscriber owns the dynamic-tool handler and another subscriber does not, the non-owner can return:
No handler registered for tool: <namespace>.<tool>
before the authoritative subscriber returns success. The model/caller sees a failure even though the tool executed successfully.
I observed this with a mutating sessions_create dynamic tool:
- the handler-owning connection executed successfully;
- the Desktop subscriber returned
No handler registeredfor the same call; - the caller saw the error;
- the created background session existed in durable state;
- retrying after the false failure created duplicate work.
This is not only a handler-registration problem. It is a request-routing and response-arbitration race between valid subscribers.
What steps can reproduce the bug?
- Initialize app-server connection A with the experimental API.
- Start a thread from A with a namespaced dynamic tool in
dynamicTools. - Initialize connection B without a handler for that dynamic tool.
- Resume the same thread from B so both connections are subscribers.
- Start a turn that invokes the dynamic tool.
- Observe that both A and B receive
item/tool/callwith the same request ID. - Have B immediately return a missing-handler error.
- Have A execute the tool and return a successful
DynamicToolCallResponse. - Observe that B's error resolves/removes the shared pending callback; A's later valid response is ignored, even though A may already have committed the side effect.
A mutating tool makes the impact especially clear: the operation exists despite the caller-visible failure, and a normal retry can duplicate it.
Source-level findings
In rust-v0.145.0:
- Thread start/resume adds each requesting connection as a subscriber.
- The thread listener gathers all subscribed connection IDs and constructs one
ThreadScopedOutgoingMessageSender. - Dynamic tool calls use the same generic thread-scoped request path, which sends one server request and request ID to every subscribed connection.
- Pending callbacks are stored by
RequestIdonly. MessageProcessor::process_responseandprocess_errordiscard the respondingConnectionIdbefore resolving the callback.DynamicToolCallParamsand initialization capabilities contain no handler-owner, connection-target, or dispatch-capability field.
Relevant areas:
codex-rs/app-server/src/request_processors/thread_lifecycle.rscodex-rs/app-server/src/outgoing_message.rscodex-rs/app-server/src/message_processor.rscodex-rs/app-server-protocol/src/protocol/v1.rs
What is the expected behavior?
A dynamic tool request should have exactly one authoritative responder.
One possible contract:
- A connection explicitly advertises dynamic-tool dispatch capability during initialization.
- For a tool-bearing thread, app-server selects exactly one subscribed capable connection. Zero or multiple capable subscribers fail closed.
item/tool/callis sent only to that connection; ordinary thread notifications can remain broadcast.- The pending request stores the expected connection ID.
- Responses/errors from non-target connections are ignored and cannot resolve the callback.
- Pending dynamic-tool requests are replayed after reconnect only when exactly one capable subscriber exists.
At minimum, a response from a connection that was not targeted for the request must not win the callback race.
Impact
- Successful mutations reported as failures.
- Duplicate sessions, approvals, messages, or other side effects after ordinary retries.
- Caller and durable state disagree.
- Handler-owning clients cannot guarantee their valid response is authoritative.
- Thread resume/reconnect makes the race more likely because dynamic tool definitions persist with the thread while executable handlers remain client-specific.
Application-side idempotency can contain duplicate side effects, but it cannot prevent app-server from delivering the request to a non-handler subscriber or accepting that subscriber's false error.
Related issues
These appear symptomatically related but do not identify this specific multi-subscriber routing cause:
- send_message_to_thread reports handler error even when delegation is delivered #29886 —
send_message_to_threadreports handler error even when delivery succeeds - Codex Desktop thread tools intermittently lose handlers and may create systemError worker threads after create_thread failure #31119 — thread creation reports missing handler while child threads may still be created
- Desktop thread tools intermittently lose handlers (
No handler registered) within an active session #28080 — Desktop thread tools intermittently lose handlers - app-server: allow dynamicTools to be updated after thread/start #24808 — dynamic tools cannot be updated after
thread/start
Additional information
I have a local source-reviewed draft that adds a dispatcher capability, targets one capable subscriber, carries connection identity through response/error processing, rejects non-owner responses, and adds focused routing tests. It has not been submitted or compiled yet. I can prepare it for review if this direction matches the intended app-server ownership model.
Activity
dglowacki commentedon Aug 6, 2026
@etraut-openai @aibrahim-oai
can we has fix pls? is making agents argue.
i have PR if you'd like, but cause & fix is clear from description above. muchas gracias.
junwei529 commentedon Aug 30, 2026
I prepared and validated a public patch implementing the single-responder direction described in this issue.
What the patch covers:
openai/dynamic-toolsinitialization extensiondynamicToolsitem/tool/callonly when exactly one capable connection is subscribed to the threadValidation:
cargo fmt --all -- --checkcargo test -p codex-app-server targeted_ --lib— 2 passedcargo test -p codex-app-server --test all dynamic_tool_request_targets_owner_and_ignores_observer_error— 1 passedgit diff --checkAn earlier broad
just test -p codex-app-serverrun produced 1381 passed, 25 failed, and 2 skipped. The one patch-related failure was fixed and the focused tests above pass. The remaining 24 failures were local environment setup failures caused by missing helper binaries (codex,codex-code-mode-host, andtest_stdio_server); I did not obtain a fully clean post-fix broad run in that environment.Scope note: this patch is specifically for the multi-subscriber routing and response-arbitration bug in this issue. It does not change
list_threads,read_thread, or history APIs. It resolves a target disconnect rather than replaying the request across reconnect; reconnect replay remains outside this patch.The repository currently rejects external PR creation for this account, so the exact commit is available for cherry-pick or internal adoption. I can update the public branch if maintainers request changes.
Shulyaka commentedon Sep 8, 2026
I think that the dynamic tool call should only be forwarded to the client that initiated the current turn