diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 201d1c7a..8d18aebc 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -313,6 +313,35 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId); const activeTask = jobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running")); if (activeTask) { + // Check if the process is actually alive before blocking + if (activeTask.pid) { + try { + process.kill(activeTask.pid, 0); + } catch (err) { + // Process is dead but job status wasn't updated — mark it as failed and clean up + if (err.code === "ESRCH" || err.code === "EPERM") { + await upsertJob(workspaceRoot, { + id: activeTask.id, + status: "failed", + phase: "failed", + pid: null, + completedAt: new Date().toISOString(), + errorMessage: "Process exited unexpectedly while job status was 'running'." + }); + // Re-fetch jobs after the update + const updatedJobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId); + const newActiveTask = updatedJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running")); + if (!newActiveTask) { + // Zombie job was cleaned up, proceed normally + } else { + throw new Error(`Task ${newActiveTask.id} is still running. Use /codex:status before continuing it.`); + } + } else { + throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); + } + return null; + } + } throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); } @@ -457,7 +486,7 @@ async function executeTaskRun(request) { defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: request.fullAccess ? "danger-full-access" : (request.write ? "workspace-write" : "read-only"), onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -704,7 +733,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", "full-access", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" } diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 90286901..63c42238 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 { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile, loadState, saveState } from "./state.mjs"; export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; @@ -9,6 +9,56 @@ export function nowIso() { return new Date().toISOString(); } +/** + * Check if a process with the given PID is still alive. + * Uses signal 0 (existence check) to avoid side effects. + * @param {number} pid + * @returns {boolean} + */ +export function isProcessAlive(pid) { + if (!Number.isFinite(pid) || pid <= 0) { + return false; + } + try { + // Signal 0 checks if process exists without sending any signal + process.kill(pid, 0); + return true; + } catch (e) { + // ESRCH = no such process — process does not exist + // EPERM = permission denied — process exists but we can't signal it + return e.code === "EPERM"; + } +} + +/** + * Sweep all jobs in state and mark zombie (dead PID) jobs as failed. + * This prevents zombie "running" jobs from blocking new task submissions. + * @param {string} cwd - workspace root + * @returns {Promise} - number of jobs marked as failed + */ +export async function sweepZombieJobs(cwd) { + const state = loadState(cwd); + let zombiesFixed = 0; + const now = nowIso(); + + for (const job of state.jobs) { + if (job.status === "running" && job.pid) { + if (!isProcessAlive(job.pid)) { + console.warn(`[tracked-jobs] Zombie job detected: ${job.id} (PID ${job.pid}). Marking as failed.`); + job.status = "failed"; + job.endedAt = now; + job.error = "Process died unexpectedly — marked failed by zombie sweep"; + zombiesFixed++; + } + } + } + + if (zombiesFixed > 0) { + saveState(cwd, state); + } + return zombiesFixed; +} + function normalizeProgressEvent(value) { if (value && typeof value === "object" && !Array.isArray(value)) { return { @@ -140,6 +190,11 @@ function readStoredJobOrNull(workspaceRoot, jobId) { } export async function runTrackedJob(job, runner, options = {}) { + // FIX #216: Clean up zombie jobs before starting a new one. + // If a previous job's worker process died without updating state, + // its record stays stuck in "running" — blocking new tasks. + await sweepZombieJobs(job.workspaceRoot); + const runningRecord = { ...job, status: "running",