diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498..11e12663 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -150,6 +150,36 @@ export function listJobs(cwd) { return loadState(cwd).jobs; } +/** + * Check whether a process with the given pid is currently alive. + * Cross-platform: uses `process.kill(pid, 0)` which sends signal 0 + * (no-op signal) and throws ESRCH if the process is gone. EPERM means + * the process exists but we lack permission to signal it (still alive). + */ +export function isPidAlive(pid) { + if (!pid || typeof pid !== "number") return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err && err.code === "EPERM"; + } +} + +/** + * A job counts as "active" only if its status says queued/running AND + * the recorded pid is still alive. This prevents zombie state from + * causing false alarms when the codex process was SIGKILL'd + * (e.g., by a cleanup script or an OS reboot) without getting a chance + * to write status="completed" to disk. + */ +export function isJobActive(job) { + if (!job) return false; + if (job.status !== "queued" && job.status !== "running") return false; + if (!job.pid) return false; + return isPidAlive(job.pid); +} + export function setConfig(cwd, key, value) { return updateState(cwd, (state) => { state.config = { diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 2346bdcf..5bec0f75 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { getCodexAvailability } from "./lib/codex.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; -import { getConfig, listJobs } from "./lib/state.mjs"; +import { getConfig, isJobActive, listJobs } from "./lib/state.mjs"; import { sortJobsNewestFirst } from "./lib/job-control.mjs"; import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; @@ -146,7 +146,7 @@ function main() { const config = getConfig(workspaceRoot); const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input)); - const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running"); + const runningJob = jobs.find(isJobActive); const runningTaskNote = runningJob ? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.` : null;