Skip to content

Release v1.2.0 - #3

Merged
CBEPX merged 53 commits into
mainfrom
release/v1.2.0
Aug 28, 2026
Merged

Release v1.2.0#3
CBEPX merged 53 commits into
mainfrom
release/v1.2.0

Conversation

@CBEPX

@CBEPX CBEPX commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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 single node … task --await --prompt-stdin <<'CODEX_PROMPT_…' call; allowed-tools back to Bash(node:*); exit codes 0/1/3 documented.
  • Lifecycle: SessionEnd stops only its own foreground jobs, checks any active workspace job, asks the broker to shut down only if idle (newline-framed, id-matched, bounded handshake; unknown → skip); broker refuses broker/shutdown while 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.
  • Bounded turns (--turn-timeout-ms, CODEX_TURN_TIMEOUT_MS): interrupt → wait for terminal notification → structured failed; referenced ack timer; terminal record on transport exit; idempotent bounded close() (TERM → KILL).
  • State integrity: cross-process replace-free ticket lock (Lamport bakery on uniquely named immutable files under 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 → failed with payload removal.
  • Secrets: all --config values 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

  • fix: keep background jobs alive across SessionEnd openai/codex-plugin-cc#355 SessionEnd now terminates only the ending session's own foreground jobs; a workspace with any active job keeps its shared broker alive across SessionEnd instead 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).
  • fix: reap ghost jobs whose worker died without recording a result openai/codex-plugin-cc#425 a PID-liveness reaper marks a queued/running job failed ("worker exited before completing") once its worker process is confirmed dead (kill(pid, 0) reports ESRCH), deletes the job's private one-shot request payload, and clears requestFile; a queued job 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 a Re-run: node "<abs>" result <id> --wait --timeout-ms 540000 hint).
  • --prompt-stdin takes 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 --wait was not given; --json returns { job, storedJob }.
  • Fixed upstream 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/running job used to throw "No job found for <id>"; it now reports the job's real status plus the result … --wait hint, exit 3.
  • --await-timeout-ms and result --timeout-ms are validated as finite positive integers (rejects 0, negatives, fractional, and non-numeric values).
  • Worker-survival caveat: the detached worker outlives the companion only when the companion returns on its own via exit 3 — a host process-tree kill (e.g. Claude Code's Bash tool timeout) kills the worker too, so keep --await-timeout-ms below the host's own timeout.
  • The worker's PID is now recorded right after spawn (updateJobPid), so a cancel issued while a job is still queued has a process to signal; cancel also 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 its result, threadId and turnId while the state index already said terminal. The PID goes into an atomic jobs/<id>.pid sidecar (write + rename) plus the existing pid-only index patch; readers (cancel, the reaper, SessionEnd cleanup) take job.pid ?? sidecar and never read a sidecar for a terminal job, and the sidecar is deleted with the job's terminal write, reap, cancel or prune.
  • SessionEnd now 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:rescue and the codex-rescue agent are now a single node … task --await --prompt-stdin <flags> <<'CODEX_PROMPT_<random>' call each, replacing the old two-Bash-call mktemp/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-stdin heredoc anymore; on exit 3 the only allowed follow-up is re-running the printed Re-run: hint for that same job.
  • The resume decision (task-resume-candidate --json + one AskUserQuestion) is now made once, before the synchronous/--background split, so both paths get the same explicit --resume-last/--fresh flag; a --background handoff never resumes silently — the agent runs fresh unless it is handed an explicit --resume-last from that shared decision.
  • rescue.md's allowed-tools is back to Bash(node:*), AskUserQuestion, Agent (widened to bare Bash in 1.1.0 only to support the removed two-Bash-call flow).
  • --turn-timeout-ms <ms> (or CODEX_TURN_TIMEOUT_MS) on task, review, and adversarial-review bounds a single Codex turn: on expiry it sends turn/interrupt and returns a structured failed result ("turn timed out after <ms> ms") instead of hanging or throwing; default is 0 (unbounded, unchanged behavior); the budget is persisted into --background/--await job requests so detached workers run under the same limit; the timeout now waits up to 10 s for the turn's terminal notification after turn/interrupt instead 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.
  • Documented: with CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0, a broker kept alive by an active background job across SessionEnd never 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.
  • Known limitations: 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 in lib/tracked-jobs.mjs); a turn/start call that never answers is still unbounded — --turn-timeout-ms only covers the window after turn/start resolves.
  • task --await jobs are recorded as background jobs (they survive SessionEnd and keep the broker while active, bounded by the reaper and the idle timeout).
  • The legacy-request move is limited to queued records, and every terminal write releases the job's private payload. A worker consumes its request before runTrackedJob flips the record to running, so staging a payload for a running legacy job would have written plaintext --config values that nothing reads and nothing deletes; runTrackedJob now 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.
  • Migrating a 1.1.1 record moves its raw request into a fresh 0600 jobs/<id>.request.json before the record loses it, for jobs that are still queued. 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 --config values (auth headers included). No record is left unredacted on disk any more.
  • Records written by 1.1.1 are redacted too: --config values are stripped at the read boundary every status/result output crosses (loadState and 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, redactConfigValues in lib/state.mjs.
  • A tracked job record never stores --config values 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 a http_headers.Cookie override, which then reached state.json, the job file, status --json and result --json. The real values still reach Codex — they live only in the private 0600 jobs/<id>.request.json the worker consumes.
  • SessionEnd no longer shuts the shared broker down while any job in the workspace is still queued/running — the check used to count only jobs flagged background: 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 plain writeFileSync mid-flight parsed a truncated — often zero-byte — file, and since loadState answers a parse failure with an empty job list, that read looked exactly like an idle workspace: a SessionEnd landing in that window could shut the shared broker down under a live job (and the reaper/cancel paths could miss active jobs the same way).
  • The reaper now reconciles a terminal job file back into the state index instead of returning it to the current caller only: a worker that died between its terminal writeJobFile and its upsertJob left an active index entry that assertThreadIsFree (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.
  • A bounded turn now always ends in a terminal job record. The acknowledgement window after turn/interrupt is 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 still running (and no longer costs the full 10 s wait), and closing a directly owned app-server escalates stdin EOF → SIGTERMSIGKILL with a hard 5 s deadline instead of awaiting process exit forever.
  • Documented: partial output on a timed-out turn is best-effort — only items Codex had already completed are kept, so a turn interrupted mid-message reports less text than Codex produced.
  • The reaper reads the authoritative job file before it looks at PID liveness, for every active index entry: 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 phantom running index entry — blocking resume on its thread and keeping SessionEnd from 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-review now persist --background on the job record, so a review dispatched to outlive its session (nohup … --background &) keeps its record — and with it /codex:status and /codex:result — after that session ends.
  • Shutdown-if-idle counts every connected client, not only those that have already sent a message: a client is a client from the moment it is accepted, and CodexAppServerClient connects before it writes initialize, 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 before initialize is answered falls back to its own app-server instead of failing the run.
  • The broker/shutdown handshake 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 two data events 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 blocked SessionEnd forever. An unanswered or unparsable handshake is now reported as unknown, and SessionEnd treats anything but a confirmed "not busy" as a reason to leave the broker (and its record, pid file and endpoint) alone.
  • The broker itself now refuses broker/shutdown while any other client is connected (replying { "busy": true } and continuing to serve), and SessionEnd treats 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 with CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0, where a client that stays connected keeps the broker alive until the operator stops it.
  • The ticket lock fails closed: a directory listing that fails (permissions, I/O) aborts the acquisition instead of being read as an empty queue, the waiter takes only its own entries back out and the callback never runs; and the bounded wait is checked first on every pass, so a blocker that is judged abandoned but cannot be unlinked ends in the timeout error rather than a hot loop.
  • The workspace state lock is a Lamport bakery on files (state.lock.d/): an acquirer creates choosing.<token>, takes a number one above the highest ticket on display, creates <n>.<token>.ticket, drops its choosing file, and holds the lock once no foreign choosing file remains and no ticket sorts before its own (by number, ties by token). Releasing is one unlink of 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.0 state.lock directory is left untouched.
  • Every read-modify-write of state.json now 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, cancel and the SessionEnd cleanup all re-read inside the lock; readers (status, result, listJobs) stay lock-free.

🤖 Generated with Claude Code

jnkliberty and others added 30 commits May 28, 2026 12:50
`--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>
CBEPX and others added 23 commits August 28, 2026 09:54
…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>
@CBEPX
CBEPX merged commit 002a9b6 into main Aug 28, 2026
3 checks passed
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.

3 participants