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/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ async function handleCancel(argv) {

const cwd = resolveCommandCwd(options);
const reference = positionals[0] ?? "";
const { workspaceRoot, job } = resolveCancelableJob(cwd, reference);
const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env });
const existing = readStoredJob(workspaceRoot, job.id) ?? {};
const threadId = existing.threadId ?? job.threadId ?? null;
const turnId = existing.turnId ?? job.turnId ?? null;
Expand Down
14 changes: 10 additions & 4 deletions plugins/codex/scripts/lib/job-control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export function resolveResultJob(cwd, reference) {
throw new Error("No finished Codex jobs found for this repository yet.");
}

export function resolveCancelableJob(cwd, reference) {
export function resolveCancelableJob(cwd, reference, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
Expand All @@ -291,12 +291,18 @@ export function resolveCancelableJob(cwd, reference) {
return { workspaceRoot, job: selected };
}

if (activeJobs.length === 1) {
return { workspaceRoot, job: activeJobs[0] };
const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);

if (sessionScopedActiveJobs.length === 1) {
return { workspaceRoot, job: sessionScopedActiveJobs[0] };
}
if (activeJobs.length > 1) {
if (sessionScopedActiveJobs.length > 1) {
throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel.");
}

if (getCurrentSessionId(options)) {
throw new Error("No active Codex jobs to cancel for this session.");
}

throw new Error("No active Codex jobs to cancel.");
}
103 changes: 103 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,109 @@ test("cancel stops an active background job and marks it cancelled", async (t) =
assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/);
});

test("cancel without a job id ignores active jobs from other Claude sessions", () => {
const workspace = makeTempDir();
const stateDir = resolveStateDir(workspace);
const jobsDir = path.join(stateDir, "jobs");
fs.mkdirSync(jobsDir, { recursive: true });

const logFile = path.join(jobsDir, "task-other.log");
fs.writeFileSync(logFile, "", "utf8");
fs.writeFileSync(
path.join(stateDir, "state.json"),
`${JSON.stringify(
{
version: 1,
config: { stopReviewGate: false },
jobs: [
{
id: "task-other",
status: "running",
title: "Codex Task",
jobClass: "task",
sessionId: "sess-other",
summary: "Other session run",
updatedAt: "2026-03-24T20:05:00.000Z",
logFile
}
]
},
null,
2
)}\n`,
"utf8"
);

const env = {
...process.env,
CODEX_COMPANION_SESSION_ID: "sess-current"
};
const status = run("node", [SCRIPT, "status", "--json"], {
cwd: workspace,
env
});
assert.equal(status.status, 0, status.stderr);
assert.deepEqual(JSON.parse(status.stdout).running, []);

const cancel = run("node", [SCRIPT, "cancel", "--json"], {
cwd: workspace,
env
});
assert.equal(cancel.status, 1);
assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./);

const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
assert.equal(state.jobs[0].status, "running");
});

test("cancel with a job id can still target an active job from another Claude session", () => {
const workspace = makeTempDir();
const stateDir = resolveStateDir(workspace);
const jobsDir = path.join(stateDir, "jobs");
fs.mkdirSync(jobsDir, { recursive: true });

const logFile = path.join(jobsDir, "task-other.log");
fs.writeFileSync(logFile, "", "utf8");
fs.writeFileSync(
path.join(stateDir, "state.json"),
`${JSON.stringify(
{
version: 1,
config: { stopReviewGate: false },
jobs: [
{
id: "task-other",
status: "running",
title: "Codex Task",
jobClass: "task",
sessionId: "sess-other",
summary: "Other session run",
updatedAt: "2026-03-24T20:05:00.000Z",
logFile
}
]
},
null,
2
)}\n`,
"utf8"
);

const env = {
...process.env,
CODEX_COMPANION_SESSION_ID: "sess-current"
};
const cancel = run("node", [SCRIPT, "cancel", "task-other", "--json"], {
cwd: workspace,
env
});
assert.equal(cancel.status, 0, cancel.stderr);
assert.equal(JSON.parse(cancel.stdout).jobId, "task-other");

const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
assert.equal(state.jobs[0].status, "cancelled");
});

test("cancel sends turn interrupt to the shared app-server before killing a brokered task", async () => {
const repo = makeTempDir();
const binDir = makeTempDir();
Expand Down