Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/codex/scripts/lib/broker-endpoint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 3 additions & 4 deletions plugins/codex/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -94,7 +94,6 @@ export function terminateProcessTree(pid, options = {}) {
throw result.error;
}

throw new Error(formatCommandFailure(result));
}

try {
Expand Down
12 changes: 6 additions & 6 deletions plugins/codex/scripts/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
)
});
}

Expand Down
27 changes: 27 additions & 0 deletions tests/process.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
71 changes: 65 additions & 6 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}
});
Expand Down Expand Up @@ -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")
}
});
Expand All @@ -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);
Expand Down Expand Up @@ -503,6 +506,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);
Expand Down Expand Up @@ -1801,7 +1849,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");
Expand Down Expand Up @@ -1905,7 +1953,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(() => {
Expand All @@ -1918,9 +1971,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", () => {
Expand Down