diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..46c2da172 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -79,7 +79,8 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--ephemeral] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " --ephemeral: run without persisting the Codex thread. Ephemeral tasks cannot be resumed and do not appear in Codex Recent. Cannot be combined with --resume/--resume-last.", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -309,6 +310,7 @@ function findLatestResumableTaskJob(jobs) { (job) => job.jobClass === "task" && job.threadId && + !job.ephemeral && job.status !== "queued" && job.status !== "running" ) ?? null @@ -482,6 +484,7 @@ async function executeTaskRun(request) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); } + const persistThread = resumeThreadId ? true : !request.ephemeral; const result = await runAppServerTurn(workspaceRoot, { resumeThreadId, prompt: request.prompt, @@ -490,8 +493,8 @@ async function executeTaskRun(request) { effort: request.effort, sandbox: request.write ? "workspace-write" : "read-only", onProgress: request.onProgress, - persistThread: true, - threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) + persistThread, + threadName: persistThread ? buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) : null }); const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; @@ -525,7 +528,8 @@ async function executeTaskRun(request) { summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), jobTitle: taskMetadata.title, jobClass: "task", - write: Boolean(request.write) + write: Boolean(request.write), + ephemeral: !persistThread }; } @@ -601,7 +605,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId, ephemeral }) { return { cwd, model, @@ -609,7 +613,8 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId prompt, write, resumeLast, - jobId + jobId, + ephemeral: Boolean(ephemeral) }; } @@ -762,7 +767,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "ephemeral"], aliasMap: { m: "model" } @@ -779,6 +784,10 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } + const ephemeral = Boolean(options.ephemeral); + if (ephemeral && resumeLast) { + throw new Error("--ephemeral cannot be combined with --resume/--resume-last. Ephemeral tasks are not persisted and cannot be resumed."); + } const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, @@ -797,7 +806,8 @@ async function handleTask(argv) { prompt, write, resumeLast, - jobId: job.id + jobId: job.id, + ephemeral }); const { payload } = enqueueBackgroundTask(cwd, job, request); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); @@ -816,6 +826,7 @@ async function handleTask(argv) { write, resumeLast, jobId: job.id, + ephemeral, onProgress: progress }), { json: options.json } diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec185236..47062ddcb 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -100,7 +100,7 @@ function escapeMarkdownCell(value) { } function formatCodexResumeCommand(job) { - if (!job?.threadId) { + if (!job?.threadId || job.ephemeral) { return null; } return `codex resume ${job.threadId}`; @@ -388,7 +388,8 @@ export function renderJobStatusReport(job) { } export function renderStoredJobResult(job, storedJob) { - const threadId = storedJob?.threadId ?? job.threadId ?? null; + const isEphemeral = Boolean(storedJob?.ephemeral ?? job.ephemeral); + const threadId = isEphemeral ? null : (storedJob?.threadId ?? job.threadId ?? null); const resumeCommand = threadId ? `codex resume ${threadId}` : null; if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 902869012..41484c1e2 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -160,6 +160,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: completionStatus, threadId: execution.threadId ?? null, turnId: execution.turnId ?? null, + ephemeral: Boolean(execution.ephemeral), pid: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, @@ -171,6 +172,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: completionStatus, threadId: execution.threadId ?? null, turnId: execution.turnId ?? null, + ephemeral: Boolean(execution.ephemeral), summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..857a2cc2e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -503,6 +503,133 @@ 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 --ephemeral runs without persisting a Codex thread", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + 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 result = run("node", [SCRIPT, "task", "--ephemeral", "disposable check"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.threads.length, 1); + assert.equal(fakeState.threads[0].ephemeral, true); + assert.equal( + fakeState.threads.filter((thread) => !thread.ephemeral).length, + 0, + "an ephemeral task must not create a persistent (Recent-visible) thread" + ); +}); + +test("task without --ephemeral still persists a Codex thread (unchanged default)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + 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 result = run("node", [SCRIPT, "task", "conversational check"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.threads.length, 1); + assert.equal(fakeState.threads[0].ephemeral, false); +}); + +test("repeated task --ephemeral runs stay independent and create zero persistent threads", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + 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 }); + + for (let i = 0; i < 3; i += 1) { + const result = run("node", [SCRIPT, "task", "--ephemeral", `disposable check ${i}`], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Handled the requested task/); + } + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.threads.length, 3); + assert.equal(fakeState.threads.every((thread) => thread.ephemeral), true); + assert.equal( + fakeState.threads.filter((thread) => !thread.ephemeral).length, + 0, + "N parallel/sequential ephemeral tasks must add 0 persistent threads" + ); +}); + +test("task --ephemeral rejects --resume-last", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--ephemeral", "--resume-last", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--ephemeral cannot be combined with --resume\/--resume-last/); +}); + +test("task --ephemeral rejects --resume", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--ephemeral", "--resume", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--ephemeral cannot be combined with --resume\/--resume-last/); +}); + +test("a completed ephemeral task cannot be picked up by a later --resume-last", () => { + 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 firstRun = run("node", [SCRIPT, "task", "--ephemeral", "disposable check"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(resume.status, 1); + assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); +}); + test("task-resume-candidate returns the latest rescue thread from the current session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace);