Release v1.2.0 - #3
Merged
Merged
Conversation
`--background` is supposed to mean "outlive the session that started me", but `session-lifecycle-hook.mjs:cleanupSessionJobs` was unconditionally terminating every running job belonging to the ending session — including the detached worker that `enqueueBackgroundTask` had just spawned. The result: any background task dispatched from a subagent (whose SessionEnd fires as soon as its turn finishes) was SIGTERM'd a few seconds in. The JSONL transcript froze at the kill timestamp, the parent session's later status probe found no live job, and the caller never got a result. Same hook then also tore down the broker the worker depended on. Fix: * Tag jobs created via `enqueueBackgroundTask` with `background: true`. * `cleanupSessionJobs` now skips termination for background jobs and preserves their entry in `state.json` so any session in the workspace can still poll for status / fetch results. * `handleSessionEnd` defers broker shutdown when active background jobs are still present in the workspace — the broker outlives this session until the last background worker is done with it. Adds a runtime test that mixes a background and a foreground job under the same sessionId, drives SessionEnd, and asserts the foreground worker is killed + its state pruned while the background worker stays alive and remains in state.
A detached task worker that dies without throwing (unhandled rejection, OOM kill, native crash) never reaches runTrackedJob's catch, so its job file stays status:"running" forever and /codex:status reports a ghost job indefinitely. Two complementary guards: - registerWorkerCrashGuard (worker side): installed in handleTaskWorker before runTrackedJob; marks the job failed on uncaughtException, unhandledRejection, SIGTERM, SIGINT, or SIGHUP, then exits. - reapDeadJobs (reader side): wraps every listJobs call in job-control so status/result/cancel probe each active job's recorded pid with process.kill(pid, 0); ESRCH means the worker is gone and the job is rewritten as failed with a resume hint. The job file is re-read first so a job that finished between the read and the probe keeps its real result, and EPERM (alive but not ours) is treated as alive. Test run: node --test — 5 new tests pass; the 4 pre-existing failures on macOS (tmpdir symlink) are unchanged from main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
markJobDead built the returned record by spreading base, which kept the old updatedAt. upsertJob wrote a fresh updatedAt only to the state index, so the in-memory record (and the per-job file) still carried the stale timestamp. Callers sort the reaped list with sortJobsNewestFirst (keyed on updatedAt), so a ghost job with more than a page of newer completed jobs ahead of it could be paged out of the first /codex:status report — the user would see no failed job or resume hint until running status again. Set updatedAt: completedAt on the record so the first reader reflects the failure it just recorded. Adds a regression test. Addresses review feedback from @chatgpt-codex-connector and @rajpratham1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/codex:cancel delivers SIGTERM (via terminateProcessTree) and then writes the job "cancelled". With the crash guard catching SIGTERM, the worker could process that same signal after the cancel command wrote its update and rewrite the job back to "failed", so a normal cancel raced into a failed status/result instead of cancelled. Drop SIGTERM/SIGINT/SIGHUP from registerWorkerCrashGuard. The guard now only handles in-process crashes (uncaughtException / unhandledRejection), where a precise error is available and no other command is writing the job. Every signal/kill death is left to the reader-side reapDeadJobs: SIGKILL is uncatchable so it must live there regardless, and reapDeadJobs never rewrites a job that already reached a terminal status, so an intentional cancel is preserved. Adds a regression test that a SIGTERMed guarded worker leaves a cancelled job untouched. Addresses @chatgpt-codex-connector review on the reaped-job head. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
reapDeadJobs was only applied at the job-control readers (status/result/
cancel). The task-resume paths read listJobs directly, so a crashed
background task with a dead pid stayed "running" for them:
resolveLatestTrackedTaskThread tripped its active-task guard (task
--resume-last failed with "still running") and handleTaskResumeCandidate
found no candidate, because findLatestResumableTaskJob skips running
jobs. So the resume hint the reaper itself prints ("resume with task
--resume-last") did not work until a separate status/result/cancel
command reaped the job first.
Wrap the two resume readers, plus the stop-review-gate hooks running-task
note (same class: it read job status without reaping and would nag that a
crashed ghost was still running at session stop). All listJobs readers
now go through reapDeadJobs. Adds an end-to-end regression test that
task-resume-candidate reaps a dead-pid running task into a resumable
failed candidate.
Addresses @chatgpt-codex-connector review on the rebased head.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`task --await` enqueues the same tracked background job `--background` does and then waits for it with the polling `status --wait` uses: terminal status prints exactly what `result <id>` prints (exit 0 completed, 1 failed/cancelled), and the default 540000ms timeout prints a `result <id> --wait` hint and exits 3, leaving the detached worker running. `--prompt-stdin` takes stdin verbatim as the prompt (one trailing newline removed, no other trimming), so it cannot be combined with --args-stdin, --prompt-file or positional text; the conflict is decided on the raw argv before anything reads stdin. `result <id> --wait [--timeout-ms <ms>]` waits under the same contract, and a plain `result <id>` on a still-active job now prints that hint and exits 3 instead of failing with "No job found" (openai#498/openai#524): resolveResultJob filtered by terminal status before matching the reference, which made its own "still running" branch unreachable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l caveat; await/json timeout rows The FAKE_CODEX_TURN_DELAY_MS branch went through emitTurnCompletedLater, an unregistered setTimeout that turn/interrupt could not clear, so a cancelled turn still completed and runTrackedJob overwrote `cancelled` with `completed`. Every held-open turn now shares the interruptible-slow-task body. The same test also cancelled while the job could still be `queued`, where the record has no pid and handleCancel terminates nothing; it now waits for a running job that owns its pid before cancelling. Documents the worker-survival caveat in the README and the codex-cli-runtime skill, and covers the --json timeout shape (resumeCommand + active job status), `--await-timeout-ms 1e400`, and model-alias resolution on the awaited path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re the background split; Bash(node:*) grant restored Collapse the two-step Bash flow (mktemp/cat/JOB-variable/status-poll-loop) in /codex:rescue and the codex-rescue agent to one node call using task --await --prompt-stdin. The resume decision (task-resume-candidate + one AskUserQuestion) now runs before the sync/background split, so the agent (no AskUserQuestion tool) always receives an explicit --resume-last or --fresh instead of guessing from prose. allowed-tools on rescue.md is narrowed back to Bash(node:*). SKILL.md execution rules rewritten to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry wording; restore non-zero stderr rule 1. agents/codex-rescue.md granted itself a bare `status`/`result` permission that contradicted SKILL.md's "only the printed Re-run line" rule. Deleted the stray sentence; the agent now defers to the same single follow-up call the skill documents. 2. `result <id> --wait` always exits 0 on any terminal record (completed, failed, or cancelled) — it never exits 1; that's retrieval semantics (ruling F2), not a success signal. rescue.md, the agent, and SKILL.md all said "run ... until it exits 0 or 1", which is unreachable/wrong. Fixed the wording in all three: retry until exit 0, then read the record's content to know whether it succeeded. 3. Restored the catch-all "if any Bash step exits non-zero, show its stderr — never report 'no result'" sentence in rescue.md, dropped from the old two-step body; it's what covers the separate `task-resume-candidate --json` call, which isn't part of the payload block's own exit-code handling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # plugins/codex/scripts/codex-companion.mjs
…eaper (openai#425, adapted), bounded turns; broker teardown regression tests SessionEnd (openai#355 merged semantically): cleanupSessionJobs runs first, so this session's foreground jobs are terminated and its background jobs (and their records) survive; only then does an active background job in the workspace skip the broker shutdown. The final clearBrokerSession is endpoint-guarded like the broker's own clearOwnSessionRecord, so a replacement broker that recorded itself during the old one's shutdown keeps its record. The check spans the whole workspace on purpose (the broker is per-workspace); with CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0 a broker kept alive for a background job never exits on its own. Reaper (openai#425 merged, adapted to the fork): the queued record is written before the spawn, so updateJobPid patches in the worker pid right after it — only the pid, and only while the job is still queued, so it cannot rewind the worker's own `running`. A queued job that still has no pid 30s after it was created counts as a worker that died before taking the record over. Every terminal reap deletes the private jobs/<id>.request.json and clears requestFile; the reaped record and the state index never carry the unredacted payload. The message is now "worker exited before completing" (the merged test's regex follows it). Cancel releases the private payload too: now that a queued job carries a pid, a cancel can kill the worker before it consumed jobs/<id>.request.json, and a cancelled job is terminal, so the reaper would never reclaim that file. Bounded turns (implemented here, not merged from openai#376): captureTurn takes a client-side budget from --turn-timeout-ms (task/review/adversarial-review, persisted into the background worker's request) or CODEX_TURN_TIMEOUT_MS, unset = unbounded, so the stop-review gate's own 13-minute limit is unaffected. On expiry it interrupts the running turn and resolves it as a failed turn with "turn timed out after <ms> ms" and any partial output — never an exception that only reaches stderr. TurnStartParams has no timeout field, so nothing new rides on the RPC. runTrackedJob now records errorMessage for a run that fails without throwing. Tests: dead queued worker before consume, dead running worker, live worker and payload untouched, cancelled job, no secret in state.json after a reap, both updateJobPid rules, four bounded-turn cases, and four broker teardown regressions (owned live broker torn down, recycled pid never signalled, background job keeps the broker until it idle-exits, replacement broker keeps its record). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound check; clarify updateJobPid window hasActiveBackgroundJobs read the raw state index, so a worker killed outright (SIGKILL, OOM) — which never writes a terminal status — kept every later SessionEnd in that workspace on the early-return path: the broker and its app-server child lingered and the dead job's 0600 payload stayed on disk until someone happened to run /codex:status. It now reaps first, which both frees the broker and releases the payload. updateJobPid's comment claimed the race was closed; it is only narrowed. The job file is still rewritten from a record read a moment earlier, so a worker that flips to running inside that window can have its job file rewound. The comment now says so, and names what keeps it harmless: the status guard, the pid-only index patch (listJobs, which the reaper and cancel read, can never be rewound) and the worker's own completion write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ILL helper; result --timeout-ms guard; hint cleanup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…is active; persist background flag for review jobs SessionEnd counted only `background: true` jobs when deciding whether the shared per-workspace broker could go away, so another Claude session's foreground job — which survives this session's own-jobs-only cleanup — had its runtime torn out from under it. The check now runs over the state that cleanup left behind and blocks the shutdown on any `queued`/`running` job in the workspace, from any session and of any kind (renamed to `hasActiveWorkspaceJobs`). `review`/`adversarial-review` parsed `--background` and dropped it, so a review dispatched to outlive its session lost its record at SessionEnd. The flag is now persisted on the job record like it is for tasks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t unacknowledged interrupts; close a direct app-server on timeout
A `turn/interrupt` that answers proves nothing: a wedged app-server keeps
running the turn. The timeout wrote a synthetic `failed` right after the RPC
(or skipped the interrupt entirely when there was no turnId), so a timed-out
`--write` turn could keep editing files behind a job that already said failed,
and a resume could land on a thread whose turn was still live.
The timeout now waits up to TURN_INTERRUPT_ACK_MS (10 s) for the turn's own
terminal notification. Acknowledged, the result is unchanged ("turn timed out
after N ms", partial output kept). Unacknowledged, the failure says so and
tells the user to check status or cancel, and a client that owns its
app-server (non-broker transport) closes it so the runaway turn dies with the
process.
Fixture knob FAKE_CODEX_IGNORE_INTERRUPT=1 answers the interrupt and keeps the
turn going, and records the client's disconnect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redaction classified secrets by key name, so an override that carries a real credential without matching /key|token|secret|auth|password/i — e.g. `--config model_providers.x.http_headers.Cookie=SESSION` — was copied verbatim into state.json, the job file, `status --json` and `result --json`, i.e. into the Claude transcript and long-lived state. Job records now keep the config keys and redact every value. The unredacted request exists only in the 0600 `jobs/<id>.request.json` until the worker consumes it (or cancel/reap releases it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README stated `task --await` as 0/1/3 and then claimed on the next line that `result` and `task --await` both exit 0 for any terminal record — the two statements contradict each other, and only the first matches the code. `result` now has its own paragraph (0 for any terminal record, 3 while the job is active) with a documentation assertion that keeps them apart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iting the job file updateJobPid read the queued job file and wrote it back whole. A worker that reached `running` — or `completed` — between that read and that write had its record replaced by the stale queued snapshot, losing `result`, `threadId` and `turnId` while the state index could already be terminal: `result` then reported a completion it could not show. The parent no longer touches the job file after the spawn. The pid goes into an atomic `jobs/<id>.pid` sidecar (write + rename) plus the pid-only index patch, and every reader that needs a pid — cancel, the reaper, SessionEnd cleanup — resolves it with `resolveJobPid` (record pid first, sidecar during the queued window, never for a terminal job, whose sidecar may name a recycled pid). The sidecar is released with the job's terminal write, reap, cancel or prune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e no longer blocked by phantom jobs A worker that died between its terminal writeJobFile and its upsertJob leaves a terminal job file behind an active index entry. markJobDead returned that file to the current caller and left state.json alone, so the raw listJobs() behind assertThreadIsFree kept seeing a running job on that thread and refused every later resume — permanently. markJobDead now upserts the terminal fields from the file into the index before returning, and assertThreadIsFree reads the reaped list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs never see torn JSON A reader that caught `state.json` mid-`writeFileSync` parsed a truncated — usually zero-byte — file, and `loadState` answers a parse failure with an empty job list. That read is indistinguishable from an idle workspace, so a SessionEnd landing in the window shut the shared broker down under a live job (observed once as a flaky broker-teardown test); the reaper and cancel paths could miss active jobs the same way. saveState, writeJobFile and writeJobRequestFile now go through one writeFileAtomic helper (sibling temp file + rename, the pattern the pid sidecar already used, which now shares it). The request payload's 0600 mode rides through the rename, so it is never briefly world-readable. Test: a child process rewrites a ~100 KB state.json for 2 s while the test reads it as fast as it can; every read must parse and hold all 50 jobs. It failed on the first read with a 0-byte file before this change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-if-idle) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only from the locked snapshot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eness Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bounded direct close with TERM→KILL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oundary Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ks go stale after 2 s; rename-then-delete takeover Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e handshake; unknown → skip teardown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…release; never steal a live holder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… before redacting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ming Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st payloads on terminal paths Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…if-idle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s); drop tombstone takeover Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ished Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… closed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s cc-plugin-codex) GitHub Release per tag with the npm-pack tarball + SHA-256 attached; the release-verify workflow re-runs the gate on the published tag. No npm publish (private package, installed through the marketplace manifest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e it ignore SIGTERM Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he worst case Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v1.2.0
46 commits on
release/v1.2.0(plan:docs/superpowers/plans/2026-08-28-codex-plugin-cc-v1.2.0.md).Highlights
task --await [--await-timeout-ms],--prompt-stdin,result <id> [--wait --timeout-ms]— rescue/agent bodies are a singlenode … task --await --prompt-stdin <<'CODEX_PROMPT_…'call;allowed-toolsback toBash(node:*); exit codes 0/1/3 documented.broker/shutdownwhile another client is connected; idle self-terminate (fix(broker): self-terminate on idle to reap orphaned shared brokers (#450) openai/codex-plugin-cc#457) kept.--turn-timeout-ms,CODEX_TURN_TIMEOUT_MS): interrupt → wait for terminal notification → structuredfailed; referenced ack timer; terminal record on transport exit; idempotent boundedclose()(TERM → KILL).state.lock.d/; abandonment: dead PID → now, unreadable → 2 s, unusable PID → 30 s, live → never; fails closed on listing errors; 5 s bound naming the blocker); atomic writes; pid sidecar; file-first reaper reconcile; dead workers →failedwith payload removal.--configvalues redacted in job records and every output path; raw request only in a 0600 payload file; legacy v1.1.1 records migrated (queued jobs get a payload file before redaction).Review trail
Codex adversarial review ×11 + Claude (opus) review ×8 over 11 fix waves; ledger and all review reports in
.superpowers/sdd/2026-08-28-codex-plugin-cc-v1.2.0/(not committed). Final gate: 230/230, 0 leaked processes,npm run build,check-version,claude plugin validate . --strict.CHANGELOG
1.2.0 — 2026-08-28
Merged from upstream pull requests
SessionEndnow terminates only the ending session's own foreground jobs; a workspace with any active job keeps its shared broker alive acrossSessionEndinstead of tearing it down out from under the worker, and the hook only clears its own broker-session record when the endpoint still matches (a replacement broker's record survives).queued/runningjobfailed("worker exited before completing") once its worker process is confirmed dead (kill(pid, 0)reportsESRCH), deletes the job's private one-shot request payload, and clearsrequestFile; aqueuedjob with no recorded PID yet gets a 30 s grace window before it is reaped, covering a worker that died between spawn and the PID being recorded.Fork changes
task --await [--await-timeout-ms <ms>]launches the same tracked job record as--background, then waits for it: exit 0 when the job completed, 1 when it failed or was cancelled, 3 when the default 540000 ms wait elapses while the job is still queued or running (prints aRe-run: node "<abs>" result <id> --wait --timeout-ms 540000hint).--prompt-stdintakes the prompt as raw stdin, stripping exactly one trailing newline and nothing else; the mutual exclusion against--args-stdin,--prompt-file, and a positional prompt is checked on the raw argv before any stdin is read, so a bad combination fails fast instead of blocking on stdin.result <id> [--wait [--timeout-ms <ms>]]exits 0 for any terminal job record — completed, failed, or cancelled alike, since this is retrieval, not a pass/fail signal — and exits 3 with the same resumable hint when the job is still active and--waitwas not given;--jsonreturns{ job, storedJob }.result <id>on a queued/running job reports "No job found" — the still-running message is unreachable when a reference is given openai/codex-plugin-cc#498/codex-companion: workspace-keyed job registry returns 'No job found' for completed jobs when cwd drifts into a git repo openai/codex-plugin-cc#524:result <id>on a still-queued/runningjob used to throw "No job found for<id>"; it now reports the job's real status plus theresult … --waithint, exit 3.--await-timeout-msandresult --timeout-msare validated as finite positive integers (rejects0, negatives, fractional, and non-numeric values).--await-timeout-msbelow the host's own timeout.updateJobPid), so acancelissued while a job is stillqueuedhas a process to signal;cancelalso releases the job's private one-shot request payload itself, since a cancelled job is terminal and the reaper never revisits it. The parent never rewrites the job JSON after the spawn — that read-modify-write could put a queued snapshot over a record the worker had already completed, losing itsresult,threadIdandturnIdwhile the state index already said terminal. The PID goes into an atomicjobs/<id>.pidsidecar (write + rename) plus the existing pid-only index patch; readers (cancel, the reaper,SessionEndcleanup) takejob.pid ?? sidecarand never read a sidecar for a terminal job, and the sidecar is deleted with the job's terminal write, reap, cancel or prune.SessionEndnow reaps dead background workers before deciding whether an active background job should keep the broker alive, so a hard-killed (e.g. OOM/SIGKILL) worker can no longer pin a broker and its job's private payload alive indefinitely./codex:rescueand thecodex-rescueagent are now a singlenode … task --await --prompt-stdin <flags> <<'CODEX_PROMPT_<random>'call each, replacing the old two-Bash-callmktemp/trap/status --wait-poll-loop dance; the per-call random delimiter suffix now only has one heredoc to protect (the request prose) since flags travel on the command line and there is no second--args-stdinheredoc anymore; on exit 3 the only allowed follow-up is re-running the printedRe-run:hint for that same job.task-resume-candidate --json+ oneAskUserQuestion) is now made once, before the synchronous/--backgroundsplit, so both paths get the same explicit--resume-last/--freshflag; a--backgroundhandoff never resumes silently — the agent runs fresh unless it is handed an explicit--resume-lastfrom that shared decision.rescue.md'sallowed-toolsis back toBash(node:*), AskUserQuestion, Agent(widened to bareBashin 1.1.0 only to support the removed two-Bash-call flow).--turn-timeout-ms <ms>(orCODEX_TURN_TIMEOUT_MS) ontask,review, andadversarial-reviewbounds a single Codex turn: on expiry it sendsturn/interruptand returns a structured failed result ("turn timed out after<ms>ms") instead of hanging or throwing; default is0(unbounded, unchanged behavior); the budget is persisted into--background/--awaitjob requests so detached workers run under the same limit; the timeout now waits up to 10 s for the turn's terminal notification afterturn/interruptinstead of declaring the turn dead the moment the RPC answers, and an unacknowledged interrupt is reported as such ("interrupt not acknowledged — the turn may still be running in the shared runtime, check status or cancel") — on a non-broker transport the app-server it owns is closed so the runaway turn dies with the process.CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0, a broker kept alive by an active background job acrossSessionEndnever exits on its own — the idle self-terminate safety net from fix(broker): self-terminate on idle to reap orphaned shared brokers (#450) openai/codex-plugin-cc#457 is disabled in that configuration.kill(pid, 0)reads a zombie process as alive and cannot detect PID reuse, so the reaper can occasionally misjudge a dead worker's liveness (documented inlib/tracked-jobs.mjs); aturn/startcall that never answers is still unbounded —--turn-timeout-msonly covers the window afterturn/startresolves.task --awaitjobs are recorded as background jobs (they survive SessionEnd and keep the broker while active, bounded by the reaper and the idle timeout).queuedrecords, and every terminal write releases the job's private payload. A worker consumes its request beforerunTrackedJobflips the record torunning, so staging a payload for a running legacy job would have written plaintext--configvalues that nothing reads and nothing deletes;runTrackedJobnow drops the payload file alongside the PID sidecar on both its terminal paths, so a payload the worker never consumed cannot outlive the job either.jobs/<id>.request.jsonbefore the record loses it, for jobs that are stillqueued. 1.1.1 wrote no private payload, so the record was the only copy — and it is exactly what a worker falls back to, which would have started Codex with"[redacted]"in place of real--configvalues (auth headers included). No record is left unredacted on disk any more.--configvalues are stripped at the read boundary everystatus/resultoutput crosses (loadStateand the job-file reader), and the same read rewrites the index and the job file once so the values stop living in long-lived state. Redaction now has a single implementation,redactConfigValuesinlib/state.mjs.--configvalues any more: it keeps the keys and writes every value as[redacted], because classifying secrets by key name (/key|token|secret|auth|password/i) missed real credentials such as ahttp_headers.Cookieoverride, which then reachedstate.json, the job file,status --jsonandresult --json. The real values still reach Codex — they live only in the private 0600jobs/<id>.request.jsonthe worker consumes.SessionEndno longer shuts the shared broker down while any job in the workspace is stillqueued/running— the check used to count only jobs flaggedbackground: true, so another Claude session's foreground run (which survives this session's own-jobs-only cleanup) had its runtime pulled out from under it. The check runs on the state left behind by that cleanup and, as before, reaps dead workers first so a killed worker cannot pin the broker.state.json, job files and the private request payload are now written atomically (sibling temp file +rename). A reader that caught a plainwriteFileSyncmid-flight parsed a truncated — often zero-byte — file, and sinceloadStateanswers a parse failure with an empty job list, that read looked exactly like an idle workspace: aSessionEndlanding in that window could shut the shared broker down under a live job (and the reaper/cancelpaths could miss active jobs the same way).writeJobFileand itsupsertJobleft an active index entry thatassertThreadIsFree(which now also reads the reaped list) saw as a phantom running job, blocking every later resume of that thread.close()on a direct app-server is idempotent as well as bounded: the deadline used to apply to the first call only, and every later call awaited process exit with no bound at all. The turn timeout closes twice by design (once to kill the runaway turn, once on the way out of the run), so the single case the deadline exists for was the one that still hung.turn/interruptis a referenced timer raced against the transport's own exit, so an app-server that answers the interrupt and dies can no longer let the companion exit with the job stillrunning(and no longer costs the full 10 s wait), and closing a directly owned app-server escalates stdin EOF →SIGTERM→SIGKILLwith a hard 5 s deadline instead of awaiting process exit forever.kill(pid, 0)reads a zombie as alive and cannot see a recycled PID, so a job whose file was already terminal could keep a phantomrunningindex entry — blocking resume on its thread and keepingSessionEndfrom ever releasing the broker — for as long as anything held that PID. Liveness is now only consulted for jobs whose own file still says they are active.review/adversarial-reviewnow persist--backgroundon the job record, so a review dispatched to outlive its session (nohup … --background &) keeps its record — and with it/codex:statusand/codex:result— after that session ends.CodexAppServerClientconnects before it writesinitialize, so a shutdown landing in that gap killed the broker under it. The readiness probe now closes its connection fully before reporting the broker up (an open probe would otherwise read as a phantom client), and a client whose broker connection is dropped beforeinitializeis answered falls back to its own app-server instead of failing the run.broker/shutdownhandshake is framed by newline and matched by request id, and it is bounded (5 s). A socket carries bytes, not messages, so a{"busy":true}reply split across twodataevents used to fail to parse and be read as "not busy" — tearing down a broker in the middle of another session's turn — while a peer that connected and stayed silent blockedSessionEndforever. An unanswered or unparsable handshake is now reported as unknown, andSessionEndtreats anything but a confirmed "not busy" as a reason to leave the broker (and its record, pid file and endpoint) alone.broker/shutdownwhile any other client is connected (replying{ "busy": true }and continuing to serve), andSessionEndtreats that refusal as "leave everything alone". The hook's active-job check is only a snapshot: another session could enqueue a job and connect in the gap before the shutdown RPC, and the broker used to shut down regardless, killing that turn. A broker kept alive this way is reaped by its own idle timeout — except withCODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0, where a client that stays connected keeps the broker alive until the operator stops it.state.lock.d/): an acquirer createschoosing.<token>, takes a number one above the highest ticket on display, creates<n>.<token>.ticket, drops itschoosingfile, and holds the lock once no foreignchoosingfile remains and no ticket sorts before its own (by number, ties by token). Releasing is oneunlinkof its own ticket. Nothing shared is ever replaced or removed: every name is unique to one acquisition and every file's content is immutable, so a verdict about a file cannot go stale before it is acted on — which is what every previous design (mkdir + holder file, rename-aside takeover, tombstone fences) could not guarantee, since POSIX has no conditional replace. Only entries whose owner is provably gone are cleared, on the unchanged contract: dead PID → at once; unreadable entry → after 2 s; unusable PID → after 30 s; live PID → never, with the wait error naming that PID and its exact ticket file. A pre-1.2.0state.lockdirectory is left untouched.state.jsonnow runs under a cross-process workspace lock (the ticket lock above; 25 ms polling and a 5 s bound). Atomic writes only stopped torn reads: two processes could still read the same file, and the one that wrote last would treat every job the other had added as deleted — pruning that job's file, private payload, PID sidecar and log.updateState,upsertJob,updateJobPid,saveState(whose prune is a diff of the snapshot it read), the reaper's reconciliation,canceland theSessionEndcleanup all re-read inside the lock; readers (status,result,listJobs) stay lock-free.🤖 Generated with Claude Code