diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..bfa847e4f 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -30,6 +30,7 @@ import { generateJobId, getConfig, listJobs, + markJobCancellationRequested, setConfig, upsertJob, writeJobFile @@ -48,6 +49,7 @@ import { createJobProgressUpdater, createJobRecord, createProgressReporter, + failTrackedJobLaunch, nowIso, runTrackedJob, SESSION_ID_ENV @@ -668,7 +670,7 @@ async function runForegroundCommand(job, runner, options = {}) { return execution; } -function spawnDetachedTaskWorker(cwd, jobId) { +function spawnDetachedTaskWorker(cwd, jobId, onError) { const scriptPath = path.join(ROOT_DIR, "scripts", "codex-companion.mjs"); const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], { cwd, @@ -677,7 +679,8 @@ function spawnDetachedTaskWorker(cwd, jobId) { stdio: "ignore", windowsHide: true }); - child.unref(); + child.once("error", onError); + child.once("spawn", () => child.unref()); return child; } @@ -685,17 +688,23 @@ function enqueueBackgroundTask(cwd, job, request) { const { logFile } = createTrackedProgress(job); appendLogLine(logFile, "Queued for background execution."); - const child = spawnDetachedTaskWorker(cwd, job.id); const queuedRecord = { ...job, status: "queued", phase: "queued", - pid: child.pid ?? null, + pid: null, logFile, request }; writeJobFile(job.workspaceRoot, job.id, queuedRecord); upsertJob(job.workspaceRoot, queuedRecord); + const failSpawn = (error) => failTrackedJobLaunch(queuedRecord, error); + try { + spawnDetachedTaskWorker(cwd, job.id, failSpawn); + } catch (error) { + failSpawn(error); + throw error; + } return { payload: { @@ -850,6 +859,10 @@ async function handleTaskWorker(argv) { if (!storedJob) { throw new Error(`No stored job found for ${options["job-id"]}.`); } + if (storedJob.status !== "queued") { + appendLogLine(storedJob.logFile ?? null, `Background worker skipped ${storedJob.status} job.`); + return; + } const request = storedJob.request; if (!request || typeof request !== "object") { @@ -969,9 +982,11 @@ async function handleCancel(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env }); + markJobCancellationRequested(workspaceRoot, job.id); const existing = readStoredJob(workspaceRoot, job.id) ?? {}; - const threadId = existing.threadId ?? job.threadId ?? null; - const turnId = existing.turnId ?? job.turnId ?? null; + const latestJob = { ...job, ...existing }; + const threadId = latestJob.threadId ?? null; + const turnId = latestJob.turnId ?? null; const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId }); if (interrupt.attempted) { @@ -983,8 +998,8 @@ async function handleCancel(argv) { ); } - terminateProcessTree(job.pid ?? Number.NaN); - appendLogLine(job.logFile, "Cancelled by user."); + terminateProcessTree(latestJob.pid ?? Number.NaN); + appendLogLine(latestJob.logFile ?? job.logFile, "Cancelled by user."); const completedAt = nowIso(); const nextJob = { diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..039b68a81 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -108,6 +108,7 @@ export function saveState(cwd, state) { continue; } removeJobFile(resolveJobFile(cwd, job.id)); + removeFileIfExists(resolveJobCancellationFile(cwd, job.id)); removeFileIfExists(job.logFile); } @@ -189,3 +190,39 @@ export function resolveJobFile(cwd, jobId) { ensureStateDir(cwd); return path.join(resolveJobsDir(cwd), `${jobId}.json`); } + +export function resolveJobCancellationFile(cwd, jobId) { + ensureStateDir(cwd); + return path.join(resolveJobsDir(cwd), `${jobId}.cancelled`); +} + +export function markJobCancellationRequested(cwd, jobId) { + const marker = resolveJobCancellationFile(cwd, jobId); + fs.writeFileSync(marker, "cancel requested\n", "utf8"); + return marker; +} + +export function isJobCancellationRequested(cwd, jobId) { + return fs.existsSync(resolveJobCancellationFile(cwd, jobId)); +} + +export function resolveJobRemovalFile(cwd, jobId) { + ensureStateDir(cwd); + return path.join(resolveJobsDir(cwd), `${jobId}.removed`); +} + +export function markJobRemovalRequested(cwd, jobId) { + const marker = resolveJobRemovalFile(cwd, jobId); + fs.writeFileSync(marker, "removed\n", "utf8"); + return marker; +} + +export function isJobRemovalRequested(cwd, jobId) { + return fs.existsSync(resolveJobRemovalFile(cwd, jobId)); +} + +export function removeJobFromState(cwd, jobId) { + return updateState(cwd, (state) => { + state.jobs = state.jobs.filter((job) => job.id !== jobId); + }); +} diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 902869012..d220993ea 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import process from "node:process"; -import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; +import { isJobCancellationRequested, isJobRemovalRequested, readJobFile, removeJobFromState, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs"; export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; @@ -73,6 +73,9 @@ export function createJobProgressUpdater(workspaceRoot, jobId) { let lastTurnId = null; return (event) => { + if (isJobRemovalRequested(workspaceRoot, jobId)) { + return; + } const normalized = normalizeProgressEvent(event); const patch = { id: jobId }; let changed = false; @@ -139,7 +142,62 @@ function readStoredJobOrNull(workspaceRoot, jobId) { return readJobFile(jobFile); } +function removedExecution(job) { + return { + exitStatus: 0, + payload: { jobId: job.id, status: "removed" }, + rendered: "", + summary: "Session ended.", + threadId: null, + turnId: null + }; +} + +export function failTrackedJobLaunch(job, error) { + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + return { ...job, status: "removed", phase: "removed", pid: null }; + } + const currentJob = readStoredJobOrNull(job.workspaceRoot, job.id) ?? job; + if (isJobCancellationRequested(job.workspaceRoot, job.id) || currentJob.status !== "queued") { + return currentJob; + } + const completedAt = nowIso(); + const errorMessage = `Background worker failed to start: ${error instanceof Error ? error.message : String(error)}`; + const failedRecord = { ...currentJob, status: "failed", phase: "failed", pid: null, completedAt, errorMessage }; + writeJobFile(job.workspaceRoot, job.id, failedRecord); + upsertJob(job.workspaceRoot, failedRecord); + + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + const jobFile = resolveJobFile(job.workspaceRoot, job.id); + if (fs.existsSync(jobFile)) fs.unlinkSync(jobFile); + removeJobFromState(job.workspaceRoot, job.id); + return { ...currentJob, status: "removed", phase: "removed", pid: null }; + } + + if (isJobCancellationRequested(job.workspaceRoot, job.id)) { + const cancelledAt = nowIso(); + const cancelledRecord = { + ...currentJob, + status: "cancelled", + phase: "cancelled", + pid: null, + completedAt: cancelledAt, + cancelledAt, + errorMessage: "Cancelled by user." + }; + writeJobFile(job.workspaceRoot, job.id, cancelledRecord); + upsertJob(job.workspaceRoot, cancelledRecord); + return cancelledRecord; + } + + appendLogLine(currentJob.logFile ?? null, errorMessage); + return failedRecord; +} + export async function runTrackedJob(job, runner, options = {}) { + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + return removedExecution(job); + } const runningRecord = { ...job, status: "running", @@ -148,11 +206,60 @@ export async function runTrackedJob(job, runner, options = {}) { pid: process.pid, logFile: options.logFile ?? job.logFile ?? null }; - writeJobFile(job.workspaceRoot, job.id, runningRecord); - upsertJob(job.workspaceRoot, runningRecord); - try { + writeJobFile(job.workspaceRoot, job.id, runningRecord); + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + const jobFile = resolveJobFile(job.workspaceRoot, job.id); + if (fs.existsSync(jobFile)) fs.unlinkSync(jobFile); + return removedExecution(job); + } + upsertJob(job.workspaceRoot, runningRecord); + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + removeJobFromState(job.workspaceRoot, job.id); + return removedExecution(job); + } + if (isJobCancellationRequested(job.workspaceRoot, job.id)) { + const completedAt = nowIso(); + const cancelledRecord = { + ...runningRecord, + status: "cancelled", + phase: "cancelled", + pid: null, + completedAt, + cancelledAt: completedAt, + errorMessage: "Cancelled by user." + }; + writeJobFile(job.workspaceRoot, job.id, cancelledRecord); + upsertJob(job.workspaceRoot, { + id: job.id, + status: "cancelled", + phase: "cancelled", + pid: null, + completedAt, + cancelledAt: completedAt, + errorMessage: "Cancelled by user." + }); + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + const jobFile = resolveJobFile(job.workspaceRoot, job.id); + if (fs.existsSync(jobFile)) fs.unlinkSync(jobFile); + removeJobFromState(job.workspaceRoot, job.id); + return removedExecution(job); + } + appendLogLine(options.logFile ?? job.logFile ?? null, "Cancelled before task execution."); + return { + exitStatus: 0, + payload: { jobId: job.id, status: "cancelled" }, + rendered: "Cancelled by user.\n", + summary: "Cancelled by user.", + threadId: null, + turnId: null + }; + } const execution = await runner(); + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + removeJobFromState(job.workspaceRoot, job.id); + return removedExecution(job); + } const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; const completedAt = nowIso(); writeJobFile(job.workspaceRoot, job.id, { @@ -179,6 +286,10 @@ export async function runTrackedJob(job, runner, options = {}) { appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); return execution; } catch (error) { + if (isJobRemovalRequested(job.workspaceRoot, job.id)) { + removeJobFromState(job.workspaceRoot, job.id); + return removedExecution(job); + } const errorMessage = error instanceof Error ? error.message : String(error); const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; const completedAt = nowIso(); diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 778571e6c..2083e7ea2 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -13,7 +13,7 @@ import { sendBrokerShutdown, teardownBrokerSession } from "./lib/broker-lifecycle.mjs"; -import { loadState, resolveStateFile, saveState } from "./lib/state.mjs"; +import { loadState, markJobRemovalRequested, readJobFile, resolveJobFile, resolveStateFile, saveState } from "./lib/state.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; @@ -61,8 +61,14 @@ function cleanupSessionJobs(cwd, sessionId) { if (!stillRunning) { continue; } + markJobRemovalRequested(workspaceRoot, job.id); + let effectiveJob = job; try { - terminateProcessTree(job.pid ?? Number.NaN); + const jobFile = resolveJobFile(workspaceRoot, job.id); + if (fs.existsSync(jobFile)) effectiveJob = { ...job, ...readJobFile(jobFile) }; + } catch {} + try { + terminateProcessTree(effectiveJob.pid ?? Number.NaN); } catch { // Ignore teardown failures during session shutdown. } diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..8d195ca7d 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -210,6 +210,37 @@ test("hooks keep session-end cleanup and stop gating enabled", () => { assert.match(source, /session-lifecycle-hook\.mjs/); }); +test("background task persists its bootstrap record before spawning the worker", () => { + const source = read("scripts/codex-companion.mjs"); + const start = source.indexOf("function enqueueBackgroundTask"); + const end = source.indexOf("\nasync function handleReviewCommand", start); + const body = source.slice(start, end); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + assert.ok(body.indexOf("writeJobFile") < body.indexOf("spawnDetachedTaskWorker")); + assert.ok(body.indexOf("upsertJob") < body.indexOf("spawnDetachedTaskWorker")); + + const tracked = read("scripts/lib/tracked-jobs.mjs"); + const trackedStart = tracked.indexOf("export async function runTrackedJob"); + const trackedBody = tracked.slice(trackedStart); + assert.ok(trackedBody.indexOf("try {") < trackedBody.indexOf("writeJobFile")); + assert.ok(trackedBody.indexOf("try {") < trackedBody.indexOf("upsertJob")); +}); + +test("cancel publishes its durable request marker before awaiting the app-server", () => { + const source = read("scripts/codex-companion.mjs"); + const start = source.indexOf("async function handleCancel"); + const end = source.indexOf("\nasync function main", start); + const body = source.slice(start, end); + const markerWrite = body.indexOf("markJobCancellationRequested"); + const interrupt = body.indexOf("await interruptAppServerTurn"); + const reread = body.indexOf("readStoredJob"); + const terminate = body.indexOf("terminateProcessTree(latestJob.pid"); + assert.ok(markerWrite >= 0 && markerWrite < reread); + assert.ok(markerWrite < interrupt); + assert.ok(reread < terminate); +}); + test("setup command can offer Codex install and still points users to codex login", () => { const setup = read("commands/setup.md"); const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..635026bca 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -969,6 +969,38 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); }); +test("cancelled queued task is never reclaimed by a late-starting worker", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + const stateDir = resolveStateDir(repo); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + const jobId = "task-startup-cancel"; + const logFile = path.join(jobsDir, `${jobId}.log`); + const record = { + id: jobId, status: "queued", phase: "queued", pid: null, title: "Codex Task", + jobClass: "task", summary: "Do not execute after cancel", workspaceRoot: repo, logFile, + request: { cwd: repo, prompt: "do not execute", write: true, resumeLast: false, jobId }, + createdAt: "2026-09-04T18:00:00.000Z", updatedAt: "2026-09-04T18:00:00.000Z" + }; + fs.writeFileSync(path.join(jobsDir, `${jobId}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8"); + fs.writeFileSync(path.join(stateDir, "state.json"), `${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [record] }, null, 2)}\n`, "utf8"); + fs.writeFileSync(logFile, "queued\n", "utf8"); + const env = buildEnv(binDir); + const cancelled = run("node", [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(cancelled.status, 0, cancelled.stderr); + assert.equal(JSON.parse(cancelled.stdout).status, "cancelled"); + assert.equal(fs.existsSync(path.join(jobsDir, `${jobId}.cancelled`)), true); + const worker = run("node", [SCRIPT, "task-worker", "--cwd", repo, "--job-id", jobId], { cwd: repo, env }); + assert.equal(worker.status, 0, worker.stderr); + assert.equal(fs.existsSync(fakeStatePath), false); + const stored = JSON.parse(fs.readFileSync(path.join(jobsDir, `${jobId}.json`), "utf8")); + assert.equal(stored.status, "cancelled"); +}); + test("review rejects focus text because it is native-review only", () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -1905,7 +1937,7 @@ 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(otherJobFile), path.basename(otherSessionLog), "review-running.removed"].sort() ); await waitFor(() => { diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57cea..fef354661 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,7 +5,7 @@ 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 { resolveJobCancellationFile, resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); @@ -61,6 +61,9 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", }; }); + const prunedCancellationFile = resolveJobCancellationFile(workspace, "job-0"); + fs.writeFileSync(prunedCancellationFile, "cancel requested\n", "utf8"); + fs.writeFileSync( stateFile, `${JSON.stringify( @@ -89,6 +92,7 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", assert.equal(fs.existsSync(retainedJobFile), true); assert.equal(fs.existsSync(retainedLogFile), true); + assert.equal(fs.existsSync(prunedCancellationFile), false); const savedState = JSON.parse(fs.readFileSync(stateFile, "utf8")); assert.equal(savedState.jobs.length, 50); diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs new file mode 100644 index 000000000..2231e9257 --- /dev/null +++ b/tests/tracked-jobs.test.mjs @@ -0,0 +1,157 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; + +import { initGitRepo, makeTempDir } from "./helpers.mjs"; +import { + markJobCancellationRequested, + markJobRemovalRequested, + readJobFile, + resolveJobFile +} from "../plugins/codex/scripts/lib/state.mjs"; +import { failTrackedJobLaunch, runTrackedJob } from "../plugins/codex/scripts/lib/tracked-jobs.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const TRACKED_JOBS_SOURCE = path.join(ROOT, "plugins", "codex", "scripts", "lib", "tracked-jobs.mjs"); + +test("runTrackedJob honors a durable cancellation request before invoking the runner", async () => { + const repo = makeTempDir(); + initGitRepo(repo); + const jobId = "task-cancel-handoff"; + markJobCancellationRequested(repo, jobId); + let runnerCalled = false; + const execution = await runTrackedJob( + { id: jobId, workspaceRoot: repo, title: "Codex Task", status: "queued" }, + async () => { + runnerCalled = true; + return { exitStatus: 0, payload: {}, rendered: "ran\n", summary: "ran" }; + } + ); + assert.equal(runnerCalled, false); + assert.equal(execution.payload.status, "cancelled"); + const stored = readJobFile(resolveJobFile(repo, jobId)); + assert.equal(stored.status, "cancelled"); + assert.equal(stored.pid, null); +}); + +test("runTrackedJob checks cancellation after publishing running state and before the runner", () => { + const source = fs.readFileSync(TRACKED_JOBS_SOURCE, "utf8"); + const start = source.indexOf("export async function runTrackedJob"); + const end = source.indexOf("\n}", start) + 2; + const body = source.slice(start, end); + const runningWrite = body.indexOf("upsertJob(job.workspaceRoot, runningRecord)"); + const cancelCheck = body.indexOf("isJobCancellationRequested(job.workspaceRoot, job.id)"); + const runnerCall = body.indexOf("await runner()"); + assert.ok(runningWrite >= 0 && runningWrite < cancelCheck); + assert.ok(cancelCheck < runnerCall); +}); + + +test("runTrackedJob rechecks removal after persisting startup cancellation", () => { + const source = fs.readFileSync(TRACKED_JOBS_SOURCE, "utf8"); + const start = source.indexOf("if (isJobCancellationRequested(job.workspaceRoot, job.id))", source.indexOf("export async function runTrackedJob")); + const end = source.indexOf("const execution = await runner()", start); + const branch = source.slice(start, end); + const cancelledWrite = branch.indexOf("writeJobFile(job.workspaceRoot, job.id, cancelledRecord)"); + const cancelledUpsert = branch.indexOf("upsertJob(job.workspaceRoot"); + const removalRecheck = branch.indexOf("isJobRemovalRequested(job.workspaceRoot, job.id)", cancelledUpsert); + assert.ok(cancelledWrite >= 0 && cancelledWrite < cancelledUpsert); + assert.ok(cancelledUpsert >= 0 && cancelledUpsert < removalRecheck); +}); + +test("runTrackedJob does not recreate a job removed during execution", async () => { + const repo = makeTempDir(); + initGitRepo(repo); + const jobId = "task-session-end"; + const execution = await runTrackedJob( + { id: jobId, workspaceRoot: repo, title: "Codex Task", status: "queued" }, + async () => { + markJobRemovalRequested(repo, jobId); + return { exitStatus: 0, payload: {}, rendered: "ran\n", summary: "ran" }; + } + ); + assert.equal(execution.payload.status, "removed"); + assert.equal(fs.existsSync(resolveJobFile(repo, jobId)), false); +}); + +test("failTrackedJobLaunch transitions a queued job to failed", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const jobId = "task-spawn-failure"; + const failed = failTrackedJobLaunch( + { id: jobId, workspaceRoot: repo, status: "queued", phase: "queued", pid: null }, + new Error("spawn denied") + ); + assert.equal(failed.status, "failed"); + assert.match(failed.errorMessage, /spawn denied/); + assert.equal(readJobFile(resolveJobFile(repo, jobId)).status, "failed"); +}); + +test("failTrackedJobLaunch does not overwrite a cancelled job", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const jobId = "task-spawn-failure-after-cancel"; + const jobFile = resolveJobFile(repo, jobId); + fs.writeFileSync( + jobFile, + `${JSON.stringify({ id: jobId, workspaceRoot: repo, status: "cancelled", phase: "cancelled", pid: null }, null, 2)}\n`, + "utf8" + ); + markJobCancellationRequested(repo, jobId); + + const result = failTrackedJobLaunch( + { id: jobId, workspaceRoot: repo, status: "queued", phase: "queued", pid: null }, + new Error("late spawn error") + ); + + assert.equal(result.status, "cancelled"); + assert.equal(readJobFile(jobFile).status, "cancelled"); +}); + +test("failTrackedJobLaunch preserves cancellation that wins after the initial check", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const jobId = "task-spawn-failure-races-cancel"; + const jobFile = resolveJobFile(repo, jobId); + const queued = { id: jobId, workspaceRoot: repo, status: "queued", phase: "queued", pid: null }; + fs.writeFileSync(jobFile, `${JSON.stringify(queued, null, 2)}\n`, "utf8"); + + const error = { + toString() { + markJobCancellationRequested(repo, jobId); + fs.writeFileSync( + jobFile, + `${JSON.stringify({ ...queued, status: "cancelled", phase: "cancelled" }, null, 2)}\n`, + "utf8" + ); + return "late spawn error"; + } + }; + + const result = failTrackedJobLaunch(queued, error); + assert.equal(result.status, "cancelled"); + assert.equal(readJobFile(jobFile).status, "cancelled"); +}); + +test("failTrackedJobLaunch preserves removal that wins after the initial check", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const jobId = "task-spawn-failure-races-removal"; + const jobFile = resolveJobFile(repo, jobId); + const queued = { id: jobId, workspaceRoot: repo, status: "queued", phase: "queued", pid: null }; + fs.writeFileSync(jobFile, `${JSON.stringify(queued, null, 2)}\n`, "utf8"); + + const error = { + toString() { + markJobRemovalRequested(repo, jobId); + fs.unlinkSync(jobFile); + return "late spawn error"; + } + }; + + const result = failTrackedJobLaunch(queued, error); + assert.equal(result.status, "removed"); + assert.equal(fs.existsSync(jobFile), false); +});