Skip to content

fix: reap detached workers that die before reaching 'running', and mark in-process crashes failed immediately - #52

Merged
axisrow merged 2 commits into
mainfrom
fix/issue-425-dead-worker-jobs
Aug 3, 2026
Merged

fix: reap detached workers that die before reaching 'running', and mark in-process crashes failed immediately#52
axisrow merged 2 commits into
mainfrom
fix/issue-425-dead-worker-jobs

Conversation

@axisrow

@axisrow axisrow commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Investigated upstream openai/codex-plugin-cc#425 ("detached task worker that dies without throwing leaves job stuck 'running' forever") for portability here. TDD-first against this fork's own job-tracking code, not the upstream diff.

Stacked on #51 (the pre-existing main build break interrupts a clean npm run build on any branch based on main).

Most of openai#425 was already closed independently. This fork has its own equivalent of upstream's reader-side reapDeadJobs: state.mjs's reconcileRunningJobs, called unconditionally inside listJobs() itself — not a separate wrapper that 5 different call sites (job-control.mjs, codex-companion.mjs, stop-review-gate-hook.mjs) each have to remember to apply, which is how upstream wired it. Every consumer that reads jobs already goes through the reconciliation for free. This also fully closes upstream openai#392 (verified separately as part of this investigation — no change needed there, see companion report to the requester).

One real gap remained: reconcileRunningJobs only reconciled job.status === "running", not "queued". enqueueBackgroundTask records the detached worker's pid at enqueue time with status: "queued", before that worker has run far enough to flip its own record to "running" via runTrackedJob. A worker that dies in that window (crash, immediate OOM kill) left the job stuck "queued" forever with an already-dead pid — invisible to the "running"-only check — permanently blocking --resume-last and every other gate that treats queued/running as active.

Reproduced: seeded a "queued" job with a real, verified-dead pid; task --resume-last threw "Task <id> is still running..." forever. Fixed by extending reconcileRunningJobs to also reconcile "queued".

Also ported: registerWorkerCrashGuard, adapted to this fork's tracked-jobs.mjs/state.mjs helpers rather than blind-copying upstream's shape. Installed in handleTaskWorker, it's an in-process uncaughtException/unhandledRejection handler that marks the job failed immediately, with the actual crash reason logged to the job's log file — instead of waiting for the next listJobs() read to lazily reconcile it. This closes the remaining functional gap between "eventually correct" (the reconciliation fix above) and "immediately correct with a useful error message," which is the rest of what upstream openai#425 does. Deliberately does not install signal handlers (SIGTERM/SIGINT/SIGHUP): SIGKILL is uncatchable regardless, so reader-side reconciliation must cover process death either way, and /codex:cancel's SIGTERM teardown races a "cancelled" write that this guard must not clobber back to "failed" — the guard checks for an already-terminal status before rewriting, mirroring upstream's own reasoning for the same signal exclusion.

Test plan

  • tests/runtime.test.mjs:
    • task --resume-last is not permanently blocked by a job stuck 'running' with a dead worker pid (upstream #392) — proves the pre-existing "running" reconciliation already unblocks --resume-last end-to-end, not just /status/task-resume-candidate.
    • task --resume-last is not permanently blocked by a job stuck 'queued' with a dead worker pid (upstream #425 gap) — spawns a real, verified-dead pid, seeds a stuck "queued" job, asserts --resume-last succeeds instead of throwing. Verified red before the state.mjs fix (reverted the change, confirmed the exact upstream-reported throw), green after.
  • tests/tracked-jobs.test.mjs (new): registerWorkerCrashGuard marks a job failed on an unhandled rejection with the reason logged in the error message; does not clobber an already-"cancelled" job when SIGTERM arrives mid-teardown.
  • npm test — 193/193 passing (190 baseline + 3 new).
  • npm run build — clean, stacked on fix: repair the tsc build broken by interruptAppServerTurn's JSDoc/param mismatch #51.

No version bump.

axisrow and others added 2 commits August 3, 2026 10:33
…ram mismatch

npm run build (tsc -p tsconfig.app-server.json) fails on main with:

  codex.mjs(1209,53): error TS2339: Property 'threadId' does not exist on type '{}'.
  codex.mjs(1209,63): error TS2339: Property 'turnId' does not exist on type '{}'.
  codex.mjs(1209,71): error TS2339: Property 'timeoutMs' does not exist on type '{}'.

interruptAppServerTurn's second parameter is destructured directly in the
signature (`{ threadId, turnId, timeoutMs } = {}`), while the JSDoc above
it types a parameter named `options`. TS's JSDoc-to-signature binding
matches by parameter position/name, not by shape, so the destructuring
pattern doesn't pick up the JSDoc type -- TS instead infers the parameter's
type from its `= {}` default, i.e. `{}`, and then rejects every property
access on the destructured names.

Fix: destructure inside the function body instead of the signature (same
pattern already used by CodexAppServerClient#request in app-server.mjs),
so the JSDoc-typed `options` parameter name lines up with the actual
parameter. No behavior change -- same defaulting, same property reads,
just moved one line down.

Landed via db52e28/f67a09f without a build check catching it (npm test
alone doesn't run tsc). Verified by reverting this change on a clean
main checkout and reproducing the same three errors, then re-applying to
confirm `npm run build` is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbkiKZR4w8hZUmNNTdb6kB
…rk in-process crashes failed immediately

Investigated upstream openai#425 ("detached task worker
that dies without throwing leaves job stuck 'running' forever") for
portability here. Verified against this fork's own job-tracking code
before porting anything.

This fork already had an independent equivalent of upstream's reader-side
reapDeadJobs: state.mjs's reconcileRunningJobs, called unconditionally
inside listJobs() itself (not as a separate wrapper every call site has
to remember to apply — architecturally tighter than upstream's approach,
which wraps 5 separate call sites in job-control.mjs/codex-companion.mjs/
stop-review-gate-hook.mjs with reapDeadJobs()). That closes the "running"
half of openai#425 and the whole of openai#392 (verified separately, see companion
report) without any change.

One real gap remained: reconcileRunningJobs only reconciled
job.status === "running", not "queued". enqueueBackgroundTask records the
detached worker's pid at enqueue time (status: "queued"), before that
worker has run far enough to flip its own record to "running" via
runTrackedJob. A worker that dies in that window — crash, immediate OOM
kill — left the job stuck "queued" forever with an already-dead pid,
invisible to the "running"-only check, permanently blocking
--resume-last and every other gate that treats queued/running as active.
Reproduced with a real dead pid recorded against a "queued" job; the
--resume-last gate threw "still running" forever. Fixed by also
reconciling "queued" jobs.

Ported registerWorkerCrashGuard on top of that gap fix: an in-process
uncaughtException/unhandledRejection handler installed in the task
worker (handleTaskWorker) that marks the job failed immediately, with
the actual crash reason logged, instead of waiting for the next
listJobs() read to lazily reconcile it. Adapted to this fork's
tracked-jobs.mjs/state.mjs helpers (readJobFile/writeJobFile/upsertJob)
rather than upstream's shape. Deliberately does NOT install signal
handlers (SIGTERM/SIGINT/SIGHUP): SIGKILL is uncatchable regardless, so
the reader-side reconciliation must cover process death either way, and
/codex:cancel's SIGTERM teardown races a "cancelled" write that this
guard must not clobber back to "failed" — the guard checks for an
already-terminal status before rewriting, same as upstream's guard.

Tests:
- tests/runtime.test.mjs: two end-to-end --resume-last tests — one
  proving the pre-existing "running" reconciliation already unblocks
  --resume-last (not just /status), one proving the "queued" gap and its
  fix (spawn a real dead pid, seed a stuck "queued" job, assert
  --resume-last succeeds instead of throwing "still running").
- tests/tracked-jobs.test.mjs (new): registerWorkerCrashGuard marks a
  job failed on an unhandled rejection with the reason logged; does not
  clobber an already-cancelled job on SIGTERM.

npm test: 193/193 passing (190 baseline + 3 new). npm run build: clean
(verified on top of #51, which this branch is stacked on, since main's
tsc currently fails on an unrelated pre-existing error #51 fixes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbkiKZR4w8hZUmNNTdb6kB
@axisrow

axisrow commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

@codex review PR #52 at exact head e4e4986.

Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

Context: this PR closes the remaining gap from upstream openai#425 (a detached task worker that dies without throwing leaves its job stuck forever). Note it is stacked on #51 — its base is fix/codex-mjs-interrupt-turn-jsdoc-build, not main.

Two changes:

  1. state.mjsreconcileRunningJobs previously reconciled only status === "running", not "queued". enqueueBackgroundTask records the detached worker pid at enqueue time as "queued", before the worker flips itself to "running". A worker dying in that window left the job stuck "queued" forever with a dead pid, invisible to the running-only check and permanently blocking --resume-last. Fix extends reconciliation to "queued".

  2. tracked-jobs.mjs — adds registerWorkerCrashGuard, an in-process uncaughtException/unhandledRejection handler installed in handleTaskWorker, marking the job failed immediately with the crash reason logged, instead of waiting for the next lazy listJobs() reconciliation. Deliberately installs no signal handlers (SIGTERM/SIGINT/SIGHUP): SIGKILL is uncatchable anyway so reader-side reconciliation must cover process death regardless, and /codex:cancel's SIGTERM teardown races a "cancelled" write the guard must not clobber back to "failed".

Specifically worth checking:

  • Does the crash guard correctly avoid clobbering an already-terminal status (cancelled/failed/completed) under a race?
  • Can extending reconciliation to "queued" mis-reap a job whose worker is alive but has not yet flipped to "running" — i.e. is the pid-liveness check sound against pid reuse?
  • Any leak or double-registration risk from the process-level handlers in registerWorkerCrashGuard?

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: e4e4986041

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Base automatically changed from fix/codex-mjs-interrupt-turn-jsdoc-build to main August 3, 2026 04:53
@axisrow
axisrow merged commit e470027 into main Aug 3, 2026
@axisrow
axisrow deleted the fix/issue-425-dead-worker-jobs branch August 3, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant