-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix: SessionEnd/status lookups miss state written under a different CLAUDE_PLUGIN_DATA root, orphaning brokers #659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6b102b3
baa5ffd
dcf9384
e349f1c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,7 @@ function defaultState() { | |
| }; | ||
| } | ||
|
|
||
| export function resolveStateDir(cwd) { | ||
| function workspaceStateDirName(cwd) { | ||
| const workspaceRoot = resolveWorkspaceRoot(cwd); | ||
| let canonicalWorkspaceRoot = workspaceRoot; | ||
| try { | ||
|
|
@@ -38,9 +38,37 @@ export function resolveStateDir(cwd) { | |
| const slugSource = path.basename(workspaceRoot) || "workspace"; | ||
| const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; | ||
| const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); | ||
| return `${slug}-${hash}`; | ||
| } | ||
|
|
||
| // CLAUDE_PLUGIN_DATA is only present when the current invocation runs as a | ||
| // plugin hook; a directly-invoked CLI call (or a hook whose env didn't | ||
| // propagate it) resolves to the tmpdir fallback instead. Since the state | ||
| // root is derived from ambient environment rather than anything persisted, | ||
| // two invocations for the *same* workspace can land on different roots -- | ||
| // the primary root is still the write target for new/updated state, but | ||
| // reads check every candidate so state written under one root is never | ||
| // invisible to a later invocation that resolves to the other. | ||
| function stateRootCandidates() { | ||
| const pluginDataDir = process.env[PLUGIN_DATA_ENV]; | ||
| const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; | ||
| return path.join(stateRoot, `${slug}-${hash}`); | ||
| return pluginDataDir | ||
| ? [path.join(pluginDataDir, "state"), FALLBACK_STATE_ROOT_DIR] | ||
| : [FALLBACK_STATE_ROOT_DIR]; | ||
| } | ||
|
|
||
| export function resolveStateDir(cwd) { | ||
| const [primaryRoot] = stateRootCandidates(); | ||
| return path.join(primaryRoot, workspaceStateDirName(cwd)); | ||
| } | ||
|
|
||
| /** | ||
| * All directories that could hold this workspace's state, primary root | ||
| * first. Use for reads that must not miss state written under a different | ||
| * root than the current invocation resolves to. | ||
| */ | ||
| export function resolveStateDirCandidates(cwd) { | ||
| const dirName = workspaceStateDirName(cwd); | ||
| return stateRootCandidates().map((root) => path.join(root, dirName)); | ||
| } | ||
|
|
||
| export function resolveStateFile(cwd) { | ||
|
|
@@ -55,26 +83,57 @@ export function ensureStateDir(cwd) { | |
| fs.mkdirSync(resolveJobsDir(cwd), { recursive: true }); | ||
| } | ||
|
|
||
| export function loadState(cwd) { | ||
| const stateFile = resolveStateFile(cwd); | ||
| function readStateFileIfValid(stateFile) { | ||
| if (!fs.existsSync(stateFile)) { | ||
| return defaultState(); | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8")); | ||
| return { | ||
| ...defaultState(), | ||
| ...parsed, | ||
| config: { | ||
| ...defaultState().config, | ||
| ...(parsed.config ?? {}) | ||
| }, | ||
| jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] | ||
| }; | ||
| return JSON.parse(fs.readFileSync(stateFile, "utf8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // Unlike the broker session (at most one meaningful record per workspace, | ||
| // so "first candidate found" is a correct selection), jobs are a growing | ||
| // collection that can genuinely differ across roots -- a job started while | ||
| // CLAUDE_PLUGIN_DATA was set and another started while it was unset are | ||
| // both real and non-conflicting. Returning only the first candidate's job | ||
| // list would silently hide whichever root wasn't picked, leaving the exact | ||
| // cross-root invisibility this fix targets for status/result/cancel | ||
| // whenever *both* roots happen to have a state.json (a reachable legacy | ||
| // state after invocations alternated). So every candidate's jobs are | ||
| // merged instead, keeping the more recently updated copy if the same job | ||
| // id somehow appears in more than one. | ||
| export function loadState(cwd) { | ||
| const parsedCandidates = resolveStateDirCandidates(cwd) | ||
| .map((stateDir) => readStateFileIfValid(path.join(stateDir, STATE_FILE_NAME))) | ||
| .filter((parsed) => parsed != null); | ||
|
|
||
| if (parsedCandidates.length === 0) { | ||
| return defaultState(); | ||
| } | ||
|
|
||
| const jobsById = new Map(); | ||
| for (const parsed of parsedCandidates) { | ||
| for (const job of Array.isArray(parsed.jobs) ? parsed.jobs : []) { | ||
| const existing = jobsById.get(job.id); | ||
| if (!existing || String(job.updatedAt ?? "") > String(existing.updatedAt ?? "")) { | ||
| jobsById.set(job.id, job); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const [primary] = parsedCandidates; | ||
| return { | ||
| ...defaultState(), | ||
| ...primary, | ||
|
Comment on lines
+127
to
+130
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When both roots contain state, only the current primary candidate supplies Useful? React with 👍 / 👎. |
||
| config: { | ||
| ...defaultState().config, | ||
| ...(primary.config ?? {}) | ||
| }, | ||
| jobs: [...jobsById.values()] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When both candidate roots contain state and a caller removes a fallback-origin job, such as Useful? React with 👍 / 👎. |
||
| }; | ||
| } | ||
|
|
||
| function pruneJobs(jobs) { | ||
|
|
@@ -107,11 +166,40 @@ export function saveState(cwd, state) { | |
| if (retainedIds.has(job.id)) { | ||
| continue; | ||
| } | ||
| removeJobFile(resolveJobFile(cwd, job.id)); | ||
| for (const jobFile of resolveJobFileCandidates(cwd, job.id)) { | ||
| removeJobFile(jobFile); | ||
| } | ||
| removeFileIfExists(job.logFile); | ||
| } | ||
|
|
||
| fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8"); | ||
|
|
||
| // previousJobs is the merged view across every candidate root (see | ||
| // loadState()), so a job dropped from state.jobs here may have | ||
| // originated entirely in a root other than the one just written above. | ||
| // Without this, that root's own state.json still holds its own | ||
| // untouched copy, and the very next loadState() merges it right back in | ||
| // -- deletions could never actually stick for a job that lives only in a | ||
| // non-primary root. Prune every other candidate root's own file down to | ||
| // the same retained set; new/updated jobs still only ever get written to | ||
| // the primary root, above -- this only ever removes, never adds or | ||
| // rewrites in place. | ||
| const [, ...otherStateDirs] = resolveStateDirCandidates(cwd); | ||
| for (const otherStateDir of otherStateDirs) { | ||
| const otherStateFile = path.join(otherStateDir, STATE_FILE_NAME); | ||
| const otherParsed = readStateFileIfValid(otherStateFile); | ||
| const otherJobs = Array.isArray(otherParsed?.jobs) ? otherParsed.jobs : []; | ||
| const prunedOtherJobs = otherJobs.filter((job) => retainedIds.has(job.id)); | ||
| if (prunedOtherJobs.length === otherJobs.length) { | ||
| continue; | ||
| } | ||
| fs.writeFileSync( | ||
| otherStateFile, | ||
| `${JSON.stringify({ ...otherParsed, jobs: prunedOtherJobs }, null, 2)}\n`, | ||
| "utf8" | ||
| ); | ||
| } | ||
|
|
||
| return nextState; | ||
| } | ||
|
|
||
|
|
@@ -189,3 +277,14 @@ export function resolveJobFile(cwd, jobId) { | |
| ensureStateDir(cwd); | ||
| return path.join(resolveJobsDir(cwd), `${jobId}.json`); | ||
| } | ||
|
|
||
| /** | ||
| * Every path a job's detail file could be at, primary root first. A job | ||
| * listed via loadState()/listJobs() (which already searches every | ||
| * candidate root) may have had its detail file written under a different | ||
| * root than resolveJobFile()'s current primary; read lookups should not | ||
| * miss it just because it isn't in the root a fresh call resolves to. | ||
| */ | ||
| export function resolveJobFileCandidates(cwd, jobId) { | ||
| return resolveStateDirCandidates(cwd).map((stateDir) => path.join(stateDir, JOBS_DIR_NAME, `${jobId}.json`)); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a session's jobs exist only in the fallback root and
SessionEndruns withCLAUDE_PLUGIN_DATAset,cleanupSessionJobs()insession-lifecycle-hook.mjsstill checks onlyresolveStateFile()(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 👍 / 👎.