[Windows Desktop] Sending is permanently blocked once a turn is stuck in `inProgress` — survives full app restart (repro on 26.915.3509.0)
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
Environment
- OS: Windows 10 Pro 22H2 (build 19045.6466)
- Codex Desktop:
26.915.3509.0(app-serverclient_version=26.915.31029) - Previously reproduced on:
26.908.4834.0,26.908.9136.0 - Not a fresh install; long-lived heavy usage (approx. 250 conversations, 2,300 turns on this machine)
Symptom
The message send button becomes disabled (greyed out) and never recovers. There is no error
toast, no retry, no visible failure — the UI silently swallows the send. Typing a new message does
not re-enable it.
It is reproduced by reopening an existing conversation and sending. Concretely:
- A turn enters
inProgressand never terminates (the sampling stream dies silently — see below). - From that point on, every send attempt in that conversation is silently dropped, permanently,
including after full app restart.
Hard evidence
1) Client never dispatches the request. Log window after a cold start on 26.915.3509.0:
thread/resume 86
thread/start 0
turn/start 0 <-- key
op.dispatch.turn_input 0 <-- key
turn/completed 0
86 thread/resume, zero turn/start. The message never leaves the renderer — this is a
client-side gating failure, not a network or backend issue.
2) The gating predicate. Inspecting the Electron client bundle (app\resources\app.asar),
canSend() delegates to a predicate with this shape:
function gate({ conversationNeedsResume, hasConversation, latestTurn, message, streamRole }) {
return message.pausedReason // ①
|| !hasConversation // ②
|| streamRole?.role === 'follower' // ③
|| latestTurn?.status === 'inProgress' // ④
? true // true = BLOCK the send
: conversationNeedsResume || latestTurn == null
? false // ⑤ bypass (on reconnect)
: latestTurn.status !== 'completed' // ⑥
|| !latestTurn.items.some(x => x.type === 'agentMessage' // ⑦
|| (x.type === 'contextCompaction' && x.source === 'manual'));
}
The ordering is the bug. Conditions ①–④ are hard blocks evaluated before the ⑤
conversationNeedsResume bypass. So when a turn is stuck at inProgress, ④ wins, and nothing —
including a full reconnect — can ever unblock that conversation.
3) The poisoning turn. Persistent state on disk (thread_history_1.sqlite → thread_turns):
completed 2140
interrupted 119
failed 36
inProgress 2 <-- turns that will never complete; the 2 conversations owning them are dead
4) Upstream cause of the stuck turn. Fatal retry loop against the API, then silence:
WARN codex_core::responses_retry
stream disconnected - retrying sampling request (N/5)...
sampling_error = stream disconnected before completion:
IO error: peer closed connection without sending TLS close_notify: unexpected EOF
After retries are exhausted the turn never emits a terminal event — it stays inProgress
forever. Durability note: this still occurs on 26.915.3509.0 (unlike issues caused by local config).
5) Verified NOT fixed in 26.915.3509.0. Bit-level comparison of app.asar between
26.908.9136.0 (310 MB) and 26.915.3509.0 (345 MB): all five identifiers around the gate
(conversationNeedsResume, pausedReason, markAllConversationsNeedResumeAfterReconnect,
streamRole, Interrupted before the steer was accepted) appear the same number of times
(4 / 25 / 12 / 69 / 6), and the predicate control flow is byte-identical modulo minifier
renaming (aSe → mCe). The 35 MB of new code did not touch this path.
Why this compounds badly
sampling stream dies (peer closes TLS without close_notify)
-> retries exhausted, no terminal event
-> turn stays inProgress persistently (thread_turns.status)
-> gate condition ④ blocks every future send in that conversation, forever
-> user force-quits the app to recover
-> the in-flight turn is hard-interrupted, leaving only a userMessage in the rollout
-> conversation becomes even harder to recover
We observed this loop produce ~25 conversations containing only a userMessage and no assistant
reply — i.e. the product's own failure mode trains users into a behavior that makes it worse.
Reproduction
- Use the desktop app normally until a turn's sampling stream is killed mid-flight
(peer closed connectionin logs). - Observe the turn stops producing output and never completes.
- Reopen that conversation and try to send. The send button is greyed out; nothing is dispatched
(turn/startcount stays at 0 in the logs).
Suggested fixes
- Timeout / staleness escape hatch. Do not treat
inProgressas an indefinite hard block.
If a turn has produced no events for N minutes, allow sending (optionally resuming or aborting
the stale turn). - Reorder the predicate. Evaluate the
conversationNeedsResumebypass (⑤) before the
inProgresscheck (④), or expose an explicit user override ("force send" / "abort turn and send"). - Surface the failure. A silently disabled send button with no explanation is the worst part
of this bug — at minimum show why sending is blocked.
Relationship to #37401 — please note, this looks like a DIFFERENT failure mode
#37401 describes sends timing out after opening the Plugins page, and states that a full app
restart restores sending. What we report here differs on both points:
| #37401 | This report | |
|---|---|---|
| Trigger | Opening the Plugins page | A turn's sampling stream dying mid-flight |
| Does a full restart help? | Yes | No — the gate never clears |
| Local mitigation | — | [features] plugins = false already applied; does not help |
We have already applied the workaround discussed in #37401's thread — [features] plugins = false
in config.toml, plus physically moving .codex/.tmp/plugins aside (180 plugin directories).
The failure still reproduces, which indicates the path reported here is independent of
plugin-discovery / app-server queue saturation. It is a purely client-side send-gating issue.
(Unrelated observation, present on both old and new builds, so not the cause:
codex_mcp::rmcp_client server_name="node_repl" MCP server startup failed,
error 系统找不到指定的路径。(os error 3) on 26.915.3509.0 — was os error 267 on older builds.)
Happy to provide filtered log excerpts (error / retry / request-timeline lines only — any
conversation content will be stripped before sharing) or additional instrumentation you need.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing canSend() and the conversationNeedsResume, latestTurn, and streamRole checks in app\resources\app.asar, then compare them with persistent inProgress entries in thread_history_1.sqlite. Reproduce the stale-turn case and verify that a recovered conversation can dispatch turn/start and that the failure is no longer silently permanent.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- electron, rust
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100