Skip to content

Import three high-value upstream PRs, pin what they guarantee, and describe the fork - #7

Merged
Edo771977 merged 33 commits into
mainfrom
claude/focused-carson-khonz0
Sep 17, 2026
Merged

Edo771977 merged 33 commits into
mainfrom
claude/focused-carson-khonz0

Conversation

@Edo771977

@Edo771977 Edo771977 commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Second round of imports from openai/codex-plugin-cc, one merge commit each so any of them can be reverted on its own — plus the defects an adversarial review found in those merges, and a README that finally says what this fork does. The fourth candidate, openai#733, is deliberately not here (see below).

Imported

Commit Upstream PR What it fixes Conflicts
b4f788e #707 the broker never released its app-server thread subscriptions, so a client that disconnected left its threads subscribed for the broker's lifetime and their notifications reached whoever connected next 9
b59bdff #659 state written under one CLAUDE_PLUGIN_DATA root was invisible to an invocation that resolved to another, orphaning brokers and hiding jobs 9
c8aa8a8 #728 a job whose worker died kept reading as "running" forever, because nothing ever rewrote the record 9

Each merge commit records its own resolution. Three are worth calling out here.

openai#707 required rebuilding the message loop, not merging it. The PR rewrote the loop into handleLine() on top of upstream main, which has none of this fork's hardening (instance-token auth on broker/shutdown, the busy check, JSON-RPC shape validation, responseSocket, abandoned turns, completions racing a handoff) — and the auto-merge kept that copy as dead code beside the fork's loop. It is removed. Its serialization is kept and matters: with the fork's async (chunk) handler a second data event interleaves with an await inside the first, and the per-request ownership claims stop describing who holds which thread. broker serializes subscription requests from one downstream client fails exactly that way. So the fork's loop body is lifted into handleLine() and driven through the PR's processing chain.

It also surfaced a latent bug in tests/fake-codex-fixture.mjs: merging the two nextThread() signatures left four call sites passing the options object positionally, which made a forked or subagent thread silently read-only instead of inheriting its source's sandbox.

openai#659 keeps this fork's atomic writes. The PR's cross-root pruning shipped with a plain writeFileSync; here it goes through writeJsonFileAtomic() like every other state write. The fork's terminal-claim files get the same candidate-root treatment as job files through a new resolveJobClaimFileCandidates(), which the PR had no reason to know about.

openai#728 is grafted, not adopted. Where its session-end and cancel rewrites met this fork's, the fork's are kept (terminal claims, interrupt budget, straggler handling, record-first cancellation) and only its distinct insight is taken: a dead worker still gets its terminal record — that is what stops /codex:status answering "No job found" for the session that just ended — except when it exited after turn/start with a thread but no turn id, where nothing can interrupt that turn and recording it cancelled would claim an outcome that did not happen.

Not taken from openai#728: session end fully cleans up jobs for the ending session, which asserts the ending session's job files are deleted. This fork retains a terminal record so a later status query shows a cause, and has its own test pinning that.

openai#733 is not imported

Its central behavioral test, cancelled queued task is never reclaimed by a late-starting worker, already passes on this fork: the cancelled job is not reclaimed, Codex is never invoked, the record stays cancelled. The only difference is the worker's exit code (1 with Job … was cancelled before it started. rather than 0). Its other "behavioral" test asserts the shape of the source (trackedBody.indexOf("try {") < indexOf("writeJobFile")), so it fails on any fork whose code is arranged differently.

What would remain is a second cancellation mechanism — .cancelled and .removed marker files — beside the terminal claims this fork already uses, i.e. two sources of truth for the same decision, and .removed serves a session-end semantics this fork deliberately does not have.

Review: five rounds

An independent review was run over the merges and re-run after each round of fixes. The full account is in a comment; the fifth round's verdict is "nothing blocking remains — the PR is mergeable."

The first pass found eight defects, four of them in the conflict resolutions made here, including two that quietly undid the protections they were part of: the cancel refusing after taking the job's unreleasable terminal claim (so the next cancel turned that claim into a bogus cancelled record for the turn being protected), and the session-end guard reading the stale state.json snapshot instead of the job file the code re-reads ten lines below for that very purpose. The other four were in imported code: a cross-root prune running under the wrong lock, a stranded stopReviewGate: true outvoting an explicit disable forever, CLAUDE_ENV_FILE rewritten rather than appended (dropping other plugins' exports and the file's mode), and a staging-lease race that let one process delete a file another was reading.

Rounds two through four were the fixes' own defects — most of them around the record retained for a turn that cannot be interrupted, which in turn became reapable, then unbounded, then a permanent broker leak under CODEX_BROKER_IDLE_SHUTDOWN_MS=0. That last one was called blocking and is fixed; the window now falls back to the staleness bound, and an unreadable timestamp counts as expired.

Every fix carries a regression test that fails with only its own fix reverted, except the staging-lease race, which needs an interleaving between two processes and rests on the reasoning in its commit.

Tests asked for by the upstream reviews

Both reviews on the upstream PRs this fork had already patched ask for coverage; it belongs here too.

  • From #731 (184df24): the durable review-gate config is 0600 in a 0700 directory (reverting to the PR's plain writeFileSync fails it), and a replacement that fails mid-write leaves the previous config intact with no temporary file beside it.
  • From #737 (ed2d434), with every manager root populated at once: a managed toolchain beats a system install (restoring the pre-fix ordering fails it, along with the four upstream tests that caught the bug), and CODEX_COMPANION_NODE overrides every manager. Which manager wins is deliberately not pinned — the launcher reads no .nvmrc, .tool-versions or mise config, so asserting one would freeze an arbitrary order as a guarantee.

CI

916fcfc fixes the only failure CI reported, in tests/locking.test.mjs — a test neither this branch nor the imports touch. The assertion was the flaky part, not the behavior: acquireLock() checks its deadline only after a failed attempt, so on a loaded runner one slow attempt still returns successfully past the 500ms the test measured. Nothing is weakened by dropping that measurement — acquireLock() throws when its deadline passes, so a successful return under a 500ms budget with staleMs at 30s already proves the lock was reclaimed because its owner is gone. Verified by commenting out reclaimAbandonedLock(): the test fails again.

Docs and metadata

625c770 and 877e58b rewrite the landing page around what the plugin now guarantees rather than which PRs it carries:

  • a "What The Background Runtime Guarantees" section up front — state checked rather than trusted, session end never reporting an outcome that did not happen, one broker per workspace retired by the last session out, a finished job keeping its cause, nothing world-readable or half-written, Node found where it is installed;
  • /codex:status documents the two reconciled states a dead worker produces, /codex:result that a non-crashing failure keeps its error text, /codex:cancel its order of operations and the three outcomes that are not a plain cancellation;
  • a "Where State Lives" table (the two plugin-data roots, the durable config under CODEX_HOME, and that threads and auth stay with the Codex CLI), and the note that the broker idle window also bounds a retained orphan;
  • "Differences From Upstream" gains this round plus every defect the review found, and what is deliberately not imported.

The plugin, marketplace and package.json descriptions now say this is a fork of openai/codex-plugin-cc carrying open upstream fixes — until now /plugin listed it with upstream's own description. The version stays 1.0.6 (npm run check-version passes): these merges do not cut a release.

Testing

npm test: 312/312, green on repeated full runs. npx tsc -p tsconfig.app-server.json clean, CI green on the head.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg

mittalpk and others added 26 commits August 19, 2026 08:28
…LAUDE_PLUGIN_DATA root, orphaning brokers

resolveStateDir() picks the state root from CLAUDE_PLUGIN_DATA when
set, falling back to $TMPDIR/codex-companion when it's absent -- same
workspace slug/hash either way, only the root differs. State written
under one root (e.g. a broker registered while the var was unset)
becomes invisible to any later lookup that resolves to the other
root, since nothing checked both. For a broker specifically, that
means SessionEnd never finds it to shut down -- it's orphaned
permanently, and since ensureBrokerSession also can't see it, the
next session spawns a duplicate broker for the same workspace,
compounding the leak. The same mechanism affects job/status state
(state.json, individual job detail files), not just the broker.

Reads now check every candidate root (current primary, then the
tmpdir fallback), not just the current invocation's primary --
writes are unchanged, still going to the primary root. Applied to
loadState() (job list, status, config), readStoredJob() (individual
job detail lookups), and loadBrokerSession()/clearBrokerSession().

Known remaining asymmetry: this fixes the direction with concrete
evidence in the issue -- state written while CLAUDE_PLUGIN_DATA was
unset, later missed by a lookup that has it set. The reverse isn't
fixable this way: an unset env var carries no trace of what value it
previously held, so there's nothing to check beyond the always-known
tmpdir fallback.

Fixes openai#636
…turned

Both call sites (handleSessionEnd, ensureBrokerSession) act on
whatever loadBrokerSession() returns -- tearing that broker down and
clearing its record -- but clearBrokerSession deleted every candidate
root's broker.json, not just the one that was actually torn down.

That's reachable in practice: it's precisely the root-split bug's own
historical fallout, where the old lookup could leave a broker
registered under one root while a duplicate got spawned under the
other. Deleting both records on the next cleanup erases the untorn
broker's only metadata, making it permanently untrackable instead of
leaving a stale-but-discoverable file behind.

Thanks to Codex Review for catching this.
… with loadBrokerSession on malformed files

Two more issues from Codex Review, both real:

1. loadState() returned only the first candidate state.json found,
   not merged. Unlike a broker session (at most one meaningful
   record, so 'first found' is correct), jobs are a growing
   collection -- a job started while CLAUDE_PLUGIN_DATA was set and a
   different job started while it was unset are both real and
   non-conflicting. Returning only the first root's job list silently
   hid whichever root wasn't picked, for every status/result/cancel
   lookup, any time both roots happened to have a state.json. Now
   merges jobs from every candidate, keeping the more recently
   updated copy if the same id somehow appears in more than one.

2. loadBrokerSession() skips a candidate it can't parse and moves on,
   so it can return a fallback session while a malformed primary file
   exists. clearBrokerSession() selected by existence alone, so it
   could delete the unrelated malformed primary while leaving the
   valid fallback record behind -- the one actually loaded and torn
   down by the caller. Both functions now share a single
   selectBrokerState() helper (exists AND parses), so they always
   agree on which candidate is the selected one.

Verified the loadState() merge fix doesn't have a side effect on
saveState()'s own previousJobs cleanup diff (its per-job file removal
resolves paths against the current-root-only resolveJobFile(), so a
job living in another root is a no-op there, not a deletion) --
confirmed empirically with a throwaway repro before concluding no
further change was needed there.

Thanks again to Codex Review.
…t the primary

saveState() only ever wrote the new job list to the current primary
root. A job that originated entirely in a different root (e.g. added
while CLAUDE_PLUGIN_DATA was unset) and later gets filtered out --
cleanupSessionJobs() during SessionEnd loads the merged view, drops
jobs for the ending session, and saves the remainder -- never
actually disappeared: that other root's own state.json still held
its own untouched copy, and the very next loadState() merged it right
back in. A removed job could keep reporting as running indefinitely.

saveState() now also prunes every other candidate root's own file
down to the same retained job-id set (derived from this save's own
merged previousJobs diff), so a deletion sticks everywhere. New and
updated jobs are unaffected -- they still only ever get written to
the primary root, exactly as before; this only ever removes.

Also made the individual job-detail-file cleanup in the same loop
candidate-aware (resolveJobFileCandidates instead of the
primary-only resolveJobFile), for the same reason.

Two of the existing tests had to seed their two-root fixtures via
direct file writes instead of two independent saveState() calls --
every real caller (updateState()/cleanupSessionJobs()) always derives
its job list from a prior loadState(), so seeding via two disjoint,
non-full-list saveState() calls doesn't reflect any real call
pattern, and (correctly, now) tripped this very fix's own deletion
logic during test setup.

Thanks again to Codex Review.
…ross roots

cleanupSessionJobs() checked only resolveStateFile()'s (the primary
candidate's) existence before deciding whether to look for jobs to clean
up. loadState() is candidate-aware, but a session whose jobs live only in
the fallback root (e.g. started without CLAUDE_PLUGIN_DATA, with SessionEnd
later running with it set, flipping which root is primary) was silently
skipped: the early check saw no primary file and returned before
loadState() was ever called. Fixed by removing the redundant pre-check --
loadState() already returns an empty job list when nothing exists anywhere,
and the existing removedJobs.length === 0 check already short-circuits
correctly, without the primary-only blind spot.

loadState()'s config merge also only ever read the primary candidate's
config, unlike jobs (already merged across every root). A boolean flag
like stopReviewGate is an opt-in toward stricter/safer behavior, so any
candidate setting it true should win over a stale false elsewhere --
e.g. /codex:setup --enable-review-gate running without CLAUDE_PLUGIN_DATA
writes it to the fallback root, invisible to a later invocation whose
primary is the plugin-data root. Reconciling by "primary wins" could
silently downgrade an explicitly-enabled gate.

Both found via Codex Review on the PR.
A thread resumed while its automatic thread/unsubscribe was still in
flight was never released again: the later release reused the stale
pending request, so the shared app-server connection stayed subscribed
after every client had closed. If the app-server had processed the
unsubscribe after the resume, the new owner would also have lost its
subscription.

Never reuse a pending unsubscribe. A release chains a fresh request
behind any in-flight one and re-checks ownership before sending. A
resume or review that provisionally claims a thread waits, bounded to
five seconds, for an in-flight unsubscribe of that thread before it is
dispatched, so a late unsubscribe cannot overtake the new subscription
and a hung cleanup cannot wedge the broker. Reply to a failed request
and clear the busy state before releasing provisional owners for the
same reason.

Add the unsubscribe-delayed and resume-fails-unsubscribe-hangs fixture
behaviors, four regression tests, and assert the retry bound in the
existing failure test.
When the bounded wait for an in-flight thread/unsubscribe expires, do
not send the resume anyway: that would race the outstanding unsubscribe
and could leave a tracked owner without an upstream subscription.
Reject the request with a retryable error, release the provisional
claim, and clear the busy state so other clients continue.
A release that followed a timed-out claim installed a fresh cleanup
wrapper that waited for the original unsubscribe for only another five
seconds, then settled as skipped once a retried claim owned the thread.
The retry could then resume upstream while the original unsubscribe was
still in flight. Make a wrapper wait for its predecessor without a
bound so the pending entry never settles before every underlying
request has, and reject retried claims until then.
An explicit thread/unsubscribe for an unowned thread queued behind a
hung automatic unsubscribe without a bound while its socket held the
busy slot, so every other client stayed busy. Apply the same bounded
wait and retryable rejection to explicit unsubscribes that the
provisional claims already use.

Two related gaps closed in the same sweep:

- A release that arrives while an earlier cleanup is still queued now
  shares that queued request instead of adding another link to the
  chain. A queued request re-checks ownership when it sends, so this
  is safe, and repeated retries against a hung upstream no longer grow
  an unbounded chain of duplicate unsubscribes.
- A child thread whose cleanup is still outstanding is no longer handed
  to new owners of its parent by a later notification.
A child notification that arrived while a provisional resume or review
was waiting or in flight attributed the child to the claiming socket,
and a rejected or failed claim released only the parent. The child then
stayed owned until the socket closed. Record children inherited through
an open claim and release them together with the claim when it is
rejected or fails.
…claim

A grandchild spawned while a provisional claim was open has an
inherited child as its source, not the claimed root, so it was owned
but omitted from the rollback. Record descendants whose source is
either a claimed root or an already inherited thread.
…eaves

When the final owner sends thread/unsubscribe and disconnects before the
upstream request fails, nothing retried the cleanup: the owner was
already removed, so the close path had nothing to release, and the
explicit path restored ownership only to a still-open socket. Schedule
the bounded automatic retry in that case.

Test fixture: write state atomically so a test never reads a partial
document, and add a delayed single-failure unsubscribe behavior for the
new regression test.
Upstream PR openai#707 (thossullivan), nine conflicts resolved.

The broker holds one upstream app-server connection and subscribes it to every
thread its clients touch, but nothing released those subscriptions: a client
that disconnected left its threads subscribed for the broker's whole lifetime,
and notifications for them kept arriving for whoever connected next. Downstream
ownership is now mirrored per socket, so a thread is unsubscribed upstream once
its last owner goes away, with retries, and one client can no longer release a
thread another client still holds.

The two sides had restructured the same message loop for different reasons, so
this is not a textual merge:

- The PR rewrote the loop into handleLine() on top of upstream main, which has
  none of this fork's hardening (instance-token auth on broker/shutdown, the
  busy check, JSON-RPC shape validation, responseSocket, abandoned turns,
  completions racing a handoff). The auto-merge kept that copy as dead code
  beside the fork's loop. It is removed; the fork's loop is the one that runs.
- The PR's serialization is kept, and matters: with the fork's `async (chunk)`
  handler a second data event interleaves with an await inside the first, and
  the per-request ownership claims stop describing who holds which thread —
  "broker serializes subscription requests from one downstream client" fails
  exactly that way. The fork's loop body is therefore lifted into handleLine()
  (`continue` becomes `return`) and driven through the PR's `processing` chain.
- In the request path, the PR's provisional claims, bounded wait on an
  in-flight unsubscribe, thread/unsubscribe routing and claim rollback are
  grafted into the fork's path, which keeps its streaming handoff, abandoned
  turns and idle-shutdown re-arming. Both actions now run on a closing socket:
  releaseThreadOwners() and armIdleShutdown().
- runShutdown() clears the retry timers, which would otherwise fire against a
  closed app-server.

tests/broker-subscriptions.test.mjs spawned the broker without
--instance-token, which this fork refuses to start without; the harness now
passes one. In tests/fake-codex-fixture.mjs the two nextThread() signatures are
merged (`sandbox` plus forkedFromId/parentThreadId), and the four call sites
that passed the options object positionally are corrected — a fork or subagent
thread now inherits its source's sandbox instead of silently becoming
read-only.

Verified: node --check on the broker and the fixture, plus a syntax check of
the generated fake codex (a template string, so node --check on the fixture
does not cover it — the first resolution left an unbalanced brace there that
only showed up at runtime); tests/broker-subscriptions.test.mjs 22/22; full
npm test 276/276 twice; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Upstream PR openai#659 (mittalpk), nine conflicts resolved.

CLAUDE_PLUGIN_DATA is only set when the code runs as a plugin hook: a direct
CLI call, or a hook whose environment did not carry it, resolves the state
root to the tmpdir fallback instead. Two invocations for the same workspace
could therefore land on different roots, and SessionEnd or /codex:status would
look in one while the jobs and the broker record lived in the other — leaving
brokers orphaned and jobs invisible. Writes still go to the primary root; reads
now check every candidate, and deletions prune the others so a removal cannot
be merged back in by the next read.

Resolutions:

- state.mjs: the PR's candidate machinery on top of this fork's file. Its
  workspaceStateDirName() is this fork's resolveWorkspaceKey(), kept under one
  name; resolveConfigFile() (from openai#731) keeps using it. saveState() keeps the
  fork's atomic writes — the PR's cross-root pruning writes through
  writeJsonFileAtomic() too, rather than the plain writeFileSync it shipped
  with — and the fork's terminal-claim files get the same candidate treatment
  as job files through a new resolveJobClaimFileCandidates(), which the PR had
  no reason to know about.
- broker-lifecycle.mjs: the PR's selectBrokerState() is adopted whole, since
  loadBrokerSession() and clearBrokerSession() must agree on which candidate is
  "the" record; the fork's import block and everything else stay.
- session-lifecycle-hook.mjs: the primary-root existsSync() guard gives way to
  the candidate-aware loadState() check, keeping the fork's imports and its
  sessionJobs flow.
- tests: the fork's broker-lifecycle and state suites with the PR's cases
  appended. Both conflicts cut through the fork's last test (the closing `});`
  was the line shared after the marker), and the PR's import header was
  dropped with the rest of its file, so clearBrokerSession and readStoredJob
  are added to the fork's imports.

Verified: node --check on every touched script; tests/state.test.mjs and
tests/broker-lifecycle.test.mjs 47/47; full npm test 286/286 twice; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
Upstream PR openai#728 (fscfede-beep), nine conflicts resolved.

A record says "running" for as long as nobody rewrites it, so a worker killed
between turn/start and its terminal write left /codex:status reporting a job
that no process was running. reconcileJobLiveness() checks the recorded pid
before reporting: a worker that is gone with no thread reads as
terminated-unknown, and one that is gone while its Codex turn may still be
running stays active with a phase that says so. Every status, result and
selection path now goes through it.

Where the PR's session-end and cancel rewrites met this fork's, the fork's are
kept — they are the later, more careful versions (terminal claims, interrupt
budget, straggler handling, record-first cancellation) — and only the PR's
distinct insight is grafted in:

- Session end: reconciliation decides one thing, not the whole flow. A dead
  worker still gets its terminal record, which is what stops /codex:status
  from answering "No job found" for the session that just ended (and what this
  fork's own "keeps a terminal record for the ending session's dead worker"
  test asserts). Only when the worker exited after turn/start with a thread but
  no turn id is the job retained as active instead: nothing can interrupt that
  turn, so recording it cancelled would claim an outcome that did not happen.
- Cancel: the same refusal has to fire before the record-first write, not after
  it, or the job is already marked cancelled when it raises. It also cannot
  wait on the turn-identity helper — the worker that would publish the id is
  already gone.
- resolveCancelableJob(): an explicitly named job is resolved against the
  stored record when reconciliation has moved it out of the active set, but
  only when a detail file exists on disk. That keeps `/codex:cancel <id>`
  repairing a stale index (this fork's behavior, three tests) while the PR's
  own "no detail file, nothing to cancel" case still refuses.

Not taken: "session end fully cleans up jobs for the ending session", which
asserts the ending session's job files are deleted. This fork deliberately
retains a terminal record and its files so a later status query shows a cause
instead of "No job found"; the test contradicts that design and the fork test
that pins it.

tests/runtime.test.mjs is rebuilt rather than merged hunk by hunk: the two
sides had added tests at the same offsets, so the conflict split test headers
from bodies and a plain union produced one test wearing another's body. The
fork's file is taken whole and the PR's seven new tests appended from its own
copy.

Verified: node --check on every touched script; full npm test 300/300 twice;
tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…eplacement

The review on openai#731 asks for exactly this coverage, and
this fork already carries the hardening it requests (ensurePrivateDir plus
writeJsonFileAtomic instead of mkdirSync plus writeFileSync), so the tests
belong here too.

- "the durable review-gate config is private" pins 0600 on the file and 0700
  on its directory. It discriminates: reverting writeDurableConfig() to the
  plain writeFileSync the PR shipped with fails it.
- "a durable config write that fails mid-write leaves the previous config
  intact" fails the replacement from inside writeJsonFileAtomic(), after it
  has created its temporary file, using a value whose toJSON() throws. It
  asserts the previous config still reads back enabled and that no temporary
  file is left beside it. This one does not discriminate against the naive
  implementation (which throws before touching the file either way); what it
  guards is the regression class where a future rewrite truncates the target
  before serializing, and the cleanup path of the atomic write.

Verified: full npm test 302/302; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
The review on openai#737 asks for a multi-manager regression
alongside the ordering fix. Two tests, with every manager root populated at
once (nvm, fnm, asdf, mise):

- "prefers a managed toolchain over a system install with several managers
  present" is the regression for the precedence bug itself. It discriminates
  where a system node exists: putting /usr/local/bin and the Homebrew paths
  back ahead of the manager roots fails it, along with the four upstream tests
  that caught the bug originally. It deliberately does not pin *which* manager
  wins — the launcher cannot tell which one the project or user selected, since
  it reads no .nvmrc, .tool-versions or mise config, so asserting one would
  freeze an arbitrary order as if it were a guarantee.
- "lets CODEX_COMPANION_NODE override every installed manager" pins the one
  way a specific runtime can be selected today, which is the honest answer to
  the reviewer's "preserve the selected runtime" until the launcher learns to
  read the active manager.

Verified: the first test fails with the pre-fix ordering restored and passes
with it in place; full npm test 304/304; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
CI failed on this PR's head with "dead owner should be reclaimed before stale
timeout" in tests/locking.test.mjs — a test neither this branch nor the imports
touch. The assertion is the flaky part, not the behavior: acquireLock() checks
its deadline only after a failed attempt, so on a loaded runner a single slow
attempt still returns successfully at over 500ms of wall clock.

Nothing is weakened by dropping it. acquireLock() throws once its deadline
passes without acquiring, so a successful return under a 500ms budget with
staleMs at 30s already proves the lock was reclaimed because its owner is gone
rather than because it aged out. Verified by commenting out
reclaimAbandonedLock(): the test fails again, as does the other reclaim test.

Verified: tests/locking.test.mjs 5/5 three runs in a row; full npm test
304/304.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…s is a fork

- README "Differences From Upstream" gains openai#659, openai#707 and openai#728, plus a short
  list of the defects the imports themselves surfaced and this fork fixes
  (busy-broker refusal, the launcher's inverted precedence, the durable
  config's private atomic write, the app-server typecheck), and what is
  deliberately not imported (openai#733, openai#761) with the reason.
- CHANGELOG records the three imports under Unreleased.
- The plugin, marketplace and package descriptions now say this is a fork of
  openai/codex-plugin-cc carrying open upstream fixes. Anyone browsing
  /plugin sees the same description the marketplace lists, and it read as
  upstream's own plugin until now.

The version stays 1.0.6 (npm run check-version passes): describing the fork is
not cutting a release.

Verified: full npm test 304/304; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
@Edo771977 Edo771977 changed the title Import three high-value upstream PRs, and cover the durable config's guarantees Import three high-value upstream PRs, pin what they guarantee, and describe the fork Sep 17, 2026
…utions

A review of the whole day's work (this branch plus the already-merged round)
found eight issues; these are the three that belong to the grafts made here,
plus one they exposed in the previous round. Each has a regression test that
fails without the fix.

- cancel refused *after* taking the terminal claim. claimTerminalStatus()
  creates a claim that is never released, so the leaked cancel-intent claim
  made the next cancel — or SessionEnd — adopt it and reassert a cancelled
  record for the turn the refusal exists to protect. The refusal now runs
  before the claim, off the same merged job view.
- The session-end guard reconciled the stale state.json snapshot instead of
  the job file the code re-reads ten lines below for exactly that reason. On a
  snapshot with pid: null the guard was skipped and the job recorded cancelled
  while its turn ran on; on a snapshot missing a turnId the file already had,
  it retained a job whose turn could have been interrupted. It now reconciles
  the captured values.
- The retain path wrote pid: null, and reconcileJobLiveness() needs a pid: the
  record could never be judged again, so it stayed "running" in /codex:status
  and was refused by /codex:result and /codex:cancel. The pid and thread id are
  written back instead.
- From the previous round: assertResumedSandbox() still asserted the requested
  mode on a scoped resume, although buildThreadAccessParams() deliberately
  sends a permission profile and no sandbox when read roots are set. Every
  `--read-root` resume was refused outright, and the error's own advice
  ("resume with --sandbox read-only") silently dropped the write grant. The
  assertion is skipped when read roots are in play.

Verified: each new test fails with only its fix reverted (stash the source
file, run the suite) and passes with it; full npm test 307/307; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…ted code

The remaining findings from the independent review. None came from this
branch's grafts; all four arrived with imported PRs and are now this fork's.

- state.mjs: the cross-root prune rewrote another root's state.json while
  holding only the primary's lock, so a process whose primary IS that root
  could lose its whole update to the rename. It now takes that root's own lock
  and never waits for it (timeoutMs: 0): two processes each holding the other's
  primary lock would deadlock, and a skipped prune is harmless — the next save
  redoes it.
- state.mjs: loadState() merges booleans across roots with OR, so a stranded
  `stopReviewGate: true` outvoted an explicit disable forever whenever the
  durable config could not be read, because setConfig() only ever wrote the
  primary. It now writes the new config into every existing root (same
  non-blocking lock discipline), keeping the fail-safe OR without making
  "disable" unreachable. The merge also folds candidates in reverse so the
  primary wins for non-boolean keys — the old "first writer wins" branch was
  dead for every key the defaults define, which is all of them.
- session-lifecycle-hook.mjs: setEnv() rewrote the shared CLAUDE_ENV_FILE
  (read, filter, rename), which drops any export another plugin's SessionStart
  hook appended in between and discards the file's mode with the replaced
  file. It appends again, and skips the append when the value the file already
  resolves to is ours — the shell takes the last export for a key, so openai#748's
  point (no growth on every session) survives without the data loss.
- claude-session-transfer.mjs: a process attaching to a staged copy whose
  creator had not yet written the marker took no lease, and the creator's
  release() then deleted the file under it. The lease is now taken
  unconditionally, and cleanup belongs to whoever leaves last (marker present,
  no leases left) rather than to whoever created the copy.

Regression tests for the first three; each fails with only its own fix
reverted. The staging race has no deterministic test — it needs an interleaving
between two processes at a specific point — so it rests on the reasoning above.

Verified: full npm test 310/310; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…ound of fixes

- The retained orphan kept its dead pid, which made it reapable: another
  session's SessionEnd failed the record without an interrupt and it stopped
  pinning the broker, tearing the runtime down under the turn the retain
  protects. The verdict is persisted instead — pid: null plus workerExited —
  and reconcileJobLiveness() reads that flag, so the record stays truthful in
  /codex:status while a pid-less active record keeps the broker up under the
  existing staleness bound. The test now also ends a second session and
  asserts the job survives it.
- Skipping assertResumedSandbox() for scoped resumes also dropped the
  escalation check. A dedicated one replaces it: a thread running with the
  sandbox disabled cannot be scoped by a permission profile, so --read-root on
  it is refused rather than silently promising a scope.
- saveState() deleted a dropped job's files from every root while the prune of
  another root's state.json is best-effort, so a contended prune left a record
  to be merged back — and rewritten into the primary — with its detail file,
  claim and log already gone. Each root's files now go with its own record.
- setEnv() appended without ensuring the file ends in a newline, so a
  preceding hook's unterminated line and ours would run together and lose both
  exports.
- The append's own comment (and the README's line for openai#748) claimed the file
  no longer grows per session, which is false for values that change every
  session — the session id and transcript path. Both now say what actually
  holds: unchanged values are skipped, changed ones append, and the shell
  takes the last export. Bounded growth is the price of never destroying
  another plugin's export.
- release() took the 5s staging lock even when there was nothing to clean up,
  from a finally, so a busy lock replaced the import error that was unwinding.
  It is wrapped now, with a lock-free unlink of our own lease as the fallback.

Verified: full npm test 310/310; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
- A retained orphan belongs to the session that is ending, so
  hasActiveJobsFromOtherSessions() does not speak for it and the same hook run
  went on to shut the broker down — killing the turn the retain protects.
  cleanupSessionJobs() now reports what it retained and SessionEnd leaves the
  runtime up for it.
- With no pid, that record's only exit was the day-long staleness rule, so it
  would block every session's broker shutdown for a day. Its protection is
  bounded to the broker's own idle window instead (CODEX_BROKER_IDLE_SHUTDOWN_MS,
  10 minutes by default): the turn cannot outlive the broker anyway, since its
  client is gone and the broker idles out on that same timer. The reaper and
  the broker guard both use the new bound.
- The other root's prune deleted a job's files on a retainedIds set computed
  before the lock, so a job created in that root since the snapshot could lose
  its detail file, claim and log while its worker ran. Both the record prune
  and the deletion are now limited to ids the caller's own snapshot held;
  anything newer is nobody's to drop here.
- The scoped-resume escalation check now also runs on a fresh scoped start: a
  default config can start a thread with the sandbox disabled, which would have
  made the resume error's own "--fresh" remedy reproduce the refused condition.
- setEnv() always opens its append with a newline rather than deciding from the
  read above it, which still raced a hook appending an unterminated line in
  between. The test for the file's contents asserts the exports and their
  order, since blank lines are nothing to the shell that sources it.

Verified: full npm test 310/310; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
… the right remedy

The fourth review round, with one finding it called blocking.

- Blocking: with CODEX_BROKER_IDLE_SHUTDOWN_MS=0 — a documented way to disable
  the broker's idle shutdown — retainedOrphanExpired() never returned true,
  while SessionEnd now declines to tear the broker down for such a record. The
  broker, its app-server and its MCP servers would have leaked permanently, and
  the job stayed "running" with no way to clear it. The window now falls back
  to the generic staleness bound when the idle timer is disabled, and an
  unreadable timestamp counts as expired rather than as protected forever:
  this is the one record that stops a teardown, so "cannot tell" must not mean
  "keep it alive". Probed both ways: with the timer off a 25h-old orphan is
  reaped and a 2h-old one is not; with the timer at 10 minutes a 2h-old one is.
- The escalation error on a fresh scoped start told the user to "start a fresh
  thread with --fresh", which is exactly what had just failed. It now names the
  real cause (the Codex config's sandbox default) and the two real remedies.
  The message still says "--fresh" on the resume path, where it works.
- updateState() passes the snapshot it mutated down to saveStateLocked(), so
  the other-root prune judges "ids the caller knew about" from that snapshot
  rather than from a later re-read. A direct saveState() has no snapshot to
  offer and keeps the re-read, which is now documented.

Verified: full npm test 310/310; tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
…out it

The fifth review round found nothing blocking. Its two substantive remarks
are addressed here:

- Both new branches of retainedOrphanExpired() were dead in the suite (the
  existing retained-orphan test seeds updatedAt in 2099). Two tests now drive
  them end to end: with the idle timer disabled a two-hour-old orphan is still
  protected while a 25-hour-old one is reaped, with the timer at ten minutes
  the two-hour-old one is reaped, and an unreadable timestamp is treated as
  expired.
- Two comments overstated things. The early return in SessionEnd said the
  broker "idles out on its own timer", which is false precisely in the
  configuration that made this bound necessary; it now says what ends the wait
  in both configurations. And the "unlike every other record" framing is gone:
  isStaleJobRecord() reads an unparseable timestamp conservatively, which is
  the behavior this function deliberately does not share, not something no
  other record does.

Verified: full npm test 312/312; tsc clean.

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

Copy link
Copy Markdown
Owner Author

Review: five rounds, and the last one comes back clean

An independent review was run over the whole day's work (this PR plus the already-merged round), then re-run after each round of fixes. The fifth round found nothing blocking: "this round closes all three findings correctly — the PR is mergeable." Suite is 312/312, tsc clean, CI green.

It was worth running. The first pass found eight defects, four of them in the conflict resolutions made here, and each fix round surfaced more — which is the point of doing it adversarially rather than once.

Round 1 — eight findings

Four in this branch's grafts, fixed in a5f7959:

  • The cancel refused after taking the terminal claim. The claim is never released, so the leaked cancel-intent claim made the next cancel — or SessionEnd — adopt it and write a cancelled record for the turn the refusal exists to protect. Exactly the outcome the guard was added to prevent.
  • The session-end guard read the stale state.json snapshot instead of the job file the code re-reads ten lines below for that very reason. With pid: null in the snapshot the guard was skipped and the job recorded cancelled while its turn ran on.
  • The retained orphan was written back with pid: null, and reconcileJobLiveness() needs a pid: the record could never be judged again.
  • assertResumedSandbox() asserted the requested mode on scoped resumes, although buildThreadAccessParams() deliberately sends a permission profile and no sandbox with --read-root. Every scoped resume was refused, and the error's own advice silently dropped the write grant. Reproduced end to end by the reviewer.

Four in imported code, fixed in 2a2bd77: the cross-root prune running under the wrong lock; a stranded stopReviewGate: true outvoting an explicit disable forever; CLAUDE_ENV_FILE rewritten rather than appended, dropping other plugins' exports and the file's mode; and a staging-lease race that let one process delete a staged file another was reading.

Rounds 2–4 — the fixes' own defects

  • The pid: null + workerExited record made the orphan reapable: another session's SessionEnd failed it and it stopped pinning the broker — tearing the runtime down under the protected turn (af0cdd7).
  • That same record then had no exit short of a 24h reap, and belonged to the ending session, so the very hook that retained it shut the broker down anyway (0c5ddd8).
  • Bounding it to the broker's idle window then leaked permanently under CODEX_BROKER_IDLE_SHUTDOWN_MS=0 — a documented opt-out. The reviewer called this blocking; it now falls back to the staleness bound, and an unreadable timestamp counts as expired rather than as protected forever (0fdea5f).
  • Also fixed along the way: the escalation check dropped by the scoped-resume fix (restored, and extended to fresh starts with the right remedy named), per-root deletion of a dropped job's artifacts, the env-file newline separator, release() masking the import error with a lock timeout, and the other-root prune judging ids from a later re-read instead of the caller's snapshot.

Round 5

Three low findings, two addressed in 84625b1: both new expiry branches were dead in the suite (the existing test seeds updatedAt in 2099) and are now driven end to end, and two comments that overstated the guarantees are corrected. The third — isStaleJobRecord() reading an unparseable timestamp conservatively for non-orphan pid-less records — is pre-existing behavior outside this PR's scope and is left alone.

Every fix carries a regression test that fails with only its own fix reverted, except the staging-lease race, which needs an interleaving between two processes and rests on the reasoning in its commit.


Generated by Claude Code

…it carries

The README listed imported PR numbers and flags, but never said what the
plugin does now — most of this fork's divergence is behavior under failure,
which is invisible from a list of links.

- "What The Background Runtime Guarantees" up front: state is checked rather
  than trusted, session end never reports an outcome that did not happen, one
  broker per workspace retired by the last session out, a finished job keeps a
  record with its cause, nothing written is world-readable or half-written,
  and Node is found where it is actually installed.
- /codex:status documents the two reconciled states a dead worker produces,
  including worker-exited-turn-unknown and why the plugin cannot resolve it.
- /codex:result says a non-crashing failure keeps its error text.
- /codex:cancel documents its order of operations and the three outcomes that
  are not a plain cancellation: already finished, a repaired stale record, and
  the refusal when the turn id was never recorded.
- Background Runtime Limits notes that the broker idle window also bounds a
  retained orphan, and what happens when that timer is disabled.
- A new "Where State Lives" table: the two plugin-data roots and why both are
  real, the durable review-gate config under CODEX_HOME, and that Codex threads
  and auth stay with the Codex CLI.
- The fork-fixes list gains the defects the review rounds found, and says they
  came from an adversarial review re-run after each round.

Verified: full npm test 312/312; npm run check-version passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg
@Edo771977
Edo771977 merged commit 1f783c2 into main Sep 17, 2026
1 check passed
@Edo771977
Edo771977 deleted the claude/focused-carson-khonz0 branch September 17, 2026 19:27
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.

5 participants