Skip to content

fix: reconcile stale background job workers - #728

Open
fscfede-beep wants to merge 5 commits into
openai:mainfrom
fscfede-beep:fix/stale-job-liveness-704
Open

fix: reconcile stale background job workers#728
fscfede-beep wants to merge 5 commits into
openai:mainfrom
fscfede-beep:fix/stale-job-liveness-704

Conversation

@fscfede-beep

Copy link
Copy Markdown

Summary

Fixes #704.

  • reconcile queued / running job records against the recorded worker PID when jobs are read
  • treat process.kill(pid, 0) ESRCH as a missing worker and project the job as terminated-unknown with phase worker-exited
  • treat EPERM as alive and fail open on unrecognized probe errors to avoid false termination
  • use the same reconciled view for status, result, and cancel resolution
  • keep reconciliation read-only: persisted job records are not rewritten
  • add deterministic coverage for missing workers, EPERM, terminal/pid-less records, and active-queue classification

This fixes stale jobs already on disk without requiring a worker-side protocol change.

Validation

On Windows 11 / Node 26.3.1:

  • test-first check initially failed because reconcileJobLiveness did not exist
  • node --test tests/job-control.test.mjs tests/render.test.mjs tests/commands.test.mjs -> 14 passed, 0 failed
  • real liveness probe with PID 999999 projects { status: "terminated-unknown", phase: "worker-exited" }
  • node --check plugins/codex/scripts/lib/job-control.mjs
  • git diff --cached --check

I also attempted the repository-wide npm test on this Windows host. It encounters an unrelated existing broker-endpoint.test.mjs failure for the Unix-socket case before the new job-control coverage; this PR does not modify broker endpoint code.

A Codex CLI review was also attempted but did not execute because the account had reached its code-review usage limit, so it is not claimed as validation.

@fscfede-beep
fscfede-beep requested a review from a team September 4, 2026 16:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1986b7ec45

ℹ️ 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".

Comment thread plugins/codex/scripts/lib/job-control.mjs Outdated

Copy link
Copy Markdown
Author

Addressed the P2 liveness-consistency feedback in c571102: the reconciled job view now reaches task --resume-last, task-resume-candidate, the Stop hook, and SessionEnd cleanup in addition to status/result/cancel. New stale-worker routes: 4/4 PASS; job-control/render/commands regression: 14/14 PASS.

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Note

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5711025d6

ℹ️ 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".

Comment thread plugins/codex/scripts/lib/job-control.mjs Outdated

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ESRCH projection handles workers that have already claimed a PID, but queued records with pid: null are explicitly left active. Since background enqueue persists exactly that bootstrap state before the detached worker is spawned, a spawn failure can still strand a job forever and keep status/resume/Stop treating it as active. I would either terminally record spawn failure at enqueue, or add a bounded policy for unclaimed queued records so this reconciliation covers the full startup lifecycle.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8286b41191

ℹ️ 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".

Comment on lines +44 to +45
if (error?.code === "ESRCH") {
return { ...job, status: "terminated-unknown", phase: "worker-exited" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify the Codex turn before declaring the job terminal

When a detached worker exits after turn/started, the shared Codex process may still be executing the turn: the broker's socket-close handler only clears ownership in app-server-broker.mjs:225-227 and does not send turn/interrupt. Marking the job terminal solely because its wrapper PID disappeared lets the resume and Stop-hook paths start another turn or review while the orphaned turn can still edit the workspace. Reconcile or interrupt the persisted threadId/turnId before treating this job as inactive.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 12a4cf5. Liveness reconciliation now distinguishes wrapper death before vs. after durable Codex turn identity exists: an ESRCH job with no threadId/turnId remains terminated-unknown, while a job with both IDs stays logically active as running with phase: worker-exited-turn-unknown, pid: null, and workerExited: true. This keeps Stop/resume fail-closed and leaves the job selectable by /codex:cancel, whose existing path can issue turn/interrupt using the persisted IDs. Deterministic RED→GREEN coverage includes unit/state consumption plus the two reviewer-critical runtime routes: task --resume-last blocks an orphaned live turn while still ignoring a stale pre-turn worker, and Stop keeps an orphaned live turn active while still ignoring a stale pre-turn worker. Fresh validation on the exact branch: job-control/render/commands 17/17 PASS; targeted runtime stale-vs-live-turn 4/4 PASS; node --check and git diff --check PASS. @codex review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 12a4cf5532

ℹ️ 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".

Copy link
Copy Markdown
Author

On the queued + pid:null startup case: I agree it must not remain active forever, but I am deliberately not adding an age-based liveness timeout here because that would guess that a legitimately slow detached worker is dead before it has claimed its PID.

I addressed that startup failure causally in the companion enqueue path in #733: the queued record is persisted before spawn, synchronous/asynchronous spawn failures terminalize that record, and the worker/cancel/SessionEnd handoff is guarded so late startup events cannot resurrect or overwrite terminal state. This PR remains the read-time reconciliation layer for jobs that did claim a PID and later disappeared. Keeping those mechanisms separate avoids a heuristic timeout in status while covering both lifecycle phases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12a4cf5532

ℹ️ 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".

return job;
}
if (error?.code === "ESRCH") {
if (job.threadId && job.turnId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep jobs active during the turn/start response window

When a detached worker exits after the broker accepts turn/start but before its response is processed, the persisted job has a threadId but no turnId, so this condition falls through to terminated-unknown and resume paths may start another workspace-editing turn. Fresh evidence in this revision is that captureTurn buffers all turn notifications until the response sets state.turnId (plugins/codex/scripts/lib/codex.mjs:563-591), while the broker releases request ownership as soon as the crashed worker's socket closes (plugins/codex/scripts/app-server-broker.mjs:225-232); therefore requiring both identifiers still leaves an accepted, potentially running turn classified as inactive.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8de65eb. I reproduced the accepted-turn/start-response window with three RED regressions on 12a4cf5: (1) dead wrapper + persisted threadId but no turnId was projected terminal, (2) /codex:cancel could not represent the still-running/unknown-turn state safely, and (3) SessionEnd removed tracking for that potentially live turn. The fix is fail-closed: any dead wrapper with durable threadId remains logically active as worker-exited-turn-unknown; cancel refuses to claim success while turnId is unavailable; SessionEnd preserves the reconciled orphaned job instead of deleting its tracking. GREEN: 3/3 new regressions, job-control/render/commands 18/18 PASS, ordinary SessionEnd cleanup 1/1 PASS, syntax/diff checks clean. The brokered cancel runtime remains flaky on Windows; the same test also fails on untouched upstream/main in this environment. @codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8de65eb8fb

ℹ️ 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".

Comment on lines +977 to +981
if (job.workerExited && threadId && !turnId) {
throw new Error(
`Cannot safely cancel ${job.id}: the worker exited after turn/start was accepted, but the turn id is not yet known. The Codex turn may still be running.`
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require a successful interrupt for orphaned turns

When a reconciled job has workerExited: true and a known turnId, this guard allows cancellation to continue even if connecting to the broker or turn/interrupt fails and returns interrupted: false. Reconciliation has already cleared the PID, so terminateProcessTree cannot stop anything, yet the job is recorded as cancelled and resume/Stop paths may permit new workspace-editing work while the orphaned turn continues. Fail closed for every worker-exited job unless interruption succeeds, not only when the turn ID is missing.

Useful? React with 👍 / 👎.

Comment on lines +44 to +45
if (error?.code === "ESRCH") {
if (job.threadId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish thread creation from turn submission

A persisted threadId does not prove that turn/start was accepted: runAppServerTurn reports and persists the thread at codex.mjs:1123-1125, then only submits the turn at 1132-1143. If the worker exits in that gap, this branch projects a job with no running turn as active, clears its PID, and the new cancellation guard rejects it forever because no turnId can ever arrive. Persist a separate turn-submission marker or query the thread rather than treating every created thread as an accepted turn.

Useful? React with 👍 / 👎.

Comment on lines +67 to +69
if (reconciled.workerExited && reconciled.threadId) {
retainedJobs.set(job.id, reconciled);
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Finalize retained orphan records after broker teardown

During SessionEnd, broker shutdown is requested before this cleanup and the broker closes its app-server process, with the subsequent teardown providing a hard-stop fallback. Retaining the reconciled record as running with pid: null therefore leaves it permanently active after its turn has been terminated; later liveness checks skip PID-less jobs, and an unknown-turn record cannot be cancelled. Preserve the diagnostic record if desired, but transition it to a terminal/unknown status once broker teardown has made the turn non-live.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8de65eb8fb

ℹ️ 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".

Comment on lines +48 to +51
status: "running",
phase: "worker-exited-turn-unknown",
pid: null,
workerExited: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile pid-less orphan states after the turn settles

When the wrapper exits after Thread ready—including before turn/start is sent—this projection records the job as running while clearing its PID. Every subsequent reconcileJobLiveness call then returns immediately because the PID is null, and a record without a turnId cannot be cancelled, so task --resume-last and status --wait remain blocked indefinitely even if no turn was started or the accepted turn later completed. Preserve enough durable turn-start state to query or eventually terminate this transitional record instead of making it permanently active.

Useful? React with 👍 / 👎.

const threadId = existing.threadId ?? job.threadId ?? null;
const turnId = existing.turnId ?? job.turnId ?? null;

if (job.workerExited && threadId && !turnId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when interrupting an orphaned turn fails

When a dead wrapper has a persisted turnId, this guard is skipped; if interruptAppServerTurn subsequently reports interrupted: false because the broker or RPC failed, the handler still terminates the already-null PID and persists cancelled. The brokered Codex turn may therefore continue editing while resume and Stop paths treat the job as terminal and allow overlapping work. For workerExited jobs, require a successful interrupt or confirmation that the turn is terminal before recording cancellation.

Useful? React with 👍 / 👎.

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.

status reports a background job as running forever when its worker dies before writing a terminal status

2 participants