Repository navigation
app-server: client-supplied thread id on thread/start and thread/fork, so a timed-out create is recoverable #41887
Description
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:
- 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/startrequests). - Give up, and leak a thread that may be running unattended.
- 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:
- Android Remote replays one create_thread operation into seven thread/start requests #36257 — Android Remote replays one
create_threadoperation into seventhread/startrequests. Same failure, different client. - Codex Desktop: atomic project-aware create_thread with runtime roots and idempotency #36250 — asks for an
idempotencyKey, but on the Desktop-ownedcreate_threadtool rather than the protocol method. Solving it at the app-server level would cover Desktop and every other client at once. - Bug Report: Unable to Create New Conversations in Codex Desktop (thread/start Timeout) #30348 —
thread/starttimeouts in Desktop.
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
TA-matrix-coder commentedon Sep 2, 2026
I have a reproducible lifecycle-safety case that seems to require server-authoritative allocation state rather than a client-side timeout.
Observed behavior (sanitized):
create_threaddispatch 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 Serverthread/start.thread/startcompleted in 168 ms with zero backend queue wait, and the conversation was registered immediately afterward.turn/interruptis 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?
preparecreates 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.cancel(operationId)returns a terminalnotAllocatedresult only when cancellation atomically wins while the operation is still prepared. That terminal result must prove that later allocation cannot occur.commit(operationId, idempotencyKey)is repeatable and converges on one logical allocation.status(operationId)eventually returns eitherallocated(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.unknown,notFound, andcommittingare non-terminal and must never authorize a retry with a new identity. After the documented tombstone expiry,notFoundremains 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.
TA-matrix-coder commentedon Sep 4, 2026
I have prepared an unsubmitted implementation candidate at commit
2d85437.It currently combines two related contracts:
notAllocated, and delayed start/fork requests cannot allocate a cancelled ID.(createdAt, threadId)ordering, including equal-timestamp pagination.Focused validation completed locally:
rustfmtand 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.