From 49b88bc06590d4d8682dfadff52bb86a28019533 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:24:43 +0300 Subject: [PATCH 01/11] docs(plans): v1.4.3 internal restructure plan (roadmap row 2d) Co-Authored-By: Claude Fable 5.1 --- .../2026-09-30-codex-plugin-cc-v1.4.3.md | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md diff --git a/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md b/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md new file mode 100644 index 000000000..d7e96b4ed --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md @@ -0,0 +1,367 @@ +# codex-plugin-cc v1.4.3 — Implementation Plan (internal restructure) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Same behaviour, fewer copies: every helper that exists twice in `plugins/codex/scripts` lives once; the `"queued"/"running"` string comparisons go through `lib/job-status.mjs`; the test harness shares its helpers and `tests/runtime.test.mjs` is split by command family; a module-boundaries test and `no-unused-vars` keep it that way. + +**Architecture:** Pure moves and renames, no control-flow edits. `render.mjs` and the new `job-status.mjs` stay leaves (`render` may import only `./job-status.mjs`); `state.mjs`/`fs.mjs` own the shared file helpers; `tracked-jobs.mjs` owns the session filter. Tests change only by import paths, helper names and file moves. `plugins/codex/scripts/codex-companion.mjs` keeps its path and name. + +**Tech Stack:** Node 18.18+ ESM, zero runtime deps, `node:test` (files auto-discovered by `scripts/run-tests.mjs`), `rg`. + +**Spec:** no spec — behaviour-preserving restructure; design = roadmap section Р3 + this plan. + +## Global Constraints + +- Worktree `.worktrees/release-v1.4.3`, branch `release/v1.4.3` (base `main` 1f91ec2). Claim per `docs/agent/process.md` before the first write: draft PR ``, `agent-work claim --target ` → `` in `.superpowers/sdd/2026-09-30-codex-plugin-cc-v1.4.3/progress.md`. No push without the controller's go. +- `rg`, never `grep`/`egrep`/`fgrep`; never `git add -A`. +- Gate before every commit, `&&` only: `npm run check && sleep 10 && [ "$(pgrep -f codex-plugin-test- | wc -l | tr -d ' ')" = 0 ] && git add && git commit -m "…"`. While iterating: `node --import ./tests/test-env.mjs --test tests/.test.mjs`. +- Baseline (v1.4.2 ledger, macOS): 466 tests / 455 pass / 11 skipped. After each commit the summary must be 466/455/11 plus exactly the tests this plan adds (A2 +3, D1 +4 → 473/462/11 at the end). +- Line numbers are verified on 1f91ec2 and shift as tasks land; every "delete Lx–y" names the symbol so the implementer re-anchors with `rg -n`. +- Commit trailer, always: `Co-Authored-By: Claude Fable 5.1 `. +- Ponytail lite: each task names the lazier alternative; the ruled version is what is built. + +## Review Focus + +1. Every moved function is byte-identical to its source (diff the bodies, not the intent); the only edits are `export`, parameter names and the explicit `shorten` limit. +2. `isActiveJobStatus(x?.status)` replaces `x?.status !== "queued" && x?.status !== "running"` only where `undefined` was already "not active"; `isTerminalRecord` keeps the `Boolean(stored) &&` guard. +3. The session filter keeps `env?.[X] ?? process.env[X] ?? null`; the stop hook's `input.session_id || …` variant is not touched. +4. `tests/broker-stale-pid.test.mjs:515–526` keeps its null-returning `waitUntil` (the failure diagnostic depends on it). +5. The split keeps the 143 test titles as a set; no test gains or loses `{ skip, timeout }` options; helper renames only. + +--- + +### Task A1 (Sonnet): `nowIso`, `readStoredJob`, `readJsonOrNull` have one home; dead `fs.mjs` exports go + +**Files:** `plugins/codex/scripts/lib/state.mjs`, `lib/fs.mjs`, `lib/tracked-jobs.mjs`, `lib/job-control.mjs`, `lib/model-catalog.mjs`, `codex-companion.mjs`. Tests: none (the only test mention is the comment at `tests/runtime.test.mjs:3592`). + +Verified duplicates: `nowIso` — `state.mjs:17` (private) and `tracked-jobs.mjs:20` (exported, used by companion L1439), identical. `readStoredJob` — `job-control.mjs:185–191` and `tracked-jobs.mjs:169–175` (`readStoredJobOrNull`), identical bodies. `readJsonOrNull` — `state.mjs:140–146` and `model-catalog.mjs:32–34` (`readJson`), identical semantics. `createTempDir`/`writeJsonFile`/`safeReadFile` — zero callers in scripts, tests and repo scripts (`rg -n 'createTempDir|writeJsonFile|safeReadFile' plugins tests scripts` → only the definitions). + +- [ ] **Step 1: `fs.mjs`.** Delete `import os from "node:os";` (L2, only used by `createTempDir`), `createTempDir` (L9–11), `writeJsonFile` (L17–19), `safeReadFile` (L21–23). After `readJsonFile` add: + +```js +// Missing, unreadable or malformed file → null (state index, job files, model catalogue). +export function readJsonOrNull(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} +``` + +- [ ] **Step 2: `state.mjs`.** L17 `function nowIso()` → `export function nowIso()`. After L8 add `import { readJsonOrNull } from "./fs.mjs";`. Delete the private `readJsonOrNull` (L140–146). After `resolveJobFile` (L821–824) add: + +```js +// The job file, or null when the job has none yet (queued, never started). +export function readStoredJob(cwd, jobId) { + const jobFile = resolveJobFile(cwd, jobId); + if (!fs.existsSync(jobFile)) { + return null; + } + return readJobFile(jobFile); +} +``` + +- [ ] **Step 3: `tracked-jobs.mjs`.** Delete `nowIso` (L20–22) and `readStoredJobOrNull` (L169–175). Import block L6–16: add `nowIso,` and `readStoredJob,` (alphabetical). Rename the six call sites L182, L206, L232, L282, L465, L522: `readStoredJobOrNull(` → `readStoredJob(`. +- [ ] **Step 4: `job-control.mjs`.** Delete `readStoredJob` (L185–191); L6 state import: add `readStoredJob`. (`fs` stays: L64, L423.) +- [ ] **Step 5: `model-catalog.mjs`.** Delete `readJson` (L32–34); after L5 add `import { readJsonOrNull } from "./fs.mjs";`; L41 and L43 `readJson(` → `readJsonOrNull(`. (Stryker's `model-catalog.mjs` shard loses three mutants; harmless.) +- [ ] **Step 6: `codex-companion.mjs`.** Move `nowIso,` (L68) and `readStoredJob,` (L57) into the `./lib/state.mjs` block (L31–46, alphabetical). Lazier alternative: a one-line `export { readStoredJob } from "./state.mjs";` in job-control — rejected, the direct import is two lines and D1 then reads the true graph. +- [ ] **Step 7: Run** `node --import ./tests/test-env.mjs --test tests/state.test.mjs tests/tracked-jobs.test.mjs tests/job-control.test.mjs tests/model-catalog.test.mjs` → all pass; `rg -n 'readStoredJobOrNull|function readJson\b|function nowIso' plugins/codex/scripts` → `state.mjs` `nowIso` only. +- [ ] **Step 8: Commit** (gate chain) `git add plugins/codex/scripts/lib/state.mjs plugins/codex/scripts/lib/fs.mjs plugins/codex/scripts/lib/tracked-jobs.mjs plugins/codex/scripts/lib/job-control.mjs plugins/codex/scripts/lib/model-catalog.mjs plugins/codex/scripts/codex-companion.mjs && git commit -m "refactor(lib): one nowIso, readStoredJob and readJsonOrNull; drop the dead fs.mjs exports"`. + +### Task A2 (Sonnet): `lib/job-status.mjs` replaces the queued/running string comparisons + +**Files:** Create `plugins/codex/scripts/lib/job-status.mjs`, `tests/job-status.test.mjs`. Modify `lib/state.mjs`, `lib/tracked-jobs.mjs`, `lib/job-control.mjs`, `lib/codex.mjs`, `lib/render.mjs`, `session-lifecycle-hook.mjs`, `stop-review-gate-hook.mjs`, `codex-companion.mjs`. + +Count on 1f91ec2: `rg -n 'status\s*(===|!==)\s*"(queued|running)"' plugins/codex/scripts` → 33 lines (companion L451–452 is one comparison): tracked-jobs 7, state 4, codex 1, render 6, session hook 2, job-control 7, stop hook 1, companion 4 (+ the private `isActiveJobStatus` L429–431, `isActiveStatus` tracked-jobs L165–167, `isTerminalRecord` job-control L326–328). Phase strings (`"queued"` as a phase, `status: "running"` literals in records, comments, tests) stay. + +- [ ] **Step 1: Create `plugins/codex/scripts/lib/job-status.mjs`** (zero imports): + +```js +// Job status vocabulary. A job is active while a worker may still write it. +export const JOB_STATUS = Object.freeze({ + QUEUED: "queued", + RUNNING: "running", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled" +}); + +export function isActiveJobStatus(status) { + return status === JOB_STATUS.QUEUED || status === JOB_STATUS.RUNNING; +} + +// A stored record that no worker will write again; null/undefined is not terminal. +export function isTerminalRecord(record) { + return Boolean(record) && !isActiveJobStatus(record.status); +} +``` + +- [ ] **Step 2: Create `tests/job-status.test.mjs`:** + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "../plugins/codex/scripts/lib/job-status.mjs"; + +test("isActiveJobStatus: only queued and running are active", () => { + assert.deepEqual(Object.values(JOB_STATUS).map(isActiveJobStatus), [true, true, false, false, false]); + assert.equal(isActiveJobStatus(undefined), false); + assert.equal(isActiveJobStatus("done"), false); +}); + +test("isTerminalRecord: a finished record is terminal, a missing or active one is not", () => { + assert.equal(isTerminalRecord({ status: "completed" }), true); + assert.equal(isTerminalRecord({ status: "running" }), false); + assert.equal(isTerminalRecord(null), false); + assert.equal(isTerminalRecord(undefined), false); +}); + +test("JOB_STATUS is frozen", () => { + assert.ok(Object.isFrozen(JOB_STATUS)); +}); +``` + +- [ ] **Step 3: Replace, file by file** (import line first, then the sites). `A(x)` = `isActiveJobStatus(x)`, `T(x)` = `isTerminalRecord(x)`: + - `state.mjs`: `import { isActiveJobStatus, JOB_STATUS } from "./job-status.mjs";` L726 `indexed?.status !== "queued" && indexed?.status !== "running"` → `!A(indexed?.status)`; L730 `indexed.status === "queued"` → `indexed.status === JOB_STATUS.QUEUED`; L793 `current.status === "queued"` → `=== JOB_STATUS.QUEUED`; L862 `job?.status !== "queued" && job?.status !== "running"` → `!A(job?.status)`. + - `tracked-jobs.mjs`: import `isActiveJobStatus, isTerminalRecord, JOB_STATUS`; delete `isActiveStatus` L165–167; L207 `!isActiveStatus(stored.status)` → `!A(stored.status)`; L327 → `!A(base.status)`; L397 `job.status !== "queued" || pid != null` → `job.status !== JOB_STATUS.QUEUED || pid != null`; L462, L519 → `!A(job.status)`; L466, L523 `stored && stored.status !== "running" && stored.status !== "queued"` → `T(stored)`. + - `job-control.mjs`: import `isActiveJobStatus, isTerminalRecord, JOB_STATUS`; delete the private `isTerminalRecord` L326–328 (callers L334, L356 unchanged); L169 → `A(job.status) || job.status === JOB_STATUS.FAILED`; L227, L283, L300 → `A(job.status)`; L230, L234 → `!A(job.status)`. + - `codex.mjs` L1355 → `A(job.status)`; `render.mjs` L115, L152, L385 → `A(job.status)`, L155, L158, L386 → `!A(job.status)` (render's only lib import: `import { isActiveJobStatus } from "./job-status.mjs";` at L1). + - `session-lifecycle-hook.mjs` L144, L235 → `A(job.status)`; `stop-review-gate-hook.mjs` L237 → `A(job.status)` (both `from "./lib/job-status.mjs"`). + - `codex-companion.mjs`: delete L429–431; import `isActiveJobStatus, isTerminalRecord` from `./lib/job-status.mjs`; L451–452 → `!A(job.status)`; L534 → `A(job.status)`; L1402 `stored && stored.status !== "queued" && stored.status !== "running"` → `T(stored)`. +- [ ] **Step 4: Proof.** `rg -n 'status\s*(===|!==)\s*"(queued|running)"' plugins/codex/scripts` → no output. `node --import ./tests/test-env.mjs --test tests/job-status.test.mjs tests/render.test.mjs tests/state.test.mjs tests/tracked-jobs.test.mjs tests/job-control.test.mjs tests/session-lifecycle-hook.test.mjs` → pass. +- [ ] **Step 5: Commit** `git add plugins/codex/scripts/lib/job-status.mjs tests/job-status.test.mjs plugins/codex/scripts/lib/state.mjs plugins/codex/scripts/lib/tracked-jobs.mjs plugins/codex/scripts/lib/job-control.mjs plugins/codex/scripts/lib/codex.mjs plugins/codex/scripts/lib/render.mjs plugins/codex/scripts/session-lifecycle-hook.mjs plugins/codex/scripts/stop-review-gate-hook.mjs plugins/codex/scripts/codex-companion.mjs && git commit -m "refactor(lib): job-status.mjs replaces the queued/running string comparisons"`. + +Lazier alternative: only `isActiveJobStatus`, no `JOB_STATUS`/`isTerminalRecord` — rejected because the three single-status sites (state L730/L793, tracked-jobs L397) would keep the regex proof from reaching zero. + +### Task A3 (Sonnet): `render.mjs` owns `shorten`, `looksLikeVerificationCommand`, the pending-cancel text; session filter and JSON-RPC error de-duplicated + +**Files:** `lib/render.mjs`, `lib/codex.mjs`, `lib/job-control.mjs`, `lib/tracked-jobs.mjs`, `lib/app-server.mjs`, `app-server-broker.mjs`, `codex-companion.mjs`; test `tests/job-control.test.mjs` (L11 import only). + +Verified: `looksLikeVerificationCommand` — `job-control.mjs:105–109` and `codex.mjs:157–161`, same regex, parameter `line` vs `command`. `shorten` — `codex.mjs:146–155` (default 72, never used: L164 passes 56, L309/329/343/519/551/552 pass 96) and `codex-companion.mjs:269–278` (default 96, used at L771). `buildJsonRpcError` — `app-server.mjs:45–47` (private) and `app-server-broker.mjs:64–66`, identical. Session filter: `job-control.mjs:17–27` (`options.env?.[X] ?? process.env[X] ?? null`) and `codex-companion.mjs:433–443` (`process.env[X] ?? null`) agree; `stop-review-gate-hook.mjs:79–80` differs (`input.session_id || process.env[X] || null`, hook input first) → **left as is**. + +- [ ] **Step 1: `render.mjs`** — after L1 (the job-status import from A2) nothing else; append at the end: + +```js +export function shorten(text, limit) { + const normalized = String(text ?? "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit - 3)}...`; +} + +export function looksLikeVerificationCommand(command) { + return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( + command + ); +} +``` + then `renderCancelPending` and `emitCancelPending` moved verbatim from `job-control.mjs` L443–481 (both leading comments included; `process` is the global, render imports nothing). +- [ ] **Step 2: `codex.mjs`.** Delete L146–161 (`shorten`, `looksLikeVerificationCommand`); after L47 add `import { looksLikeVerificationCommand, shorten } from "./render.mjs";`. Call sites unchanged (all pass a limit). +- [ ] **Step 3: `job-control.mjs`.** Delete L105–109 and L443–481; add `import { looksLikeVerificationCommand } from "./render.mjs";`. Delete `getCurrentSessionId`/`filterJobsForCurrentSession` L17–27; L7 → `import { DEAD_WORKER_MESSAGE, filterJobsForSession, getCurrentSessionId, reapDeadJobs } from "./tracked-jobs.mjs";` (`SESSION_ID_ENV` was only used at L18). Sites: L222 `filterJobsForCurrentSession(…, options)` → `filterJobsForSession(…, options.env)`; L268 `filterJobsForCurrentSession(…)` → `filterJobsForSession(…)`; L310 → `filterJobsForSession(activeJobs, options.env)`; L319 `getCurrentSessionId(options)` → `getCurrentSessionId(options.env)` (enclosing functions default `options = {}`: L219, L248, L297). +- [ ] **Step 4: `tracked-jobs.mjs`** — after L18: + +```js +export function getCurrentSessionId(env) { + return env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null; +} + +export function filterJobsForSession(jobs, env) { + const sessionId = getCurrentSessionId(env); + if (!sessionId) { + return jobs; + } + return jobs.filter((job) => job.sessionId === sessionId); +} +``` + +- [ ] **Step 5: `codex-companion.mjs`.** Delete `shorten` L269–278 and L433–443; L771 `shorten(prompt || fallbackSummary)` → `shorten(prompt || fallbackSummary, 96)`; L531, L1299 `getCurrentClaudeSessionId()` → `getCurrentSessionId()`; L533, L1300 `filterJobsForCurrentClaudeSession(` → `filterJobsForSession(`. Imports: `emitCancelPending,` (L54) and `shorten,` go to the `./lib/render.mjs` block (L75–84); `filterJobsForSession, getCurrentSessionId` join the tracked-jobs block and `SESSION_ID_ENV` (L72) leaves it (only use was L434). +- [ ] **Step 6: `app-server.mjs`** L45 `function buildJsonRpcError` → `export function buildJsonRpcError`. **`app-server-broker.mjs`:** delete L64–66; L9 → `import { BROKER_BUSY_RPC_CODE, buildJsonRpcError, CodexAppServerClient } from "./lib/app-server.mjs";`. +- [ ] **Step 7: `tests/job-control.test.mjs`** L11: remove `emitCancelPending, renderCancelPending` from the job-control import; add `import { emitCancelPending, renderCancelPending } from "../plugins/codex/scripts/lib/render.mjs";`. Lazier alternative: `export { emitCancelPending, renderCancelPending } from "./render.mjs";` in job-control (1 line vs 3) — rejected for the same reason as A1 Step 6. +- [ ] **Step 8: Run** `node --import ./tests/test-env.mjs --test tests/job-control.test.mjs tests/render.test.mjs tests/app-server.test.mjs tests/broker-idle-timeout.test.mjs tests/runtime.test.mjs` → pass. `rg -n 'function shorten|function looksLikeVerificationCommand|function buildJsonRpcError|function filterJobsForCurrent|function getCurrentClaudeSessionId' plugins/codex/scripts` → `render.mjs` ×2, `app-server.mjs` ×1, `stop-review-gate-hook.mjs` `filterJobsForCurrentSession` ×1 only. If `npm run build` (checkJs pulls `render.mjs` in through `codex.mjs`) reports a type error in `render.mjs`, fix it with JSDoc only. +- [ ] **Step 9: Commit** `git add plugins/codex/scripts/lib/render.mjs plugins/codex/scripts/lib/codex.mjs plugins/codex/scripts/lib/job-control.mjs plugins/codex/scripts/lib/tracked-jobs.mjs plugins/codex/scripts/lib/app-server.mjs plugins/codex/scripts/app-server-broker.mjs plugins/codex/scripts/codex-companion.mjs tests/job-control.test.mjs && git commit -m "refactor(lib): render.mjs owns shorten, looksLikeVerificationCommand and the pending-cancel text; one session filter, one buildJsonRpcError"`. + +Not duplicates, left alone (per design): `sleep`/`sleepSync`, the `isAlive` variants, `isDirectRun` in repo scripts vs the hook, `SESSION_ID_ENV` re-declared in `session-lifecycle-hook.mjs:24`. + +### Task C1 (Sonnet): shared test harness helpers + +**Files:** `tests/helpers.mjs`; `tests/runtime.test.mjs`, `tests/broker-idle-timeout.test.mjs`, `tests/broker-stale-pid.test.mjs`, `tests/state.test.mjs`. + +Verified copies: `waitForExit` — idle-timeout L29–46 (default 5000) and stale-pid L35–52 (default 10000), identical bodies; every idle-timeout call passes `timeoutMs` explicitly (L109, 143, 202, 305, 347, 386, 445), stale-pid L234/590/661 pass it, one call relies on 10000. `deadPid` — state.test L318–322 (`process.exit(0)`, `assert`) and stale-pid L1059–1063 (`""`, `assert`): same effect. `delay` idle-timeout L67–69 (7 sites). `waitUntil` stale-pid L205–215 (9 sites) **returns null on timeout**; L515–526 asserts on that null and prints the broker-log tail only on that path — so `waitUntil` moves **verbatim** (deviation from the ruling `waitUntil → waitFor`: `waitFor` throws first and the diagnostic never prints, against `docs/agent/testing-and-ci.md` "Evidence in tests"). `readPersistedJob` runtime L44–50 (33 sites), `jobDiagnostics` L52–61 (24 sites, cancel + task families), `isAlive` L42 (15 sites), `seededRepo` L3202–3209 (43 sites), constants L27–41. + +- [ ] **Step 1: `tests/helpers.mjs`** — add imports `import { fileURLToPath } from "node:url";`, `import { isPidAlive, … } from "../plugins/codex/scripts/lib/process.mjs";` (extend the existing line), `import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs";`, then append: + +```js +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); +export const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); +export const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); +export const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); +export const FAKE_RESOLVED_SETTINGS = { /* verbatim from runtime.test.mjs L32–41 */ }; + +export const isAlive = (pid) => isPidAlive(pid) === true; + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// A pid that has certainly exited (a finished child), for stale-record fixtures. +export function deadPid() { + const finished = run(process.execPath, ["-e", ""]); + if (finished.status !== 0) throw new Error(`deadPid: helper child exited ${finished.status}`); + return finished.pid; +} + +export function waitForExit(child, { timeoutMs = 10000 } = {}) { /* verbatim from broker-stale-pid L35–52 */ } + +// Like waitFor but resolves null on timeout, so the caller's own assertion (and its +// on-failure diagnostic, e.g. the broker-log tail) still runs. +export async function waitUntil(predicate, { timeoutMs = 8000, intervalMs = 100 } = {}) { /* verbatim from L205–215 */ } + +export function readStateIndex(workspaceRoot) { + return JSON.parse(fs.readFileSync(path.join(resolveStateDir(workspaceRoot), "state.json"), "utf8")); +} + +export function readJobRecord(workspaceRoot, jobId = null) { + const resolvedJobId = jobId ?? readStateIndex(workspaceRoot).jobs[0].id; + return JSON.parse(fs.readFileSync(path.join(resolveStateDir(workspaceRoot), "jobs", `${resolvedJobId}.json`), "utf8")); +} + +export function jobDiagnostics(repo, jobId) { /* verbatim from runtime L52–61, readPersistedJob → readJobRecord */ } + +export function seededRepo() { /* verbatim from runtime L3202–3209 */ } +``` + +- [ ] **Step 2: call sites.** `runtime.test.mjs`: delete L27–61 and L3202–3209; import the names from `./helpers.mjs` (L9); `readPersistedJob(` → `readJobRecord(` (33 sites). `broker-idle-timeout.test.mjs`: delete `waitForExit` L29–46 and `delay` L67–69, import both (keep its local `ROOT`/`BROKER_SCRIPT`; do not import `ROOT`). `broker-stale-pid.test.mjs`: delete `waitForExit`, `waitUntil`, `deadPid`; delete its `ROOT`/`SESSION_HOOK`/`SCRIPT` (L30–33, a same-name import would be a SyntaxError) and import them plus `waitForExit, waitUntil, deadPid` (its `isAlive` at L53 has a different body — leave). `state.test.mjs`: delete `deadPid` L318–322, import it. The `delay` default and the `waitForExit` 10000 default keep every existing call's timing. +- [ ] **Step 3: Run** `node --import ./tests/test-env.mjs --test tests/runtime.test.mjs tests/broker-idle-timeout.test.mjs tests/broker-stale-pid.test.mjs tests/state.test.mjs` → same pass/skip counts as before the task. Then the gate. +- [ ] **Step 4: Commit** `git add tests/helpers.mjs tests/runtime.test.mjs tests/broker-idle-timeout.test.mjs tests/broker-stale-pid.test.mjs tests/state.test.mjs && git commit -m "test: shared harness helpers (waitForExit, waitUntil, deadPid, delay, readJobRecord, seededRepo, script paths)"`. + +Lazier alternative: keep the name `readPersistedJob` (33 fewer changed lines) — rejected only because the ruling names `readJobRecord`; the rename is mechanical. + +### Task C2 (Sonnet): split `tests/runtime.test.mjs` by command family + +**Files:** delete `tests/runtime.test.mjs`; create `tests/runtime-setup.test.mjs`, `runtime-review.test.mjs`, `runtime-task.test.mjs`, `runtime-status.test.mjs`, `runtime-cancel.test.mjs`, `runtime-transfer.test.mjs`, `runtime-hooks.test.mjs`. `scripts/run-tests.mjs` discovers `tests/*.test.mjs` — no change; `stryker.config.mjs`/`tsconfig.tests.json` do not name the file. + +Shared hooks: `rg -n '^test\.before|^before\(|^after\(|^describe\(|^let |^var |^process\.env' tests/runtime.test.mjs` → none, so a split loses nothing. 143 `test(` calls (start lines on 1f91ec2; a test ends before the next `test(`/helper): + +| file | tests (start line) | n | +|---|---|---| +| setup | 62, 80, 101, 119, 137, 155, 2656, 2670, 2740, 3043, 3105, 3179 | 12 | +| review | 172, 400, 421, 442, 463, 490, 510, 1007, 1029, 1272, 1292, 1313, 1334, 3211, 3249 | 15 | +| task | 194, 212, 382, 535–1209 (24 tests through 1209), 2270, 3235–3907 (20), 4011–4154 (7), 4235–4374 (10) | 62 | +| status | 1365, 1463, 1534, 1595, 1656, 1719, 1817, 3150, 3350, 3796, 4388 | 11 | +| cancel | 1843, 1954, 2004, 2032, 2087, 2135, 2208, 2241, 3925, 3972, 4170, 4404, 4428, 4476, 4497, 4531 | 16 | +| transfer | 230, 281, 311, 340, 364 | 5 | +| hooks | 768, 2289, 2415, 2552, 2580, 2636, 2686, 2717, 2729, 2758, 2773, 2811, 2823, 2837, 2853, 2869, 2886, 2906, 2963, 2988, 3015, 4516 | 22 | + +- [ ] **Step 1:** Before touching anything, in the scratchpad: `rg -o '^test\("[^"]*"' tests/runtime.test.mjs | sort > /titles-before`. +- [ ] **Step 2:** Create the seven files: each starts with the L1–25 import block of the original (trim names the file does not use — after D2 eslint flags leftovers, so do not over-think it here), then its tests in original order, verbatim. `gateDecisions` (L2704–2715) and `runHookWithOpenStdin` (L2788–2810) go to `runtime-hooks` above their first use. `git rm tests/runtime.test.mjs`. +- [ ] **Step 3: bounded cleanup, moved tests only.** (a) 21 raw `JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"))` reads: replace with `readStateIndex(repo)` **only where that leaves `stateDir` unused** (then delete the `stateDir` line); otherwise leave. (b) The 50 inline `initGitRepo … "commit", "-m", "init"` blocks stay: review tests diff the exact seeded content, a blanket `seededRepo()` swap changes fixture data. Record both counts in the task report. +- [ ] **Step 4: Proof.** `rg -o '^test\("[^"]*"' tests/runtime-*.test.mjs | sort > /titles-after && diff /titles-before /titles-after` → empty; `rg -c '^test\(' tests/runtime-*.test.mjs` sums to 143. `node --import ./tests/test-env.mjs --test tests/runtime-*.test.mjs` → 143 tests, same skip count as the old file on this host. (`git diff -M --stat` will most likely show one delete + seven adds — the largest piece is ~43 % of the original, below git's 50 % rename threshold — the title-set diff is the proof, not the rename detection.) +- [ ] **Step 5: CI note for the ledger.** `node --test` already runs the 21 test files in parallel (no `--test-concurrency` in `scripts/run-tests.mjs`); seven runtime files raise the number of subprocess-heavy suites that overlap on the Windows runner. If the matrix flakes on this commit only, add to `scripts/run-tests.mjs` the win32 fallback `...(process.platform === "win32" ? ["--test-concurrency", "2"] : [])` before `--test`, in a separate commit, and record the run ids. +- [ ] **Step 6: Commit** `git add tests/runtime.test.mjs tests/runtime-setup.test.mjs tests/runtime-review.test.mjs tests/runtime-task.test.mjs tests/runtime-status.test.mjs tests/runtime-cancel.test.mjs tests/runtime-transfer.test.mjs tests/runtime-hooks.test.mjs && git commit -m "test: split runtime.test.mjs by command family"`. + +Lazier alternative: stop after C1 (helpers extracted, one 4.5 k-line file kept) or split into three (task / cancel+hooks / rest) — `runtime-task` alone stays ~2 000 lines; ruled as seven, the controller may merge `runtime-transfer` into `runtime-setup` if the 5-test file feels silly. + +### Task D1 (Sonnet): `tests/module-boundaries.test.mjs` + +**Files:** create `tests/module-boundaries.test.mjs`. Allow-lists below are the **post-A3** graph (`rg -n '^import .*from "\./|^} from "\./' plugins/codex/scripts/*.mjs plugins/codex/scripts/lib/*.mjs` after A3 must match them exactly; adjust the list, not the code). + +```js +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { PLUGIN_ROOT } from "./helpers.mjs"; + +const SCRIPTS = path.join(PLUGIN_ROOT, "scripts"); +const LIB = path.join(SCRIPTS, "lib"); +// Leaves may import nothing from lib except the zero-import job-status.mjs (render needs it). +const LEAVES = ["args", "broker-endpoint", "fs", "hook-input", "job-status", "process", "prompts", "render"]; +const ENTRY_ALLOW = { + "app-server-broker.mjs": ["args", "app-server", "broker-endpoint", "broker-lifecycle"], + "session-lifecycle-hook.mjs": ["process", "app-server", "broker-lifecycle", "state", "job-control", "tracked-jobs", "claude-session-transfer", "workspace", "hook-input", "job-status"], + "stop-review-gate-hook.mjs": ["codex", "hook-input", "prompts", "state", "job-control", "tracked-jobs", "workspace", "job-status"] +}; + +// Static import statements only; JSDoc `import("./x")` types do not count. +function imports(file) { + const src = fs.readFileSync(file, "utf8"); + return [...src.matchAll(/^(?:import\b[^;]*?|\}) from "(\.[^"]+)";/gm)].map((m) => m[1]); +} +const libName = (spec) => path.basename(spec).replace(/\.mjs$/, ""); + +test("lib modules import only siblings, never ../", () => { + for (const f of fs.readdirSync(LIB).filter((n) => n.endsWith(".mjs"))) { + for (const spec of imports(path.join(LIB, f))) assert.ok(spec.startsWith("./"), `${f} imports ${spec}`); + } +}); + +test("leaf modules import nothing from lib (render: job-status only)", () => { + for (const name of LEAVES) { + const libImports = imports(path.join(LIB, `${name}.mjs`)).map(libName); + assert.deepEqual(libImports, name === "render" ? ["job-status"] : [], `${name}.mjs`); + } +}); + +test("hooks and the broker stay within their import allow-list", () => { + for (const [file, allowed] of Object.entries(ENTRY_ALLOW)) { + const actual = imports(path.join(SCRIPTS, file)).map(libName); + assert.deepEqual(actual.filter((n) => !allowed.includes(n)), [], `${file} imports outside its allow-list`); + } +}); + +test("job-control reaches codex.mjs for getSessionRuntimeStatus only", () => { + const src = fs.readFileSync(path.join(LIB, "job-control.mjs"), "utf8"); + assert.equal((src.match(/^import \{ getSessionRuntimeStatus \} from "\.\/codex\.mjs";$/m) ?? []).length, 1); + assert.equal((src.match(/from "\.\/codex\.mjs"/g) ?? []).length, 1); +}); +``` + +- [ ] **Step 1:** Create the file; `node --import ./tests/test-env.mjs --test tests/module-boundaries.test.mjs` → 4 pass. Sanity: temporarily add `import "./state.mjs";` to `render.mjs`, rerun → the leaf test fails; revert. +- [ ] **Step 2:** README Development coverage numbers already match `.c8rc.json` (88/88/78/95, README L393–395) — nothing to do. +- [ ] **Step 3: Commit** `git add tests/module-boundaries.test.mjs && git commit -m "test: module-boundaries guard for plugins/codex/scripts"`. + +Lazier alternative: only the leaf test (12 lines) — the hook allow-lists are what catches the next `hook → codex.mjs` shortcut, keep all four. + +### Task D2 (Sonnet): `no-unused-vars` on; stale ignore dropped + +**Files:** `eslint.config.mjs`, `plugins/codex/scripts/codex-companion.mjs` (+ whatever the rule reports). + +Read-only estimate on 1f91ec2 (imports only, script over all `.mjs`): one unused import — `removeJobPidFile` in `codex-companion.mjs:37`. Unused locals, parameters and uncalled private functions are not counted: **measure in stage** with `npx eslint .` after the edit. `.claude/` exists in neither the worktree nor the main checkout and is not in `.gitignore` → the ignore entry is stale. + +- [ ] **Step 1: `eslint.config.mjs`.** Delete `".claude/**",` (L6). Replace `"no-unused-vars": "off",` (L28) with `"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrors: "none" }],`. +- [ ] **Step 2:** `npx eslint .` → fix every report by deleting the symbol (imports, dead locals, dead private functions). Rename to `_x` only for a parameter whose position must stay (callback signatures). No logic edits; if a report points at something that looks load-bearing, stop and ask the controller. Start with `codex-companion.mjs:37` `removeJobPidFile,`. +- [ ] **Step 3:** `npm run lint` → 0 problems; `node --import ./tests/test-env.mjs --test tests/` count unchanged (473/462/11 expected). +- [ ] **Step 4: Commit** `git add eslint.config.mjs plugins/codex/scripts/codex-companion.mjs && git commit -m "chore(lint): no-unused-vars on; drop the stale .claude ignore"`. + +Deferred, say so in the ledger: Stryker shards beyond `args`/`model-catalog` (no shard change in this release). + +### Task F (controller): gate, reviews, release + +- [ ] **Step 1: Reviews.** Fable whole-branch review (`superpowers:requesting-code-review`, `model: fable`, `--base main`); `pr-review-toolkit:code-reviewer` and `pr-test-analyzer` on the branch; `ponytail-review` per commit diff (7 diffs); Codex review on the default model `--effort medium` (`/codex:review --base main`); **no adversarial pass** (no behaviour change, ruled in the ledger). Rulings and evidence in `.superpowers/sdd/2026-09-30-codex-plugin-cc-v1.4.3/progress.md`. +- [ ] **Step 2: Whole-branch proofs** (record in the ledger): `rg -n 'status\s*(===|!==)\s*"(queued|running)"' plugins/codex/scripts` → none; `rg -n 'function (shorten|looksLikeVerificationCommand|readStoredJob|nowIso|readJsonOrNull|buildJsonRpcError|waitForExit|deadPid|delay|waitUntil)\b' plugins tests` → one definition each; test summary 473/462/11; leak check 0. +- [ ] **Step 3: CHANGELOG** — insert above `## 1.4.2`, then `cp CHANGELOG.md plugins/codex/CHANGELOG.md`: + +```markdown +## 1.4.3 — + +### Internal +- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the session filter, `buildJsonRpcError`); job status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. +- Tests: shared harness helpers in `tests/helpers.mjs`; `tests/runtime.test.mjs` split by command family (`runtime-setup`, `-review`, `-task`, `-status`, `-cancel`, `-transfer`, `-hooks`); new `module-boundaries` and `job-status` unit tests; eslint `no-unused-vars` enabled. +``` + +- [ ] **Step 4: Bump and release** per `docs/RELEASING.md`: step 0 (claim ``, no adversarial findings to park, `npm audit --omit=dev` 0); step 1 `npm run bump-version -- 1.4.3 && npm run check-version`; step 2 gate chain + `claude plugin validate . --strict`, commit `chore(release): v1.4.3`, PR `` against `main`, CI line `CI : rc=; : …` (`gh run watch --exit-status; rc=$?`); steps 3–4 tag, `npm pack`, GitHub Release — **only after the user's explicit go**; step 5 local installs (`claude plugin marketplace update cbepx && claude plugin update codex@cbepx`); step 6 smoke, archive the SDD directory to `docs/superpowers/reports/v1.4.3/`, `agent-work release --stopped`. + +### Critical Files for Implementation +- /Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.4.3/plugins/codex/scripts/lib/job-control.mjs +- /Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.4.3/plugins/codex/scripts/lib/tracked-jobs.mjs +- /Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.4.3/plugins/codex/scripts/lib/state.mjs +- /Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.4.3/plugins/codex/scripts/codex-companion.mjs +- /Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.4.3/tests/runtime.test.mjs From 3a7105bdb9a8fead06b6b3c37658757bbf3889f9 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:31:24 +0300 Subject: [PATCH 02/11] refactor(lib): one nowIso, readStoredJob and readJsonOrNull; drop the dead fs.mjs exports Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 4 ++-- plugins/codex/scripts/lib/fs.mjs | 18 ++++++-------- plugins/codex/scripts/lib/job-control.mjs | 10 +------- plugins/codex/scripts/lib/model-catalog.mjs | 9 +++---- plugins/codex/scripts/lib/state.mjs | 20 +++++++++------- plugins/codex/scripts/lib/tracked-jobs.mjs | 26 +++++++-------------- 6 files changed, 32 insertions(+), 55 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index df8dc029a..603bc8625 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -33,6 +33,8 @@ import { generateJobId, getConfig, listJobs, + nowIso, + readStoredJob, recordWorkerPid, removeJobPidFile, redactConfigValues, @@ -54,7 +56,6 @@ import { emitCancelPending, isWorkerProvedRecord, isWorkerTerminalRecord, - readStoredJob, resolveCancelableJob, resolveResultJob, sortJobsNewestFirst @@ -65,7 +66,6 @@ import { createJobProgressUpdater, createJobRecord, createProgressReporter, - nowIso, reapDeadJobs, registerWorkerCrashGuard, runTrackedJob, diff --git a/plugins/codex/scripts/lib/fs.mjs b/plugins/codex/scripts/lib/fs.mjs index 83d5d7be2..38390d6e4 100644 --- a/plugins/codex/scripts/lib/fs.mjs +++ b/plugins/codex/scripts/lib/fs.mjs @@ -1,25 +1,21 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; export function ensureAbsolutePath(cwd, maybePath) { return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath); } -export function createTempDir(prefix = "codex-plugin-") { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - export function readJsonFile(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } -export function writeJsonFile(filePath, value) { - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -export function safeReadFile(filePath) { - return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; +// Missing, unreadable or malformed file → null (state index, job files, model catalogue). +export function readJsonOrNull(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } } export function isProbablyText(buffer) { diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index d59f9e518..66e269ee2 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import { BROKER_ENDPOINT_ENV } from "./app-server.mjs"; import { loadBrokerSession, resolveBrokerStateFile } from "./broker-lifecycle.mjs"; import { getSessionRuntimeStatus } from "./codex.mjs"; -import { getConfig, listJobs, readJobFile, removeJobPidFile, removeJobRequestFile, resolveJobFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; +import { getConfig, listJobs, readStoredJob, removeJobPidFile, removeJobRequestFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; import { DEAD_WORKER_MESSAGE, reapDeadJobs, SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -182,14 +182,6 @@ export function enrichJob(job, options = {}) { }; } -export function readStoredJob(workspaceRoot, jobId) { - const jobFile = resolveJobFile(workspaceRoot, jobId); - if (!fs.existsSync(jobFile)) { - return null; - } - return readJobFile(jobFile); -} - function matchJobReference(jobs, reference, predicate = () => true, options = {}) { const filtered = jobs.filter(predicate); if (!reference) { diff --git a/plugins/codex/scripts/lib/model-catalog.mjs b/plugins/codex/scripts/lib/model-catalog.mjs index 7d7e149ce..20decf933 100644 --- a/plugins/codex/scripts/lib/model-catalog.mjs +++ b/plugins/codex/scripts/lib/model-catalog.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { readJsonOrNull } from "./fs.mjs"; import { runCommand } from "./process.mjs"; export const CATALOG_ENV = "CODEX_COMPANION_MODEL_CATALOG"; @@ -29,18 +30,14 @@ function normalizeEntries(raw) { })); } -function readJson(file) { - try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } -} - // Cached per process for the default environment only; an explicit env (tests) // always reads fresh. export function loadModelCatalog({ env = process.env, runCommandImpl = runCommand, cache = env === process.env } = {}) { if (cache && cached) return cached; const sources = []; - if (env[CATALOG_ENV]) sources.push(() => readJson(env[CATALOG_ENV])); + if (env[CATALOG_ENV]) sources.push(() => readJsonOrNull(env[CATALOG_ENV])); const codexHome = path.resolve(env.CODEX_HOME || path.join(os.homedir(), ".codex")); - sources.push(() => readJson(path.join(codexHome, "models_cache.json"))); + sources.push(() => readJsonOrNull(path.join(codexHome, "models_cache.json"))); // Last resort: the bundled catalogue, never the network-refreshing form — its // output is ~500 KB and this runs on every companion invocation. sources.push(() => { diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 68496f1e4..35af8d4a1 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { readJsonOrNull } from "./fs.mjs"; import { getProcessIdentity, isPidAlive } from "./process.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -14,7 +15,7 @@ const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; const MAX_JOBS = 50; -function nowIso() { +export function nowIso() { return new Date().toISOString(); } @@ -137,14 +138,6 @@ function withRedactedRequest(record) { : record; } -function readJsonOrNull(filePath) { - try { - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return null; - } -} - // Records written before values were redacted (1.1.1 and earlier) carry the raw // ones under the same STATE_VERSION, so an upgrade alone would keep serving them. // Redaction happens on every read — that is the boundary every `status`/`result` @@ -823,6 +816,15 @@ export function resolveJobFile(cwd, jobId) { return path.join(resolveJobsDir(cwd), `${jobId}.json`); } +// The job file, or null when the job has none yet (queued, never started). +export function readStoredJob(cwd, jobId) { + const jobFile = resolveJobFile(cwd, jobId); + if (!fs.existsSync(jobFile)) { + return null; + } + return readJobFile(jobFile); +} + export function resolveJobPidFile(cwd, jobId) { ensureStateDir(cwd); return path.join(resolveJobsDir(cwd), `${jobId}.pid`); diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 49c2ff2fe..6dba57bf4 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -4,7 +4,9 @@ import process from "node:process"; import { getProcessIdentities, getProcessIdentity, isPidAlive, processCommandLine } from "./process.mjs"; import { + nowIso, readJobFile, + readStoredJob, removeJobPidFile, removeJobRequestFile, resolveJobFile, @@ -17,10 +19,6 @@ import { export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; -export function nowIso() { - return new Date().toISOString(); -} - function normalizeProgressEvent(value) { if (value && typeof value === "object" && !Array.isArray(value)) { return { @@ -166,20 +164,12 @@ function isActiveStatus(status) { return status === "queued" || status === "running"; } -function readStoredJobOrNull(workspaceRoot, jobId) { - const jobFile = resolveJobFile(workspaceRoot, jobId); - if (!fs.existsSync(jobFile)) { - return null; - } - return readJobFile(jobFile); -} - // A cancel that was acknowledged already wrote the terminal record and released // the artifacts; a worker that outlives it must not replace `cancelled` with its // own outcome. Read and write share the lock so a cancel cannot land in between. function writeTerminalUnlessCancelled(workspaceRoot, jobId, logFile, write) { return withStateLock(workspaceRoot, () => { - if (readStoredJobOrNull(workspaceRoot, jobId)?.status === "cancelled") { + if (readStoredJob(workspaceRoot, jobId)?.status === "cancelled") { appendLogLine(logFile, "Worker finished after the job was cancelled; the cancelled record is kept."); return; } @@ -203,7 +193,7 @@ export async function runTrackedJob(job, runner, options = {}) { // check and the takeover share the lock so none can land in between, and a // job that is no longer queued or running is never run. const refused = withStateLock(job.workspaceRoot, () => { - const stored = readStoredJobOrNull(job.workspaceRoot, job.id); + const stored = readStoredJob(job.workspaceRoot, job.id); if (stored && !isActiveStatus(stored.status)) { appendLogLine(runningRecord.logFile, `Worker started after the job was ${stored.status}; the turn was not run.`); removeJobPidFile(job.workspaceRoot, job.id); @@ -229,7 +219,7 @@ export async function runTrackedJob(job, runner, options = {}) { writeTerminalUnlessCancelled(job.workspaceRoot, job.id, logFile, () => { // `runningRecord` predates `turn/started`, which is where the progress // updater stored the transport; read it back so the final record keeps it. - const stored = readStoredJobOrNull(job.workspaceRoot, job.id); + const stored = readStoredJob(job.workspaceRoot, job.id); writeJobFile(job.workspaceRoot, job.id, { ...runningRecord, transport: stored?.transport ?? runningRecord.transport ?? null, @@ -279,7 +269,7 @@ export async function runTrackedJob(job, runner, options = {}) { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); writeTerminalUnlessCancelled(job.workspaceRoot, job.id, options.logFile ?? job.logFile ?? null, () => { - const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; + const existing = readStoredJob(job.workspaceRoot, job.id) ?? runningRecord; const completedAt = nowIso(); writeJobFile(job.workspaceRoot, job.id, { ...existing, @@ -462,7 +452,7 @@ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { if (job.status !== "running" && job.status !== "queued") { return null; } - const stored = readStoredJobOrNull(workspaceRoot, job.id); + const stored = readStoredJob(workspaceRoot, job.id); if (stored && stored.status !== "running" && stored.status !== "queued") { return null; } @@ -519,7 +509,7 @@ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { if (job.status !== "running" && job.status !== "queued") { return job; } - const stored = readStoredJobOrNull(workspaceRoot, job.id); + const stored = readStoredJob(workspaceRoot, job.id); if (stored && stored.status !== "running" && stored.status !== "queued") { // Terminal on disk: markJobDead keeps the real result and reconciles it // into the index rather than failing the job. From 5cd00d6bc34608059a466ce3db6e412a3b79fe14 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:42:22 +0300 Subject: [PATCH 03/11] refactor(lib): job-status.mjs replaces the queued/running string comparisons Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 12 ++++------- plugins/codex/scripts/lib/codex.mjs | 3 ++- plugins/codex/scripts/lib/job-control.mjs | 17 +++++++-------- plugins/codex/scripts/lib/job-status.mjs | 17 +++++++++++++++ plugins/codex/scripts/lib/render.mjs | 14 +++++++------ plugins/codex/scripts/lib/state.mjs | 9 ++++---- plugins/codex/scripts/lib/tracked-jobs.mjs | 19 +++++++---------- .../codex/scripts/session-lifecycle-hook.mjs | 5 +++-- .../codex/scripts/stop-review-gate-hook.mjs | 3 ++- tests/job-status.test.mjs | 21 +++++++++++++++++++ 10 files changed, 77 insertions(+), 43 deletions(-) create mode 100644 plugins/codex/scripts/lib/job-status.mjs create mode 100644 tests/job-status.test.mjs diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 603bc8625..382f11f4a 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -25,6 +25,7 @@ import { import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; +import { isActiveJobStatus, isTerminalRecord } from "./lib/job-status.mjs"; import { loadModelCatalog, resolveModelAlias, supportedEfforts } from "./lib/model-catalog.mjs"; import { binaryAvailable, isPidAlive, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; @@ -426,10 +427,6 @@ function renderStatusPayload(report, asJson) { return asJson ? report : renderStatusReport(report); } -function isActiveJobStatus(status) { - return status === "queued" || status === "running"; -} - function getCurrentClaudeSessionId() { return process.env[SESSION_ID_ENV] ?? null; } @@ -448,8 +445,7 @@ function findLatestResumableTaskJob(jobs) { (job) => job.jobClass === "task" && job.threadId && - job.status !== "queued" && - job.status !== "running" + !isActiveJobStatus(job.status) ) ?? null ); } @@ -531,7 +527,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { const sessionId = getCurrentClaudeSessionId(); const jobs = sortJobsNewestFirst(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot))).filter((job) => job.id !== options.excludeJobId); const visibleJobs = filterJobsForCurrentClaudeSession(jobs); - const activeTask = visibleJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running")); + const activeTask = visibleJobs.find((job) => job.jobClass === "task" && isActiveJobStatus(job.status)); if (activeTask) { throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); } @@ -1399,7 +1395,7 @@ async function waitForTerminalRecord(workspaceRoot, jobId, timeoutMs) { const deadline = Date.now() + timeoutMs; for (;;) { const stored = readStoredJob(workspaceRoot, jobId); - if (stored && stored.status !== "queued" && stored.status !== "running") { + if (isTerminalRecord(stored)) { return stored; } if (Date.now() >= deadline) { diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index c2dcf1cb8..22bcdb5c5 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -49,6 +49,7 @@ import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from import { loadBrokerSession } from "./broker-lifecycle.mjs"; import { binaryAvailable } from "./process.mjs"; import { listJobs } from "./state.mjs"; +import { isActiveJobStatus } from "./job-status.mjs"; import { reapDeadJobs } from "./tracked-jobs.mjs"; const SERVICE_NAME = "claude_code_codex_plugin"; @@ -1352,7 +1353,7 @@ function assertThreadIsFree(cwd, threadId, excludeJobId = null) { (job) => job.id !== excludeJobId && job.threadId === threadId && - (job.status === "queued" || job.status === "running") + isActiveJobStatus(job.status) ); if (busy) { throw new Error(`Thread ${threadId} is busy in job ${busy.id}; wait for it or run cancel ${busy.id} first.`); diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index 66e269ee2..1927fd2f3 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -4,6 +4,7 @@ import { BROKER_ENDPOINT_ENV } from "./app-server.mjs"; import { loadBrokerSession, resolveBrokerStateFile } from "./broker-lifecycle.mjs"; import { getSessionRuntimeStatus } from "./codex.mjs"; import { getConfig, listJobs, readStoredJob, removeJobPidFile, removeJobRequestFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; +import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "./job-status.mjs"; import { DEAD_WORKER_MESSAGE, reapDeadJobs, SESSION_ID_ENV } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -166,7 +167,7 @@ export function enrichJob(job, options = {}) { ...job, kindLabel: getJobTypeLabel(job), progressPreview: - job.status === "queued" || job.status === "running" || job.status === "failed" + isActiveJobStatus(job.status) || job.status === JOB_STATUS.FAILED ? readJobProgressPreview(job.logFile, maxProgressLines) : [], elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null), @@ -216,14 +217,14 @@ export function buildStatusSnapshot(cwd, options = {}) { const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES; const running = jobs - .filter((job) => job.status === "queued" || job.status === "running") + .filter((job) => isActiveJobStatus(job.status)) .map((job) => enrichJob(job, { maxProgressLines })); - const latestFinishedRaw = jobs.find((job) => job.status !== "queued" && job.status !== "running") ?? null; + const latestFinishedRaw = jobs.find((job) => !isActiveJobStatus(job.status)) ?? null; const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) : null; const recent = (options.all ? jobs : jobs.slice(0, maxJobs)) - .filter((job) => job.status !== "queued" && job.status !== "running" && job.id !== latestFinished?.id) + .filter((job) => !isActiveJobStatus(job.status) && job.id !== latestFinished?.id) .map((job) => enrichJob(job, { maxProgressLines })); return { @@ -272,7 +273,7 @@ export function resolveResultJob(cwd, reference) { const active = matchJobReference( jobs, reference, - (job) => job.status === "queued" || job.status === "running", + (job) => isActiveJobStatus(job.status), { optional: true } ); if (active) { @@ -289,7 +290,7 @@ export function resolveResultJob(cwd, reference) { export function resolveCancelableJob(cwd, reference, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const jobs = sortJobsNewestFirst(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot))); - const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running"); + const activeJobs = jobs.filter((job) => isActiveJobStatus(job.status)); if (reference) { const selected = matchJobReference(activeJobs, reference); @@ -315,10 +316,6 @@ export function resolveCancelableJob(cwd, reference, options = {}) { throw new Error("No active Codex jobs to cancel."); } -function isTerminalRecord(stored) { - return Boolean(stored) && stored.status !== "queued" && stored.status !== "running"; -} - // A terminal record carrying the worker's own `workerClosed` marker, set only by // its cooperative terminal write after the turn and after its app-server client // closed. The crash guard, the reaper and v1.4.0 records never carry it. diff --git a/plugins/codex/scripts/lib/job-status.mjs b/plugins/codex/scripts/lib/job-status.mjs new file mode 100644 index 000000000..904a32adc --- /dev/null +++ b/plugins/codex/scripts/lib/job-status.mjs @@ -0,0 +1,17 @@ +// Job status vocabulary. A job is active while a worker may still write it. +export const JOB_STATUS = Object.freeze({ + QUEUED: "queued", + RUNNING: "running", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled" +}); + +export function isActiveJobStatus(status) { + return status === JOB_STATUS.QUEUED || status === JOB_STATUS.RUNNING; +} + +// A stored record that no worker will write again; null/undefined is not terminal. +export function isTerminalRecord(record) { + return Boolean(record) && !isActiveJobStatus(record.status); +} diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 55dbc88e1..5b4e34ddf 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -1,3 +1,5 @@ +import { isActiveJobStatus } from "./job-status.mjs"; + function severityRank(severity) { switch (severity) { case "critical": @@ -112,7 +114,7 @@ function appendActiveJobsTable(lines, jobs) { lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |"); for (const job of jobs) { const actions = [`/codex:status ${job.id}`]; - if (job.status === "queued" || job.status === "running") { + if (isActiveJobStatus(job.status)) { actions.push(`/codex:cancel ${job.id}`); } lines.push( @@ -149,13 +151,13 @@ function pushJobDetails(lines, job, options = {}) { if (job.logFile && options.showLog) { lines.push(` Log: ${job.logFile}`); } - if ((job.status === "queued" || job.status === "running") && options.showCancelHint) { + if (isActiveJobStatus(job.status) && options.showCancelHint) { lines.push(` Cancel: /codex:cancel ${job.id}`); } - if (job.status !== "queued" && job.status !== "running" && options.showResultHint) { + if (!isActiveJobStatus(job.status) && options.showResultHint) { lines.push(` Result: /codex:result ${job.id}`); } - if (job.status !== "queued" && job.status !== "running" && job.jobClass === "task" && job.write && options.showReviewHint) { + if (!isActiveJobStatus(job.status) && job.jobClass === "task" && job.write && options.showReviewHint) { lines.push(" Review changes: /codex:review --wait"); lines.push(" Stricter review: /codex:adversarial-review --wait"); } @@ -382,8 +384,8 @@ export function renderStatusReport(report) { export function renderJobStatusReport(job) { const lines = ["# Codex Job Status", ""]; pushJobDetails(lines, job, { - showElapsed: job.status === "queued" || job.status === "running", - showDuration: job.status !== "queued" && job.status !== "running", + showElapsed: isActiveJobStatus(job.status), + showDuration: !isActiveJobStatus(job.status), showLog: true, showCancelHint: true, showResultHint: true, diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 35af8d4a1..0ec861e67 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { readJsonOrNull } from "./fs.mjs"; +import { isActiveJobStatus, JOB_STATUS } from "./job-status.mjs"; import { getProcessIdentity, isPidAlive } from "./process.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -716,11 +717,11 @@ export function updateJobPid(cwd, jobId, pid, identity = null) { // wrote its own pid there, the newer one. withStateLock(cwd, () => { const indexed = listJobs(cwd).find((job) => job.id === jobId); - if (indexed?.status !== "queued" && indexed?.status !== "running") { + if (!isActiveJobStatus(indexed?.status)) { return; } writeJobPidFile(cwd, jobId, pid, identity); - if (indexed.status === "queued") { + if (indexed.status === JOB_STATUS.QUEUED) { upsertJob(cwd, { id: jobId, pid, pidIdentity: identity }); } }); @@ -783,7 +784,7 @@ export function readJobFile(jobFile) { // `runTrackedJob` flips the status to `running`, so staging one for a // running job would write plaintext `--config` values that nothing reads // and nothing deletes. - const active = current.status === "queued"; + const active = current.status === JOB_STATUS.QUEUED; const migrated = withRedactedRequest(current); if (active && !fs.existsSync(requestFile)) { // Same shape and mode as `writeJobRequestFile`: the temp file carries the @@ -861,7 +862,7 @@ export function resolveJobPid(cwd, job) { if (job?.pid != null) { return { pid: job.pid, identity: typeof job.pidIdentity === "string" ? job.pidIdentity : null }; } - if (job?.status !== "queued" && job?.status !== "running") { + if (!isActiveJobStatus(job?.status)) { return { pid: null, identity: null }; } return readJobPidSidecar(cwd, job.id) ?? { pid: null, identity: null }; diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 6dba57bf4..ab959f381 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import process from "node:process"; +import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "./job-status.mjs"; import { getProcessIdentities, getProcessIdentity, isPidAlive, processCommandLine } from "./process.mjs"; import { @@ -160,10 +161,6 @@ export function createProgressReporter({ stderr = false, logFile = null, onEvent }; } -function isActiveStatus(status) { - return status === "queued" || status === "running"; -} - // A cancel that was acknowledged already wrote the terminal record and released // the artifacts; a worker that outlives it must not replace `cancelled` with its // own outcome. Read and write share the lock so a cancel cannot land in between. @@ -194,7 +191,7 @@ export async function runTrackedJob(job, runner, options = {}) { // job that is no longer queued or running is never run. const refused = withStateLock(job.workspaceRoot, () => { const stored = readStoredJob(job.workspaceRoot, job.id); - if (stored && !isActiveStatus(stored.status)) { + if (stored && !isActiveJobStatus(stored.status)) { appendLogLine(runningRecord.logFile, `Worker started after the job was ${stored.status}; the turn was not run.`); removeJobPidFile(job.workspaceRoot, job.id); removeJobRequestFile(job.workspaceRoot, job.id); @@ -314,7 +311,7 @@ function markJobDeadLocked(workspaceRoot, jobSummary, errorMessage) { const jobFile = resolveJobFile(workspaceRoot, jobSummary.id); const stored = fs.existsSync(jobFile) ? readJobFile(jobFile) : null; const base = stored ?? jobSummary; - if (base.status !== "running" && base.status !== "queued") { + if (!isActiveJobStatus(base.status)) { // The job finished between the caller's read and now — keep the real result, // and put it in the index too: a worker that died between its terminal // `writeJobFile` and its `upsertJob` leaves an active index entry that @@ -384,7 +381,7 @@ const QUEUED_WITHOUT_PID_GRACE_MS = 30000; // cannot tell that apart from a record that was written microseconds ago, so age // decides it. function isQueuedWithoutWorker(job, pid) { - if (job.status !== "queued" || pid != null) { + if (job.status !== JOB_STATUS.QUEUED || pid != null) { return false; } const createdAt = Date.parse(job.createdAt ?? ""); @@ -449,11 +446,11 @@ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { // disk, with a live pid that carries an identity — the same tests the loop // below applies, so the batch never probes a pid the loop would not. const liveIdentityCandidate = (job) => { - if (job.status !== "running" && job.status !== "queued") { + if (!isActiveJobStatus(job.status)) { return null; } const stored = readStoredJob(workspaceRoot, job.id); - if (stored && stored.status !== "running" && stored.status !== "queued") { + if (isTerminalRecord(stored)) { return null; } const { pid, identity } = resolveJobPid(workspaceRoot, job); @@ -506,11 +503,11 @@ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { deferred.push(job.id); return job; } - if (job.status !== "running" && job.status !== "queued") { + if (!isActiveJobStatus(job.status)) { return job; } const stored = readStoredJob(workspaceRoot, job.id); - if (stored && stored.status !== "running" && stored.status !== "queued") { + if (isTerminalRecord(stored)) { // Terminal on disk: markJobDead keeps the real result and reconciles it // into the index rather than failing the job. return markJobDead(workspaceRoot, job, DEAD_WORKER_MESSAGE, waitFor()); diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 8b36959e1..174f851e6 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -20,6 +20,7 @@ import { reapDeadJobs } from "./lib/tracked-jobs.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; import { readHookInput } from "./lib/hook-input.mjs"; +import { isActiveJobStatus } from "./lib/job-status.mjs"; export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; // How long a `busy` broker is given to shed a client this hook has just reaped, @@ -141,7 +142,7 @@ export function cleanupSessionJobs(cwd, sessionId, lockWaitMs, remainingMs, deps if (job.background) { continue; } - const stillRunning = job.status === "queued" || job.status === "running"; + const stillRunning = isActiveJobStatus(job.status); if (!stillRunning) { continue; } @@ -232,7 +233,7 @@ function activeWorkspaceJobs(cwd, lockWaitMs, remainingMs) { // and trusting that record would keep this broker — and every later session's — // alive forever, with the dead job's private payload still on disk. return reapDeadJobs(workspaceRoot, state.jobs, { lockWaitMs, remainingMs }) - .filter((job) => job.status === "queued" || job.status === "running") + .filter((job) => isActiveJobStatus(job.status)) .map((job) => `${job.id}:${job.status}`); } diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index bebd3422a..604f3bc89 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { getCodexAvailability } from "./lib/codex.mjs"; import { readHookInput } from "./lib/hook-input.mjs"; +import { isActiveJobStatus } from "./lib/job-status.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { getConfig, setConfig, listJobs, resolveStateFile, retryOnWindows } from "./lib/state.mjs"; import { sortJobsNewestFirst } from "./lib/job-control.mjs"; @@ -234,7 +235,7 @@ async function main() { const config = getConfig(workspaceRoot); const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)), input)); - const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running"); + const runningJob = jobs.find((job) => isActiveJobStatus(job.status)); 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; diff --git a/tests/job-status.test.mjs b/tests/job-status.test.mjs new file mode 100644 index 000000000..efec2dc42 --- /dev/null +++ b/tests/job-status.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "../plugins/codex/scripts/lib/job-status.mjs"; + +test("isActiveJobStatus: only queued and running are active", () => { + assert.deepEqual(Object.values(JOB_STATUS).map(isActiveJobStatus), [true, true, false, false, false]); + assert.equal(isActiveJobStatus(undefined), false); + assert.equal(isActiveJobStatus("done"), false); +}); + +test("isTerminalRecord: a finished record is terminal, a missing or active one is not", () => { + assert.equal(isTerminalRecord({ status: "completed" }), true); + assert.equal(isTerminalRecord({ status: "running" }), false); + assert.equal(isTerminalRecord(null), false); + assert.equal(isTerminalRecord(undefined), false); +}); + +test("JOB_STATUS is frozen", () => { + assert.ok(Object.isFrozen(JOB_STATUS)); +}); From bb9b95db0acf620e32e036a29bffb2bd7df6d755 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:56:44 +0300 Subject: [PATCH 04/11] refactor(lib): render.mjs owns shorten, looksLikeVerificationCommand and the pending-cancel text; one session filter, one buildJsonRpcError Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/app-server-broker.mjs | 6 +- plugins/codex/scripts/codex-companion.mjs | 43 ++++--------- plugins/codex/scripts/lib/app-server.mjs | 2 +- plugins/codex/scripts/lib/codex.mjs | 18 +----- plugins/codex/scripts/lib/job-control.mjs | 69 ++------------------- plugins/codex/scripts/lib/render.mjs | 57 +++++++++++++++++ plugins/codex/scripts/lib/tracked-jobs.mjs | 12 ++++ tests/job-control.test.mjs | 3 +- 8 files changed, 91 insertions(+), 119 deletions(-) diff --git a/plugins/codex/scripts/app-server-broker.mjs b/plugins/codex/scripts/app-server-broker.mjs index 6f80a3cd5..ba5d1f8bb 100644 --- a/plugins/codex/scripts/app-server-broker.mjs +++ b/plugins/codex/scripts/app-server-broker.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import process from "node:process"; import { parseArgs } from "./lib/args.mjs"; -import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs"; +import { BROKER_BUSY_RPC_CODE, buildJsonRpcError, CodexAppServerClient } from "./lib/app-server.mjs"; import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs"; import { clearBrokerSessionIfEndpoint, registerBrokerProcess } from "./lib/broker-lifecycle.mjs"; @@ -61,10 +61,6 @@ function buildStreamThreadIds(method, params, result) { return threadIds; } -function buildJsonRpcError(code, message, data) { - return data === undefined ? { code, message } : { code, message, data }; -} - function send(socket, message) { if (socket.destroyed) { return; diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 382f11f4a..622f6f5f8 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -54,7 +54,6 @@ import { buildStatusSnapshot, cancelDecision, commitCancel, - emitCancelPending, isWorkerProvedRecord, isWorkerTerminalRecord, resolveCancelableJob, @@ -67,13 +66,15 @@ import { createJobProgressUpdater, createJobRecord, createProgressReporter, + filterJobsForSession, + getCurrentSessionId, reapDeadJobs, registerWorkerCrashGuard, - runTrackedJob, - SESSION_ID_ENV + runTrackedJob } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; import { + emitCancelPending, renderNativeReviewResult, renderReviewResult, renderStoredJobResult, @@ -81,7 +82,8 @@ import { renderJobStatusReport, renderSetupReport, renderStatusReport, - renderTaskResult + renderTaskResult, + shorten } from "./lib/render.mjs"; const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -267,17 +269,6 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function shorten(text, limit = 96) { - const normalized = String(text ?? "").trim().replace(/\s+/g, " "); - if (!normalized) { - return ""; - } - if (normalized.length <= limit) { - return normalized; - } - return `${normalized.slice(0, limit - 3)}...`; -} - function firstMeaningfulLine(text, fallback) { const line = String(text ?? "") .split(/\r?\n/) @@ -427,18 +418,6 @@ function renderStatusPayload(report, asJson) { return asJson ? report : renderStatusReport(report); } -function getCurrentClaudeSessionId() { - return process.env[SESSION_ID_ENV] ?? null; -} - -function filterJobsForCurrentClaudeSession(jobs) { - const sessionId = getCurrentClaudeSessionId(); - if (!sessionId) { - return jobs; - } - return jobs.filter((job) => job.sessionId === sessionId); -} - function findLatestResumableTaskJob(jobs) { return ( jobs.find( @@ -524,9 +503,9 @@ function outputJobResult(cwd, reference, asJson) { async function resolveLatestTrackedTaskThread(cwd, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); - const sessionId = getCurrentClaudeSessionId(); + const sessionId = getCurrentSessionId(); const jobs = sortJobsNewestFirst(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot))).filter((job) => job.id !== options.excludeJobId); - const visibleJobs = filterJobsForCurrentClaudeSession(jobs); + const visibleJobs = filterJobsForSession(jobs); const activeTask = visibleJobs.find((job) => job.jobClass === "task" && isActiveJobStatus(job.status)); if (activeTask) { throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`); @@ -764,7 +743,7 @@ function buildTaskRunMetadata({ prompt, resumeLast = false }) { const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task"; return { title, - summary: shorten(prompt || fallbackSummary) + summary: shorten(prompt || fallbackSummary, 96) }; } @@ -1292,8 +1271,8 @@ function handleTaskResumeCandidate(argv) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const sessionId = getCurrentClaudeSessionId(); - const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)))); + const sessionId = getCurrentSessionId(); + const jobs = filterJobsForSession(sortJobsNewestFirst(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)))); const candidate = findLatestResumableTaskJob(jobs); const payload = { diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index ac5024228..a38080664 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -42,7 +42,7 @@ const DEFAULT_CAPABILITIES = { ] }; -function buildJsonRpcError(code, message, data) { +export function buildJsonRpcError(code, message, data) { return data === undefined ? { code, message } : { code, message, data }; } diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 22bcdb5c5..3b6e03ff0 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -48,6 +48,7 @@ import { readJsonFile } from "./fs.mjs"; import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs"; import { loadBrokerSession } from "./broker-lifecycle.mjs"; import { binaryAvailable } from "./process.mjs"; +import { looksLikeVerificationCommand, shorten } from "./render.mjs"; import { listJobs } from "./state.mjs"; import { isActiveJobStatus } from "./job-status.mjs"; import { reapDeadJobs } from "./tracked-jobs.mjs"; @@ -144,23 +145,6 @@ function buildTurnInput(prompt) { return [{ type: "text", text: prompt, text_elements: [] }]; } -function shorten(text, limit = 72) { - const normalized = String(text ?? "").trim().replace(/\s+/g, " "); - if (!normalized) { - return ""; - } - if (normalized.length <= limit) { - return normalized; - } - return `${normalized.slice(0, limit - 3)}...`; -} - -function looksLikeVerificationCommand(command) { - return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( - command - ); -} - function buildTaskThreadName(prompt) { const excerpt = shorten(prompt, 56); return excerpt ? `${TASK_THREAD_PREFIX}: ${excerpt}` : TASK_THREAD_PREFIX; diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index 1927fd2f3..9309da879 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -5,7 +5,8 @@ import { loadBrokerSession, resolveBrokerStateFile } from "./broker-lifecycle.mj import { getSessionRuntimeStatus } from "./codex.mjs"; import { getConfig, listJobs, readStoredJob, removeJobPidFile, removeJobRequestFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "./job-status.mjs"; -import { DEAD_WORKER_MESSAGE, reapDeadJobs, SESSION_ID_ENV } from "./tracked-jobs.mjs"; +import { looksLikeVerificationCommand } from "./render.mjs"; +import { DEAD_WORKER_MESSAGE, filterJobsForSession, getCurrentSessionId, reapDeadJobs } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; export const DEFAULT_MAX_STATUS_JOBS = 8; @@ -15,18 +16,6 @@ export function sortJobsNewestFirst(jobs) { return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); } -function getCurrentSessionId(options = {}) { - return options.env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null; -} - -function filterJobsForCurrentSession(jobs, options = {}) { - const sessionId = getCurrentSessionId(options); - if (!sessionId) { - return jobs; - } - return jobs.filter((job) => job.sessionId === sessionId); -} - function getJobTypeLabel(job) { if (typeof job.kindLabel === "string" && job.kindLabel) { return job.kindLabel; @@ -103,12 +92,6 @@ function formatElapsedDuration(startValue, endValue = null) { return `${seconds}s`; } -function looksLikeVerificationCommand(line) { - return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( - line - ); -} - function inferLegacyJobPhase(job, progressPreview = []) { switch (job.status) { case "queued": @@ -212,7 +195,7 @@ function matchJobReference(jobs, reference, predicate = () => true, options = {} export function buildStatusSnapshot(cwd, options = {}) { const workspaceRoot = resolveWorkspaceRoot(cwd); const config = getConfig(workspaceRoot); - const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)), options)); + const jobs = sortJobsNewestFirst(filterJobsForSession(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)), options.env)); const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS; const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES; @@ -258,7 +241,7 @@ export function buildSingleJobSnapshot(cwd, reference, options = {}) { // "No job found" (#498/#524); the caller decides how to report an active job. export function resolveResultJob(cwd, reference) { const workspaceRoot = resolveWorkspaceRoot(cwd); - const jobs = sortJobsNewestFirst(reference ? reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)) : filterJobsForCurrentSession(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)))); + const jobs = sortJobsNewestFirst(reference ? reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)) : filterJobsForSession(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)))); const selected = matchJobReference( jobs, reference, @@ -300,7 +283,7 @@ export function resolveCancelableJob(cwd, reference, options = {}) { return { workspaceRoot, job: selected }; } - const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options); + const sessionScopedActiveJobs = filterJobsForSession(activeJobs, options.env); if (sessionScopedActiveJobs.length === 1) { return { workspaceRoot, job: sessionScopedActiveJobs[0] }; @@ -309,7 +292,7 @@ export function resolveCancelableJob(cwd, reference, options = {}) { throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel."); } - if (getCurrentSessionId(options)) { + if (getCurrentSessionId(options.env)) { throw new Error("No active Codex jobs to cancel for this session."); } @@ -428,43 +411,3 @@ export function brokerExclusion(broker) { const identity = typeof broker.pidIdentity === "string" && /^win32:\d+$/.test(broker.pidIdentity) ? broker.pidIdentity : null; return Number.isInteger(broker.pid) && broker.pid >= 1 && identity ? [{ pid: broker.pid, identity }] : null; } - -// Pending cancel, rendered once for the three sinks. On posix `survivors` is -// always [] and `reason` is v1.4.0's, so json/text/logLine are byte-identical -// to v1.4.0 there; only win32 adds a survivors suffix and a stderr diagnostic. -export function renderCancelPending(decision, pid, jobId) { - const survivors = decision.survivors ?? []; - // A brokered job may have no pid to name (its worker was never signalled). - const pending = `cancellation not confirmed: worker ${pid == null ? "" : `pid ${pid} `}left running (${decision.reason})`; - const survivorText = survivors.map((s) => `${s.pid}:${s.identity ?? "unknown"}`).join(" "); - const suffix = survivors.length > 0 - ? ` worker tree survivors: ${survivorText}` - : decision.reason === "kill-failed" ? " (unverified)" : ""; - // The root exited but part of its tree did not: nothing "waits for the worker". - const rootGone = survivors.length > 0 && decision.rootAlive === false; - const tail = rootGone - ? `worker pid ${pid} exited but part of its tree is still running (survivors: ${survivorText}); the job stays running until the reaper judges it.` - : decision.reason === "turn-not-interrupted" - ? "the shared runtime has not ended the turn, so the worker was not stopped; the job stays running." - : (decision.reason === "identity-unavailable" || decision.reason === "identity-mismatch" || decision.reason === "process-missing") && decision.rootAlive === false - ? `worker pid ${pid} exited before it could be verified; the job stays running until the reaper judges it.` - : "the job stays running until the worker exits."; - return { - json: { jobId, status: "running", cancellationPending: true, reason: decision.reason, ...(survivors.length > 0 ? { survivors } : {}) }, - text: `${pending}\nThe turn interrupt was sent; ${tail} Re-run cancel or wait for result.\n`, - logLine: `${pending}${suffix}`, - diagnostic: survivors.length > 0 ? `[codex] worker tree survivors: ${survivorText}\n` : null, - }; -} - -// The caller's side of a pending cancel: one JSON document or the text on -// stdout, the diagnostic (if any) on stderr, one line in the job log. -export function emitCancelPending(decision, pid, jobId, { json, appendLog, stdout = process.stdout, stderr = process.stderr }) { - const rendered = renderCancelPending(decision, pid, jobId); - appendLog(rendered.logLine); - if (rendered.diagnostic) { - stderr.write(rendered.diagnostic); - } - stdout.write(json ? `${JSON.stringify(rendered.json, null, 2)}\n` : rendered.text); - return rendered; -} diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 5b4e34ddf..5054bc3bc 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -470,3 +470,60 @@ export function renderCancelReport(job) { return `${lines.join("\n").trimEnd()}\n`; } + +export function shorten(text, limit) { + const normalized = String(text ?? "").trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit - 3)}...`; +} + +export function looksLikeVerificationCommand(command) { + return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test( + command + ); +} + +// Pending cancel, rendered once for the three sinks. On posix `survivors` is +// always [] and `reason` is v1.4.0's, so json/text/logLine are byte-identical +// to v1.4.0 there; only win32 adds a survivors suffix and a stderr diagnostic. +export function renderCancelPending(decision, pid, jobId) { + const survivors = decision.survivors ?? []; + // A brokered job may have no pid to name (its worker was never signalled). + const pending = `cancellation not confirmed: worker ${pid == null ? "" : `pid ${pid} `}left running (${decision.reason})`; + const survivorText = survivors.map((s) => `${s.pid}:${s.identity ?? "unknown"}`).join(" "); + const suffix = survivors.length > 0 + ? ` worker tree survivors: ${survivorText}` + : decision.reason === "kill-failed" ? " (unverified)" : ""; + // The root exited but part of its tree did not: nothing "waits for the worker". + const rootGone = survivors.length > 0 && decision.rootAlive === false; + const tail = rootGone + ? `worker pid ${pid} exited but part of its tree is still running (survivors: ${survivorText}); the job stays running until the reaper judges it.` + : decision.reason === "turn-not-interrupted" + ? "the shared runtime has not ended the turn, so the worker was not stopped; the job stays running." + : (decision.reason === "identity-unavailable" || decision.reason === "identity-mismatch" || decision.reason === "process-missing") && decision.rootAlive === false + ? `worker pid ${pid} exited before it could be verified; the job stays running until the reaper judges it.` + : "the job stays running until the worker exits."; + return { + json: { jobId, status: "running", cancellationPending: true, reason: decision.reason, ...(survivors.length > 0 ? { survivors } : {}) }, + text: `${pending}\nThe turn interrupt was sent; ${tail} Re-run cancel or wait for result.\n`, + logLine: `${pending}${suffix}`, + diagnostic: survivors.length > 0 ? `[codex] worker tree survivors: ${survivorText}\n` : null, + }; +} + +// The caller's side of a pending cancel: one JSON document or the text on +// stdout, the diagnostic (if any) on stderr, one line in the job log. +export function emitCancelPending(decision, pid, jobId, { json, appendLog, stdout = process.stdout, stderr = process.stderr }) { + const rendered = renderCancelPending(decision, pid, jobId); + appendLog(rendered.logLine); + if (rendered.diagnostic) { + stderr.write(rendered.diagnostic); + } + stdout.write(json ? `${JSON.stringify(rendered.json, null, 2)}\n` : rendered.text); + return rendered; +} diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index ab959f381..e30e93451 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -20,6 +20,18 @@ import { export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; +export function getCurrentSessionId(env) { + return env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null; +} + +export function filterJobsForSession(jobs, env) { + const sessionId = getCurrentSessionId(env); + if (!sessionId) { + return jobs; + } + return jobs.filter((job) => job.sessionId === sessionId); +} + function normalizeProgressEvent(value) { if (value && typeof value === "object" && !Array.isArray(value)) { return { diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 748d55103..ba585ae29 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -8,7 +8,8 @@ import { saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle import { DEAD_WORKER_MESSAGE } from "../plugins/codex/scripts/lib/tracked-jobs.mjs"; import assert from "node:assert/strict"; -import { brokerExclusion, brokerPresence, cancelDecision, commitCancel, emitCancelPending, isWorkerProvedRecord, isWorkerTerminalRecord, renderCancelPending } from "../plugins/codex/scripts/lib/job-control.mjs"; +import { brokerExclusion, brokerPresence, cancelDecision, commitCancel, isWorkerProvedRecord, isWorkerTerminalRecord } from "../plugins/codex/scripts/lib/job-control.mjs"; +import { emitCancelPending, renderCancelPending } from "../plugins/codex/scripts/lib/render.mjs"; const SURVIVORS = [{ pid: 4301, identity: "win32:7" }]; From 6d66105c18b47c29b0a9e603fe5b8a600d92809a Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:21:57 +0300 Subject: [PATCH 05/11] test: shared harness helpers (waitForExit, waitUntil, deadPid, delay, readJobRecord, seededRepo, script paths) Co-Authored-By: Claude Fable 5.1 --- tests/broker-idle-timeout.test.mjs | 24 +----- tests/broker-stale-pid.test.mjs | 42 +--------- tests/helpers.mjs | 94 ++++++++++++++++++++- tests/runtime.test.mjs | 130 +++++++++++------------------ tests/state.test.mjs | 8 +- 5 files changed, 147 insertions(+), 151 deletions(-) diff --git a/tests/broker-idle-timeout.test.mjs b/tests/broker-idle-timeout.test.mjs index 194467a99..d0796661a 100644 --- a/tests/broker-idle-timeout.test.mjs +++ b/tests/broker-idle-timeout.test.mjs @@ -7,7 +7,7 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; -import { IS_WIN, makeTempDir, run } from "./helpers.mjs"; +import { delay, IS_WIN, makeTempDir, run, waitForExit } from "./helpers.mjs"; import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; import { clearBrokerSession, loadBrokerSession, saveBrokerSession, waitForBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; @@ -26,24 +26,6 @@ function spawnBroker({ cwd, endpoint, env, idleTimeoutMs }) { }); } -function waitForExit(child, { timeoutMs = 5000 } = {}) { - return new Promise((resolve, reject) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve({ code: child.exitCode, signal: child.signalCode }); - return; - } - const timer = setTimeout(() => { - child.removeListener("exit", onExit); - reject(new Error("Timed out waiting for broker process to exit.")); - }, timeoutMs); - function onExit(code, signal) { - clearTimeout(timer); - resolve({ code, signal }); - } - child.once("exit", onExit); - }); -} - function connectClient(endpoint) { const target = parseBrokerEndpoint(endpoint); return new Promise((resolve, reject) => { @@ -64,10 +46,6 @@ async function waitFor(predicate, timeoutMs, message) { throw new Error(message); } -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - // Connect and try one `initialize`, reporting whether the broker served it. function connectAndInitialize(endpoint, { timeoutMs = 1000 } = {}) { const target = parseBrokerEndpoint(endpoint); diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index d5b24f3d8..315a23ee7 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -5,10 +5,9 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; -import { IS_WIN, makeTempDir, run, waitFor } from "./helpers.mjs"; +import { deadPid, IS_WIN, makeTempDir, ROOT, run, SCRIPT, SESSION_HOOK, waitFor, waitForExit, waitUntil } from "./helpers.mjs"; import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; import { clearBrokerSession, @@ -27,28 +26,7 @@ import { getProcessIdentity, terminateProcessTree } from "../plugins/codex/scrip import { brokerExclusion } from "../plugins/codex/scripts/lib/job-control.mjs"; import { resolveStateDir, STATE_LOCK_TIMEOUT_CODE } from "../plugins/codex/scripts/lib/state.mjs"; -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const BROKER_SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "app-server-broker.mjs"); -const SESSION_HOOK = path.join(ROOT, "plugins", "codex", "scripts", "session-lifecycle-hook.mjs"); -const SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "codex-companion.mjs"); - -function waitForExit(child, { timeoutMs = 10000 } = {}) { - return new Promise((resolve, reject) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve({ code: child.exitCode, signal: child.signalCode }); - return; - } - const timer = setTimeout(() => { - child.removeListener("exit", onExit); - reject(new Error("Timed out waiting for broker process to exit.")); - }, timeoutMs); - function onExit(code, signal) { - clearTimeout(timer); - resolve({ code, signal }); - } - child.once("exit", onExit); - }); -} function isAlive(pid) { try { @@ -202,18 +180,6 @@ function runSessionEndHookAsync(workspace, { env = process.env, sessionId = null return new Promise((resolve) => child.on("close", (status) => resolve({ status, stdout, stderr }))); } -async function waitUntil(predicate, { timeoutMs = 8000, intervalMs = 100 } = {}) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const value = await predicate(); - if (value) { - return value; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - return null; -} - // Regression cover for the graceful path: the session that owns the broker ends, // nothing else depends on it, so the process must be gone and the record must // not survive to be signalled by a later hook. @@ -1056,12 +1022,6 @@ function recordingKill(killed) { }; } -function deadPid() { - const result = run(process.execPath, ["-e", ""]); - assert.equal(result.status, 0); - return result.pid; -} - test("ensureBrokerSession kills a live unreachable broker before replacing it (#753/#762)", async () => { const binDir = makeTempDir(); installFakeCodex(binDir); diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 5b52cf590..f66a7a744 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -3,8 +3,10 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -import { parseProtocolLines, resetWindowsIdentityCircuit, runPowerShell } from "../plugins/codex/scripts/lib/process.mjs"; +import { isPidAlive, parseProtocolLines, resetWindowsIdentityCircuit, runPowerShell } from "../plugins/codex/scripts/lib/process.mjs"; +import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; export const IS_WIN = process.platform === "win32"; @@ -78,3 +80,93 @@ export async function waitFor(predicate, { timeoutMs = 30000, intervalMs = 50 } } throw new Error("Timed out waiting for condition."); } + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); +export const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); +export const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); +export const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); +export const FAKE_RESOLVED_SETTINGS = { + model: "gpt-5.4", + modelProvider: "openai", + reasoningEffort: null, + sandbox: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false + } +}; + +export const isAlive = (pid) => isPidAlive(pid) === true; + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// A pid that has certainly exited (a finished child), for stale-record fixtures. +export function deadPid() { + const finished = run(process.execPath, ["-e", ""]); + if (finished.status !== 0) throw new Error(`deadPid: helper child exited ${finished.status}`); + return finished.pid; +} + +export function waitForExit(child, { timeoutMs = 10000 } = {}) { + return new Promise((resolve, reject) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + const timer = setTimeout(() => { + child.removeListener("exit", onExit); + reject(new Error("Timed out waiting for broker process to exit.")); + }, timeoutMs); + function onExit(code, signal) { + clearTimeout(timer); + resolve({ code, signal }); + } + child.once("exit", onExit); + }); +} + +// Like waitFor but resolves null on timeout, so the caller's own assertion (and its +// on-failure diagnostic, e.g. the broker-log tail) still runs. +export async function waitUntil(predicate, { timeoutMs = 8000, intervalMs = 100 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = await predicate(); + if (value) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return null; +} + +export function readStateIndex(workspaceRoot) { + return JSON.parse(fs.readFileSync(path.join(resolveStateDir(workspaceRoot), "state.json"), "utf8")); +} + +export function readJobRecord(workspaceRoot, jobId = null) { + const resolvedJobId = jobId ?? readStateIndex(workspaceRoot).jobs[0].id; + return JSON.parse(fs.readFileSync(path.join(resolveStateDir(workspaceRoot), "jobs", `${resolvedJobId}.json`), "utf8")); +} + +// Read only on failure: which record a cancel found, who wrote it, and the job-log tail. +export function jobDiagnostics(repo, jobId) { + try { + const record = readJobRecord(repo, jobId); + const log = fs.readFileSync(record.logFile, "utf8").split("\n").slice(-20).join("\n"); + return `record: ${JSON.stringify({ status: record.status, phase: record.phase, transport: record.transport, workerClosed: record.workerClosed, appServerExited: record.appServerExited, errorMessage: record.errorMessage })}\njob log tail:\n${log}`; + } catch (error) { + return `(job record unreadable: ${error.message})`; + } +} + +export function seededRepo() { + const repo = makeTempDir(); + 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 }); + return repo; +} diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index af04a8e27..3b4877fae 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3,12 +3,29 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; -import { cimTree, homeEnv, initGitRepo, IS_WIN, makeTempDir, run, waitFor } from "./helpers.mjs"; +import { + cimTree, + FAKE_RESOLVED_SETTINGS, + homeEnv, + initGitRepo, + isAlive, + IS_WIN, + jobDiagnostics, + makeTempDir, + readJobRecord, + ROOT, + run, + SCRIPT, + seededRepo, + SESSION_HOOK, + STOP_HOOK, + waitFor +} from "./helpers.mjs"; import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; -import { getProcessIdentity, isPidAlive } from "../plugins/codex/scripts/lib/process.mjs"; +import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; import { resolveClaudeSessionPath, resolveClaudeProjectsDir } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; import { consumeJobRequestFile, @@ -23,42 +40,6 @@ import { writeJobPidFile } from "../plugins/codex/scripts/lib/state.mjs"; -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); -const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); -const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); -const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); -const FAKE_RESOLVED_SETTINGS = { - model: "gpt-5.4", - modelProvider: "openai", - reasoningEffort: null, - sandbox: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false - } -}; - -const isAlive = (pid) => isPidAlive(pid) === true; - -function readPersistedJob(workspaceRoot, jobId = null) { - const stateDir = resolveStateDir(workspaceRoot); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const resolvedJobId = jobId ?? state.jobs[0].id; - return JSON.parse(fs.readFileSync(path.join(stateDir, "jobs", `${resolvedJobId}.json`), "utf8")); -} - -// Read only on failure: which record a cancel found, who wrote it, and the job-log tail. -function jobDiagnostics(repo, jobId) { - try { - const record = readPersistedJob(repo, jobId); - const log = fs.readFileSync(record.logFile, "utf8").split("\n").slice(-20).join("\n"); - return `record: ${JSON.stringify({ status: record.status, phase: record.phase, transport: record.transport, workerClosed: record.workerClosed, appServerExited: record.appServerExited, errorMessage: record.errorMessage })}\njob log tail:\n${log}`; - } catch (error) { - return `(job record unreadable: ${error.message})`; - } -} - test("setup reports ready when fake codex is installed and authenticated", () => { const binDir = makeTempDir(); installFakeCodex(binDir); @@ -188,7 +169,7 @@ test("review renders a no-findings result from app-server review/start", () => { assert.equal(result.status, 0); assert.match(result.stdout, /Reviewed uncommitted changes/); assert.match(result.stdout, /No material issues found/); - assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS); + assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); }); test("task runs when the active provider does not require OpenAI login", () => { @@ -436,7 +417,7 @@ test("adversarial review renders structured findings over app-server turn/start" assert.equal(result.status, 0); assert.match(result.stdout, /Missing empty-state guard/); - assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS); + assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); }); test("adversarial review accepts the same base-branch targeting as review", () => { @@ -554,7 +535,7 @@ test("task --resume-last resumes the latest persisted task thread", () => { assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); - assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS); + assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); }); test("task-resume-candidate returns the latest rescue thread from the current session", () => { @@ -931,7 +912,7 @@ test("task forwards model selection and reasoning effort to app-server turn/star const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); assert.equal(fakeState.lastTurnStart.effort, "low"); - assert.deepEqual(readPersistedJob(repo).resolved, { + assert.deepEqual(readJobRecord(repo).resolved, { ...FAKE_RESOLVED_SETTINGS, model: "gpt-5.3-codex-spark", reasoningEffort: "low" @@ -954,7 +935,7 @@ test("task preserves resolved settings when turn/start fails", () => { assert.notEqual(result.status, 0); assert.match(result.stderr, /turn\/start failed after thread resolution/); - const storedJob = readPersistedJob(repo); + const storedJob = readJobRecord(repo); assert.equal(storedJob.status, "failed"); // `--effort` is applied via thread/start.config, so the resolved settings echo it back. const resolvedWithEffort = { ...FAKE_RESOLVED_SETTINGS, reasoningEffort: "xhigh" }; @@ -1227,7 +1208,7 @@ test("task --background enqueues a detached worker and exposes per-job status", const runningJob = await waitFor(() => { try { - const storedJob = readPersistedJob(repo, launchPayload.jobId); + const storedJob = readJobRecord(repo, launchPayload.jobId); return storedJob.status === "running" && storedJob.resolved ? storedJob : null; } catch { return null; @@ -2174,7 +2155,7 @@ test("cancel interrupts a brokered task and records cancelled only after the wor assert.equal(cancelPayload.status, "cancelled"); assert.equal(cancelPayload.turnInterruptAttempted, true); assert.equal(cancelPayload.turnInterrupted, true); - const stored = readPersistedJob(repo, jobId); + const stored = readJobRecord(repo, jobId); assert.equal(stored.status, "cancelled", jobDiagnostics(repo, jobId)); assert.deepEqual([stored.workerClosed, stored.appServerExited], [true, true], jobDiagnostics(repo, jobId)); const log = fs.readFileSync(stored.logFile, "utf8"); @@ -2213,7 +2194,7 @@ test("a brokered cancel whose interrupt is ignored stays pending and kills nothi const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); assert.equal(launched.status, 0, launched.stderr); const { jobId } = JSON.parse(launched.stdout); - const running = await waitFor(() => { const job = readPersistedJob(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); + const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); t.after(() => run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) })); assert.equal(running.transport, "broker", jobDiagnostics(repo, jobId)); @@ -2222,7 +2203,7 @@ test("a brokered cancel whose interrupt is ignored stays pending and kills nothi assert.equal(first.status, 1, `cancel said: ${first.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); assert.deepEqual(JSON.parse(first.stdout), { jobId, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }); assert.equal(isAlive(running.pid), true, "no kill while the turn still runs in the broker"); - assert.equal(readPersistedJob(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); + assert.equal(readJobRecord(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); assert.match(fs.readFileSync(running.logFile, "utf8"), /left running \(turn-not-interrupted\)/); const second = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); @@ -2230,7 +2211,7 @@ test("a brokered cancel whose interrupt is ignored stays pending and kills nothi const payload = JSON.parse(second.stdout); assert.equal(payload.status, "cancelled", `cancel said: ${second.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); assert.equal(payload.turnInterrupted, true); - assert.equal(readPersistedJob(repo, jobId).status, "cancelled", jobDiagnostics(repo, jobId)); + assert.equal(readJobRecord(repo, jobId).status, "cancelled", jobDiagnostics(repo, jobId)); await waitFor(() => !isAlive(running.pid)); }); @@ -2247,19 +2228,19 @@ test("a job file forged to transport direct does not take the direct kill path w const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); assert.equal(launched.status, 0, launched.stderr); const { jobId } = JSON.parse(launched.stdout); - const running = await waitFor(() => { const job = readPersistedJob(repo, jobId); return job.status === "running" && job.pid && job.turnId && job.transport === "broker" ? job : null; }); + const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId && job.transport === "broker" ? job : null; }); t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); t.after(() => run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) })); // Only the job file is rewritten; the index keeps `broker`. writeJobFile(repo, jobId, { ...readJobFile(resolveJobFile(repo, jobId)), transport: "direct" }); - assert.equal(readPersistedJob(repo, jobId).transport, "direct", jobDiagnostics(repo, jobId)); + assert.equal(readJobRecord(repo, jobId).transport, "direct", jobDiagnostics(repo, jobId)); const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); assert.equal(cancel.status, 1, `cancel said: ${cancel.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); assert.deepEqual(JSON.parse(cancel.stdout), { jobId, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }, jobDiagnostics(repo, jobId)); assert.equal(isAlive(running.pid), true, `no kill on a disagreeing transport\n${jobDiagnostics(repo, jobId)}`); - assert.equal(readPersistedJob(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); + assert.equal(readJobRecord(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); assert.equal(listJobs(repo).find((entry) => entry.id === jobId)?.transport, "broker", "the index was never forged"); const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); assert.ok(fakeState.lastInterrupt, `the interrupt was sent (brokered path)\n${jobDiagnostics(repo, jobId)}`); @@ -3199,15 +3180,6 @@ test("setup and status honor --cwd when reading shared session runtime", () => { assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); }); -function seededRepo() { - const repo = makeTempDir(); - 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 }); - return repo; -} - test("review forwards model, review_model, effort and config overrides into thread/start config", () => { const repo = seededRepo(); const binDir = makeTempDir(); @@ -3986,7 +3958,7 @@ test("a direct cancel skips the interrupt, and the cancellation survives a worke const launch = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "--prompt-stdin"], { cwd: repo, env, input: "cancel me late\n" }); assert.equal(launch.status, 0, launch.stderr); const { jobId } = JSON.parse(launch.stdout); - const running = await waitFor(() => { const job = readPersistedJob(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); + const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); assert.equal(running.transport, "direct", jobDiagnostics(repo, jobId)); const startsBefore = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts; @@ -4226,7 +4198,7 @@ test("cancel removes the private request payload of a job killed in the queued w assert.equal(JSON.parse(cancelled.stdout).status, "cancelled"); assert.equal(fs.existsSync(requestFile), false, "the private payload must not outlive the cancelled job"); - const stored = readPersistedJob(repo, "task-queued"); + const stored = readJobRecord(repo, "task-queued"); assert.equal(stored.status, "cancelled"); assert.equal(stored.requestFile, null); assert.equal(fs.readFileSync(path.join(stateDir, "state.json"), "utf8").includes(secret), false); @@ -4256,7 +4228,7 @@ test("task keeps running through an error notification that Codex will retry", ( const result = run(process.execPath, [SCRIPT, "task", "--json", "do the thing"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); assert.equal(result.status, 0, result.stderr); assert.match(JSON.parse(result.stdout).rawOutput, /./); - const stored = readPersistedJob(repo); + const stored = readJobRecord(repo); assert.equal(stored.status, "completed"); assert.equal(stored.errorMessage, null); }); @@ -4353,7 +4325,7 @@ test("a subagent's terminal error does not fail the main turn", () => { assert.equal(result.error, undefined); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /subagent at capacity/); - const stored = readPersistedJob(repo); + const stored = readJobRecord(repo); assert.equal(stored.status, "completed"); assert.equal(stored.errorMessage, null); }); @@ -4382,7 +4354,7 @@ test("a timed-out turn whose turn/start carried no id is still interrupted (#781 assert.equal(result.status, 1, result.stderr); const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); assert.ok(fakeState.lastInterrupt?.turnId, "the turn named by turn/started must be interrupted"); - assert.equal(readPersistedJob(repo).turnId, fakeState.lastInterrupt.turnId); + assert.equal(readJobRecord(repo).turnId, fakeState.lastInterrupt.turnId); }); test("status --wait reports a timeout in text output and exits 1 while the job is still active (#774)", () => { @@ -4410,9 +4382,9 @@ test("cancel on Windows kills a direct worker and the codex.cmd tree under it", const launched = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "hold"], { cwd: repo, env }); assert.equal(launched.status, 0, launched.stderr); const jobId = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readPersistedJob(repo, jobId); return j.pid ? j : null; }); + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - const running = await waitFor(() => { const j = readPersistedJob(repo, jobId); return j.status === "running" && j.pidIdentity && j.threadId && j.turnId ? j : null; }); + const running = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.pidIdentity && j.threadId && j.turnId ? j : null; }); assert.match(running.pidIdentity, /^win32:\d+$/); assert.equal(running.transport, "direct"); const tree = cimTree(running.pid); @@ -4422,7 +4394,7 @@ test("cancel on Windows kills a direct worker and the codex.cmd tree under it", assert.equal(cancel.status, 0, cancel.stderr); assert.equal(JSON.parse(cancel.stdout).turnInterruptAttempted, false, "a direct job's app-server is the worker's own"); await waitFor(() => (tree.every((n) => !isAlive(n.pid)) ? "gone" : null)); - assert.equal(readPersistedJob(repo, jobId).status, "cancelled"); + assert.equal(readJobRecord(repo, jobId).status, "cancelled"); }); test("a brokered cancel on Windows kills nothing until the turn ends, leaves the shared broker and its subtree alive, and the same app-server serves the next job", { skip: !IS_WIN, timeout: 180_000 }, async (t) => { @@ -4432,9 +4404,9 @@ test("a brokered cancel on Windows kills nothing until the turn ends, leaves the const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1", CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "60000" }); const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold A"], { cwd: repo, env }); const jobA = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readPersistedJob(repo, jobA); return j.pid ? j : null; }); + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobA); return j.pid ? j : null; }); t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - const running = await waitFor(() => { const j = readPersistedJob(repo, jobA); return j.status === "running" && j.turnId ? j : null; }); + const running = await waitFor(() => { const j = readJobRecord(repo, jobA); return j.status === "running" && j.turnId ? j : null; }); assert.equal(running.transport, "broker", jobDiagnostics(repo, jobA)); const broker = loadBrokerSession(repo); assert.ok(broker?.pid, "worker A started the shared broker"); @@ -4448,7 +4420,7 @@ test("a brokered cancel on Windows kills nothing until the turn ends, leaves the assert.equal(pending.status, 1, `cancel said: ${pending.stdout.trim()}\n${jobDiagnostics(repo, jobA)}`); assert.deepEqual(JSON.parse(pending.stdout), { jobId: jobA, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }); assert.equal(isAlive(withPid.pid), true, "no kill while the turn still runs in the broker"); - assert.equal(readPersistedJob(repo, jobA).status, "running", jobDiagnostics(repo, jobA)); + assert.equal(readJobRecord(repo, jobA).status, "running", jobDiagnostics(repo, jobA)); assert.equal(isAlive(broker.pid), true, "the shared broker is untouched by a pending cancel"); assert.ok(brokerTree.every((n) => isAlive(n.pid)), "and so is its subtree"); assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); @@ -4464,7 +4436,7 @@ test("a brokered cancel on Windows kills nothing until the turn ends, leaves the assert.equal(next.error, undefined); assert.equal(next.status, 1, next.stderr); // The foreground JSON carries only the payload; the stored job carries the outcome. - const jobC = readPersistedJob(repo); + const jobC = readJobRecord(repo); assert.notEqual(jobC.id, jobA); assert.match(jobC.errorMessage ?? "", /turn timed out after 3000 ms/, "the next job really ran a turn (and hit its budget)"); const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); @@ -4480,16 +4452,16 @@ test("a root that died before cancel is failed by the reaper on Windows; nothing const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); const launched = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "hold"], { cwd: repo, env }); const jobId = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readPersistedJob(repo, jobId); return j.pid ? j : null; }); + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - await waitFor(() => { const j = readPersistedJob(repo, jobId); return j.status === "running" && j.turnId ? j : null; }); + await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.turnId ? j : null; }); const tree = cimTree(withPid.pid); t.after(() => { for (const { pid } of tree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); process.kill(withPid.pid, "SIGKILL"); await waitFor(() => (!isAlive(withPid.pid) ? "dead" : null)); const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); assert.notEqual(cancel.status, 0, "the reaper already failed the job; cancel has nothing active to signal"); - assert.equal(readPersistedJob(repo, jobId).status, "failed"); + assert.equal(readJobRecord(repo, jobId).status, "failed"); // The orphaned children are the documented limitation here: nothing is touched by number. assert.ok(tree.filter((n) => n.pid !== withPid.pid).some((n) => isAlive(n.pid))); }); @@ -4499,16 +4471,16 @@ test("a reused-looking identity is never signalled on Windows: the reaper fails const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); const jobId = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readPersistedJob(repo, jobId); return j.pid ? j : null; }); + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - await waitFor(() => { const j = readPersistedJob(repo, jobId); return j.status === "running" && j.pidIdentity ? j : null; }); + await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.pidIdentity ? j : null; }); upsertJob(repo, { id: jobId, pidIdentity: "win32:1" }); const jobFile = path.join(resolveStateDir(repo), "jobs", `${jobId}.json`); fs.writeFileSync(jobFile, JSON.stringify({ ...JSON.parse(fs.readFileSync(jobFile, "utf8")), pidIdentity: "win32:1" })); const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); assert.notEqual(cancel.status, 0); assert.equal(isAlive(withPid.pid), true, "the process holding the pid is a stranger to this record and must stay"); - const stored = readPersistedJob(repo, jobId); + const stored = readJobRecord(repo, jobId); assert.equal(stored.status, "failed"); assert.match(stored.errorMessage ?? "", /pid reused/); }); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index a5a5c4e9f..92f3db618 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -6,7 +6,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { IS_WIN, makeTempDir, run } from "./helpers.mjs"; +import { deadPid, IS_WIN, makeTempDir, run } from "./helpers.mjs"; import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; import { consumeJobRequestFile, @@ -315,12 +315,6 @@ function seedLockEntry(lockDir, name, pid, startedAt = new Date().toISOString(), return entry; } -function deadPid() { - const finished = run(process.execPath, ["-e", "process.exit(0)"], { env: process.env }); - assert.equal(finished.status, 0); - return finished.pid; -} - // The property the whole lock exists for, checked the only way that means // anything: a counter that is read, incremented and written back — non-atomic by // construction, so any overlap loses increments. From acb11a75ba1756bc6d405124147ce39c39dc4544 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:39:49 +0300 Subject: [PATCH 06/11] test: split runtime.test.mjs by command family Co-Authored-By: Claude Fable 5.1 --- tests/runtime-cancel.test.mjs | 743 +++++ tests/runtime-hooks.test.mjs | 774 ++++++ tests/runtime-review.test.mjs | 351 +++ tests/runtime-setup.test.mjs | 305 +++ tests/runtime-status.test.mjs | 583 ++++ tests/runtime-task.test.mjs | 1703 ++++++++++++ tests/runtime-transfer.test.mjs | 170 ++ tests/runtime.test.mjs | 4517 ------------------------------- 8 files changed, 4629 insertions(+), 4517 deletions(-) create mode 100644 tests/runtime-cancel.test.mjs create mode 100644 tests/runtime-hooks.test.mjs create mode 100644 tests/runtime-review.test.mjs create mode 100644 tests/runtime-setup.test.mjs create mode 100644 tests/runtime-status.test.mjs create mode 100644 tests/runtime-task.test.mjs create mode 100644 tests/runtime-transfer.test.mjs delete mode 100644 tests/runtime.test.mjs diff --git a/tests/runtime-cancel.test.mjs b/tests/runtime-cancel.test.mjs new file mode 100644 index 000000000..432366838 --- /dev/null +++ b/tests/runtime-cancel.test.mjs @@ -0,0 +1,743 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + cimTree, + initGitRepo, + isAlive, + IS_WIN, + jobDiagnostics, + makeTempDir, + readJobRecord, + readStateIndex, + ROOT, + run, + SCRIPT, + seededRepo, + SESSION_HOOK, + waitFor +} from "./helpers.mjs"; +import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; +import { + listJobs, + readJobFile, + resolveJobFile, + resolveJobPidFile, + resolveStateDir, + upsertJob, + writeJobFile, + writeJobPidFile +} from "../plugins/codex/scripts/lib/state.mjs"; + + +test("cancel stops an active background job and marks it cancelled", async (t) => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + // A record without an identity (written by v1.2.x) is only signalled when the + // pid's command line is still this job's worker (#743). + const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "codex-companion.mjs", "task-worker", "--job-id", "task-live"], { + cwd: workspace, + detached: true, + stdio: "ignore" + }); + sleeper.unref(); + + t.after(() => { + try { + process.kill(-sleeper.pid, "SIGTERM"); + } catch { + try { + process.kill(sleeper.pid, "SIGTERM"); + } catch { + // Ignore missing process. + } + } + }); + + const logFile = path.join(jobsDir, "task-live.log"); + const jobFile = path.join(jobsDir, "task-live.json"); + fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); + fs.writeFileSync( + jobFile, + JSON.stringify( + { + id: "task-live", + status: "running", + title: "Codex Task", + logFile + }, + null, + 2 + ), + "utf8" + ); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-live", + status: "running", + title: "Codex Task", + jobClass: "task", + summary: "Investigate flaky test", + pid: sleeper.pid, + logFile, + createdAt: "2026-03-18T15:30:00.000Z", + startedAt: "2026-03-18T15:30:01.000Z", + updatedAt: "2026-03-18T15:30:02.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const cancelResult = run(process.execPath, [SCRIPT, "cancel", "task-live", "--json"], { + cwd: workspace + }); + + if (IS_WIN) { + // Documented v1.3.0 refusal: win32 cannot prove the pid is this job's worker + // (identity-unavailable until v1.4.1), so the job stays running, exit 1. + assert.equal(cancelResult.status, 1, cancelResult.stderr); + assert.deepEqual(JSON.parse(cancelResult.stdout), { jobId: "task-live", status: "running", cancellationPending: true, reason: "identity-unavailable" }); + return; + } + assert.equal(cancelResult.status, 0, cancelResult.stderr); + assert.equal(JSON.parse(cancelResult.stdout).status, "cancelled"); + + await waitFor(() => { + try { + process.kill(sleeper.pid, 0); + return false; + } catch (error) { + return error?.code === "ESRCH"; + } + }); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + const cancelled = state.jobs.find((job) => job.id === "task-live"); + assert.equal(cancelled.status, "cancelled"); + assert.equal(cancelled.pid, null); + + const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); + assert.equal(stored.status, "cancelled"); + assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/); +}); + +// The #743 scenario through the no-identity command-line fallback: a record from +// before identities existed names a pid the OS has since handed to another +// companion process. The reaper cannot rule a companion out by command line, so +// it keeps the job; cancel must refuse to signal a process that is not this +// job's worker, say so, and not claim the job was cancelled — it stays running +// (sidecar kept) until the pid goes away. (A pid now running something that is +// not a companion at all is reaped instead; see tests/tracked-jobs.test.mjs.) +test("cancel through the no-identity command-line fallback refuses a foreign pid and keeps the job running", { skip: process.platform === "win32" }, async (t) => { + const repo = makeTempDir(); + initGitRepo(repo); + // Still a companion process (the reaper cannot rule it out by command line), + // just not this job's worker. + const stranger = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "codex-companion.mjs", "task-worker", "--job-id", "task-other"], { detached: true, stdio: "ignore" }); + stranger.unref(); + t.after(() => { + try { process.kill(stranger.pid, "SIGKILL"); } catch {} + }); + const job = { + id: "task-recycled", + status: "running", + phase: "delegating", + title: "Codex Task", + jobClass: "task", + pid: stranger.pid, + logFile: null, + createdAt: new Date().toISOString() + }; + writeJobFile(repo, job.id, job); + upsertJob(repo, job); + writeJobPidFile(repo, job.id, stranger.pid); + + const cancel = run(process.execPath, [SCRIPT, "cancel", job.id], { cwd: repo }); + + assert.equal(cancel.status, 1, cancel.stderr); + assert.match(cancel.stdout, new RegExp(`cancellation not confirmed: worker pid ${stranger.pid} left running \\(identity-mismatch\\)`)); + assert.match(cancel.stdout, /the job stays running until the worker exits/); + const cancelJson = run(process.execPath, [SCRIPT, "cancel", job.id, "--json"], { cwd: repo }); + assert.equal(cancelJson.status, 1, cancelJson.stderr); + assert.deepEqual(JSON.parse(cancelJson.stdout), { jobId: job.id, status: "running", cancellationPending: true, reason: "identity-mismatch" }); + const json = run(process.execPath, [SCRIPT, "status", job.id, "--json"], { cwd: repo }); + assert.equal(json.status, 0, json.stderr); + assert.equal(JSON.parse(json.stdout).job.status, "running"); + assert.equal(fs.existsSync(resolveJobPidFile(repo, job.id)), true, "the pid sidecar must survive a refused cancel"); + process.kill(stranger.pid, 0); // still alive: throws ESRCH if cancel signalled it + + // Once the pid is gone the job reaches a terminal state the normal way. + process.kill(stranger.pid, "SIGKILL"); + await waitFor(() => { + try { process.kill(stranger.pid, 0); return false; } catch (error) { return error?.code === "ESRCH"; } + }); + const after = run(process.execPath, [SCRIPT, "status", job.id, "--json"], { cwd: repo }); + assert.equal(after.status, 0, after.stderr); + assert.notEqual(JSON.parse(after.stdout).job.status, "running"); +}); + +// The parent records the worker's identity next to its pid, so cancel can prove +// the pid is still that worker before signalling it. +test("a background worker's pid sidecar carries its identity and cancel signals it", async () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "8000" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "slow"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const sidecar = JSON.parse(fs.readFileSync(resolveJobPidFile(repo, jobId), "utf8")); + try { + assert.ok(Number.isInteger(sidecar.pid)); + assert.equal(sidecar.identity, getProcessIdentity(sidecar.pid)); + assert.match(sidecar.identity, /^(linux|darwin|win32):/); + // v1.4.1 refuses kills while the broker record has no identity (Windows start window). + if (IS_WIN) { + await waitFor(() => (/^win32:\d+$/.test(loadBrokerSession(repo)?.pidIdentity ?? "") ? "ready" : null)); + } + const cancel = run(process.execPath, [SCRIPT, "cancel", jobId], { cwd: repo, env }); + assert.equal(cancel.status, 0, cancel.stderr); + assert.doesNotMatch(cancel.stdout, /left running/); + await waitFor(() => { + try { process.kill(sidecar.pid, 0); return false; } catch (error) { return error?.code === "ESRCH"; } + }); + } finally { + try { process.kill(-sidecar.pid, "SIGKILL"); } catch { try { process.kill(sidecar.pid, "SIGKILL"); } catch {} } + } +}); + +test("cancel without a job id ignores active jobs from other Claude sessions", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const logFile = path.join(jobsDir, "task-other.log"); + fs.writeFileSync(logFile, "", "utf8"); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-other", + status: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-other", + summary: "Other session run", + updatedAt: "2026-03-24T20:05:00.000Z", + logFile + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const env = { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + }; + const status = run(process.execPath, [SCRIPT, "status", "--json"], { + cwd: workspace, + env + }); + assert.equal(status.status, 0, status.stderr); + assert.deepEqual(JSON.parse(status.stdout).running, []); + + const cancel = run(process.execPath, [SCRIPT, "cancel", "--json"], { + cwd: workspace, + env + }); + assert.equal(cancel.status, 1); + assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + assert.equal(state.jobs[0].status, "running"); +}); + +test("cancel with a job id can still target an active job from another Claude session", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const logFile = path.join(jobsDir, "task-other.log"); + fs.writeFileSync(logFile, "", "utf8"); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-other", + status: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-other", + summary: "Other session run", + updatedAt: "2026-03-24T20:05:00.000Z", + logFile + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const env = { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + }; + const cancel = run(process.execPath, [SCRIPT, "cancel", "task-other", "--json"], { + cwd: workspace, + env + }); + assert.equal(cancel.status, 0, cancel.stderr); + assert.equal(JSON.parse(cancel.stdout).jobId, "task-other"); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + assert.equal(state.jobs[0].status, "cancelled"); +}); + +test("cancel interrupts a brokered task and records cancelled only after the worker's own terminal record", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "interruptible-slow-task"); + 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 env = buildEnv(binDir); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "investigate the flaky worker timeout"], { + cwd: repo, + env + }); + + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + const jobId = launchPayload.jobId; + assert.ok(jobId); + + const runningJob = await waitFor(() => { + const state = readStateIndex(repo); + const job = state.jobs.find((candidate) => candidate.id === jobId); + if (job?.status === "running" && job.threadId && job.turnId && job.transport) { + return job; + } + return null; + }, { timeoutMs: 30000 }); + assert.equal(runningJob.transport, "broker"); + + const cancelResult = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { + cwd: repo, + env + }); + + assert.equal(cancelResult.status, 0, cancelResult.stderr); + const cancelPayload = JSON.parse(cancelResult.stdout); + assert.equal(cancelPayload.status, "cancelled"); + assert.equal(cancelPayload.turnInterruptAttempted, true); + assert.equal(cancelPayload.turnInterrupted, true); + const stored = readJobRecord(repo, jobId); + assert.equal(stored.status, "cancelled", jobDiagnostics(repo, jobId)); + assert.deepEqual([stored.workerClosed, stored.appServerExited], [true, true], jobDiagnostics(repo, jobId)); + const log = fs.readFileSync(stored.logFile, "utf8"); + assert.ok(log.includes("Turn interrupted.") && log.indexOf("Turn interrupted.") < log.indexOf("Cancelled by user."), `the turn ended before the cancel wrote:\n${log}`); + + await waitFor(() => { + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + return fakeState.lastInterrupt ?? null; + }); + + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.deepEqual(fakeState.lastInterrupt, { + threadId: runningJob.threadId, + turnId: runningJob.turnId + }); + + const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env, + input: JSON.stringify({ + hook_event_name: "SessionEnd", + cwd: repo + }) + }); + assert.equal(cleanup.status, 0, cleanup.stderr); +}); + +// The turn lives in the shared runtime, not in the worker: a cancel whose +// interrupt the runtime ignores must not claim success and must not kill the +// worker (the turn would keep running and the reaper would fail the job). +test("a brokered cancel whose interrupt is ignored stays pending and kills nothing; the next cancel ends the turn", { skip: IS_WIN, timeout: 90_000 }, async (t) => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const { jobId } = JSON.parse(launched.stdout); + const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); + t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); + t.after(() => run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) })); + assert.equal(running.transport, "broker", jobDiagnostics(repo, jobId)); + + const first = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(first.status, 1, `cancel said: ${first.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); + assert.deepEqual(JSON.parse(first.stdout), { jobId, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }); + assert.equal(isAlive(running.pid), true, "no kill while the turn still runs in the broker"); + assert.equal(readJobRecord(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); + assert.match(fs.readFileSync(running.logFile, "utf8"), /left running \(turn-not-interrupted\)/); + + const second = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(second.status, 0, `${second.stderr}\n${jobDiagnostics(repo, jobId)}`); + const payload = JSON.parse(second.stdout); + assert.equal(payload.status, "cancelled", `cancel said: ${second.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); + assert.equal(payload.turnInterrupted, true); + assert.equal(readJobRecord(repo, jobId).status, "cancelled", jobDiagnostics(repo, jobId)); + await waitFor(() => !isAlive(running.pid)); +}); + +// The direct path is trusted only when the job file and the index agree: the +// updater patches the index (locked) and then the file, so a file that says +// direct while the index says broker is forged or torn, and the kill (no turn +// end) would strand the turn. +test("a job file forged to transport direct does not take the direct kill path while the index says broker", { skip: IS_WIN, timeout: 90_000 }, async (t) => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const { jobId } = JSON.parse(launched.stdout); + const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId && job.transport === "broker" ? job : null; }); + t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); + t.after(() => run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) })); + + // Only the job file is rewritten; the index keeps `broker`. + writeJobFile(repo, jobId, { ...readJobFile(resolveJobFile(repo, jobId)), transport: "direct" }); + assert.equal(readJobRecord(repo, jobId).transport, "direct", jobDiagnostics(repo, jobId)); + + const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(cancel.status, 1, `cancel said: ${cancel.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); + assert.deepEqual(JSON.parse(cancel.stdout), { jobId, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }, jobDiagnostics(repo, jobId)); + assert.equal(isAlive(running.pid), true, `no kill on a disagreeing transport\n${jobDiagnostics(repo, jobId)}`); + assert.equal(readJobRecord(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); + assert.equal(listJobs(repo).find((entry) => entry.id === jobId)?.transport, "broker", "the index was never forged"); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.ok(fakeState.lastInterrupt, `the interrupt was sent (brokered path)\n${jobDiagnostics(repo, jobId)}`); +}); + +test("cancelling an awaited job ends the await with exit 1 and leaves a readable result", async () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "6000" }); + + const child = spawn(process.execPath, [SCRIPT, "task", "--await", "--await-timeout-ms", "30000", "--prompt-stdin"], { + cwd: repo, + env, + stdio: ["pipe", "pipe", "pipe"] + }); + child.stdin.end("cancel me\n"); + const exited = new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", resolve); + }); + + const stateFile = path.join(resolveStateDir(repo), "state.json"); + const jobId = await waitFor(() => { + if (!fs.existsSync(stateFile)) { + return null; + } + const job = JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs?.[0]; + // Wait for the worker to own the record: the turn has to be under way for + // the cancel to have a running turn to interrupt. + return job && job.status === "running" && job.pid ? job.id : null; + }, { timeoutMs: 15000 }); + + // v1.4.1 refuses kills while the broker record has no identity (Windows start window). + if (IS_WIN) { + await waitFor(() => (/^win32:\d+$/.test(loadBrokerSession(repo)?.pidIdentity ?? "") ? "ready" : null)); + } + const cancelled = run(process.execPath, [SCRIPT, "cancel", jobId], { cwd: repo, env }); + assert.equal(cancelled.status, 0, cancelled.stderr); + assert.match(cancelled.stdout, /cancelled/i); + assert.equal(await exited, 1); + + const stored = run(process.execPath, [SCRIPT, "result", jobId, "--json"], { cwd: repo, env }); + assert.equal(stored.status, 0, stored.stderr); + assert.equal(JSON.parse(stored.stdout).job.status, "cancelled"); +}); + +// Direct transport (a cold --resume-last owns its app-server): cancel sends no +// turn/interrupt — no second client can reach that app-server, and the old +// attempt could start a codex of its own — and kills the worker's group. The +// worker ignores SIGTERM and outlives the kill as its app-server dies; its late +// write must not replace the acknowledged `cancelled` record. +test("a direct cancel skips the interrupt, and the cancellation survives a worker that finishes after it", { skip: process.platform === "win32", timeout: 60_000 }, async (t) => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(seeded.status, 0, seeded.stderr); + // Only the task worker ignores SIGTERM; the broker and fake codex keep the default. + const preload = path.join(binDir, "worker-ignores-sigterm.mjs"); + fs.writeFileSync(preload, 'if (process.argv.includes("task-worker")) process.on("SIGTERM", () => {});\n'); + const env = buildEnv(binDir, { + FAKE_CODEX_TURN_DELAY_MS: "20000", + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import ${pathToFileURL(preload).href}`.trim() + }); + const launch = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "--prompt-stdin"], { cwd: repo, env, input: "cancel me late\n" }); + assert.equal(launch.status, 0, launch.stderr); + const { jobId } = JSON.parse(launch.stdout); + const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); + t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); + assert.equal(running.transport, "direct", jobDiagnostics(repo, jobId)); + const startsBefore = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts; + + const cancelled = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(cancelled.status, 0, `${cancelled.stderr}\n${jobDiagnostics(repo, jobId)}`); + const payload = JSON.parse(cancelled.stdout); + assert.equal(payload.status, "cancelled", `cancel said: ${cancelled.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); + assert.equal(payload.turnInterruptAttempted, false, "a direct job's app-server is the worker's own"); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.lastInterrupt ?? null, null, "no turn/interrupt was sent"); + assert.equal(fakeState.appServerStarts, startsBefore, "cancel started no codex of its own"); + + await waitFor(() => !isAlive(running.pid)); + const stored = run(process.execPath, [SCRIPT, "result", jobId, "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(stored.status, 0, stored.stderr); + assert.equal(JSON.parse(stored.stdout).job.status, "cancelled", jobDiagnostics(repo, jobId)); +}); + +// Recording the worker pid on the queued record (so a queued job can be +// cancelled at all) means cancel can now kill a worker *before* it consumed its +// private one-shot payload. A cancelled job is terminal, so the reaper will +// never look at it again — cancel has to release the file itself. The worker's +// identity is recorded, so the kill is provable. +test("cancel removes the private request payload of a job killed in the queued window", async (t) => { + const repo = seededRepo(); + const stateDir = resolveStateDir(repo); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const secret = "sk-cancel-secret-value"; + const requestFile = path.join(jobsDir, "task-queued.request.json"); + fs.writeFileSync(requestFile, JSON.stringify({ prompt: "hi", config: { auth_header: secret } }), { + encoding: "utf8", + mode: 0o600 + }); + + const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + cwd: repo, + detached: true, + stdio: "ignore" + }); + sleeper.unref(); + t.after(() => { + try { + process.kill(-sleeper.pid, "SIGKILL"); + } catch { + // No process groups on Windows, or already gone. + try { + process.kill(sleeper.pid, "SIGKILL"); + } catch { + // Already gone. + } + } + }); + + const job = { + id: "task-queued", + status: "queued", + phase: "queued", + jobClass: "task", + title: "Codex Task", + background: true, + pid: sleeper.pid, + pidIdentity: getProcessIdentity(sleeper.pid), + logFile: null, + requestFile, + request: { prompt: "hi", config: { auth_header: "[redacted]" } }, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:30:00.000Z" + }; + fs.writeFileSync(path.join(jobsDir, "task-queued.json"), `${JSON.stringify(job, null, 2)}\n`, "utf8"); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [job] }, null, 2)}\n`, + "utf8" + ); + + const cancelled = run(process.execPath, [SCRIPT, "cancel", "task-queued", "--json"], { cwd: repo, env: process.env }); + assert.equal(cancelled.status, 0, cancelled.stderr); + assert.equal(JSON.parse(cancelled.stdout).status, "cancelled"); + + assert.equal(fs.existsSync(requestFile), false, "the private payload must not outlive the cancelled job"); + const stored = readJobRecord(repo, "task-queued"); + assert.equal(stored.status, "cancelled"); + assert.equal(stored.requestFile, null); + assert.equal(fs.readFileSync(path.join(stateDir, "state.json"), "utf8").includes(secret), false); +}); + +test("cancel on Windows kills a direct worker and the codex.cmd tree under it", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { + const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(seeded.status, 0, seeded.stderr); + // A cold resume owns its own app-server, so the tree hangs under the worker, not the broker. + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "hold"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); + t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); + const running = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.pidIdentity && j.threadId && j.turnId ? j : null; }); + assert.match(running.pidIdentity, /^win32:\d+$/); + assert.equal(running.transport, "direct"); + const tree = cimTree(running.pid); + t.after(() => { for (const { pid } of tree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); + assert.ok(tree.some((n) => /^cmd\.exe$/i.test(n.name)) && tree.filter((n) => /^node\.exe$/i.test(n.name)).length >= 2, `expected worker → cmd.exe → node.exe, got ${JSON.stringify(tree)}`); + const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(cancel.status, 0, cancel.stderr); + assert.equal(JSON.parse(cancel.stdout).turnInterruptAttempted, false, "a direct job's app-server is the worker's own"); + await waitFor(() => (tree.every((n) => !isAlive(n.pid)) ? "gone" : null)); + assert.equal(readJobRecord(repo, jobId).status, "cancelled"); +}); + +test("a brokered cancel on Windows kills nothing until the turn ends, leaves the shared broker and its subtree alive, and the same app-server serves the next job", { skip: !IS_WIN, timeout: 180_000 }, async (t) => { + const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + // The broker's app-server ignores only the first interrupt: the first cancel must stay pending, the second ends the turn; the "quick C" timeout interrupt is the third and is honoured. + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1", CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "60000" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold A"], { cwd: repo, env }); + const jobA = JSON.parse(launched.stdout).jobId; + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobA); return j.pid ? j : null; }); + t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); + const running = await waitFor(() => { const j = readJobRecord(repo, jobA); return j.status === "running" && j.turnId ? j : null; }); + assert.equal(running.transport, "broker", jobDiagnostics(repo, jobA)); + const broker = loadBrokerSession(repo); + assert.ok(broker?.pid, "worker A started the shared broker"); + t.after(() => { try { process.kill(broker.pid, "SIGKILL"); } catch {} }); + const brokerTree = cimTree(broker.pid); + t.after(() => { for (const { pid } of brokerTree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); + assert.ok(brokerTree.some((n) => /^cmd\.exe$/i.test(n.name)), `expected the app-server tree under the broker, got ${JSON.stringify(brokerTree)}`); + assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); + // The broker is A's child by ParentProcessId on Windows; the kill must skip its whole subtree. + const pending = run(process.execPath, [SCRIPT, "cancel", jobA, "--json"], { cwd: repo, env }); + assert.equal(pending.status, 1, `cancel said: ${pending.stdout.trim()}\n${jobDiagnostics(repo, jobA)}`); + assert.deepEqual(JSON.parse(pending.stdout), { jobId: jobA, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }); + assert.equal(isAlive(withPid.pid), true, "no kill while the turn still runs in the broker"); + assert.equal(readJobRecord(repo, jobA).status, "running", jobDiagnostics(repo, jobA)); + assert.equal(isAlive(broker.pid), true, "the shared broker is untouched by a pending cancel"); + assert.ok(brokerTree.every((n) => isAlive(n.pid)), "and so is its subtree"); + assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); + const cancel = run(process.execPath, [SCRIPT, "cancel", jobA, "--json"], { cwd: repo, env }); + assert.equal(cancel.status, 0, `${cancel.stderr}\n${jobDiagnostics(repo, jobA)}`); + assert.equal(JSON.parse(cancel.stdout).status, "cancelled", jobDiagnostics(repo, jobA)); + await waitFor(() => (!isAlive(withPid.pid) ? "gone" : null)); + assert.equal(isAlive(broker.pid), true, "the shared broker survives a worker kill"); + assert.ok(brokerTree.every((n) => isAlive(n.pid)), "the broker's subtree survives"); + // The same app-server still serves: the fake holds every turn 60 s, so bound the next job by the turn timeout + // and prove it went through the existing app-server (no second start) rather than a direct fallback. + const next = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "3000", "--json", "quick C"], { cwd: repo, env, timeout: 60000 }); + assert.equal(next.error, undefined); + assert.equal(next.status, 1, next.stderr); + // The foreground JSON carries only the payload; the stored job carries the outcome. + const jobC = readJobRecord(repo); + assert.notEqual(jobC.id, jobA); + assert.match(jobC.errorMessage ?? "", /turn timed out after 3000 ms/, "the next job really ran a turn (and hit its budget)"); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.appServerStarts, 1, "no second app-server was started for the next job"); + assert.equal(fakeState.lastTurnStart?.prompt, "quick C", "the turn went through the existing app-server"); + assert.equal(loadBrokerSession(repo)?.pid, broker.pid, "no replacement broker was started"); +}); + +test("a root that died before cancel is failed by the reaper on Windows; nothing is signalled by number", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { + const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(seeded.status, 0, seeded.stderr); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "hold"], { cwd: repo, env }); + const jobId = JSON.parse(launched.stdout).jobId; + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); + t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); + await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.turnId ? j : null; }); + const tree = cimTree(withPid.pid); + t.after(() => { for (const { pid } of tree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); + process.kill(withPid.pid, "SIGKILL"); + await waitFor(() => (!isAlive(withPid.pid) ? "dead" : null)); + const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.notEqual(cancel.status, 0, "the reaper already failed the job; cancel has nothing active to signal"); + assert.equal(readJobRecord(repo, jobId).status, "failed"); + // The orphaned children are the documented limitation here: nothing is touched by number. + assert.ok(tree.filter((n) => n.pid !== withPid.pid).some((n) => isAlive(n.pid))); +}); + +test("a reused-looking identity is never signalled on Windows: the reaper fails the job and cancel reports it", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { + const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); + const jobId = JSON.parse(launched.stdout).jobId; + const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); + t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); + await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.pidIdentity ? j : null; }); + upsertJob(repo, { id: jobId, pidIdentity: "win32:1" }); + const jobFile = path.join(resolveStateDir(repo), "jobs", `${jobId}.json`); + fs.writeFileSync(jobFile, JSON.stringify({ ...JSON.parse(fs.readFileSync(jobFile, "utf8")), pidIdentity: "win32:1" })); + const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.notEqual(cancel.status, 0); + assert.equal(isAlive(withPid.pid), true, "the process holding the pid is a stranger to this record and must stay"); + const stored = readJobRecord(repo, jobId); + assert.equal(stored.status, "failed"); + assert.match(stored.errorMessage ?? "", /pid reused/); +}); + +test("a planted PowerShell in the workspace or a relative PATH entry is never what the identity probe runs", { skip: !IS_WIN, timeout: 90_000 }, () => { + const repo = makeTempDir(); fs.mkdirSync(path.join(repo, "tools")); + const marker = path.join(repo, "HIJACKED"); + fs.copyFileSync(path.join(process.env.SystemRoot, "System32", "cmd.exe"), path.join(repo, "powershell.exe")); + for (const planted of ["powershell.cmd", path.join("tools", "powershell.cmd"), path.join("tools", "powershell.exe.cmd")]) { + fs.writeFileSync(path.join(repo, planted), `@echo off\r\necho x> "${marker}"\r\n`); + } + const testEnvUrl = pathToFileURL(path.join(ROOT, "tests", "test-env.mjs")).href; + const processUrl = pathToFileURL(path.join(ROOT, "plugins", "codex", "scripts", "lib", "process.mjs")).href; + const probe = run(process.execPath, ["--import", testEnvUrl, "-e", `import(${JSON.stringify(processUrl)}).then(m => console.log(m.getProcessIdentity(process.pid) ?? 'null'))`], { + cwd: repo, env: { ...process.env, PATH: `.;tools;${process.env.PATH}`, PSModulePath: path.join(repo, "tools") } + }); + assert.match(probe.stdout.trim(), /^win32:\d+$/, probe.stderr); + assert.equal(fs.existsSync(marker), false); +}); diff --git a/tests/runtime-hooks.test.mjs b/tests/runtime-hooks.test.mjs new file mode 100644 index 000000000..1351554f0 --- /dev/null +++ b/tests/runtime-hooks.test.mjs @@ -0,0 +1,774 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + initGitRepo, + isAlive, + IS_WIN, + makeTempDir, + ROOT, + run, + SCRIPT, + seededRepo, + SESSION_HOOK, + STOP_HOOK, + waitFor +} from "./helpers.mjs"; +import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; +import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; + + +test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => { + const repo = makeTempDir(); + const envFile = path.join(makeTempDir(), "claude-env.sh"); + fs.writeFileSync(envFile, "", "utf8"); + const pluginDataDir = makeTempDir(); + const transcriptPath = path.join(repo, "session.jsonl"); + + const result = run(process.execPath, [SESSION_HOOK, "SessionStart"], { + cwd: repo, + env: { + ...process.env, + CLAUDE_ENV_FILE: envFile, + CLAUDE_PLUGIN_DATA: pluginDataDir + }, + input: JSON.stringify({ + hook_event_name: "SessionStart", + session_id: "sess-current", + transcript_path: transcriptPath, + cwd: repo + }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(envFile, "utf8"), + `export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n` + ); +}); + +test("session end fully cleans up jobs for the ending session", async (t) => { + const repo = makeTempDir(); + 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 stateDir = resolveStateDir(repo); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const completedLog = path.join(jobsDir, "completed.log"); + const runningLog = path.join(jobsDir, "running.log"); + const otherSessionLog = path.join(jobsDir, "other.log"); + const completedJobFile = path.join(jobsDir, "review-completed.json"); + const runningJobFile = path.join(jobsDir, "review-running.json"); + const otherJobFile = path.join(jobsDir, "review-other.json"); + fs.writeFileSync(completedLog, "completed\n", "utf8"); + fs.writeFileSync(runningLog, "running\n", "utf8"); + fs.writeFileSync(otherSessionLog, "other\n", "utf8"); + fs.writeFileSync(completedJobFile, JSON.stringify({ id: "review-completed" }, null, 2), "utf8"); + fs.writeFileSync(otherJobFile, JSON.stringify({ id: "review-other" }, null, 2), "utf8"); + + const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + cwd: repo, + detached: true, + stdio: "ignore" + }); + sleeper.unref(); + // A live worker's own file says `running`; a status-less file reads as terminal + // on disk and the reaper would reconcile that into the kept index entry. + fs.writeFileSync(runningJobFile, JSON.stringify({ id: "review-running", status: "running" }, null, 2), "utf8"); + + t.after(() => { + try { + process.kill(-sleeper.pid, "SIGTERM"); + } catch { + try { + process.kill(sleeper.pid, "SIGTERM"); + } catch { + // Ignore missing process. + } + } + }); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "review-completed", + status: "completed", + title: "Codex Review", + sessionId: "sess-current", + logFile: completedLog, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:31:00.000Z" + }, + { + id: "review-running", + status: "running", + title: "Codex Review", + sessionId: "sess-current", + pid: sleeper.pid, + // Records since v1.3.0 carry the worker's identity (#743). + pidIdentity: getProcessIdentity(sleeper.pid), + logFile: runningLog, + createdAt: "2026-03-18T15:32:00.000Z", + updatedAt: "2026-03-18T15:33:00.000Z" + }, + { + id: "review-other", + status: "completed", + title: "Codex Review", + sessionId: "sess-other", + logFile: otherSessionLog, + createdAt: "2026-03-18T15:34:00.000Z", + updatedAt: "2026-03-18T15:35:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env: { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + }, + input: JSON.stringify({ + hook_event_name: "SessionEnd", + session_id: "sess-current", + cwd: repo + }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.existsSync(otherSessionLog), true); + assert.equal(fs.existsSync(otherJobFile), true); + assert.deepEqual( + fs.readdirSync(path.dirname(otherJobFile)).sort(), + [path.basename(otherJobFile), path.basename(otherSessionLog)].sort() + ); + + await waitFor(() => { + try { + process.kill(sleeper.pid, 0); + return false; + } catch (error) { + return error?.code === "ESRCH"; + } + }); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + assert.deepEqual(state.jobs.map((job) => job.id), ["review-other"]); + const otherJob = state.jobs[0]; + assert.equal(otherJob.logFile, otherSessionLog); +}); + +test("session end preserves background jobs and their broker so workers survive their dispatching session", async (t) => { + const repo = makeTempDir(); + 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 stateDir = resolveStateDir(repo); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const backgroundLog = path.join(jobsDir, "background.log"); + const foregroundLog = path.join(jobsDir, "foreground.log"); + const backgroundJobFile = path.join(jobsDir, "task-background.json"); + const foregroundJobFile = path.join(jobsDir, "review-foreground.json"); + fs.writeFileSync(backgroundLog, "background\n", "utf8"); + fs.writeFileSync(foregroundLog, "foreground\n", "utf8"); + fs.writeFileSync(backgroundJobFile, JSON.stringify({ id: "task-background" }, null, 2), "utf8"); + fs.writeFileSync(foregroundJobFile, JSON.stringify({ id: "review-foreground" }, null, 2), "utf8"); + + const backgroundSleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + cwd: repo, + detached: true, + stdio: "ignore" + }); + backgroundSleeper.unref(); + const foregroundSleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + cwd: repo, + detached: true, + stdio: "ignore" + }); + foregroundSleeper.unref(); + + t.after(() => { + for (const proc of [backgroundSleeper, foregroundSleeper]) { + try { + process.kill(-proc.pid, "SIGTERM"); + } catch { + try { + process.kill(proc.pid, "SIGTERM"); + } catch { + // Ignore missing process. + } + } + } + }); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-background", + status: "running", + title: "Codex Task", + sessionId: "sess-current", + background: true, + pid: backgroundSleeper.pid, + logFile: backgroundLog, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:31:00.000Z" + }, + { + id: "review-foreground", + status: "running", + title: "Codex Review", + sessionId: "sess-current", + pid: foregroundSleeper.pid, + pidIdentity: getProcessIdentity(foregroundSleeper.pid), + logFile: foregroundLog, + createdAt: "2026-03-18T15:32:00.000Z", + updatedAt: "2026-03-18T15:33:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env: { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + }, + input: JSON.stringify({ + hook_event_name: "SessionEnd", + session_id: "sess-current", + cwd: repo + }) + }); + + assert.equal(result.status, 0, result.stderr); + + // Foreground job killed + pruned from state. + await waitFor(() => { + try { + process.kill(foregroundSleeper.pid, 0); + return false; + } catch (error) { + return error?.code === "ESRCH"; + } + }); + + // Background job still alive — its worker outlives the session that started it. + assert.equal( + (() => { + try { + process.kill(backgroundSleeper.pid, 0); + return true; + } catch { + return false; + } + })(), + true, + "background job worker should not be terminated by SessionEnd" + ); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); + assert.deepEqual( + state.jobs.map((job) => job.id).sort(), + ["task-background"], + "background job stays in state so later sessions can poll it" + ); + assert.equal(fs.existsSync(backgroundJobFile), true, "background job file preserved"); + assert.equal(fs.existsSync(backgroundLog), true, "background log preserved"); +}); + +// `--background` on a review means what it means on a task: the job is +// dispatched to outlive the session that started it. The flag was parsed and +// then dropped, so SessionEnd pruned the review's record — and with it the +// only way to read the review back with `/codex:result`. +test("an adversarial review dispatched with --background survives its own session's SessionEnd", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + fs.writeFileSync(path.join(repo, "README.md"), "hello world\n"); + const env = { ...buildEnv(binDir), CODEX_COMPANION_SESSION_ID: "sess-current" }; + + const review = run(process.execPath, [SCRIPT, "adversarial-review", "--background"], { cwd: repo, env }); + assert.equal(review.status, 0, review.stderr); + + const stateFile = path.join(resolveStateDir(repo), "state.json"); + const recorded = JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs[0]; + assert.equal(recorded.background, true, "a --background review must be recorded as a background job"); + + const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env, + input: JSON.stringify({ hook_event_name: "SessionEnd", session_id: "sess-current", cwd: repo }) + }); + assert.equal(cleanup.status, 0, cleanup.stderr); + + assert.deepEqual( + JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs.map((job) => job.id), + [recorded.id], + "the background review record must survive the dispatching session's SessionEnd" + ); +}); + +test("stop hook runs a stop-time review task and blocks on findings when the review gate is enabled", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(setup.status, 0, setup.stderr); + const setupPayload = JSON.parse(setup.stdout); + assert.equal(setupPayload.reviewGateEnabled, true); + + const taskResult = run(process.execPath, [SCRIPT, "task", "--write", "fix the issue"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(taskResult.status, 0, taskResult.stderr); + + const blocked = run(process.execPath, [STOP_HOOK], { + cwd: repo, + env: buildEnv(binDir), + input: JSON.stringify({ + cwd: repo, + session_id: "sess-stop-review", + last_assistant_message: "I completed the refactor and updated the retry logic." + }) + }); + assert.equal(blocked.status, 0, blocked.stderr); + const blockedPayload = JSON.parse(blocked.stdout); + assert.equal(blockedPayload.decision, "block"); + assert.match(blockedPayload.reason, /Codex stop-time review found issues that still need fixes/i); + assert.match(blockedPayload.reason, /Missing empty-state guard/i); + + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.match(fakeState.lastTurnStart.prompt, //i); + assert.match(fakeState.lastTurnStart.prompt, //i); + assert.match(fakeState.lastTurnStart.prompt, /Only review the work from the previous Claude turn/i); + assert.match(fakeState.lastTurnStart.prompt, /I completed the refactor and updated the retry logic\./); + + const status = run(process.execPath, [SCRIPT, "status"], { + cwd: repo, + env: { + ...buildEnv(binDir), + CODEX_COMPANION_SESSION_ID: "sess-stop-review" + } + }); + assert.equal(status.status, 0, status.stderr); + assert.match(status.stdout, /Codex Stop Gate Review/); +}); + +test("stop gate forwards the configured model and effort to the review task (#769)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--review-gate-model", "spark", "--review-gate-effort", "low", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(setup.status, 0, setup.stderr); + const payload = JSON.parse(setup.stdout); + assert.equal(payload.reviewGateModel, "gpt-5.3-codex-spark"); + assert.equal(payload.reviewGateEffort, "low"); + const hook = run(process.execPath, [STOP_HOOK], { cwd: repo, env: buildEnv(binDir), input: JSON.stringify({ cwd: repo, session_id: "sess-gate-model", last_assistant_message: "done" }) }); + assert.equal(hook.status, 0, hook.stderr); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.lastThreadStart.config.model, "gpt-5.3-codex-spark"); + assert.equal(fakeState.lastThreadStart.config.model_reasoning_effort, "low"); + const cleared = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "inherit", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(JSON.parse(cleared.stdout).reviewGateModel, null); +}); + +test("stop gate stops blocking after three gate-induced rounds by default (#548)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); + const env = { ...buildEnv(binDir) }; + delete env.CODEX_REVIEW_GATE_MAX_ROUNDS; + const input = (active) => JSON.stringify({ cwd: repo, session_id: "sess-rounds", stop_hook_active: active, last_assistant_message: "I completed the refactor." }); + const decisions = []; + for (const active of [false, true, true, true]) { + const r = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input: input(active) }); + assert.equal(r.status, 0, r.stderr); + decisions.push(r.stdout.trim() ? JSON.parse(r.stdout).decision : "allow"); + } + assert.deepEqual(decisions, ["block", "block", "block", "allow"]); +}); + +function gateDecisions(env, repo, session, rounds) { + const decisions = []; + let stderr = ""; + for (let i = 0; i < rounds; i += 1) { + const input = JSON.stringify({ cwd: repo, session_id: session, stop_hook_active: i > 0, last_assistant_message: "I completed the refactor." }); + const r = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input }); + assert.equal(r.status, 0, r.stderr); + stderr += r.stderr; + decisions.push(r.stdout.trim() ? JSON.parse(r.stdout).decision : "allow"); + } + return { decisions, stderr }; +} + +test("CODEX_REVIEW_GATE_MAX_ROUNDS=0 never stops blocking and =5 allows the sixth stop", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); + const unlimited = gateDecisions(buildEnv(binDir, { CODEX_REVIEW_GATE_MAX_ROUNDS: "0" }), repo, "sess-rounds-0", 4); + assert.deepEqual(unlimited.decisions, ["block", "block", "block", "block"]); + const five = gateDecisions(buildEnv(binDir, { CODEX_REVIEW_GATE_MAX_ROUNDS: "5" }), repo, "sess-rounds-5", 6); + assert.deepEqual(five.decisions, ["block", "block", "block", "block", "block", "allow"]); +}); + +test("an invalid CODEX_REVIEW_GATE_MAX_ROUNDS falls back to 3 with a warning", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); + const { decisions, stderr } = gateDecisions(buildEnv(binDir, { CODEX_REVIEW_GATE_MAX_ROUNDS: "0.5" }), repo, "sess-rounds-bad", 4); + assert.deepEqual(decisions, ["block", "block", "block", "allow"]); + assert.match(stderr, /Ignoring CODEX_REVIEW_GATE_MAX_ROUNDS="0\.5"/); +}); + +test("stop gate names the signal when the review task is killed and always names the escape hatch (#589/#483)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", CODEX_STOP_REVIEW_TIMEOUT_MS: "800" }); + const r = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input: JSON.stringify({ cwd: repo, session_id: "sess-signal", last_assistant_message: "x" }) }); + assert.equal(r.status, 0, r.stderr); + const payload = JSON.parse(r.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /timed out after 0\.8 minutes|terminated by signal SIGKILL/); + assert.match(payload.reason, /Disable with \/codex:setup --disable-review-gate\./); +}); + +test("stop hook blocks when hook input is malformed JSON", () => { + const blocked = run(process.execPath, [STOP_HOOK], { + cwd: ROOT, + input: "{not-json" + }); + + assert.equal(blocked.status, 0, blocked.stderr); + assert.deepEqual(JSON.parse(blocked.stdout), { + decision: "block", + reason: "The stop review gate could not read or parse hook input; refusing to fail open." + }); +}); + +// Claude Code can leave the hook's stdin open (#530): spawn without ending it and +// time how long the hook takes to decide. +async function runHookWithOpenStdin(t, args, { cwd, env, input = "" }) { + const child = spawn(process.execPath, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + const kill = () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }; + t.after(kill); + // A hook that never exits must fail the test, not hang the run. + setTimeout(kill, 20_000).unref(); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.stdin.on("error", () => {}); + const started = Date.now(); + if (input) { + child.stdin.write(input); + } + const status = await new Promise((resolve) => child.on("close", resolve)); + return { status, stdout, stderr, elapsedMs: Date.now() - started }; +} + +test("stop hook with the gate disabled allows promptly when stdin stays open (#530)", { timeout: 30_000 }, async (t) => { + const repo = makeTempDir(); + initGitRepo(repo); + const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; + for (const input of ["", JSON.stringify({ cwd: repo, session_id: "sess-open" })]) { + const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env, input }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "", `no decision expected for input ${JSON.stringify(input)}`); + assert.ok(result.elapsedMs < 10000, `hook took ${result.elapsedMs} ms`); + } +}); + +test("stop hook with the gate enabled blocks when hook input never arrives", { timeout: 30_000 }, async (t) => { + const repo = makeTempDir(); + initGitRepo(repo); + const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { cwd: repo }); + assert.equal(setup.status, 0, setup.stderr); + const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; + const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /hook input did not arrive/); + assert.ok(result.elapsedMs < 10000, `hook took ${result.elapsedMs} ms`); +}); + +test("stop hook with an unreadable state file keeps the gate closed when hook input never arrives", { timeout: 30_000 }, async (t) => { + const repo = makeTempDir(); + initGitRepo(repo); + const stateDir = resolveStateDir(repo); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "state.json"), "{not-json"); + const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; + const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /hook input did not arrive/); +}); + +// existsSync answers false for a path under an unreadable directory, which must +// not read as "gate never configured". +test("stop hook with an unreadable state directory keeps the gate closed when hook input never arrives", { timeout: 30_000, skip: IS_WIN || process.getuid?.() === 0 }, async (t) => { + const repo = makeTempDir(); + initGitRepo(repo); + const stateDir = resolveStateDir(repo); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "state.json"), JSON.stringify({ config: { stopReviewGate: true } })); + fs.chmodSync(stateDir, 0o000); + t.after(() => fs.chmodSync(stateDir, 0o700)); + const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; + const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /hook input did not arrive/); +}); + +test("stop hook input above 1 MiB allows with the gate off and blocks with it on", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const env = { ...process.env, CLAUDE_PROJECT_DIR: repo }; + const input = JSON.stringify({ cwd: repo, session_id: "sess-huge", last_assistant_message: "x".repeat(1.1 * 1024 * 1024) }); + const off = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input }); + assert.equal(off.status, 0, off.stderr); + assert.equal(off.stdout.trim(), ""); + const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { cwd: repo }); + assert.equal(setup.status, 0, setup.stderr); + const on = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input }); + assert.equal(on.status, 0, on.stderr); + const payload = JSON.parse(on.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /exceeded/); +}); + +test("stop gate hands a 300 KB last_assistant_message to Codex whole", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(setup.status, 0, setup.stderr); + const message = `start-${"x".repeat(300 * 1024)}-end`; + const hook = run(process.execPath, [STOP_HOOK], { + cwd: repo, + env: buildEnv(binDir), + input: JSON.stringify({ cwd: repo, session_id: "sess-big", last_assistant_message: message }) + }); + assert.equal(hook.status, 0, hook.stderr); + assert.equal(JSON.parse(hook.stdout).decision, "block", hook.stdout); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.ok(fakeState.lastTurnStart.prompt.includes(message), "the whole message must reach Codex"); +}); + +test("stop hook logs running tasks to stderr without blocking when the review gate is disabled", () => { + const repo = makeTempDir(); + 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 stateDir = resolveStateDir(repo); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const runningLog = path.join(jobsDir, "task-running.log"); + fs.writeFileSync(runningLog, "running\n", "utf8"); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { + stopReviewGate: false + }, + jobs: [ + { + id: "task-live", + status: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-current", + logFile: runningLog, + createdAt: "2026-03-18T15:32:00.000Z", + updatedAt: "2026-03-18T15:33:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const blocked = run(process.execPath, [STOP_HOOK], { + cwd: repo, + env: { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + }, + input: JSON.stringify({ cwd: repo }) + }); + + assert.equal(blocked.status, 0, blocked.stderr); + assert.equal(blocked.stdout.trim(), ""); + assert.match(blocked.stderr, /Codex task task-live is still running/i); + assert.match(blocked.stderr, /\/codex:status/i); + assert.match(blocked.stderr, /\/codex:cancel task-live/i); +}); + +test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "adversarial-clean"); + 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(setup.status, 0, setup.stderr); + + const allowed = run(process.execPath, [STOP_HOOK], { + cwd: repo, + env: buildEnv(binDir), + input: JSON.stringify({ cwd: repo, session_id: "sess-stop-clean" }) + }); + + assert.equal(allowed.status, 0, allowed.stderr); + assert.equal(allowed.stdout.trim(), ""); +}); + +test("stop hook does not block when Codex is unavailable even if the review gate is enabled", () => { + const repo = makeTempDir(); + 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo + }); + assert.equal(setup.status, 0, setup.stderr); + + const allowed = run(process.execPath, [STOP_HOOK], { + cwd: repo, + env: { + ...process.env, + PATH: "" + }, + input: JSON.stringify({ cwd: repo }) + }); + + assert.equal(allowed.status, 0, allowed.stderr); + assert.equal(allowed.stdout.trim(), ""); + assert.match(allowed.stderr, /Codex is not set up for the review gate/i); + assert.match(allowed.stderr, /Run \/codex:setup/i); +}); + +test("stop hook runs the actual task when auth status looks stale", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "refreshable-auth"); + 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(setup.status, 0, setup.stderr); + + const allowed = run(process.execPath, [STOP_HOOK], { + cwd: repo, + env: buildEnv(binDir), + input: JSON.stringify({ cwd: repo }) + }); + + assert.equal(allowed.status, 0, allowed.stderr); + assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i); + const payload = JSON.parse(allowed.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /Missing empty-state guard/i); +}); + +test("SessionEnd tears down a broker that acknowledged shutdown but stayed up, by its recorded identity", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { + const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); + const env = buildEnv(binDir, { CODEX_COMPANION_SESSION_ID: "sess-win", CODEX_COMPANION_BROKER_HANG_ON_SHUTDOWN: "1", CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "60000" }); + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env }); + assert.equal(seeded.status, 0, seeded.stderr); + const broker = await waitFor(() => loadBrokerSession(repo)); + t.after(() => { try { process.kill(broker.pid, "SIGKILL"); } catch {} }); + assert.match(broker.pidIdentity ?? "", /^win32:\d+$/); + const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo, session_id: "sess-win" }) }); + assert.equal(cleanup.status, 0, cleanup.stderr); + assert.match(cleanup.stderr, /Broker teardown: .*signalled=true reason=identity-match/); + await waitFor(() => (!isAlive(broker.pid) ? "gone" : null)); + assert.equal(loadBrokerSession(repo), null); +}); diff --git a/tests/runtime-review.test.mjs b/tests/runtime-review.test.mjs new file mode 100644 index 000000000..37ae4206e --- /dev/null +++ b/tests/runtime-review.test.mjs @@ -0,0 +1,351 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + FAKE_RESOLVED_SETTINGS, + initGitRepo, + makeTempDir, + readJobRecord, + readStateIndex, + run, + SCRIPT, + seededRepo +} from "./helpers.mjs"; + + +test("review renders a no-findings result from app-server review/start", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + + const result = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Reviewed uncommitted changes/); + assert.match(result.stdout, /No material issues found/); + assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); +}); + +test("review accepts the quoted raw argument style for built-in base-branch review", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + + const result = run(process.execPath, [SCRIPT, "review", "--base main"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Reviewed changes against main/); + assert.match(result.stdout, /No material issues found/); +}); + +test("adversarial review renders structured findings over app-server turn/start", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); + + const result = run(process.execPath, [SCRIPT, "adversarial-review"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Missing empty-state guard/); + assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); +}); + +test("adversarial review accepts the same base-branch targeting as review", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); + + const result = run(process.execPath, [SCRIPT, "adversarial-review", "--base", "main"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Branch review against main|against main/i); + assert.match(result.stdout, /Missing empty-state guard/); +}); + +test("adversarial review asks Codex to inspect larger diffs itself", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + for (const name of ["a.js", "b.js", "c.js"]) { + fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`); + } + run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n'); + fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n'); + fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n'); + + const result = run(process.execPath, [SCRIPT, "adversarial-review"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.match(state.lastTurnStart.prompt, /lightweight summary/i); + assert.match(state.lastTurnStart.prompt, /read-only git commands/i); + assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/); +}); + +test("review includes reasoning output when the app server returns it", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-reasoning"); + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const result = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Reasoning:/); + assert.match(result.stdout, /Reviewed the changed files and checked the likely regression paths first|Reviewed the changed files and checked the likely regression paths/i); +}); + +test("review logs reasoning summaries and review output to the job log", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-reasoning"); + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const result = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const state = readStateIndex(repo); + const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); + assert.match(log, /Reasoning summary/); + assert.match(log, /Reviewed the changed files and checked the likely regression paths/); + assert.match(log, /Review output/); + assert.match(log, /Reviewed uncommitted changes\./); +}); + +test("review resolves model aliases the same way task does", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + + const result = run(process.execPath, [SCRIPT, "review", "--model", "spark"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.model, "gpt-5.3-codex-spark"); +}); + +test("adversarial review resolves model aliases the same way task does", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); + run("git", ["add", "src/app.js"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); + + const result = run(process.execPath, [SCRIPT, "adversarial-review", "--model", "spark"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.model, "gpt-5.3-codex-spark"); +}); + +test("review rejects focus text because it is native-review only", () => { + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const result = run(process.execPath, [SCRIPT, "review", "--scope working-tree focus on auth"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status > 0, true); + assert.match(result.stderr, /does not support custom focus text/i); + assert.match(result.stderr, /\/codex:adversarial-review focus on auth/i); +}); + +test("review rejects staged-only scope because it is native-review only", () => { + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + run("git", ["add", "README.md"], { cwd: repo }); + + const result = run(process.execPath, [SCRIPT, "review", "--scope", "staged"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status > 0, true); + assert.match(result.stderr, /Unsupported review scope "staged"/i); + assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); +}); + +test("adversarial review rejects staged-only scope to match review target selection", () => { + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + run("git", ["add", "README.md"], { cwd: repo }); + + const result = run(process.execPath, [SCRIPT, "adversarial-review", "--scope", "staged"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status > 0, true); + assert.match(result.stderr, /Unsupported review scope "staged"/i); + assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); +}); + +test("review accepts --background while still running as a tracked review job", () => { + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const launched = run(process.execPath, [SCRIPT, "review", "--background", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.review, "Review"); + assert.match(launchPayload.codex.stdout, /No material issues found/); + + const status = run(process.execPath, [SCRIPT, "status"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(status.status, 0, status.stderr); + assert.match(status.stdout, /# Codex Status/); + assert.match(status.stdout, /Codex Review/); + assert.match(status.stdout, /completed/); +}); + +test("review forwards model, review_model, effort and config overrides into thread/start config", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + fs.writeFileSync(path.join(repo, "README.md"), "hello world\n"); + + const result = run( + process.execPath, + [SCRIPT, "review", "--wait", "--model", "sol", "--effort", "max", "--config", "model_provider=ollama", "--config", "foo.bar=3"], + { cwd: repo, env: buildEnv(binDir) } + ); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.deepEqual(fakeState.lastThreadStart.config, { + model_provider: "ollama", + "foo.bar": 3, + model: "gpt-6-sol", + review_model: "gpt-6-sol", + model_reasoning_effort: "max" + }); +}); + +test("review accepts slash-command style single-string arguments", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + fs.writeFileSync(path.join(repo, "README.md"), "hello world\n"); + + const result = run(process.execPath, [SCRIPT, "review", "--wait --effort xhigh --config model_provider=ollama"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama", model_reasoning_effort: "xhigh" }); +}); diff --git a/tests/runtime-setup.test.mjs b/tests/runtime-setup.test.mjs new file mode 100644 index 000000000..7dc7b1276 --- /dev/null +++ b/tests/runtime-setup.test.mjs @@ -0,0 +1,305 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + initGitRepo, + IS_WIN, + makeTempDir, + ROOT, + run, + SCRIPT, + SESSION_HOOK +} from "./helpers.mjs"; +import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; + + +test("setup reports ready when fake codex is installed and authenticated", () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + + const result = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, true); + assert.match(payload.codex.detail, /advanced runtime available/); + assert.equal(payload.sessionRuntime.mode, "direct"); +}); + +// Windows: node.exe cannot be isolated from npm portably; adding the Node dir to PATH +// would bring npm back and void the "npm unavailable" invariant this test models. +test("setup is ready without npm when Codex is already installed and authenticated", { skip: IS_WIN }, () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + fs.symlinkSync(process.execPath, path.join(binDir, "node")); + + const result = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: { + ...process.env, + PATH: binDir + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, true); + assert.equal(payload.npm.available, false); + assert.equal(payload.codex.available, true); + assert.equal(payload.auth.loggedIn, true); +}); + +test("setup trusts app-server API key auth even when login status alone would fail", () => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "api-key-account-only"); + + const result = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, true); + assert.equal(payload.auth.loggedIn, true); + assert.equal(payload.auth.authMethod, "apiKey"); + assert.equal(payload.auth.source, "app-server"); + assert.match(payload.auth.detail, /API key configured \(unverified\)/); +}); + +test("setup is ready when the active provider does not require OpenAI login", () => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "provider-no-auth"); + + const result = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, true); + assert.equal(payload.auth.loggedIn, true); + assert.equal(payload.auth.authMethod, null); + assert.equal(payload.auth.source, "app-server"); + assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); +}); + +test("setup treats custom providers with app-server-ready config as ready", () => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "env-key-provider"); + + const result = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, true); + assert.equal(payload.auth.loggedIn, true); + assert.equal(payload.auth.authMethod, null); + assert.equal(payload.auth.source, "app-server"); + assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); +}); + +test("setup reports not ready when app-server config read fails", () => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "config-read-fails"); + + const result = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ready, false); + assert.equal(payload.auth.loggedIn, false); + assert.equal(payload.auth.source, "app-server"); + assert.match(payload.auth.detail, /config\/read failed for cwd/); +}); + +test("setup rejects a gate effort the gate model does not support and writes nothing", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + const setup = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "spark", "--review-gate-effort", "ultra", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.notEqual(setup.status, 0); + assert.match(setup.stderr, /not supported by gpt-5\.3-codex-spark\. gpt-5\.3-codex-spark supports: /); + const after = run(process.execPath, [SCRIPT, "setup", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(after.status, 0, after.stderr); + assert.equal(JSON.parse(after.stdout).reviewGateModel, null, "a rejected setup must not write the model"); + assert.equal(JSON.parse(after.stdout).reviewGateEffort, null); +}); + +test("setup rejects a gate model that cannot run the stored gate effort and writes nothing", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + const first = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "astra", "--review-gate-effort", "ultra", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(first.status, 0, first.stderr); + const switched = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "spark", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.notEqual(switched.status, 0); + assert.match(switched.stderr, /not supported by gpt-5\.3-codex-spark\. gpt-5\.3-codex-spark supports: /); + const after = run(process.execPath, [SCRIPT, "setup", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(after.status, 0, after.stderr); + assert.equal(JSON.parse(after.stdout).reviewGateModel, "gpt-6-astra", "a rejected setup must not write the model"); + assert.equal(JSON.parse(after.stdout).reviewGateEffort, "ultra"); +}); + +test("setup rejects an empty gate model or effort and writes nothing", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + const first = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "astra", "--review-gate-effort", "high", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(first.status, 0, first.stderr); + for (const flag of ["--review-gate-model", "--review-gate-effort"]) { + const empty = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", flag, "", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.notEqual(empty.status, 0); + assert.match(empty.stderr, /use inherit to clear/); + } + const after = JSON.parse(run(process.execPath, [SCRIPT, "setup", "--json"], { cwd: repo, env: buildEnv(binDir) }).stdout); + assert.equal(after.reviewGateModel, "gpt-6-astra"); + assert.equal(after.reviewGateEffort, "high"); + assert.equal(after.reviewGateEnabled, false, "the gate flag must not be written either"); +}); + +test("commands lazily start and reuse one shared app-server after first use", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + // The broker must outlive the gap between the two CLI runs: on a slow runner + // spawning the second command alone can take several seconds. + const idleMs = 15000; + const env = buildEnv(binDir, { CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: String(idleMs) }); + + const review = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env + }); + assert.equal(review.status, 0, review.stderr); + + const brokerSession = loadBrokerSession(repo); + if (!brokerSession) { + return; + } + + const adversarial = run(process.execPath, [SCRIPT, "adversarial-review"], { + cwd: repo, + env + }); + assert.equal(adversarial.status, 0, adversarial.stderr); + + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.appServerStarts, 1); + + const brokerPid = brokerSession.pid; + assert.ok(brokerPid > 0); + const deadline = Date.now() + idleMs + 10000; + let alive = true; + while (alive && Date.now() < deadline) { + try { + process.kill(brokerPid, 0); + await new Promise((r) => setTimeout(r, 200)); + } catch { + alive = false; + } + } + assert.equal(alive, false, `broker ${brokerPid} should exit within the ${idleMs} ms test idle timeout`); + + const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env, + input: JSON.stringify({ + hook_event_name: "SessionEnd", + cwd: repo + }) + }); + assert.equal(cleanup.status, 0, cleanup.stderr); +}); + +test("setup reuses an existing shared app-server without starting another one", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const env = buildEnv(binDir); + + const review = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env + }); + assert.equal(review.status, 0, review.stderr); + + const brokerSession = loadBrokerSession(repo); + if (!brokerSession) { + return; + } + + const setup = run(process.execPath, [SCRIPT, "setup", "--json"], { + cwd: repo, + env + }); + assert.equal(setup.status, 0, setup.stderr); + + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.appServerStarts, 1); + + const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { + cwd: repo, + env, + input: JSON.stringify({ + hook_event_name: "SessionEnd", + cwd: repo + }) + }); + assert.equal(cleanup.status, 0, cleanup.stderr); +}); + +test("setup and status honor --cwd when reading shared session runtime", () => { + const targetWorkspace = makeTempDir(); + const invocationWorkspace = makeTempDir(); + + saveBrokerSession(targetWorkspace, { + endpoint: "unix:/tmp/fake-broker.sock" + }); + + const status = run(process.execPath, [SCRIPT, "status", "--cwd", targetWorkspace], { + cwd: invocationWorkspace + }); + assert.equal(status.status, 0, status.stderr); + assert.match(status.stdout, /Session runtime: shared session/); + + const setup = run(process.execPath, [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], { + cwd: invocationWorkspace + }); + assert.equal(setup.status, 0, setup.stderr); + const payload = JSON.parse(setup.stdout); + assert.equal(payload.sessionRuntime.mode, "shared"); + assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); +}); diff --git a/tests/runtime-status.test.mjs b/tests/runtime-status.test.mjs new file mode 100644 index 000000000..089934037 --- /dev/null +++ b/tests/runtime-status.test.mjs @@ -0,0 +1,583 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + initGitRepo, + makeTempDir, + run, + SCRIPT, + seededRepo +} from "./helpers.mjs"; +import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; + + +test("status shows phases, hints, and the latest finished job", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const logFile = path.join(jobsDir, "review-live.log"); + fs.writeFileSync( + logFile, + [ + "[2026-03-18T15:30:00.000Z] Starting Codex Review.", + "[2026-03-18T15:30:01.000Z] Thread ready (thr_1).", + "[2026-03-18T15:30:02.000Z] Turn started (turn_1).", + "[2026-03-18T15:30:03.000Z] Reviewer started: current changes" + ].join("\n"), + "utf8" + ); + + const finishedJobFile = path.join(jobsDir, "review-done.json"); + fs.writeFileSync( + finishedJobFile, + JSON.stringify( + { + id: "review-done", + status: "completed", + title: "Codex Review", + rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n" + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "review-live", + kind: "review", + kindLabel: "review", + status: "running", + title: "Codex Review", + jobClass: "review", + phase: "reviewing", + threadId: "thr_1", + summary: "Review working tree diff", + logFile, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:30:03.000Z" + }, + { + id: "review-done", + status: "completed", + title: "Codex Review", + jobClass: "review", + threadId: "thr_done", + summary: "Review main...HEAD", + createdAt: "2026-03-18T15:10:00.000Z", + startedAt: "2026-03-18T15:10:05.000Z", + completedAt: "2026-03-18T15:11:10.000Z", + updatedAt: "2026-03-18T15:11:10.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "status"], { + cwd: workspace + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Active jobs:/); + assert.match(result.stdout, /\| Job \| Kind \| Status \| Phase \| Elapsed \| Codex Session ID \| Summary \| Actions \|/); + assert.match(result.stdout, /\| review-live \| review \| running \| reviewing \| .* \| thr_1 \| Review working tree diff \|/); + assert.match(result.stdout, /`\/codex:status review-live`
`\/codex:cancel review-live`/); + assert.match(result.stdout, /Live details:/); + assert.match(result.stdout, /Latest finished:/); + assert.match(result.stdout, /Progress:/); + assert.match(result.stdout, /Session runtime: direct startup/); + assert.match(result.stdout, /Phase: reviewing/); + assert.match(result.stdout, /Codex session ID: thr_1/); + assert.match(result.stdout, /Resume in Codex: codex resume thr_1/); + assert.match(result.stdout, /Thread ready \(thr_1\)\./); + assert.match(result.stdout, /Reviewer started: current changes/); + assert.match(result.stdout, /Duration: 1m 5s/); + assert.match(result.stdout, /Codex session ID: thr_done/); + assert.match(result.stdout, /Resume in Codex: codex resume thr_done/); +}); + +test("status without a job id only shows jobs from the current Claude session", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const currentLog = path.join(jobsDir, "review-current.log"); + const otherLog = path.join(jobsDir, "review-other.log"); + fs.writeFileSync(currentLog, "[2026-03-18T15:30:00.000Z] Reviewer started: current changes\n", "utf8"); + fs.writeFileSync(otherLog, "[2026-03-18T15:31:00.000Z] Reviewer started: old changes\n", "utf8"); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "review-current", + kind: "review", + kindLabel: "review", + status: "running", + title: "Codex Review", + jobClass: "review", + phase: "reviewing", + sessionId: "sess-current", + threadId: "thr_current", + summary: "Current session review", + logFile: currentLog, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:30:00.000Z" + }, + { + id: "review-other", + kind: "review", + kindLabel: "review", + status: "completed", + title: "Codex Review", + jobClass: "review", + sessionId: "sess-other", + threadId: "thr_other", + summary: "Previous session review", + createdAt: "2026-03-18T15:20:00.000Z", + startedAt: "2026-03-18T15:20:05.000Z", + completedAt: "2026-03-18T15:21:00.000Z", + updatedAt: "2026-03-18T15:21:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "status"], { + cwd: workspace, + env: { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + [...new Set(result.stdout.match(/review-(?:current|other)/g) ?? [])], + ["review-current"] + ); +}); + +test("status preserves adversarial review kind labels", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const logFile = path.join(jobsDir, "review-adv.log"); + fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Reviewer started: adversarial review\n", "utf8"); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "review-adv-live", + kind: "adversarial-review", + status: "running", + title: "Codex Adversarial Review", + jobClass: "review", + phase: "reviewing", + threadId: "thr_adv_live", + summary: "Adversarial review current changes", + logFile, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:30:00.000Z" + }, + { + id: "review-adv", + kind: "adversarial-review", + status: "completed", + title: "Codex Adversarial Review", + jobClass: "review", + threadId: "thr_adv_done", + summary: "Adversarial review working tree diff", + createdAt: "2026-03-18T15:10:00.000Z", + startedAt: "2026-03-18T15:10:05.000Z", + completedAt: "2026-03-18T15:11:10.000Z", + updatedAt: "2026-03-18T15:11:10.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "status"], { + cwd: workspace + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\| review-adv-live \| adversarial-review \| running \| reviewing \|/); + assert.match(result.stdout, /- review-adv \| completed \| adversarial-review \| Codex Adversarial Review/); + assert.match(result.stdout, /Codex session ID: thr_adv_live/); + assert.match(result.stdout, /Codex session ID: thr_adv_done/); +}); + +test("status --wait times out cleanly when a job is still active", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + const logFile = path.join(jobsDir, "task-live.log"); + fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); + fs.writeFileSync( + path.join(jobsDir, "task-live.json"), + JSON.stringify( + { + id: "task-live", + status: "running", + title: "Codex Task", + logFile + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-live", + status: "running", + title: "Codex Task", + jobClass: "task", + summary: "Investigate flaky test", + logFile, + createdAt: "2026-03-18T15:30:00.000Z", + startedAt: "2026-03-18T15:30:01.000Z", + updatedAt: "2026-03-18T15:30:02.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "status", "task-live", "--wait", "--timeout-ms", "25", "--json"], { + cwd: workspace + }); + + // A timed-out wait exits 1 in JSON mode too (#774); the snapshot itself is unchanged. + assert.equal(result.status, 1, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.job.id, "task-live"); + assert.equal(payload.job.status, "running"); + assert.equal(payload.waitTimedOut, true); +}); + +test("result returns the stored output for the latest finished job by default", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + fs.writeFileSync( + path.join(jobsDir, "review-finished.json"), + JSON.stringify( + { + id: "review-finished", + status: "completed", + title: "Codex Review", + rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n", + result: { + codex: { + stdout: "Reviewed uncommitted changes.\nNo material issues found." + } + }, + threadId: "thr_review_finished" + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "review-finished", + status: "completed", + title: "Codex Review", + jobClass: "review", + threadId: "thr_review_finished", + summary: "Review working tree diff", + createdAt: "2026-03-18T15:00:00.000Z", + updatedAt: "2026-03-18T15:01:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "result"], { + cwd: workspace + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + result.stdout, + "Reviewed uncommitted changes.\nNo material issues found.\n\nCodex session ID: thr_review_finished\nResume in Codex: codex resume thr_review_finished\n" + ); +}); + +test("result without a job id prefers the latest finished job from the current Claude session", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + fs.writeFileSync( + path.join(jobsDir, "review-current.json"), + JSON.stringify( + { + id: "review-current", + status: "completed", + title: "Codex Review", + threadId: "thr_current", + result: { + codex: { + stdout: "Current session output." + } + } + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(jobsDir, "review-other.json"), + JSON.stringify( + { + id: "review-other", + status: "completed", + title: "Codex Review", + threadId: "thr_other", + result: { + codex: { + stdout: "Old session output." + } + } + }, + null, + 2 + ), + "utf8" + ); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "review-current", + status: "completed", + title: "Codex Review", + jobClass: "review", + sessionId: "sess-current", + threadId: "thr_current", + summary: "Current session review", + createdAt: "2026-03-18T15:10:00.000Z", + updatedAt: "2026-03-18T15:11:00.000Z" + }, + { + id: "review-other", + status: "completed", + title: "Codex Review", + jobClass: "review", + sessionId: "sess-other", + threadId: "thr_other", + summary: "Old session review", + createdAt: "2026-03-18T15:20:00.000Z", + updatedAt: "2026-03-18T15:21:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "result"], { + cwd: workspace, + env: { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + } + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + result.stdout, + "Current session output.\n\nCodex session ID: thr_current\nResume in Codex: codex resume thr_current\n" + ); +}); + +test("result for a finished write-capable task returns the raw Codex final response", () => { + 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 taskRun = run(process.execPath, [SCRIPT, "task", "--write", "fix the flaky integration test"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(taskRun.status, 0, taskRun.stderr); + + const result = run(process.execPath, [SCRIPT, "result"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Handled the requested task\.\nTask prompt accepted\.\n/); + assert.match(result.stdout, /Codex session ID: thr_[a-z0-9]+/i); + assert.match(result.stdout, /Resume in Codex: codex resume thr_[a-z0-9]+/i); +}); + +test("status reports shared session runtime when a lazy broker is active", () => { + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const review = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(review.status, 0, review.stderr); + + if (!loadBrokerSession(repo)) { + return; + } + + const result = run(process.execPath, [SCRIPT, "status"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Session runtime: shared session/); +}); + +test("status --args-stdin tokenizes the raw argument string from stdin", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const viaStdin = run(process.execPath, [SCRIPT, "status", "--args-stdin"], { + cwd: repo, + env: buildEnv(binDir), + input: "--all --json\n" + }); + const viaArgv = run(process.execPath, [SCRIPT, "status", "--all", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(viaStdin.status, 0, viaStdin.stderr); + assert.equal(viaArgv.status, 0, viaArgv.stderr); + assert.deepEqual(JSON.parse(viaStdin.stdout), JSON.parse(viaArgv.stdout)); +}); + +test("result on a still-running job exits 3 with the wait hint instead of \"No job found\"", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "3000" }); + const launch = run(process.execPath, [SCRIPT, "task", "--background", "--json", "--prompt-stdin"], { + cwd: repo, env, input: "slow background task\n" + }); + assert.equal(launch.status, 0, launch.stderr); + const { jobId } = JSON.parse(launch.stdout); + + const active = run(process.execPath, [SCRIPT, "result", jobId], { cwd: repo, env }); + assert.equal(active.status, 3, active.stderr); + assert.match( + active.stdout, + new RegExp(`Job ${jobId} is still (queued|running)\\. Re-run: node .*result ${jobId} --wait --timeout-ms 540000`) + ); + + const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); + assert.equal(done.status, 0, done.stderr); + assert.ok(done.stdout.trim().length > 0); +}); + +test("status --wait reports a timeout in text output and exits 1 while the job is still active (#774)", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "4000" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "slow"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const status = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "500"], { cwd: repo, env }); + assert.equal(status.status, 1); + assert.match(status.stdout, /Timed out after 1s while the job was still running\./); + const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); + assert.equal(done.status, 0, done.stderr); +}); diff --git a/tests/runtime-task.test.mjs b/tests/runtime-task.test.mjs new file mode 100644 index 000000000..7ec1b84f5 --- /dev/null +++ b/tests/runtime-task.test.mjs @@ -0,0 +1,1703 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + FAKE_RESOLVED_SETTINGS, + initGitRepo, + IS_WIN, + makeTempDir, + readJobRecord, + readStateIndex, + run, + SCRIPT, + seededRepo, + waitFor +} from "./helpers.mjs"; +import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { + consumeJobRequestFile, + readJobFile, + resolveJobFile, + resolveJobRequestFile, + resolveStateDir, + upsertJob, + writeJobFile +} from "../plugins/codex/scripts/lib/state.mjs"; + + +test("task runs when the active provider does not require OpenAI login", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "provider-no-auth"); + 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(process.execPath, [SCRIPT, "task", "check auth preflight"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Handled the requested task/); +}); + +test("task runs without auth preflight so Codex can refresh an expired session", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "refreshable-auth"); + 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(process.execPath, [SCRIPT, "task", "check refreshable auth"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Handled the requested task/); +}); + +test("task reports the actual Codex auth error when the run is rejected", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "auth-run-fails"); + 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(process.execPath, [SCRIPT, "task", "check failed auth"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /authentication expired; run codex login/); +}); + +test("task --resume-last resumes the latest persisted task thread", () => { + 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(process.execPath, [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); + assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); +}); + +test("task-resume-candidate returns the latest rescue thread from the current session", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-current", + status: "completed", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-current", + threadId: "thr_current", + summary: "Investigate the flaky test", + updatedAt: "2026-03-24T20:00:00.000Z" + }, + { + id: "task-other-session", + status: "completed", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-other", + threadId: "thr_other", + summary: "Old rescue run", + updatedAt: "2026-03-24T20:05:00.000Z" + }, + { + id: "review-current", + status: "completed", + title: "Codex Review", + jobClass: "review", + sessionId: "sess-current", + threadId: "thr_review", + summary: "Review main...HEAD", + updatedAt: "2026-03-24T20:10:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { + cwd: workspace, + env: { + ...process.env, + CODEX_COMPANION_SESSION_ID: "sess-current" + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.available, true); + assert.equal(payload.sessionId, "sess-current"); + assert.equal(payload.candidate.id, "task-current"); + assert.equal(payload.candidate.threadId, "thr_current"); +}); + +test("task-resume-candidate reaps a crashed running task so it becomes resumable", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + + // A pid that has already exited: process.kill(pid, 0) will throw ESRCH. + const deadPid = run(process.execPath, ["-e", ""]).pid; + const crashedJob = { + id: "task-crashed", + status: "running", + phase: "delegating", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-current", + threadId: "thr_crashed", + summary: "Investigate the crash", + pid: deadPid, + updatedAt: "2026-03-24T20:00:00.000Z" + }; + fs.writeFileSync(path.join(jobsDir, "task-crashed.json"), `${JSON.stringify(crashedJob, null, 2)}\n`, "utf8"); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [crashedJob] }, null, 2)}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { + cwd: workspace, + env: { ...process.env, CODEX_COMPANION_SESSION_ID: "sess-current" } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + // Without the reaper the job would still read as "running" and be skipped, + // leaving the resume probe with no candidate. + assert.equal(payload.available, true); + assert.equal(payload.candidate.id, "task-crashed"); + assert.equal(payload.candidate.status, "failed"); + assert.equal(payload.candidate.threadId, "thr_crashed"); +}); + +test("task --resume-last does not resume a task from another Claude session", () => { + 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 otherEnv = { + ...buildEnv(binDir), + CODEX_COMPANION_SESSION_ID: "sess-other" + }; + const currentEnv = { + ...buildEnv(binDir), + CODEX_COMPANION_SESSION_ID: "sess-current" + }; + + const firstRun = run(process.execPath, [SCRIPT, "task", "initial task"], { + cwd: repo, + env: otherEnv + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const candidate = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { + cwd: repo, + env: currentEnv + }); + assert.equal(candidate.status, 0, candidate.stderr); + assert.equal(JSON.parse(candidate.stdout).available, false); + + const resume = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { + cwd: repo, + env: currentEnv + }); + assert.equal(resume.status, 1); + assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "initial task"); +}); + +test("task --resume-last ignores running tasks from other Claude sessions", () => { + 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 stateDir = resolveStateDir(repo); + fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + version: 1, + config: { stopReviewGate: false }, + jobs: [ + { + id: "task-other-running", + status: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-other", + threadId: "thr_other", + summary: "Other session active task", + updatedAt: "2026-03-24T20:05:00.000Z" + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + const env = { + ...buildEnv(binDir), + CODEX_COMPANION_SESSION_ID: "sess-current" + }; + const status = run(process.execPath, [SCRIPT, "status", "--json"], { + cwd: repo, + env + }); + assert.equal(status.status, 0, status.stderr); + assert.deepEqual(JSON.parse(status.stdout).running, []); + + const resume = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { + cwd: repo, + env + }); + assert.equal(resume.status, 1); + assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); +}); + +test("write task output focuses on the Codex result without generic follow-up hints", () => { + 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(process.execPath, [SCRIPT, "task", "--write", "fix the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.approvalPolicy, "on-request"); + assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); +}); + +test("read-only task keeps never approval policy on app-server thread/start", () => { + 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(process.execPath, [SCRIPT, "task", "inspect the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.approvalPolicy, "never"); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); +}); + +test("task --resume-last --write forwards write approval policy to app-server thread/resume", () => { + 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 firstRun = run(process.execPath, [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--write", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); + assert.equal(fakeState.lastThreadResume.approvalPolicy, "on-request"); + assert.equal(fakeState.lastThreadResume.sandbox, "workspace-write"); +}); + +test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { + 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 firstRun = run(process.execPath, [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run(process.execPath, [SCRIPT, "task", "--resume", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); +}); + +test("task --fresh is treated as routing control and does not leak into the prompt", () => { + 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(process.execPath, [SCRIPT, "task", "--fresh", "diagnose the flaky test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test"); +}); + +test("task forwards model selection and reasoning effort to app-server turn/start", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "resolved-effort"); + 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(process.execPath, [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); + assert.equal(fakeState.lastTurnStart.effort, "low"); + assert.deepEqual(readJobRecord(repo).resolved, { + ...FAKE_RESOLVED_SETTINGS, + model: "gpt-5.3-codex-spark", + reasoningEffort: "low" + }); +}); + +test("task preserves resolved settings when turn/start fails", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-start-fails"); + 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(process.execPath, [SCRIPT, "task", "--effort", "xhigh", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /turn\/start failed after thread resolution/); + const storedJob = readJobRecord(repo); + assert.equal(storedJob.status, "failed"); + // `--effort` is applied via thread/start.config, so the resolved settings echo it back. + const resolvedWithEffort = { ...FAKE_RESOLVED_SETTINGS, reasoningEffort: "xhigh" }; + assert.deepEqual(storedJob.resolved, resolvedWithEffort); + const state = readStateIndex(repo); + assert.deepEqual(state.jobs[0].resolved, resolvedWithEffort); +}); + +for (const effort of ["max", "ultra"]) { + test(`task forwards ${effort} reasoning effort to app-server turn/start`, () => { + 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(process.execPath, [SCRIPT, "task", "--effort", effort, "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, effort); + }); +} + +test("task rejects an unknown reasoning effort", () => { + 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 result = run(process.execPath, [SCRIPT, "task", "--effort", "supreme", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unsupported reasoning effort "supreme"/); +}); + +test("task logs reasoning summaries and assistant messages to the job log", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-reasoning"); + 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(process.execPath, [SCRIPT, "task", "investigate the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const state = readStateIndex(repo); + const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); + assert.match(log, /Reasoning summary/); + assert.match(log, /Inspected the prompt, gathered evidence, and checked the highest-risk paths first/); + assert.match(log, /Assistant message/); + assert.match(log, /Handled the requested task/); +}); + +test("task logs subagent reasoning and messages with a subagent prefix", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-subagent"); + 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const state = readStateIndex(repo); + const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); + assert.match(log, /Starting subagent design-challenger via collaboration tool: wait\./); + assert.match(log, /Subagent design-challenger reasoning:/); + assert.match(log, /Questioned the retry strategy and the cache invalidation boundaries\./); + assert.match(log, /Subagent design-challenger:/); + assert.match( + log, + /The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees\./ + ); +}); + +test("task keeps the subagent label when thread/started arrives before the turn/start response", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-subagent"); + 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { + cwd: repo, + env: buildEnv(binDir, { FAKE_CODEX_SUBAGENT_EARLY_STARTED: "1" }) + }); + + assert.equal(result.status, 0, result.stderr); + const state = readStateIndex(repo); + const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); + assert.match(log, /Starting subagent design-challenger via collaboration tool: wait\./); + assert.match(log, /Subagent design-challenger:/); +}); + +test("task waits for the main thread to complete before returning the final result", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-subagent"); + 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); +}); + +test("task ignores later subagent messages when choosing the final returned output", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-late-subagent-message"); + 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); +}); + +test("task can finish after subagent work even if the parent turn/completed event is missing", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-subagent-no-main-turn-completed"); + 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); +}); + +test("task using the shared broker still completes when Codex spawns subagents", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "with-subagent"); + 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 }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const env = buildEnv(binDir); + const review = run(process.execPath, [SCRIPT, "review"], { + cwd: repo, + env + }); + assert.equal(review.status, 0, review.stderr); + + if (!loadBrokerSession(repo)) { + return; + } + + const result = run(process.execPath, [SCRIPT, "task", "challenge the current design"], { + cwd: repo, + env + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); +}); + +test("task --background enqueues a detached worker and exposes per-job status", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "slow-task"); + 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 launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "investigate the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.status, "queued"); + assert.match(launchPayload.jobId, /^task-/); + + const runningJob = await waitFor(() => { + try { + const storedJob = readJobRecord(repo, launchPayload.jobId); + return storedJob.status === "running" && storedJob.resolved ? storedJob : null; + } catch { + return null; + } + }); + assert.deepEqual(runningJob.resolved, FAKE_RESOLVED_SETTINGS); + const runningState = JSON.parse(fs.readFileSync(path.join(resolveStateDir(repo), "state.json"), "utf8")); + assert.deepEqual(runningState.jobs.find((job) => job.id === launchPayload.jobId).resolved, FAKE_RESOLVED_SETTINGS); + + const waitedStatus = run( + process.execPath, + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(waitedStatus.status, 0, waitedStatus.stderr); + const waitedPayload = JSON.parse(waitedStatus.stdout); + assert.equal(waitedPayload.job.id, launchPayload.jobId); + assert.equal(waitedPayload.job.status, "completed"); + + const resultPayload = await waitFor(() => { + const result = run(process.execPath, [SCRIPT, "result", launchPayload.jobId, "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + if (result.status !== 0) { + return null; + } + return JSON.parse(result.stdout); + }); + + assert.equal(resultPayload.job.id, launchPayload.jobId); + assert.equal(resultPayload.job.status, "completed"); + assert.deepEqual(resultPayload.job.resolved, FAKE_RESOLVED_SETTINGS); + assert.deepEqual(resultPayload.storedJob.resolved, FAKE_RESOLVED_SETTINGS); + assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); +}); + +// A cancel that lands after the spawn but before the worker takes the record +// over writes `cancelled`; the worker that starts afterwards must not run it. +test("a worker started against a cancelled job exits without running the turn", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + const jobId = "task-cancelled-before-start"; + const record = { id: jobId, kind: "task", jobClass: "task", title: "Codex Task", workspaceRoot: repo, status: "cancelled", phase: "cancelled", background: true, pid: null, pidIdentity: null, errorMessage: "Cancelled by user.", completedAt: new Date().toISOString(), request: { prompt: "never run" } }; + writeJobFile(repo, jobId, record); + upsertJob(repo, record); + const worker = run(process.execPath, [SCRIPT, "task-worker", "--cwd", repo, "--job-id", jobId], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(worker.status, 0, worker.stderr); + const stored = readJobFile(resolveJobFile(repo, jobId)); + assert.equal(stored.status, "cancelled"); + assert.equal(stored.startedAt, undefined, "no running record was written"); + assert.equal(stored.result, undefined); + const appServerStarts = fs.existsSync(fakeStatePath) ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts : 0; + assert.equal(appServerStarts, 0, "no Codex turn may start for a cancelled job"); +}); + +test("task --model sol resolves through the model catalogue and rejects an unsupported effort", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + const ok = run(process.execPath, [SCRIPT, "task", "--json", "--model", "sol", "--effort", "max", "hello"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(ok.status, 0, ok.stderr); + assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).lastThreadStart.config.model, "gpt-6-sol"); + const bad = run(process.execPath, [SCRIPT, "task", "--json", "--model", "gpt-5.6-sol", "--effort", "max", "hello"], { cwd: repo, env: buildEnv(binDir) }); + assert.notEqual(bad.status, 0); + assert.match(bad.stderr, /gpt-5\.6-sol supports: low, medium, high/); +}); + +test("task forwards config overrides and keeps option-looking prompt words", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + const result = run(process.execPath, [SCRIPT, "task", "--effort", "max", "--config", "model_provider=ollama", "investigate", "ls", "-R", "usage"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama", model_reasoning_effort: "max" }); + assert.match(fakeState.lastTurnStart.prompt, /investigate ls -R usage/); +}); + +test("task --resume-last never puts model or effort into thread/resume config", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + const first = run(process.execPath, [SCRIPT, "task", "--model", "sol", "--effort", "high", "first"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(first.status, 0, first.stderr); + const startsAfterFirst = JSON.parse(fs.readFileSync(statePath, "utf8")).appServerStarts; + const second = run(process.execPath, [SCRIPT, "task", "--resume-last", "--effort", "max", "--config", "model_provider=ollama", "again"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(second.status, 0, second.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.deepEqual(fakeState.lastThreadResume.config, { model_provider: "ollama" }); + assert.equal(fakeState.lastTurnStart.effort, "max"); + // A hot broker rejoin would ignore the config/sandbox overrides, so the resume + // must have run on a freshly spawned app-server process. + assert.equal(fakeState.appServerStarts, startsAfterFirst + 1); +}); + +test("task --resume-last cold-resumes without a thread/resume model override", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + const first = run(process.execPath, [SCRIPT, "task", "--model", "sol", "--effort", "high", "first"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(first.status, 0, first.stderr); + const startsAfterFirst = JSON.parse(fs.readFileSync(statePath, "utf8")).appServerStarts; + + const second = run(process.execPath, [SCRIPT, "task", "--resume-last", "--model", "sol", "--effort", "max", "again"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(second.status, 0, second.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + // A top-level `model` on thread/resume sets has_model_resume_override and stops + // Codex restoring the thread's persisted model/provider/effort. + assert.equal(fakeState.lastThreadResume.model, undefined); + assert.equal(fakeState.lastThreadResume.config, null); + assert.equal(fakeState.appServerStarts, startsAfterFirst + 1); + assert.equal(fakeState.lastTurnStart.model, "gpt-6-sol"); + assert.equal(fakeState.lastTurnStart.effort, "max"); +}); + +test("task --background stores config overrides in the job request", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + const result = run(process.execPath, [SCRIPT, "task", "--background", "--json", "--config", "model_provider=ollama", "bg"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + const jobId = JSON.parse(result.stdout).jobId; + const done = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(done.status, 0, done.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama" }); +}); + +test("task --args-stdin keeps shell metacharacters inside the prompt instead of executing them", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + const sentinel = path.join(makeTempDir(), "pwned"); + 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 rawArguments = `--effort max investigate $(touch ${sentinel}) \`id\``; + const result = run(process.execPath, [SCRIPT, "task", "--args-stdin"], { + cwd: repo, + env: buildEnv(binDir), + input: `${rawArguments}\n` + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "max"); + assert.equal(fakeState.lastTurnStart.prompt, `investigate $(touch ${sentinel}) \`id\``); + assert.equal(fs.existsSync(sentinel), false); +}); + +test("task --background persists the job record before spawning the worker", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "slow-task"); + + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "investigate the ordering"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(launched.status, 0, launched.stderr); + const { jobId } = JSON.parse(launched.stdout); + + // The launch command has returned, so the record must already be readable by + // the worker no matter how fast it started. + const stateDir = resolveStateDir(repo); + const indexed = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs.find((job) => job.id === jobId); + assert.ok(indexed, "job must be in the state index as soon as the launch returns"); + assert.ok(["queued", "running"].includes(indexed.status), `unexpected status ${indexed.status}`); + assert.ok(fs.existsSync(path.join(stateDir, "jobs", `${jobId}.json`)), "job file must exist as soon as the launch returns"); + + const waited = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(waited.status, 0, waited.stderr); + assert.equal(JSON.parse(waited.stdout).job.status, "completed"); +}); + +// Classifying secrets by key name always misses one: a session cookie header is +// every bit a credential and matches no denylist. Every `--config` value stays +// out of the public record; only the keys are kept. +test("task --background keeps secret --config values out of every job record", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + const launched = run( + process.execPath, + [ + SCRIPT, + "task", + "--background", + "--json", + "--config", + "model_providers.x.http_headers.Cookie=SECRET_SENTINEL_42", + "--config", + "model_provider=ollama", + "x" + ], + { cwd: repo, env: buildEnv(binDir) } + ); + assert.equal(launched.status, 0, launched.stderr); + const { jobId } = JSON.parse(launched.stdout); + + const waited = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(waited.status, 0, waited.stderr); + assert.equal(JSON.parse(waited.stdout).job.status, "completed"); + const resultRun = run(process.execPath, [SCRIPT, "result", jobId, "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(resultRun.status, 0, resultRun.stderr); + + const stateDir = resolveStateDir(repo); + const exposures = { + "state index": fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), + "job file": fs.readFileSync(path.join(stateDir, "jobs", `${jobId}.json`), "utf8"), + "status --json stdout": waited.stdout, + "result --json stdout": resultRun.stdout + }; + for (const [label, text] of Object.entries(exposures)) { + assert.equal(text.includes("SECRET_SENTINEL_42"), false, `${label} leaked the secret --config value`); + assert.equal(text.includes("[redacted]"), true, `${label} should keep the redacted placeholder`); + assert.equal(text.includes("model_provider"), true, `${label} should still record which config keys were set`); + assert.equal(text.includes("ollama"), false, `${label} stored a --config value; keys are recorded, values never are`); + } + + // The one-shot payload file is deleted by the worker once it has read it. + assert.equal(fs.existsSync(path.join(stateDir, "jobs", `${jobId}.request.json`)), false); + + // The worker still forwarded the real value to Codex. + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.config["model_providers.x.http_headers.Cookie"], "SECRET_SENTINEL_42"); + assert.equal(fakeState.lastThreadStart.config.model_provider, "ollama"); +}); + +// Redaction was added when the job is created, which does nothing for records +// v1.1.1 already wrote: same `STATE_VERSION`, raw `--config` values, and every +// `status`/`result --json` still echoing them back after the upgrade. +test("a v1.1.1 record's --config values never reach status/result and are redacted on disk", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir); + + const seeded = run(process.execPath, [SCRIPT, "task", "seed the state dir"], { cwd: repo, env }); + assert.equal(seeded.status, 0, seeded.stderr); + + const stateDir = resolveStateDir(repo); + const statePath = path.join(stateDir, "state.json"); + const legacyRequest = { + cwd: repo, + prompt: "legacy prompt", + config: { "model_providers.x.http_headers.Cookie": "SESSION_SECRET_FROM_1_1_1" } + }; + const legacyJob = { + id: "task-legacy", + status: "completed", + phase: "done", + jobClass: "task", + kind: "task", + title: "Codex Task", + summary: "Legacy job written by 1.1.1", + threadId: "thr_legacy", + updatedAt: "2026-03-24T20:05:00.000Z", + completedAt: "2026-03-24T20:06:00.000Z", + request: legacyRequest + }; + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + state.jobs.push(legacyJob); + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + const legacyJobFile = path.join(stateDir, "jobs", "task-legacy.json"); + fs.writeFileSync( + legacyJobFile, + `${JSON.stringify({ ...legacyJob, result: { status: 0, finalMessage: "legacy output" }, rendered: "legacy output\n" }, null, 2)}\n`, + "utf8" + ); + + const status = run(process.execPath, [SCRIPT, "status", "task-legacy", "--json"], { cwd: repo, env }); + assert.equal(status.status, 0, status.stderr); + const result = run(process.execPath, [SCRIPT, "result", "task-legacy", "--json"], { cwd: repo, env }); + assert.equal(result.status, 0, result.stderr); + + const exposures = { + "status --json stdout": status.stdout, + "result --json stdout": result.stdout, + "state index": fs.readFileSync(statePath, "utf8"), + "job file": fs.readFileSync(legacyJobFile, "utf8") + }; + for (const [label, text] of Object.entries(exposures)) { + assert.equal(text.includes("SESSION_SECRET_FROM_1_1_1"), false, `${label} leaked a legacy --config value`); + assert.equal(text.includes("[redacted]"), true, `${label} should carry the redacted placeholder`); + assert.equal( + text.includes("model_providers.x.http_headers.Cookie"), + true, + `${label} should still record which config keys were set` + ); + } +}); + +// The record of an ACTIVE 1.1.1 job is the only copy of its request — 1.1.1 wrote +// no private payload file — and `handleTaskWorker` falls back to exactly that +// record when there is none. Redacting it in place would hand the worker +// "[redacted]" as its Codex config (auth headers included), so the raw request is +// moved into a fresh 0600 payload file first and only then dropped from the record. +test("an active v1.1.1 record keeps its real --config for the worker while output stays redacted", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir); + + const seeded = run(process.execPath, [SCRIPT, "task", "seed the state dir"], { cwd: repo, env }); + assert.equal(seeded.status, 0, seeded.stderr); + + const stateDir = resolveStateDir(repo); + const statePath = path.join(stateDir, "state.json"); + const now = new Date().toISOString(); + const legacyRequest = { + cwd: repo, + prompt: "legacy queued prompt", + config: { "model_providers.x.http_headers.Cookie": "SESSION_SECRET_FROM_1_1_1" } + }; + const legacyJob = { + id: "task-legacy-queued", + status: "queued", + phase: "queued", + jobClass: "task", + kind: "task", + title: "Codex Task", + summary: "Legacy queued job written by 1.1.1", + createdAt: now, + updatedAt: now, + request: legacyRequest + }; + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + state.jobs.push(legacyJob); + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + const legacyJobFile = resolveJobFile(repo, "task-legacy-queued"); + fs.writeFileSync(legacyJobFile, `${JSON.stringify(legacyJob, null, 2)}\n`, "utf8"); + + const status = run(process.execPath, [SCRIPT, "status", "task-legacy-queued", "--json"], { cwd: repo, env }); + assert.equal(status.status, 0, status.stderr); + assert.equal(status.stdout.includes("SESSION_SECRET_FROM_1_1_1"), false, "status --json leaked a legacy --config value"); + assert.equal(status.stdout.includes("[redacted]"), true); + + // The worker's own read path (`readStoredJob` → `readJobFile`). + const workerView = readJobFile(legacyJobFile); + assert.equal(workerView.request.config["model_providers.x.http_headers.Cookie"], "[redacted]"); + + const requestFile = resolveJobRequestFile(repo, "task-legacy-queued"); + assert.equal(fs.existsSync(requestFile), true, "the raw request must be moved into the private payload file"); + // Windows has no POSIX mode bits; the owner-only invariant is not modelled there. + if (!IS_WIN) assert.equal(fs.statSync(requestFile).mode & 0o777, 0o600, "the payload file must be owner-only"); + assert.equal(fs.readFileSync(legacyJobFile, "utf8").includes("SESSION_SECRET_FROM_1_1_1"), false, "the record must be redacted on disk"); + + // What the worker actually runs with. + const consumed = consumeJobRequestFile(repo, "task-legacy-queued"); + assert.equal(consumed.config["model_providers.x.http_headers.Cookie"], "SESSION_SECRET_FROM_1_1_1"); + assert.equal(consumed.prompt, "legacy queued prompt"); +}); + +test("a resume refuses to start a second turn on a thread another job is still using", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = { ...buildEnv(binDir), CODEX_COMPANION_SESSION_ID: "sess-current" }; + + const first = run(process.execPath, [SCRIPT, "task", "first"], { cwd: repo, env }); + assert.equal(first.status, 0, first.stderr); + + // Another Claude session is mid-turn on the very thread this session would + // resume. Its job is invisible to this session's resume-candidate lookup, so + // only the thread-level guard can catch it. + const statePath = path.join(resolveStateDir(repo), "state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + const busyJob = { + id: "task-other-running", + status: "running", + phase: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-other", + threadId: "thr_1", + summary: "Other session active task", + updatedAt: "2026-03-24T20:05:00.000Z" + }; + state.jobs.push(busyJob); + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + const blocked = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, env }); + assert.notEqual(blocked.status, 0); + assert.match( + blocked.stderr, + /Thread thr_1 is busy in job task-other-running; wait for it or run cancel task-other-running first\./ + ); + + // Once that job finishes, the same resume goes through. + busyJob.status = "completed"; + busyJob.phase = "done"; + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + const resumed = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, env }); + assert.equal(resumed.status, 0, resumed.stderr); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); +}); + +// The crash window between a worker's terminal `writeJobFile` and its +// `upsertJob`: the job file is terminal, the index still says running. The +// reaper handed the terminal file back to its caller but left the index alone, +// so the raw `listJobs()` behind `assertThreadIsFree` kept seeing a phantom +// running job and blocked every later resume of that thread. The recorded pid +// here is alive and unrelated (this test runner) — exactly what a zombie or a +// recycled pid looks like — so nothing but the job file itself can settle it. +test("a terminal job file reconciles the state index and unblocks resume", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = { ...buildEnv(binDir), CODEX_COMPANION_SESSION_ID: "sess-current" }; + + const first = run(process.execPath, [SCRIPT, "task", "first"], { cwd: repo, env }); + assert.equal(first.status, 0, first.stderr); + + const stateDir = resolveStateDir(repo); + const statePath = path.join(stateDir, "state.json"); + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); + state.jobs.push({ + id: "task-crash-window", + status: "running", + phase: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-other", + threadId: "thr_1", + pid: process.pid, + summary: "Other session task that died after writing its result", + updatedAt: "2026-03-24T20:05:00.000Z" + }); + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + fs.writeFileSync( + path.join(stateDir, "jobs", "task-crash-window.json"), + `${JSON.stringify( + { + id: "task-crash-window", + status: "completed", + phase: "done", + pid: null, + threadId: "thr_1", + turnId: "turn_9", + result: { status: 0, finalMessage: "done" }, + rendered: "done\n", + completedAt: "2026-03-24T20:06:00.000Z" + }, + null, + 2 + )}\n`, + "utf8" + ); + + // Resume first: only a reaped/reconciled job list can tell this thread is free. + const resumed = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, env }); + assert.equal(resumed.status, 0, resumed.stderr); + + const reconciled = JSON.parse(fs.readFileSync(statePath, "utf8")).jobs.find((job) => job.id === "task-crash-window"); + assert.equal(reconciled.status, "completed", "the terminal job file must be reconciled into the state index"); + assert.equal(reconciled.pid, null); + + const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env }); + assert.equal(status.status, 0, status.stderr); +}); + +test("task --prompt-file wins over --args-stdin and keeps the prompt byte-exact", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + // Everything splitRawArgumentString would eat: quotes as grouping, backslashes + // as escapes, newlines as separators. + const promptText = `line one \\d+ "quoted" 'single' C:\\Users\\x\nsecond line with $(id) and \`backticks\``; + const promptFile = path.join(makeTempDir(), "request.txt"); + fs.writeFileSync(promptFile, promptText, "utf8"); + + const result = run(process.execPath, [SCRIPT, "task", "--prompt-file", promptFile, "--args-stdin"], { + cwd: repo, + env: buildEnv(binDir), + input: "--effort max\n" + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, promptText); + assert.equal(fakeState.lastTurnStart.effort, "max"); +}); + +test("task --await launches a tracked job, waits, and prints the result", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + const result = run(process.execPath, [SCRIPT, "task", "--await", "--json", "--model", "sol", "--effort", "low", "--prompt-stdin"], { + cwd: repo, env: buildEnv(binDir), input: "line one \\d+ \"quoted\" 'single'\nline two\n" + }); + assert.equal(result.status, 0, result.stderr); + const out = JSON.parse(result.stdout); + assert.match(out.job.id, /^task-/); + assert.equal(out.job.status, "completed"); + assert.ok(typeof out.storedJob.result.rawOutput === "string" && out.storedJob.result.rawOutput.length > 0); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, "line one \\d+ \"quoted\" 'single'\nline two"); + assert.equal(fakeState.lastTurnStart.effort, "low"); + assert.equal(fakeState.lastTurnStart.model, "gpt-6-sol"); + const status = run(process.execPath, [SCRIPT, "status", out.job.id, "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(JSON.parse(status.stdout).job.status, "completed"); +}); + +test("task --await exits 3 with a resumable hint when the await timeout elapses", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "3000" }); + const result = run(process.execPath, [SCRIPT, "task", "--await", "--await-timeout-ms", "500", "--prompt-stdin"], { cwd: repo, env, input: "slow task\n" }); + assert.equal(result.status, 3); + assert.match(result.stdout, /Still running: job task-[A-Za-z0-9_-]+\. Re-run: node .*result task-[A-Za-z0-9_-]+ --wait --timeout-ms 540000/); + const jobId = result.stdout.match(/job (task-[A-Za-z0-9_-]+)/)[1]; + + const timedOutJson = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "100", "--json"], { cwd: repo, env }); + assert.equal(timedOutJson.status, 3, timedOutJson.stderr); + const snapshot = JSON.parse(timedOutJson.stdout); + assert.ok(["queued", "running"].includes(snapshot.job.status), snapshot.job.status); + assert.match( + snapshot.resumeCommand, + new RegExp(`^node ".*codex-companion\\.mjs" result ${jobId} --wait --timeout-ms 540000$`) + ); + + const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); + assert.equal(done.status, 0, done.stderr); +}); + +test("task rejects --prompt-stdin combined with --args-stdin or --prompt-file", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const r = run(process.execPath, [SCRIPT, "task", "--prompt-stdin", "--args-stdin"], { cwd: repo, env: buildEnv(binDir), input: "x" }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /--prompt-stdin/); +}); + +test("task usage errors around --prompt-stdin arrive without waiting for stdin", async () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const cases = [ + [["--prompt-stdin", "--args-stdin"], /--prompt-stdin cannot be combined with --args-stdin/], + [["--prompt-stdin", "--await", "--background"], /Choose either --await or --background/] + ]; + + for (const [args, pattern] of cases) { + const startedAt = Date.now(); + const child = spawn(process.execPath, [SCRIPT, "task", ...args], { + cwd: repo, + env: buildEnv(binDir), + stdio: ["pipe", "pipe", "pipe"] + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + // stdin stays open and empty: the error must not wait for EOF. + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", resolve); + }); + child.stdin.destroy(); + + assert.notEqual(code, 0, args.join(" ")); + assert.match(stderr, pattern, args.join(" ")); + assert.ok(Date.now() - startedAt < 2000, `${args.join(" ")} took ${Date.now() - startedAt}ms`); + } +}); + +test("task --prompt-stdin sends the prompt verbatim minus one trailing newline", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + const promptText = "\n indented first line \r\nsecond\tline\n\nlast line without a newline"; + + const withNewline = run(process.execPath, [SCRIPT, "task", "--prompt-stdin"], { + cwd: repo, env: buildEnv(binDir), input: `${promptText}\r\n` + }); + assert.equal(withNewline.status, 0, withNewline.stderr); + assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastTurnStart.prompt, promptText); + + const withoutNewline = run(process.execPath, [SCRIPT, "task", "--prompt-stdin"], { + cwd: repo, env: buildEnv(binDir), input: promptText + }); + assert.equal(withoutNewline.status, 0, withoutNewline.stderr); + assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastTurnStart.prompt, promptText); +}); + +test("task rejects contradictory await and prompt flag combinations", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const cases = [ + [["--prompt-stdin", "inline prompt text"], /--prompt-stdin cannot be combined with --prompt-file or prompt text/], + [["--prompt-stdin", "--prompt-file", "prompt.txt"], /--prompt-stdin cannot be combined with --prompt-file or prompt text/], + [["--await", "--background", "do it"], /Choose either --await or --background/], + [["--await-timeout-ms", "1000", "do it"], /--await-timeout-ms requires --await/], + [["--await", "--await-timeout-ms", "0", "do it"], /--await-timeout-ms expects a positive integer/], + [["--await", "--await-timeout-ms", "-5", "do it"], /--await-timeout-ms expects a positive integer/], + [["--await", "--await-timeout-ms", "1.5", "do it"], /--await-timeout-ms expects a positive integer/], + [["--await", "--await-timeout-ms", "nope", "do it"], /--await-timeout-ms expects a positive integer/], + [["--await", "--await-timeout-ms", "1e400", "do it"], /--await-timeout-ms expects a positive integer/] + ]; + + for (const [args, pattern] of cases) { + const result = run(process.execPath, [SCRIPT, "task", ...args], { cwd: repo, env: buildEnv(binDir), input: "" }); + assert.notEqual(result.status, 0, args.join(" ")); + assert.match(result.stderr, pattern, args.join(" ")); + } + + const badResultTimeout = run(process.execPath, [SCRIPT, "result", "task-x", "--wait", "--timeout-ms", "0"], { + cwd: repo, env: buildEnv(binDir) + }); + assert.notEqual(badResultTimeout.status, 0); + assert.match(badResultTimeout.stderr, /--timeout-ms expects a positive integer/); + + const missingWaitTimeout = run(process.execPath, [SCRIPT, "result", "task-x", "--timeout-ms", "1000"], { + cwd: repo, env: buildEnv(binDir) + }); + assert.notEqual(missingWaitTimeout.status, 0); + assert.match(missingWaitTimeout.stderr, /--timeout-ms requires --wait/); +}); + +test("task --await reports a failed job with exit 1 while result stays exit 0", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-start-fails"); + + const awaited = run(process.execPath, [SCRIPT, "task", "--await", "--json", "--prompt-stdin"], { + cwd: repo, env: buildEnv(binDir), input: "break on purpose\n" + }); + assert.equal(awaited.status, 1, awaited.stderr); + const out = JSON.parse(awaited.stdout); + assert.equal(out.job.status, "failed"); + assert.match(out.storedJob.errorMessage, /turn\/start failed after thread resolution/); + + const stored = run(process.execPath, [SCRIPT, "result", out.job.id], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(stored.status, 0, stored.stderr); + assert.match(stored.stdout, /turn\/start failed after thread resolution/); +}); + +// A turn that never completes used to hang the companion until Claude Code's +// Bash tool SIGKILLed it, leaving the job "running" and no output at all. +test("task --turn-timeout-ms interrupts a stalled turn and fails the job with the timeout message", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + // The fake turn is held far longer than the budget: a slow CI VM can spend + // several seconds just starting the broker, so the margin is generous. + const fakeTurnMs = 20000; + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: String(fakeTurnMs) }); + + const started = Date.now(); + const result = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "500", "--json", "stall please"], { + cwd: repo, + env + }); + + assert.equal(result.status, 1, result.stderr); + assert.ok(Date.now() - started < fakeTurnMs, "the turn budget must fire long before the fake turn completes"); + assert.equal(JSON.parse(result.stdout).status, 1); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.ok(fakeState.lastInterrupt, "a timed-out turn must be interrupted, not abandoned"); + + const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env }); + assert.equal(status.status, 0, status.stderr); + const latest = JSON.parse(status.stdout).latestFinished; + assert.equal(latest.status, "failed"); + assert.match(latest.errorMessage, /turn timed out after 500 ms/); +}); + +// `turn/interrupt` returning is not proof the turn stopped: a wedged app-server +// answers the RPC and keeps going. Writing `failed` right there claims a turn +// (possibly a `--write` one) is over while it is still editing files, so the +// timeout waits for the terminal turn notification and says so when it never +// arrives. `--resume-last` is the path that owns a direct app-server, so +// closing the connection is what actually kills the runaway turn. +test("an unacknowledged interrupt is reported and closes a direct app-server", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(seeded.status, 0, seeded.stderr); + + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "20000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); + const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--turn-timeout-ms", "500", "--json", "stall please"], { + cwd: repo, + env + }); + + assert.equal(result.status, 1, result.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.ok(fakeState.lastInterrupt, "the timed-out turn must still be interrupted"); + assert.equal(fakeState.clientClosed, true, "a direct app-server must be closed so the runaway turn dies with it"); + + const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(status.status, 0, status.stderr); + const latest = JSON.parse(status.stdout).latestFinished; + assert.equal(latest.status, "failed"); + assert.match(latest.errorMessage, /turn timed out after 500 ms; interrupt not acknowledged/); + assert.match(latest.errorMessage, /may still be running in the shared runtime/); +}); + +// The other degraded shape: the app-server answers the interrupt and dies before +// any terminal notification. Nothing can arrive after that, and the acknowledgement +// window used to be an unref'd timer with no exit awareness — so the companion +// waited on a promise nothing would settle and could exit before writing the job's +// terminal record, leaving it `running` forever. +test("a transport that exits after the interrupt still writes a terminal record", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(seeded.status, 0, seeded.stderr); + + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "20000", FAKE_CODEX_EXIT_AFTER_INTERRUPT: "1" }); + const started = Date.now(); + const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--turn-timeout-ms", "500", "--json", "stall please"], { + cwd: repo, + env + }); + const elapsed = Date.now() - started; + + assert.equal(result.status, 1, result.stderr); + assert.ok(elapsed < 9000, `a dead transport must end the acknowledgement wait early, took ${elapsed} ms`); + + const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(status.status, 0, status.stderr); + const latest = JSON.parse(status.stdout).latestFinished; + assert.equal(latest.status, "failed", "the job must not be left running"); + assert.match(latest.errorMessage, /turn timed out after 500 ms; interrupt not acknowledged/); +}); + +test("task --turn-timeout-ms survives into the detached background worker", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "5000" }); + + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--turn-timeout-ms", "500", "--json", "stall please"], { + cwd: repo, + env + }); + assert.equal(launched.status, 0, launched.stderr); + const { jobId } = JSON.parse(launched.stdout); + + const waited = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { cwd: repo, env }); + assert.equal(waited.status, 0, waited.stderr); + const job = JSON.parse(waited.stdout).job; + assert.equal(job.status, "failed"); + assert.match(job.errorMessage, /turn timed out after 500 ms/); +}); + +test("task without a turn budget is unbounded", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + + const result = run(process.execPath, [SCRIPT, "task", "--json", "take your time"], { + cwd: repo, + env: buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "700" }) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).status, 0); +}); + +test("CODEX_TURN_TIMEOUT_MS bounds a turn when no flag is passed", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + + const result = run(process.execPath, [SCRIPT, "task", "--json", "stall please"], { + cwd: repo, + env: buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "5000", CODEX_TURN_TIMEOUT_MS: "500" }) + }); + + assert.equal(result.status, 1, result.stderr); +}); + +test("task rejects a non-positive turn budget", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + + const result = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "0", "hi"], { cwd: repo, env: buildEnv(binDir) }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--turn-timeout-ms expects a positive integer/); +}); + +test("task fails fast when Codex sends a terminal error notification (#698)", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "error-notification"); + const result = run(process.execPath, [SCRIPT, "task", "do the thing"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 15000 + }); + assert.equal(result.error, undefined, "companion must not hang until the test timeout"); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /Selected model is at capacity/); + assert.match(result.stderr, /Codex error: Selected model is at capacity/); +}); + +test("task keeps running through an error notification that Codex will retry", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "error-notification-retry"); + const result = run(process.execPath, [SCRIPT, "task", "--json", "do the thing"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); + assert.equal(result.status, 0, result.stderr); + assert.match(JSON.parse(result.stdout).rawOutput, /./); + const stored = readJobRecord(repo); + assert.equal(stored.status, "completed"); + assert.equal(stored.errorMessage, null); +}); + +test("task survives fileChange started items that omit changes (#775)", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "file-change-no-changes"); + const result = run(process.execPath, [SCRIPT, "task", "--json", "edit"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stderr, /Cannot read properties of undefined/); +}); + +test("a server-side turn failure that terminates normally still records an errorMessage (#757)", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-failed-silently"); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "do the thing"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(done.status, 0, done.stderr); + const status = run(process.execPath, [SCRIPT, "status", jobId], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(status.status, 0, status.stderr); + assert.match(status.stdout, /\| failed \|/); + assert.doesNotMatch(status.stdout, /Summary: \{$/m); + assert.match(status.stdout, /Codex turn ended with status "failed"/); +}); + +// The final answer arriving well before the terminal notification (a slow relay, +// a loaded CI host) must not be mistaken for the turn having completed. +test("a failed turn whose turn/completed arrives late is still recorded as failed", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-failed-silently"); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "800" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "do the thing"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env }); + assert.equal(done.status, 0, done.stderr); + assert.equal(JSON.parse(done.stdout).job.status, "failed"); + assert.match(JSON.parse(done.stdout).job.errorMessage ?? "", /Codex turn ended with status "failed"/); +}); + +// The server dying after the final answer but before turn/completed must still +// end the turn: as failed, with the captured output, on both transports. +test("a transport that exits after the final answer ends a direct turn as failed", () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(seeded.status, 0, seeded.stderr); + + installFakeCodex(binDir, "turn-failed-silently"); + const env = buildEnv(binDir, { FAKE_CODEX_EXIT_AFTER_FINAL_ANSWER: "1" }); + const started = Date.now(); + const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--turn-timeout-ms", "20000", "--json", "do the thing"], { cwd: repo, env, timeout: 40000 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 1, result.stderr); + assert.ok(Date.now() - started < 15000, "the dead transport must end the turn long before the turn timeout"); + const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(status.status, 0, status.stderr); + const latest = JSON.parse(status.stdout).latestFinished; + assert.equal(latest.status, "failed"); + assert.match(latest.errorMessage ?? "", /exited|closed/i); +}); + +test("a transport that exits after the final answer ends a brokered background job as failed", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-failed-silently"); + const env = buildEnv(binDir, { FAKE_CODEX_EXIT_AFTER_FINAL_ANSWER: "1" }); + const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "do the thing"], { cwd: repo, env }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000", "--json"], { cwd: repo, env, timeout: 40000 }); + assert.equal(done.status, 0, done.stderr); + const job = JSON.parse(done.stdout).job; + assert.equal(job.status, "failed"); + assert.match(job.errorMessage ?? "", /exited|closed/i); +}); + +test("a subagent's terminal error does not fail the main turn", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "subagent-error"); + const result = run(process.execPath, [SCRIPT, "task", "challenge the design"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /subagent at capacity/); + const stored = readJobRecord(repo); + assert.equal(stored.status, "completed"); + assert.equal(stored.errorMessage, null); +}); + +test("task completes when the turn/start response carries no turn id (#781)", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-start-without-id"); + const result = run(process.execPath, [SCRIPT, "task", "--json", "hello"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); + assert.equal(result.error, undefined, "must not hang"); + assert.equal(result.status, 0, result.stderr); + assert.match(JSON.parse(result.stdout).rawOutput, /./); +}); + +// Without an id in the turn/start response, turn/started is what names the turn: +// the timeout path needs it to send turn/interrupt at all. +test("a timed-out turn whose turn/start carried no id is still interrupted (#781)", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir, "turn-start-without-id"); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "5000" }); + const result = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "500", "--json", "stall please"], { cwd: repo, env, timeout: 15000 }); + assert.equal(result.error, undefined, "must not hang"); + assert.equal(result.status, 1, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.ok(fakeState.lastInterrupt?.turnId, "the turn named by turn/started must be interrupted"); + assert.equal(readJobRecord(repo).turnId, fakeState.lastInterrupt.turnId); +}); diff --git a/tests/runtime-transfer.test.mjs b/tests/runtime-transfer.test.mjs new file mode 100644 index 000000000..e1f81327b --- /dev/null +++ b/tests/runtime-transfer.test.mjs @@ -0,0 +1,170 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { + homeEnv, + initGitRepo, + makeTempDir, + run, + SCRIPT +} from "./helpers.mjs"; +import { + resolveClaudeSessionPath, + resolveClaudeProjectsDir +} from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; + + +test("transfer delegates the current Claude session directly to native import", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const sessionId = "sess-native-transfer"; + fs.mkdirSync(repo, { recursive: true }); + const projectDir = path.join(home, ".claude", "projects", "-repo"); + const sourcePath = path.join(projectDir, `${sessionId}.jsonl`); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir); + initGitRepo(repo); + + fs.writeFileSync( + sourcePath, + [ + { type: "custom-title", customTitle: "Native transfer" }, + { type: "user", cwd: repo, message: { role: "user", content: "Initial request" } }, + { type: "assistant", cwd: repo, message: { role: "assistant", content: "Initial answer" } }, + { type: "user", cwd: repo, message: { role: "user", content: "/codex:transfer" } } + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n", + "utf8" + ); + const result = run(process.execPath, [SCRIPT, "transfer", "--json"], { + cwd: repo, + env: { + ...buildEnv(binDir), + ...homeEnv(home), + CODEX_HOME: path.join(home, ".codex"), + CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const canonicalSourcePath = fs.realpathSync(sourcePath); + assert.equal(payload.threadId, "thr_1"); + assert.equal(payload.resumeCommand, "codex resume thr_1"); + assert.equal(payload.sourcePath, canonicalSourcePath); + assert.equal(payload.sessionId, sessionId); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(fakeState.threads.length, 1); + assert.equal(fakeState.threads[0].ephemeral, false); + assert.equal(fakeState.threads[0].name, "Native transfer"); + assert.equal(fakeState.lastExternalAgentImport.sourcePath, canonicalSourcePath); + assert.deepEqual( + fakeState.threads[0].visibleMessages.map((message) => message.text), + ["Initial request", "Initial answer", "/codex:transfer"] + ); +}); + +test("transfer reports an actionable upgrade error when native import is unsupported", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const projectDir = path.join(home, ".claude", "projects", "-repo"); + const sourcePath = path.join(projectDir, "session.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir, "external-import-unsupported"); + initGitRepo(repo); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "transfer", "--source", sourcePath, "--json"], { + cwd: repo, + env: { + ...buildEnv(binDir), + ...homeEnv(home), + CODEX_HOME: path.join(home, ".codex") + } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /does not support Claude session transfer/); + assert.match(result.stderr, /@openai\/codex@latest/); +}); + +test("transfer fails visibly when native import completes without a ledger record", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const projectDir = path.join(home, ".claude", "projects", "-repo"); + const sourcePath = path.join(projectDir, "session.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir, "external-import-fails"); + initGitRepo(repo); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not lose this request." } })}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "transfer", "--source", sourcePath], { + cwd: repo, + env: { + ...buildEnv(binDir), + ...homeEnv(home), + CODEX_HOME: path.join(home, ".codex") + } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /did not record an imported thread/); +}); + +test("transfer rejects sources outside the Claude projects directory", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const sourcePath = path.join(home, "session.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true }); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Outside source." } })}\n`, + "utf8" + ); + + const result = run(process.execPath, [SCRIPT, "transfer", "--source", sourcePath], { + cwd: repo, + env: { ...buildEnv(binDir), ...homeEnv(home) } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /only from .*\.claude.*projects/); +}); + +test("transfer resolves transcripts under CLAUDE_CONFIG_DIR when it is set (#721)", () => { + const configDir = makeTempDir(); + const projectDir = path.join(configDir, "projects", "-tmp-repo"); + fs.mkdirSync(projectDir, { recursive: true }); + const transcript = path.join(projectDir, "sess.jsonl"); + fs.writeFileSync(transcript, "{}\n"); + const env = { CLAUDE_CONFIG_DIR: configDir }; + assert.equal(resolveClaudeProjectsDir(env), path.join(configDir, "projects")); + assert.equal(resolveClaudeSessionPath(process.cwd(), { source: transcript, env }), fs.realpathSync(transcript)); + + const otherConfigDir = makeTempDir(); + fs.mkdirSync(path.join(otherConfigDir, "projects"), { recursive: true }); + assert.throws( + () => resolveClaudeSessionPath(process.cwd(), { source: transcript, env: { CLAUDE_CONFIG_DIR: otherConfigDir } }), + /can import Claude sessions only from/ + ); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs deleted file mode 100644 index 3b4877fae..000000000 --- a/tests/runtime.test.mjs +++ /dev/null @@ -1,4517 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import test from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { pathToFileURL } from "node:url"; - -import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; -import { - cimTree, - FAKE_RESOLVED_SETTINGS, - homeEnv, - initGitRepo, - isAlive, - IS_WIN, - jobDiagnostics, - makeTempDir, - readJobRecord, - ROOT, - run, - SCRIPT, - seededRepo, - SESSION_HOOK, - STOP_HOOK, - waitFor -} from "./helpers.mjs"; -import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; -import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; -import { resolveClaudeSessionPath, resolveClaudeProjectsDir } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; -import { - consumeJobRequestFile, - listJobs, - readJobFile, - resolveJobFile, - resolveJobPidFile, - resolveJobRequestFile, - resolveStateDir, - upsertJob, - writeJobFile, - writeJobPidFile -} from "../plugins/codex/scripts/lib/state.mjs"; - -test("setup reports ready when fake codex is installed and authenticated", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir); - - const result = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.match(payload.codex.detail, /advanced runtime available/); - assert.equal(payload.sessionRuntime.mode, "direct"); -}); - -// Windows: node.exe cannot be isolated from npm portably; adding the Node dir to PATH -// would bring npm back and void the "npm unavailable" invariant this test models. -test("setup is ready without npm when Codex is already installed and authenticated", { skip: IS_WIN }, () => { - const binDir = makeTempDir(); - installFakeCodex(binDir); - fs.symlinkSync(process.execPath, path.join(binDir, "node")); - - const result = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: { - ...process.env, - PATH: binDir - } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.npm.available, false); - assert.equal(payload.codex.available, true); - assert.equal(payload.auth.loggedIn, true); -}); - -test("setup trusts app-server API key auth even when login status alone would fail", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "api-key-account-only"); - - const result = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.auth.loggedIn, true); - assert.equal(payload.auth.authMethod, "apiKey"); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /API key configured \(unverified\)/); -}); - -test("setup is ready when the active provider does not require OpenAI login", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "provider-no-auth"); - - const result = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.auth.loggedIn, true); - assert.equal(payload.auth.authMethod, null); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); -}); - -test("setup treats custom providers with app-server-ready config as ready", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "env-key-provider"); - - const result = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, true); - assert.equal(payload.auth.loggedIn, true); - assert.equal(payload.auth.authMethod, null); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i); -}); - -test("setup reports not ready when app-server config read fails", () => { - const binDir = makeTempDir(); - installFakeCodex(binDir, "config-read-fails"); - - const result = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: ROOT, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.ready, false); - assert.equal(payload.auth.loggedIn, false); - assert.equal(payload.auth.source, "app-server"); - assert.match(payload.auth.detail, /config\/read failed for cwd/); -}); - -test("review renders a no-findings result from app-server review/start", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); - - const result = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Reviewed uncommitted changes/); - assert.match(result.stdout, /No material issues found/); - assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); -}); - -test("task runs when the active provider does not require OpenAI login", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "provider-no-auth"); - 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(process.execPath, [SCRIPT, "task", "check auth preflight"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Handled the requested task/); -}); - -test("task runs without auth preflight so Codex can refresh an expired session", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "refreshable-auth"); - 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(process.execPath, [SCRIPT, "task", "check refreshable auth"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Handled the requested task/); -}); - -test("transfer delegates the current Claude session directly to native import", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const sessionId = "sess-native-transfer"; - fs.mkdirSync(repo, { recursive: true }); - const projectDir = path.join(home, ".claude", "projects", "-repo"); - const sourcePath = path.join(projectDir, `${sessionId}.jsonl`); - fs.mkdirSync(projectDir, { recursive: true }); - installFakeCodex(binDir); - initGitRepo(repo); - - fs.writeFileSync( - sourcePath, - [ - { type: "custom-title", customTitle: "Native transfer" }, - { type: "user", cwd: repo, message: { role: "user", content: "Initial request" } }, - { type: "assistant", cwd: repo, message: { role: "assistant", content: "Initial answer" } }, - { type: "user", cwd: repo, message: { role: "user", content: "/codex:transfer" } } - ].map((entry) => JSON.stringify(entry)).join("\n") + "\n", - "utf8" - ); - const result = run(process.execPath, [SCRIPT, "transfer", "--json"], { - cwd: repo, - env: { - ...buildEnv(binDir), - ...homeEnv(home), - CODEX_HOME: path.join(home, ".codex"), - CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath - } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - const canonicalSourcePath = fs.realpathSync(sourcePath); - assert.equal(payload.threadId, "thr_1"); - assert.equal(payload.resumeCommand, "codex resume thr_1"); - assert.equal(payload.sourcePath, canonicalSourcePath); - assert.equal(payload.sessionId, sessionId); - - const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.equal(fakeState.threads.length, 1); - assert.equal(fakeState.threads[0].ephemeral, false); - assert.equal(fakeState.threads[0].name, "Native transfer"); - assert.equal(fakeState.lastExternalAgentImport.sourcePath, canonicalSourcePath); - assert.deepEqual( - fakeState.threads[0].visibleMessages.map((message) => message.text), - ["Initial request", "Initial answer", "/codex:transfer"] - ); -}); - -test("transfer reports an actionable upgrade error when native import is unsupported", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const projectDir = path.join(home, ".claude", "projects", "-repo"); - const sourcePath = path.join(projectDir, "session.jsonl"); - fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(projectDir, { recursive: true }); - installFakeCodex(binDir, "external-import-unsupported"); - initGitRepo(repo); - fs.writeFileSync( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "transfer", "--source", sourcePath, "--json"], { - cwd: repo, - env: { - ...buildEnv(binDir), - ...homeEnv(home), - CODEX_HOME: path.join(home, ".codex") - } - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /does not support Claude session transfer/); - assert.match(result.stderr, /@openai\/codex@latest/); -}); - -test("transfer fails visibly when native import completes without a ledger record", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const projectDir = path.join(home, ".claude", "projects", "-repo"); - const sourcePath = path.join(projectDir, "session.jsonl"); - fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(projectDir, { recursive: true }); - installFakeCodex(binDir, "external-import-fails"); - initGitRepo(repo); - fs.writeFileSync( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not lose this request." } })}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "transfer", "--source", sourcePath], { - cwd: repo, - env: { - ...buildEnv(binDir), - ...homeEnv(home), - CODEX_HOME: path.join(home, ".codex") - } - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /did not record an imported thread/); -}); - -test("transfer rejects sources outside the Claude projects directory", () => { - const home = makeTempDir(); - const repo = path.join(home, "repo"); - const binDir = makeTempDir(); - const sourcePath = path.join(home, "session.jsonl"); - fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true }); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync( - sourcePath, - `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Outside source." } })}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "transfer", "--source", sourcePath], { - cwd: repo, - env: { ...buildEnv(binDir), ...homeEnv(home) } - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /only from .*\.claude.*projects/); -}); - -test("transfer resolves transcripts under CLAUDE_CONFIG_DIR when it is set (#721)", () => { - const configDir = makeTempDir(); - const projectDir = path.join(configDir, "projects", "-tmp-repo"); - fs.mkdirSync(projectDir, { recursive: true }); - const transcript = path.join(projectDir, "sess.jsonl"); - fs.writeFileSync(transcript, "{}\n"); - const env = { CLAUDE_CONFIG_DIR: configDir }; - assert.equal(resolveClaudeProjectsDir(env), path.join(configDir, "projects")); - assert.equal(resolveClaudeSessionPath(process.cwd(), { source: transcript, env }), fs.realpathSync(transcript)); - - const otherConfigDir = makeTempDir(); - fs.mkdirSync(path.join(otherConfigDir, "projects"), { recursive: true }); - assert.throws( - () => resolveClaudeSessionPath(process.cwd(), { source: transcript, env: { CLAUDE_CONFIG_DIR: otherConfigDir } }), - /can import Claude sessions only from/ - ); -}); - -test("task reports the actual Codex auth error when the run is rejected", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "auth-run-fails"); - 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(process.execPath, [SCRIPT, "task", "check failed auth"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /authentication expired; run codex login/); -}); - -test("review accepts the quoted raw argument style for built-in base-branch review", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); - - const result = run(process.execPath, [SCRIPT, "review", "--base main"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Reviewed changes against main/); - assert.match(result.stdout, /No material issues found/); -}); - -test("adversarial review renders structured findings over app-server turn/start", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); - - const result = run(process.execPath, [SCRIPT, "adversarial-review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0); - assert.match(result.stdout, /Missing empty-state guard/); - assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); -}); - -test("adversarial review accepts the same base-branch targeting as review", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0];\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = items[0].id;\n"); - - const result = run(process.execPath, [SCRIPT, "adversarial-review", "--base", "main"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Branch review against main|against main/i); - assert.match(result.stdout, /Missing empty-state guard/); -}); - -test("adversarial review asks Codex to inspect larger diffs itself", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - for (const name of ["a.js", "b.js", "c.js"]) { - fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`); - } - run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n'); - fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n'); - fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n'); - - const result = run(process.execPath, [SCRIPT, "adversarial-review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.match(state.lastTurnStart.prompt, /lightweight summary/i); - assert.match(state.lastTurnStart.prompt, /read-only git commands/i); - assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/); -}); - -test("review includes reasoning output when the app server returns it", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const result = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Reasoning:/); - assert.match(result.stdout, /Reviewed the changed files and checked the likely regression paths first|Reviewed the changed files and checked the likely regression paths/i); -}); - -test("review logs reasoning summaries and review output to the job log", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const result = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Reasoning summary/); - assert.match(log, /Reviewed the changed files and checked the likely regression paths/); - assert.match(log, /Review output/); - assert.match(log, /Reviewed uncommitted changes\./); -}); - -test("task --resume-last resumes the latest persisted task thread", () => { - 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(process.execPath, [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); - assert.deepEqual(readJobRecord(repo).resolved, FAKE_RESOLVED_SETTINGS); -}); - -test("task-resume-candidate returns the latest rescue thread from the current session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-current", - status: "completed", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-current", - threadId: "thr_current", - summary: "Investigate the flaky test", - updatedAt: "2026-03-24T20:00:00.000Z" - }, - { - id: "task-other-session", - status: "completed", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Old rescue run", - updatedAt: "2026-03-24T20:05:00.000Z" - }, - { - id: "review-current", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-current", - threadId: "thr_review", - summary: "Review main...HEAD", - updatedAt: "2026-03-24T20:10:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { - cwd: workspace, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.available, true); - assert.equal(payload.sessionId, "sess-current"); - assert.equal(payload.candidate.id, "task-current"); - assert.equal(payload.candidate.threadId, "thr_current"); -}); - -test("task-resume-candidate reaps a crashed running task so it becomes resumable", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - // A pid that has already exited: process.kill(pid, 0) will throw ESRCH. - const deadPid = run(process.execPath, ["-e", ""]).pid; - const crashedJob = { - id: "task-crashed", - status: "running", - phase: "delegating", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-current", - threadId: "thr_crashed", - summary: "Investigate the crash", - pid: deadPid, - updatedAt: "2026-03-24T20:00:00.000Z" - }; - fs.writeFileSync(path.join(jobsDir, "task-crashed.json"), `${JSON.stringify(crashedJob, null, 2)}\n`, "utf8"); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [crashedJob] }, null, 2)}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { - cwd: workspace, - env: { ...process.env, CODEX_COMPANION_SESSION_ID: "sess-current" } - }); - - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - // Without the reaper the job would still read as "running" and be skipped, - // leaving the resume probe with no candidate. - assert.equal(payload.available, true); - assert.equal(payload.candidate.id, "task-crashed"); - assert.equal(payload.candidate.status, "failed"); - assert.equal(payload.candidate.threadId, "thr_crashed"); -}); - -test("task --resume-last does not resume a task from another Claude session", () => { - 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 otherEnv = { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-other" - }; - const currentEnv = { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - - const firstRun = run(process.execPath, [SCRIPT, "task", "initial task"], { - cwd: repo, - env: otherEnv - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const candidate = run(process.execPath, [SCRIPT, "task-resume-candidate", "--json"], { - cwd: repo, - env: currentEnv - }); - assert.equal(candidate.status, 0, candidate.stderr); - assert.equal(JSON.parse(candidate.stdout).available, false); - - const resume = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { - cwd: repo, - env: currentEnv - }); - assert.equal(resume.status, 1); - assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); - - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "initial task"); -}); - -test("task --resume-last ignores running tasks from other Claude sessions", () => { - 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 stateDir = resolveStateDir(repo); - fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-other-running", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Other session active task", - updatedAt: "2026-03-24T20:05:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const env = { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - const status = run(process.execPath, [SCRIPT, "status", "--json"], { - cwd: repo, - env - }); - assert.equal(status.status, 0, status.stderr); - assert.deepEqual(JSON.parse(status.stdout).running, []); - - const resume = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { - cwd: repo, - env - }); - assert.equal(resume.status, 1); - assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); -}); - -test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => { - const repo = makeTempDir(); - const envFile = path.join(makeTempDir(), "claude-env.sh"); - fs.writeFileSync(envFile, "", "utf8"); - const pluginDataDir = makeTempDir(); - const transcriptPath = path.join(repo, "session.jsonl"); - - const result = run(process.execPath, [SESSION_HOOK, "SessionStart"], { - cwd: repo, - env: { - ...process.env, - CLAUDE_ENV_FILE: envFile, - CLAUDE_PLUGIN_DATA: pluginDataDir - }, - input: JSON.stringify({ - hook_event_name: "SessionStart", - session_id: "sess-current", - transcript_path: transcriptPath, - cwd: repo - }) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal( - fs.readFileSync(envFile, "utf8"), - `export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n` - ); -}); - -test("write task output focuses on the Codex result without generic follow-up hints", () => { - 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(process.execPath, [SCRIPT, "task", "--write", "fix the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadStart.approvalPolicy, "on-request"); - assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); -}); - -test("read-only task keeps never approval policy on app-server thread/start", () => { - 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(process.execPath, [SCRIPT, "task", "inspect the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadStart.approvalPolicy, "never"); - assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); -}); - -test("task --resume-last --write forwards write approval policy to app-server thread/resume", () => { - 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 firstRun = run(process.execPath, [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--write", "follow up"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); - assert.equal(fakeState.lastThreadResume.approvalPolicy, "on-request"); - assert.equal(fakeState.lastThreadResume.sandbox, "workspace-write"); -}); - -test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { - 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 firstRun = run(process.execPath, [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const result = run(process.execPath, [SCRIPT, "task", "--resume", "follow up"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "follow up"); -}); - -test("task --fresh is treated as routing control and does not leak into the prompt", () => { - 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(process.execPath, [SCRIPT, "task", "--fresh", "diagnose the flaky test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test"); -}); - -test("task forwards model selection and reasoning effort to app-server turn/start", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir, "resolved-effort"); - 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(process.execPath, [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); - assert.equal(fakeState.lastTurnStart.effort, "low"); - assert.deepEqual(readJobRecord(repo).resolved, { - ...FAKE_RESOLVED_SETTINGS, - model: "gpt-5.3-codex-spark", - reasoningEffort: "low" - }); -}); - -test("task preserves resolved settings when turn/start fails", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-start-fails"); - 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(process.execPath, [SCRIPT, "task", "--effort", "xhigh", "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /turn\/start failed after thread resolution/); - const storedJob = readJobRecord(repo); - assert.equal(storedJob.status, "failed"); - // `--effort` is applied via thread/start.config, so the resolved settings echo it back. - const resolvedWithEffort = { ...FAKE_RESOLVED_SETTINGS, reasoningEffort: "xhigh" }; - assert.deepEqual(storedJob.resolved, resolvedWithEffort); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.deepEqual(state.jobs[0].resolved, resolvedWithEffort); -}); - -for (const effort of ["max", "ultra"]) { - test(`task forwards ${effort} reasoning effort to app-server turn/start`, () => { - 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(process.execPath, [SCRIPT, "task", "--effort", effort, "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.effort, effort); - }); -} - -test("task rejects an unknown reasoning effort", () => { - 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 result = run(process.execPath, [SCRIPT, "task", "--effort", "supreme", "diagnose the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /Unsupported reasoning effort "supreme"/); -}); - -test("review resolves model aliases the same way task does", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); - - const result = run(process.execPath, [SCRIPT, "review", "--model", "spark"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadStart.model, "gpt-5.3-codex-spark"); -}); - -test("adversarial review resolves model aliases the same way task does", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 1;\n"); - run("git", ["add", "src/app.js"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - fs.writeFileSync(path.join(repo, "src", "app.js"), "export const value = 2;\n"); - - const result = run(process.execPath, [SCRIPT, "adversarial-review", "--model", "spark"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadStart.model, "gpt-5.3-codex-spark"); -}); - -test("task logs reasoning summaries and assistant messages to the job log", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-reasoning"); - 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(process.execPath, [SCRIPT, "task", "investigate the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Reasoning summary/); - assert.match(log, /Inspected the prompt, gathered evidence, and checked the highest-risk paths first/); - assert.match(log, /Assistant message/); - assert.match(log, /Handled the requested task/); -}); - -test("task logs subagent reasoning and messages with a subagent prefix", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Starting subagent design-challenger via collaboration tool: wait\./); - assert.match(log, /Subagent design-challenger reasoning:/); - assert.match(log, /Questioned the retry strategy and the cache invalidation boundaries\./); - assert.match(log, /Subagent design-challenger:/); - assert.match( - log, - /The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees\./ - ); -}); - -test("task keeps the subagent label when thread/started arrives before the turn/start response", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir, { FAKE_CODEX_SUBAGENT_EARLY_STARTED: "1" }) - }); - - assert.equal(result.status, 0, result.stderr); - const stateDir = resolveStateDir(repo); - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const log = fs.readFileSync(state.jobs[0].logFile, "utf8"); - assert.match(log, /Starting subagent design-challenger via collaboration tool: wait\./); - assert.match(log, /Subagent design-challenger:/); -}); - -test("task waits for the main thread to complete before returning the final result", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task ignores later subagent messages when choosing the final returned output", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-late-subagent-message"); - 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task can finish after subagent work even if the parent turn/completed event is missing", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent-no-main-turn-completed"); - 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(process.execPath, [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task using the shared broker still completes when Codex spawns subagents", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "with-subagent"); - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const env = buildEnv(binDir); - const review = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); - - if (!loadBrokerSession(repo)) { - return; - } - - const result = run(process.execPath, [SCRIPT, "task", "challenge the current design"], { - cwd: repo, - env - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); -}); - -test("task --background enqueues a detached worker and exposes per-job status", async () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "slow-task"); - 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 launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "investigate the failing test"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(launched.status, 0, launched.stderr); - const launchPayload = JSON.parse(launched.stdout); - assert.equal(launchPayload.status, "queued"); - assert.match(launchPayload.jobId, /^task-/); - - const runningJob = await waitFor(() => { - try { - const storedJob = readJobRecord(repo, launchPayload.jobId); - return storedJob.status === "running" && storedJob.resolved ? storedJob : null; - } catch { - return null; - } - }); - assert.deepEqual(runningJob.resolved, FAKE_RESOLVED_SETTINGS); - const runningState = JSON.parse(fs.readFileSync(path.join(resolveStateDir(repo), "state.json"), "utf8")); - assert.deepEqual(runningState.jobs.find((job) => job.id === launchPayload.jobId).resolved, FAKE_RESOLVED_SETTINGS); - - const waitedStatus = run( - process.execPath, - [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], - { - cwd: repo, - env: buildEnv(binDir) - } - ); - - assert.equal(waitedStatus.status, 0, waitedStatus.stderr); - const waitedPayload = JSON.parse(waitedStatus.stdout); - assert.equal(waitedPayload.job.id, launchPayload.jobId); - assert.equal(waitedPayload.job.status, "completed"); - - const resultPayload = await waitFor(() => { - const result = run(process.execPath, [SCRIPT, "result", launchPayload.jobId, "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - if (result.status !== 0) { - return null; - } - return JSON.parse(result.stdout); - }); - - assert.equal(resultPayload.job.id, launchPayload.jobId); - assert.equal(resultPayload.job.status, "completed"); - assert.deepEqual(resultPayload.job.resolved, FAKE_RESOLVED_SETTINGS); - assert.deepEqual(resultPayload.storedJob.resolved, FAKE_RESOLVED_SETTINGS); - assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); -}); - -test("review rejects focus text because it is native-review only", () => { - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const result = run(process.execPath, [SCRIPT, "review", "--scope working-tree focus on auth"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status > 0, true); - assert.match(result.stderr, /does not support custom focus text/i); - assert.match(result.stderr, /\/codex:adversarial-review focus on auth/i); -}); - -test("review rejects staged-only scope because it is native-review only", () => { - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - run("git", ["add", "README.md"], { cwd: repo }); - - const result = run(process.execPath, [SCRIPT, "review", "--scope", "staged"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status > 0, true); - assert.match(result.stderr, /Unsupported review scope "staged"/i); - assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); -}); - -test("adversarial review rejects staged-only scope to match review target selection", () => { - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - run("git", ["add", "README.md"], { cwd: repo }); - - const result = run(process.execPath, [SCRIPT, "adversarial-review", "--scope", "staged"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status > 0, true); - assert.match(result.stderr, /Unsupported review scope "staged"/i); - assert.match(result.stderr, /Use one of: auto, working-tree, branch, or pass --base /i); -}); - -test("review accepts --background while still running as a tracked review job", () => { - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const launched = run(process.execPath, [SCRIPT, "review", "--background", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(launched.status, 0, launched.stderr); - const launchPayload = JSON.parse(launched.stdout); - assert.equal(launchPayload.review, "Review"); - assert.match(launchPayload.codex.stdout, /No material issues found/); - - const status = run(process.execPath, [SCRIPT, "status"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /# Codex Status/); - assert.match(status.stdout, /Codex Review/); - assert.match(status.stdout, /completed/); -}); - -test("status shows phases, hints, and the latest finished job", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "review-live.log"); - fs.writeFileSync( - logFile, - [ - "[2026-03-18T15:30:00.000Z] Starting Codex Review.", - "[2026-03-18T15:30:01.000Z] Thread ready (thr_1).", - "[2026-03-18T15:30:02.000Z] Turn started (turn_1).", - "[2026-03-18T15:30:03.000Z] Reviewer started: current changes" - ].join("\n"), - "utf8" - ); - - const finishedJobFile = path.join(jobsDir, "review-done.json"); - fs.writeFileSync( - finishedJobFile, - JSON.stringify( - { - id: "review-done", - status: "completed", - title: "Codex Review", - rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n" - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-live", - kind: "review", - kindLabel: "review", - status: "running", - title: "Codex Review", - jobClass: "review", - phase: "reviewing", - threadId: "thr_1", - summary: "Review working tree diff", - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:03.000Z" - }, - { - id: "review-done", - status: "completed", - title: "Codex Review", - jobClass: "review", - threadId: "thr_done", - summary: "Review main...HEAD", - createdAt: "2026-03-18T15:10:00.000Z", - startedAt: "2026-03-18T15:10:05.000Z", - completedAt: "2026-03-18T15:11:10.000Z", - updatedAt: "2026-03-18T15:11:10.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "status"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Active jobs:/); - assert.match(result.stdout, /\| Job \| Kind \| Status \| Phase \| Elapsed \| Codex Session ID \| Summary \| Actions \|/); - assert.match(result.stdout, /\| review-live \| review \| running \| reviewing \| .* \| thr_1 \| Review working tree diff \|/); - assert.match(result.stdout, /`\/codex:status review-live`
`\/codex:cancel review-live`/); - assert.match(result.stdout, /Live details:/); - assert.match(result.stdout, /Latest finished:/); - assert.match(result.stdout, /Progress:/); - assert.match(result.stdout, /Session runtime: direct startup/); - assert.match(result.stdout, /Phase: reviewing/); - assert.match(result.stdout, /Codex session ID: thr_1/); - assert.match(result.stdout, /Resume in Codex: codex resume thr_1/); - assert.match(result.stdout, /Thread ready \(thr_1\)\./); - assert.match(result.stdout, /Reviewer started: current changes/); - assert.match(result.stdout, /Duration: 1m 5s/); - assert.match(result.stdout, /Codex session ID: thr_done/); - assert.match(result.stdout, /Resume in Codex: codex resume thr_done/); -}); - -test("status without a job id only shows jobs from the current Claude session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const currentLog = path.join(jobsDir, "review-current.log"); - const otherLog = path.join(jobsDir, "review-other.log"); - fs.writeFileSync(currentLog, "[2026-03-18T15:30:00.000Z] Reviewer started: current changes\n", "utf8"); - fs.writeFileSync(otherLog, "[2026-03-18T15:31:00.000Z] Reviewer started: old changes\n", "utf8"); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-current", - kind: "review", - kindLabel: "review", - status: "running", - title: "Codex Review", - jobClass: "review", - phase: "reviewing", - sessionId: "sess-current", - threadId: "thr_current", - summary: "Current session review", - logFile: currentLog, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" - }, - { - id: "review-other", - kind: "review", - kindLabel: "review", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Previous session review", - createdAt: "2026-03-18T15:20:00.000Z", - startedAt: "2026-03-18T15:20:05.000Z", - completedAt: "2026-03-18T15:21:00.000Z", - updatedAt: "2026-03-18T15:21:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "status"], { - cwd: workspace, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } - }); - - assert.equal(result.status, 0, result.stderr); - assert.deepEqual( - [...new Set(result.stdout.match(/review-(?:current|other)/g) ?? [])], - ["review-current"] - ); -}); - -test("status preserves adversarial review kind labels", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "review-adv.log"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Reviewer started: adversarial review\n", "utf8"); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-adv-live", - kind: "adversarial-review", - status: "running", - title: "Codex Adversarial Review", - jobClass: "review", - phase: "reviewing", - threadId: "thr_adv_live", - summary: "Adversarial review current changes", - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" - }, - { - id: "review-adv", - kind: "adversarial-review", - status: "completed", - title: "Codex Adversarial Review", - jobClass: "review", - threadId: "thr_adv_done", - summary: "Adversarial review working tree diff", - createdAt: "2026-03-18T15:10:00.000Z", - startedAt: "2026-03-18T15:10:05.000Z", - completedAt: "2026-03-18T15:11:10.000Z", - updatedAt: "2026-03-18T15:11:10.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "status"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /\| review-adv-live \| adversarial-review \| running \| reviewing \|/); - assert.match(result.stdout, /- review-adv \| completed \| adversarial-review \| Codex Adversarial Review/); - assert.match(result.stdout, /Codex session ID: thr_adv_live/); - assert.match(result.stdout, /Codex session ID: thr_adv_done/); -}); - -test("status --wait times out cleanly when a job is still active", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "task-live.log"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); - fs.writeFileSync( - path.join(jobsDir, "task-live.json"), - JSON.stringify( - { - id: "task-live", - status: "running", - title: "Codex Task", - logFile - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-live", - status: "running", - title: "Codex Task", - jobClass: "task", - summary: "Investigate flaky test", - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - startedAt: "2026-03-18T15:30:01.000Z", - updatedAt: "2026-03-18T15:30:02.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "status", "task-live", "--wait", "--timeout-ms", "25", "--json"], { - cwd: workspace - }); - - // A timed-out wait exits 1 in JSON mode too (#774); the snapshot itself is unchanged. - assert.equal(result.status, 1, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.job.id, "task-live"); - assert.equal(payload.job.status, "running"); - assert.equal(payload.waitTimedOut, true); -}); - -test("result returns the stored output for the latest finished job by default", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - fs.writeFileSync( - path.join(jobsDir, "review-finished.json"), - JSON.stringify( - { - id: "review-finished", - status: "completed", - title: "Codex Review", - rendered: "# Codex Review\n\nReviewed uncommitted changes.\nNo material issues found.\n", - result: { - codex: { - stdout: "Reviewed uncommitted changes.\nNo material issues found." - } - }, - threadId: "thr_review_finished" - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-finished", - status: "completed", - title: "Codex Review", - jobClass: "review", - threadId: "thr_review_finished", - summary: "Review working tree diff", - createdAt: "2026-03-18T15:00:00.000Z", - updatedAt: "2026-03-18T15:01:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "result"], { - cwd: workspace - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal( - result.stdout, - "Reviewed uncommitted changes.\nNo material issues found.\n\nCodex session ID: thr_review_finished\nResume in Codex: codex resume thr_review_finished\n" - ); -}); - -test("result without a job id prefers the latest finished job from the current Claude session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - fs.writeFileSync( - path.join(jobsDir, "review-current.json"), - JSON.stringify( - { - id: "review-current", - status: "completed", - title: "Codex Review", - threadId: "thr_current", - result: { - codex: { - stdout: "Current session output." - } - } - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(jobsDir, "review-other.json"), - JSON.stringify( - { - id: "review-other", - status: "completed", - title: "Codex Review", - threadId: "thr_other", - result: { - codex: { - stdout: "Old session output." - } - } - }, - null, - 2 - ), - "utf8" - ); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-current", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-current", - threadId: "thr_current", - summary: "Current session review", - createdAt: "2026-03-18T15:10:00.000Z", - updatedAt: "2026-03-18T15:11:00.000Z" - }, - { - id: "review-other", - status: "completed", - title: "Codex Review", - jobClass: "review", - sessionId: "sess-other", - threadId: "thr_other", - summary: "Old session review", - createdAt: "2026-03-18T15:20:00.000Z", - updatedAt: "2026-03-18T15:21:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SCRIPT, "result"], { - cwd: workspace, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - } - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal( - result.stdout, - "Current session output.\n\nCodex session ID: thr_current\nResume in Codex: codex resume thr_current\n" - ); -}); - -test("result for a finished write-capable task returns the raw Codex final response", () => { - 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 taskRun = run(process.execPath, [SCRIPT, "task", "--write", "fix the flaky integration test"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(taskRun.status, 0, taskRun.stderr); - - const result = run(process.execPath, [SCRIPT, "result"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /^Handled the requested task\.\nTask prompt accepted\.\n/); - assert.match(result.stdout, /Codex session ID: thr_[a-z0-9]+/i); - assert.match(result.stdout, /Resume in Codex: codex resume thr_[a-z0-9]+/i); -}); - -test("cancel stops an active background job and marks it cancelled", async (t) => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - // A record without an identity (written by v1.2.x) is only signalled when the - // pid's command line is still this job's worker (#743). - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "codex-companion.mjs", "task-worker", "--job-id", "task-live"], { - cwd: workspace, - detached: true, - stdio: "ignore" - }); - sleeper.unref(); - - t.after(() => { - try { - process.kill(-sleeper.pid, "SIGTERM"); - } catch { - try { - process.kill(sleeper.pid, "SIGTERM"); - } catch { - // Ignore missing process. - } - } - }); - - const logFile = path.join(jobsDir, "task-live.log"); - const jobFile = path.join(jobsDir, "task-live.json"); - fs.writeFileSync(logFile, "[2026-03-18T15:30:00.000Z] Starting Codex Task.\n", "utf8"); - fs.writeFileSync( - jobFile, - JSON.stringify( - { - id: "task-live", - status: "running", - title: "Codex Task", - logFile - }, - null, - 2 - ), - "utf8" - ); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-live", - status: "running", - title: "Codex Task", - jobClass: "task", - summary: "Investigate flaky test", - pid: sleeper.pid, - logFile, - createdAt: "2026-03-18T15:30:00.000Z", - startedAt: "2026-03-18T15:30:01.000Z", - updatedAt: "2026-03-18T15:30:02.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const cancelResult = run(process.execPath, [SCRIPT, "cancel", "task-live", "--json"], { - cwd: workspace - }); - - if (IS_WIN) { - // Documented v1.3.0 refusal: win32 cannot prove the pid is this job's worker - // (identity-unavailable until v1.4.1), so the job stays running, exit 1. - assert.equal(cancelResult.status, 1, cancelResult.stderr); - assert.deepEqual(JSON.parse(cancelResult.stdout), { jobId: "task-live", status: "running", cancellationPending: true, reason: "identity-unavailable" }); - return; - } - assert.equal(cancelResult.status, 0, cancelResult.stderr); - assert.equal(JSON.parse(cancelResult.stdout).status, "cancelled"); - - await waitFor(() => { - try { - process.kill(sleeper.pid, 0); - return false; - } catch (error) { - return error?.code === "ESRCH"; - } - }); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const cancelled = state.jobs.find((job) => job.id === "task-live"); - assert.equal(cancelled.status, "cancelled"); - assert.equal(cancelled.pid, null); - - const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); - assert.equal(stored.status, "cancelled"); - assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/); -}); - -// The #743 scenario through the no-identity command-line fallback: a record from -// before identities existed names a pid the OS has since handed to another -// companion process. The reaper cannot rule a companion out by command line, so -// it keeps the job; cancel must refuse to signal a process that is not this -// job's worker, say so, and not claim the job was cancelled — it stays running -// (sidecar kept) until the pid goes away. (A pid now running something that is -// not a companion at all is reaped instead; see tests/tracked-jobs.test.mjs.) -test("cancel through the no-identity command-line fallback refuses a foreign pid and keeps the job running", { skip: process.platform === "win32" }, async (t) => { - const repo = makeTempDir(); - initGitRepo(repo); - // Still a companion process (the reaper cannot rule it out by command line), - // just not this job's worker. - const stranger = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "codex-companion.mjs", "task-worker", "--job-id", "task-other"], { detached: true, stdio: "ignore" }); - stranger.unref(); - t.after(() => { - try { process.kill(stranger.pid, "SIGKILL"); } catch {} - }); - const job = { - id: "task-recycled", - status: "running", - phase: "delegating", - title: "Codex Task", - jobClass: "task", - pid: stranger.pid, - logFile: null, - createdAt: new Date().toISOString() - }; - writeJobFile(repo, job.id, job); - upsertJob(repo, job); - writeJobPidFile(repo, job.id, stranger.pid); - - const cancel = run(process.execPath, [SCRIPT, "cancel", job.id], { cwd: repo }); - - assert.equal(cancel.status, 1, cancel.stderr); - assert.match(cancel.stdout, new RegExp(`cancellation not confirmed: worker pid ${stranger.pid} left running \\(identity-mismatch\\)`)); - assert.match(cancel.stdout, /the job stays running until the worker exits/); - const cancelJson = run(process.execPath, [SCRIPT, "cancel", job.id, "--json"], { cwd: repo }); - assert.equal(cancelJson.status, 1, cancelJson.stderr); - assert.deepEqual(JSON.parse(cancelJson.stdout), { jobId: job.id, status: "running", cancellationPending: true, reason: "identity-mismatch" }); - const json = run(process.execPath, [SCRIPT, "status", job.id, "--json"], { cwd: repo }); - assert.equal(json.status, 0, json.stderr); - assert.equal(JSON.parse(json.stdout).job.status, "running"); - assert.equal(fs.existsSync(resolveJobPidFile(repo, job.id)), true, "the pid sidecar must survive a refused cancel"); - process.kill(stranger.pid, 0); // still alive: throws ESRCH if cancel signalled it - - // Once the pid is gone the job reaches a terminal state the normal way. - process.kill(stranger.pid, "SIGKILL"); - await waitFor(() => { - try { process.kill(stranger.pid, 0); return false; } catch (error) { return error?.code === "ESRCH"; } - }); - const after = run(process.execPath, [SCRIPT, "status", job.id, "--json"], { cwd: repo }); - assert.equal(after.status, 0, after.stderr); - assert.notEqual(JSON.parse(after.stdout).job.status, "running"); -}); - -// The parent records the worker's identity next to its pid, so cancel can prove -// the pid is still that worker before signalling it. -test("a background worker's pid sidecar carries its identity and cancel signals it", async () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "8000" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "slow"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const jobId = JSON.parse(launched.stdout).jobId; - const sidecar = JSON.parse(fs.readFileSync(resolveJobPidFile(repo, jobId), "utf8")); - try { - assert.ok(Number.isInteger(sidecar.pid)); - assert.equal(sidecar.identity, getProcessIdentity(sidecar.pid)); - assert.match(sidecar.identity, /^(linux|darwin|win32):/); - // v1.4.1 refuses kills while the broker record has no identity (Windows start window). - if (IS_WIN) { - await waitFor(() => (/^win32:\d+$/.test(loadBrokerSession(repo)?.pidIdentity ?? "") ? "ready" : null)); - } - const cancel = run(process.execPath, [SCRIPT, "cancel", jobId], { cwd: repo, env }); - assert.equal(cancel.status, 0, cancel.stderr); - assert.doesNotMatch(cancel.stdout, /left running/); - await waitFor(() => { - try { process.kill(sidecar.pid, 0); return false; } catch (error) { return error?.code === "ESRCH"; } - }); - } finally { - try { process.kill(-sidecar.pid, "SIGKILL"); } catch { try { process.kill(sidecar.pid, "SIGKILL"); } catch {} } - } -}); - -test("cancel without a job id ignores active jobs from other Claude sessions", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "task-other.log"); - fs.writeFileSync(logFile, "", "utf8"); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-other", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - summary: "Other session run", - updatedAt: "2026-03-24T20:05:00.000Z", - logFile - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const env = { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - const status = run(process.execPath, [SCRIPT, "status", "--json"], { - cwd: workspace, - env - }); - assert.equal(status.status, 0, status.stderr); - assert.deepEqual(JSON.parse(status.stdout).running, []); - - const cancel = run(process.execPath, [SCRIPT, "cancel", "--json"], { - cwd: workspace, - env - }); - assert.equal(cancel.status, 1); - assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.equal(state.jobs[0].status, "running"); -}); - -test("cancel with a job id can still target an active job from another Claude session", () => { - const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const logFile = path.join(jobsDir, "task-other.log"); - fs.writeFileSync(logFile, "", "utf8"); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-other", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - summary: "Other session run", - updatedAt: "2026-03-24T20:05:00.000Z", - logFile - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const env = { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }; - const cancel = run(process.execPath, [SCRIPT, "cancel", "task-other", "--json"], { - cwd: workspace, - env - }); - assert.equal(cancel.status, 0, cancel.stderr); - assert.equal(JSON.parse(cancel.stdout).jobId, "task-other"); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.equal(state.jobs[0].status, "cancelled"); -}); - -test("cancel interrupts a brokered task and records cancelled only after the worker's own terminal record", async () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir, "interruptible-slow-task"); - 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 env = buildEnv(binDir); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "investigate the flaky worker timeout"], { - cwd: repo, - env - }); - - assert.equal(launched.status, 0, launched.stderr); - const launchPayload = JSON.parse(launched.stdout); - const jobId = launchPayload.jobId; - assert.ok(jobId); - - const stateDir = resolveStateDir(repo); - const runningJob = await waitFor(() => { - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - const job = state.jobs.find((candidate) => candidate.id === jobId); - if (job?.status === "running" && job.threadId && job.turnId && job.transport) { - return job; - } - return null; - }, { timeoutMs: 30000 }); - assert.equal(runningJob.transport, "broker"); - - const cancelResult = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { - cwd: repo, - env - }); - - assert.equal(cancelResult.status, 0, cancelResult.stderr); - const cancelPayload = JSON.parse(cancelResult.stdout); - assert.equal(cancelPayload.status, "cancelled"); - assert.equal(cancelPayload.turnInterruptAttempted, true); - assert.equal(cancelPayload.turnInterrupted, true); - const stored = readJobRecord(repo, jobId); - assert.equal(stored.status, "cancelled", jobDiagnostics(repo, jobId)); - assert.deepEqual([stored.workerClosed, stored.appServerExited], [true, true], jobDiagnostics(repo, jobId)); - const log = fs.readFileSync(stored.logFile, "utf8"); - assert.ok(log.includes("Turn interrupted.") && log.indexOf("Turn interrupted.") < log.indexOf("Cancelled by user."), `the turn ended before the cancel wrote:\n${log}`); - - await waitFor(() => { - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - return fakeState.lastInterrupt ?? null; - }); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.deepEqual(fakeState.lastInterrupt, { - threadId: runningJob.threadId, - turnId: runningJob.turnId - }); - - const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - cwd: repo - }) - }); - assert.equal(cleanup.status, 0, cleanup.stderr); -}); - -// The turn lives in the shared runtime, not in the worker: a cancel whose -// interrupt the runtime ignores must not claim success and must not kill the -// worker (the turn would keep running and the reaper would fail the job). -test("a brokered cancel whose interrupt is ignored stays pending and kills nothing; the next cancel ends the turn", { skip: IS_WIN, timeout: 90_000 }, async (t) => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const { jobId } = JSON.parse(launched.stdout); - const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); - t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); - t.after(() => run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) })); - assert.equal(running.transport, "broker", jobDiagnostics(repo, jobId)); - - const first = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.equal(first.status, 1, `cancel said: ${first.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); - assert.deepEqual(JSON.parse(first.stdout), { jobId, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }); - assert.equal(isAlive(running.pid), true, "no kill while the turn still runs in the broker"); - assert.equal(readJobRecord(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); - assert.match(fs.readFileSync(running.logFile, "utf8"), /left running \(turn-not-interrupted\)/); - - const second = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.equal(second.status, 0, `${second.stderr}\n${jobDiagnostics(repo, jobId)}`); - const payload = JSON.parse(second.stdout); - assert.equal(payload.status, "cancelled", `cancel said: ${second.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); - assert.equal(payload.turnInterrupted, true); - assert.equal(readJobRecord(repo, jobId).status, "cancelled", jobDiagnostics(repo, jobId)); - await waitFor(() => !isAlive(running.pid)); -}); - -// The direct path is trusted only when the job file and the index agree: the -// updater patches the index (locked) and then the file, so a file that says -// direct while the index says broker is forged or torn, and the kill (no turn -// end) would strand the turn. -test("a job file forged to transport direct does not take the direct kill path while the index says broker", { skip: IS_WIN, timeout: 90_000 }, async (t) => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const { jobId } = JSON.parse(launched.stdout); - const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId && job.transport === "broker" ? job : null; }); - t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); - t.after(() => run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo }) })); - - // Only the job file is rewritten; the index keeps `broker`. - writeJobFile(repo, jobId, { ...readJobFile(resolveJobFile(repo, jobId)), transport: "direct" }); - assert.equal(readJobRecord(repo, jobId).transport, "direct", jobDiagnostics(repo, jobId)); - - const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.equal(cancel.status, 1, `cancel said: ${cancel.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); - assert.deepEqual(JSON.parse(cancel.stdout), { jobId, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }, jobDiagnostics(repo, jobId)); - assert.equal(isAlive(running.pid), true, `no kill on a disagreeing transport\n${jobDiagnostics(repo, jobId)}`); - assert.equal(readJobRecord(repo, jobId).status, "running", jobDiagnostics(repo, jobId)); - assert.equal(listJobs(repo).find((entry) => entry.id === jobId)?.transport, "broker", "the index was never forged"); - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.ok(fakeState.lastInterrupt, `the interrupt was sent (brokered path)\n${jobDiagnostics(repo, jobId)}`); -}); - -// A cancel that lands after the spawn but before the worker takes the record -// over writes `cancelled`; the worker that starts afterwards must not run it. -test("a worker started against a cancelled job exits without running the turn", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - const jobId = "task-cancelled-before-start"; - const record = { id: jobId, kind: "task", jobClass: "task", title: "Codex Task", workspaceRoot: repo, status: "cancelled", phase: "cancelled", background: true, pid: null, pidIdentity: null, errorMessage: "Cancelled by user.", completedAt: new Date().toISOString(), request: { prompt: "never run" } }; - writeJobFile(repo, jobId, record); - upsertJob(repo, record); - const worker = run(process.execPath, [SCRIPT, "task-worker", "--cwd", repo, "--job-id", jobId], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(worker.status, 0, worker.stderr); - const stored = readJobFile(resolveJobFile(repo, jobId)); - assert.equal(stored.status, "cancelled"); - assert.equal(stored.startedAt, undefined, "no running record was written"); - assert.equal(stored.result, undefined); - const appServerStarts = fs.existsSync(fakeStatePath) ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts : 0; - assert.equal(appServerStarts, 0, "no Codex turn may start for a cancelled job"); -}); - -test("session end fully cleans up jobs for the ending session", async (t) => { - const repo = makeTempDir(); - 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 stateDir = resolveStateDir(repo); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const completedLog = path.join(jobsDir, "completed.log"); - const runningLog = path.join(jobsDir, "running.log"); - const otherSessionLog = path.join(jobsDir, "other.log"); - const completedJobFile = path.join(jobsDir, "review-completed.json"); - const runningJobFile = path.join(jobsDir, "review-running.json"); - const otherJobFile = path.join(jobsDir, "review-other.json"); - fs.writeFileSync(completedLog, "completed\n", "utf8"); - fs.writeFileSync(runningLog, "running\n", "utf8"); - fs.writeFileSync(otherSessionLog, "other\n", "utf8"); - fs.writeFileSync(completedJobFile, JSON.stringify({ id: "review-completed" }, null, 2), "utf8"); - fs.writeFileSync(otherJobFile, JSON.stringify({ id: "review-other" }, null, 2), "utf8"); - - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: repo, - detached: true, - stdio: "ignore" - }); - sleeper.unref(); - // A live worker's own file says `running`; a status-less file reads as terminal - // on disk and the reaper would reconcile that into the kept index entry. - fs.writeFileSync(runningJobFile, JSON.stringify({ id: "review-running", status: "running" }, null, 2), "utf8"); - - t.after(() => { - try { - process.kill(-sleeper.pid, "SIGTERM"); - } catch { - try { - process.kill(sleeper.pid, "SIGTERM"); - } catch { - // Ignore missing process. - } - } - }); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "review-completed", - status: "completed", - title: "Codex Review", - sessionId: "sess-current", - logFile: completedLog, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:31:00.000Z" - }, - { - id: "review-running", - status: "running", - title: "Codex Review", - sessionId: "sess-current", - pid: sleeper.pid, - // Records since v1.3.0 carry the worker's identity (#743). - pidIdentity: getProcessIdentity(sleeper.pid), - logFile: runningLog, - createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" - }, - { - id: "review-other", - status: "completed", - title: "Codex Review", - sessionId: "sess-other", - logFile: otherSessionLog, - createdAt: "2026-03-18T15:34:00.000Z", - updatedAt: "2026-03-18T15:35:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - session_id: "sess-current", - cwd: repo - }) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(fs.existsSync(otherSessionLog), true); - assert.equal(fs.existsSync(otherJobFile), true); - assert.deepEqual( - fs.readdirSync(path.dirname(otherJobFile)).sort(), - [path.basename(otherJobFile), path.basename(otherSessionLog)].sort() - ); - - await waitFor(() => { - try { - process.kill(sleeper.pid, 0); - return false; - } catch (error) { - return error?.code === "ESRCH"; - } - }); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.deepEqual(state.jobs.map((job) => job.id), ["review-other"]); - const otherJob = state.jobs[0]; - assert.equal(otherJob.logFile, otherSessionLog); -}); - -test("session end preserves background jobs and their broker so workers survive their dispatching session", async (t) => { - const repo = makeTempDir(); - 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 stateDir = resolveStateDir(repo); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const backgroundLog = path.join(jobsDir, "background.log"); - const foregroundLog = path.join(jobsDir, "foreground.log"); - const backgroundJobFile = path.join(jobsDir, "task-background.json"); - const foregroundJobFile = path.join(jobsDir, "review-foreground.json"); - fs.writeFileSync(backgroundLog, "background\n", "utf8"); - fs.writeFileSync(foregroundLog, "foreground\n", "utf8"); - fs.writeFileSync(backgroundJobFile, JSON.stringify({ id: "task-background" }, null, 2), "utf8"); - fs.writeFileSync(foregroundJobFile, JSON.stringify({ id: "review-foreground" }, null, 2), "utf8"); - - const backgroundSleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: repo, - detached: true, - stdio: "ignore" - }); - backgroundSleeper.unref(); - const foregroundSleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: repo, - detached: true, - stdio: "ignore" - }); - foregroundSleeper.unref(); - - t.after(() => { - for (const proc of [backgroundSleeper, foregroundSleeper]) { - try { - process.kill(-proc.pid, "SIGTERM"); - } catch { - try { - process.kill(proc.pid, "SIGTERM"); - } catch { - // Ignore missing process. - } - } - } - }); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { stopReviewGate: false }, - jobs: [ - { - id: "task-background", - status: "running", - title: "Codex Task", - sessionId: "sess-current", - background: true, - pid: backgroundSleeper.pid, - logFile: backgroundLog, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:31:00.000Z" - }, - { - id: "review-foreground", - status: "running", - title: "Codex Review", - sessionId: "sess-current", - pid: foregroundSleeper.pid, - pidIdentity: getProcessIdentity(foregroundSleeper.pid), - logFile: foregroundLog, - createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const result = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - session_id: "sess-current", - cwd: repo - }) - }); - - assert.equal(result.status, 0, result.stderr); - - // Foreground job killed + pruned from state. - await waitFor(() => { - try { - process.kill(foregroundSleeper.pid, 0); - return false; - } catch (error) { - return error?.code === "ESRCH"; - } - }); - - // Background job still alive — its worker outlives the session that started it. - assert.equal( - (() => { - try { - process.kill(backgroundSleeper.pid, 0); - return true; - } catch { - return false; - } - })(), - true, - "background job worker should not be terminated by SessionEnd" - ); - - const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")); - assert.deepEqual( - state.jobs.map((job) => job.id).sort(), - ["task-background"], - "background job stays in state so later sessions can poll it" - ); - assert.equal(fs.existsSync(backgroundJobFile), true, "background job file preserved"); - assert.equal(fs.existsSync(backgroundLog), true, "background log preserved"); -}); - -// `--background` on a review means what it means on a task: the job is -// dispatched to outlive the session that started it. The flag was parsed and -// then dropped, so SessionEnd pruned the review's record — and with it the -// only way to read the review back with `/codex:result`. -test("an adversarial review dispatched with --background survives its own session's SessionEnd", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - fs.writeFileSync(path.join(repo, "README.md"), "hello world\n"); - const env = { ...buildEnv(binDir), CODEX_COMPANION_SESSION_ID: "sess-current" }; - - const review = run(process.execPath, [SCRIPT, "adversarial-review", "--background"], { cwd: repo, env }); - assert.equal(review.status, 0, review.stderr); - - const stateFile = path.join(resolveStateDir(repo), "state.json"); - const recorded = JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs[0]; - assert.equal(recorded.background, true, "a --background review must be recorded as a background job"); - - const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env, - input: JSON.stringify({ hook_event_name: "SessionEnd", session_id: "sess-current", cwd: repo }) - }); - assert.equal(cleanup.status, 0, cleanup.stderr); - - assert.deepEqual( - JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs.map((job) => job.id), - [recorded.id], - "the background review record must survive the dispatching session's SessionEnd" - ); -}); - -test("stop hook runs a stop-time review task and blocks on findings when the review gate is enabled", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(setup.status, 0, setup.stderr); - const setupPayload = JSON.parse(setup.stdout); - assert.equal(setupPayload.reviewGateEnabled, true); - - const taskResult = run(process.execPath, [SCRIPT, "task", "--write", "fix the issue"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(taskResult.status, 0, taskResult.stderr); - - const blocked = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ - cwd: repo, - session_id: "sess-stop-review", - last_assistant_message: "I completed the refactor and updated the retry logic." - }) - }); - assert.equal(blocked.status, 0, blocked.stderr); - const blockedPayload = JSON.parse(blocked.stdout); - assert.equal(blockedPayload.decision, "block"); - assert.match(blockedPayload.reason, /Codex stop-time review found issues that still need fixes/i); - assert.match(blockedPayload.reason, /Missing empty-state guard/i); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.match(fakeState.lastTurnStart.prompt, //i); - assert.match(fakeState.lastTurnStart.prompt, //i); - assert.match(fakeState.lastTurnStart.prompt, /Only review the work from the previous Claude turn/i); - assert.match(fakeState.lastTurnStart.prompt, /I completed the refactor and updated the retry logic\./); - - const status = run(process.execPath, [SCRIPT, "status"], { - cwd: repo, - env: { - ...buildEnv(binDir), - CODEX_COMPANION_SESSION_ID: "sess-stop-review" - } - }); - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /Codex Stop Gate Review/); -}); - -test("stop gate forwards the configured model and effort to the review task (#769)", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--review-gate-model", "spark", "--review-gate-effort", "low", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(setup.status, 0, setup.stderr); - const payload = JSON.parse(setup.stdout); - assert.equal(payload.reviewGateModel, "gpt-5.3-codex-spark"); - assert.equal(payload.reviewGateEffort, "low"); - const hook = run(process.execPath, [STOP_HOOK], { cwd: repo, env: buildEnv(binDir), input: JSON.stringify({ cwd: repo, session_id: "sess-gate-model", last_assistant_message: "done" }) }); - assert.equal(hook.status, 0, hook.stderr); - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.lastThreadStart.config.model, "gpt-5.3-codex-spark"); - assert.equal(fakeState.lastThreadStart.config.model_reasoning_effort, "low"); - const cleared = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "inherit", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(JSON.parse(cleared.stdout).reviewGateModel, null); -}); - -test("setup rejects a gate effort the gate model does not support and writes nothing", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - const setup = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "spark", "--review-gate-effort", "ultra", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.notEqual(setup.status, 0); - assert.match(setup.stderr, /not supported by gpt-5\.3-codex-spark\. gpt-5\.3-codex-spark supports: /); - const after = run(process.execPath, [SCRIPT, "setup", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(after.status, 0, after.stderr); - assert.equal(JSON.parse(after.stdout).reviewGateModel, null, "a rejected setup must not write the model"); - assert.equal(JSON.parse(after.stdout).reviewGateEffort, null); -}); - -test("setup rejects a gate model that cannot run the stored gate effort and writes nothing", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - const first = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "astra", "--review-gate-effort", "ultra", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(first.status, 0, first.stderr); - const switched = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "spark", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.notEqual(switched.status, 0); - assert.match(switched.stderr, /not supported by gpt-5\.3-codex-spark\. gpt-5\.3-codex-spark supports: /); - const after = run(process.execPath, [SCRIPT, "setup", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(after.status, 0, after.stderr); - assert.equal(JSON.parse(after.stdout).reviewGateModel, "gpt-6-astra", "a rejected setup must not write the model"); - assert.equal(JSON.parse(after.stdout).reviewGateEffort, "ultra"); -}); - -test("stop gate stops blocking after three gate-induced rounds by default (#548)", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); - const env = { ...buildEnv(binDir) }; - delete env.CODEX_REVIEW_GATE_MAX_ROUNDS; - const input = (active) => JSON.stringify({ cwd: repo, session_id: "sess-rounds", stop_hook_active: active, last_assistant_message: "I completed the refactor." }); - const decisions = []; - for (const active of [false, true, true, true]) { - const r = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input: input(active) }); - assert.equal(r.status, 0, r.stderr); - decisions.push(r.stdout.trim() ? JSON.parse(r.stdout).decision : "allow"); - } - assert.deepEqual(decisions, ["block", "block", "block", "allow"]); -}); - -function gateDecisions(env, repo, session, rounds) { - const decisions = []; - let stderr = ""; - for (let i = 0; i < rounds; i += 1) { - const input = JSON.stringify({ cwd: repo, session_id: session, stop_hook_active: i > 0, last_assistant_message: "I completed the refactor." }); - const r = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input }); - assert.equal(r.status, 0, r.stderr); - stderr += r.stderr; - decisions.push(r.stdout.trim() ? JSON.parse(r.stdout).decision : "allow"); - } - return { decisions, stderr }; -} - -test("CODEX_REVIEW_GATE_MAX_ROUNDS=0 never stops blocking and =5 allows the sixth stop", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); - const unlimited = gateDecisions(buildEnv(binDir, { CODEX_REVIEW_GATE_MAX_ROUNDS: "0" }), repo, "sess-rounds-0", 4); - assert.deepEqual(unlimited.decisions, ["block", "block", "block", "block"]); - const five = gateDecisions(buildEnv(binDir, { CODEX_REVIEW_GATE_MAX_ROUNDS: "5" }), repo, "sess-rounds-5", 6); - assert.deepEqual(five.decisions, ["block", "block", "block", "block", "block", "allow"]); -}); - -test("an invalid CODEX_REVIEW_GATE_MAX_ROUNDS falls back to 3 with a warning", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); - const { decisions, stderr } = gateDecisions(buildEnv(binDir, { CODEX_REVIEW_GATE_MAX_ROUNDS: "0.5" }), repo, "sess-rounds-bad", 4); - assert.deepEqual(decisions, ["block", "block", "block", "allow"]); - assert.match(stderr, /Ignoring CODEX_REVIEW_GATE_MAX_ROUNDS="0\.5"/); -}); - -test("setup rejects an empty gate model or effort and writes nothing", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - const first = run(process.execPath, [SCRIPT, "setup", "--review-gate-model", "astra", "--review-gate-effort", "high", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(first.status, 0, first.stderr); - for (const flag of ["--review-gate-model", "--review-gate-effort"]) { - const empty = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", flag, "", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.notEqual(empty.status, 0); - assert.match(empty.stderr, /use inherit to clear/); - } - const after = JSON.parse(run(process.execPath, [SCRIPT, "setup", "--json"], { cwd: repo, env: buildEnv(binDir) }).stdout); - assert.equal(after.reviewGateModel, "gpt-6-astra"); - assert.equal(after.reviewGateEffort, "high"); - assert.equal(after.reviewGateEnabled, false, "the gate flag must not be written either"); -}); - -test("stop gate names the signal when the review task is killed and always names the escape hatch (#589/#483)", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - run(process.execPath, [SCRIPT, "setup", "--enable-review-gate"], { cwd: repo, env: buildEnv(binDir) }); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", CODEX_STOP_REVIEW_TIMEOUT_MS: "800" }); - const r = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input: JSON.stringify({ cwd: repo, session_id: "sess-signal", last_assistant_message: "x" }) }); - assert.equal(r.status, 0, r.stderr); - const payload = JSON.parse(r.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /timed out after 0\.8 minutes|terminated by signal SIGKILL/); - assert.match(payload.reason, /Disable with \/codex:setup --disable-review-gate\./); -}); - -test("stop hook blocks when hook input is malformed JSON", () => { - const blocked = run(process.execPath, [STOP_HOOK], { - cwd: ROOT, - input: "{not-json" - }); - - assert.equal(blocked.status, 0, blocked.stderr); - assert.deepEqual(JSON.parse(blocked.stdout), { - decision: "block", - reason: "The stop review gate could not read or parse hook input; refusing to fail open." - }); -}); - -// Claude Code can leave the hook's stdin open (#530): spawn without ending it and -// time how long the hook takes to decide. -async function runHookWithOpenStdin(t, args, { cwd, env, input = "" }) { - const child = spawn(process.execPath, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); - const kill = () => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }; - t.after(kill); - // A hook that never exits must fail the test, not hang the run. - setTimeout(kill, 20_000).unref(); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => (stdout += chunk)); - child.stderr.on("data", (chunk) => (stderr += chunk)); - child.stdin.on("error", () => {}); - const started = Date.now(); - if (input) { - child.stdin.write(input); - } - const status = await new Promise((resolve) => child.on("close", resolve)); - return { status, stdout, stderr, elapsedMs: Date.now() - started }; -} - -test("stop hook with the gate disabled allows promptly when stdin stays open (#530)", { timeout: 30_000 }, async (t) => { - const repo = makeTempDir(); - initGitRepo(repo); - const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; - for (const input of ["", JSON.stringify({ cwd: repo, session_id: "sess-open" })]) { - const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env, input }); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout.trim(), "", `no decision expected for input ${JSON.stringify(input)}`); - assert.ok(result.elapsedMs < 10000, `hook took ${result.elapsedMs} ms`); - } -}); - -test("stop hook with the gate enabled blocks when hook input never arrives", { timeout: 30_000 }, async (t) => { - const repo = makeTempDir(); - initGitRepo(repo); - const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { cwd: repo }); - assert.equal(setup.status, 0, setup.stderr); - const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; - const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env }); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /hook input did not arrive/); - assert.ok(result.elapsedMs < 10000, `hook took ${result.elapsedMs} ms`); -}); - -test("stop hook with an unreadable state file keeps the gate closed when hook input never arrives", { timeout: 30_000 }, async (t) => { - const repo = makeTempDir(); - initGitRepo(repo); - const stateDir = resolveStateDir(repo); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(path.join(stateDir, "state.json"), "{not-json"); - const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; - const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env }); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /hook input did not arrive/); -}); - -// existsSync answers false for a path under an unreadable directory, which must -// not read as "gate never configured". -test("stop hook with an unreadable state directory keeps the gate closed when hook input never arrives", { timeout: 30_000, skip: IS_WIN || process.getuid?.() === 0 }, async (t) => { - const repo = makeTempDir(); - initGitRepo(repo); - const stateDir = resolveStateDir(repo); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(path.join(stateDir, "state.json"), JSON.stringify({ config: { stopReviewGate: true } })); - fs.chmodSync(stateDir, 0o000); - t.after(() => fs.chmodSync(stateDir, 0o700)); - const env = { ...process.env, CLAUDE_PROJECT_DIR: repo, CODEX_HOOK_STDIN_TIMEOUT_MS: "200" }; - const result = await runHookWithOpenStdin(t, [STOP_HOOK], { cwd: repo, env }); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /hook input did not arrive/); -}); - -test("stop hook input above 1 MiB allows with the gate off and blocks with it on", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const env = { ...process.env, CLAUDE_PROJECT_DIR: repo }; - const input = JSON.stringify({ cwd: repo, session_id: "sess-huge", last_assistant_message: "x".repeat(1.1 * 1024 * 1024) }); - const off = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input }); - assert.equal(off.status, 0, off.stderr); - assert.equal(off.stdout.trim(), ""); - const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { cwd: repo }); - assert.equal(setup.status, 0, setup.stderr); - const on = run(process.execPath, [STOP_HOOK], { cwd: repo, env, input }); - assert.equal(on.status, 0, on.stderr); - const payload = JSON.parse(on.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /exceeded/); -}); - -test("stop gate hands a 300 KB last_assistant_message to Codex whole", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(setup.status, 0, setup.stderr); - const message = `start-${"x".repeat(300 * 1024)}-end`; - const hook = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo, session_id: "sess-big", last_assistant_message: message }) - }); - assert.equal(hook.status, 0, hook.stderr); - assert.equal(JSON.parse(hook.stdout).decision, "block", hook.stdout); - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.ok(fakeState.lastTurnStart.prompt.includes(message), "the whole message must reach Codex"); -}); - -test("stop hook logs running tasks to stderr without blocking when the review gate is disabled", () => { - const repo = makeTempDir(); - 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 stateDir = resolveStateDir(repo); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const runningLog = path.join(jobsDir, "task-running.log"); - fs.writeFileSync(runningLog, "running\n", "utf8"); - - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify( - { - version: 1, - config: { - stopReviewGate: false - }, - jobs: [ - { - id: "task-live", - status: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-current", - logFile: runningLog, - createdAt: "2026-03-18T15:32:00.000Z", - updatedAt: "2026-03-18T15:33:00.000Z" - } - ] - }, - null, - 2 - )}\n`, - "utf8" - ); - - const blocked = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: { - ...process.env, - CODEX_COMPANION_SESSION_ID: "sess-current" - }, - input: JSON.stringify({ cwd: repo }) - }); - - assert.equal(blocked.status, 0, blocked.stderr); - assert.equal(blocked.stdout.trim(), ""); - assert.match(blocked.stderr, /Codex task task-live is still running/i); - assert.match(blocked.stderr, /\/codex:status/i); - assert.match(blocked.stderr, /\/codex:cancel task-live/i); -}); - -test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "adversarial-clean"); - 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(setup.status, 0, setup.stderr); - - const allowed = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo, session_id: "sess-stop-clean" }) - }); - - assert.equal(allowed.status, 0, allowed.stderr); - assert.equal(allowed.stdout.trim(), ""); -}); - -test("stop hook does not block when Codex is unavailable even if the review gate is enabled", () => { - const repo = makeTempDir(); - 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo - }); - assert.equal(setup.status, 0, setup.stderr); - - const allowed = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: { - ...process.env, - PATH: "" - }, - input: JSON.stringify({ cwd: repo }) - }); - - assert.equal(allowed.status, 0, allowed.stderr); - assert.equal(allowed.stdout.trim(), ""); - assert.match(allowed.stderr, /Codex is not set up for the review gate/i); - assert.match(allowed.stderr, /Run \/codex:setup/i); -}); - -test("stop hook runs the actual task when auth status looks stale", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "refreshable-auth"); - 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 setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(setup.status, 0, setup.stderr); - - const allowed = run(process.execPath, [STOP_HOOK], { - cwd: repo, - env: buildEnv(binDir), - input: JSON.stringify({ cwd: repo }) - }); - - assert.equal(allowed.status, 0, allowed.stderr); - assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i); - const payload = JSON.parse(allowed.stdout); - assert.equal(payload.decision, "block"); - assert.match(payload.reason, /Missing empty-state guard/i); -}); - -test("commands lazily start and reuse one shared app-server after first use", async () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - // The broker must outlive the gap between the two CLI runs: on a slow runner - // spawning the second command alone can take several seconds. - const idleMs = 15000; - const env = buildEnv(binDir, { CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: String(idleMs) }); - - const review = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); - - const brokerSession = loadBrokerSession(repo); - if (!brokerSession) { - return; - } - - const adversarial = run(process.execPath, [SCRIPT, "adversarial-review"], { - cwd: repo, - env - }); - assert.equal(adversarial.status, 0, adversarial.stderr); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.appServerStarts, 1); - - const brokerPid = brokerSession.pid; - assert.ok(brokerPid > 0); - const deadline = Date.now() + idleMs + 10000; - let alive = true; - while (alive && Date.now() < deadline) { - try { - process.kill(brokerPid, 0); - await new Promise((r) => setTimeout(r, 200)); - } catch { - alive = false; - } - } - assert.equal(alive, false, `broker ${brokerPid} should exit within the ${idleMs} ms test idle timeout`); - - const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - cwd: repo - }) - }); - assert.equal(cleanup.status, 0, cleanup.stderr); -}); - -test("setup reuses an existing shared app-server without starting another one", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const fakeStatePath = 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const env = buildEnv(binDir); - - const review = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env - }); - assert.equal(review.status, 0, review.stderr); - - const brokerSession = loadBrokerSession(repo); - if (!brokerSession) { - return; - } - - const setup = run(process.execPath, [SCRIPT, "setup", "--json"], { - cwd: repo, - env - }); - assert.equal(setup.status, 0, setup.stderr); - - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.appServerStarts, 1); - - const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { - cwd: repo, - env, - input: JSON.stringify({ - hook_event_name: "SessionEnd", - cwd: repo - }) - }); - assert.equal(cleanup.status, 0, cleanup.stderr); -}); - -test("status reports shared session runtime when a lazy broker is active", () => { - 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 }); - fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); - - const review = run(process.execPath, [SCRIPT, "review"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(review.status, 0, review.stderr); - - if (!loadBrokerSession(repo)) { - return; - } - - const result = run(process.execPath, [SCRIPT, "status"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Session runtime: shared session/); -}); - -test("setup and status honor --cwd when reading shared session runtime", () => { - const targetWorkspace = makeTempDir(); - const invocationWorkspace = makeTempDir(); - - saveBrokerSession(targetWorkspace, { - endpoint: "unix:/tmp/fake-broker.sock" - }); - - const status = run(process.execPath, [SCRIPT, "status", "--cwd", targetWorkspace], { - cwd: invocationWorkspace - }); - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /Session runtime: shared session/); - - const setup = run(process.execPath, [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], { - cwd: invocationWorkspace - }); - assert.equal(setup.status, 0, setup.stderr); - const payload = JSON.parse(setup.stdout); - assert.equal(payload.sessionRuntime.mode, "shared"); - assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); -}); - -test("review forwards model, review_model, effort and config overrides into thread/start config", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - fs.writeFileSync(path.join(repo, "README.md"), "hello world\n"); - - const result = run( - process.execPath, - [SCRIPT, "review", "--wait", "--model", "sol", "--effort", "max", "--config", "model_provider=ollama", "--config", "foo.bar=3"], - { cwd: repo, env: buildEnv(binDir) } - ); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.deepEqual(fakeState.lastThreadStart.config, { - model_provider: "ollama", - "foo.bar": 3, - model: "gpt-6-sol", - review_model: "gpt-6-sol", - model_reasoning_effort: "max" - }); -}); - -test("task --model sol resolves through the model catalogue and rejects an unsupported effort", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - const ok = run(process.execPath, [SCRIPT, "task", "--json", "--model", "sol", "--effort", "max", "hello"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(ok.status, 0, ok.stderr); - assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).lastThreadStart.config.model, "gpt-6-sol"); - const bad = run(process.execPath, [SCRIPT, "task", "--json", "--model", "gpt-5.6-sol", "--effort", "max", "hello"], { cwd: repo, env: buildEnv(binDir) }); - assert.notEqual(bad.status, 0); - assert.match(bad.stderr, /gpt-5\.6-sol supports: low, medium, high/); -}); - -test("review accepts slash-command style single-string arguments", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - fs.writeFileSync(path.join(repo, "README.md"), "hello world\n"); - - const result = run(process.execPath, [SCRIPT, "review", "--wait --effort xhigh --config model_provider=ollama"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama", model_reasoning_effort: "xhigh" }); -}); - -test("task forwards config overrides and keeps option-looking prompt words", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - const result = run(process.execPath, [SCRIPT, "task", "--effort", "max", "--config", "model_provider=ollama", "investigate", "ls", "-R", "usage"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama", model_reasoning_effort: "max" }); - assert.match(fakeState.lastTurnStart.prompt, /investigate ls -R usage/); -}); - -test("task --resume-last never puts model or effort into thread/resume config", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - const first = run(process.execPath, [SCRIPT, "task", "--model", "sol", "--effort", "high", "first"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(first.status, 0, first.stderr); - const startsAfterFirst = JSON.parse(fs.readFileSync(statePath, "utf8")).appServerStarts; - const second = run(process.execPath, [SCRIPT, "task", "--resume-last", "--effort", "max", "--config", "model_provider=ollama", "again"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(second.status, 0, second.stderr); - - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.deepEqual(fakeState.lastThreadResume.config, { model_provider: "ollama" }); - assert.equal(fakeState.lastTurnStart.effort, "max"); - // A hot broker rejoin would ignore the config/sandbox overrides, so the resume - // must have run on a freshly spawned app-server process. - assert.equal(fakeState.appServerStarts, startsAfterFirst + 1); -}); - -test("task --resume-last cold-resumes without a thread/resume model override", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - const first = run(process.execPath, [SCRIPT, "task", "--model", "sol", "--effort", "high", "first"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(first.status, 0, first.stderr); - const startsAfterFirst = JSON.parse(fs.readFileSync(statePath, "utf8")).appServerStarts; - - const second = run(process.execPath, [SCRIPT, "task", "--resume-last", "--model", "sol", "--effort", "max", "again"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(second.status, 0, second.stderr); - - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - // A top-level `model` on thread/resume sets has_model_resume_override and stops - // Codex restoring the thread's persisted model/provider/effort. - assert.equal(fakeState.lastThreadResume.model, undefined); - assert.equal(fakeState.lastThreadResume.config, null); - assert.equal(fakeState.appServerStarts, startsAfterFirst + 1); - assert.equal(fakeState.lastTurnStart.model, "gpt-6-sol"); - assert.equal(fakeState.lastTurnStart.effort, "max"); -}); - -test("task --background stores config overrides in the job request", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - const result = run(process.execPath, [SCRIPT, "task", "--background", "--json", "--config", "model_provider=ollama", "bg"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(result.status, 0, result.stderr); - const jobId = JSON.parse(result.stdout).jobId; - const done = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(done.status, 0, done.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama" }); -}); - -test("status --args-stdin tokenizes the raw argument string from stdin", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - initGitRepo(repo); - - const viaStdin = run(process.execPath, [SCRIPT, "status", "--args-stdin"], { - cwd: repo, - env: buildEnv(binDir), - input: "--all --json\n" - }); - const viaArgv = run(process.execPath, [SCRIPT, "status", "--all", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(viaStdin.status, 0, viaStdin.stderr); - assert.equal(viaArgv.status, 0, viaArgv.stderr); - assert.deepEqual(JSON.parse(viaStdin.stdout), JSON.parse(viaArgv.stdout)); -}); - -test("task --args-stdin keeps shell metacharacters inside the prompt instead of executing them", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - const sentinel = path.join(makeTempDir(), "pwned"); - 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 rawArguments = `--effort max investigate $(touch ${sentinel}) \`id\``; - const result = run(process.execPath, [SCRIPT, "task", "--args-stdin"], { - cwd: repo, - env: buildEnv(binDir), - input: `${rawArguments}\n` - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.effort, "max"); - assert.equal(fakeState.lastTurnStart.prompt, `investigate $(touch ${sentinel}) \`id\``); - assert.equal(fs.existsSync(sentinel), false); -}); - -test("task --background persists the job record before spawning the worker", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "slow-task"); - - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "investigate the ordering"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(launched.status, 0, launched.stderr); - const { jobId } = JSON.parse(launched.stdout); - - // The launch command has returned, so the record must already be readable by - // the worker no matter how fast it started. - const stateDir = resolveStateDir(repo); - const indexed = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs.find((job) => job.id === jobId); - assert.ok(indexed, "job must be in the state index as soon as the launch returns"); - assert.ok(["queued", "running"].includes(indexed.status), `unexpected status ${indexed.status}`); - assert.ok(fs.existsSync(path.join(stateDir, "jobs", `${jobId}.json`)), "job file must exist as soon as the launch returns"); - - const waited = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(waited.status, 0, waited.stderr); - assert.equal(JSON.parse(waited.stdout).job.status, "completed"); -}); - -// Classifying secrets by key name always misses one: a session cookie header is -// every bit a credential and matches no denylist. Every `--config` value stays -// out of the public record; only the keys are kept. -test("task --background keeps secret --config values out of every job record", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - const launched = run( - process.execPath, - [ - SCRIPT, - "task", - "--background", - "--json", - "--config", - "model_providers.x.http_headers.Cookie=SECRET_SENTINEL_42", - "--config", - "model_provider=ollama", - "x" - ], - { cwd: repo, env: buildEnv(binDir) } - ); - assert.equal(launched.status, 0, launched.stderr); - const { jobId } = JSON.parse(launched.stdout); - - const waited = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(waited.status, 0, waited.stderr); - assert.equal(JSON.parse(waited.stdout).job.status, "completed"); - const resultRun = run(process.execPath, [SCRIPT, "result", jobId, "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(resultRun.status, 0, resultRun.stderr); - - const stateDir = resolveStateDir(repo); - const exposures = { - "state index": fs.readFileSync(path.join(stateDir, "state.json"), "utf8"), - "job file": fs.readFileSync(path.join(stateDir, "jobs", `${jobId}.json`), "utf8"), - "status --json stdout": waited.stdout, - "result --json stdout": resultRun.stdout - }; - for (const [label, text] of Object.entries(exposures)) { - assert.equal(text.includes("SECRET_SENTINEL_42"), false, `${label} leaked the secret --config value`); - assert.equal(text.includes("[redacted]"), true, `${label} should keep the redacted placeholder`); - assert.equal(text.includes("model_provider"), true, `${label} should still record which config keys were set`); - assert.equal(text.includes("ollama"), false, `${label} stored a --config value; keys are recorded, values never are`); - } - - // The one-shot payload file is deleted by the worker once it has read it. - assert.equal(fs.existsSync(path.join(stateDir, "jobs", `${jobId}.request.json`)), false); - - // The worker still forwarded the real value to Codex. - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadStart.config["model_providers.x.http_headers.Cookie"], "SECRET_SENTINEL_42"); - assert.equal(fakeState.lastThreadStart.config.model_provider, "ollama"); -}); - -// Redaction was added when the job is created, which does nothing for records -// v1.1.1 already wrote: same `STATE_VERSION`, raw `--config` values, and every -// `status`/`result --json` still echoing them back after the upgrade. -test("a v1.1.1 record's --config values never reach status/result and are redacted on disk", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir); - - const seeded = run(process.execPath, [SCRIPT, "task", "seed the state dir"], { cwd: repo, env }); - assert.equal(seeded.status, 0, seeded.stderr); - - const stateDir = resolveStateDir(repo); - const statePath = path.join(stateDir, "state.json"); - const legacyRequest = { - cwd: repo, - prompt: "legacy prompt", - config: { "model_providers.x.http_headers.Cookie": "SESSION_SECRET_FROM_1_1_1" } - }; - const legacyJob = { - id: "task-legacy", - status: "completed", - phase: "done", - jobClass: "task", - kind: "task", - title: "Codex Task", - summary: "Legacy job written by 1.1.1", - threadId: "thr_legacy", - updatedAt: "2026-03-24T20:05:00.000Z", - completedAt: "2026-03-24T20:06:00.000Z", - request: legacyRequest - }; - const state = JSON.parse(fs.readFileSync(statePath, "utf8")); - state.jobs.push(legacyJob); - fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - const legacyJobFile = path.join(stateDir, "jobs", "task-legacy.json"); - fs.writeFileSync( - legacyJobFile, - `${JSON.stringify({ ...legacyJob, result: { status: 0, finalMessage: "legacy output" }, rendered: "legacy output\n" }, null, 2)}\n`, - "utf8" - ); - - const status = run(process.execPath, [SCRIPT, "status", "task-legacy", "--json"], { cwd: repo, env }); - assert.equal(status.status, 0, status.stderr); - const result = run(process.execPath, [SCRIPT, "result", "task-legacy", "--json"], { cwd: repo, env }); - assert.equal(result.status, 0, result.stderr); - - const exposures = { - "status --json stdout": status.stdout, - "result --json stdout": result.stdout, - "state index": fs.readFileSync(statePath, "utf8"), - "job file": fs.readFileSync(legacyJobFile, "utf8") - }; - for (const [label, text] of Object.entries(exposures)) { - assert.equal(text.includes("SESSION_SECRET_FROM_1_1_1"), false, `${label} leaked a legacy --config value`); - assert.equal(text.includes("[redacted]"), true, `${label} should carry the redacted placeholder`); - assert.equal( - text.includes("model_providers.x.http_headers.Cookie"), - true, - `${label} should still record which config keys were set` - ); - } -}); - -// The record of an ACTIVE 1.1.1 job is the only copy of its request — 1.1.1 wrote -// no private payload file — and `handleTaskWorker` falls back to exactly that -// record when there is none. Redacting it in place would hand the worker -// "[redacted]" as its Codex config (auth headers included), so the raw request is -// moved into a fresh 0600 payload file first and only then dropped from the record. -test("an active v1.1.1 record keeps its real --config for the worker while output stays redacted", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir); - - const seeded = run(process.execPath, [SCRIPT, "task", "seed the state dir"], { cwd: repo, env }); - assert.equal(seeded.status, 0, seeded.stderr); - - const stateDir = resolveStateDir(repo); - const statePath = path.join(stateDir, "state.json"); - const now = new Date().toISOString(); - const legacyRequest = { - cwd: repo, - prompt: "legacy queued prompt", - config: { "model_providers.x.http_headers.Cookie": "SESSION_SECRET_FROM_1_1_1" } - }; - const legacyJob = { - id: "task-legacy-queued", - status: "queued", - phase: "queued", - jobClass: "task", - kind: "task", - title: "Codex Task", - summary: "Legacy queued job written by 1.1.1", - createdAt: now, - updatedAt: now, - request: legacyRequest - }; - const state = JSON.parse(fs.readFileSync(statePath, "utf8")); - state.jobs.push(legacyJob); - fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - const legacyJobFile = resolveJobFile(repo, "task-legacy-queued"); - fs.writeFileSync(legacyJobFile, `${JSON.stringify(legacyJob, null, 2)}\n`, "utf8"); - - const status = run(process.execPath, [SCRIPT, "status", "task-legacy-queued", "--json"], { cwd: repo, env }); - assert.equal(status.status, 0, status.stderr); - assert.equal(status.stdout.includes("SESSION_SECRET_FROM_1_1_1"), false, "status --json leaked a legacy --config value"); - assert.equal(status.stdout.includes("[redacted]"), true); - - // The worker's own read path (`readStoredJob` → `readJobFile`). - const workerView = readJobFile(legacyJobFile); - assert.equal(workerView.request.config["model_providers.x.http_headers.Cookie"], "[redacted]"); - - const requestFile = resolveJobRequestFile(repo, "task-legacy-queued"); - assert.equal(fs.existsSync(requestFile), true, "the raw request must be moved into the private payload file"); - // Windows has no POSIX mode bits; the owner-only invariant is not modelled there. - if (!IS_WIN) assert.equal(fs.statSync(requestFile).mode & 0o777, 0o600, "the payload file must be owner-only"); - assert.equal(fs.readFileSync(legacyJobFile, "utf8").includes("SESSION_SECRET_FROM_1_1_1"), false, "the record must be redacted on disk"); - - // What the worker actually runs with. - const consumed = consumeJobRequestFile(repo, "task-legacy-queued"); - assert.equal(consumed.config["model_providers.x.http_headers.Cookie"], "SESSION_SECRET_FROM_1_1_1"); - assert.equal(consumed.prompt, "legacy queued prompt"); -}); - -test("a resume refuses to start a second turn on a thread another job is still using", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = { ...buildEnv(binDir), CODEX_COMPANION_SESSION_ID: "sess-current" }; - - const first = run(process.execPath, [SCRIPT, "task", "first"], { cwd: repo, env }); - assert.equal(first.status, 0, first.stderr); - - // Another Claude session is mid-turn on the very thread this session would - // resume. Its job is invisible to this session's resume-candidate lookup, so - // only the thread-level guard can catch it. - const statePath = path.join(resolveStateDir(repo), "state.json"); - const state = JSON.parse(fs.readFileSync(statePath, "utf8")); - const busyJob = { - id: "task-other-running", - status: "running", - phase: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - threadId: "thr_1", - summary: "Other session active task", - updatedAt: "2026-03-24T20:05:00.000Z" - }; - state.jobs.push(busyJob); - fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - const blocked = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, env }); - assert.notEqual(blocked.status, 0); - assert.match( - blocked.stderr, - /Thread thr_1 is busy in job task-other-running; wait for it or run cancel task-other-running first\./ - ); - - // Once that job finishes, the same resume goes through. - busyJob.status = "completed"; - busyJob.phase = "done"; - fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - const resumed = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, env }); - assert.equal(resumed.status, 0, resumed.stderr); - const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "follow up"); -}); - -// The crash window between a worker's terminal `writeJobFile` and its -// `upsertJob`: the job file is terminal, the index still says running. The -// reaper handed the terminal file back to its caller but left the index alone, -// so the raw `listJobs()` behind `assertThreadIsFree` kept seeing a phantom -// running job and blocked every later resume of that thread. The recorded pid -// here is alive and unrelated (this test runner) — exactly what a zombie or a -// recycled pid looks like — so nothing but the job file itself can settle it. -test("a terminal job file reconciles the state index and unblocks resume", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = { ...buildEnv(binDir), CODEX_COMPANION_SESSION_ID: "sess-current" }; - - const first = run(process.execPath, [SCRIPT, "task", "first"], { cwd: repo, env }); - assert.equal(first.status, 0, first.stderr); - - const stateDir = resolveStateDir(repo); - const statePath = path.join(stateDir, "state.json"); - const state = JSON.parse(fs.readFileSync(statePath, "utf8")); - state.jobs.push({ - id: "task-crash-window", - status: "running", - phase: "running", - title: "Codex Task", - jobClass: "task", - sessionId: "sess-other", - threadId: "thr_1", - pid: process.pid, - summary: "Other session task that died after writing its result", - updatedAt: "2026-03-24T20:05:00.000Z" - }); - fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - fs.writeFileSync( - path.join(stateDir, "jobs", "task-crash-window.json"), - `${JSON.stringify( - { - id: "task-crash-window", - status: "completed", - phase: "done", - pid: null, - threadId: "thr_1", - turnId: "turn_9", - result: { status: 0, finalMessage: "done" }, - rendered: "done\n", - completedAt: "2026-03-24T20:06:00.000Z" - }, - null, - 2 - )}\n`, - "utf8" - ); - - // Resume first: only a reaped/reconciled job list can tell this thread is free. - const resumed = run(process.execPath, [SCRIPT, "task", "--resume-last", "follow up"], { cwd: repo, env }); - assert.equal(resumed.status, 0, resumed.stderr); - - const reconciled = JSON.parse(fs.readFileSync(statePath, "utf8")).jobs.find((job) => job.id === "task-crash-window"); - assert.equal(reconciled.status, "completed", "the terminal job file must be reconciled into the state index"); - assert.equal(reconciled.pid, null); - - const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env }); - assert.equal(status.status, 0, status.stderr); -}); - -test("task --prompt-file wins over --args-stdin and keeps the prompt byte-exact", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - // Everything splitRawArgumentString would eat: quotes as grouping, backslashes - // as escapes, newlines as separators. - const promptText = `line one \\d+ "quoted" 'single' C:\\Users\\x\nsecond line with $(id) and \`backticks\``; - const promptFile = path.join(makeTempDir(), "request.txt"); - fs.writeFileSync(promptFile, promptText, "utf8"); - - const result = run(process.execPath, [SCRIPT, "task", "--prompt-file", promptFile, "--args-stdin"], { - cwd: repo, - env: buildEnv(binDir), - input: "--effort max\n" - }); - - assert.equal(result.status, 0, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, promptText); - assert.equal(fakeState.lastTurnStart.effort, "max"); -}); - -test("task --await launches a tracked job, waits, and prints the result", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - const result = run(process.execPath, [SCRIPT, "task", "--await", "--json", "--model", "sol", "--effort", "low", "--prompt-stdin"], { - cwd: repo, env: buildEnv(binDir), input: "line one \\d+ \"quoted\" 'single'\nline two\n" - }); - assert.equal(result.status, 0, result.stderr); - const out = JSON.parse(result.stdout); - assert.match(out.job.id, /^task-/); - assert.equal(out.job.status, "completed"); - assert.ok(typeof out.storedJob.result.rawOutput === "string" && out.storedJob.result.rawOutput.length > 0); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, "line one \\d+ \"quoted\" 'single'\nline two"); - assert.equal(fakeState.lastTurnStart.effort, "low"); - assert.equal(fakeState.lastTurnStart.model, "gpt-6-sol"); - const status = run(process.execPath, [SCRIPT, "status", out.job.id, "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(JSON.parse(status.stdout).job.status, "completed"); -}); - -test("task --await exits 3 with a resumable hint when the await timeout elapses", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "3000" }); - const result = run(process.execPath, [SCRIPT, "task", "--await", "--await-timeout-ms", "500", "--prompt-stdin"], { cwd: repo, env, input: "slow task\n" }); - assert.equal(result.status, 3); - assert.match(result.stdout, /Still running: job task-[A-Za-z0-9_-]+\. Re-run: node .*result task-[A-Za-z0-9_-]+ --wait --timeout-ms 540000/); - const jobId = result.stdout.match(/job (task-[A-Za-z0-9_-]+)/)[1]; - - const timedOutJson = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "100", "--json"], { cwd: repo, env }); - assert.equal(timedOutJson.status, 3, timedOutJson.stderr); - const snapshot = JSON.parse(timedOutJson.stdout); - assert.ok(["queued", "running"].includes(snapshot.job.status), snapshot.job.status); - assert.match( - snapshot.resumeCommand, - new RegExp(`^node ".*codex-companion\\.mjs" result ${jobId} --wait --timeout-ms 540000$`) - ); - - const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); - assert.equal(done.status, 0, done.stderr); -}); - -test("task rejects --prompt-stdin combined with --args-stdin or --prompt-file", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const r = run(process.execPath, [SCRIPT, "task", "--prompt-stdin", "--args-stdin"], { cwd: repo, env: buildEnv(binDir), input: "x" }); - assert.notEqual(r.status, 0); - assert.match(r.stderr, /--prompt-stdin/); -}); - -test("result on a still-running job exits 3 with the wait hint instead of \"No job found\"", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "3000" }); - const launch = run(process.execPath, [SCRIPT, "task", "--background", "--json", "--prompt-stdin"], { - cwd: repo, env, input: "slow background task\n" - }); - assert.equal(launch.status, 0, launch.stderr); - const { jobId } = JSON.parse(launch.stdout); - - const active = run(process.execPath, [SCRIPT, "result", jobId], { cwd: repo, env }); - assert.equal(active.status, 3, active.stderr); - assert.match( - active.stdout, - new RegExp(`Job ${jobId} is still (queued|running)\\. Re-run: node .*result ${jobId} --wait --timeout-ms 540000`) - ); - - const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); - assert.equal(done.status, 0, done.stderr); - assert.ok(done.stdout.trim().length > 0); -}); - -test("task usage errors around --prompt-stdin arrive without waiting for stdin", async () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const cases = [ - [["--prompt-stdin", "--args-stdin"], /--prompt-stdin cannot be combined with --args-stdin/], - [["--prompt-stdin", "--await", "--background"], /Choose either --await or --background/] - ]; - - for (const [args, pattern] of cases) { - const startedAt = Date.now(); - const child = spawn(process.execPath, [SCRIPT, "task", ...args], { - cwd: repo, - env: buildEnv(binDir), - stdio: ["pipe", "pipe", "pipe"] - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - // stdin stays open and empty: the error must not wait for EOF. - const code = await new Promise((resolve, reject) => { - child.on("error", reject); - child.on("exit", resolve); - }); - child.stdin.destroy(); - - assert.notEqual(code, 0, args.join(" ")); - assert.match(stderr, pattern, args.join(" ")); - assert.ok(Date.now() - startedAt < 2000, `${args.join(" ")} took ${Date.now() - startedAt}ms`); - } -}); - -test("task --prompt-stdin sends the prompt verbatim minus one trailing newline", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - const promptText = "\n indented first line \r\nsecond\tline\n\nlast line without a newline"; - - const withNewline = run(process.execPath, [SCRIPT, "task", "--prompt-stdin"], { - cwd: repo, env: buildEnv(binDir), input: `${promptText}\r\n` - }); - assert.equal(withNewline.status, 0, withNewline.stderr); - assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastTurnStart.prompt, promptText); - - const withoutNewline = run(process.execPath, [SCRIPT, "task", "--prompt-stdin"], { - cwd: repo, env: buildEnv(binDir), input: promptText - }); - assert.equal(withoutNewline.status, 0, withoutNewline.stderr); - assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastTurnStart.prompt, promptText); -}); - -test("task rejects contradictory await and prompt flag combinations", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const cases = [ - [["--prompt-stdin", "inline prompt text"], /--prompt-stdin cannot be combined with --prompt-file or prompt text/], - [["--prompt-stdin", "--prompt-file", "prompt.txt"], /--prompt-stdin cannot be combined with --prompt-file or prompt text/], - [["--await", "--background", "do it"], /Choose either --await or --background/], - [["--await-timeout-ms", "1000", "do it"], /--await-timeout-ms requires --await/], - [["--await", "--await-timeout-ms", "0", "do it"], /--await-timeout-ms expects a positive integer/], - [["--await", "--await-timeout-ms", "-5", "do it"], /--await-timeout-ms expects a positive integer/], - [["--await", "--await-timeout-ms", "1.5", "do it"], /--await-timeout-ms expects a positive integer/], - [["--await", "--await-timeout-ms", "nope", "do it"], /--await-timeout-ms expects a positive integer/], - [["--await", "--await-timeout-ms", "1e400", "do it"], /--await-timeout-ms expects a positive integer/] - ]; - - for (const [args, pattern] of cases) { - const result = run(process.execPath, [SCRIPT, "task", ...args], { cwd: repo, env: buildEnv(binDir), input: "" }); - assert.notEqual(result.status, 0, args.join(" ")); - assert.match(result.stderr, pattern, args.join(" ")); - } - - const badResultTimeout = run(process.execPath, [SCRIPT, "result", "task-x", "--wait", "--timeout-ms", "0"], { - cwd: repo, env: buildEnv(binDir) - }); - assert.notEqual(badResultTimeout.status, 0); - assert.match(badResultTimeout.stderr, /--timeout-ms expects a positive integer/); - - const missingWaitTimeout = run(process.execPath, [SCRIPT, "result", "task-x", "--timeout-ms", "1000"], { - cwd: repo, env: buildEnv(binDir) - }); - assert.notEqual(missingWaitTimeout.status, 0); - assert.match(missingWaitTimeout.stderr, /--timeout-ms requires --wait/); -}); - -test("task --await reports a failed job with exit 1 while result stays exit 0", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-start-fails"); - - const awaited = run(process.execPath, [SCRIPT, "task", "--await", "--json", "--prompt-stdin"], { - cwd: repo, env: buildEnv(binDir), input: "break on purpose\n" - }); - assert.equal(awaited.status, 1, awaited.stderr); - const out = JSON.parse(awaited.stdout); - assert.equal(out.job.status, "failed"); - assert.match(out.storedJob.errorMessage, /turn\/start failed after thread resolution/); - - const stored = run(process.execPath, [SCRIPT, "result", out.job.id], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(stored.status, 0, stored.stderr); - assert.match(stored.stdout, /turn\/start failed after thread resolution/); -}); - -test("cancelling an awaited job ends the await with exit 1 and leaves a readable result", async () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "6000" }); - - const child = spawn(process.execPath, [SCRIPT, "task", "--await", "--await-timeout-ms", "30000", "--prompt-stdin"], { - cwd: repo, - env, - stdio: ["pipe", "pipe", "pipe"] - }); - child.stdin.end("cancel me\n"); - const exited = new Promise((resolve, reject) => { - child.on("error", reject); - child.on("exit", resolve); - }); - - const stateFile = path.join(resolveStateDir(repo), "state.json"); - const jobId = await waitFor(() => { - if (!fs.existsSync(stateFile)) { - return null; - } - const job = JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs?.[0]; - // Wait for the worker to own the record: the turn has to be under way for - // the cancel to have a running turn to interrupt. - return job && job.status === "running" && job.pid ? job.id : null; - }, { timeoutMs: 15000 }); - - // v1.4.1 refuses kills while the broker record has no identity (Windows start window). - if (IS_WIN) { - await waitFor(() => (/^win32:\d+$/.test(loadBrokerSession(repo)?.pidIdentity ?? "") ? "ready" : null)); - } - const cancelled = run(process.execPath, [SCRIPT, "cancel", jobId], { cwd: repo, env }); - assert.equal(cancelled.status, 0, cancelled.stderr); - assert.match(cancelled.stdout, /cancelled/i); - assert.equal(await exited, 1); - - const stored = run(process.execPath, [SCRIPT, "result", jobId, "--json"], { cwd: repo, env }); - assert.equal(stored.status, 0, stored.stderr); - assert.equal(JSON.parse(stored.stdout).job.status, "cancelled"); -}); - -// Direct transport (a cold --resume-last owns its app-server): cancel sends no -// turn/interrupt — no second client can reach that app-server, and the old -// attempt could start a codex of its own — and kills the worker's group. The -// worker ignores SIGTERM and outlives the kill as its app-server dies; its late -// write must not replace the acknowledged `cancelled` record. -test("a direct cancel skips the interrupt, and the cancellation survives a worker that finishes after it", { skip: process.platform === "win32", timeout: 60_000 }, async (t) => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(seeded.status, 0, seeded.stderr); - // Only the task worker ignores SIGTERM; the broker and fake codex keep the default. - const preload = path.join(binDir, "worker-ignores-sigterm.mjs"); - fs.writeFileSync(preload, 'if (process.argv.includes("task-worker")) process.on("SIGTERM", () => {});\n'); - const env = buildEnv(binDir, { - FAKE_CODEX_TURN_DELAY_MS: "20000", - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import ${pathToFileURL(preload).href}`.trim() - }); - const launch = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "--prompt-stdin"], { cwd: repo, env, input: "cancel me late\n" }); - assert.equal(launch.status, 0, launch.stderr); - const { jobId } = JSON.parse(launch.stdout); - const running = await waitFor(() => { const job = readJobRecord(repo, jobId); return job.status === "running" && job.pid && job.turnId ? job : null; }); - t.after(() => { try { process.kill(-running.pid, "SIGKILL"); } catch {} }); - assert.equal(running.transport, "direct", jobDiagnostics(repo, jobId)); - const startsBefore = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts; - - const cancelled = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.equal(cancelled.status, 0, `${cancelled.stderr}\n${jobDiagnostics(repo, jobId)}`); - const payload = JSON.parse(cancelled.stdout); - assert.equal(payload.status, "cancelled", `cancel said: ${cancelled.stdout.trim()}\n${jobDiagnostics(repo, jobId)}`); - assert.equal(payload.turnInterruptAttempted, false, "a direct job's app-server is the worker's own"); - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.lastInterrupt ?? null, null, "no turn/interrupt was sent"); - assert.equal(fakeState.appServerStarts, startsBefore, "cancel started no codex of its own"); - - await waitFor(() => !isAlive(running.pid)); - const stored = run(process.execPath, [SCRIPT, "result", jobId, "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(stored.status, 0, stored.stderr); - assert.equal(JSON.parse(stored.stdout).job.status, "cancelled", jobDiagnostics(repo, jobId)); -}); - -// A turn that never completes used to hang the companion until Claude Code's -// Bash tool SIGKILLed it, leaving the job "running" and no output at all. -test("task --turn-timeout-ms interrupts a stalled turn and fails the job with the timeout message", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - // The fake turn is held far longer than the budget: a slow CI VM can spend - // several seconds just starting the broker, so the margin is generous. - const fakeTurnMs = 20000; - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: String(fakeTurnMs) }); - - const started = Date.now(); - const result = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "500", "--json", "stall please"], { - cwd: repo, - env - }); - - assert.equal(result.status, 1, result.stderr); - assert.ok(Date.now() - started < fakeTurnMs, "the turn budget must fire long before the fake turn completes"); - assert.equal(JSON.parse(result.stdout).status, 1); - - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.ok(fakeState.lastInterrupt, "a timed-out turn must be interrupted, not abandoned"); - - const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env }); - assert.equal(status.status, 0, status.stderr); - const latest = JSON.parse(status.stdout).latestFinished; - assert.equal(latest.status, "failed"); - assert.match(latest.errorMessage, /turn timed out after 500 ms/); -}); - -// `turn/interrupt` returning is not proof the turn stopped: a wedged app-server -// answers the RPC and keeps going. Writing `failed` right there claims a turn -// (possibly a `--write` one) is over while it is still editing files, so the -// timeout waits for the terminal turn notification and says so when it never -// arrives. `--resume-last` is the path that owns a direct app-server, so -// closing the connection is what actually kills the runaway turn. -test("an unacknowledged interrupt is reported and closes a direct app-server", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(seeded.status, 0, seeded.stderr); - - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "20000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); - const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--turn-timeout-ms", "500", "--json", "stall please"], { - cwd: repo, - env - }); - - assert.equal(result.status, 1, result.stderr); - - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.ok(fakeState.lastInterrupt, "the timed-out turn must still be interrupted"); - assert.equal(fakeState.clientClosed, true, "a direct app-server must be closed so the runaway turn dies with it"); - - const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(status.status, 0, status.stderr); - const latest = JSON.parse(status.stdout).latestFinished; - assert.equal(latest.status, "failed"); - assert.match(latest.errorMessage, /turn timed out after 500 ms; interrupt not acknowledged/); - assert.match(latest.errorMessage, /may still be running in the shared runtime/); -}); - -// The other degraded shape: the app-server answers the interrupt and dies before -// any terminal notification. Nothing can arrive after that, and the acknowledgement -// window used to be an unref'd timer with no exit awareness — so the companion -// waited on a promise nothing would settle and could exit before writing the job's -// terminal record, leaving it `running` forever. -test("a transport that exits after the interrupt still writes a terminal record", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(seeded.status, 0, seeded.stderr); - - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "20000", FAKE_CODEX_EXIT_AFTER_INTERRUPT: "1" }); - const started = Date.now(); - const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--turn-timeout-ms", "500", "--json", "stall please"], { - cwd: repo, - env - }); - const elapsed = Date.now() - started; - - assert.equal(result.status, 1, result.stderr); - assert.ok(elapsed < 9000, `a dead transport must end the acknowledgement wait early, took ${elapsed} ms`); - - const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(status.status, 0, status.stderr); - const latest = JSON.parse(status.stdout).latestFinished; - assert.equal(latest.status, "failed", "the job must not be left running"); - assert.match(latest.errorMessage, /turn timed out after 500 ms; interrupt not acknowledged/); -}); - -test("task --turn-timeout-ms survives into the detached background worker", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "5000" }); - - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--turn-timeout-ms", "500", "--json", "stall please"], { - cwd: repo, - env - }); - assert.equal(launched.status, 0, launched.stderr); - const { jobId } = JSON.parse(launched.stdout); - - const waited = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "20000", "--json"], { cwd: repo, env }); - assert.equal(waited.status, 0, waited.stderr); - const job = JSON.parse(waited.stdout).job; - assert.equal(job.status, "failed"); - assert.match(job.errorMessage, /turn timed out after 500 ms/); -}); - -test("task without a turn budget is unbounded", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - - const result = run(process.execPath, [SCRIPT, "task", "--json", "take your time"], { - cwd: repo, - env: buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "700" }) - }); - - assert.equal(result.status, 0, result.stderr); - assert.equal(JSON.parse(result.stdout).status, 0); -}); - -test("CODEX_TURN_TIMEOUT_MS bounds a turn when no flag is passed", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - - const result = run(process.execPath, [SCRIPT, "task", "--json", "stall please"], { - cwd: repo, - env: buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "5000", CODEX_TURN_TIMEOUT_MS: "500" }) - }); - - assert.equal(result.status, 1, result.stderr); -}); - -test("task rejects a non-positive turn budget", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - - const result = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "0", "hi"], { cwd: repo, env: buildEnv(binDir) }); - - assert.equal(result.status, 1); - assert.match(result.stderr, /--turn-timeout-ms expects a positive integer/); -}); - -// Recording the worker pid on the queued record (so a queued job can be -// cancelled at all) means cancel can now kill a worker *before* it consumed its -// private one-shot payload. A cancelled job is terminal, so the reaper will -// never look at it again — cancel has to release the file itself. The worker's -// identity is recorded, so the kill is provable. -test("cancel removes the private request payload of a job killed in the queued window", async (t) => { - const repo = seededRepo(); - const stateDir = resolveStateDir(repo); - const jobsDir = path.join(stateDir, "jobs"); - fs.mkdirSync(jobsDir, { recursive: true }); - - const secret = "sk-cancel-secret-value"; - const requestFile = path.join(jobsDir, "task-queued.request.json"); - fs.writeFileSync(requestFile, JSON.stringify({ prompt: "hi", config: { auth_header: secret } }), { - encoding: "utf8", - mode: 0o600 - }); - - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { - cwd: repo, - detached: true, - stdio: "ignore" - }); - sleeper.unref(); - t.after(() => { - try { - process.kill(-sleeper.pid, "SIGKILL"); - } catch { - // No process groups on Windows, or already gone. - try { - process.kill(sleeper.pid, "SIGKILL"); - } catch { - // Already gone. - } - } - }); - - const job = { - id: "task-queued", - status: "queued", - phase: "queued", - jobClass: "task", - title: "Codex Task", - background: true, - pid: sleeper.pid, - pidIdentity: getProcessIdentity(sleeper.pid), - logFile: null, - requestFile, - request: { prompt: "hi", config: { auth_header: "[redacted]" } }, - createdAt: "2026-03-18T15:30:00.000Z", - updatedAt: "2026-03-18T15:30:00.000Z" - }; - fs.writeFileSync(path.join(jobsDir, "task-queued.json"), `${JSON.stringify(job, null, 2)}\n`, "utf8"); - fs.writeFileSync( - path.join(stateDir, "state.json"), - `${JSON.stringify({ version: 1, config: { stopReviewGate: false }, jobs: [job] }, null, 2)}\n`, - "utf8" - ); - - const cancelled = run(process.execPath, [SCRIPT, "cancel", "task-queued", "--json"], { cwd: repo, env: process.env }); - assert.equal(cancelled.status, 0, cancelled.stderr); - assert.equal(JSON.parse(cancelled.stdout).status, "cancelled"); - - assert.equal(fs.existsSync(requestFile), false, "the private payload must not outlive the cancelled job"); - const stored = readJobRecord(repo, "task-queued"); - assert.equal(stored.status, "cancelled"); - assert.equal(stored.requestFile, null); - assert.equal(fs.readFileSync(path.join(stateDir, "state.json"), "utf8").includes(secret), false); -}); - -test("task fails fast when Codex sends a terminal error notification (#698)", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "error-notification"); - const result = run(process.execPath, [SCRIPT, "task", "do the thing"], { - cwd: repo, - env: buildEnv(binDir), - timeout: 15000 - }); - assert.equal(result.error, undefined, "companion must not hang until the test timeout"); - assert.equal(result.status, 1, result.stderr); - assert.match(result.stdout, /Selected model is at capacity/); - assert.match(result.stderr, /Codex error: Selected model is at capacity/); -}); - -test("task keeps running through an error notification that Codex will retry", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "error-notification-retry"); - const result = run(process.execPath, [SCRIPT, "task", "--json", "do the thing"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); - assert.equal(result.status, 0, result.stderr); - assert.match(JSON.parse(result.stdout).rawOutput, /./); - const stored = readJobRecord(repo); - assert.equal(stored.status, "completed"); - assert.equal(stored.errorMessage, null); -}); - -test("task survives fileChange started items that omit changes (#775)", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "file-change-no-changes"); - const result = run(process.execPath, [SCRIPT, "task", "--json", "edit"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); - assert.equal(result.status, 0, result.stderr); - assert.doesNotMatch(result.stderr, /Cannot read properties of undefined/); -}); - -test("a server-side turn failure that terminates normally still records an errorMessage (#757)", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-failed-silently"); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "do the thing"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(launched.status, 0, launched.stderr); - const jobId = JSON.parse(launched.stdout).jobId; - const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(done.status, 0, done.stderr); - const status = run(process.execPath, [SCRIPT, "status", jobId], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(status.status, 0, status.stderr); - assert.match(status.stdout, /\| failed \|/); - assert.doesNotMatch(status.stdout, /Summary: \{$/m); - assert.match(status.stdout, /Codex turn ended with status "failed"/); -}); - -// The final answer arriving well before the terminal notification (a slow relay, -// a loaded CI host) must not be mistaken for the turn having completed. -test("a failed turn whose turn/completed arrives late is still recorded as failed", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-failed-silently"); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "800" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "do the thing"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const jobId = JSON.parse(launched.stdout).jobId; - const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env }); - assert.equal(done.status, 0, done.stderr); - assert.equal(JSON.parse(done.stdout).job.status, "failed"); - assert.match(JSON.parse(done.stdout).job.errorMessage ?? "", /Codex turn ended with status "failed"/); -}); - -// The server dying after the final answer but before turn/completed must still -// end the turn: as failed, with the captured output, on both transports. -test("a transport that exits after the final answer ends a direct turn as failed", () => { - const repo = seededRepo(); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(seeded.status, 0, seeded.stderr); - - installFakeCodex(binDir, "turn-failed-silently"); - const env = buildEnv(binDir, { FAKE_CODEX_EXIT_AFTER_FINAL_ANSWER: "1" }); - const started = Date.now(); - const result = run(process.execPath, [SCRIPT, "task", "--resume-last", "--turn-timeout-ms", "20000", "--json", "do the thing"], { cwd: repo, env, timeout: 40000 }); - assert.equal(result.error, undefined); - assert.equal(result.status, 1, result.stderr); - assert.ok(Date.now() - started < 15000, "the dead transport must end the turn long before the turn timeout"); - const status = run(process.execPath, [SCRIPT, "status", "--json"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(status.status, 0, status.stderr); - const latest = JSON.parse(status.stdout).latestFinished; - assert.equal(latest.status, "failed"); - assert.match(latest.errorMessage ?? "", /exited|closed/i); -}); - -test("a transport that exits after the final answer ends a brokered background job as failed", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-failed-silently"); - const env = buildEnv(binDir, { FAKE_CODEX_EXIT_AFTER_FINAL_ANSWER: "1" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "do the thing"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const jobId = JSON.parse(launched.stdout).jobId; - const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000", "--json"], { cwd: repo, env, timeout: 40000 }); - assert.equal(done.status, 0, done.stderr); - const job = JSON.parse(done.stdout).job; - assert.equal(job.status, "failed"); - assert.match(job.errorMessage ?? "", /exited|closed/i); -}); - -test("a subagent's terminal error does not fail the main turn", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "subagent-error"); - const result = run(process.execPath, [SCRIPT, "task", "challenge the design"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); - assert.equal(result.error, undefined); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stderr, /subagent at capacity/); - const stored = readJobRecord(repo); - assert.equal(stored.status, "completed"); - assert.equal(stored.errorMessage, null); -}); - -test("task completes when the turn/start response carries no turn id (#781)", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-start-without-id"); - const result = run(process.execPath, [SCRIPT, "task", "--json", "hello"], { cwd: repo, env: buildEnv(binDir), timeout: 15000 }); - assert.equal(result.error, undefined, "must not hang"); - assert.equal(result.status, 0, result.stderr); - assert.match(JSON.parse(result.stdout).rawOutput, /./); -}); - -// Without an id in the turn/start response, turn/started is what names the turn: -// the timeout path needs it to send turn/interrupt at all. -test("a timed-out turn whose turn/start carried no id is still interrupted (#781)", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir, "turn-start-without-id"); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "5000" }); - const result = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "500", "--json", "stall please"], { cwd: repo, env, timeout: 15000 }); - assert.equal(result.error, undefined, "must not hang"); - assert.equal(result.status, 1, result.stderr); - const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.ok(fakeState.lastInterrupt?.turnId, "the turn named by turn/started must be interrupted"); - assert.equal(readJobRecord(repo).turnId, fakeState.lastInterrupt.turnId); -}); - -test("status --wait reports a timeout in text output and exits 1 while the job is still active (#774)", () => { - const repo = makeTempDir(); - initGitRepo(repo); - const binDir = makeTempDir(); - installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "4000" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "slow"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const jobId = JSON.parse(launched.stdout).jobId; - const status = run(process.execPath, [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "500"], { cwd: repo, env }); - assert.equal(status.status, 1); - assert.match(status.stdout, /Timed out after 1s while the job was still running\./); - const done = run(process.execPath, [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); - assert.equal(done.status, 0, done.stderr); -}); - -test("cancel on Windows kills a direct worker and the codex.cmd tree under it", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { - const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(seeded.status, 0, seeded.stderr); - // A cold resume owns its own app-server, so the tree hangs under the worker, not the broker. - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "hold"], { cwd: repo, env }); - assert.equal(launched.status, 0, launched.stderr); - const jobId = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); - t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - const running = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.pidIdentity && j.threadId && j.turnId ? j : null; }); - assert.match(running.pidIdentity, /^win32:\d+$/); - assert.equal(running.transport, "direct"); - const tree = cimTree(running.pid); - t.after(() => { for (const { pid } of tree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); - assert.ok(tree.some((n) => /^cmd\.exe$/i.test(n.name)) && tree.filter((n) => /^node\.exe$/i.test(n.name)).length >= 2, `expected worker → cmd.exe → node.exe, got ${JSON.stringify(tree)}`); - const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.equal(cancel.status, 0, cancel.stderr); - assert.equal(JSON.parse(cancel.stdout).turnInterruptAttempted, false, "a direct job's app-server is the worker's own"); - await waitFor(() => (tree.every((n) => !isAlive(n.pid)) ? "gone" : null)); - assert.equal(readJobRecord(repo, jobId).status, "cancelled"); -}); - -test("a brokered cancel on Windows kills nothing until the turn ends, leaves the shared broker and its subtree alive, and the same app-server serves the next job", { skip: !IS_WIN, timeout: 180_000 }, async (t) => { - const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); - const fakeStatePath = path.join(binDir, "fake-codex-state.json"); - // The broker's app-server ignores only the first interrupt: the first cancel must stay pending, the second ends the turn; the "quick C" timeout interrupt is the third and is honoured. - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_FIRST_INTERRUPTS: "1", CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "60000" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold A"], { cwd: repo, env }); - const jobA = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readJobRecord(repo, jobA); return j.pid ? j : null; }); - t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - const running = await waitFor(() => { const j = readJobRecord(repo, jobA); return j.status === "running" && j.turnId ? j : null; }); - assert.equal(running.transport, "broker", jobDiagnostics(repo, jobA)); - const broker = loadBrokerSession(repo); - assert.ok(broker?.pid, "worker A started the shared broker"); - t.after(() => { try { process.kill(broker.pid, "SIGKILL"); } catch {} }); - const brokerTree = cimTree(broker.pid); - t.after(() => { for (const { pid } of brokerTree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); - assert.ok(brokerTree.some((n) => /^cmd\.exe$/i.test(n.name)), `expected the app-server tree under the broker, got ${JSON.stringify(brokerTree)}`); - assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); - // The broker is A's child by ParentProcessId on Windows; the kill must skip its whole subtree. - const pending = run(process.execPath, [SCRIPT, "cancel", jobA, "--json"], { cwd: repo, env }); - assert.equal(pending.status, 1, `cancel said: ${pending.stdout.trim()}\n${jobDiagnostics(repo, jobA)}`); - assert.deepEqual(JSON.parse(pending.stdout), { jobId: jobA, status: "running", cancellationPending: true, reason: "turn-not-interrupted" }); - assert.equal(isAlive(withPid.pid), true, "no kill while the turn still runs in the broker"); - assert.equal(readJobRecord(repo, jobA).status, "running", jobDiagnostics(repo, jobA)); - assert.equal(isAlive(broker.pid), true, "the shared broker is untouched by a pending cancel"); - assert.ok(brokerTree.every((n) => isAlive(n.pid)), "and so is its subtree"); - assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); - const cancel = run(process.execPath, [SCRIPT, "cancel", jobA, "--json"], { cwd: repo, env }); - assert.equal(cancel.status, 0, `${cancel.stderr}\n${jobDiagnostics(repo, jobA)}`); - assert.equal(JSON.parse(cancel.stdout).status, "cancelled", jobDiagnostics(repo, jobA)); - await waitFor(() => (!isAlive(withPid.pid) ? "gone" : null)); - assert.equal(isAlive(broker.pid), true, "the shared broker survives a worker kill"); - assert.ok(brokerTree.every((n) => isAlive(n.pid)), "the broker's subtree survives"); - // The same app-server still serves: the fake holds every turn 60 s, so bound the next job by the turn timeout - // and prove it went through the existing app-server (no second start) rather than a direct fallback. - const next = run(process.execPath, [SCRIPT, "task", "--turn-timeout-ms", "3000", "--json", "quick C"], { cwd: repo, env, timeout: 60000 }); - assert.equal(next.error, undefined); - assert.equal(next.status, 1, next.stderr); - // The foreground JSON carries only the payload; the stored job carries the outcome. - const jobC = readJobRecord(repo); - assert.notEqual(jobC.id, jobA); - assert.match(jobC.errorMessage ?? "", /turn timed out after 3000 ms/, "the next job really ran a turn (and hit its budget)"); - const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); - assert.equal(fakeState.appServerStarts, 1, "no second app-server was started for the next job"); - assert.equal(fakeState.lastTurnStart?.prompt, "quick C", "the turn went through the existing app-server"); - assert.equal(loadBrokerSession(repo)?.pid, broker.pid, "no replacement broker was started"); -}); - -test("a root that died before cancel is failed by the reaper on Windows; nothing is signalled by number", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { - const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(seeded.status, 0, seeded.stderr); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--resume-last", "--json", "hold"], { cwd: repo, env }); - const jobId = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); - t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.turnId ? j : null; }); - const tree = cimTree(withPid.pid); - t.after(() => { for (const { pid } of tree) { try { process.kill(pid, "SIGKILL"); } catch {} } }); - process.kill(withPid.pid, "SIGKILL"); - await waitFor(() => (!isAlive(withPid.pid) ? "dead" : null)); - const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.notEqual(cancel.status, 0, "the reaper already failed the job; cancel has nothing active to signal"); - assert.equal(readJobRecord(repo, jobId).status, "failed"); - // The orphaned children are the documented limitation here: nothing is touched by number. - assert.ok(tree.filter((n) => n.pid !== withPid.pid).some((n) => isAlive(n.pid))); -}); - -test("a reused-looking identity is never signalled on Windows: the reaper fails the job and cancel reports it", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { - const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); - const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "60000", FAKE_CODEX_IGNORE_INTERRUPT: "1" }); - const launched = run(process.execPath, [SCRIPT, "task", "--background", "--json", "hold"], { cwd: repo, env }); - const jobId = JSON.parse(launched.stdout).jobId; - const withPid = await waitFor(() => { const j = readJobRecord(repo, jobId); return j.pid ? j : null; }); - t.after(() => { try { process.kill(withPid.pid, "SIGKILL"); } catch {} }); - await waitFor(() => { const j = readJobRecord(repo, jobId); return j.status === "running" && j.pidIdentity ? j : null; }); - upsertJob(repo, { id: jobId, pidIdentity: "win32:1" }); - const jobFile = path.join(resolveStateDir(repo), "jobs", `${jobId}.json`); - fs.writeFileSync(jobFile, JSON.stringify({ ...JSON.parse(fs.readFileSync(jobFile, "utf8")), pidIdentity: "win32:1" })); - const cancel = run(process.execPath, [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); - assert.notEqual(cancel.status, 0); - assert.equal(isAlive(withPid.pid), true, "the process holding the pid is a stranger to this record and must stay"); - const stored = readJobRecord(repo, jobId); - assert.equal(stored.status, "failed"); - assert.match(stored.errorMessage ?? "", /pid reused/); -}); - -test("SessionEnd tears down a broker that acknowledged shutdown but stayed up, by its recorded identity", { skip: !IS_WIN, timeout: 90_000 }, async (t) => { - const repo = seededRepo(); const binDir = makeTempDir(); installFakeCodex(binDir); - const env = buildEnv(binDir, { CODEX_COMPANION_SESSION_ID: "sess-win", CODEX_COMPANION_BROKER_HANG_ON_SHUTDOWN: "1", CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "60000" }); - const seeded = run(process.execPath, [SCRIPT, "task", "initial task"], { cwd: repo, env }); - assert.equal(seeded.status, 0, seeded.stderr); - const broker = await waitFor(() => loadBrokerSession(repo)); - t.after(() => { try { process.kill(broker.pid, "SIGKILL"); } catch {} }); - assert.match(broker.pidIdentity ?? "", /^win32:\d+$/); - const cleanup = run(process.execPath, [SESSION_HOOK, "SessionEnd"], { cwd: repo, env, input: JSON.stringify({ hook_event_name: "SessionEnd", cwd: repo, session_id: "sess-win" }) }); - assert.equal(cleanup.status, 0, cleanup.stderr); - assert.match(cleanup.stderr, /Broker teardown: .*signalled=true reason=identity-match/); - await waitFor(() => (!isAlive(broker.pid) ? "gone" : null)); - assert.equal(loadBrokerSession(repo), null); -}); - -test("a planted PowerShell in the workspace or a relative PATH entry is never what the identity probe runs", { skip: !IS_WIN, timeout: 90_000 }, () => { - const repo = makeTempDir(); fs.mkdirSync(path.join(repo, "tools")); - const marker = path.join(repo, "HIJACKED"); - fs.copyFileSync(path.join(process.env.SystemRoot, "System32", "cmd.exe"), path.join(repo, "powershell.exe")); - for (const planted of ["powershell.cmd", path.join("tools", "powershell.cmd"), path.join("tools", "powershell.exe.cmd")]) { - fs.writeFileSync(path.join(repo, planted), `@echo off\r\necho x> "${marker}"\r\n`); - } - const testEnvUrl = pathToFileURL(path.join(ROOT, "tests", "test-env.mjs")).href; - const processUrl = pathToFileURL(path.join(ROOT, "plugins", "codex", "scripts", "lib", "process.mjs")).href; - const probe = run(process.execPath, ["--import", testEnvUrl, "-e", `import(${JSON.stringify(processUrl)}).then(m => console.log(m.getProcessIdentity(process.pid) ?? 'null'))`], { - cwd: repo, env: { ...process.env, PATH: `.;tools;${process.env.PATH}`, PSModulePath: path.join(repo, "tools") } - }); - assert.match(probe.stdout.trim(), /^win32:\d+$/, probe.stderr); - assert.equal(fs.existsSync(marker), false); -}); From f0926185ac53b62566a562f816de44f16d445130 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:53:33 +0300 Subject: [PATCH 07/11] test: module-boundaries guard for plugins/codex/scripts Co-Authored-By: Claude Fable 5.1 --- tests/module-boundaries.test.mjs | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/module-boundaries.test.mjs diff --git a/tests/module-boundaries.test.mjs b/tests/module-boundaries.test.mjs new file mode 100644 index 000000000..277f5070c --- /dev/null +++ b/tests/module-boundaries.test.mjs @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { PLUGIN_ROOT } from "./helpers.mjs"; + +const SCRIPTS = path.join(PLUGIN_ROOT, "scripts"); +const LIB = path.join(SCRIPTS, "lib"); +// Leaves may import nothing from lib except the zero-import job-status.mjs (render needs it). +const LEAVES = ["args", "broker-endpoint", "fs", "hook-input", "job-status", "process", "prompts", "render"]; +const ENTRY_ALLOW = { + "app-server-broker.mjs": ["args", "app-server", "broker-endpoint", "broker-lifecycle"], + "session-lifecycle-hook.mjs": ["process", "app-server", "broker-lifecycle", "state", "job-control", "tracked-jobs", "claude-session-transfer", "workspace", "hook-input", "job-status"], + "stop-review-gate-hook.mjs": ["codex", "hook-input", "prompts", "state", "job-control", "tracked-jobs", "workspace", "job-status"] +}; + +// Static import statements only; JSDoc `import("./x")` types do not count. +function imports(file) { + const src = fs.readFileSync(file, "utf8"); + return [...src.matchAll(/^(?:import\b(?:[^;]*? from)?|\})\s*"(\.[^"]+)";/gm)].map((m) => m[1]); +} +const libName = (spec) => path.basename(spec).replace(/\.mjs$/, ""); + +test("lib modules import only siblings, never ../", () => { + for (const f of fs.readdirSync(LIB).filter((n) => n.endsWith(".mjs"))) { + for (const spec of imports(path.join(LIB, f))) assert.ok(spec.startsWith("./"), `${f} imports ${spec}`); + } +}); + +test("leaf modules import nothing from lib (render: job-status only)", () => { + for (const name of LEAVES) { + const libImports = imports(path.join(LIB, `${name}.mjs`)).map(libName); + assert.deepEqual(libImports, name === "render" ? ["job-status"] : [], `${name}.mjs`); + } +}); + +test("hooks and the broker stay within their import allow-list", () => { + for (const [file, allowed] of Object.entries(ENTRY_ALLOW)) { + const actual = imports(path.join(SCRIPTS, file)).map(libName); + assert.deepEqual(actual.filter((n) => !allowed.includes(n)), [], `${file} imports outside its allow-list`); + } +}); + +test("job-control reaches codex.mjs for getSessionRuntimeStatus only", () => { + const src = fs.readFileSync(path.join(LIB, "job-control.mjs"), "utf8"); + assert.equal((src.match(/^import \{ getSessionRuntimeStatus \} from "\.\/codex\.mjs";$/m) ?? []).length, 1); + assert.equal((src.match(/from "\.\/codex\.mjs"/g) ?? []).length, 1); +}); From f3fe63c90855aae0195edbfae375854f7be40517 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:59:32 +0300 Subject: [PATCH 08/11] chore(lint): no-unused-vars on; drop the stale .claude ignore Co-Authored-By: Claude Fable 5.1 --- eslint.config.mjs | 3 +-- plugins/codex/scripts/codex-companion.mjs | 3 --- plugins/codex/scripts/lib/broker-lifecycle.mjs | 2 +- plugins/codex/scripts/lib/model-catalog.mjs | 1 - plugins/codex/scripts/lib/render.mjs | 2 +- plugins/codex/scripts/stop-review-gate-hook.mjs | 1 - tests/model-catalog.test.mjs | 2 +- tests/state.test.mjs | 2 -- 8 files changed, 4 insertions(+), 12 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index bc3b726a0..0e583fad1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,7 +4,6 @@ import globals from "globals"; export default [ { ignores: [ - ".claude/**", ".githooks/**", ".stryker-tmp/**", ".worktrees/**", @@ -27,7 +26,7 @@ export default [ }, rules: { "no-empty": "off", - "no-unused-vars": "off", + "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrors: "none" }], "no-useless-assignment": "off", "no-useless-escape": "off", "preserve-caught-error": "off", diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 622f6f5f8..44f9a981b 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -37,7 +37,6 @@ import { nowIso, readStoredJob, recordWorkerPid, - removeJobPidFile, redactConfigValues, removeJobRequestFile, resolveJobPid, @@ -1150,7 +1149,6 @@ async function handleTaskWorker(argv) { throw new Error("Missing required --job-id for task-worker."); } - const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); const storedJob = readStoredJob(workspaceRoot, options["job-id"]); if (!storedJob) { @@ -1269,7 +1267,6 @@ function handleTaskResumeCandidate(argv) { return; } - const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); const sessionId = getCurrentSessionId(); const jobs = filterJobsForSession(sortJobsNewestFirst(reapDeadJobs(workspaceRoot, listJobs(workspaceRoot)))); diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index 4649e09c3..567c2259d 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -390,7 +390,7 @@ async function ensureBrokerSessionLocked(cwd, options, attempt) { } identity = post; } - const { replacer, replacingAt, ...rest } = current; + const { replacer: _replacer, replacingAt: _replacingAt, ...rest } = current; const promoted = { ...rest, state: "ready", pidIdentity: identity }; return writeBrokerSessionIf(cwd, (latest) => sameClaim(latest, current), promoted) ? promoted : await retryClaim("the broker record kept changing"); }; diff --git a/plugins/codex/scripts/lib/model-catalog.mjs b/plugins/codex/scripts/lib/model-catalog.mjs index 20decf933..ef3f0c4c7 100644 --- a/plugins/codex/scripts/lib/model-catalog.mjs +++ b/plugins/codex/scripts/lib/model-catalog.mjs @@ -1,4 +1,3 @@ -import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 5054bc3bc..89a5eaa44 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -319,7 +319,7 @@ export function renderNativeReviewResult(result, meta) { return `${lines.join("\n").trimEnd()}\n`; } -export function renderTaskResult(parsedResult, meta) { +export function renderTaskResult(parsedResult, _meta) { const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : ""; if (rawOutput) { return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 604f3bc89..95e36c2d0 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -24,7 +24,6 @@ const ESCAPE_HATCH = "Disable with /codex:setup --disable-review-gate."; const MANUAL_HINT = `Run /codex:review --wait manually. ${ESCAPE_HATCH}`; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(SCRIPT_DIR, ".."); -const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; const GATE_ROUNDS_CONFIG_KEY = "stopReviewGateRoundsBySession"; function emitDecision(payload) { diff --git a/tests/model-catalog.test.mjs b/tests/model-catalog.test.mjs index b30a91470..97015ec31 100644 --- a/tests/model-catalog.test.mjs +++ b/tests/model-catalog.test.mjs @@ -71,7 +71,7 @@ test("loadModelCatalog falls back from the env catalogue to models_cache.json, t writeCatalog(dir, "models_cache.json", { models: [{ slug: "gpt-cache" }] }); const empty = writeCatalog(dir, "empty.json", { models: [] }); const calls = []; - const bundled = (cmd, args, opts) => { + const bundled = (cmd, args) => { calls.push([cmd, args]); return { status: 0, stdout: JSON.stringify({ models: [{ slug: "gpt-bundled" }] }), error: null }; }; diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 92f3db618..82f9ad025 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -21,7 +21,6 @@ import { retryOnWindows, saveState, STATE_LOCK_TIMEOUT_CODE, - upsertJob, withStateLock, writeJobRequestFile } from "../plugins/codex/scripts/lib/state.mjs"; @@ -112,7 +111,6 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", }); const prunedJobFile = resolveJobFile(workspace, "job-0"); - const prunedLogFile = resolveJobLogFile(workspace, "job-0"); const retainedJobFile = resolveJobFile(workspace, "job-50"); const retainedLogFile = resolveJobLogFile(workspace, "job-50"); const jobsDir = path.dirname(prunedJobFile); From 5b8663eb49139f922264f80e0a8d72512ba0b118 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:15:49 +0300 Subject: [PATCH 09/11] chore: v1.4.3 review nits (import order, generic waitForExit message, simpler import regex); CHANGELOG 1.4.3 Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 ++++++ docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md | 2 +- plugins/codex/CHANGELOG.md | 6 ++++++ plugins/codex/scripts/lib/codex.mjs | 2 +- plugins/codex/scripts/lib/job-control.mjs | 2 +- tests/helpers.mjs | 2 +- tests/module-boundaries.test.mjs | 2 +- 7 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45363650d..9ae92628d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.4.3 — 2026-09-30 + +### Internal +- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the session filter, `buildJsonRpcError`); job status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. +- Tests: shared harness helpers in `tests/helpers.mjs`; `tests/runtime.test.mjs` split by command family (`runtime-setup`, `-review`, `-task`, `-status`, `-cancel`, `-transfer`, `-hooks`), which roughly halves the local suite's wall time; new `module-boundaries` and `job-status` unit tests; eslint `no-unused-vars` enabled (unused imports and locals removed, no logic changes). + ## 1.4.2 — 2026-09-30 ### Fixed diff --git a/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md b/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md index d7e96b4ed..d28077f9e 100644 --- a/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md +++ b/docs/superpowers/plans/2026-09-30-codex-plugin-cc-v1.4.3.md @@ -73,7 +73,7 @@ export function readStoredJob(cwd, jobId) { **Files:** Create `plugins/codex/scripts/lib/job-status.mjs`, `tests/job-status.test.mjs`. Modify `lib/state.mjs`, `lib/tracked-jobs.mjs`, `lib/job-control.mjs`, `lib/codex.mjs`, `lib/render.mjs`, `session-lifecycle-hook.mjs`, `stop-review-gate-hook.mjs`, `codex-companion.mjs`. -Count on 1f91ec2: `rg -n 'status\s*(===|!==)\s*"(queued|running)"' plugins/codex/scripts` → 33 lines (companion L451–452 is one comparison): tracked-jobs 7, state 4, codex 1, render 6, session hook 2, job-control 7, stop hook 1, companion 4 (+ the private `isActiveJobStatus` L429–431, `isActiveStatus` tracked-jobs L165–167, `isTerminalRecord` job-control L326–328). Phase strings (`"queued"` as a phase, `status: "running"` literals in records, comments, tests) stay. +Count on 1f91ec2: `rg -n 'status\s*(===|!==)\s*"(queued|running)"' plugins/codex/scripts` → 32 comparisons on 33 lines (companion L451–452 is one comparison): tracked-jobs 7, state 4, codex 1, render 6, session hook 2, job-control 7, stop hook 1, companion 4 (+ the private `isActiveJobStatus` L429–431, `isActiveStatus` tracked-jobs L165–167, `isTerminalRecord` job-control L326–328). Phase strings (`"queued"` as a phase, `status: "running"` literals in records, comments, tests) stay. - [ ] **Step 1: Create `plugins/codex/scripts/lib/job-status.mjs`** (zero imports): diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 45363650d..9ae92628d 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.4.3 — 2026-09-30 + +### Internal +- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the session filter, `buildJsonRpcError`); job status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. +- Tests: shared harness helpers in `tests/helpers.mjs`; `tests/runtime.test.mjs` split by command family (`runtime-setup`, `-review`, `-task`, `-status`, `-cancel`, `-transfer`, `-hooks`), which roughly halves the local suite's wall time; new `module-boundaries` and `job-status` unit tests; eslint `no-unused-vars` enabled (unused imports and locals removed, no logic changes). + ## 1.4.2 — 2026-09-30 ### Fixed diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 3b6e03ff0..c2e79e560 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -48,9 +48,9 @@ import { readJsonFile } from "./fs.mjs"; import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs"; import { loadBrokerSession } from "./broker-lifecycle.mjs"; import { binaryAvailable } from "./process.mjs"; +import { isActiveJobStatus } from "./job-status.mjs"; import { looksLikeVerificationCommand, shorten } from "./render.mjs"; import { listJobs } from "./state.mjs"; -import { isActiveJobStatus } from "./job-status.mjs"; import { reapDeadJobs } from "./tracked-jobs.mjs"; const SERVICE_NAME = "claude_code_codex_plugin"; diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index 9309da879..6cf83c0ea 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -3,9 +3,9 @@ import fs from "node:fs"; import { BROKER_ENDPOINT_ENV } from "./app-server.mjs"; import { loadBrokerSession, resolveBrokerStateFile } from "./broker-lifecycle.mjs"; import { getSessionRuntimeStatus } from "./codex.mjs"; -import { getConfig, listJobs, readStoredJob, removeJobPidFile, removeJobRequestFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "./job-status.mjs"; import { looksLikeVerificationCommand } from "./render.mjs"; +import { getConfig, listJobs, readStoredJob, removeJobPidFile, removeJobRequestFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; import { DEAD_WORKER_MESSAGE, filterJobsForSession, getCurrentSessionId, reapDeadJobs } from "./tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; diff --git a/tests/helpers.mjs b/tests/helpers.mjs index f66a7a744..4fb48a7a6 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -118,7 +118,7 @@ export function waitForExit(child, { timeoutMs = 10000 } = {}) { } const timer = setTimeout(() => { child.removeListener("exit", onExit); - reject(new Error("Timed out waiting for broker process to exit.")); + reject(new Error("Timed out waiting for the process to exit.")); }, timeoutMs); function onExit(code, signal) { clearTimeout(timer); diff --git a/tests/module-boundaries.test.mjs b/tests/module-boundaries.test.mjs index 277f5070c..7daa26fed 100644 --- a/tests/module-boundaries.test.mjs +++ b/tests/module-boundaries.test.mjs @@ -18,7 +18,7 @@ const ENTRY_ALLOW = { // Static import statements only; JSDoc `import("./x")` types do not count. function imports(file) { const src = fs.readFileSync(file, "utf8"); - return [...src.matchAll(/^(?:import\b(?:[^;]*? from)?|\})\s*"(\.[^"]+)";/gm)].map((m) => m[1]); + return [...src.matchAll(/^import\b(?:[^;]*? from)?\s*"(\.[^"]+)";/gm)].map((m) => m[1]); } const libName = (spec) => path.basename(spec).replace(/\.mjs$/, ""); From 31c5b1d15a3f0ab26b280758d7bff69db174f8cd Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:15:50 +0300 Subject: [PATCH 10/11] release: bump version to 1.4.3 Co-Authored-By: Claude Fable 5.1 --- .claude-plugin/marketplace.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 22c9349d8..113dba607 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "CBEPX fork of the OpenAI Codex plugin for Claude Code: max/ultra effort, per-thread config overrides, gpt-5.6 aliases, rescue agent fixes.", - "version": "1.4.2" + "version": "1.4.3" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.4.2", + "version": "1.4.3", "author": { "name": "OpenAI" }, diff --git a/package-lock.json b/package-lock.json index b7a5e4bc0..6458f9eb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cbepx/codex-plugin-cc", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cbepx/codex-plugin-cc", - "version": "1.4.2", + "version": "1.4.3", "license": "Apache-2.0", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 1b6e19092..75ce527ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cbepx/codex-plugin-cc", - "version": "1.4.2", + "version": "1.4.3", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 447c284cd..a0c2f65b3 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.4.2", + "version": "1.4.3", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" From 9d2d4ac2a5ded3c3468da5cb7202e872093a458c Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:35:24 +0300 Subject: [PATCH 11/11] =?UTF-8?q?chore:=20v1.4.3=20final=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20half-applied=20JOB=5FSTATUS=20enum,=20?= =?UTF-8?q?one=20readStoredJob,=20SESSION=5FID=5FENV=20imported,=20unused?= =?UTF-8?q?=20renderTaskResult=20param=20removed;=20tests=20for=20shorten,?= =?UTF-8?q?=20looksLikeVerificationCommand,=20filterJobsForSession,=20job-?= =?UTF-8?q?status=20rows,=20boundary=20self-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- plugins/codex/CHANGELOG.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 17 ++++--------- plugins/codex/scripts/lib/job-control.mjs | 4 ++-- plugins/codex/scripts/lib/job-status.mjs | 12 ++-------- plugins/codex/scripts/lib/render.mjs | 2 +- plugins/codex/scripts/lib/state.mjs | 8 +++---- plugins/codex/scripts/lib/tracked-jobs.mjs | 7 +++--- .../codex/scripts/session-lifecycle-hook.mjs | 3 +-- tests/job-control.test.mjs | 14 ++++++++++- tests/job-status.test.mjs | 24 +++++++++++-------- tests/module-boundaries.test.mjs | 1 + tests/render.test.mjs | 15 +++++++++++- 13 files changed, 62 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae92628d..e22673c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.4.3 — 2026-09-30 ### Internal -- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the session filter, `buildJsonRpcError`); job status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. +- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the env-based session filter, `buildJsonRpcError`); the queued/running status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. - Tests: shared harness helpers in `tests/helpers.mjs`; `tests/runtime.test.mjs` split by command family (`runtime-setup`, `-review`, `-task`, `-status`, `-cancel`, `-transfer`, `-hooks`), which roughly halves the local suite's wall time; new `module-boundaries` and `job-status` unit tests; eslint `no-unused-vars` enabled (unused imports and locals removed, no logic changes). ## 1.4.2 — 2026-09-30 diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 9ae92628d..e22673c62 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.4.3 — 2026-09-30 ### Internal -- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the session filter, `buildJsonRpcError`); job status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. +- No behaviour change. Duplicate helpers in `plugins/codex/scripts` consolidated (`nowIso`, `readStoredJob`, `readJsonOrNull`, `shorten`, `looksLikeVerificationCommand`, the pending-cancel text, the env-based session filter, `buildJsonRpcError`); the queued/running status comparisons go through `lib/job-status.mjs`; dead `fs.mjs` exports removed. - Tests: shared harness helpers in `tests/helpers.mjs`; `tests/runtime.test.mjs` split by command family (`runtime-setup`, `-review`, `-task`, `-status`, `-cancel`, `-transfer`, `-hooks`), which roughly halves the local suite's wall time; new `module-boundaries` and `job-status` unit tests; eslint `no-unused-vars` enabled (unused imports and locals removed, no logic changes). ## 1.4.2 — 2026-09-30 diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 44f9a981b..ce1d702d4 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -683,18 +683,11 @@ async function executeTaskRun(request) { const failureMessage = result.error?.message ?? (result.status !== 0 ? (result.stderr || `Codex turn ended with status "${turnStatus ?? "failed"}"`) : ""); - const rendered = renderTaskResult( - { - rawOutput, - failureMessage, - reasoningSummary: result.reasoningSummary - }, - { - title: taskMetadata.title, - jobId: request.jobId ?? null, - write: Boolean(request.write) - } - ); + const rendered = renderTaskResult({ + rawOutput, + failureMessage, + reasoningSummary: result.reasoningSummary + }); const payload = { status: result.status, threadId: result.threadId, diff --git a/plugins/codex/scripts/lib/job-control.mjs b/plugins/codex/scripts/lib/job-control.mjs index 6cf83c0ea..2da273e6e 100644 --- a/plugins/codex/scripts/lib/job-control.mjs +++ b/plugins/codex/scripts/lib/job-control.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import { BROKER_ENDPOINT_ENV } from "./app-server.mjs"; import { loadBrokerSession, resolveBrokerStateFile } from "./broker-lifecycle.mjs"; import { getSessionRuntimeStatus } from "./codex.mjs"; -import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "./job-status.mjs"; +import { isActiveJobStatus, isTerminalRecord } from "./job-status.mjs"; import { looksLikeVerificationCommand } from "./render.mjs"; import { getConfig, listJobs, readStoredJob, removeJobPidFile, removeJobRequestFile, upsertJob, withStateLock, writeJobFile } from "./state.mjs"; import { DEAD_WORKER_MESSAGE, filterJobsForSession, getCurrentSessionId, reapDeadJobs } from "./tracked-jobs.mjs"; @@ -150,7 +150,7 @@ export function enrichJob(job, options = {}) { ...job, kindLabel: getJobTypeLabel(job), progressPreview: - isActiveJobStatus(job.status) || job.status === JOB_STATUS.FAILED + isActiveJobStatus(job.status) || job.status === "failed" ? readJobProgressPreview(job.logFile, maxProgressLines) : [], elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null), diff --git a/plugins/codex/scripts/lib/job-status.mjs b/plugins/codex/scripts/lib/job-status.mjs index 904a32adc..7850f0ddd 100644 --- a/plugins/codex/scripts/lib/job-status.mjs +++ b/plugins/codex/scripts/lib/job-status.mjs @@ -1,14 +1,6 @@ -// Job status vocabulary. A job is active while a worker may still write it. -export const JOB_STATUS = Object.freeze({ - QUEUED: "queued", - RUNNING: "running", - COMPLETED: "completed", - FAILED: "failed", - CANCELLED: "cancelled" -}); - +// A job is active ("queued" or "running") while a worker may still write it. export function isActiveJobStatus(status) { - return status === JOB_STATUS.QUEUED || status === JOB_STATUS.RUNNING; + return status === "queued" || status === "running"; } // A stored record that no worker will write again; null/undefined is not terminal. diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 89a5eaa44..e10cbdb9d 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -319,7 +319,7 @@ export function renderNativeReviewResult(result, meta) { return `${lines.join("\n").trimEnd()}\n`; } -export function renderTaskResult(parsedResult, _meta) { +export function renderTaskResult(parsedResult) { const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : ""; if (rawOutput) { return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 0ec861e67..b9d6f37b5 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { readJsonOrNull } from "./fs.mjs"; -import { isActiveJobStatus, JOB_STATUS } from "./job-status.mjs"; +import { isActiveJobStatus } from "./job-status.mjs"; import { getProcessIdentity, isPidAlive } from "./process.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; @@ -721,7 +721,7 @@ export function updateJobPid(cwd, jobId, pid, identity = null) { return; } writeJobPidFile(cwd, jobId, pid, identity); - if (indexed.status === JOB_STATUS.QUEUED) { + if (indexed.status === "queued") { upsertJob(cwd, { id: jobId, pid, pidIdentity: identity }); } }); @@ -784,7 +784,7 @@ export function readJobFile(jobFile) { // `runTrackedJob` flips the status to `running`, so staging one for a // running job would write plaintext `--config` values that nothing reads // and nothing deletes. - const active = current.status === JOB_STATUS.QUEUED; + const active = current.status === "queued"; const migrated = withRedactedRequest(current); if (active && !fs.existsSync(requestFile)) { // Same shape and mode as `writeJobRequestFile`: the temp file carries the @@ -817,7 +817,7 @@ export function resolveJobFile(cwd, jobId) { return path.join(resolveJobsDir(cwd), `${jobId}.json`); } -// The job file, or null when the job has none yet (queued, never started). +// The job file, or null when none exists for the id. export function readStoredJob(cwd, jobId) { const jobFile = resolveJobFile(cwd, jobId); if (!fs.existsSync(jobFile)) { diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index e30e93451..1091ef616 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 { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "./job-status.mjs"; +import { isActiveJobStatus, isTerminalRecord } from "./job-status.mjs"; import { getProcessIdentities, getProcessIdentity, isPidAlive, processCommandLine } from "./process.mjs"; import { @@ -320,8 +320,7 @@ function markJobDead(workspaceRoot, jobSummary, errorMessage, lockWaitMs = undef } function markJobDeadLocked(workspaceRoot, jobSummary, errorMessage) { - const jobFile = resolveJobFile(workspaceRoot, jobSummary.id); - const stored = fs.existsSync(jobFile) ? readJobFile(jobFile) : null; + const stored = readStoredJob(workspaceRoot, jobSummary.id); const base = stored ?? jobSummary; if (!isActiveJobStatus(base.status)) { // The job finished between the caller's read and now — keep the real result, @@ -393,7 +392,7 @@ const QUEUED_WITHOUT_PID_GRACE_MS = 30000; // cannot tell that apart from a record that was written microseconds ago, so age // decides it. function isQueuedWithoutWorker(job, pid) { - if (job.status !== JOB_STATUS.QUEUED || pid != null) { + if (job.status !== "queued" || pid != null) { return false; } const createdAt = Date.parse(job.createdAt ?? ""); diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 174f851e6..34f6be5fd 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -16,13 +16,12 @@ import { } from "./lib/broker-lifecycle.mjs"; import { loadState, resolveJobPid, resolveStateFile, saveState, STATE_LOCK_TIMEOUT_CODE, withStateLock } from "./lib/state.mjs"; import { brokerExclusion, brokerPresence } from "./lib/job-control.mjs"; -import { reapDeadJobs } from "./lib/tracked-jobs.mjs"; +import { reapDeadJobs, SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; import { readHookInput } from "./lib/hook-input.mjs"; import { isActiveJobStatus } from "./lib/job-status.mjs"; -export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; // How long a `busy` broker is given to shed a client this hook has just reaped, // and how often to ask. Bounded: a broker that is really in use stays busy for the // whole window and keeps everything it owns. diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index ba585ae29..b18756ddd 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -5,7 +5,7 @@ import { makeTempDir } from "./helpers.mjs"; import { readJobFile, resolveJobFile, resolveStateDir, writeJobFile } from "../plugins/codex/scripts/lib/state.mjs"; import { BROKER_ENDPOINT_ENV } from "../plugins/codex/scripts/lib/app-server.mjs"; import { saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; -import { DEAD_WORKER_MESSAGE } from "../plugins/codex/scripts/lib/tracked-jobs.mjs"; +import { DEAD_WORKER_MESSAGE, filterJobsForSession, SESSION_ID_ENV } from "../plugins/codex/scripts/lib/tracked-jobs.mjs"; import assert from "node:assert/strict"; import { brokerExclusion, brokerPresence, cancelDecision, commitCancel, isWorkerProvedRecord, isWorkerTerminalRecord } from "../plugins/codex/scripts/lib/job-control.mjs"; @@ -230,3 +230,15 @@ test("commitCancel keeps a reaper-written failure even when this cancel's interr assert.equal(readJobFile(resolveJobFile(workspace, "task-1")).status, kept ? "failed" : "cancelled", label); } }); + +test("filterJobsForSession: keeps the current session's jobs, or all jobs without a session", () => { + const jobs = [{ sessionId: "a" }, { sessionId: "b" }]; + assert.deepEqual(filterJobsForSession(jobs, { [SESSION_ID_ENV]: "a" }), [{ sessionId: "a" }]); + const original = process.env[SESSION_ID_ENV]; + delete process.env[SESSION_ID_ENV]; + try { + assert.deepEqual(filterJobsForSession(jobs, {}), jobs); + } finally { + if (original !== undefined) process.env[SESSION_ID_ENV] = original; + } +}); diff --git a/tests/job-status.test.mjs b/tests/job-status.test.mjs index efec2dc42..37c301b32 100644 --- a/tests/job-status.test.mjs +++ b/tests/job-status.test.mjs @@ -1,21 +1,25 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { isActiveJobStatus, isTerminalRecord, JOB_STATUS } from "../plugins/codex/scripts/lib/job-status.mjs"; +import { isActiveJobStatus, isTerminalRecord } from "../plugins/codex/scripts/lib/job-status.mjs"; test("isActiveJobStatus: only queued and running are active", () => { - assert.deepEqual(Object.values(JOB_STATUS).map(isActiveJobStatus), [true, true, false, false, false]); - assert.equal(isActiveJobStatus(undefined), false); - assert.equal(isActiveJobStatus("done"), false); + assert.equal(isActiveJobStatus("queued"), true); + assert.equal(isActiveJobStatus("running"), true); + for (const status of ["completed", "failed", "cancelled", undefined, null, "done"]) { + assert.equal(isActiveJobStatus(status), false, String(status)); + } }); test("isTerminalRecord: a finished record is terminal, a missing or active one is not", () => { - assert.equal(isTerminalRecord({ status: "completed" }), true); - assert.equal(isTerminalRecord({ status: "running" }), false); + for (const status of ["completed", "failed", "cancelled"]) { + assert.equal(isTerminalRecord({ status }), true, status); + } + for (const status of ["queued", "running"]) { + assert.equal(isTerminalRecord({ status }), false, status); + } assert.equal(isTerminalRecord(null), false); assert.equal(isTerminalRecord(undefined), false); -}); - -test("JOB_STATUS is frozen", () => { - assert.ok(Object.isFrozen(JOB_STATUS)); + // A status-less record counts as terminal (the pre-existing inline semantics). + assert.equal(isTerminalRecord({}), true); }); diff --git a/tests/module-boundaries.test.mjs b/tests/module-boundaries.test.mjs index 7daa26fed..f8bc82411 100644 --- a/tests/module-boundaries.test.mjs +++ b/tests/module-boundaries.test.mjs @@ -23,6 +23,7 @@ function imports(file) { const libName = (spec) => path.basename(spec).replace(/\.mjs$/, ""); test("lib modules import only siblings, never ../", () => { + assert.ok(imports(path.join(LIB, "job-control.mjs")).length >= 8, "import parser found nothing"); for (const f of fs.readdirSync(LIB).filter((n) => n.endsWith(".mjs"))) { for (const spec of imports(path.join(LIB, f))) assert.ok(spec.startsWith("./"), `${f} imports ${spec}`); } diff --git a/tests/render.test.mjs b/tests/render.test.mjs index b9e3f1650..f7eb385af 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { renderJobStatusReport, renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { looksLikeVerificationCommand, renderJobStatusReport, renderReviewResult, renderStoredJobResult, shorten } from "../plugins/codex/scripts/lib/render.mjs"; test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( @@ -67,3 +67,16 @@ test("renderJobStatusReport prints Error only for failed jobs whose error adds t const completed = renderJobStatusReport({ ...base, status: "completed", summary: "Done", errorMessage: "stale" }); assert.doesNotMatch(completed, /Error:/); }); + +test("shorten: collapses whitespace, truncates with an ellipsis, tolerates null", () => { + const long = shorten("a".repeat(100), 96); + assert.equal(long.length, 96); + assert.ok(long.endsWith("...")); + assert.equal(shorten(" a \n b ", 96), "a b"); + assert.equal(shorten(null, 96), ""); +}); + +test("looksLikeVerificationCommand: test runners yes, plain shell no", () => { + for (const command of ["npm test", "pytest -q", "tsc"]) assert.equal(looksLikeVerificationCommand(command), true, command); + for (const command of ["git status", "ls -la"]) assert.equal(looksLikeVerificationCommand(command), false, command); +});