Repository navigation
CLI worker silent death after parallel-command burst (announce → mkdir → probe → exit) on Linux/WSL2 — Turn completed event never fires #21937
Description
Summary
The Codex CLI app-server worker process exits with code 0 mid-turn on Linux/WSL2 immediately after a specific tool-call sequence pattern, leaving the broker stuck and the wrapper layer hanging on state.completion. No error event is propagated. Reproducible 4 times across 24 hours on codex-cli 0.130.0 and 0.129.0.
Environment
- Codex CLI: 0.129.0 → 0.130.0 (both reproduce)
- Companion / wrapper:
@openai/codexplugin runtime viacodex-companion.mjs - Host: Windows 11 + WSL2 (Ubuntu 22.04, kernel 6.6.114.1-microsoft-standard-WSL2)
- Model: gpt-5.5 with xhigh reasoning effort (default for codex)
- Sandbox:
danger-full-access - Trigger: dispatched as background job from a Claude Code orchestrator
Reproducer (high reliability — 4/4 in 24h)
The worker dies after this exact sequence in a single turn:
- Multiple
psql/sedexploration commands (parallel batch, all complete with exit 0) - Model emits an "announcement" assistant message ("I'm going to write
<file>...") mkdir -p <output_dir>— succeeds- ONE more
psqlor shell probe (e.g.,psql -Atc "SELECT exchange, contract_type FROM symbols") — succeeds - Worker silently exits. No
Turn completedevent, no error, no SIGTERM in logs. Companionstate.completionPromise hangs indefinitely.
Observed across 4 distinct Codex jobs today:
task-moxeya7t-248j70(v4 analysis executor, 21:20 UTC)task-moxpj6m0-zr9uvr(PR creation task, 02:13 UTC next day)task-moxq4ixm-aln8jh(REST probe investigation, 02:29 UTC)bt0dkgkg6/b77sx7ne9(analytical re-run, 16:00 UTC)
What I see in logs
/home/user/.claude/plugins/data/codex-openai-codex/state/<workspace>/jobs/<task-id>.log ends with:
[ts] Command completed: /bin/bash -lc 'psql ...' (exit 0)
and zero lines after that. No turn/completed notification, no client.exit, no error event.
broker.log is empty (0 bytes) on the workspace's /tmp/cxc-*/ directory.
Expected behavior
Either:
- Turn completes normally with subsequent file-write tool calls
- OR worker emits an error event before exiting
Neither happens — exit is silent.
Workaround applied locally (wrapper-layer)
Patched lib/codex.mjs captureTurn() to use Promise.race([state.completion, exitRace]) where exitRace watches client.exitPromise and throws if turn isn't yet complete. This converts indefinite hang into clean exception, but doesn't fix the underlying app-server crash.
Related (non-duplicate)
- Detached task-worker exits without writing failed status on broker socket disconnect #21813 — Detached task-worker exits without writing failed status on broker socket disconnect (related: same silent-death symptom, but that issue's trigger is a broker socket reset; here the broker is alive and broker.log is 0 bytes — no socket event is involved)
- Turn completes prematurely when unified_exec background processes are still running #14731 — Turn completes prematurely when unified_exec background processes are still running (different failure mode)
- Any Single Engine Crash Leaves Orphaned Threads That Cause a Persistent Crash/Resume Loop #16271 — Any Single Engine Crash Leaves Orphaned Threads (Windows app, different surface)
Reproduction setup (if upstream wants to bisect)
I can provide:
- All 4 task log files (~10-90 lines each, JSON-ish single-line structure)
- Companion broker.log captures
- Process tree at moment of failure (broker alive, app-server dead)
- The wrapper-layer Promise.race patch as before/after diff
Request: enable structured logging in app-server's exit path, OR add a "worker-died-mid-turn" event to surface this state instead of silent exit.
Activity
henkterharmsel commentedon May 12, 2026
Confirming this reproduces on plain Linux (no WSL). Wrapper is the OpenClaw gateway spawning
codex app-server --listen stdio://as a child via@openai/codexplugin runtime.Same outcome as the OP — worker stops emitting mid-turn, no
task_completeevent, no error, no SIGTERM. The rollout JSONL file just ends. From the wrapper side, the completion promise hangs indefinitely; only an external abort frees the lane.Last events in the rollout before silence (from
$CODEX_HOME/sessions/2026/05/11/rollout-*.jsonl, redacted):So the OP's symptom isn't WSL-specific; it reproduces on a vanilla Linux host too. Repro rate is high enough that we accumulate ~6 hung session records per active day across telegram-channel-driven dispatch, all with the same shape:
announcement → parallel batch → reasoning → silence. Also previously seen oncodex-cli 0.128.0in the same wrapper, so not introduced by 0.130.I scanned the 60 commits in
rust-v0.130.0..rust-v0.131.0-alpha.6and didn't spot one that looks like it would fix this (closest near-misses: #21825 transport timeout bump, #21778 turn-notification routing by ID — neither addresses the silent worker exit specifically).Happy to share full rollout fragments or wrapper-side stderr/diagnostic logs if that helps narrow it down.
gupta-ash commentedon May 19, 2026
Adding two independent reproductions of this on Windows 11 + Codex CLI 0.129.0 in the past 48 hours. Same downstream symptom (no
Turn completednotification; companion'sstate.completionPromise hangs indefinitely; outer Bash timeout is the only bound).Reproduction 1 — Windows 11 + Obsidian vault content review (2026-05-17)
claude_codex_plugin-ccplugin v1.0.4 baseline)D:/Obsidian/KidsObs(non-git directory)read-only(pertaskmode default)Get-Content+Select-Stringcommands against vault chapter files for evidence-tag accuracyCommand failed: pwsh ... Get-Content (exit -1)within 44 ms${TMPDIR}/codex-companion/${slug}-${hash}/jobs/task-mpaic5td-xu0oqz.log, last entryCommand failed ... (exit -1), zero further activitystatus: "running",pid: 38644,updatedAt: 2026-05-18T01:11:39Z(stale —updatedAtonly refreshes onphase/threadId/turnIdchanges, not on every progress event; log file is the truth)Reproduction 2 — Windows 11 + neev workspace content review (2026-05-18, 18 hours later)
E:/dev/neev(git repo)read-onlyfindstr,Get-Content,Select-String, and somegit diff --no-indexcommands. A fewCommand declined(exit -1) interleaved with successful completions before the wedge${TMPDIR}/codex-companion/neev-afc8637dfd450973/jobs/task-mpbkoe5v-odz1t1.log, 68 lines preserved~/.codex/sessions/2026/05/18/rollout-2026-05-18T12-04-01-019e3c79-4725-7f43-9ddc-12b391fb4879.jsonl, 144 lines; last entryfunction_call_outputat2026-05-18T19:07:29.903Zcodex exec 'echo ok'returned in 3.3s. Bug is session-specific, not codex-downPattern observation across all three reports (yours + ours × 2)
danger-full-accessread-onlyread-onlyGet-Contentbatch with someexit -1failuresfindstr/Get-Content/git diffalternationturn.completed, worker exits 0,state.completionhangsWhatever the proximate trigger (heavy parallel tool-call activity; failures in the batch; possible context-window threshold), the failure mode is consistent: codex CLI app-server worker exits without emitting
turn/completed. The wrapper layer (companion) has no internal timeout onstate.completionand waits forever.Companion-layer workaround we shipped
For our environment (Windows + Claude Code orchestrating discriminator reviews against codex), we built a thin Node wrapper that sits between the orchestrator and codex-companion.mjs. The wrapper:
codex-companion.mjsas a subprocess (transparent argv pass-through).${stateDir}/broker.json) for the broker pid.wmic process where (ParentProcessId=<broker>) get ProcessId,ParentProcessId,Name /format:csv— note: codex.exe is a grandchild of broker under Windows shell-true spawning (broker → cmd.exe → codex.exe), NOT a direct child, so direct-child enumeration would miss it./codex/i-named descendants. If a previously-tracked descendant disappears (verified viaprocess.kill(pid, 0)returning ESRCH) AND the companion subprocess is still alive AND the zero-descendant state persists for 2 consecutive polls (debounce against legitimate process replacement) → trip.taskkill /T /Fthe companion tree + emit a structured JSON envelope marking the run asverdict: "tooling-failure"(downstream orchestrator already handles this verdict).CODEX_COMPANION_SESSION_IDenv var (passed to child) to avoid cross-wrapper log false-positives.Detection latency: ~2-4 sec (process watchdog) or 15 min (time fallback). Empirically validates against the smoke 6 + smoke 7 scenarios we documented.
This is a wrapper, not an upstream patch — we don't maintain a fork of codex-companion.mjs. The wrapper imports stdlib only (no junction-protected imports) and works by observing the broker's process tree externally. Source on request if upstream finds it useful.
Secondary issue spotted:
codex-companion.mjs cancelfails to kill the wedged tree under Git-Bash on WindowsWhen trying to cancel reproduction-2 manually,
codex-companion.mjs cancel <jobid>invoked from Git-Bash on Windows produced:Root cause:
lib/process.mjsterminateProcessTree()usesspawnSync("taskkill", [...], { shell: process.env.SHELL || true }). Withprocess.env.SHELL = "/usr/bin/bash"(Git-Bash), bash's MSYS path-conversion rule rewrites the leading-slash arg/PID→E:/Prg/Git/PID(or similar Git install path) before taskkill sees it.Workaround that worked: invoke
taskkillviachild_process.spawnSyncWITHOUTshell: true(Node's child_process resolvestaskkillon PATH directly without going through bash; CreateProcessW gets the args as-is). Our wrapper does this; codex-companion's own cancel path does not. Worth a small fix.Suggested upstream patches
Two independent surfaces:
A.
state.completionrace / silent worker exit:worker-died-mid-turnevent so wrapper layers can react instead of hang indefinitely--turn-no-progress-timeout 600000) — would let companions opt-in to a sane upper boundB.
lib/process.mjsGit-Bash taskkill:shell: false(or omit shell on Windows entirely) when invokingtaskkill /PID. The arg pattern matters less than avoiding bash arg-mangling.Happy to share more reproduction artifacts (log files, JSONL session dumps, our wrapper source) if useful.
bondtesty commentedon May 19, 2026
Adding another downstream reproduction from a Linux app-server wrapper/integration. I am keeping project paths, prompts, thread contents, and agent identifiers out of this public report.
Environment
0.130.0codex app-server --listen stdio://danger-full-accessneverinitialize,thread/resume, thenturn/start, and waits forturn/completedbefore marking the runtime wake complete.Observed failure
A resumed app-server turn produced partial output and completed tool executions, then never emitted a terminal turn event. The wrapper remained waiting for
turn/completeduntil it was externally diagnosed/restarted.Sanitized raw event shape:
Local artifact counts from the stalled run:
There was no
turn/completed, no top-levelerror, no warning, no stderr output, and no non-zero command exit code in the stalled turn.Why this looks related
This matches the downstream failure mode described here: the app-server turn has clearly accepted and progressed, command tools have returned, but the completion notification never arrives, so the wrapper's completion wait has no terminal signal.
It is not the
apply_patchduplicate-approval case (#21117) in this reproduction: the tools were ordinary shell command executions, notapply_patch.Recovery / workaround
Restarting the app-server-backed runtime and re-requesting the same logical work recovered immediately. The follow-up turn completed normally.
Our local mitigation will be to bound the app-server terminal-event wait and fail explicitly with a diagnostic like "app-server timed out waiting for turn/completed after tool output" rather than letting the collaborator lane wait indefinitely. That does not fix the upstream issue, but it makes the failure recoverable and easier to classify.
Happy to provide additional redacted event tails if maintainers want a specific shape, but I cannot post full prompts or session transcripts publicly.
feat(pipeline): codex hang watchdog + one retry (#86)
feat(observability): surface codex watchdog kills + worker-attribute …
chenditc commentedon Jun 13, 2026
Adding a reliably-observed, related repro of the "turn never completes /
Turn completednever fires" failure, in case it helps. Seen on codex-cli 0.136.0 drivingcodex app-server --stdioin goal mode — looks like the permanent (non-recovering) variant of this issue, closely related to #23807 (same ~300s gap value) and the active-turn-control meta-bug #25779.Setup
codex app-server --stdio, codex-cli 0.136.0wire_api = "responses",base_url= an OpenAI-compatible/v1/responsesendpointthread/goal/set { status: "active" }+ a singleturn/start; the client then only reads notifications and auto-approves exec requests (never sends another turn)What happens
The model backgrounds a long-running shell command and then polls it with an empty
write_stdin:Each empty poll returns after exactly ~300.0 s — the background-terminal ceiling (
DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS = 300_000; an emptywrite_stdinis clamped tomax_write_stdin_yield_time_ms, not the 30 s cap) — with the process still running. codex records thefunction_call_output+token_count. The second-to-last such poll correctly re-prompts the model; after the last one, codex never issues the next/v1/responsesrequest. Noturn/completed, no error;thread/goal/updatedstaysstatus:"active"forever.Forensics (from the rollout JSONL)
1x task_started,0x turn_complete/ error / abort.function_callandfunction_call_outputcounts are equal (e.g. 28 ↔ 28) — every tool call, including the final poll, got a result. No dangling tool call; the loop just stops after a successful tool result. One turn id throughout — it never completes./v1/responsestraffic for that run was 28/28 = HTTP 200 +data: [DONE], zero failures / zero retries — so this is not a model-call hang or a rate-limit; the next request is simply never sent.Why goal continuation can't recover it
In
core/src/goals.rs,maybe_continue_goal_if_idle/maybe_start_goal_continuation_turnbail wheneveractive_turn.is_some(). The block that emitsTurnComplete, clearsactive_turn, and firesMaybeContinueIfIdle(core/src/tasks/mod.rs) only runs when the model returns a tool-free response. Because the model keeps ending each step with anotherwrite_stdinpoll, that block is never reached,active_turnstaysSome, and continuation is structurally blocked — so one wedged turn-step silently kills the whole goal.Minimal repro
The model backgrounds the job and polls via empty
write_stdin(300 s ceilings). After a poll that returns while the job is still running, no further/v1/responsesis issued and noturn/completedever fires.Note: intermittent — it only wedges on a poll that returns with the process still running; polls that return after the process exits always continue normally. May take several poll cycles to hit.
Expected
Either (a) after an exec/
write_stdinpoll result the turn-step loop reliably issues the next model request and eventually emitsturn/completed; or (b) if a between-turn-step dispatch stalls (cf. #23807), it surfaces a visible error/retry and the turn fails loudly instead of leaving the session — and any active goal — silently stuckactive. In app-server goal mode specifically, a turn that cannot advance should fail the turn (clearingactive_turn) somaybe_continue_goal_if_idlecan re-drive the goal.waiting761 commentedon Jul 4, 2026
Adding another independent reproduction of this exact failure mode — Windows 11, via the Claude Code plugin's
codex-companion.mjs→ broker → app-server path (same architecture @gupta-ash described above: broker → cmd.exe → codex.exe as grandchild, not direct child). This adds a concrete new data point: direct process-level evidence that the stuck-turn's CPU is going into the Windows sandbox-setup helper itself, plus a negative-reproduction result.Environment
sandbox_mode = "workspace-write",[sandbox_workspace_write] network_access = true(notread-only— noting this because [Windows] read-only sandbox mode causes MCP shell_command to stall indefinitely — no function_call_output returned #13987 reports a similar stall but scoped specifically toread-onlymode; this report isworkspace-write)codex-companion.mjs→ broker (named pipe) →cmd.exe /d /s /c "codex app-server"→codex.js app-server(node) →codex.exe(native win32-x64 vendor binary)What happened (live incident, captured mid-hang)
A turn issued 3 concurrent
shell_commandcalls (each a simple local-fileGet-Content,timeout_ms: 10000), 29ms apart. All three were logged as "Running command" by the companion. From that point on, no further events were ever written — nofunction_call_output, notask_complete, nothing — for 32+ minutes at time of inspection, with the job still reportingstatus: "running".Live process-tree inspection at the 32-minute mark showed:
All three
codex-windows-sandbox-setup.exeprocesses (one per concurrentshell_command) wereResponding=True(not deadlocked — actively spinning/busy) and had each burned 970-1035 CPU-seconds without ever handing a result back tocodex.exe. This is consistent with the "worker exits or hangs mid-turn, noturn/completed, wrapper has no timeout" pattern already documented in this thread, but pinpoints the CPU activity specifically to the Windows sandbox-setup helper process rather than the app-server/worker process itself.Separately, we have one historical job (different session, months back) that hung in the same way for 24+ hours until it was ended passively by
codex app-server connection closed.— i.e., the only thing that ever terminated it was the transport dying, not any turn-level timeout.Negative reproduction (important caveat)
We tried to build a minimal repro: same architecture, 3 concurrent
Get-Contentcalls against small scratch files (vs. the original incident's larger workspace and deeper file path), with an even tighter concurrent-dispatch window (1ms / 7ms apart vs. 29ms in the live incident). Both attempts completed normally in 53-60 seconds, with 0 hangs. So "3 concurrent shell_commands" alone is not a sufficient trigger — this matches @gupta-ash's "non-determinism" observation above. Candidate differentiating factors we have not yet verified: workspace size/depth, file path depth, system-wide process state at time of dispatch (concurrent codex instances, other CUA/runtime processes active), or a cold-start race in the sandbox helper's setup path.No timeout at any layer
Confirmed by reading the wrapper source: the companion layer has no turn-level/idle-progress timeout — only a manual cancel path exists (which does have a working
terminateProcessTree, it's just never invoked automatically). This matches the fix direction already proposed in this thread (idle-progress timeout /worker-died-mid-turnevent).Workaround in place locally
We've added a local watchdog external to the wrapper that monitors the process tree and force-kills after an idle period with no log progress, to bound the damage — this is a local mitigation only, not a fix for the underlying hang.
Happy to share the raw job state file / rollout JSONL timestamps (redacted) if useful for triage.
argszero commentedon Aug 26, 2026
This issue and #40010 are the same failure class, and I traced the exit mechanism on the macOS side — the observations here match it exactly.
Root cause (from #40010, verified against source)
A clean
exit 0mid-turn fromcodex app-serveris only possible through theConnectionClosedbreak in the single-client stdio path (app-server/src/lib.rs:1036–1038: single-client stdio breaks immediately instead of draining). The stdio reader thread only ends on EOF or a read error — and since nothing in the app-server callsprocess::exit, the EOF must come from the wrapper side closing its write end of the pipe.That maps 1:1 onto every reproduction in this thread:
task_completeevent, no error, no SIGTERM, rollout JSONL just ends" (henkterharmsel) — exactly what a cleanConnectionClosedbreak produces@openai/codexplugin viacodex-companion.mjs, Claude Code plugin) talk to app-server over stdio and are positioned to hit EOF on the server's stdin when the wrapper-side write end closes for any client-side reasonWhy it's invisible
The processor exit path logs an unconditional
info!(lib.rs:1191–1196) with no turn-state context, so a mid-turn exit looks identical to a healthy shutdown in the logs. That's why none of the repros produced a diagnostic.Proposed fix (design complete, implementation pending)
Change that unconditional exit
info!to a conditionalwarn!whenrunning_turn_count > 0at processor exit, including the connection count and shutdown-forced state in the log line. Next occurrence of this class then leaves an explicit "processor task exited while a turn was still running" breadcrumb instead of silence. This covers both #40010 (macOS) and this issue (Linux/WSL2/Windows wrappers) since they share the same exit path.Full design discussion is in #40010 (issuecomment-5403178963 for the root-cause trace, issuecomment-5412055098 for the fix shape). I've prepared the patch but can't open a PR yet — the repo's contribution process is invitation-gated, so I'm waiting on maintainer sign-off there. Happy to post it as soon as that's cleared, or hand the diff to a maintainer who wants it faster.
One caveat: chenditc's reproduction (2026-06-13) mentions a "permanent (non-recovering) variant" with a ~300s gap tied to #23807 — that may be a different mechanism (active-turn coordination stuck rather than process exit). The analysis above covers the process-exit variant; if the permanent variant still reproduces after the warn-log lands, the new breadcrumb will disambiguate it.
merge: codex turn races worker exit and retries once on every path