diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468a..f58c9092 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -27,6 +27,7 @@ import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from " import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { + clearTerminalJobs, generateJobId, getConfig, listJobs, @@ -779,6 +780,9 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } + if (fresh) { + clearTerminalJobs(cwd); + } const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498..85f2f6fe 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -147,7 +147,34 @@ export function upsertJob(cwd, jobPatch) { } export function listJobs(cwd) { - return loadState(cwd).jobs; + const state = loadState(cwd); + const now = Date.now(); + const terminalStatuses = ["completed", "failed", "cancelled", "stopped"]; + const nextJobs = state.jobs.filter((job) => { + if (terminalStatuses.includes(job.status) && job.updatedAt) { + const updatedTime = Date.parse(job.updatedAt); + if (Number.isFinite(updatedTime) && now - updatedTime > 30 * 60 * 1000) { + return false; + } + } + return true; + }); + if (nextJobs.length !== state.jobs.length) { + state.jobs = nextJobs; + saveState(cwd, state); + } + return nextJobs; +} + +export function clearTerminalJobs(cwd) { + const state = loadState(cwd); + const terminalStatuses = ["completed", "failed", "cancelled", "stopped"]; + const nextJobs = state.jobs.filter((job) => !terminalStatuses.includes(job.status)); + if (nextJobs.length !== state.jobs.length) { + state.jobs = nextJobs; + saveState(cwd, state); + } + return nextJobs; } export function setConfig(cwd, key, value) { diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57ce..84bfda35 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -4,8 +4,8 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { makeTempDir, initGitRepo } from "./helpers.mjs"; +import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState, listJobs, clearTerminalJobs } from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); @@ -42,6 +42,7 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => { const workspace = makeTempDir(); + initGitRepo(workspace); const stateFile = resolveStateFile(workspace); fs.mkdirSync(path.dirname(stateFile), { recursive: true }); @@ -103,3 +104,118 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", .sort() ); }); + +test("listJobs automatically garbage collects completed/failed jobs older than 30 minutes", () => { + const workspace = makeTempDir(); + initGitRepo(workspace); + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + + const now = Date.now(); + const jobs = [ + { + id: "job-running-old", + status: "running", + updatedAt: new Date(now - 40 * 60 * 1000).toISOString(), + createdAt: new Date(now - 40 * 60 * 1000).toISOString() + }, + { + id: "job-queued-old", + status: "queued", + updatedAt: new Date(now - 40 * 60 * 1000).toISOString(), + createdAt: new Date(now - 40 * 60 * 1000).toISOString() + }, + { + id: "job-completed-old", + status: "completed", + updatedAt: new Date(now - 40 * 60 * 1000).toISOString(), + createdAt: new Date(now - 40 * 60 * 1000).toISOString() + }, + { + id: "job-completed-fresh", + status: "completed", + updatedAt: new Date(now - 10 * 60 * 1000).toISOString(), + createdAt: new Date(now - 10 * 60 * 1000).toISOString() + } + ]; + + for (const job of jobs) { + fs.writeFileSync(resolveJobFile(workspace, job.id), JSON.stringify(job), "utf8"); + fs.writeFileSync(resolveJobLogFile(workspace, job.id), "log\n", "utf8"); + } + + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs + }); + + // Old completed job should be GC'ed, old running/queued and fresh completed jobs should remain. + const activeJobs = listJobs(workspace); + assert.equal(activeJobs.length, 3); + assert.equal(activeJobs.some((j) => j.id === "job-running-old"), true); + assert.equal(activeJobs.some((j) => j.id === "job-queued-old"), true); + assert.equal(activeJobs.some((j) => j.id === "job-completed-fresh"), true); + assert.equal(activeJobs.some((j) => j.id === "job-completed-old"), false); + + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-completed-old")), false); + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-completed-fresh")), true); + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-running-old")), true); + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-queued-old")), true); +}); + +test("clearTerminalJobs removes all completed/failed/stopped jobs but keeps running ones", () => { + const workspace = makeTempDir(); + initGitRepo(workspace); + const stateFile = resolveStateFile(workspace); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + + const now = Date.now(); + const jobs = [ + { + id: "job-running", + status: "running", + updatedAt: new Date(now).toISOString(), + createdAt: new Date(now).toISOString() + }, + { + id: "job-queued", + status: "queued", + updatedAt: new Date(now).toISOString(), + createdAt: new Date(now).toISOString() + }, + { + id: "job-completed", + status: "completed", + updatedAt: new Date(now).toISOString(), + createdAt: new Date(now).toISOString() + }, + { + id: "job-failed", + status: "failed", + updatedAt: new Date(now).toISOString(), + createdAt: new Date(now).toISOString() + } + ]; + + for (const job of jobs) { + fs.writeFileSync(resolveJobFile(workspace, job.id), JSON.stringify(job), "utf8"); + fs.writeFileSync(resolveJobLogFile(workspace, job.id), "log\n", "utf8"); + } + + saveState(workspace, { + version: 1, + config: { stopReviewGate: false }, + jobs + }); + + const activeJobs = clearTerminalJobs(workspace); + assert.equal(activeJobs.length, 2); + assert.equal(activeJobs.some((j) => j.id === "job-running"), true); + assert.equal(activeJobs.some((j) => j.id === "job-queued"), true); + + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-completed")), false); + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-failed")), false); + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-running")), true); + assert.equal(fs.existsSync(resolveJobFile(workspace, "job-queued")), true); +});