Message delivery: outbox, turn, history
How a message enters the durable outbox, reaches a Codex turn, is confirmed by Codex’s echo, and waits for you instead of being sent again when the reply is lost.
Checked against the source on 17 September 2026
On this page
The life of a message
- Savedreceipt · queueId · operationId
One transaction stores the request fingerprint and the outbox entry, a drain task starts, and the receipt goes back. The desktop composer clears on the receipt. No turn has to exist yet.
- AcceptedrunId · threadId · turnId · acceptedAt
Written to the operation ledger when turn/start answers. It proves a turn took the message. Only the echo proves that Codex recorded it.
- ConfirmeduserMessage.clientId == operationId
Codex records the user message with the operationId and sends item/completed. The ledger marks the operation sent, and then the entry leaves the queue.
- No clear replyuserMessage.clientId in history
If turn/start gets no clear reply, the desktop looks for the operationId in history. Found means sent and removed. Missing or unreadable holds the entry with auto-drain off and asks you to check the timeline first.
One message has several identities
| Identity | What it identifies |
|---|---|
| sessionId | The PlanToCode conversation that owns the outbox. |
| queueId | The visible entry in that outbox. |
| operationId | The immutable message operation, retained across delivery and recovery. |
| idempotencyKey | The remote mutation request. Its replay rules belong to the RPC layer. |
| runId / threadId / turnId | The run and the exact agent turn that accepted the operation. The operation ledger keeps them with acceptedAt, and a steer’s acceptance is also stored on the outbox entry. |
The submit service computes an immutable request fingerprint and checks the recorded operation identity before admission. The fingerprint is a SHA-256 over sessionId, queueId, userText, promptText, attachments, codexThreadId, speedMode, accessMode, the expected model and effort, rollbackBeforeSend, source, and createdAt. It deliberately excludes intent, sendMode, and expectedActiveRunId, so queueing a message and then sending it now converges on the same operation, while retargeting a steer to a different run is a conflict. The database transaction claims the pair (session_id, operation_id) and saves the outbox JSON together, and a different fingerprint under the same operation ID cannot overwrite the original claim.
Custody, acceptance, and history are separate
Each moment writes its own record. The receipt does not prove a turn started, and an accepted turn does not prove that Codex recorded the message.
| Moment | Record | What it proves |
|---|---|---|
| Submit | workspace_chat_outbox and workspace_chat_outbox_operation_identity, written in one transaction | The desktop has custody and returns the receipt. |
| Just before turn/start reaches the actor | codex_chat_operation_ledger with status sending | A send attempt started, so a turn may exist. |
| turn/start answers | The ledger result gains runId, threadId, turnId, and acceptedAt | A turn accepted the message. |
| userMessage.clientId arrives live or is found in history | Ledger status sent, then the outbox entry is removed | Codex recorded the message. Delivery is confirmed. |
The persisted entry status has two values: queued and sending. Held and failed entries can return to queued with autoDrain disabled and an error or hold reason. Sent, failed, and blocked also occur as service outcomes; they are not additional values of WorkspaceChatOutboxStatus.
| Submission control | Meaning |
|---|---|
| intent: automatic | Save custody and allow the selected drain path. |
| intent: enqueue | Append to the queue and send automatically when the session is idle. |
| intent: replace-latest | Set the replacement behavior, including rollbackBeforeSend, in the saved entry. |
| sendMode: queue | Use queued delivery. |
| sendMode: steer-running | Use the steering drain path for an eligible automatic submission. |
| sendMode: interrupt-running | Stop the active run, wait until it has settled, and then start this message as its own run. |
Validation ties the controls together: enqueue requires sendMode queue, replace-latest requires interrupt-running, steer-running requires expectedActiveRunId, and any other mode rejects that field. A session outbox holds at most 256 entries, and the phone waits 20 seconds for the receipt.
What the composer actions do
- Queueintent: enqueue
Appended to the end of the queue and never mixed into a running turn. Editing a queued message keeps its queueId and takes a new operationId.
- Steersteer-running · expectedActiveRunId
Bound to the run you watched. turn/steer adds the message to that run, so the run’s model, effort, speed, and access apply. If the run ends before using it, the message comes back with auto-drain off.
- Interruptinterrupt-running
The model is checked before the run is stopped. The message starts once the old run has left the run and turn registries, checked every 25 ms for up to 10 s. A newer turn that appears during the wait keeps it queued.
- Replace latestreplace-latest · rollbackBeforeSend
Stops a running turn like an interrupt, then rolls back the latest turn before this message is sent.
A steer reply that proves the guidance was not admitted returns the operation to automatic queue custody, and transport uncertainty goes through the same reconciliation as any other send. If the old run is still settling after 10 seconds, an interrupt’s replacement also stays queued. Editing a queued message rewrites the text and keeps its place in the queue unless you send it now or reorder it. Removing one deletes the waiting entry and leaves the active run untouched.
The admission boundary controls safe recovery
- Hand-offcommand_permit.send
The reserved command passes to the actor only after the ledger says sending and the gate finds the actor open. The send path itself stops waiting 31 s after the hand-off.
- Failed beforePreAdmission
A reservation timeout, a failed sending write, a retiring actor, or a closed actor. The entry returns to the queue with auto-drain off and the error.
- Uncertain afterAdmissionUncertain
An error reply, a failed write, a closed channel, a deadline, or a failed acceptance record. The turn may exist, so the desktop checks history. Found means sent. Missing or unreadable holds the entry.
- Late resultsexpected_sending_entry
Recovery changes an entry only while it still matches the snapshot taken while it was sending, so a late result never overwrites a newer queue decision.
Each session has two drain lanes, normal and steer, each with its own guard. A wake-up that arrives while a lane is busy only sets a follow-up flag, which the finishing pass returns, so nothing that lands at the settlement boundary is lost. The steer lane admits only the queued head, only for an exact active-run steer, and never starts a new turn. A steer whose run already ended waits at the head while a different run is working, and then starts as a new run.
Delivery cleanup uses the queue and operation pair. The registry keeps runtime clients leased for active work, including descendant thread activity, and routes approval resolution to the actor that owns the full request identity.
| Failure | Required interpretation |
|---|---|
| Saving custody fails | Do not claim a durable receipt. No automatic admission should precede the save. |
| Acceptance exists, history is delayed | Keep acceptance evidence. Do not infer that the message is absent from the execution. |
Uncertain delivery
After an uncertain send, or after a restart with an entry still marked sending, the desktop reads the operation’s frozen history route. Delivery is confirmed only by a userMessage.clientId equal to the outbox operation ID, on the storage profile’s indexed thread or on the exact path with its ancestor manifest. Sibling threads are never scanned. A confirmed operation is written to the sent ledger before outbox custody is removed. Missing evidence, a read failure, or a route mismatch leaves the entry visible with automatic drain off, and there is no automatic retry or backoff.
Stop and send now is the only automatic re-send. The figure shows when the desktop allows it.
- Requestedhandoff.phase: requested
The model is checked first. Then the entry records run R, its thread, and its turn, with auto-drain off.
- Detachturn/detachPendingInputForReplay
Asks Codex to take the steer out of the run’s pending input by its operationId. Only detachedForReplay or alreadyDetachedForReplay lets the desktop go on.
- Revokecodex_chat_operation_ledger
Clears only the acceptance for that exact run, thread, and turn. If the ledger already says sent, nothing is replayed.
- Dedicatedhandoff.phase: dedicated
The entry is queued again with auto-drain on and the same operationId, and it starts its own turn once run R has settled.
What Stop does
Stop asks the Codex app-server to cancel the current turn. Commands that already ran and files that were already edited stay as they are, so read the timeline and the Git diff once the run has settled. Removing a queued message, stopping a run, and reverting a file are three different actions, and only the last one touches your working tree.