Skip to content
Draft
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
105 changes: 89 additions & 16 deletions plugins/codex/scripts/lib/job-control.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";

import { getSessionRuntimeStatus } from "./codex.mjs";
import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
import { getConfig, listJobs, listJobsAcrossWorkspaces, readJobFile, resolveJobFile } from "./state.mjs";
import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "./workspace.mjs";

Expand Down Expand Up @@ -188,7 +190,7 @@ export function readStoredJob(workspaceRoot, jobId) {
return readJobFile(jobFile);
}

function matchJobReference(jobs, reference, predicate = () => true) {
function matchJobReference(jobs, reference, predicate = () => true, options = {}) {
const filtered = jobs.filter(predicate);
if (!reference) {
return filtered[0] ?? null;
Expand All @@ -207,9 +209,58 @@ function matchJobReference(jobs, reference, predicate = () => true) {
throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
}

if (options.allowMissing) {
return null;
}
throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`);
}

function matchJobAcrossWorkspaces(reference, predicate = () => true, options = {}) {
if (!reference) {
return null;
}

const entries = [];
const seen = new Set();
const addJob = (job, workspaceRoot) => {
if (!job || !workspaceRoot || !predicate(job)) {
return;
}
const key = `${pathKey(workspaceRoot)}\0${job.id}`;
if (seen.has(key)) {
return;
}
seen.add(key);
entries.push({ workspaceRoot, job });
};

for (const job of options.currentJobs ?? []) {
addJob(job, options.currentWorkspaceRoot ?? job.workspaceRoot);
}
for (const job of listJobsAcrossWorkspaces()) {
addJob(job, job.workspaceRoot);
}

const exact = entries.find((entry) => entry.job.id === reference);
if (exact) {
return exact;
}

const prefixMatches = entries.filter((entry) => entry.job.id.startsWith(reference));
if (prefixMatches.length === 1) {
return prefixMatches[0];
}
if (prefixMatches.length > 1) {
throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
}
return null;
}

function pathKey(value) {
const resolved = path.resolve(value);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}

export function buildStatusSnapshot(cwd, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const config = getConfig(workspaceRoot);
Expand Down Expand Up @@ -242,33 +293,55 @@ export function buildStatusSnapshot(cwd, options = {}) {
export function buildSingleJobSnapshot(cwd, reference, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const selected = matchJobReference(jobs, reference);
if (!selected) {
const matched = matchJobAcrossWorkspaces(reference, () => true, {
currentWorkspaceRoot: workspaceRoot,
currentJobs: jobs
});
if (!matched) {
throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`);
}

return {
workspaceRoot,
job: enrichJob(selected, { maxProgressLines: options.maxProgressLines })
workspaceRoot: matched.workspaceRoot,
job: enrichJob(matched.job, { maxProgressLines: options.maxProgressLines })
};
}

export function resolveResultJob(cwd, reference) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
const selected = matchJobReference(
jobs,
reference,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
);

if (selected) {
return { workspaceRoot, job: selected };
if (!reference) {
const selected = matchJobReference(
jobs,
null,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled",
{ allowMissing: true }
);
if (selected) {
return { workspaceRoot, job: selected };
}
} else {
const finished = matchJobAcrossWorkspaces(
reference,
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled",
{ currentWorkspaceRoot: workspaceRoot, currentJobs: jobs }
);
if (finished) {
return finished;
}
}

const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running");
const active = reference
? matchJobAcrossWorkspaces(
reference,
(job) => job.status === "queued" || job.status === "running",
{ currentWorkspaceRoot: workspaceRoot, currentJobs: jobs }
)
: null;
if (active) {
throw new Error(`Job ${active.id} is still ${active.status}. Check /codex:status and try again once it finishes.`);
throw new Error(
`Job ${active.job.id} is still ${active.job.status}. Check /codex:status and try again once it finishes.`
);
}

if (reference) {
Expand Down
51 changes: 48 additions & 3 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const STATE_FILE_NAME = "state.json";
const JOBS_DIR_NAME = "jobs";
const MAX_JOBS = 50;

function resolveStateRoot() {
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
return pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
}

function nowIso() {
return new Date().toISOString();
}
Expand All @@ -38,9 +43,7 @@ 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);
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 path.join(resolveStateRoot(), `${slug}-${hash}`);
}

export function resolveStateFile(cwd) {
Expand Down Expand Up @@ -150,6 +153,48 @@ export function listJobs(cwd) {
return loadState(cwd).jobs;
}

export function listJobsAcrossWorkspaces() {
const stateRoot = resolveStateRoot();
if (!fs.existsSync(stateRoot)) {
return [];
}

const jobs = [];
for (const entry of fs.readdirSync(stateRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}

const stateDir = path.join(stateRoot, entry.name);
const stateFile = path.join(stateDir, STATE_FILE_NAME);
if (!fs.existsSync(stateFile)) {
continue;
}

try {
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
for (const job of Array.isArray(parsed.jobs) ? parsed.jobs : []) {
if (!job || typeof job.id !== "string" || typeof job.workspaceRoot !== "string") {
continue;
}

// A state file is only authoritative for its own workspace. Do not
// trust a record that points at another directory merely because it
// was found beneath the shared plugin-data root.
if (path.resolve(resolveStateDir(job.workspaceRoot)) !== path.resolve(stateDir)) {
continue;
}
jobs.push(job);
}
} catch {
// Match loadState(): one damaged workspace index must not hide healthy
// job records belonging to other workspaces.
}
}

return jobs;
}

export function setConfig(cwd, key, value) {
return updateState(cwd, (state) => {
state.config = {
Expand Down
143 changes: 143 additions & 0 deletions tests/job-control.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";

import {
buildSingleJobSnapshot,
readStoredJob,
resolveResultJob
} from "../plugins/codex/scripts/lib/job-control.mjs";
import { resolveStateFile, saveState, writeJobFile } from "../plugins/codex/scripts/lib/state.mjs";
import { initGitRepo, makeTempDir } from "./helpers.mjs";

function completedJob(id, workspaceRoot) {
return {
id,
kind: "task",
jobClass: "task",
title: "Codex Task",
workspaceRoot,
status: "completed",
createdAt: "2026-07-25T10:00:00.000Z",
updatedAt: "2026-07-25T10:01:00.000Z"
};
}

function saveCompletedJob(workspaceRoot, job) {
saveState(workspaceRoot, {
version: 1,
config: { stopReviewGate: false },
jobs: [job]
});
writeJobFile(workspaceRoot, job.id, {
...job,
result: { rawOutput: `result for ${job.id}` }
});
}

test("explicit status and result lookups find a job after cwd moves to another repository", () => {
const previousPluginData = process.env.CLAUDE_PLUGIN_DATA;
const pluginData = makeTempDir();
const originalWorkspace = makeTempDir();
const currentWorkspace = makeTempDir();
process.env.CLAUDE_PLUGIN_DATA = pluginData;

try {
initGitRepo(originalWorkspace);
initGitRepo(currentWorkspace);
const job = completedJob("task-cross-workspace-1234", originalWorkspace);
saveCompletedJob(originalWorkspace, job);

const status = buildSingleJobSnapshot(currentWorkspace, job.id);
assert.equal(status.workspaceRoot, originalWorkspace);
assert.equal(status.job.id, job.id);

const result = resolveResultJob(currentWorkspace, "task-cross-workspace");
assert.equal(result.workspaceRoot, originalWorkspace);
assert.equal(result.job.id, job.id);
assert.equal(readStoredJob(result.workspaceRoot, result.job.id).result.rawOutput, `result for ${job.id}`);
} finally {
if (previousPluginData == null) {
delete process.env.CLAUDE_PLUGIN_DATA;
} else {
process.env.CLAUDE_PLUGIN_DATA = previousPluginData;
}
}
});

test("cross-workspace lookup rejects ambiguous prefixes", () => {
const previousPluginData = process.env.CLAUDE_PLUGIN_DATA;
const pluginData = makeTempDir();
const firstWorkspace = makeTempDir();
const secondWorkspace = makeTempDir();
const currentWorkspace = makeTempDir();
process.env.CLAUDE_PLUGIN_DATA = pluginData;

try {
for (const workspace of [firstWorkspace, secondWorkspace, currentWorkspace]) {
initGitRepo(workspace);
}
saveCompletedJob(firstWorkspace, completedJob("task-shared-prefix-one", firstWorkspace));
saveCompletedJob(secondWorkspace, completedJob("task-shared-prefix-two", secondWorkspace));

assert.throws(
() => buildSingleJobSnapshot(currentWorkspace, "task-shared-prefix"),
/Job reference "task-shared-prefix" is ambiguous/
);

const exactJob = completedJob("task-shared-prefix", currentWorkspace);
saveCompletedJob(currentWorkspace, exactJob);
const exact = buildSingleJobSnapshot(currentWorkspace, exactJob.id);
assert.equal(path.normalize(exact.workspaceRoot), path.normalize(currentWorkspace));
assert.equal(exact.job.id, exactJob.id);
} finally {
if (previousPluginData == null) {
delete process.env.CLAUDE_PLUGIN_DATA;
} else {
process.env.CLAUDE_PLUGIN_DATA = previousPluginData;
}
}
});

test("cross-workspace lookup ignores records stored under the wrong workspace key", () => {
const previousPluginData = process.env.CLAUDE_PLUGIN_DATA;
const pluginData = makeTempDir();
const claimedWorkspace = makeTempDir();
const storageWorkspace = makeTempDir();
const currentWorkspace = makeTempDir();
process.env.CLAUDE_PLUGIN_DATA = pluginData;

try {
for (const workspace of [claimedWorkspace, storageWorkspace, currentWorkspace]) {
initGitRepo(workspace);
}
const forgedJob = completedJob("task-mismatched-workspace", claimedWorkspace);
const stateFile = resolveStateFile(storageWorkspace);
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(
stateFile,
`${JSON.stringify(
{
version: 1,
config: { stopReviewGate: false },
jobs: [forgedJob]
},
null,
2
)}\n`,
"utf8"
);

assert.throws(
() => buildSingleJobSnapshot(currentWorkspace, forgedJob.id),
/No job found for "task-mismatched-workspace"/
);
} finally {
if (previousPluginData == null) {
delete process.env.CLAUDE_PLUGIN_DATA;
} else {
process.env.CLAUDE_PLUGIN_DATA = previousPluginData;
}
}
});