From f25155d36a5fe59edf026990e3c5c0afbaa76554 Mon Sep 17 00:00:00 2001 From: Asher123452 Date: Tue, 28 Jul 2026 21:42:56 +1000 Subject: [PATCH 1/3] fix: preserve rescue threads across Claude resume SessionEnd previously deleted completed task records along with active jobs, so a resumed Claude conversation could not resolve its prior Codex thread. Retain terminal job history while still terminating and removing queued/running work. --- .../codex/scripts/session-lifecycle-hook.mjs | 12 ++-- tests/runtime.test.mjs | 64 +++++++++++++++++-- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 778571e6c..c57ad4026 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -51,16 +51,14 @@ function cleanupSessionJobs(cwd, sessionId) { } const state = loadState(workspaceRoot); - const removedJobs = state.jobs.filter((job) => job.sessionId === sessionId); + const removedJobs = state.jobs.filter( + (job) => job.sessionId === sessionId && (job.status === "queued" || job.status === "running") + ); if (removedJobs.length === 0) { return; } for (const job of removedJobs) { - const stillRunning = job.status === "queued" || job.status === "running"; - if (!stillRunning) { - continue; - } try { terminateProcessTree(job.pid ?? Number.NaN); } catch { @@ -70,7 +68,9 @@ function cleanupSessionJobs(cwd, sessionId) { saveState(workspaceRoot, { ...state, - jobs: state.jobs.filter((job) => job.sessionId !== sessionId) + jobs: state.jobs.filter( + (job) => job.sessionId !== sessionId || (job.status !== "queued" && job.status !== "running") + ) }); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..128095f0a 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -503,6 +503,51 @@ test("task --resume-last resumes the latest persisted task thread", () => { assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); }); +test("task --resume-last survives Claude session end and resume", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const env = { + ...buildEnv(binDir), + CODEX_COMPANION_SESSION_ID: "sess-resumed" + }; + const firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const sessionEnd = run("node", [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env, + input: JSON.stringify({ + hook_event_name: "SessionEnd", + session_id: "sess-resumed", + cwd: repo + }) + }); + assert.equal(sessionEnd.status, 0, sessionEnd.stderr); + + const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], { + cwd: repo, + env + }); + assert.equal(candidate.status, 0, candidate.stderr); + assert.equal(JSON.parse(candidate.stdout).available, true); + + const resumedRun = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { + cwd: repo, + env + }); + assert.equal(resumedRun.status, 0, resumedRun.stderr); + assert.equal(resumedRun.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); +}); + test("task-resume-candidate returns the latest rescue thread from the current session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); @@ -1801,7 +1846,7 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok assert.equal(cleanup.status, 0, cleanup.stderr); }); -test("session end fully cleans up jobs for the ending session", async (t) => { +test("session end stops active jobs but retains completed jobs for resume", async (t) => { const repo = makeTempDir(); initGitRepo(repo); fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); @@ -1905,7 +1950,12 @@ test("session end fully cleans up jobs for the ending session", async (t) => { assert.equal(fs.existsSync(otherJobFile), true); assert.deepEqual( fs.readdirSync(path.dirname(otherJobFile)).sort(), - [path.basename(otherJobFile), path.basename(otherSessionLog)].sort() + [ + path.basename(completedJobFile), + path.basename(completedLog), + path.basename(otherJobFile), + path.basename(otherSessionLog) + ].sort() ); await waitFor(() => { @@ -1918,9 +1968,15 @@ test("session end fully cleans up jobs for the ending session", async (t) => { }); const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.deepEqual(state.jobs.map((job) => job.id), ["review-other"]); - const otherJob = state.jobs[0]; + assert.deepEqual(state.jobs.map((job) => job.id), ["review-other", "review-completed"]); + const otherJob = state.jobs.find((job) => job.id === "review-other"); assert.equal(otherJob.logFile, otherSessionLog); + const completedJob = state.jobs.find((job) => job.id === "review-completed"); + assert.equal(completedJob.logFile, completedLog); + assert.equal(fs.existsSync(completedLog), true); + assert.equal(fs.existsSync(completedJobFile), true); + assert.equal(fs.existsSync(runningLog), false); + assert.equal(fs.existsSync(runningJobFile), false); }); test("stop hook runs a stop-time review task and blocks on findings when the review gate is enabled", () => { From 46e2374d70c54241d42ffcaddb5f15578f41dcf1 Mon Sep 17 00:00:00 2001 From: Asher123452 Date: Tue, 28 Jul 2026 21:42:56 +1000 Subject: [PATCH 2/3] fix: use POSIX joins for Unix broker endpoints --- plugins/codex/scripts/lib/broker-endpoint.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex/scripts/lib/broker-endpoint.mjs b/plugins/codex/scripts/lib/broker-endpoint.mjs index 8abdcc71a..664c58494 100644 --- a/plugins/codex/scripts/lib/broker-endpoint.mjs +++ b/plugins/codex/scripts/lib/broker-endpoint.mjs @@ -13,7 +13,7 @@ export function createBrokerEndpoint(sessionDir, platform = process.platform) { return `pipe:\\\\.\\pipe\\${pipeName}`; } - return `unix:${path.join(sessionDir, "broker.sock")}`; + return `unix:${path.posix.join(sessionDir, "broker.sock")}`; } export function parseBrokerEndpoint(endpoint) { From 4f7d159972c3b460060040f5b8599dad2d5a6fdc Mon Sep 17 00:00:00 2001 From: Asher123452 Date: Tue, 28 Jul 2026 21:58:17 +1000 Subject: [PATCH 3/3] fix: harden Windows process cleanup Fall back to terminating the tracked root process when taskkill reports partial tree failure. Make the no-npm and session-transfer fixtures use Windows-correct executable and home-directory environment names so the full suite is portable. --- plugins/codex/scripts/lib/process.mjs | 7 +++---- tests/process.test.mjs | 27 +++++++++++++++++++++++++++ tests/runtime.test.mjs | 7 +++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index dd8fc3751..a556812ed 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -78,13 +78,13 @@ export function terminateProcessTree(pid, options = {}) { return { attempted: true, delivered: false, method: "taskkill", result }; } - if (result.error?.code === "ENOENT") { + if (!result.error || result.error.code === "ENOENT") { try { killImpl(pid); - return { attempted: true, delivered: true, method: "kill" }; + return { attempted: true, delivered: true, method: "kill", result }; } catch (error) { if (error?.code === "ESRCH") { - return { attempted: true, delivered: false, method: "kill" }; + return { attempted: true, delivered: false, method: "kill", result }; } throw error; } @@ -94,7 +94,6 @@ export function terminateProcessTree(pid, options = {}) { throw result.error; } - throw new Error(formatCommandFailure(result)); } try { diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 80e0715b0..ce7b80143 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -53,3 +53,30 @@ test("terminateProcessTree treats missing Windows processes as already stopped", assert.equal(outcome.result.status, 128); assert.match(outcome.result.stdout, /not found/i); }); + +test("terminateProcessTree falls back to the root process when taskkill only partially succeeds", () => { + let killedPid = null; + const outcome = terminateProcessTree(1234, { + platform: "win32", + runCommandImpl(command, args) { + return { + command, + args, + status: 128, + signal: null, + stdout: "", + stderr: "A child process could not be terminated.", + error: null + }; + }, + killImpl(pid) { + killedPid = pid; + } + }); + + assert.equal(killedPid, 1234); + assert.equal(outcome.attempted, true); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "kill"); + assert.equal(outcome.result.status, 128); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 128095f0a..b8b9b8091 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -47,7 +47,7 @@ test("setup reports ready when fake codex is installed and authenticated", () => test("setup is ready without npm when Codex is already installed and authenticated", () => { const binDir = makeTempDir(); installFakeCodex(binDir); - fs.symlinkSync(process.execPath, path.join(binDir, "node")); + fs.symlinkSync(process.execPath, path.join(binDir, process.platform === "win32" ? "node.exe" : "node")); const result = run("node", [SCRIPT, "setup", "--json"], { cwd: ROOT, @@ -220,6 +220,7 @@ test("transfer delegates the current Claude session directly to native import", env: { ...buildEnv(binDir), HOME: home, + USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath } @@ -265,6 +266,7 @@ test("transfer reports an actionable upgrade error when native import is unsuppo env: { ...buildEnv(binDir), HOME: home, + USERPROFILE: home, CODEX_HOME: path.join(home, ".codex") } }); @@ -295,6 +297,7 @@ test("transfer fails visibly when native import completes without a ledger recor env: { ...buildEnv(binDir), HOME: home, + USERPROFILE: home, CODEX_HOME: path.join(home, ".codex") } }); @@ -320,7 +323,7 @@ test("transfer rejects sources outside the Claude projects directory", () => { const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { cwd: repo, - env: { ...buildEnv(binDir), HOME: home } + env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home } }); assert.notEqual(result.status, 0);