Skip to content

app-server: client-supplied thread id on thread/start and thread/fork, so a timed-out create is recoverable #41887

Open

Description

@j-pollack

What variant of Codex are you using?

CLI / app-server (codex-cli 0.151.0, macOS), driven programmatically over the app-server JSON-RPC API.

What feature would you like to see?

Please add a client-supplied identity field to thread/start and thread/fork — a clientThreadId (or idempotencyKey), mirroring the clientUserMessageId that TurnStartParams already accepts.

Why: a timed-out thread/start is unrecoverable for the client.

The app-server has no transport-level cancellation — nothing addresses "request N" — which is a reasonable design, because every cancellable thing is a named object with its own stop verb (turn/interrupt, command/exec/terminate, process/kill). But that name arrives in the response the client is still waiting for. When a request outlives its deadline, the client is left with no handle at all.

For turns this is survivable: thread/turns/list reports the thread's inProgress turn, so a client can recover the id and interrupt it. I verified this against a real app-server — recovering the id of a turn whose turn/start response I deliberately ignored, then interrupting by that recovered id, and confirming the id matched what turn/start eventually returned.

Thread creation has no equivalent. If thread/start times out, the thread may or may not exist, it has no id the client knows, and there is no way to ask "did the thread I asked for get created?" The client's only options are:

  1. Retry, and risk creating a second thread (this is what Android Remote replays one create_thread operation into seven thread/start requests #36257 reports — one logical create replayed into seven thread/start requests).
  2. Give up, and leak a thread that may be running unattended.
  3. Watch the abandoned request id and reconcile if the response ever arrives — which only helps when it does arrive.

A caller-supplied id collapses all three. The client already knows the identity it asked for, so a retry is safe, a duplicate is detectable server-side, and "did this land?" becomes answerable.

Why this level rather than a cancel verb. A generic $/cancelRequest would let the server drop the in-flight future, but dropping a future does not un-create a thread. Identity is the thing that actually fixes the failure, and it's a smaller change: one optional field on two *Params structs, following the client_user_message_id precedent in app-server-protocol/src/protocol/v2/turn.rs.

Additional information

Related, none of which cover thread/start itself:

Reported from an independent MCP client that orchestrates concurrent app-server workers, so this is not Desktop-specific. Happy to send a PR if the shape above is one you'd take — the clientUserMessageId precedent makes it fairly mechanical.

Activity

  1. added
    enhancementNew feature or request
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    on Aug 31, 2026
  2. TA-matrix-coder commented on Sep 2, 2026

    @TA-matrix-coder

    I have a reproducible lifecycle-safety case that seems to require server-authoritative allocation state rather than a client-side timeout.

    Observed behavior (sanitized):

    • A Desktop create_thread dispatch remained pending for 1,594.539 seconds in the outer Desktop/elicitation path, before the create handler actually began. This delay is not attributed to App Server thread/start.
    • Once the create handler was invoked, its App Server thread/start completed in 168 ms with zero backend queue wait, and the conversation was registered immediately afterward.
    • During the outer delay, a bounded observer window expired without discovering the task. The caller therefore could not distinguish “not allocated” from “allocation may still occur later.”
    • Retrying in that state would be unsafe: the original operation can still allocate after the caller has timed out or rolled back its surrounding state.

    turn/interrupt is too late for this case because it operates on a turn after a thread exists. A client-side deadline that merely stops waiting also does not establish non-allocation.

    Could the App Server expose a server-authoritative prepare/commit lifecycle (or an equivalent cancellable allocation operation) with these semantics?

    1. prepare creates no thread, rollout, subscription, notification, or Trust mutation. It returns a stable operation identity and an idempotency/client-creation identity bound to both a canonical immutable request fingerprint and the authorized caller scope. Reuse with a changed request or caller scope must return a conflict rather than allocate.
    2. cancel(operationId) returns a terminal notAllocated result only when cancellation atomically wins while the operation is still prepared. That terminal result must prove that later allocation cannot occur.
    3. commit(operationId, idempotencyKey) is repeatable and converges on one logical allocation.
    4. status(operationId) eventually returns either allocated(threadId, createdAt) or a proven terminal no-allocation result. Operation state and terminal tombstones must be retained for an explicit bounded period sufficient for recovery and reconciliation.
    5. unknown, notFound, and committing are non-terminal and must never authorize a retry with a new identity. After the documented tombstone expiry, notFound remains outcome-unknown; expiry must not be interpreted as proof of non-allocation.

    This should not be described as one atomic cross-store transaction. Thread metadata/state in SQLite and rollout JSONL are separate durability domains. The robust implementation is a recoverable, idempotent saga with persisted operation state, deterministic reconciliation, and a stable allocation identity across retries/restarts.

    The key safety property is simple: after a deadline, the caller needs server truth about whether allocation is impossible, pending, or already committed. Without that, a timed-out create remains outcome-unknown and cannot be retried safely.

  3. TA-matrix-coder commented on Sep 4, 2026

    @TA-matrix-coder

    I have prepared an unsubmitted implementation candidate at commit 2d85437.

    It currently combines two related contracts:

    Focused validation completed locally: rustfmt and four lifecycle tests pass. Broader workspace validation has not been rerun, and no PR is open.

    Would a maintainer please confirm whether this approach aligns with the intended direction and, if so, explicitly invite a PR under the repository's contribution policy? I can submit it as one combined PR or split the lifecycle and listing changes if preferred.

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

    CLIIssues related to the Codex CLIapp-serverIssues involving app server protocol or interfacesenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions