Skip to content

app-server: dynamic tool request is broadcast to all thread subscribers and first response wins #35894

Open

Description

@dglowacki

What version of Codex are you using?

  • codex-cli 0.145.0
  • Source verified against tag rust-v0.145.0, commit 25af12f7e61572b0bc18ddb1008be543b91519b0
  • 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 registered for 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?

  1. Initialize app-server connection A with the experimental API.
  2. Start a thread from A with a namespaced dynamic tool in dynamicTools.
  3. Initialize connection B without a handler for that dynamic tool.
  4. Resume the same thread from B so both connections are subscribers.
  5. Start a turn that invokes the dynamic tool.
  6. Observe that both A and B receive item/tool/call with the same request ID.
  7. Have B immediately return a missing-handler error.
  8. Have A execute the tool and return a successful DynamicToolCallResponse.
  9. 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 RequestId only.
  • MessageProcessor::process_response and process_error discard the responding ConnectionId before resolving the callback.
  • DynamicToolCallParams and initialization capabilities contain no handler-owner, connection-target, or dispatch-capability field.

Relevant areas:

  • codex-rs/app-server/src/request_processors/thread_lifecycle.rs
  • codex-rs/app-server/src/outgoing_message.rs
  • codex-rs/app-server/src/message_processor.rs
  • codex-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:

  1. A connection explicitly advertises dynamic-tool dispatch capability during initialization.
  2. For a tool-bearing thread, app-server selects exactly one subscribed capable connection. Zero or multiple capable subscribers fail closed.
  3. item/tool/call is sent only to that connection; ordinary thread notifications can remain broadcast.
  4. The pending request stores the expected connection ID.
  5. Responses/errors from non-target connections are ignored and cannot resolve the callback.
  6. 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:

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

  1. added
    bugSomething isn't working
    app-serverIssues involving app server protocol or interfaces
    on Jul 29, 2026
  2. dglowacki commented on Aug 6, 2026

    @dglowacki
    Author

    @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.

  3. junwei529 commented on Aug 30, 2026

    @junwei529

    I prepared and validated a public patch implementing the single-responder direction described in this issue.

    What the patch covers:

    • advertises dynamic-tool dispatch capability per app-server connection through the experimental openai/dynamic-tools initialization extension
    • preserves compatibility by marking a connection capable after it starts a thread with non-empty legacy dynamicTools
    • dispatches item/tool/call only when exactly one capable connection is subscribed to the thread
    • records the expected responder and ignores responses or errors from non-target connections
    • monitors write completion and cancels the pending callback if delivery fails or the target disconnects, so the waiter does not hang
    • keeps ordinary thread notifications broadcast

    Validation:

    • cargo fmt --all -- --check
    • cargo test -p codex-app-server targeted_ --lib — 2 passed
    • cargo test -p codex-app-server --test all dynamic_tool_request_targets_owner_and_ignores_observer_error — 1 passed
    • git diff --check
    • native Codex review after fixing a disconnect/delivery race — no remaining findings

    An earlier broad just test -p codex-app-server run 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, and test_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.

  4. Shulyaka commented on Sep 8, 2026

    @Shulyaka

    I think that the dynamic tool call should only be forwarded to the client that initiated the current turn

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app-serverIssues involving app server protocol or interfacesbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions