Repository navigation
Description
Summary
When a Codex worker process dies unexpectedly (SIGKILL, OOM, broker socket disconnect, app-server crash), the companion job state file retains status: "running" indefinitely. No code path in the companion checks whether the stored PID is actually alive. This is the shared root cause behind #164 (review stuck in "running") and #202 (zombie job blocks subsequent tasks), plus a third unreported symptom: result <job-id> returning "No job found" while status <job-id> reports the full (stale) job object.
Three Symptoms, One Root Cause
| Symptom | Affected command | Existing issue |
|---|---|---|
--wait poll loop spins until timeout against a dead process |
status --wait |
#164 |
| "Task X is still running" blocks all subsequent task calls | task (via resolveLatestTrackedTaskThread) |
#202 |
result <job-id> says "No job found" while status <job-id> returns the job |
result vs status |
(new) |
Root Cause Analysis
1. Detached worker spawned with zero crash monitoring
spawnDetachedTaskWorker() in codex-companion.mjs (lines 613–624):
function spawnDetachedTaskWorker(cwd, jobId) {
const child = spawn(process.execPath, [...], {
detached: true, // own process group
stdio: "ignore", // parent never sees output
windowsHide: true
});
child.unref(); // parent drops all references
return child;
}- No
child.on('exit', ...)handler - No
child.on('error', ...)handler - Parent has no visibility into worker lifecycle after spawn
2. runTrackedJob() catch block only handles JS errors
tracked-jobs.mjs (lines 142–204): The try/catch around await runner() handles JavaScript exceptions but cannot fire when the detached worker process is killed at the OS level (SIGKILL, OOM killer, etc.). The promise simply never settles, and the state file remains at status: "running".
3. No PID liveness probe in any status/query path
The following functions read the state file and return status as-is, without checking if job.pid is alive:
| Function | File | Lines |
|---|---|---|
enrichJob() |
job-control.mjs |
161–181 |
buildSingleJobSnapshot() |
job-control.mjs |
242–254 |
waitForSingleJobSnapshot() |
codex-companion.mjs |
293–309 |
resolveLatestTrackedTaskThread() |
codex-companion.mjs |
312–320 |
resolveResultJob() |
job-control.mjs |
256–279 |
Zero occurrences of kill(pid, 0) or ps -p exist outside terminateProcessTree(), which is only called by handleCancel().
4. result vs status inconsistency
resolveResultJob() (line 258) filters for completed | failed | cancelled. A zombie "running" job is caught at line 269 and throws "Job X is still running." But without a reference argument, it filters to current-session jobs only (line 258: filterJobsForCurrentSession). If the stale job belongs to a previous session, result says "No finished job found" while status (which reads all jobs) returns it as "running". This creates a confusing split where both commands appear to contradict each other.
Real-World Evidence
Incident 1: adversarial-review on macOS (2026-04-11)
Job: review-mnugvp3h-uy1ly9
Kind: adversarial-review
Created: 2026-04-11T15:05:41.837Z
Updated: 2026-04-11T15:07:23.215Z ← last state write
Cancelled: 2026-04-11T15:27:20.275Z ← manual cancel 20 min later
- Backend PID died silently at ~15:07.
statusreported "running" for 20 minutes until manual/codex:cancel.result <job-id>returned "No job found" during the stale window.ps -p <pid>confirmed the process was dead (no row returned).
Incident 2: task broker disconnect (2026-04-07)
[2026-04-07T05:40:45.631Z] Codex turn interrupt failed: connect ECONNREFUSED .../broker.sock
[2026-04-07T05:40:45.632Z] Cancelled by user.
Broker socket became unreachable — same pattern: process dead, state file stale, only resolved by manual cancel.
Suggested Fix
Add a isProcessAlive(pid) utility and call it in the status/query path:
// lib/process.mjs
export function isProcessAlive(pid) {
if (!Number.isFinite(pid)) return false;
try {
process.kill(pid, 0);
return true;
} catch (e) {
return e.code === 'EPERM'; // exists but no permission
}
}Then in enrichJob() or buildSingleJobSnapshot():
import { isProcessAlive } from "./process.mjs";
export function enrichJob(job, options = {}) {
// Auto-reap zombie jobs
if (
(job.status === "running" || job.status === "queued") &&
job.pid != null &&
!isProcessAlive(job.pid)
) {
const reaped = {
...job,
status: "failed",
phase: "failed",
pid: null,
errorMessage: "Worker process exited without updating job status (zombie reap).",
completedAt: new Date().toISOString()
};
// Persist the reap so it doesn't recur
upsertJob(job.workspaceRoot ?? resolveWorkspaceRoot(process.cwd()), reaped);
writeJobFile(job.workspaceRoot ?? resolveWorkspaceRoot(process.cwd()), job.id, reaped);
return enrichJob(reaped, options); // re-enrich with corrected status
}
// ... existing enrichment logic ...
}This fixes all three symptoms at the source:
status --waitwill see the reap and stop pollingresolveLatestTrackedTaskThread()will no longer block on a dead taskresultwill find the job infailedstate instead of "still running"
Environment
- codex-plugin-cc: v1.0.2 (local cache), v1.0.3 available
- Codex CLI: v0.118.0
- Claude Code: latest
- Platform: macOS Darwin 24.6.0 (Apple Silicon)
- Node.js: v22.21.1
Related Issues
- Review jobs stuck in 'running' after process death — no dead-PID detection #164 — Review jobs stuck in 'running' after process death (symptom 1)
- Zombie running job blocks all subsequent task calls when process crashes #202 — Zombie running job blocks all subsequent task calls (symptom 2)
- cancel without a job id can cancel another Claude session's hidden running task #82 — Cancel scoping across sessions (related state management issue)
- [Bug] codex app-server crashes on macOS inside Claude Code sandbox (SCDynamicStore NULL panic) #141 — App-server crash on macOS (one of the triggers for unexpected process death)
Activity
fix: add --full-access flag and prevent zombie/hanging jobs
fedoseevstanislav commentedon Apr 18, 2026
Hitting this reliably on Linux with codex-plugin-cc v1.0.3 + codex-cli v0.121.0, when the plugin is invoked as the
codex:codex-rescuesubagent from Claude Code.Additional trigger I've observed (broker-side, not worker):
app-server-broker.mjsitself dies during long reasoning turns or after a few failed shell-heredoc commands from the worker. The broker socket under/tmp/cxc-*/broker.sockremains on disk but nothing answers. From the companion's perspective, it's the same zombie state.Concrete repro this morning
codex:codex-rescuewith a multi-step edit task (~250 words of brief, ~5 file writes).python - <<'PY') that failed with exit 127/1 due to shell quoting mangling.ps.state.jsonstill readsstatus: "running"."Task task-mo42z5fy-kcmbsh is still running. Use /codex:status before continuing it."Same job's
state.jsonhadpid: 3246522—kill -0 3246522returns ESRCH.Second repro (different trigger, same symptom)
Yesterday: stale brokers from April 13 were still alive (PID 2208403/2208411/2208418) pointing at worktrees that no longer existed on disk. New dispatches hung on broker init because the old
/tmp/cxc-WTXKGN/broker.socketc. still accepted connections but the handshake never completed. Killing those three PIDs + removing the stale/tmp/cxc-*/dirs unblocked new dispatches.Workaround I'm using
Same as #202's workaround, documented here for completeness:
~/.claude/plugins/data/codex-openai-codex/state/<workspace-slug>/state.json.status: "running"whosepidis not inps, flipstatusto"failed"andpidtonull.phaseto"failed", adderrorMessageandcompletedAt.jobs/<task-id>.json.I've had to do this 2-3 times in this session alone.
Patch applied locally
I applied the suggested fix in this issue's body (added
isProcessAlivetolib/process.mjs+ the auto-reap block inenrichJobinlib/job-control.mjs). Syntax checks pass (node --check). Will report back if it holds on this machine over the next few days.Happy to open a PR if that would help move this along.
fix: reconcile stale 'running' jobs when tracked PID is dead
fix: add captureTurn transport watchdog and runTrackedJob hard timeout
fix: reconcile stale 'running' jobs when tracked PID is dead
fix: add captureTurn transport watchdog and runTrackedJob hard timeout
fix(broker): P0 stability — idle timeout + PID liveness + shutdown ti…
ghbaud commentedon May 21, 2026
Another instance of this symptom from Windows 11. I am not yet sure of the root cause; reporting what I have in case it helps maintainers or other folks correlate.
Environment
codex:codex-rescuesubagent from a Claude Code skill (the standard/codex:rescueAgent-tool path)Symptom
Same shape this issue already documents. Three production failures over 17 hours, all on the same machine, all against the workspace
C:/Users/dhite/Projects/python/WheelHouse:task-mpdop7yn-7zxanm(pid 20032)task-mpemqlen-c0b5sx(pid 7156)task-mpep94bb-ro8lqn(pid 25712)Every failed job's per-job JSON file shows
status: "running",phase: "starting", with a pid that is dead by the time anyone notices. The job log file ends after exactly the two lines the parent dispatcher emits before the worker starts running:The third line,
Starting Codex task thread.(emitted fromrunAppServerTurninsidewithAppServer), is never written. None of the three failed jobs appear in the workspacestate.jsonat all -- so the worker died afterwriteJobFile(line 151 oftracked-jobs.mjs) but before the immediately followingupsertJobcould writestate.json. That places the death window insideexecuteTaskRun->runAppServerTurn->CodexAppServerClient.connect, specifically insideensureBrokerSessionor its first RPC.Useful side observation
The same job id can be recovered. After one of the failures (
task-mpepysuo-nuanulfrom a hand-launched repro), I re-rannode codex-companion.mjs task-worker --cwd ... --job-id task-mpepysuo-nuanulsynchronously in the foreground. It picked up the stalephase: "starting"per-job JSON, reachedStarting Codex task thread.within seconds, completed the turn, and wrote the terminal record cleanly. Same workspace, same prompt, same env, same job id -- the only difference is detached background vs synchronous foreground.This is a useful data point: the death is not deterministic against the job's contents or workspace state. The same inputs can succeed when run in foreground.
What I tried to reproduce, and what happened
I wrote a small Node harness that dispatches
codex-companion.mjs task --background ...via the same Bash tool the production failures used, polls the per-job JSON for pid liveness, and watches the log for theStarting Codex task thread.line. Three batches, ten iterations each:--write(workspace-write sandbox)--write, cached broker forcibly torn down before EVERY iteration (so every dispatch takes the slow fresh-broker code path)Total 30 dispatches, zero startup deaths. Each successful dispatch went
phase: starting -> finalizing -> donein 14-26 seconds.So the failure is rare or condition-specific, not a constant-rate hazard. I cannot point at a root cause from the data I have. Plausible angles I have not been able to rule out: long multi-paragraph review prompts referencing staged files, concurrent activity from other companion processes against the same workspace, state.json size or content, or transient OS conditions (the day of the production failures, the Application event log shows a "UPS On Battery" warning -- power was noisy that day).
What this issue's proposed fix would do for us
The
isProcessAlive(pid)probe and auto-reap inenrichJobfrom this issue's body would make any future startup death visible asstatus: "failed"instead of leaving a permanentstatus: "running"orphan. Right now our agent-review-loop wrapper has to fall back to a separate STALE_LOG heuristic to notice the worker has died, and the workspace accumulates orphan running entries that pollute/codex:statusoutput. The probe also closes theresult <job-id>vsstatus <job-id>inconsistency the issue body describes.For our case the probe alone is sufficient, because the failure is rare enough that we do not need to also stop the worker from dying -- making the failure visible is enough for the wrapper to retry against a fresh job id.
I am applying the probe locally as a temporary patch to my plugin cache. Happy to convert it to a PR against this repo if maintainers prefer that flow.
Cross-references
openai/codex#21813-- "Detached task-worker exits without writing failed status on broker socket disconnect" -- same observable shape, different proximate trigger, same proposed fix. Probably worth treating as duplicates of this issue.openai/codex-plugin-cc#228-- foreground signal handlers. Different trigger (SIGTERMto the foreground companion). The signal handler proposed there would also catch our case if the worker happens to be signalled by the OS rather than killed outright; complementary to the probe in this issue.openai/codex-plugin-cc#279(Bug 2) -- samespawnDetachedTaskWorkercritique from a different angle.ghbaud commentedon May 21, 2026
Follow-up to my earlier comment (#222 (comment)). The Windows-11 production failures I described above have a subtlety the issue body's proposed fix does not cover: the
isProcessAliveprobe inenrichJobalone would leave them permanently invisible.The state-missing variant
runTrackedJobintracked-jobs.mjswrites the per-job JSON viawriteJobFilebefore it callsupsertJobto register the job instate.json. Our three production failures all died inside that window:<state-dir>/jobs/<task-id>.jsonis present withstatus: "running",phase: "starting",pid: <dead>.state.jsonhas no entry for that job id.enrichJob(and every other listing path) operates on the result oflistJobs(workspaceRoot), which readsstate.json. A reap added at the top ofenrichJobwill not run for these orphans because they are never enumerated. From/codex:statusthey are invisible; from/codex:result <job-id>they raise "No job found"; only direct inspection of thejobs/directory turns them up.What we added locally on top of this issue's fix
In addition to the
isProcessAliveprobe and an in-enrichJobreap, we added a second sweep that walks<state-dir>/jobs/*.jsondirectly:The sweep runs before listing in three places so any state.json-shaped query rescues orphans first:
buildStatusSnapshot(for/codex:statuslistings)buildSingleJobSnapshot(for/codex:status <job-id>)resolveResultJob(for/codex:result <job-id>)After the sweep, the three production-failure jobs that had been permanent invisible orphans now show up as
status: "failed"withphase: "orphaned"and the error message"Worker process exited without registering in state.json (orphan reap; pid <n> no longer alive)."Safety notes
kill(pid, 0)probe and the reap write.phase: "orphaned"(vs. a regularphase: "failed") acts as a marker that the sweep, not the worker itself, recorded the terminal state.How this relates to the main proposal
The issue body's
enrichJobchange is necessary and catches the longer-tail case where the worker died afterupsertJobsucceeded. Our extra sweep catches the shorter-tail case where the worker died afterwriteJobFilebut beforeupsertJob. Both reaps coexist cleanly; the sweep handles state-missing orphans first, the per-job reap handles state-known zombies on the listing path.Happy to fold the sweep into a PR alongside the main fix if maintainers want it, or leave it as a local divergence until the broader issue lands.
fix(state): reap stale running jobs via OS-level PID liveness check (o…
docs(v2.0.0): sync README + MIGRATION_v2.0.md + TROUBLESHOOTING.md wi…
sorcerai commentedon Jul 10, 2026
Supporting field data for this root cause, from production use of the plugin's
codex-rescuepath:state.jsonmarkedrunning; all 4 PIDs dead — the oldest for 12 days. The completion-update path only fires on a clean try/catch exit, so external kill / OOM / SIGKILL leavesstatus: "running"forever.Detection recipe we settled on before trusting any job status, which may be useful as the fix's acceptance criteria:
ps -p <pid>— dead PID +status: running⇒ zombie.state.json.boo13 commentedon Jul 13, 2026
Confirming the liveness gap with a concrete data point (2026-07-13, plugin 1.0.0, Codex CLI 0.144.3, macOS/Darwin 25.5, arm64). After a detached
task-workerwas reaped mid-turn (see #432),status --allreported it as live indefinitely:…while
kill -0 47998→ ESRCH (process dead). A second wedged job from the same session showed the identical pattern. Nothing ever transitioned them offrunning;cancelwas required.process.kill(pid, 0)in the status/enrich paths (your proposed probe) would have flipped both tofailedimmediately. +1 — this is the highest-leverage of the layered fixes, since it neutralizes the symptom regardless of why the worker died.cauyxy commentedon Aug 2, 2026
Confirming this is still present in 1.0.6 (latest), and on the background task path — not only the review/rescue path most reports above cover.
Environment
codex-companion.mjs task --background --writeEvidence (2026-08-02)
Job
task-msc0avk8-tu4mns: worker died mid-turn whilestatus <id> --jsonkept returningps -p 63224→ no row (process dead)logFilemtime was frozen from the moment of death onwardstatusalone spun indefinitely — exactly the Review jobs stuck in 'running' after process death — no dead-PID detection #164 shapeRecovery data point (relevant to the proposed auto-reap)
cancel <task-id>followed bytask --resume-last --writeon the same thread recovered cleanly: the worker writes the working tree directly, so every file change up to the crash was intact on disk, and the resumed turn continued fromgit statusinstead of restarting. So auto-reaping zombies tofailed(as proposed in the issue body) loses no work; including the resumablethreadIdin the reap error message would make recovery a one-steptask --resume-last.Workaround until the liveness probe lands
We watch three signals instead of
statusalone: (1)status != running, (2)kill -0 $pid, (3)logFilemtime staleness (>300 s). Signal 3 is worth considering as a complementary heartbeat in the status output itself — it also catches the alive-but-wedged worker that a PID probe alone reports as healthy.jkleske commentedon Aug 19, 2026
Field report, plugin 1.0.5, macOS (darwin 25.6.0), shared-session broker runtime.
Confirming the "Task X is still running" symptom with one forensic detail I haven't seen documented: the block can silently "self-heal" after ~12.5 minutes, which makes the bug present as random latency while the stuck record stays invisible.
Sequence from our transcripts (2026-08-17): a foreground
taskcall was killed by the host's 2-minute command timeout (exit 143). Its job record stayedrunning. A retry loop then calledtaskevery ~15 s; each attempt failed with "Task … is still running" and each failure wrote a newfailedjob record. After exactly 50 failed attempts (12.5 min),pruneJobs(MAX_JOBS = 50, lib/state.mjs) evicted the stalerunningrecord from the sorted list. Only then did the nexttaskcall go through. So the observed user experience is "Codex does nothing for 15 minutes, then works", with no hint that a stale record was the cause.Two more stale
runningrecords with dead PIDs from separate incidents (Aug 13, Aug 15) are still sitting in our state dir, so this happens in routine use. Aprocess.kill(pid, 0)liveness check inresolveLatestTrackedTaskThreadandbuildStatusSnapshot, as proposed here, would cover all of it.