Skip to content

fix: SessionEnd/status lookups miss state written under a different CLAUDE_PLUGIN_DATA root, orphaning brokers - #659

Open
mittalpk wants to merge 4 commits into
openai:mainfrom
mittalpk:fix/session-end-state-root-mismatch
Open

fix: SessionEnd/status lookups miss state written under a different CLAUDE_PLUGIN_DATA root, orphaning brokers#659
mittalpk wants to merge 4 commits into
openai:mainfrom
mittalpk:fix/session-end-state-root-mismatch

Conversation

@mittalpk

Copy link
Copy Markdown

Fixes #636.

What's wrong

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.

Fix

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 consistently to loadState() (job list, status, config), readStoredJob() (individual job detail lookups), and loadBrokerSession()/clearBrokerSession().

Known remaining asymmetry, called out honestly: 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 (written while set, later looked up unset) 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. Closing that direction fully would need something like your suggested fix #1 (persist the resolved root, e.g. inside broker.json) — happy to take a pass at that too if it's wanted, but wanted to keep this PR to the achievable, lower-risk fix first.

Testing

Added regression tests for loadState, readStoredJob, and loadBrokerSession/clearBrokerSession each finding/clearing state across the root split; confirmed all fail on unpatched code. Full suite: 95 passed, 0 regressions.

…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
@mittalpk
mittalpk requested a review from a team August 19, 2026 06:28

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b102b3d29

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +105 to +108
export function clearBrokerSession(cwd) {
const stateFile = resolveBrokerStateFile(cwd);
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);
for (const stateFile of resolveBrokerStateFileCandidates(cwd)) {
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Tear down every broker before clearing candidate files

When both the plugin-data and fallback roots contain broker.json—a state that existing installations can have because the old lookup behavior spawned duplicate brokers—loadBrokerSession() returns only the first record, so SessionEnd tears down only that broker. This loop then deletes both records, leaving the second broker running with its only metadata removed; similarly, ensureBrokerSession() encountering a stale primary record can erase a live fallback record before spawning another duplicate. Clear only the selected record, or load and tear down every distinct candidate before deleting their files.

Useful? React with 👍 / 👎.

…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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: baa5ffdcfe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread plugins/codex/scripts/lib/state.mjs Outdated
Comment on lines +89 to +90
if (fs.existsSync(stateFile)) {
return stateFile;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Merge jobs from every candidate state file

When both roots already contain state.json—a reachable legacy state after invocations alternated between having and lacking CLAUDE_PLUGIN_DATA—this returns the primary file immediately and never reads the fallback file. If the fallback index contains a running job absent from the primary index, /codex:status, result, and cancellation lookups still cannot see it, so the cross-root orphaning this change targets remains whenever both files exist; reconcile the candidate indexes rather than treating the first existing file as the complete state.

Useful? React with 👍 / 👎.

Comment on lines +115 to +118
for (const stateFile of resolveBrokerStateFileCandidates(cwd)) {
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the broker record that was actually loaded

When the primary broker.json exists but is malformed and the fallback record is valid, loadBrokerSession() skips the parse failure and returns the fallback broker, but this loop deletes the malformed primary and returns. SessionEnd therefore shuts down the fallback broker while leaving its valid record behind, causing subsequent lookups to report a stopped endpoint. Fresh evidence in the revised code is that loadBrokerSession() skips unreadable candidates while clearBrokerSession() selects solely by file existence.

Useful? React with 👍 / 👎.

… 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.
@mittalpk

Copy link
Copy Markdown
Author

Thanks, all three caught real issues -- fixed in the two follow-up commits:

  • clearBrokerSession() deleting every candidate instead of just the selected one — fixed.
  • loadState() returning only the first candidate's job list instead of merging — jobs are a growing collection where both roots can legitimately have real, non-conflicting entries, so I merge them now (keeping the more recent copy if an id somehow collides).
  • clearBrokerSession() selecting by file existence while loadBrokerSession() selects by existence and successful parse — they now share one selection helper so they can't disagree.

Added regression tests for each, confirmed they fail against the prior code. Full suite: 98 passed.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dcf9384f53

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

...defaultState().config,
...(primary.config ?? {})
},
jobs: [...jobsById.values()]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist deletions across every merged state root

When both candidate roots contain state and a caller removes a fallback-origin job, such as cleanupSessionJobs() filtering it during SessionEnd, saveState() writes the filtered collection only to the current primary file while leaving the fallback file unchanged. The next loadState() merges that fallback record back into jobsById, so session cleanup and pruning cannot permanently remove cross-root jobs; status can continue reporting a terminated job as running, even after its log was deleted. The fallback state must be migrated/updated or otherwise record deletions before returning this merged collection.

Useful? React with 👍 / 👎.

…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.
@mittalpk

Copy link
Copy Markdown
Author

Right again, and this one was more severe than the earlier finding it followed from — merging reads across roots wasn't enough on its own, since saveState() only ever wrote the new job list back to the current primary root. A job originating in the other root that got filtered out (e.g. cleanupSessionJobs() during SessionEnd) never actually disappeared: that root's own state.json kept its own untouched copy, and the next loadState() merged it right back in.

Fixed: saveState() now also prunes every other candidate root's own file down to the same retained job-id set. New/updated jobs are unaffected — still only ever written to the primary root — this only ever removes. Added a regression test mirroring cleanupSessionJobs()'s exact pattern (load merged view, drop one job that originated in the other root, save the remainder), confirmed it fails against the prior code. Full suite: 99 passed.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e349f1c09b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +109 to +111
const parsedCandidates = resolveStateDirCandidates(cwd)
.map((stateDir) => readStateFileIfValid(path.join(stateDir, STATE_FILE_NAME)))
.filter((parsed) => parsed != null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make SessionEnd check every state candidate

When a session's jobs exist only in the fallback root and SessionEnd runs with CLAUDE_PLUGIN_DATA set, cleanupSessionJobs() in session-lifecycle-hook.mjs still checks only resolveStateFile() (the primary path) at lines 48-50 and returns before calling this candidate-aware loader. Consequently, the hook neither terminates nor removes those jobs, leaving the background processes and records orphaned despite the new cross-root lookup; the early existence check must also consider all candidate files or be removed.

Useful? React with 👍 / 👎.

Comment on lines +127 to +130
const [primary] = parsedCandidates;
return {
...defaultState(),
...primary,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the enabled review gate across state roots

When both roots contain state, only the current primary candidate supplies config, even though the fallback candidate may contain a later or previously authoritative stopReviewGate: true. This is reachable when an older plugin invocation created a primary file with the default disabled value and /codex:setup --enable-review-gate later ran without CLAUDE_PLUGIN_DATA; subsequent stop hooks with the variable set read the stale primary value and silently skip the explicitly enabled review gate. The configuration needs reconciliation or migration rather than unconditional primary selection.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SessionEnd cannot find the broker when CLAUDE_PLUGIN_DATA differs between spawn and teardown — same cwd, same hash, different state root

1 participant