From bd7b36705062babcb52b5f3b77f39fe820b93371 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:29:58 +0300 Subject: [PATCH 01/36] docs(plan): v1.3.0 implementation plan (lifecycle correctness) Co-Authored-By: Claude Fable 5.1 --- .../2026-09-27-codex-plugin-cc-v1.3.0.md | 1311 +++++++++++++++++ 1 file changed, 1311 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-codex-plugin-cc-v1.3.0.md diff --git a/docs/superpowers/plans/2026-09-27-codex-plugin-cc-v1.3.0.md b/docs/superpowers/plans/2026-09-27-codex-plugin-cc-v1.3.0.md new file mode 100644 index 000000000..f5d1bad8c --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-codex-plugin-cc-v1.3.0.md @@ -0,0 +1,1311 @@ +# codex-plugin-cc v1.3.0 Implementation Plan + +> **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:** Закрыть класс «job висит в `running` навсегда» и «SessionEnd/teardown сигналит не тот процесс» на posix, дать stop-gate явную модель/усилие, сделать алиасы моделей data-driven и убрать два security-дефекта в state/broker.json. + +**Architecture:** Все изменения — в companion-runtime (`plugins/codex/scripts/**`), без новых зависимостей. Терминальность turn'а решается в одном месте (`applyTurnNotification`), идентичность процесса — в одном модуле (`lib/process.mjs`) с одной обёрткой `terminateRecordedProcess`, через которую проходят все kill-сайты. Каталог моделей — новый модуль `lib/model-catalog.mjs`, читающий `$CODEX_HOME/models_cache.json`; хардкод остаётся последним fallback. Windows-ветка identity — v1.4.0. + +**Tech Stack:** Node ≥18.18, ESM `.mjs`, `node --test`, fake Codex fixture (`tests/fake-codex-fixture.mjs`), `gh`. + +**Spec:** `/Users/g.mehrenin/.claude/plans/glistening-chasing-backus.md` (раздел «v1.3.0» + «Дизайн: process identity»). Upstream-референсы: #698/#710, #757/#763, #775, #781, #773, #774, #753/#762/#782, #768, #749, #743, #769, #548/#565, #589, #483/#573, #459, #721, #468/#703/#485/#128, #521, #609/#631/#683. + +## Global Constraints + +- Worktree `/Users/g.mehrenin/project/personal/codex-plugin-cc/.worktrees/release-v1.3.0`, ветка `release/v1.3.0` от `main` (858188f). `main` остаётся установленным плагином `codex@cbepx` — не редактировать его рабочее дерево. +- Гейт на задачу: `npm test > /tmp/npm-test.log 2>&1; st=$?; rg -e 'ℹ (tests|pass|fail)' -e '^not ok' /tmp/npm-test.log; test "$st" -eq 0` → `fail 0` (239 на базе); `sleep 10; pgrep -f codex-plugin-test- | wc -l` → 0; `npm run build`. +- Никаких `grep` — только `rg`. Никаких `git add -A`. Трейлер коммита: `Co-Authored-By: Claude Fable 5.1 `; для портированных upstream-PR дополнительно `Co-authored-by: ` (авторы — в `docs/superpowers/triage/2026-09-27-upstream-triage.md`, раздел «Authors to credit»). Не пушить без команды пользователя. +- Порт-механика: тест и намерение из upstream PR, реализация против кода форка. `git merge pr/N` не используется. +- Совместимость: записи v1.2.x без `pidIdentity`, bare-integer sidecar `jobs/.pid`, `broker.json` без identity — читаются и работают как раньше на posix. +- Порядок: Task 1–8 независимы и малы; Task 9 (identity) — последней; если не укладывается, релиз v1.3.0 без него, identity → v1.3.1. + +## Review Focus + +1. `error`-нотификация с `willRetry: true` посреди живого turn'а — turn обязан продолжаться, а не завершаться `failed` (тест в Task 1). +2. `turn/start` без `turn.id` + последующие нотификации с `turnId` — `turn/completed` не должен потеряться в буфере (тест в Task 2). +3. `ensureBrokerSession` при живом, но медленном broker (readiness-probe 150 ms не успел) — broker не убивается без повторной пробы 2 s (тест в Task 4). +4. `status --wait` с истёкшим таймаутом должен быть отличим от успеха и в тексте, и по exit-коду (тест в Task 3). +5. Fallback-каталог state в `os.tmpdir()`, уже созданный другим пользователем/с mode 0755 — использовать нельзя (тест в Task 8). + +--- + +### Task 1: Терминальные ошибки turn'а: `error` (без retry), `errorMessage` при не-бросающем провале, `fileChange` без `changes` + +**Files:** +- Modify: `plugins/codex/scripts/lib/codex.mjs` — `applyTurnNotification` (`case "error"`, ~591), `describeStartedItem` (~303) +- Modify: `plugins/codex/scripts/codex-companion.mjs` — сборка результата `task` (~688–697: `errorMessage`, `summary`) +- Modify: `tests/fake-codex-fixture.mjs` — новые `BEHAVIOR`: `error-notification`, `error-notification-retry`, `file-change-no-changes`, `turn-failed-silently` +- Test: `tests/runtime.test.mjs` + +**Interfaces:** +- Consumes: `completeTurn(state, turn)` (идемпотентен по `state.completed`), `emitProgress`, `buildResultStatus` (`finalTurn.status === "completed" ? 0 : 1`). +- Produces: turn с `error.willRetry !== true` завершается `finalTurn = { id, status: "failed", error }`; `result.error.message` заполнен; `task`-результат при `status !== 0` имеет `errorMessage` и `summary`, взятые из ошибки, а не из `rawOutput`. + +- [ ] **Step 1: Fixture behaviors.** В `tests/fake-codex-fixture.mjs`, в `case "turn/start":` сразу после `send({ id: message.id, result: { turn: buildTurn(turnId) } });` добавить: + +```js + if (BEHAVIOR === "error-notification" || BEHAVIOR === "error-notification-retry") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ + method: "error", + params: { + threadId: thread.id, + turnId, + willRetry: BEHAVIOR === "error-notification-retry", + error: { message: "Selected model is at capacity" } + } + }); + if (BEHAVIOR === "error-notification-retry") { + // Codex retried and finished: the earlier error was not terminal. + emitTurnCompleted(thread.id, turnId, [ + { completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } } + ]); + } + // error-notification: no turn/completed ever arrives. + break; + } + if (BEHAVIOR === "file-change-no-changes") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "item/started", params: { threadId: thread.id, turnId, item: { type: "fileChange", id: "fc_" + turnId } } }); + emitTurnCompleted(thread.id, turnId, [ + { completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } } + ]); + break; + } + if (BEHAVIOR === "turn-failed-silently") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ + method: "item/completed", + params: { threadId: thread.id, turnId, item: { type: "agentMessage", id: "msg_" + turnId, text: "{\n \"error\": \"quota exhausted\"\n}", phase: "final_answer" } } + }); + send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "failed") } }); + break; + } +``` + +(`payload` уже вычислен строкой выше в этом же `case`; `emitTurnCompleted` уже существует в fixture.) + +- [ ] **Step 2: Failing tests.** В `tests/runtime.test.mjs` (helpers `makeTempDir`, `installFakeCodex`, `buildEnv`, `run`, `SCRIPT`, `initGitRepo` уже импортированы): + +```js +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("node", [SCRIPT, "task", "--json", "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); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 1); + assert.match(result.stderr, /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("node", [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, /./); +}); + +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("node", [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("node", [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("node", [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(done.status, 0, done.stderr); + const status = run("node", [SCRIPT, "status", jobId], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(status.status, 0, status.stderr); + assert.match(status.stdout, /Status: failed/); + assert.doesNotMatch(status.stdout, /Summary: \{$/m); + assert.match(status.stdout, /Codex turn ended with status "failed"/); +}); +``` + +- [ ] **Step 3: Run, expect failures.** `node --import ./tests/test-env.mjs --test --test-name-pattern "#698|will retry|#775|#757" tests/runtime.test.mjs` → первый тест падает по таймауту/`result.error`, третий — по `Cannot read properties of undefined`, четвёртый — по `Summary: {`. + +- [ ] **Step 4: Implement.** `lib/codex.mjs`: + +```js + case "error": { + const error = message.params.error ?? { message: "Codex reported an error." }; + state.error = error; + if (message.params.willRetry === true) { + emitProgress(state.onProgress, `Codex error (retrying): ${error.message}`, null); + break; + } + emitProgress(state.onProgress, `Codex error: ${error.message}`, "failed"); + // Terminal: no turn/completed follows a non-retried error (#698). completeTurn + // is idempotent, so a late turn/completed is harmless. + completeTurn(state, { id: state.turnId ?? "errored-turn", status: "failed", error }); + break; + } +``` + +`describeStartedItem`: + +```js + case "fileChange": { + const count = Array.isArray(item.changes) ? item.changes.length : 0; + return { message: `Applying ${count} file change(s).`, phase: "editing" }; + } +``` + +`codex-companion.mjs`, сборка `task`-результата: + +```js + const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; + const turnStatus = result.turnStatus ?? null; + const failureMessage = + result.error?.message ?? + (result.status !== 0 ? (result.stderr || `Codex turn ended with status "${turnStatus ?? "failed"}"`) : ""); + ... + errorMessage: failureMessage || null, + summary: + result.status === 0 + ? firstMeaningfulLine(rawOutput, `${taskMetadata.title} finished.`) + : firstMeaningfulLine(failureMessage, firstMeaningfulLine(rawOutput, `${taskMetadata.title} failed.`)), +``` + +и в `lib/codex.mjs` там, где `runAppServerTurn` формирует возвращаемый объект (`status: buildResultStatus(turnState)`), добавить `turnStatus: turnState.finalTurn?.status ?? null`. + +- [ ] **Step 5: Run tests** → 4 новых PASS; полный гейт. +- [ ] **Step 6: Commit** `fix(runtime): terminal error notifications, errorMessage on silent turn failure, fileChange guard` с `Co-authored-by` авторов #710 (ALV0612), #763 (Soumya95), #775 (kevin9327). + +--- + +### Task 2: `turn/start` без `turn.id` не подвешивает захват (#781) + +**Files:** +- Modify: `plugins/codex/scripts/lib/codex.mjs` — `createTurnCaptureState` (`started` флаг), `captureTurn` (~720–760) +- Modify: `tests/fake-codex-fixture.mjs` — `BEHAVIOR` `turn-start-without-id` +- Test: `tests/runtime.test.mjs` + +**Interfaces:** буферизация нотификаций гейтится новым `state.started`, а не `state.turnId`; `belongsToTurn` при `trackedTurnId === null` уже принимает любые turnId. + +- [ ] **Step 1: Fixture.** В `case "turn/start":` заменить строку `send({ id: message.id, result: { turn: buildTurn(turnId) } });` на: + +```js + send({ id: message.id, result: { turn: BEHAVIOR === "turn-start-without-id" ? { status: "inProgress", items: [] } : buildTurn(turnId) } }); +``` + +- [ ] **Step 2: Failing test.** + +```js +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("node", [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, /./); +}); +``` + +- [ ] **Step 3: Run** → падает по таймауту. +- [ ] **Step 4: Implement.** В `createTurnCaptureState` добавить `started: false,`. В `captureTurn`: + +```js + client.setNotificationHandler((message) => { + if (!state.started) { + state.bufferedNotifications.push(message); + return; + } + ... + }); + try { + const response = await startRequest(); + options.onResponse?.(response, state); + state.turnId = response.turn?.id ?? null; + if (state.turnId) { + state.threadTurnIds.set(state.threadId, state.turnId); + } + state.started = true; + for (const message of state.bufferedNotifications) { ... } // без изменений +``` + +- [ ] **Step 5: Run** → PASS; гейт. +- [ ] **Step 6: Commit** `fix(runtime): gate turn notification buffering on turn start, not on turn id`. + +--- + +### Task 3: Ограниченные connect'ы к broker (#773) и честный `status --wait` (#774) + +**Files:** +- Modify: `plugins/codex/scripts/lib/broker-lifecycle.mjs` — `waitForBrokerEndpoint` +- Modify: `plugins/codex/scripts/lib/app-server.mjs` — `BrokerCodexAppServerClient.initialize` +- Modify: `plugins/codex/scripts/lib/codex.mjs` — `withAppServer` (`shouldRetryDirect`) +- Modify: `plugins/codex/scripts/codex-companion.mjs` — `handleStatus` +- Test: `tests/broker-stale-pid.test.mjs`, `tests/app-server.test.mjs`, `tests/runtime.test.mjs` + +**Interfaces:** +- `waitForBrokerEndpoint(endpoint, timeoutMs = 2000, { connectImpl } = {})` — `connectImpl(path)` возвращает socket-like `EventEmitter` с `destroy()`; каждая попытка ограничена `min(500, remaining)` ms. +- `BrokerCodexAppServerClient` принимает `options.connectImpl` и `options.connectTimeoutMs` (default 2000); при истечении — reject `Error` с `code: "ETIMEDOUT"`. +- `status --wait` при таймауте печатает `Timed out after s while the job was still running.` и ставит `process.exitCode = 1`; JSON-снимок с `waitTimedOut`/`timeoutMs` без изменений. + +- [ ] **Step 1: Failing tests.** `tests/broker-stale-pid.test.mjs`: + +```js +import { EventEmitter } from "node:events"; + +test("waitForBrokerEndpoint gives up on a socket that never connects or errors (#773)", async () => { + let destroyed = 0; + const connectImpl = () => { + const socket = new EventEmitter(); + socket.destroy = () => { destroyed += 1; socket.emit("close"); }; + socket.end = () => {}; + return socket; + }; + const started = Date.now(); + const ready = await waitForBrokerEndpoint("unix:/nonexistent/broker.sock", 600, { connectImpl }); + assert.equal(ready, false); + assert.ok(Date.now() - started < 1500, "must respect the overall timeout"); + assert.ok(destroyed >= 1, "hung probe sockets must be destroyed"); +}); +``` + +`tests/app-server.test.mjs` (импортировать `BrokerCodexAppServerClient` — экспортировать класс, если ещё не экспортирован): + +```js +test("broker client connect times out with ETIMEDOUT instead of hanging", async () => { + const connectImpl = () => { + const socket = new EventEmitter(); + socket.setEncoding = () => {}; + socket.destroy = () => socket.emit("close"); + socket.end = () => {}; + return socket; + }; + const client = new BrokerCodexAppServerClient(process.cwd(), { brokerEndpoint: "unix:/nonexistent.sock", connectImpl, connectTimeoutMs: 200 }); + await assert.rejects(client.initialize(), (error) => error.code === "ETIMEDOUT"); +}); +``` + +`tests/runtime.test.mjs`: + +```js +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("node", [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("node", [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("node", [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); + assert.equal(done.status, 0, done.stderr); +}); +``` + +- [ ] **Step 2: Run** → первый тест зависает до `timeoutMs`/падает по `destroyed`, второй — hang/reject-mismatch, третий — exit 0 без текста. +- [ ] **Step 3: Implement.** `broker-lifecycle.mjs`: + +```js +const PROBE_ATTEMPT_MS = 500; + +export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000, options = {}) { + const connectImpl = options.connectImpl ?? ((socketPath) => net.createConnection({ path: socketPath })); + const target = parseBrokerEndpoint(endpoint); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const attemptMs = Math.max(1, Math.min(PROBE_ATTEMPT_MS, timeoutMs - (Date.now() - start))); + const ready = await new Promise((resolve) => { + const socket = connectImpl(target.path); + let connected = false; + let settled = false; + const finish = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } }; + // A socket stuck in `connecting` fires neither connect nor error (#773). + const timer = setTimeout(() => { socket.destroy(); finish(false); }, attemptMs); + socket.on("connect", () => { connected = true; socket.end(); }); + socket.on("close", () => finish(connected)); + socket.on("error", () => finish(false)); + }); + if (ready) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return false; +} +``` + +`app-server.mjs`, `BrokerCodexAppServerClient.initialize`: + +```js + await new Promise((resolve, reject) => { + const target = parseBrokerEndpoint(this.endpoint); + const connectImpl = this.options.connectImpl ?? ((socketPath) => net.createConnection({ path: socketPath })); + const connectTimeoutMs = this.options.connectTimeoutMs ?? 2000; + this.socket = connectImpl(target.path); + this.socket.setEncoding("utf8"); + const timer = setTimeout(() => { + const error = Object.assign(new Error(`codex app-server broker connect timed out after ${connectTimeoutMs} ms.`), { code: "ETIMEDOUT" }); + this.socket.destroy(); + reject(error); + }, connectTimeoutMs); + this.socket.on("connect", () => { clearTimeout(timer); resolve(); }); + this.socket.on("error", (error) => { clearTimeout(timer); if (!this.exitResolved) reject(error); this.handleExit(error); }); + ... +``` + +`codex.mjs` `withAppServer`: `(brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED" || error?.code === "ETIMEDOUT"))`. + +`codex-companion.mjs` `handleStatus`: + +```js + if (snapshot.waitTimedOut) { + const seconds = Math.max(1, Math.round(snapshot.timeoutMs / 1000)); + outputCommandResult(snapshot, `${renderJobStatusReport(snapshot.job)}\nTimed out after ${seconds}s while the job was still running.\n`, options.json); + process.exitCode = 1; + return; + } + outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json); +``` + +- [ ] **Step 4: Run** → PASS; гейт. Проверить, что `tests/commands.test.mjs`/README не обещают exit 0 для `status --wait` (`rg -n "status --wait" README.md tests/commands.test.mjs`); README: добавить строку «`status --wait` exits 1 when the wait times out». +- [ ] **Step 5: Commit** `fix(broker): bound hung connects; status --wait exits 1 on timeout` с `Co-authored-by` kevin9327 (#773, #774). + +--- + +### Task 4: Teardown broker без утечек: kill при пересоздании, повторная проба, без сигнала устаревшему pid (#753/#762/#768/#749) + +**Files:** +- Modify: `plugins/codex/scripts/lib/broker-lifecycle.mjs` — `ensureBrokerSession`, `teardownBrokerSession`, `ownsBrokerProcess` (экспортировать) +- Test: `tests/broker-stale-pid.test.mjs` + +**Interfaces:** +- `ensureBrokerSession(cwd, options)` новые опции: `killProcess` (default `terminateProcessTree`), `isAliveImpl` (default `isPidAlive`), `ownsProcessImpl` (default `ownsBrokerProcess`), `retryTimeoutMs` (default 2000). +- Правило: existing не ready → если `pid` жив **и** принадлежит broker'у → повторная проба `retryTimeoutMs`; всё ещё не ready → `killProcess(pid)` (#762/#768), затем очистка файлов; если `pid` мёртв или не наш → только очистка, **без сигнала** (#749). + +- [ ] **Step 1: Failing tests.** + +```js +import { ensureBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; + +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); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + const staleEndpoint = createBrokerEndpoint(sessionDir); // nothing listens here + saveBrokerSession(workspace, { endpoint: staleEndpoint, pidFile: path.join(sessionDir, "broker.pid"), logFile: path.join(sessionDir, "broker.log"), sessionDir, pid: process.pid }); + const killed = []; + let probes = 0; + const session = await ensureBrokerSession(workspace, { + env: buildEnv(binDir), + isAliveImpl: () => true, + ownsProcessImpl: () => { probes += 1; return true; }, + killProcess: (pid) => { killed.push(pid); }, + retryTimeoutMs: 300 + }); + try { + assert.deepEqual(killed, [process.pid], "the unreachable but live broker must be signalled"); + assert.ok(probes >= 1); + assert.ok(session && session.endpoint !== staleEndpoint, "a fresh broker must be spawned"); + assert.equal(loadBrokerSession(workspace)?.endpoint, session.endpoint); + } finally { + if (session?.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } + clearBrokerSession(workspace); + } +}); + +test("ensureBrokerSession never signals a dead or foreign pid from a stale record (#749)", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + saveBrokerSession(workspace, { endpoint: createBrokerEndpoint(sessionDir), pidFile: null, logFile: null, sessionDir, pid: deadPid() }); + const killed = []; + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), killProcess: (pid) => killed.push(pid) }); + try { + assert.deepEqual(killed, []); + assert.ok(session); + } finally { + if (session?.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } + clearBrokerSession(workspace); + } +}); + +test("ensureBrokerSession retries the readiness probe before giving up on a slow broker (#768)", async () => { + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + const endpoint = createBrokerEndpoint(sessionDir); + const server = net.createServer((socket) => socket.end()); + await new Promise((resolve) => setTimeout(resolve, 300)); // not listening yet during the first probe + const listening = new Promise((resolve) => server.listen(parseBrokerEndpoint(endpoint).path, resolve)); + saveBrokerSession(workspace, { endpoint, pidFile: null, logFile: null, sessionDir, pid: process.pid }); + const killed = []; + const sessionPromise = ensureBrokerSession(workspace, { + isAliveImpl: () => true, + ownsProcessImpl: () => true, + killProcess: (pid) => killed.push(pid), + retryTimeoutMs: 2000 + }); + await listening; + const session = await sessionPromise; + try { + assert.deepEqual(killed, [], "a broker that answers within the retry window must not be killed"); + assert.equal(session.endpoint, endpoint); + } finally { + server.close(); + clearBrokerSession(workspace); + } +}); +``` + +(В третьем тесте порядок: probe 150 ms не успевает, retry 2 s успевает, потому что `listen` стартует после первого probe.) + +- [ ] **Step 2: Run** → 1-й: `killed` пуст (нет `killProcess` по умолчанию и нет retry-семантики); 3-й: broker убит/пересоздан. +- [ ] **Step 3: Implement.** + +```js +import { isPidAlive, processCommandLine, terminateProcessTree } from "./process.mjs"; + +export function ownsBrokerProcess(pid, endpoint, timeoutMs) { + if (process.platform === "win32") { + return true; // v1.4.0: CIM identity + } + const commandLine = processCommandLine(pid, { timeoutMs }); + if (!commandLine || !commandLine.includes("app-server-broker.mjs")) return false; + return !endpoint || commandLine.includes(endpoint); +} + +const STALE_BROKER_RETRY_MS = 2000; + +export async function ensureBrokerSession(cwd, options = {}) { + const killProcess = options.killProcess ?? terminateProcessTree; + const isAliveImpl = options.isAliveImpl ?? isPidAlive; + const ownsProcessImpl = options.ownsProcessImpl ?? ownsBrokerProcess; + const existing = loadBrokerSession(cwd); + if (existing && (await isBrokerEndpointReady(existing.endpoint))) { + return existing; + } + + if (existing) { + const pid = Number.isFinite(existing.pid) ? existing.pid : null; + const liveOwned = pid !== null && isAliveImpl(pid) === true && ownsProcessImpl(pid, existing.endpoint ?? null, options.timeoutMs); + // A live broker that missed the 150 ms probe is not a dead one (#768): give it the + // full window before deciding it is wedged. + const stillDown = liveOwned && !(await waitForBrokerEndpoint(existing.endpoint, options.retryTimeoutMs ?? STALE_BROKER_RETRY_MS).catch(() => false)); + if (liveOwned && !stillDown) { + return existing; + } + teardownBrokerSession({ + endpoint: existing.endpoint ?? null, + pidFile: existing.pidFile ?? null, + logFile: existing.logFile ?? null, + sessionDir: existing.sessionDir ?? null, + // Only a live broker that is provably ours gets a signal (#762); a dead or + // recycled pid is left alone (#749) — the files are stale either way. + pid: liveOwned ? pid : null, + killProcess: liveOwned ? killProcess : null, + ownsProcess: () => true + }); + clearBrokerSession(cwd); + } + ... // spawn как раньше; в not-ready ветке после spawn: killProcess (не options.killProcess ?? null) +} + +export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null, timeoutMs = undefined, ownsProcess = ownsBrokerProcess }) { + let signalled = false; + if (Number.isFinite(pid) && killProcess && ownsProcess(pid, endpoint, timeoutMs)) { ... } +``` + +- [ ] **Step 4: Run** → PASS; полный гейт (существующие тесты SessionEnd в `broker-stale-pid.test.mjs` не должны измениться). +- [ ] **Step 5: Commit** `fix(broker): kill a live wedged broker on replace, retry the readiness probe, never signal a stale pid` с `Co-authored-by` Soumya95 (#762), mzl9039 (#768), sylvesterkaczmarek (#749). + +--- + +### Task 5: Stop-review gate: модель/усилие, bounded rounds по умолчанию, signal в причине, подсказка отключения, `hooks.json` без `description` + +**Files:** +- Modify: `plugins/codex/scripts/stop-review-gate-hook.mjs` — `getMaxRounds`, `runStopReview`, reason-строки +- Modify: `plugins/codex/scripts/codex-companion.mjs` — `handleSetup`, `buildSetupReport`, `printUsage` +- Modify: `plugins/codex/scripts/lib/render.mjs` — `renderSetupReport` (строка про gate model/effort) +- Modify: `plugins/codex/commands/setup.md` — `argument-hint`, прокидывание флагов +- Modify: `plugins/codex/hooks/hooks.json` — убрать top-level `description` (#459) +- Modify: `README.md` — раздел review gate +- Test: `tests/runtime.test.mjs`, `tests/commands.test.mjs` + +**Interfaces:** +- Config keys: `stopReviewGateModel: string|null`, `stopReviewGateEffort: string|null` (через существующие `getConfig`/`setConfig`). +- `setup --review-gate-model --review-gate-effort `; `inherit` очищает. Алиасы моделей нормализуются той же `normalizeRequestedModel`, effort — `normalizeReasoningEffort`. +- `CODEX_REVIEW_GATE_MAX_ROUNDS` unset → default **3** (было 0 = без предела; #548). `0` явно → без предела (сохранить старое поведение по явному запросу). +- Reason-строки при провале gate заканчиваются `Disable with /codex:setup --disable-review-gate.`; signal-terminated review: `The stop-time Codex review task was terminated by signal SIGKILL.` + +- [ ] **Step 1: Failing tests.** `tests/runtime.test.mjs` (по образцу теста на строке ~2340): + +```js +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("node", [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("node", [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("node", [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("node", [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("node", [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"]); +}); + +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("node", [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("node", [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\./); +}); +``` + +`tests/commands.test.mjs`, в тест `hooks keep session-end cleanup and stop gating enabled` добавить `assert.equal("description" in JSON.parse(source), false);`. + +- [ ] **Step 2: Run** → падают (неизвестные флаги setup; decisions `["block","block","block","block"]`; нет текста escape hatch; `description` присутствует). +- [ ] **Step 3: Implement.** + +`codex-companion.mjs` `handleSetup`: `valueOptions: ["cwd", "review-gate-model", "review-gate-effort"]`; после обработки enable/disable: + +```js + if (options["review-gate-model"] != null) { + const value = String(options["review-gate-model"]).trim().toLowerCase() === "inherit" ? null : normalizeRequestedModel(options["review-gate-model"]); + setConfig(workspaceRoot, "stopReviewGateModel", value); + actionsTaken.push(value ? `Stop-time review gate model set to ${value}.` : "Stop-time review gate model now inherits Codex config."); + } + if (options["review-gate-effort"] != null) { + const value = String(options["review-gate-effort"]).trim().toLowerCase() === "inherit" ? null : normalizeReasoningEffort(options["review-gate-effort"]); + setConfig(workspaceRoot, "stopReviewGateEffort", value); + actionsTaken.push(value ? `Stop-time review gate effort set to ${value}.` : "Stop-time review gate effort now inherits Codex config."); + } +``` + +`buildSetupReport`: добавить `reviewGateModel: config.stopReviewGateModel ?? null, reviewGateEffort: config.stopReviewGateEffort ?? null`. `render.mjs` `renderSetupReport`: строка `- Review gate model/effort: / ` под строкой про gate. `printUsage`: `setup [--enable-review-gate|--disable-review-gate] [--review-gate-model ] [--review-gate-effort ] [--json]`. `commands/setup.md`: `argument-hint` и тело уже прокидывают `$ARGUMENTS` через `--args-stdin` — проверить, что новые флаги проходят (`rg -n "args-stdin" plugins/codex/commands/setup.md`). + +`stop-review-gate-hook.mjs`: + +```js +const DEFAULT_MAX_ROUNDS = 3; +const STOP_REVIEW_TIMEOUT_MS = Number(process.env.CODEX_STOP_REVIEW_TIMEOUT_MS) > 0 ? Number(process.env.CODEX_STOP_REVIEW_TIMEOUT_MS) : STOP_REVIEW_TIMEOUT_MINUTES * 60 * 1000; +const ESCAPE_HATCH = "Disable with /codex:setup --disable-review-gate."; + +function getMaxRounds() { + const raw = process.env.CODEX_REVIEW_GATE_MAX_ROUNDS; + if (raw == null || raw === "") return DEFAULT_MAX_ROUNDS; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_ROUNDS; +} + +function runStopReview(cwd, input = {}, config = {}) { + ... + const args = [scriptPath, "task", "--json"]; + if (config.stopReviewGateModel) args.push("--model", config.stopReviewGateModel); + if (config.stopReviewGateEffort) args.push("--effort", config.stopReviewGateEffort); + args.push(prompt); + const result = spawnSync(process.execPath, args, { ... }); + if (result.error?.code === "ETIMEDOUT") { + return { ok: false, reason: `The stop-time Codex review task timed out after ${(STOP_REVIEW_TIMEOUT_MS / 60000).toFixed(1)} minutes. Run /codex:review --wait manually. ${ESCAPE_HATCH}` }; + } + if (result.signal) { + return { ok: false, reason: `The stop-time Codex review task was terminated by signal ${result.signal}. Run /codex:review --wait manually. ${ESCAPE_HATCH}` }; + } + if (result.status !== 0) { ... `${detail} ${ESCAPE_HATCH}` } + // invalid JSON / no output / unexpected answer: append ESCAPE_HATCH +``` + +Вызов: `runStopReview(cwd, input, config)`. `hooks.json`: удалить ключ `"description"`. Существующий тест `stop gate script timeout is shorter than the Stop hook timeout` читает `STOP_REVIEW_TIMEOUT_MINUTES` — константа остаётся; env-override используется только тестами. + +- [ ] **Step 4: Run** → PASS; гейт; README: описать `--review-gate-model/--review-gate-effort`, default 3 rounds, `CODEX_REVIEW_GATE_MAX_ROUNDS=0` = без предела. +- [ ] **Step 5: Commit** `feat(stop-gate): pin model/effort, bound rounds by default, name signal and escape hatch; drop hooks.json description` с `Co-authored-by` mittalpk (#565), SomSamantray (#573). + +--- + +### Task 6: Transfer учитывает `CLAUDE_CONFIG_DIR` (#721) + +**Files:** +- Modify: `plugins/codex/scripts/lib/claude-session-transfer.mjs` +- Modify: `tests/test-env.mjs` — добавить `CLAUDE_CONFIG_DIR` в список стираемых +- Test: `tests/runtime.test.mjs` (рядом с существующими transfer-тестами; `rg -n "^test\(.*transfer" tests/runtime.test.mjs`) + +**Interfaces:** `resolveClaudeProjectsDir(env = process.env)` → `path.join(env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(os.homedir(), ".claude"), "projects")`; экспортируется для тестов; `resolveClaudeSessionPath(cwd, options)` принимает `options.env`. + +- [ ] **Step 1: Failing test.** + +```js +import { resolveClaudeSessionPath, resolveClaudeProjectsDir } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; + +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)); + assert.throws(() => resolveClaudeSessionPath(process.cwd(), { source: transcript, env: {} }), /can import Claude sessions only from/); +}); +``` + +- [ ] **Step 2: Run** → `resolveClaudeProjectsDir is not a function`. +- [ ] **Step 3: Implement.** + +```js +export function resolveClaudeProjectsDir(env = process.env) { + const configDir = env.CLAUDE_CONFIG_DIR ? path.resolve(String(env.CLAUDE_CONFIG_DIR)) : path.join(os.homedir(), ".claude"); + return path.join(configDir, "projects"); +} + +export function resolveClaudeSessionPath(cwd, options = {}) { + const env = options.env ?? process.env; + const projectsDir = resolveClaudeProjectsDir(env); + const requestedPath = options.source || env[TRANSCRIPT_PATH_ENV]; + ... // далее вместо CLAUDE_PROJECTS_DIR использовать projectsDir (и в сообщении об ошибке) +``` + +Удалить константу `CLAUDE_PROJECTS_DIR`. В `tests/test-env.mjs` добавить `"CLAUDE_CONFIG_DIR"`. + +- [ ] **Step 4: Run** → PASS; гейт. README раздел transfer: одна строка «honours `CLAUDE_CONFIG_DIR`». +- [ ] **Step 5: Commit** `fix(transfer): resolve Claude transcripts under CLAUDE_CONFIG_DIR`. + +--- + +### Task 7: Data-driven каталог моделей (#468/#703/#485/#128) + +**Files:** +- Create: `plugins/codex/scripts/lib/model-catalog.mjs` +- Modify: `plugins/codex/scripts/codex-companion.mjs` — `MODEL_ALIASES`, `normalizeRequestedModel`, `normalizeReasoningEffort` (+ проверка по модели), `printUsage` +- Create: `tests/fixtures/models-catalog.json` +- Modify: `tests/test-env.mjs` — `process.env.CODEX_COMPANION_MODEL_CATALOG = ` +- Create: `tests/model-catalog.test.mjs` +- Test: `tests/runtime.test.mjs` +- Docs: `README.md` (алиасы), `plugins/codex/skills/codex-cli-runtime/SKILL.md:27-28,37`, `plugins/codex/agents/codex-rescue.md:39-40`, `plugins/codex/commands/rescue.md:3` + +**Interfaces:** +- `loadModelCatalog({ env = process.env, runCommandImpl = runCommand } = {})` → `Array<{ slug, visibility, priority, efforts: string[] }>`; источник по порядку: `env.CODEX_COMPANION_MODEL_CATALOG` (файл, для тестов) → `${CODEX_HOME ?? ~/.codex}/models_cache.json` → `codex debug models --bundled` (timeoutMs 10000, maxBuffer 64 MiB) → `[]`. Никогда не бросает; результат кешируется на процесс. +- `resolveModelAlias(alias, catalog)` → slug. Точное совпадение со slug из каталога → как есть. Иначе family-алиас: кандидаты `visibility === "list"` и (`slug === alias` или `slug.endsWith("-" + alias)`), сортировка: `priority` по возрастанию, затем по убыванию числа семейства из `/^gpt-(\d+(?:\.\d+)?)/` → первый. Иначе `FALLBACK_ALIASES.get(alias)`; иначе `alias`. +- `supportedEfforts(slug, catalog)` → `string[] | null` (`null` = модель не в каталоге → принять любой из `VALID_REASONING_EFFORTS`). +- `FALLBACK_ALIASES`: `spark→gpt-5.3-codex-spark`, `astra→gpt-6-astra`, `sol→gpt-6-sol`, `luna→gpt-6-luna`, `terra→gpt-5.6-terra`, `mini→gpt-5.4-mini`. + +- [ ] **Step 1: Fixture** `tests/fixtures/models-catalog.json`: + +```json +{ "models": [ + { "slug": "gpt-6-astra", "visibility": "list", "priority": 1, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"}, {"effort":"xhigh"}, {"effort":"max"}, {"effort":"ultra"} ] }, + { "slug": "gpt-6-sol", "visibility": "list", "priority": 2, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"}, {"effort":"xhigh"}, {"effort":"max"}, {"effort":"ultra"} ] }, + { "slug": "gpt-5.6-sol", "visibility": "list", "priority": 5, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"} ] }, + { "slug": "gpt-5.6-terra", "visibility": "list", "priority": 6, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"}, {"effort":"xhigh"} ] }, + { "slug": "gpt-reserve", "visibility": "hide", "priority": 9, "supported_reasoning_levels": [ {"effort":"low"} ] }, + { "slug": "gpt-5.3-codex-spark", "visibility": "list", "priority": 7, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"} ] } +] } +``` + +- [ ] **Step 2: Failing tests** `tests/model-catalog.test.mjs`: + +```js +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { loadModelCatalog, resolveModelAlias, supportedEfforts } from "../plugins/codex/scripts/lib/model-catalog.mjs"; + +const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "models-catalog.json"); +const catalog = loadModelCatalog({ env: { CODEX_COMPANION_MODEL_CATALOG: FIXTURE } }); + +test("family alias resolves to the listed model with the lowest priority, newest family on ties", () => { + assert.equal(resolveModelAlias("sol", catalog), "gpt-6-sol"); + assert.equal(resolveModelAlias("terra", catalog), "gpt-5.6-terra"); + assert.equal(resolveModelAlias("astra", catalog), "gpt-6-astra"); + assert.equal(resolveModelAlias("SOL", catalog), "gpt-6-sol"); +}); + +test("hidden models never resolve from an alias and exact slugs pass through", () => { + assert.equal(resolveModelAlias("reserve", catalog), "reserve"); + assert.equal(resolveModelAlias("gpt-reserve", catalog), "gpt-reserve"); + assert.equal(resolveModelAlias("gpt-5.6-sol", catalog), "gpt-5.6-sol"); +}); + +test("hardcoded fallback applies only without a catalogue", () => { + assert.equal(resolveModelAlias("sol", []), "gpt-6-sol"); + assert.equal(resolveModelAlias("mini", catalog), "gpt-5.4-mini"); +}); + +test("supportedEfforts reports the catalogue list or null for unknown models", () => { + assert.deepEqual(supportedEfforts("gpt-5.6-sol", catalog), ["low", "medium", "high"]); + assert.equal(supportedEfforts("o3", catalog), null); +}); + +test("loadModelCatalog never throws on a missing or malformed source", () => { + assert.deepEqual(loadModelCatalog({ env: { CODEX_COMPANION_MODEL_CATALOG: "/nonexistent.json", CODEX_HOME: "/nonexistent" }, runCommandImpl: () => ({ status: 1, stdout: "", stderr: "", error: null }) }), []); +}); +``` + +`tests/runtime.test.mjs`: + +```js +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("node", [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("node", [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/); +}); +``` + +Существующие assert'ы `lastThreadStart.model === "gpt-5.3-codex-spark"` (runtime ~998/1020) и `thread-config` остаются валидными (spark в fixture-каталоге). + +- [ ] **Step 3: Run** → `Cannot find module model-catalog.mjs`. +- [ ] **Step 4: Implement** `lib/model-catalog.mjs`: + +```js +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { runCommand } from "./process.mjs"; + +export const CATALOG_ENV = "CODEX_COMPANION_MODEL_CATALOG"; +export const FALLBACK_ALIASES = new Map([ + ["spark", "gpt-5.3-codex-spark"], + ["astra", "gpt-6-astra"], + ["sol", "gpt-6-sol"], + ["luna", "gpt-6-luna"], + ["terra", "gpt-5.6-terra"], + ["mini", "gpt-5.4-mini"] +]); + +let cached = null; + +function normalizeEntries(raw) { + const models = Array.isArray(raw?.models) ? raw.models : Array.isArray(raw) ? raw : []; + return models + .filter((m) => m && typeof m.slug === "string") + .map((m) => ({ + slug: m.slug, + visibility: m.visibility ?? "list", + priority: Number.isFinite(m.priority) ? m.priority : Number.MAX_SAFE_INTEGER, + efforts: Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels.map((l) => l?.effort).filter(Boolean) : [] + })); +} + +function readJson(file) { + try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } +} + +export function loadModelCatalog({ env = process.env, runCommandImpl = runCommand, cache = true } = {}) { + if (cache && cached) return cached; + const sources = []; + if (env[CATALOG_ENV]) sources.push(() => readJson(env[CATALOG_ENV])); + const codexHome = path.resolve(env.CODEX_HOME || path.join(os.homedir(), ".codex")); + sources.push(() => readJson(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(() => { + const result = runCommandImpl("codex", ["debug", "models", "--bundled"], { env, timeoutMs: 10000, maxBuffer: 64 * 1024 * 1024 }); + if (result.error || result.status !== 0) return null; + try { return JSON.parse(result.stdout); } catch { return null; } + }); + let entries = []; + for (const source of sources) { + entries = normalizeEntries(source()); + if (entries.length > 0) break; + } + if (cache) cached = entries; + return entries; +} + +function familyNumber(slug) { + const match = /^gpt-(\d+(?:\.\d+)?)/.exec(slug); + return match ? Number(match[1]) : -1; +} + +export function resolveModelAlias(alias, catalog) { + const wanted = String(alias ?? "").trim(); + if (!wanted) return null; + if (catalog.some((m) => m.slug === wanted)) return wanted; + const lower = wanted.toLowerCase(); + const candidates = catalog + .filter((m) => m.visibility === "list" && (m.slug === lower || m.slug.endsWith(`-${lower}`))) + .sort((a, b) => a.priority - b.priority || familyNumber(b.slug) - familyNumber(a.slug)); + if (candidates.length > 0) return candidates[0].slug; + return FALLBACK_ALIASES.get(lower) ?? wanted; +} + +export function supportedEfforts(slug, catalog) { + const entry = catalog.find((m) => m.slug === slug); + return entry && entry.efforts.length > 0 ? entry.efforts : null; +} +``` + +`codex-companion.mjs`: удалить `MODEL_ALIASES`; `normalizeRequestedModel(model)` → `resolveModelAlias(model, loadModelCatalog())`; `normalizeReasoningEffort(effort, model = null)`: после проверки `VALID_REASONING_EFFORTS`, если `model` задан: `const allowed = supportedEfforts(model, loadModelCatalog()); if (allowed && !allowed.includes(normalized)) throw new Error(\`Reasoning effort "${normalized}" is not supported by ${model}. ${model} supports: ${allowed.join(", ")}.\`)`. Во всех трёх местах вызова (`review`, `adversarial-review`, `task`) сначала нормализовать модель, затем `normalizeReasoningEffort(options.effort, model)`. `printUsage`: `--model `. `tests/test-env.mjs`: `process.env.CODEX_COMPANION_MODEL_CATALOG = new URL("./fixtures/models-catalog.json", import.meta.url).pathname;` (после цикла `delete`). + +- [ ] **Step 5: Docs.** README алиасы: «aliases resolve against the local Codex model catalogue (`$CODEX_HOME/models_cache.json`); today `sol → gpt-6-sol`, `astra → gpt-6-astra`, `luna → gpt-6-luna`, `terra → gpt-5.6-terra`, `spark → gpt-5.3-codex-spark`, `mini → gpt-5.4-mini`; run `codex debug models` to see yours». SKILL.md:27-28,37, agent:39-40, rescue.md argument-hint — то же, без хардкода семейства (`sol` → «the newest listed `*-sol` model»). Пример README:150/298 → `gpt-6-astra`. +- [ ] **Step 6: Run** → PASS; гейт; `tests/commands.test.mjs` README-assertions (`rg -n "gpt-5" tests/commands.test.mjs`) обновить при необходимости. +- [ ] **Step 7: Commit** `feat(models): resolve aliases and validate efforts against the Codex model catalogue`. + +--- + +### Task 8: Security: приватный fallback state root с plugin-сегментом (#521/#609) и валидация `broker.json` + +**Files:** +- Modify: `plugins/codex/scripts/lib/state.mjs` — `FALLBACK_STATE_ROOT_DIR` → `resolveFallbackStateRoot()`, `resolveStateDir` +- Modify: `plugins/codex/scripts/lib/broker-lifecycle.mjs` — `loadBrokerSession` валидация +- Test: `tests/state.test.mjs`, `tests/broker-stale-pid.test.mjs` + +**Interfaces:** +- `resolveFallbackStateRoot({ env = process.env, tmpdir = os.tmpdir(), uid = process.getuid?.() ?? null, pluginRoot })` → `path.join(tmpdir, \`codex-companion-${uid ?? "user"}\`, sha256(realpath(pluginRoot)).slice(0, 12))`; `pluginRoot` = `env.CLAUDE_PLUGIN_ROOT` или `path.resolve(SCRIPT_DIR, "..")`. Каталог создаётся с `mode: 0o700`; на posix после `mkdirSync` проверяется `stat`: `uid === process.getuid()` и `(mode & 0o077) === 0`, иначе `throw new Error("Refusing to use shared state directory : owned by another user or group/world accessible. Set CLAUDE_PLUGIN_DATA.")`. +- `loadBrokerSession(cwd)` возвращает `null` (и пишет в stderr `[codex] Ignoring malformed broker.json at : `), если: не объект; `endpoint` не строка или `parseBrokerEndpoint` бросает; `pid` не `null` и не положительное целое; любой из `pidFile`/`logFile`/`sessionDir` задан и не абсолютный путь. + +- [ ] **Step 1: Failing tests.** `tests/state.test.mjs`: + +```js +import { resolveFallbackStateRoot } from "../plugins/codex/scripts/lib/state.mjs"; + +test("fallback state root is private to the user and namespaced per plugin root (#521/#609)", { skip: process.platform === "win32" }, () => { + const tmp = makeTempDir(); + const pluginA = makeTempDir(); + const pluginB = makeTempDir(); + const a = resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: pluginA }); + const b = resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: pluginB }); + assert.notEqual(a, b); + assert.ok(a.startsWith(path.join(tmp, `codex-companion-${process.getuid()}`))); + assert.equal(fs.statSync(path.dirname(a)).mode & 0o077, 0); +}); + +test("fallback state root refuses a pre-existing world-accessible directory", { skip: process.platform === "win32" }, () => { + const tmp = makeTempDir(); + const shared = path.join(tmp, `codex-companion-${process.getuid()}`); + fs.mkdirSync(shared, { mode: 0o755 }); + assert.throws(() => resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: makeTempDir() }), /Refusing to use shared state directory/); +}); +``` + +`tests/broker-stale-pid.test.mjs`: + +```js +test("loadBrokerSession ignores a malformed record instead of trusting it", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + fs.mkdirSync(stateDir, { recursive: true }); + const file = path.join(stateDir, "broker.json"); + for (const bad of [ + "[]", + JSON.stringify({ endpoint: "ftp://x", pid: 1 }), + JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: -5 }), + JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: 1, pidFile: "relative/broker.pid" }) + ]) { + fs.writeFileSync(file, bad); + assert.equal(loadBrokerSession(workspace), null, bad); + } + fs.writeFileSync(file, JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: null, pidFile: null, logFile: null, sessionDir: null })); + assert.ok(loadBrokerSession(workspace)); +}); +``` + +- [ ] **Step 2: Run** → `resolveFallbackStateRoot is not a function`; broker-тест: malformed записи возвращаются как есть. +- [ ] **Step 3: Implement.** `state.mjs`: + +```js +import { fileURLToPath } from "node:url"; +const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +export function resolveFallbackStateRoot({ env = process.env, tmpdir = os.tmpdir(), uid = typeof process.getuid === "function" ? process.getuid() : null, pluginRoot = env.CLAUDE_PLUGIN_ROOT || SCRIPT_ROOT } = {}) { + let canonicalPluginRoot = pluginRoot; + try { canonicalPluginRoot = fs.realpathSync.native(pluginRoot); } catch { /* keep as given */ } + const userDir = path.join(tmpdir, `codex-companion-${uid ?? "user"}`); + fs.mkdirSync(userDir, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + const stats = fs.statSync(userDir); + if ((uid !== null && stats.uid !== uid) || (stats.mode & 0o077) !== 0) { + throw new Error(`Refusing to use shared state directory ${userDir}: owned by another user or group/world accessible. Set CLAUDE_PLUGIN_DATA.`); + } + } + // ponytail: plugin identity = hash of the install root; sibling plugins/forks get separate roots (#609) + return path.join(userDir, createHash("sha256").update(canonicalPluginRoot).digest("hex").slice(0, 12)); +} +``` + +`resolveStateDir`: `const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : resolveFallbackStateRoot();`. Удалить `FALLBACK_STATE_ROOT_DIR`. + +`broker-lifecycle.mjs`: + +```js +function describeBrokerRecordProblem(record) { + if (!record || typeof record !== "object" || Array.isArray(record)) return "not an object"; + if (typeof record.endpoint !== "string") return "endpoint is not a string"; + try { parseBrokerEndpoint(record.endpoint); } catch (error) { return error.message; } + if (record.pid != null && !(Number.isInteger(record.pid) && record.pid > 0)) return "pid is not a positive integer"; + for (const key of ["pidFile", "logFile", "sessionDir"]) { + if (record[key] != null && !(typeof record[key] === "string" && path.isAbsolute(record[key]))) return `${key} is not an absolute path`; + } + return null; +} + +export function loadBrokerSession(cwd) { + const stateFile = resolveBrokerStateFile(cwd); + if (!fs.existsSync(stateFile)) return null; + let record; + try { record = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch { return null; } + const problem = describeBrokerRecordProblem(record); + if (problem) { + process.stderr.write(`[codex] Ignoring malformed broker.json at ${stateFile}: ${problem}.\n`); + return null; + } + return record; +} +``` + +- [ ] **Step 4: Run** → PASS; гейт. Проверить, что тесты, которые пишут `broker.json` вручную с относительными путями, не сломались (`rg -n "saveBrokerSession\(" tests/*.mjs`). +- [ ] **Step 5: Commit** `fix(state): private per-user, per-plugin fallback state root; validate broker.json before use`. + +--- + +### Task 9: Process identity на posix (#743) — по разделу «Дизайн: process identity» спека + +**Files:** +- Modify: `plugins/codex/scripts/lib/process.mjs` — `runCommand` (`status ?? null`), `getProcessIdentity`, `terminateProcessTreeIfIdentityMatches`, `terminateRecordedProcess` +- Modify: `plugins/codex/scripts/lib/broker-lifecycle.mjs` — identity в `ensureBrokerSession`/`teardownBrokerSession` +- Modify: `plugins/codex/scripts/lib/state.mjs` — `writeJobPidFile`/`readJobPidSidecar` (JSON + bare integer), `updateJobPid(cwd, jobId, pid, identity)`, `resolveJobPid` → `{ pid, identity }`, ticket owner identity + `judgeLockEntry` +- Modify: `plugins/codex/scripts/lib/tracked-jobs.mjs` — `runTrackedJob` (`pidIdentity`), `reapDeadJobs` +- Modify: `plugins/codex/scripts/codex-companion.mjs` — `enqueueBackgroundTask`, `handleCancel` +- Modify: `plugins/codex/scripts/session-lifecycle-hook.mjs` — `cleanupSessionJobs`, вызов `teardownBrokerSession` +- Test: `tests/process.test.mjs`, `tests/tracked-jobs.test.mjs`, `tests/state.test.mjs`, `tests/broker-stale-pid.test.mjs`, `tests/runtime.test.mjs` + +**Interfaces (обязательные сигнатуры):** +- `getProcessIdentity(pid, { platform = process.platform, timeoutMs = 10000, runCommandImpl = runCommand, readFileSyncImpl = fs.readFileSync } = {})` → `string | null`. linux: поле 22 из `/proc//stat` (после последней `)`), формат `linux:`; darwin: `ps -o lstart=,comm= -p ` → `darwin:|`; win32: `null` (v1.4.0). Свой pid кешируется. +- `terminateRecordedProcess(pid, { identity = null, commandLineMatch = null, timeoutMs, platform, killImpl, runCommandImpl } = {})` → `{ attempted: boolean, delivered: boolean, reason: "identity-match"|"command-line-match"|"identity-mismatch"|"identity-unavailable"|"no-pid" }`. identity задан → сравнить с `getProcessIdentity`; mismatch/unavailable → `attempted:false`. identity `null` и posix → `commandLineMatch(processCommandLine(pid))` (функция или RegExp); нет match → `attempted:false, reason:"identity-mismatch"`. identity `null` и win32 → `attempted:false, reason:"identity-unavailable"`. +- Sidecar `jobs/.pid`: `{"pid":N,"identity":"..."}`; читалка принимает и старый bare-integer. `resolveJobPid(cwd, job)` → `{ pid: number|null, identity: string|null }` (три вызывающих места обновить). +- Job-record: `pidIdentity: string|null` рядом с `pid` (в `runTrackedJob` и во всех местах, где `pid: null`). +- `broker.json`: `pidIdentity: string|null`. + +- [ ] **Step 1: Failing unit tests** `tests/process.test.mjs`: + +```js +import { getProcessIdentity, terminateRecordedProcess } from "../plugins/codex/scripts/lib/process.mjs"; + +test("getProcessIdentity is stable for the same process and differs for another one", { skip: process.platform === "win32" }, () => { + const mine = getProcessIdentity(process.pid); + assert.ok(mine && mine.length > 0); + assert.equal(getProcessIdentity(process.pid), mine); + const child = spawnSync(process.execPath, ["-e", "setTimeout(()=>{}, 2000); console.log(process.pid)"], { encoding: "utf8", timeout: 100 }); + // The child was killed by the timeout; its identity, if any, must not equal ours. + assert.notEqual(getProcessIdentity(Number(child.stdout.trim()) || 999999), mine); +}); + +test("getProcessIdentity parses linux /proc stat and darwin ps output", () => { + assert.equal(getProcessIdentity(42, { platform: "linux", readFileSyncImpl: () => "42 (node (x)) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 20 0 1 0 123456 1 2 3" }), "linux:123456"); + assert.equal(getProcessIdentity(42, { platform: "darwin", runCommandImpl: () => ({ status: 0, stdout: "Mon Sep 27 10:00:00 2026 node\n", stderr: "", error: null }) }), "darwin:Mon Sep 27 10:00:00 2026|node"); + assert.equal(getProcessIdentity(42, { platform: "win32" }), null); +}); + +test("terminateRecordedProcess refuses on identity mismatch and without identity on win32", () => { + let killed = false; + const mismatch = terminateRecordedProcess(4242, { identity: "linux:1", platform: "linux", readFileSyncImpl: () => "4242 (node) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 1 0 999 0 0 0", killImpl: () => { killed = true; } }); + assert.equal(mismatch.attempted, false); + assert.equal(mismatch.reason, "identity-mismatch"); + assert.equal(killed, false); + const win = terminateRecordedProcess(4242, { identity: null, platform: "win32", killImpl: () => { killed = true; } }); + assert.deepEqual([win.attempted, win.reason, killed], [false, "identity-unavailable", false]); +}); + +test("terminateRecordedProcess falls back to the command line on posix when no identity was recorded", () => { + const calls = []; + const ok = terminateRecordedProcess(4242, { identity: null, platform: "linux", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "node app-server-broker.mjs serve\n", stderr: "", error: null }), killImpl: (pid, sig) => calls.push([pid, sig]) }); + assert.equal(ok.attempted, true); + assert.equal(ok.reason, "command-line-match"); + assert.deepEqual(calls[0], [-4242, "SIGTERM"]); + const no = terminateRecordedProcess(4242, { identity: null, platform: "linux", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "bash\n", stderr: "", error: null }), killImpl: () => calls.push("must not") }); + assert.equal(no.attempted, false); +}); +``` + +`tests/tracked-jobs.test.mjs`: + +```js +test("reapDeadJobs fails a running job whose pid was recycled by another process", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-recycled", status: "running", phase: "delegating", pid: process.pid, pidIdentity: "linux:not-this-process", logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { getProcessIdentityImpl: () => "linux:something-else" }); + assert.equal(reaped[0].status, "failed"); + assert.match(reaped[0].errorMessage, /pid reused/); +}); + +test("reapDeadJobs leaves a running job alone when the identity probe fails", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-probe-fails", status: "running", phase: "delegating", pid: process.pid, pidIdentity: "linux:x", logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { getProcessIdentityImpl: () => { throw new Error("ps unavailable"); } }); + assert.equal(reaped[0].status, "running"); +}); + +test("pid sidecar round-trips identity and still reads the legacy bare integer", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-sidecar", status: "queued", phase: "queued", pid: null, logFile: null }); + updateJobPid(workspace, "job-sidecar", 777, "linux:777"); + assert.deepEqual(resolveJobPid(workspace, listJobs(workspace)[0]), { pid: 777, identity: "linux:777" }); + fs.writeFileSync(resolveJobPidFile(workspace, "job-sidecar"), "778\n"); + assert.deepEqual(resolveJobPid(workspace, { id: "job-sidecar", status: "queued", pid: null }), { pid: 778, identity: null }); +}); +``` + +`tests/broker-stale-pid.test.mjs`: + +```js +test("SessionEnd leaves a recorded broker pid alone when its identity no longer matches (#743)", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + const endpoint = createBrokerEndpoint(sessionDir); + saveBrokerSession(workspace, { endpoint, pidFile: null, logFile: null, sessionDir, pid: process.pid, pidIdentity: "darwin:definitely-not-this|nope" }); + const hook = run("node", [SESSION_HOOK, "SessionEnd"], { cwd: workspace, env: buildEnv(binDir), input: JSON.stringify({ cwd: workspace, session_id: "sess-identity" }) }); + assert.equal(hook.status, 0, hook.stderr); + assert.match(hook.stderr, /signalled=false/); + assert.match(hook.stderr, /identity-mismatch/); + clearBrokerSession(workspace); +}); +``` + +`tests/runtime.test.mjs` (e2e): + +```js +test("cancel refuses to signal a worker whose recorded identity no longer matches and says so", () => { + const repo = makeTempDir(); + initGitRepo(repo); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "8000" }); + const launched = run("node", [SCRIPT, "task", "--background", "--json", "slow"], { cwd: repo, env }); + const jobId = JSON.parse(launched.stdout).jobId; + const sidecar = resolveJobPidFile(repo, jobId); + const record = JSON.parse(fs.readFileSync(sidecar, "utf8")); + fs.writeFileSync(sidecar, JSON.stringify({ pid: record.pid, identity: "linux:tampered" })); + const job = readPersistedJob(repo, jobId); + if (job.pid != null) { fs.writeFileSync(resolveJobFile(repo, jobId), JSON.stringify({ ...job, pidIdentity: "linux:tampered" })); } + const cancel = run("node", [SCRIPT, "cancel", jobId], { cwd: repo, env }); + assert.equal(cancel.status, 0, cancel.stderr); + assert.match(cancel.stdout, /worker pid \d+ left running: identity-mismatch/); + try { process.kill(record.pid, "SIGKILL"); } catch {} +}); +``` + +(`resolveJobPidFile`, `resolveJobFile` импортировать из `state.mjs`.) + +- [ ] **Step 2: Run** → все новые тесты падают на отсутствующих экспортах/полях. +- [ ] **Step 3: Implement `process.mjs`.** + +```js +import fs from "node:fs"; + +// runCommand: status: result.status ?? null (a timed-out spawnSync has null status; never read it as exit 0) + +const ownIdentityCache = new Map(); + +export function getProcessIdentity(pid, options = {}) { + if (!Number.isInteger(pid) || pid <= 0) return null; + const platform = options.platform ?? process.platform; + if (platform === "win32") return null; // ponytail: CIM identity lands in v1.4.0 + if (pid === process.pid && ownIdentityCache.has(platform)) return ownIdentityCache.get(platform); + const runCommandImpl = options.runCommandImpl ?? runCommand; + const readFileSyncImpl = options.readFileSyncImpl ?? fs.readFileSync; + let identity = null; + if (platform === "linux") { + try { + const stat = String(readFileSyncImpl(`/proc/${pid}/stat`, "utf8")); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + const starttime = fields[19]; // field 22 overall: pid(1) comm(2) then 20 fields after ")" + identity = starttime ? `linux:${starttime}` : null; + } catch { identity = null; } + } else { + const result = runCommandImpl("ps", ["-o", "lstart=,comm=", "-p", String(pid)], { timeoutMs: options.timeoutMs ?? 10000, shell: false }); + const line = !result.error && result.status === 0 ? result.stdout.trim() : ""; + if (line) { + const idx = line.lastIndexOf(" "); + identity = `darwin:${line.slice(0, idx).trim()}|${line.slice(idx + 1).trim()}`; + } + } + if (pid === process.pid && identity) ownIdentityCache.set(platform, identity); + return identity; +} + +export function terminateRecordedProcess(pid, options = {}) { + if (!Number.isInteger(pid) || pid <= 0) return { attempted: false, delivered: false, reason: "no-pid" }; + const platform = options.platform ?? process.platform; + const identity = options.identity ?? null; + if (identity) { + const actual = getProcessIdentity(pid, options); + if (!actual) return { attempted: false, delivered: false, reason: "identity-unavailable" }; + if (actual !== identity) return { attempted: false, delivered: false, reason: "identity-mismatch" }; + const outcome = terminateProcessTree(pid, options); + return { ...outcome, reason: "identity-match" }; + } + if (platform === "win32") return { attempted: false, delivered: false, reason: "identity-unavailable" }; + const commandLine = processCommandLine(pid, options); + const match = options.commandLineMatch; + const matched = commandLine && (typeof match === "function" ? match(commandLine) : match instanceof RegExp ? match.test(commandLine) : false); + if (!matched) return { attempted: false, delivered: false, reason: "identity-mismatch" }; + return { ...terminateProcessTree(pid, options), reason: "command-line-match" }; +} +``` + +(`processCommandLine` и `terminateProcessTree` уже принимают `runCommandImpl`/`killImpl`/`platform`.) В `processCommandLine` вызов `ps` — добавить `shell: false`. + +- [ ] **Step 4: Implement `state.mjs`.** + +```js +export function writeJobPidFile(cwd, jobId, pid, identity = null) { + return writeFileAtomic(resolveJobPidFile(cwd, jobId), `${JSON.stringify({ pid, identity })}\n`); +} +function readJobPidSidecar(cwd, jobId) { + try { + const raw = fs.readFileSync(resolveJobPidFile(cwd, jobId), "utf8").trim(); + if (raw.startsWith("{")) { + const parsed = JSON.parse(raw); + const pid = Number.isInteger(parsed.pid) && parsed.pid > 0 ? parsed.pid : null; + return pid ? { pid, identity: typeof parsed.identity === "string" ? parsed.identity : null } : null; + } + const pid = Number.parseInt(raw, 10); // v1.2.x bare integer + return Number.isInteger(pid) && pid > 0 ? { pid, identity: null } : null; + } catch { return null; } +} +export function updateJobPid(cwd, jobId, pid, identity = null) { + writeJobPidFile(cwd, jobId, pid, identity); + withStateLock(cwd, () => { + const indexed = listJobs(cwd).find((job) => job.id === jobId); + if (indexed?.status === "queued") upsertJob(cwd, { id: jobId, pid, pidIdentity: identity }); + }); +} +export function resolveJobPid(cwd, job) { + if (job?.pid != null) return { pid: job.pid, identity: job.pidIdentity ?? null }; + if (job?.status !== "queued" && job?.status !== "running") return { pid: null, identity: null }; + return readJobPidSidecar(cwd, job.id) ?? { pid: null, identity: null }; +} +``` + +Ticket lock (posix only): в записи владельца добавить `identity: process.platform === "win32" ? null : getProcessIdentity(process.pid)`; в `judgeLockEntry` после `alive === true`: `if (owner.identity) { const actual = getProcessIdentity(owner.pid); if (actual && actual !== owner.identity) return LOCK_ENTRY_ABANDONED; }` — `// ponytail: win32 lock entries stay PID-liveness only`. + +- [ ] **Step 5: Implement `tracked-jobs.mjs`, `codex-companion.mjs`, `broker-lifecycle.mjs`, `session-lifecycle-hook.mjs`.** + +`runTrackedJob`: рядом с `pid: process.pid` → `pidIdentity: getProcessIdentity(process.pid)`; каждое `pid: null` → `pidIdentity: null`. `reapDeadJobs`: + +```js + const { pid, identity } = resolveJobPid(workspaceRoot, job); + if (isPidAlive(pid) === false || isQueuedWithoutWorker(job, pid)) { + return markJobDead(workspaceRoot, job, DEAD_WORKER_MESSAGE, waitFor()); + } + if (pid && identity) { + let actual = null; + try { actual = (options.getProcessIdentityImpl ?? getProcessIdentity)(pid, { timeoutMs: Math.min(2000, remainingMs ? Math.max(0, remainingMs()) : 2000) }); } + catch { actual = null; } + if (actual && actual !== identity) { + return markJobDead(workspaceRoot, job, `${DEAD_WORKER_MESSAGE} (worker pid ${pid} reused by another process)`, waitFor()); + } + } + return job; +``` + +(`markJobDead` должен положить причину в `errorMessage`; проверить сигнатуру — если она принимает только message, текст `pid reused` попадёт в `errorMessage` через неё.) + +`enqueueBackgroundTask`: `updateJobPid(job.workspaceRoot, job.id, child.pid, getProcessIdentity(child.pid))`. `handleCancel`: + +```js + const { pid, identity } = resolveJobPid(workspaceRoot, job); + const kill = terminateRecordedProcess(pid, { identity, commandLineMatch: new RegExp(`task-worker.*--job-id ${job.id}(\\s|$)`) }); + if (pid && !kill.attempted) { + appendLogLine(job.logFile, `worker pid ${pid} left running: ${kill.reason}`); + } + ... // payload/rendered: добавить строку `worker pid ${pid} left running: ${kill.reason}` когда !kill.attempted +``` + +`ensureBrokerSession`: `pidIdentity: getProcessIdentity(child.pid)` в сохраняемую сессию; `teardownBrokerSession({ ..., pidIdentity = null })`: заменить блок kill на `const outcome = terminateRecordedProcess(pid, { identity: pidIdentity, commandLineMatch: (line) => line.includes("app-server-broker.mjs") && (!endpoint || line.includes(endpoint)), timeoutMs, killImpl: killProcess ? (p) => killProcess(p) : undefined })` и вернуть `{ signalled: outcome.attempted && outcome.delivered, reason: outcome.reason }`. Внимание: `killProcess` в хуке = `terminateProcessTree` (принимает pid) — `killImpl` в `terminateProcessTree` ожидает сигнатуру `process.kill`; поэтому в `terminateRecordedProcess` добавить опцию `terminateImpl` (default `terminateProcessTree`) и в teardown передавать `terminateImpl: killProcess`. + +`session-lifecycle-hook.mjs`: `teardownBrokerSession({ ..., pidIdentity: brokerSession?.pidIdentity ?? null, ... })`; строка решения: `signalled=${teardown.signalled} reason=${teardown.reason}`. `cleanupSessionJobs`: `const { pid, identity } = resolveJobPid(workspaceRoot, job); terminateRecordedProcess(pid, { identity, commandLineMatch: new RegExp(\`task-worker.*--job-id ${job.id}(\\s|$)\`), timeoutMs: Math.min(2000, lockWaitMs ?? 2000) })`. + +- [ ] **Step 6: Run** → PASS; полный гейт; `sleep 10; pgrep -f codex-plugin-test-` = 0. +- [ ] **Step 7: Commit** `fix(process): identity-checked kills and reaping on posix; identity in pid sidecar, job records and broker.json`. + +--- + +### Task 10: Release v1.3.0 + +**Files:** `package.json`, `package-lock.json`, `plugins/codex/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` (через `scripts/bump-version.mjs`), `CHANGELOG.md`, `plugins/codex/CHANGELOG.md`, `README.md`, `docs/superpowers/triage/2026-09-27-upstream-triage.md` (статусы `fixed-in v1.3.0`). + +- [ ] **Step 1:** `node scripts/bump-version.mjs 1.3.0 && npm run check-version`. +- [ ] **Step 2:** CHANGELOG `## 1.3.0 (2026-MM-DD)` — по одному bullet на задачу с upstream-номерами (формат как в 1.2.0); `plugins/codex/CHANGELOG.md` синхронизировать с корневым (скопировать секцию 1.3.0 и предыдущие, чтобы файл перестал быть «1.0.0»). +- [ ] **Step 3:** Полный гейт + `claude plugin validate . --strict` + `npm audit --omit=dev` + `npm pack --dry-run`. +- [ ] **Step 4:** Claude code review (pr-review-toolkit: code-reviewer, silent-failure-hunter, pr-test-analyzer) по `git diff main...release/v1.3.0`; исправить блокеры. +- [ ] **Step 5:** `/codex:adversarial-review --base main --effort max` из установленного плагина на `main` (worktree `release/v1.3.0` как `--cwd`); DO-NOT-SHIP → исправить и повторить. +- [ ] **Step 6:** Ручной smoke в свежей сессии Claude Code после `claude plugin update`: `/codex:status`, `/codex:rescue --effort low Strictly read-only: reply PONG`, `/codex:review --background` → `/codex:result`, `/codex:setup --review-gate-model spark`. +- [ ] **Step 7:** По команде пользователя: push, PR `release/v1.3.0 → main`, merge, tag `v1.3.0`, `gh release create` по `docs/RELEASING.md`; черновик `docs/superpowers/triage/upstream-comments-v1.3.0.md` → одобрение → `gh issue comment`. + +## Self-review (выполнено при написании) + +- Spec coverage: пункты 1–8 v1.3.0 спека → Task 1–8; пункт identity → Task 9; release → Task 10. #750 («already imported») намеренно оставлен на v1.5.0 (в спеке «часть в v1.5.0»). +- Placeholder scan: нет TBD/TODO; каждый код-шаг содержит код. +- Type consistency: `resolveJobPid` везде возвращает `{ pid, identity }`; `terminateRecordedProcess` → `{ attempted, delivered, reason }`; `teardownBrokerSession` → `{ signalled, reason }`; `loadModelCatalog` → массив `{ slug, visibility, priority, efforts }`. +- Review Focus: 1 → Task 1 (`error-notification-retry`), 2 → Task 2, 3 → Task 4 (третий тест), 4 → Task 3, 5 → Task 8 (второй тест). From bc7720941e09d904290aabb5c0e3d25ecf87b896 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:53:31 +0300 Subject: [PATCH 02/36] fix(runtime): terminal error notifications, errorMessage on silent turn failure, fileChange guard - An `error` notification without `willRetry: true` now ends the turn as failed instead of waiting for a turn/completed that never comes (#698). - A task whose turn fails without an error payload records an errorMessage and a summary naming the turn status, not the first line of rawOutput (#757). The job status report shows the error for failed jobs. - `fileChange` started items without `changes` no longer crash progress (#775). - Test helper `run()` now forwards `timeout` to spawnSync. Co-authored-by: ALV0612 Co-authored-by: Soumya95 Co-authored-by: kevin9327 Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 10 ++++- plugins/codex/scripts/lib/codex.mjs | 22 +++++++--- plugins/codex/scripts/lib/render.mjs | 3 ++ tests/fake-codex-fixture.mjs | 38 ++++++++++++++++ tests/helpers.mjs | 1 + tests/runtime.test.mjs | 53 +++++++++++++++++++++++ 6 files changed, 120 insertions(+), 7 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index d3641f6d1..c40e7041c 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -665,7 +665,10 @@ async function executeTaskRun(request) { }); const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; - const failureMessage = result.error?.message ?? result.stderr ?? ""; + const turnStatus = result.turnStatus ?? null; + const failureMessage = + result.error?.message ?? + (result.status !== 0 ? (result.stderr || `Codex turn ended with status "${turnStatus ?? "failed"}"`) : ""); const rendered = renderTaskResult( { rawOutput, @@ -694,7 +697,10 @@ async function executeTaskRun(request) { payload, rendered, errorMessage: failureMessage || null, - summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), + summary: + result.status === 0 + ? firstMeaningfulLine(rawOutput, `${taskMetadata.title} finished.`) + : firstMeaningfulLine(failureMessage, firstMeaningfulLine(rawOutput, `${taskMetadata.title} failed.`)), jobTitle: taskMetadata.title, jobClass: "task", write: Boolean(request.write) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index f2a4a3775..2c39d597b 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -299,8 +299,10 @@ function describeStartedItem(state, item) { message: `Running command: ${shorten(item.command, 96)}`, phase: looksLikeVerificationCommand(item.command) ? "verifying" : "running" }; - case "fileChange": - return { message: `Applying ${item.changes.length} file change(s).`, phase: "editing" }; + case "fileChange": { + const count = Array.isArray(item.changes) ? item.changes.length : 0; + return { message: `Applying ${count} file change(s).`, phase: "editing" }; + } case "mcpToolCall": return { message: `Calling ${item.server}/${item.tool}.`, phase: "investigating" }; case "dynamicToolCall": @@ -588,10 +590,19 @@ function applyTurnNotification(state, message) { emitProgress(state.onProgress, update?.message, update?.phase ?? null); } break; - case "error": - state.error = message.params.error; - emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed"); + case "error": { + const error = message.params.error ?? { message: "Codex reported an error." }; + state.error = error; + if (message.params.willRetry === true) { + emitProgress(state.onProgress, `Codex error (retrying): ${error.message}`, null); + break; + } + emitProgress(state.onProgress, `Codex error: ${error.message}`, "failed"); + // Terminal: no turn/completed follows a non-retried error (#698). completeTurn + // is idempotent, so a late turn/completed is harmless. + completeTurn(state, { id: state.turnId ?? "errored-turn", status: "failed", error }); break; + } case "turn/completed": if ((message.params.threadId ?? null) !== state.threadId) { state.activeSubagentTurns.delete(message.params.threadId); @@ -1369,6 +1380,7 @@ export async function runAppServerTurn(cwd, options = {}) { return { status: buildResultStatus(turnState), + turnStatus: turnState.finalTurn?.status ?? null, threadId, turnId: turnState.turnId, resolved, diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec185236..89c597ddc 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -126,6 +126,9 @@ function pushJobDetails(lines, job, options = {}) { if (job.summary) { lines.push(` Summary: ${job.summary}`); } + if (job.status === "failed" && job.errorMessage) { + lines.push(` Error: ${job.errorMessage}`); + } if (job.phase) { lines.push(` Phase: ${job.phase}`); } diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 507bfb401..f4dcf5187 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -513,6 +513,44 @@ rl.on("line", (line) => { ? structuredReviewPayload(prompt) : taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state")); + if (BEHAVIOR === "error-notification" || BEHAVIOR === "error-notification-retry") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ + method: "error", + params: { + threadId: thread.id, + turnId, + willRetry: BEHAVIOR === "error-notification-retry", + error: { message: "Selected model is at capacity" } + } + }); + if (BEHAVIOR === "error-notification-retry") { + // Codex retried and finished: the earlier error was not terminal. + emitTurnCompleted(thread.id, turnId, [ + { completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } } + ]); + } + // error-notification: no turn/completed ever arrives. + break; + } + if (BEHAVIOR === "file-change-no-changes") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "item/started", params: { threadId: thread.id, turnId, item: { type: "fileChange", id: "fc_" + turnId } } }); + emitTurnCompleted(thread.id, turnId, [ + { completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" } } + ]); + break; + } + if (BEHAVIOR === "turn-failed-silently") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ + method: "item/completed", + params: { threadId: thread.id, turnId, item: { type: "agentMessage", id: "msg_" + turnId, text: JSON.stringify({ error: "quota exhausted" }, null, 2), phase: "final_answer" } } + }); + send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "failed") } }); + break; + } + if ( BEHAVIOR === "with-subagent" || BEHAVIOR === "with-late-subagent-message" || diff --git a/tests/helpers.mjs b/tests/helpers.mjs index d6981197a..41ced54dd 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -18,6 +18,7 @@ export function run(command, args, options = {}) { env: options.env, encoding: "utf8", input: options.input, + timeout: options.timeout, shell: options.shell ?? (process.platform === "win32" && !path.isAbsolute(command)), windowsHide: true }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 791ea3182..b17777777 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3659,3 +3659,56 @@ test("cancel removes the private request payload of a job killed in the queued w 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("node", [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("node", [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, /./); +}); + +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("node", [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("node", [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("node", [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "15000", "--json"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(done.status, 0, done.stderr); + const status = run("node", [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"/); +}); From d82fe1efb88188f209694b14f57ed9f64aa68909 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:58:39 +0300 Subject: [PATCH 03/36] fix(runtime): retried errors leave no errorMessage; status prints Error only when it adds information Co-authored-by: ALV0612 Co-authored-by: Soumya95 Co-authored-by: kevin9327 Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/codex.mjs | 2 +- plugins/codex/scripts/lib/render.mjs | 3 ++- tests/render.test.mjs | 12 +++++++++++- tests/runtime.test.mjs | 3 +++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 2c39d597b..5772e336d 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -592,11 +592,11 @@ function applyTurnNotification(state, message) { break; case "error": { const error = message.params.error ?? { message: "Codex reported an error." }; - state.error = error; if (message.params.willRetry === true) { emitProgress(state.onProgress, `Codex error (retrying): ${error.message}`, null); break; } + state.error = error; emitProgress(state.onProgress, `Codex error: ${error.message}`, "failed"); // Terminal: no turn/completed follows a non-retried error (#698). completeTurn // is idempotent, so a late turn/completed is harmless. diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 89c597ddc..2cea06032 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -126,7 +126,8 @@ function pushJobDetails(lines, job, options = {}) { if (job.summary) { lines.push(` Summary: ${job.summary}`); } - if (job.status === "failed" && job.errorMessage) { + const errorText = job.errorMessage?.trim(); + if (job.status === "failed" && errorText && errorText !== job.summary?.trim()) { lines.push(` Error: ${job.errorMessage}`); } if (job.phase) { diff --git a/tests/render.test.mjs b/tests/render.test.mjs index ab68038e5..b9e3f1650 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 { renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; +import { renderJobStatusReport, renderReviewResult, renderStoredJobResult } from "../plugins/codex/scripts/lib/render.mjs"; test("renderReviewResult degrades gracefully when JSON is missing required review fields", () => { const output = renderReviewResult( @@ -57,3 +57,13 @@ test("renderStoredJobResult prefers rendered output for structured review jobs", assert.match(output, /Codex session ID: thr_123/); assert.match(output, /Resume in Codex: codex resume thr_123/); }); + +test("renderJobStatusReport prints Error only for failed jobs whose error adds to the summary", () => { + const base = { id: "task-1", status: "failed", kindLabel: "rescue", title: "Codex Task" }; + const duplicate = renderJobStatusReport({ ...base, summary: "Quota exhausted", errorMessage: " Quota exhausted\n" }); + assert.doesNotMatch(duplicate, /Error:/); + const distinct = renderJobStatusReport({ ...base, summary: "Codex Task failed.", errorMessage: "Quota exhausted" }); + assert.match(distinct, /^ {2}Error: Quota exhausted$/m); + const completed = renderJobStatusReport({ ...base, status: "completed", summary: "Done", errorMessage: "stale" }); + assert.doesNotMatch(completed, /Error:/); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index b17777777..279d52b4c 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3684,6 +3684,9 @@ test("task keeps running through an error notification that Codex will retry", ( const result = run("node", [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); + assert.equal(stored.status, "completed"); + assert.equal(stored.errorMessage, null); }); test("task survives fileChange started items that omit changes (#775)", () => { From 06102ce1bb9a2a800e69b12d7220b36a107081a7 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:05:56 +0300 Subject: [PATCH 04/36] fix(runtime): a subagent's terminal error does not fail the main turn Co-authored-by: ALV0612 Co-authored-by: Soumya95 Co-authored-by: kevin9327 Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/codex.mjs | 10 ++++++++++ tests/fake-codex-fixture.mjs | 12 ++++++++++-- tests/runtime.test.mjs | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 5772e336d..3d057281b 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -596,6 +596,16 @@ function applyTurnNotification(state, message) { emitProgress(state.onProgress, `Codex error (retrying): ${error.message}`, null); break; } + const errorThreadId = message.params.threadId ?? null; + if (errorThreadId && errorThreadId !== state.threadId) { + // A subagent's terminal error ends only that subagent's turn, like its turn/completed. + // An error without a threadId stays terminal for the main turn. + const label = labelForThread(state, errorThreadId) ?? errorThreadId; + emitProgress(state.onProgress, `Subagent ${label} error: ${error.message}`, null); + state.activeSubagentTurns.delete(errorThreadId); + scheduleInferredCompletion(state); + break; + } state.error = error; emitProgress(state.onProgress, `Codex error: ${error.message}`, "failed"); // Terminal: no turn/completed follows a non-retried error (#698). completeTurn diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f4dcf5187..8dd147a37 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -554,7 +554,8 @@ rl.on("line", (line) => { if ( BEHAVIOR === "with-subagent" || BEHAVIOR === "with-late-subagent-message" || - BEHAVIOR === "with-subagent-no-main-turn-completed" + BEHAVIOR === "with-subagent-no-main-turn-completed" || + BEHAVIOR === "subagent-error" ) { const subThread = nextThread(state, thread.cwd, true); const subThreadRecord = ensureThread(state, subThread.id); @@ -622,7 +623,14 @@ rl.on("line", (line) => { } } }); - send({ method: "turn/completed", params: { threadId: subThread.id, turn: buildTurn(subTurnId, "completed") } }); + if (BEHAVIOR === "subagent-error") { + send({ + method: "error", + params: { threadId: subThread.id, turnId: subTurnId, willRetry: false, error: { message: "subagent at capacity" } } + }); + } else { + send({ method: "turn/completed", params: { threadId: subThread.id, turn: buildTurn(subTurnId, "completed") } }); + } send({ method: "item/completed", params: { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 279d52b4c..c81fdb92b 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3715,3 +3715,17 @@ test("a server-side turn failure that terminates normally still records an error assert.doesNotMatch(status.stdout, /Summary: \{$/m); assert.match(status.stdout, /Codex turn ended with status "failed"/); }); + +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("node", [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 = readPersistedJob(repo); + assert.equal(stored.status, "completed"); + assert.equal(stored.errorMessage, null); +}); From 73509cc06a313c781bc4c4c411eabd6ef5460a5c Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:14:59 +0300 Subject: [PATCH 05/36] fix(runtime): gate turn notification buffering on turn start, not on turn id A turn/start response without turn.id left state.turnId null, so every notification stayed buffered and the capture never completed (#781). Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/codex.mjs | 5 ++++- tests/fake-codex-fixture.mjs | 2 +- tests/runtime.test.mjs | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 3d057281b..04e9082cc 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -15,6 +15,7 @@ * threadTurnIds: Map, * threadLabels: Map, * turnId: string | null, + * started: boolean, * bufferedNotifications: AppServerNotification[], * completion: Promise, * resolveCompletion: (state: TurnCaptureState) => void, @@ -371,6 +372,7 @@ function createTurnCaptureState(threadId, options = {}) { threadTurnIds: new Map(), threadLabels: new Map(), turnId: null, + started: false, bufferedNotifications: [], completion, resolveCompletion, @@ -739,7 +741,7 @@ async function captureTurn(client, threadId, startRequest, options = {}) { let timeoutTimer = null; client.setNotificationHandler((message) => { - if (!state.turnId) { + if (!state.started) { state.bufferedNotifications.push(message); return; } @@ -766,6 +768,7 @@ async function captureTurn(client, threadId, startRequest, options = {}) { if (state.turnId) { state.threadTurnIds.set(state.threadId, state.turnId); } + state.started = true; for (const message of state.bufferedNotifications) { if (belongsToTurn(state, message)) { applyTurnNotification(state, message); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 8dd147a37..4c839f2cd 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -507,7 +507,7 @@ rl.on("line", (line) => { prompt }; saveState(state); - send({ id: message.id, result: { turn: buildTurn(turnId) } }); + send({ id: message.id, result: { turn: BEHAVIOR === "turn-start-without-id" ? { status: "inProgress", items: [] } : buildTurn(turnId) } }); const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict ? structuredReviewPayload(prompt) diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index c81fdb92b..05f0a85d7 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3729,3 +3729,14 @@ test("a subagent's terminal error does not fail the main turn", () => { 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("node", [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, /./); +}); From e06cadb42ac629be9ffc207b1436bcbeae9d2715 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:31:43 +0300 Subject: [PATCH 06/36] fix(broker): bound hung connects; status --wait exits 1 on timeout A broker socket stuck in `connecting` fires neither connect nor error, so waitForBrokerEndpoint and the broker client's initialize could hang forever (#773). Each probe attempt is now bounded by min(500 ms, remaining budget) and destroys its socket on expiry; the broker client rejects with ETIMEDOUT after connectTimeoutMs (default 2000), and withAppServer falls back to a direct app-server on ETIMEDOUT when a broker was requested. `status --wait` reported a timed-out wait with exit 0 and no hint (#774). It now prints "Timed out after s while the job was still running." and exits 1; the --json snapshot is unchanged but exits 1 too. Co-authored-by: kevin9327 Co-Authored-By: Claude Fable 5.1 --- README.md | 2 ++ plugins/codex/scripts/codex-companion.mjs | 10 +++++++ plugins/codex/scripts/lib/app-server.mjs | 20 +++++++++++--- .../codex/scripts/lib/broker-lifecycle.mjs | 26 ++++++++++++++++--- plugins/codex/scripts/lib/codex.mjs | 2 +- tests/app-server.test.mjs | 15 ++++++++++- tests/broker-stale-pid.test.mjs | 16 ++++++++++++ tests/runtime.test.mjs | 19 +++++++++++++- 8 files changed, 100 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fcdb79c8e..a78aa6c4a 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,8 @@ Use it to: - see the latest completed job - confirm whether a task is still running +`status --wait [--timeout-ms ]` blocks until the job reaches a terminal status; it exits 1 when the wait times out while the job is still running (with `--json` too, whose snapshot carries `waitTimedOut: true`), and the text output ends with `Timed out after s while the job was still running.` + ### `/codex:result` Shows the final stored Codex output for a finished job. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index c40e7041c..8b67c9455 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -1189,6 +1189,16 @@ async function handleStatus(argv) { pollIntervalMs: options["poll-interval-ms"] }) : buildSingleJobSnapshot(cwd, reference); + if (snapshot.waitTimedOut) { + const seconds = Math.max(1, Math.round(snapshot.timeoutMs / 1000)); + outputCommandResult( + snapshot, + `${renderJobStatusReport(snapshot.job)}\nTimed out after ${seconds}s while the job was still running.\n`, + options.json + ); + process.exitCode = 1; + return; + } outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json); return; } diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index 025bdf969..fecc21277 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -349,7 +349,7 @@ class SpawnedCodexAppServerClient extends AppServerClientBase { } } -class BrokerCodexAppServerClient extends AppServerClientBase { +export class BrokerCodexAppServerClient extends AppServerClientBase { constructor(cwd, options = {}) { super(cwd, options); this.transport = "broker"; @@ -359,13 +359,27 @@ class BrokerCodexAppServerClient extends AppServerClientBase { async initialize() { await new Promise((resolve, reject) => { const target = parseBrokerEndpoint(this.endpoint); - this.socket = net.createConnection({ path: target.path }); + const connectImpl = this.options.connectImpl ?? ((socketPath) => net.createConnection({ path: socketPath })); + const connectTimeoutMs = this.options.connectTimeoutMs ?? 2000; + this.socket = connectImpl(target.path); this.socket.setEncoding("utf8"); - this.socket.on("connect", resolve); + // A socket stuck in `connecting` fires neither connect nor error (#773). + const timer = setTimeout(() => { + const error = Object.assign(new Error(`codex app-server broker connect timed out after ${connectTimeoutMs} ms.`), { + code: "ETIMEDOUT" + }); + this.socket.destroy(); + reject(error); + }, connectTimeoutMs); + this.socket.on("connect", () => { + clearTimeout(timer); + resolve(); + }); this.socket.on("data", (chunk) => { this.handleChunk(chunk); }); this.socket.on("error", (error) => { + clearTimeout(timer); if (!this.exitResolved) { reject(error); } diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index 11092dcd1..b75c14344 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -22,12 +22,30 @@ function connectToEndpoint(endpoint) { return net.createConnection({ path: target.path }); } -export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) { +const PROBE_ATTEMPT_MS = 500; + +export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000, options = {}) { + const connectImpl = options.connectImpl ?? ((socketPath) => net.createConnection({ path: socketPath })); + const target = parseBrokerEndpoint(endpoint); const start = Date.now(); while (Date.now() - start < timeoutMs) { + const attemptMs = Math.max(1, Math.min(PROBE_ATTEMPT_MS, timeoutMs - (Date.now() - start))); const ready = await new Promise((resolve) => { - const socket = connectToEndpoint(endpoint); + const socket = connectImpl(target.path); let connected = false; + let settled = false; + const finish = (value) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(value); + } + }; + // A socket stuck in `connecting` fires neither connect nor error (#773). + const timer = setTimeout(() => { + socket.destroy(); + finish(false); + }, attemptMs); socket.on("connect", () => { connected = true; socket.end(); @@ -35,8 +53,8 @@ export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) { // Report ready only once the probe connection is fully closed. A probe the // broker still sees as open is a phantom client: it holds off the idle // timer and makes the broker refuse a shutdown. - socket.on("close", () => resolve(connected)); - socket.on("error", () => resolve(false)); + socket.on("close", () => finish(connected)); + socket.on("error", () => finish(false)); }); if (ready) { return true; diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 04e9082cc..5f31ba1b2 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -812,7 +812,7 @@ async function withAppServer(cwd, fn, clientOptions = {}) { const brokerRequested = client?.transport === "broker" || Boolean(process.env[BROKER_ENDPOINT_ENV]); const shouldRetryDirect = (client?.transport === "broker" && error?.rpcCode === BROKER_BUSY_RPC_CODE) || - (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED")); + (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED" || error?.code === "ETIMEDOUT")); if (client) { await client.close().catch(() => {}); diff --git a/tests/app-server.test.mjs b/tests/app-server.test.mjs index e9177d733..7ba0e7939 100644 --- a/tests/app-server.test.mjs +++ b/tests/app-server.test.mjs @@ -1,10 +1,11 @@ +import { EventEmitter } from "node:events"; import net from "node:net"; import { test } from "node:test"; import assert from "node:assert/strict"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { makeTempDir } from "./helpers.mjs"; -import { AppServerClientBase, CodexAppServerClient } from "../plugins/codex/scripts/lib/app-server.mjs"; +import { AppServerClientBase, BrokerCodexAppServerClient, CodexAppServerClient } from "../plugins/codex/scripts/lib/app-server.mjs"; import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; /** Minimal client that records the JSON-RPC messages it would send. */ @@ -176,3 +177,15 @@ test("a broker that drops the connection during initialize falls back to a direc client = await CodexAppServerClient.connect(binDir, { brokerEndpoint: endpoint, env: buildEnv(binDir) }); assert.equal(client.transport, "direct", "a broker that hangs up must not fail the run"); }); + +test("broker client connect times out with ETIMEDOUT instead of hanging", async () => { + const connectImpl = () => { + const socket = new EventEmitter(); + socket.setEncoding = () => {}; + socket.destroy = () => socket.emit("close"); + socket.end = () => {}; + return socket; + }; + const client = new BrokerCodexAppServerClient(process.cwd(), { brokerEndpoint: "unix:/nonexistent.sock", connectImpl, connectTimeoutMs: 200 }); + await assert.rejects(client.initialize(), (error) => error.code === "ETIMEDOUT"); +}); diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 150073d6c..79d71ae80 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { EventEmitter } from "node:events"; import net from "node:net"; import path from "node:path"; import test from "node:test"; @@ -905,3 +906,18 @@ test( } } ); + +test("waitForBrokerEndpoint gives up on a socket that never connects or errors (#773)", async () => { + let destroyed = 0; + const connectImpl = () => { + const socket = new EventEmitter(); + socket.destroy = () => { destroyed += 1; socket.emit("close"); }; + socket.end = () => {}; + return socket; + }; + const started = Date.now(); + const ready = await waitForBrokerEndpoint("unix:/nonexistent/broker.sock", 600, { connectImpl }); + assert.equal(ready, false); + assert.ok(Date.now() - started < 1500, "must respect the overall timeout"); + assert.ok(destroyed >= 1, "hung probe sockets must be destroyed"); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 05f0a85d7..5632c7e4e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1595,7 +1595,8 @@ test("status --wait times out cleanly when a job is still active", () => { cwd: workspace }); - assert.equal(result.status, 0, result.stderr); + // 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"); @@ -3740,3 +3741,19 @@ test("task completes when the turn/start response carries no turn id (#781)", () assert.equal(result.status, 0, result.stderr); assert.match(JSON.parse(result.stdout).rawOutput, /./); }); + +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("node", [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("node", [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("node", [SCRIPT, "result", jobId, "--wait", "--timeout-ms", "20000"], { cwd: repo, env }); + assert.equal(done.status, 0, done.stderr); +}); From 5f4085e0979cc2d061921a01ede472eba2aef0a0 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:45:57 +0300 Subject: [PATCH 07/36] fix(broker): kill a live wedged broker on replace, retry the readiness probe, never signal a stale pid ensureBrokerSession now defaults killProcess to terminateProcessTree, so a replaced or never-ready broker is no longer leaked (#753/#762). A live, owned broker that misses the 150 ms probe gets a full retry window before it is treated as wedged (#768). A dead or foreign pid from a stale record is never signalled; only its files are cleared (#749). A probe socket that connected but closes slowly now reads as ready. Co-authored-by: Soumya95 Co-authored-by: mzl9039 Co-authored-by: sylvesterkaczmarek Co-Authored-By: Claude Fable 5.1 --- .../codex/scripts/lib/broker-lifecycle.mjs | 35 ++++-- tests/broker-stale-pid.test.mjs | 100 ++++++++++++++++++ 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index b75c14344..d7488fb84 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -6,7 +6,7 @@ import process from "node:process"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createBrokerEndpoint, parseBrokerEndpoint } from "./broker-endpoint.mjs"; -import { processCommandLine } from "./process.mjs"; +import { isPidAlive, processCommandLine, terminateProcessTree } from "./process.mjs"; import { resolveStateDir } from "./state.mjs"; export const PID_FILE_ENV = "CODEX_COMPANION_APP_SERVER_PID_FILE"; @@ -42,9 +42,10 @@ export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000, options } }; // A socket stuck in `connecting` fires neither connect nor error (#773). + // A socket that already connected but closes slowly is still a live broker. const timer = setTimeout(() => { socket.destroy(); - finish(false); + finish(connected); }, attemptMs); socket.on("connect", () => { connected = true; @@ -178,20 +179,38 @@ async function isBrokerEndpointReady(endpoint) { } } +const STALE_BROKER_RETRY_MS = 2000; + export async function ensureBrokerSession(cwd, options = {}) { + const killProcess = options.killProcess ?? terminateProcessTree; + const isAliveImpl = options.isAliveImpl ?? isPidAlive; + const ownsProcessImpl = options.ownsProcessImpl ?? ownsBrokerProcess; const existing = loadBrokerSession(cwd); if (existing && (await isBrokerEndpointReady(existing.endpoint))) { return existing; } if (existing) { + const pid = Number.isFinite(existing.pid) ? existing.pid : null; + const liveOwned = pid !== null && isAliveImpl(pid) === true && ownsProcessImpl(pid, existing.endpoint ?? null, options.timeoutMs); + // A live broker that missed the 150 ms probe is not a dead one (#768): give it the + // full window before deciding it is wedged. + if (liveOwned) { + const ready = await waitForBrokerEndpoint(existing.endpoint, options.retryTimeoutMs ?? STALE_BROKER_RETRY_MS).catch(() => false); + if (ready) { + return existing; + } + } teardownBrokerSession({ endpoint: existing.endpoint ?? null, pidFile: existing.pidFile ?? null, logFile: existing.logFile ?? null, sessionDir: existing.sessionDir ?? null, - pid: existing.pid ?? null, - killProcess: options.killProcess ?? null + // Only a live broker that is provably ours gets a signal (#762); a dead or + // recycled pid is left alone (#749) — the files are stale either way. + pid: liveOwned ? pid : null, + killProcess: liveOwned ? killProcess : null, + ownsProcess: () => true }); clearBrokerSession(cwd); } @@ -222,7 +241,7 @@ export async function ensureBrokerSession(cwd, options = {}) { logFile, sessionDir, pid: child.pid ?? null, - killProcess: options.killProcess ?? null + killProcess }); return null; } @@ -243,7 +262,7 @@ export async function ensureBrokerSession(cwd, options = {}) { // record behind long enough for the OS to hand the PID — and with it the process // group `terminateProcessTree` kills — to something unrelated. Windows has no // cheap equivalent probe, so it keeps the previous unconditional behavior. -function ownsBrokerProcess(pid, endpoint, timeoutMs) { +export function ownsBrokerProcess(pid, endpoint, timeoutMs) { if (process.platform === "win32") { return true; } @@ -257,9 +276,9 @@ function ownsBrokerProcess(pid, endpoint, timeoutMs) { // Reports whether the recorded process was actually signalled: a PID that no // longer looks like this broker is deliberately left alone, and a caller that // wonders why a broker outlived its teardown needs to know which it was. -export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null, timeoutMs = undefined }) { +export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null, timeoutMs = undefined, ownsProcess = ownsBrokerProcess }) { let signalled = false; - if (Number.isFinite(pid) && killProcess && ownsBrokerProcess(pid, endpoint, timeoutMs)) { + if (Number.isFinite(pid) && killProcess && ownsProcess(pid, endpoint, timeoutMs)) { try { killProcess(pid); signalled = true; diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 79d71ae80..1f682ea05 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -12,6 +12,7 @@ import { makeTempDir, run } from "./helpers.mjs"; import { createBrokerEndpoint, parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; import { clearBrokerSession, + ensureBrokerSession, loadBrokerSession, saveBrokerSession, sendBrokerShutdown, @@ -921,3 +922,102 @@ test("waitForBrokerEndpoint gives up on a socket that never connects or errors ( assert.ok(Date.now() - started < 1500, "must respect the overall timeout"); assert.ok(destroyed >= 1, "hung probe sockets must be destroyed"); }); + +test("waitForBrokerEndpoint reads a connected probe whose close is slow as ready", async () => { + const connectImpl = () => { + const socket = new EventEmitter(); + socket.destroy = () => {}; + socket.end = () => {}; + setImmediate(() => socket.emit("connect")); + return socket; + }; + const started = Date.now(); + const ready = await waitForBrokerEndpoint("unix:/nonexistent/broker.sock", 600, { connectImpl }); + assert.equal(ready, true); + assert.ok(Date.now() - started < 1500, "must resolve within the attempt window"); +}); + +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); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + const staleEndpoint = createBrokerEndpoint(sessionDir); // nothing listens here + saveBrokerSession(workspace, { endpoint: staleEndpoint, pidFile: path.join(sessionDir, "broker.pid"), logFile: path.join(sessionDir, "broker.log"), sessionDir, pid: process.pid }); + const killed = []; + let probes = 0; + const session = await ensureBrokerSession(workspace, { + env: buildEnv(binDir), + isAliveImpl: () => true, + ownsProcessImpl: () => { probes += 1; return true; }, + killProcess: (pid) => { killed.push(pid); }, + retryTimeoutMs: 300 + }); + try { + assert.deepEqual(killed, [process.pid], "the unreachable but live broker must be signalled"); + assert.ok(probes >= 1); + assert.ok(session && session.endpoint !== staleEndpoint, "a fresh broker must be spawned"); + assert.equal(loadBrokerSession(workspace)?.endpoint, session.endpoint); + } finally { + if (session?.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } + clearBrokerSession(workspace); + } +}); + +test("ensureBrokerSession never signals a dead or foreign pid from a stale record (#749)", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + saveBrokerSession(workspace, { endpoint: createBrokerEndpoint(sessionDir), pidFile: null, logFile: null, sessionDir, pid: deadPid() }); + const killed = []; + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), killProcess: (pid) => killed.push(pid) }); + try { + assert.deepEqual(killed, []); + assert.ok(session); + } finally { + if (session?.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } + clearBrokerSession(workspace); + } +}); + +test("ensureBrokerSession retries the readiness probe before giving up on a slow broker (#768)", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + const endpoint = createBrokerEndpoint(sessionDir); + const server = net.createServer((socket) => socket.end()); + saveBrokerSession(workspace, { endpoint, pidFile: null, logFile: null, sessionDir, pid: process.pid }); + const killed = []; + const sessionPromise = ensureBrokerSession(workspace, { + env: buildEnv(binDir), + isAliveImpl: () => true, + ownsProcessImpl: () => true, + killProcess: (pid) => killed.push(pid), + retryTimeoutMs: 2000 + }); + let session = null; + try { + // Not listening yet during the first 150 ms probe; the 2 s retry must catch it. + await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(parseBrokerEndpoint(endpoint).path, resolve); + }); + session = await sessionPromise; + assert.deepEqual(killed, [], "a broker that answers within the retry window must not be killed"); + assert.equal(session.endpoint, endpoint); + } finally { + session ??= await sessionPromise.catch(() => null); + if (session?.pid && session.pid !== process.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } + server.close(); + clearBrokerSession(workspace); + } +}); From cac1b2b9edddd8497d4a3fd8f861640b6ff07d1c Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:57:18 +0300 Subject: [PATCH 08/36] feat(stop-gate): pin model/effort, bound rounds by default, name signal and escape hatch; drop hooks.json description - setup --review-gate-model/--review-gate-effort (inherit clears); the stop gate forwards them to its review task (#769) - CODEX_REVIEW_GATE_MAX_ROUNDS defaults to 3; explicit 0 keeps unbounded (#548) - failure reasons name the kill signal and end with the disable hint (#589, #483) - hooks.json drops the top-level description key (#459) Co-authored-by: mittalpk Co-authored-by: SomSamantray Co-Authored-By: Claude Fable 5.1 --- README.md | 15 ++++-- plugins/codex/commands/setup.md | 2 +- plugins/codex/hooks/hooks.json | 1 - plugins/codex/scripts/codex-companion.mjs | 16 +++++- plugins/codex/scripts/lib/render.mjs | 1 + .../codex/scripts/stop-review-gate-hook.mjs | 50 +++++++++++------ tests/commands.test.mjs | 3 +- tests/runtime.test.mjs | 53 +++++++++++++++++++ 8 files changed, 118 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a78aa6c4a..9af08c7a6 100644 --- a/README.md +++ b/README.md @@ -247,21 +247,30 @@ You can also use `/codex:setup` to manage the optional review gate. /codex:setup --disable-review-gate ``` -When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. +When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. When the review itself fails (timeout, killed by a signal, invalid output), the block reason says why and ends with `Disable with /codex:setup --disable-review-gate.` + +To pin the model and reasoning effort the gate's review uses, independently of your Codex config: + +```bash +/codex:setup --review-gate-model spark --review-gate-effort low +/codex:setup --review-gate-model inherit --review-gate-effort inherit +``` + +Model aliases (`spark`, `sol`, `luna`, `terra`, `mini`) resolve the same way as for `/codex:rescue`; `inherit` clears the pin so the review uses your Codex config again. > [!WARNING] > The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session. #### Bounding the review gate -By default the gate keeps blocking the stop until Codex is satisfied, which is what can create the loop above. Set `CODEX_REVIEW_GATE_MAX_ROUNDS` to cap how many consecutive gate rounds run in a single session before the stop is allowed through: +By default the gate blocks at most 3 consecutive rounds in a single session, then lets the stop through. Set `CODEX_REVIEW_GATE_MAX_ROUNDS` to change that cap: ```bash # allow at most 5 stop-gate review rounds per session, then let the stop proceed export CODEX_REVIEW_GATE_MAX_ROUNDS=5 ``` -When unset or `0`, the gate is unbounded (the previous behavior). The count is per session, increments on each blocked round (tracked via `stop_hook_active`), and resets once a stop is allowed or a fresh user turn begins. +When unset, the cap is 3. Set it to `0` explicitly to keep the gate unbounded (the pre-1.3.0 behavior). The count is per session, increments on each blocked round (tracked via `stop_hook_active`), and resets once a stop is allowed or a fresh user turn begins. ## Typical Flows diff --git a/plugins/codex/commands/setup.md b/plugins/codex/commands/setup.md index 2ebbb0cb6..944f5c5dd 100644 --- a/plugins/codex/commands/setup.md +++ b/plugins/codex/commands/setup.md @@ -1,6 +1,6 @@ --- description: Check whether the local Codex CLI is ready and optionally toggle the stop-time review gate -argument-hint: '[--enable-review-gate|--disable-review-gate]' +argument-hint: '[--enable-review-gate|--disable-review-gate] [--review-gate-model ] [--review-gate-effort ]' allowed-tools: Bash(node:*), Bash(npm:*), AskUserQuestion --- diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 0c0e1a5f8..595328444 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -1,5 +1,4 @@ { - "description": "Optional stop-time review gate for Codex Companion.", "hooks": { "SessionStart": [ { diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 8b67c9455..6536fbf7e 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -106,7 +106,7 @@ function printUsage() { console.log( [ "Usage:", - " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", + " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--review-gate-model ] [--review-gate-effort ] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]...", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [focus text]", " node scripts/codex-companion.mjs task [--background|--await [--await-timeout-ms ]] [--prompt-stdin] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [prompt]", @@ -305,6 +305,8 @@ async function buildSetupReport(cwd, actionsTaken = []) { auth: authStatus, sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot), reviewGateEnabled: Boolean(config.stopReviewGate), + reviewGateModel: config.stopReviewGateModel ?? null, + reviewGateEffort: config.stopReviewGateEffort ?? null, actionsTaken, nextSteps }; @@ -312,7 +314,7 @@ async function buildSetupReport(cwd, actionsTaken = []) { async function handleSetup(argv) { const { options } = parseCommandInput(argv, { - valueOptions: ["cwd"], + valueOptions: ["cwd", "review-gate-model", "review-gate-effort"], booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] }); if (maybePrintCommandHelp(options)) { @@ -334,6 +336,16 @@ async function handleSetup(argv) { setConfig(workspaceRoot, "stopReviewGate", false); actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); } + if (options["review-gate-model"] != null) { + const value = String(options["review-gate-model"]).trim().toLowerCase() === "inherit" ? null : normalizeRequestedModel(options["review-gate-model"]); + setConfig(workspaceRoot, "stopReviewGateModel", value); + actionsTaken.push(value ? `Stop-time review gate model set to ${value}.` : "Stop-time review gate model now inherits Codex config."); + } + if (options["review-gate-effort"] != null) { + const value = String(options["review-gate-effort"]).trim().toLowerCase() === "inherit" ? null : normalizeReasoningEffort(options["review-gate-effort"]); + setConfig(workspaceRoot, "stopReviewGateEffort", value); + actionsTaken.push(value ? `Stop-time review gate effort set to ${value}.` : "Stop-time review gate effort now inherits Codex config."); + } const finalReport = await buildSetupReport(cwd, actionsTaken); outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json); diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2cea06032..55dbc88e1 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -191,6 +191,7 @@ export function renderSetupReport(report) { `- auth: ${report.auth.detail}`, `- session runtime: ${report.sessionRuntime.label}`, `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, + `- review gate model/effort: ${report.reviewGateModel ?? "inherit"} / ${report.reviewGateEffort ?? "inherit"}`, "" ]; diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index ebe185a4f..ca8a4cf1e 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -15,6 +15,11 @@ import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; const STOP_REVIEW_TIMEOUT_MINUTES = 13; const STOP_REVIEW_TIMEOUT_MS = STOP_REVIEW_TIMEOUT_MINUTES * 60 * 1000; +// Tests only: shorten the review timeout instead of waiting the full 13 minutes. +const STOP_REVIEW_TIMEOUT_OVERRIDE_MS = Number(process.env.CODEX_STOP_REVIEW_TIMEOUT_MS) > 0 ? Number(process.env.CODEX_STOP_REVIEW_TIMEOUT_MS) : 0; +const DEFAULT_MAX_ROUNDS = 3; +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."; @@ -39,15 +44,15 @@ function logNote(message) { process.stderr.write(`${message}\n`); } -// Optional cap on how many consecutive stop-gate rounds run in one session. -// Unset or 0 keeps the previous unbounded behavior. +// Cap on how many consecutive gate-induced rounds run in one session. +// Unset or invalid → DEFAULT_MAX_ROUNDS; an explicit 0 keeps the rounds unbounded. function getMaxRounds() { const raw = process.env.CODEX_REVIEW_GATE_MAX_ROUNDS; if (raw == null || raw === "") { - return 0; + return DEFAULT_MAX_ROUNDS; } const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_ROUNDS; } function gateSessionId(input) { @@ -108,7 +113,7 @@ function parseStopReviewOutput(rawOutput) { return { ok: false, reason: - "The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate." + `The stop-time Codex review task returned no final output. ${MANUAL_HINT}` }; } @@ -127,31 +132,46 @@ function parseStopReviewOutput(rawOutput) { return { ok: false, reason: - "The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate." + `The stop-time Codex review task returned an unexpected answer. ${MANUAL_HINT}` }; } -function runStopReview(cwd, input = {}) { +function runStopReview(cwd, input = {}, config = {}) { const scriptPath = path.join(SCRIPT_DIR, "codex-companion.mjs"); const prompt = buildStopReviewPrompt(input); const childEnv = { ...process.env, ...(input.session_id ? { [SESSION_ID_ENV]: input.session_id } : {}) }; - const result = spawnSync(process.execPath, [scriptPath, "task", "--json", prompt], { + const args = [scriptPath, "task", "--json"]; + if (config.stopReviewGateModel) { + args.push("--model", config.stopReviewGateModel); + } + if (config.stopReviewGateEffort) { + args.push("--effort", config.stopReviewGateEffort); + } + args.push(prompt); + const result = spawnSync(process.execPath, args, { cwd, env: childEnv, encoding: "utf8", - timeout: STOP_REVIEW_TIMEOUT_MS, + timeout: STOP_REVIEW_TIMEOUT_OVERRIDE_MS || STOP_REVIEW_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: 16 * 1024 * 1024 }); if (result.error?.code === "ETIMEDOUT") { + const limit = STOP_REVIEW_TIMEOUT_OVERRIDE_MS ? `${STOP_REVIEW_TIMEOUT_OVERRIDE_MS} ms` : `${STOP_REVIEW_TIMEOUT_MINUTES} minutes`; return { ok: false, - reason: - `The stop-time Codex review task timed out after ${STOP_REVIEW_TIMEOUT_MINUTES} minutes. Run /codex:review --wait manually or bypass the gate.` + reason: `The stop-time Codex review task timed out after ${limit} and was terminated by signal ${result.signal ?? "SIGKILL"}. ${MANUAL_HINT}` + }; + } + + if (result.signal) { + return { + ok: false, + reason: `The stop-time Codex review task was terminated by signal ${result.signal}. ${MANUAL_HINT}` }; } @@ -160,8 +180,8 @@ function runStopReview(cwd, input = {}) { return { ok: false, reason: detail - ? `The stop-time Codex review task failed: ${detail}` - : "The stop-time Codex review task failed. Run /codex:review --wait manually or bypass the gate." + ? `The stop-time Codex review task failed: ${detail} ${ESCAPE_HATCH}` + : `The stop-time Codex review task failed. ${MANUAL_HINT}` }; } @@ -172,7 +192,7 @@ function runStopReview(cwd, input = {}) { return { ok: false, reason: - "The stop-time Codex review task returned invalid JSON. Run /codex:review --wait manually or bypass the gate." + `The stop-time Codex review task returned invalid JSON. ${MANUAL_HINT}` }; } } @@ -225,7 +245,7 @@ function main() { return; } - const review = runStopReview(cwd, input); + const review = runStopReview(cwd, input, config); if (!review.ok) { writeGateRounds(workspaceRoot, sessionId, priorRounds + 1); emitDecision({ diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 7e96ede61..1a7f0e4a4 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -237,6 +237,7 @@ test("hooks keep session-end cleanup and stop gating enabled", () => { assert.match(source, /SessionEnd/); assert.match(source, /stop-review-gate-hook\.mjs/); assert.match(source, /session-lifecycle-hook\.mjs/); + assert.equal("description" in JSON.parse(source), false); }); test("session start hook allows enough time to restore session state", () => { @@ -250,7 +251,7 @@ test("setup command can offer Codex install and still points users to codex logi const setup = read("commands/setup.md"); const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); - assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\]'/); + assert.match(setup, /argument-hint:\s*'\[--enable-review-gate\|--disable-review-gate\] \[--review-gate-model \] \[--review-gate-effort \]'/); assert.match(setup, /AskUserQuestion/); assert.match(setup, /npm install -g @openai\/codex/); assert.match(setup, /codex-companion\.mjs" setup --json --args-stdin <<'CODEX_ARGS'/); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5632c7e4e..69bd94311 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2394,6 +2394,59 @@ test("stop hook runs a stop-time review task and blocks on findings when the rev 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("node", [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("node", [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("node", [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("node", [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("node", [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"]); +}); + +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("node", [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("node", [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, From b756bdf0dffff37fabb1525eca372ed5baef784f Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:12:56 +0300 Subject: [PATCH 09/36] fix(transfer): resolve Claude transcripts under CLAUDE_CONFIG_DIR /codex:transfer hardcoded ~/.claude/projects as the only accepted transcript root, so a Claude Code install using CLAUDE_CONFIG_DIR to relocate its config directory could never pass the source-path check (#721). resolveClaudeProjectsDir(env) now derives the projects dir from CLAUDE_CONFIG_DIR (resolved via path.resolve when set) and falls back to ~/.claude/projects otherwise; resolveClaudeSessionPath threads options.env through to it and to the TRANSCRIPT_PATH_ENV lookup, defaulting to process.env. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- .../scripts/lib/claude-session-transfer.mjs | 14 ++++++++++---- tests/runtime.test.mjs | 19 +++++++++++++++++++ tests/test-env.mjs | 3 ++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9af08c7a6..6ff512fb2 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Examples: /codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl ``` -The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. +The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. The transcript root honours `CLAUDE_CONFIG_DIR` when set, resolving to `/projects` instead of `~/.claude/projects`. ### `/codex:status` diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index eea0aeba2..e974dbb3e 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -5,7 +5,11 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH"; -const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); + +export function resolveClaudeProjectsDir(env = process.env) { + const configDir = env.CLAUDE_CONFIG_DIR ? path.resolve(String(env.CLAUDE_CONFIG_DIR)) : path.join(os.homedir(), ".claude"); + return path.join(configDir, "projects"); +} function resolveUserPath(cwd, value) { if (value === "~") { @@ -18,7 +22,9 @@ function resolveUserPath(cwd, value) { } export function resolveClaudeSessionPath(cwd, options = {}) { - const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV]; + const env = options.env ?? process.env; + const projectsDir = resolveClaudeProjectsDir(env); + const requestedPath = options.source || env[TRANSCRIPT_PATH_ENV]; if (!requestedPath) { throw new Error("Could not identify the current Claude transcript. Retry with --source ."); } @@ -32,13 +38,13 @@ export function resolveClaudeSessionPath(cwd, options = {}) { let projects; try { source = fs.realpathSync(sourcePath); - projects = fs.realpathSync(CLAUDE_PROJECTS_DIR); + projects = fs.realpathSync(projectsDir); } catch { throw new Error(`Claude session file not found: ${sourcePath}`); } const relative = path.relative(projects, source); if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`); + throw new Error(`Codex can import Claude sessions only from ${projectsDir}: ${source}`); } return source; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 69bd94311..9a329605d 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { resolveClaudeSessionPath, resolveClaudeProjectsDir } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; import { consumeJobRequestFile, readJobFile, @@ -351,6 +352,24 @@ test("transfer rejects sources outside the Claude projects directory", () => { 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(); diff --git a/tests/test-env.mjs b/tests/test-env.mjs index 47106acb8..c7cc3b7cf 100644 --- a/tests/test-env.mjs +++ b/tests/test-env.mjs @@ -8,7 +8,8 @@ for (const name of [ "CODEX_COMPANION_APP_SERVER_ENDPOINT", "CODEX_COMPANION_APP_SERVER_PID_FILE", "CODEX_COMPANION_APP_SERVER_LOG_FILE", - "CODEX_PLUGIN_CC_ARGS" + "CODEX_PLUGIN_CC_ARGS", + "CLAUDE_CONFIG_DIR" ]) { delete process.env[name]; } From a9d7b2ac2032204ca787e15f2ff925e139bfd4de Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:26:23 +0300 Subject: [PATCH 10/36] feat(models): resolve aliases and validate efforts against the Codex model catalogue Model aliases now resolve against the local Codex catalogue ($CODEX_COMPANION_MODEL_CATALOG -> $CODEX_HOME/models_cache.json -> `codex debug models --bundled` -> hardcoded fallback), picking the listed model whose slug ends in - (lowest priority, newest family on ties). Exact slugs pass through; hidden models never match an alias. --effort is rejected when the catalogued model does not list it. Adds the astra alias. Tests pin the catalogue to a fixture. (#468/#703/#485/#128) Co-Authored-By: Claude Fable 5.1 --- README.md | 12 +-- plugins/codex/agents/codex-rescue.md | 3 +- plugins/codex/commands/adversarial-review.md | 2 +- plugins/codex/commands/rescue.md | 2 +- plugins/codex/commands/review.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 28 +++---- plugins/codex/scripts/lib/model-catalog.mjs | 80 +++++++++++++++++++ .../codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 8 +- tests/fixtures/models-catalog.json | 8 ++ tests/model-catalog.test.mjs | 35 ++++++++ tests/runtime.test.mjs | 22 ++++- tests/test-env.mjs | 2 + 13 files changed, 173 insertions(+), 33 deletions(-) create mode 100644 plugins/codex/scripts/lib/model-catalog.mjs create mode 100644 tests/fixtures/models-catalog.json create mode 100644 tests/model-catalog.test.mjs diff --git a/README.md b/README.md index 6ff512fb2..399c2c013 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Examples: /codex:rescue investigate why the tests started failing /codex:rescue fix the failing test with the smallest safe patch /codex:rescue --resume apply the top fix from the last run -/codex:rescue --model gpt-5.6-terra --effort medium investigate the flaky integration test +/codex:rescue --model gpt-6-astra --effort medium investigate the flaky integration test /codex:rescue --model spark fix the issue quickly /codex:rescue --background investigate the regression ``` @@ -161,8 +161,8 @@ Ask Codex to redesign the database connection to be more resilient. **Notes:** - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. -- `--effort` accepts `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. Which of those a given model actually supports is decided by Codex, not by the plugin — run `codex debug models` to see the reasoning levels each model advertises. -- model aliases: `spark` -> `gpt-5.3-codex-spark`, `sol` -> `gpt-5.6-sol`, `luna` -> `gpt-5.6-luna`, `terra` -> `gpt-5.6-terra`, `mini` -> `gpt-5.4-mini` +- `--effort` accepts `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. Which of those a given model supports comes from the local Codex model catalogue: when `--model` names a catalogued model, the plugin rejects an effort that model does not list, and otherwise leaves the check to Codex — run `codex debug models` to see the reasoning levels each model advertises. +- model aliases resolve against the local Codex model catalogue (`$CODEX_HOME/models_cache.json`, else `codex debug models --bundled`): an alias picks the listed model whose slug ends in `-`, lowest priority first, newest family on ties; today `sol` -> `gpt-6-sol`, `astra` -> `gpt-6-astra`, `luna` -> `gpt-6-luna`, `terra` -> `gpt-5.6-terra`, `spark` -> `gpt-5.3-codex-spark`, `mini` -> `gpt-5.4-mini`; run `codex debug models` to see yours. An exact model slug passes through unchanged, and when the model is in the catalogue `--effort` is checked against the reasoning levels it lists - `--config key=value` (repeatable, also on `/codex:review` and `/codex:adversarial-review`) forwards a `config.toml` override to the Codex thread, e.g. `--config model_provider=ollama`. On `--resume-last` the plugin opens a fresh app-server session (cold resume) so `--config` overrides, sandbox and approval policy take effect; model and effort for the resumed turn are sent on the turn, never on the resume request. In a `--background`/`--await` job record the config **keys** are recorded and the **values** are never stored (they read back as `[redacted]` in `status`/`result`): the real values live only in the job's private 0600 `jobs/.request.json`, which the worker consumes and deletes. - follow-up rescue requests can continue the latest Codex task in the repo - under the hood, `/codex:rescue` and the `codex-rescue` agent are each a single `scripts/codex-companion.mjs task --await --prompt-stdin ` call: `--await [--await-timeout-ms ]` launches the same tracked background job as `--background`, then waits for it (default 540000 ms), and `--prompt-stdin` reads the prompt as stdin verbatim (so it cannot be combined with `--args-stdin`, `--prompt-file`, or prompt text on the command line). Exit code is 0 when the job completed, 1 when it failed or was cancelled, and 3 when the wait times out while the job is still queued or running — exit 3 prints a `Re-run: node "" result --wait --timeout-ms 540000` hint, which is the only follow-up call the rescue flow makes. @@ -256,7 +256,7 @@ To pin the model and reasoning effort the gate's review uses, independently of y /codex:setup --review-gate-model inherit --review-gate-effort inherit ``` -Model aliases (`spark`, `sol`, `luna`, `terra`, `mini`) resolve the same way as for `/codex:rescue`; `inherit` clears the pin so the review uses your Codex config again. +Model aliases (`spark`, `astra`, `sol`, `luna`, `terra`, `mini`) resolve the same way as for `/codex:rescue`; `inherit` clears the pin so the review uses your Codex config again. > [!WARNING] > The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session. @@ -306,10 +306,10 @@ The Codex plugin wraps the [Codex app server](https://developers.openai.com/code ### Common Configurations -If you want to change the default reasoning effort or the default model that gets used by the plugin, you can define that inside your user-level or project-level `config.toml`. For example to always use `gpt-5.6-terra` on `high` for a specific project you can add the following to a `.codex/config.toml` file at the root of the directory you started Claude in: +If you want to change the default reasoning effort or the default model that gets used by the plugin, you can define that inside your user-level or project-level `config.toml`. For example to always use `gpt-6-astra` on `high` for a specific project you can add the following to a `.codex/config.toml` file at the root of the directory you started Claude in: ```toml -model = "gpt-5.6-terra" +model = "gpt-6-astra" model_reasoning_effort = "high" ``` diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 9bc1541c2..800346db0 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -37,7 +37,8 @@ CODEX_PROMPT_ - Leave `--effort` unset unless the user explicitly requests a specific reasoning effort. - Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. -- If the user asks for a concrete model name such as `gpt-5.6-terra`, pass it through with `--model`. +- If the user asks for `astra`, `sol`, `luna`, `terra` or `mini`, pass the alias through with `--model`; the companion resolves it against the local Codex model catalogue (e.g. `sol` becomes the newest listed `*-sol` model). +- If the user asks for a concrete model name such as `gpt-6-astra`, pass it through with `--model`. - Treat `--effort `, `--model `, and `--config key=value` as runtime controls and do not include them in the task text you pass through. - Never add `--write` unless the user explicitly asked Codex to modify files. - Preserve the user's task text as-is apart from stripping routing flags. diff --git a/plugins/codex/commands/adversarial-review.md b/plugins/codex/commands/adversarial-review.md index 0482349ed..ee604d401 100644 --- a/plugins/codex/commands/adversarial-review.md +++ b/plugins/codex/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run a Codex review that challenges the implementation approach and design choices -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value] [focus ...]' +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 176e22cd2..14b5247ef 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background] [--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [what Codex should investigate, solve, or continue]" +argument-hint: "[--background] [--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- diff --git a/plugins/codex/commands/review.md b/plugins/codex/commands/review.md index 93f1af661..c8fb174b8 100644 --- a/plugins/codex/commands/review.md +++ b/plugins/codex/commands/review.md @@ -1,6 +1,6 @@ --- description: Run a Codex code review against local git state -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]' +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 6536fbf7e..7d0b1b6b6 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -24,6 +24,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 { loadModelCatalog, resolveModelAlias, supportedEfforts } from "./lib/model-catalog.mjs"; import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { @@ -93,13 +94,6 @@ const VALID_REASONING_EFFORTS = new Set([ "max", "ultra" ]); -const MODEL_ALIASES = new Map([ - ["spark", "gpt-5.3-codex-spark"], - ["sol", "gpt-5.6-sol"], - ["luna", "gpt-5.6-luna"], - ["terra", "gpt-5.6-terra"], - ["mini", "gpt-5.4-mini"] -]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; function printUsage() { @@ -107,9 +101,9 @@ function printUsage() { [ "Usage:", " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--review-gate-model ] [--review-gate-effort ] [--json]", - " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]...", - " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [focus text]", - " node scripts/codex-companion.mjs task [--background|--await [--await-timeout-ms ]] [--prompt-stdin] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [prompt]", + " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]...", + " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [focus text]", + " node scripts/codex-companion.mjs task [--background|--await [--await-timeout-ms ]] [--prompt-stdin] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [--turn-timeout-ms ] [--config key=value]... [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--wait [--timeout-ms ]] [--json]", @@ -147,10 +141,10 @@ function normalizeRequestedModel(model) { if (!normalized) { return null; } - return MODEL_ALIASES.get(normalized.toLowerCase()) ?? normalized; + return resolveModelAlias(normalized, loadModelCatalog()); } -function normalizeReasoningEffort(effort) { +function normalizeReasoningEffort(effort, model = null) { if (effort == null) { return null; } @@ -163,6 +157,12 @@ function normalizeReasoningEffort(effort) { `Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh, max, ultra.` ); } + if (model) { + const allowed = supportedEfforts(model, loadModelCatalog()); + if (allowed && !allowed.includes(normalized)) { + throw new Error(`Reasoning effort "${normalized}" is not supported by ${model}. ${model} supports: ${allowed.join(", ")}.`); + } + } return normalized; } @@ -969,7 +969,7 @@ async function handleReviewCommand(argv, config) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); const model = normalizeRequestedModel(options.model); - const effort = normalizeReasoningEffort(options.effort); + const effort = normalizeReasoningEffort(options.effort, model); const configOverrides = parseConfigOverrides(options.config); const turnTimeoutMs = parseTimeoutOption(options["turn-timeout-ms"], "--turn-timeout-ms"); const focusText = positionals.join(" ").trim(); @@ -1034,7 +1034,7 @@ async function handleTask(argv) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); const model = normalizeRequestedModel(options.model); - const effort = normalizeReasoningEffort(options.effort); + const effort = normalizeReasoningEffort(options.effort, model); const configOverrides = parseConfigOverrides(options.config); // Every flag conflict is decided before the prompt is read: `--prompt-stdin` // blocks on an open stdin, so a usage error must never wait for EOF. diff --git a/plugins/codex/scripts/lib/model-catalog.mjs b/plugins/codex/scripts/lib/model-catalog.mjs new file mode 100644 index 000000000..7d7e149ce --- /dev/null +++ b/plugins/codex/scripts/lib/model-catalog.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { runCommand } from "./process.mjs"; + +export const CATALOG_ENV = "CODEX_COMPANION_MODEL_CATALOG"; +// Used only when no catalogue is readable (no models_cache.json and no codex binary). +export const FALLBACK_ALIASES = new Map([ + ["spark", "gpt-5.3-codex-spark"], + ["astra", "gpt-6-astra"], + ["sol", "gpt-6-sol"], + ["luna", "gpt-6-luna"], + ["terra", "gpt-5.6-terra"], + ["mini", "gpt-5.4-mini"] +]); + +let cached = null; + +function normalizeEntries(raw) { + const models = Array.isArray(raw?.models) ? raw.models : Array.isArray(raw) ? raw : []; + return models + .filter((m) => m && typeof m.slug === "string") + .map((m) => ({ + slug: m.slug, + visibility: m.visibility ?? "list", + priority: Number.isFinite(m.priority) ? m.priority : Number.MAX_SAFE_INTEGER, + efforts: Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels.map((l) => l?.effort).filter(Boolean) : [] + })); +} + +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])); + const codexHome = path.resolve(env.CODEX_HOME || path.join(os.homedir(), ".codex")); + sources.push(() => readJson(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(() => { + const result = runCommandImpl("codex", ["debug", "models", "--bundled"], { env, timeoutMs: 10000, maxBuffer: 64 * 1024 * 1024 }); + if (result.error || result.status !== 0) return null; + try { return JSON.parse(result.stdout); } catch { return null; } + }); + let entries = []; + for (const source of sources) { + try { entries = normalizeEntries(source()); } catch { entries = []; } + if (entries.length > 0) break; + } + if (cache) cached = entries; + return entries; +} + +function familyNumber(slug) { + const match = /^gpt-(\d+(?:\.\d+)?)/.exec(slug); + return match ? Number(match[1]) : -1; +} + +export function resolveModelAlias(alias, catalog) { + const wanted = String(alias ?? "").trim(); + if (!wanted) return null; + if (catalog.some((m) => m.slug === wanted)) return wanted; + const lower = wanted.toLowerCase(); + const candidates = catalog + .filter((m) => m.visibility === "list" && (m.slug === lower || m.slug.endsWith(`-${lower}`))) + .sort((a, b) => a.priority - b.priority || familyNumber(b.slug) - familyNumber(a.slug)); + if (candidates.length > 0) return candidates[0].slug; + return FALLBACK_ALIASES.get(lower) ?? wanted; +} + +export function supportedEfforts(slug, catalog) { + const entry = catalog.find((m) => m.slug === slug); + return entry && entry.efforts.length > 0 ? entry.efforts : null; +} diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 92c841d1a..71588f998 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -25,7 +25,7 @@ Execution rules: - Leave `--effort` unset unless the user explicitly requests a specific effort. - Leave model unset by default. Add `--model` only when the user explicitly asks for one. - Map `spark` to `--model gpt-5.3-codex-spark`. -- Map `sol` to `--model gpt-5.6-sol`, `luna` to `--model gpt-5.6-luna`, `terra` to `--model gpt-5.6-terra`, `mini` to `--model gpt-5.4-mini`. +- Pass the aliases `astra`, `sol`, `luna`, `terra` and `mini` through as `--model ` unchanged: the companion resolves each against the local Codex model catalogue (e.g. `sol` becomes the newest listed `*-sol` model). Pass a concrete slug through as-is. - Never add `--write` unless the user explicitly asked Codex to modify files. Command selection: diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 1a7f0e4a4..5e132ae4a 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -109,7 +109,7 @@ test("rescue command absorbs continue semantics", () => { assert.doesNotMatch(rescue, /^context:\s*fork\b/m); assert.match(rescue, /\[--background\]/); assert.match(rescue, /--resume\|--fresh/); - assert.match(rescue, /--model /); + assert.match(rescue, /--model /); assert.match(rescue, /--effort /); assert.match(rescue, /\[--turn-timeout-ms \]/); assert.match(rescue, /task-resume-candidate --json/); @@ -131,7 +131,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i); assert.match(agent, /Leave model unset by default/i); assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); - assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.6-terra`, pass it through with `--model`/i); + assert.match(agent, /If the user asks for a concrete model name such as `gpt-6-astra`, pass it through with `--model`/i); assert.match(agent, /Return the `result` stdout exactly as-is/i); assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return the command's exit status and stderr verbatim/i); assert.match(agent, /codex-prompting/); @@ -152,7 +152,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return the command's exit status and stderr verbatim/i); assert.match(readme, /`codex:codex-rescue` subagent/i); assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i); - assert.match(readme, /--model gpt-5\.6-terra --effort medium/i); + assert.match(readme, /--model gpt-6-astra --effort medium/i); assert.match(readme, /`spark` -> `gpt-5\.3-codex-spark`/i); assert.match(readme, /continue a previous Codex task/i); assert.match(readme, /### `\/codex:setup`/); @@ -187,7 +187,7 @@ test("rescue runs synchronously through the companion and uses Agent only for -- assert.doesNotMatch(agent, /own `status`/); assert.match(agent, /--config/); assert.doesNotMatch(runtimeSkill, /return nothing/i); - assert.match(runtimeSkill, /Map `sol` to `--model gpt-5\.6-sol`/i); + assert.match(runtimeSkill, /the newest listed `\*-sol` model/i); assert.match(runtimeSkill, /\$agent-compat:skill-router/); assert.doesNotMatch(agent, /adding `--write` unless/i); assert.doesNotMatch(runtimeSkill, /adding `--write` unless/i); diff --git a/tests/fixtures/models-catalog.json b/tests/fixtures/models-catalog.json new file mode 100644 index 000000000..6c9550234 --- /dev/null +++ b/tests/fixtures/models-catalog.json @@ -0,0 +1,8 @@ +{ "models": [ + { "slug": "gpt-6-astra", "visibility": "list", "priority": 1, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"}, {"effort":"xhigh"}, {"effort":"max"}, {"effort":"ultra"} ] }, + { "slug": "gpt-6-sol", "visibility": "list", "priority": 2, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"}, {"effort":"xhigh"}, {"effort":"max"}, {"effort":"ultra"} ] }, + { "slug": "gpt-5.6-sol", "visibility": "list", "priority": 5, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"} ] }, + { "slug": "gpt-5.6-terra", "visibility": "list", "priority": 6, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"}, {"effort":"xhigh"} ] }, + { "slug": "gpt-reserve", "visibility": "hide", "priority": 9, "supported_reasoning_levels": [ {"effort":"low"} ] }, + { "slug": "gpt-5.3-codex-spark", "visibility": "list", "priority": 7, "supported_reasoning_levels": [ {"effort":"low"}, {"effort":"medium"}, {"effort":"high"} ] } +] } diff --git a/tests/model-catalog.test.mjs b/tests/model-catalog.test.mjs new file mode 100644 index 000000000..f0df3d87d --- /dev/null +++ b/tests/model-catalog.test.mjs @@ -0,0 +1,35 @@ +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { loadModelCatalog, resolveModelAlias, supportedEfforts } from "../plugins/codex/scripts/lib/model-catalog.mjs"; + +const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "models-catalog.json"); +const catalog = loadModelCatalog({ env: { CODEX_COMPANION_MODEL_CATALOG: FIXTURE } }); + +test("family alias resolves to the listed model with the lowest priority, newest family on ties", () => { + assert.equal(resolveModelAlias("sol", catalog), "gpt-6-sol"); + assert.equal(resolveModelAlias("terra", catalog), "gpt-5.6-terra"); + assert.equal(resolveModelAlias("astra", catalog), "gpt-6-astra"); + assert.equal(resolveModelAlias("SOL", catalog), "gpt-6-sol"); +}); + +test("hidden models never resolve from an alias and exact slugs pass through", () => { + assert.equal(resolveModelAlias("reserve", catalog), "reserve"); + assert.equal(resolveModelAlias("gpt-reserve", catalog), "gpt-reserve"); + assert.equal(resolveModelAlias("gpt-5.6-sol", catalog), "gpt-5.6-sol"); +}); + +test("hardcoded fallback applies only without a catalogue", () => { + assert.equal(resolveModelAlias("sol", []), "gpt-6-sol"); + assert.equal(resolveModelAlias("mini", catalog), "gpt-5.4-mini"); +}); + +test("supportedEfforts reports the catalogue list or null for unknown models", () => { + assert.deepEqual(supportedEfforts("gpt-5.6-sol", catalog), ["low", "medium", "high"]); + assert.equal(supportedEfforts("o3", catalog), null); +}); + +test("loadModelCatalog never throws on a missing or malformed source", () => { + assert.deepEqual(loadModelCatalog({ env: { CODEX_COMPANION_MODEL_CATALOG: "/nonexistent.json", CODEX_HOME: "/nonexistent" }, runCommandImpl: () => ({ status: 1, stdout: "", stderr: "", error: null }) }), []); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 9a329605d..ba01c8a54 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2799,12 +2799,26 @@ test("review forwards model, review_model, effort and config overrides into thre assert.deepEqual(fakeState.lastThreadStart.config, { model_provider: "ollama", "foo.bar": 3, - model: "gpt-5.6-sol", - review_model: "gpt-5.6-sol", + 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("node", [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("node", [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(); @@ -2884,7 +2898,7 @@ test("task --resume-last cold-resumes without a thread/resume model override", ( assert.equal(fakeState.lastThreadResume.model, undefined); assert.equal(fakeState.lastThreadResume.config, null); assert.equal(fakeState.appServerStarts, startsAfterFirst + 1); - assert.equal(fakeState.lastTurnStart.model, "gpt-5.6-sol"); + assert.equal(fakeState.lastTurnStart.model, "gpt-6-sol"); assert.equal(fakeState.lastTurnStart.effort, "max"); }); @@ -3314,7 +3328,7 @@ test("task --await launches a tracked job, waits, and prints the result", () => 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-5.6-sol"); + assert.equal(fakeState.lastTurnStart.model, "gpt-6-sol"); const status = run("node", [SCRIPT, "status", out.job.id, "--json"], { cwd: repo, env: buildEnv(binDir) }); assert.equal(JSON.parse(status.stdout).job.status, "completed"); }); diff --git a/tests/test-env.mjs b/tests/test-env.mjs index c7cc3b7cf..66aa14af1 100644 --- a/tests/test-env.mjs +++ b/tests/test-env.mjs @@ -13,3 +13,5 @@ for (const name of [ ]) { delete process.env[name]; } +// Never read the host's real Codex model catalogue from tests. +process.env.CODEX_COMPANION_MODEL_CATALOG = new URL("./fixtures/models-catalog.json", import.meta.url).pathname; From aa3209efe76b4259fdd1466014b6e0481f694a65 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:33:59 +0300 Subject: [PATCH 11/36] fix(state): private per-user, per-plugin fallback state root; validate broker.json before use Co-authored-by: weivwang Co-Authored-By: Claude Fable 5.1 --- .../codex/scripts/lib/broker-lifecycle.mjs | 32 ++++++++++++++++++- plugins/codex/scripts/lib/state.mjs | 31 ++++++++++++++++-- tests/broker-stale-pid.test.mjs | 27 ++++++++++++++++ tests/state.test.mjs | 19 +++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index d7488fb84..4ffdd338e 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -142,17 +142,47 @@ function resolveBrokerStateFile(cwd) { return path.join(resolveStateDir(cwd), BROKER_STATE_FILE); } +function describeBrokerRecordProblem(record) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return "not an object"; + } + if (typeof record.endpoint !== "string") { + return "endpoint is not a string"; + } + try { + parseBrokerEndpoint(record.endpoint); + } catch (error) { + return error.message.replace(/\.$/, ""); + } + if (record.pid != null && !(Number.isInteger(record.pid) && record.pid > 0)) { + return "pid is not a positive integer"; + } + for (const key of ["pidFile", "logFile", "sessionDir"]) { + if (record[key] != null && !(typeof record[key] === "string" && path.isAbsolute(record[key]))) { + return `${key} is not an absolute path`; + } + } + return null; +} + export function loadBrokerSession(cwd) { const stateFile = resolveBrokerStateFile(cwd); if (!fs.existsSync(stateFile)) { return null; } + let record; try { - return JSON.parse(fs.readFileSync(stateFile, "utf8")); + record = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch { return null; } + const problem = describeBrokerRecordProblem(record); + if (problem) { + process.stderr.write(`[codex] Ignoring malformed broker.json at ${stateFile}: ${problem}.\n`); + return null; + } + return record; } export function saveBrokerSession(cwd, session) { diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 509c7e0fe..9f45ebf67 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -2,13 +2,14 @@ import { createHash, randomBytes } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { isPidAlive } from "./process.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; const STATE_VERSION = 1; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion"); +const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; const MAX_JOBS = 50; @@ -27,6 +28,32 @@ function defaultState() { }; } +export function resolveFallbackStateRoot({ + env = process.env, + tmpdir = os.tmpdir(), + uid = typeof process.getuid === "function" ? process.getuid() : null, + pluginRoot = env.CLAUDE_PLUGIN_ROOT || SCRIPT_ROOT +} = {}) { + let canonicalPluginRoot = pluginRoot; + try { + canonicalPluginRoot = fs.realpathSync.native(pluginRoot); + } catch { + // keep as given + } + const userDir = path.join(tmpdir, `codex-companion-${uid ?? "user"}`); + fs.mkdirSync(userDir, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") { + const stats = fs.statSync(userDir); + if ((uid !== null && stats.uid !== uid) || (stats.mode & 0o077) !== 0) { + throw new Error( + `Refusing to use shared state directory ${userDir}: owned by another user or group/world accessible. Set CLAUDE_PLUGIN_DATA.` + ); + } + } + // ponytail: plugin identity = hash of the install root; sibling plugins/forks get separate roots (#609) + return path.join(userDir, createHash("sha256").update(canonicalPluginRoot).digest("hex").slice(0, 12)); +} + export function resolveStateDir(cwd) { const workspaceRoot = resolveWorkspaceRoot(cwd); let canonicalWorkspaceRoot = workspaceRoot; @@ -40,7 +67,7 @@ export function resolveStateDir(cwd) { const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); const pluginDataDir = process.env[PLUGIN_DATA_ENV]; - const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; + const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : resolveFallbackStateRoot(); return path.join(stateRoot, `${slug}-${hash}`); } diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 1f682ea05..6dbd893f5 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1021,3 +1021,30 @@ test("ensureBrokerSession retries the readiness probe before giving up on a slow clearBrokerSession(workspace); } }); + +test("loadBrokerSession ignores a malformed record instead of trusting it", () => { + const workspace = makeTempDir(); + const stateDir = resolveStateDir(workspace); + fs.mkdirSync(stateDir, { recursive: true }); + const file = path.join(stateDir, "broker.json"); + const notes = []; + const originalWrite = process.stderr.write; + process.stderr.write = (chunk) => (notes.push(String(chunk)), true); + try { + for (const bad of [ + "[]", + JSON.stringify({ endpoint: "ftp://x", pid: 1 }), + JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: -5 }), + JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: 1, pidFile: "relative/broker.pid" }) + ]) { + fs.writeFileSync(file, bad); + assert.equal(loadBrokerSession(workspace), null, bad); + } + fs.writeFileSync(file, JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: null, pidFile: null, logFile: null, sessionDir: null })); + assert.ok(loadBrokerSession(workspace)); + } finally { + process.stderr.write = originalWrite; + } + assert.equal(notes.length, 4); + assert.ok(notes.every((note) => note.startsWith(`[codex] Ignoring malformed broker.json at ${file}: `))); +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index ed1e740d4..c70bc629f 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -14,6 +14,7 @@ import { resolveJobFile, resolveJobLogFile, resolveJobRequestFile, + resolveFallbackStateRoot, resolveStateDir, resolveStateFile, saveState, @@ -788,3 +789,21 @@ test("an entry with no checkable PID holds its place for the long grace", () => assert.equal(withStateLock(aged, () => "ok", { waitMs: 500 }), "ok"); assert.equal(fs.existsSync(agedEntry), false, "past the long grace it is debris"); }); + +test("fallback state root is private to the user and namespaced per plugin root (#521/#609)", { skip: process.platform === "win32" }, () => { + const tmp = makeTempDir(); + const pluginA = makeTempDir(); + const pluginB = makeTempDir(); + const a = resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: pluginA }); + const b = resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: pluginB }); + assert.notEqual(a, b); + assert.ok(a.startsWith(path.join(tmp, `codex-companion-${process.getuid()}`))); + assert.equal(fs.statSync(path.dirname(a)).mode & 0o077, 0); +}); + +test("fallback state root refuses a pre-existing world-accessible directory", { skip: process.platform === "win32" }, () => { + const tmp = makeTempDir(); + const shared = path.join(tmp, `codex-companion-${process.getuid()}`); + fs.mkdirSync(shared, { mode: 0o755 }); + assert.throws(() => resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: makeTempDir() }), /Refusing to use shared state directory/); +}); From 98f8b856868cb8e0bc0cb4def0ebae065eb3796e Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:40:43 +0300 Subject: [PATCH 12/36] fix(state): refuse a symlinked fallback state root Co-authored-by: weivwang Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/state.mjs | 5 +++-- tests/state.test.mjs | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 9f45ebf67..4c63724cc 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -43,8 +43,9 @@ export function resolveFallbackStateRoot({ const userDir = path.join(tmpdir, `codex-companion-${uid ?? "user"}`); fs.mkdirSync(userDir, { recursive: true, mode: 0o700 }); if (process.platform !== "win32") { - const stats = fs.statSync(userDir); - if ((uid !== null && stats.uid !== uid) || (stats.mode & 0o077) !== 0) { + // lstat: a planted symlink would pass a following stat and could be retargeted later. + const stats = fs.lstatSync(userDir); + if (!stats.isDirectory() || (uid !== null && stats.uid !== uid) || (stats.mode & 0o077) !== 0) { throw new Error( `Refusing to use shared state directory ${userDir}: owned by another user or group/world accessible. Set CLAUDE_PLUGIN_DATA.` ); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index c70bc629f..5a5b678cb 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -807,3 +807,11 @@ test("fallback state root refuses a pre-existing world-accessible directory", { fs.mkdirSync(shared, { mode: 0o755 }); assert.throws(() => resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: makeTempDir() }), /Refusing to use shared state directory/); }); + +test("fallback state root refuses a symlinked user directory", { skip: process.platform === "win32" }, () => { + const tmp = makeTempDir(); + const target = makeTempDir(); + fs.chmodSync(target, 0o700); + fs.symlinkSync(target, path.join(tmp, `codex-companion-${process.getuid()}`)); + assert.throws(() => resolveFallbackStateRoot({ env: {}, tmpdir: tmp, pluginRoot: makeTempDir() }), /Refusing to use shared state directory/); +}); From 59208db9614e822b214a4408af18fa943a0ac9f3 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:12:09 +0300 Subject: [PATCH 13/36] fix(process): identity-checked kills and reaping on posix; identity in pid sidecar, job records and broker.json A recorded pid is signalled, reaped as dead, or evicted from the state lock only once its start identity proves it is still the recorded process (#743): linux /proc//stat starttime, darwin `ps -o lstart=,comm=` pinned to LC_ALL=C/TZ=UTC. Unavailable identity never authorises a kill; win32 reports identity-unavailable (CIM identity is v1.4.0); posix records without an identity fall back to the command-line check. runCommand reports a timed-out command as status null instead of 0. Cancel says when it left a worker running, and the SessionEnd teardown line prints its reason. Co-authored-by: sylvesterkaczmarek Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 21 +++- .../codex/scripts/lib/broker-lifecycle.mjs | 45 +++++--- plugins/codex/scripts/lib/process.mjs | 108 +++++++++++++++++- plugins/codex/scripts/lib/state.mjs | 43 ++++--- plugins/codex/scripts/lib/tracked-jobs.mjs | 30 ++++- .../codex/scripts/session-lifecycle-hook.mjs | 23 +++- tests/broker-stale-pid.test.mjs | 62 +++++++++- tests/process.test.mjs | 98 +++++++++++++++- tests/runtime.test.mjs | 73 +++++++++++- tests/state.test.mjs | 40 ++++++- tests/tracked-jobs.test.mjs | 58 +++++++++- 11 files changed, 546 insertions(+), 55 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 7d0b1b6b6..9fb3f2a33 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -25,7 +25,7 @@ import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; import { loadModelCatalog, resolveModelAlias, supportedEfforts } from "./lib/model-catalog.mjs"; -import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; +import { binaryAvailable, getProcessIdentity, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { consumeJobRequestFile, @@ -907,6 +907,7 @@ function enqueueBackgroundTask(cwd, job, request) { // patches the real one in as soon as the worker exists. background: true, pid: null, + pidIdentity: null, logFile, requestFile, request: { ...request, config: redactConfigValues(request.config) } @@ -936,7 +937,7 @@ function enqueueBackgroundTask(cwd, job, request) { // owns it — it writes an atomic `jobs/.pid` sidecar plus a pid-only index // patch. Without it a `cancel` inside the queued window signals nothing and // the reaper cannot tell a dead queued worker from a live one. - updateJobPid(job.workspaceRoot, job.id, child.pid); + updateJobPid(job.workspaceRoot, job.id, child.pid, getProcessIdentity(child.pid)); return { payload: { @@ -1318,7 +1319,13 @@ async function handleCancel(argv) { ); } - terminateProcessTree(resolveJobPid(workspaceRoot, job) ?? Number.NaN); + // Only a pid that is provably still this job's worker is signalled (#743). + const { pid, identity } = resolveJobPid(workspaceRoot, job); + const kill = terminateRecordedProcess(pid, { identity, commandLineMatch: workerCommandLine(job.id) }); + const leftRunning = pid && !kill.attempted ? `worker pid ${pid} left running: ${kill.reason}` : null; + if (leftRunning) { + appendLogLine(job.logFile, leftRunning); + } appendLogLine(job.logFile, "Cancelled by user."); const completedAt = nowIso(); @@ -1327,6 +1334,7 @@ async function handleCancel(argv) { status: "cancelled", phase: "cancelled", pid: null, + pidIdentity: null, requestFile: null, completedAt, errorMessage: "Cancelled by user." @@ -1353,6 +1361,7 @@ async function handleCancel(argv) { status: "cancelled", phase: "cancelled", pid: null, + pidIdentity: null, requestFile: null, errorMessage: "Cancelled by user.", completedAt @@ -1364,10 +1373,12 @@ async function handleCancel(argv) { status: "cancelled", title: job.title, turnInterruptAttempted: interrupt.attempted, - turnInterrupted: interrupt.interrupted + turnInterrupted: interrupt.interrupted, + workerLeftRunning: leftRunning }; - outputCommandResult(payload, renderCancelReport(nextJob), options.json); + const rendered = renderCancelReport(nextJob); + outputCommandResult(payload, leftRunning ? `${rendered}${leftRunning}\n` : rendered, options.json); } async function main() { diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index 4ffdd338e..e59b4f37b 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -6,7 +6,7 @@ import process from "node:process"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createBrokerEndpoint, parseBrokerEndpoint } from "./broker-endpoint.mjs"; -import { isPidAlive, processCommandLine, terminateProcessTree } from "./process.mjs"; +import { getProcessIdentity, isPidAlive, processCommandLine, terminateProcessTree, terminateRecordedProcess } from "./process.mjs"; import { resolveStateDir } from "./state.mjs"; export const PID_FILE_ENV = "CODEX_COMPANION_APP_SERVER_PID_FILE"; @@ -157,6 +157,9 @@ function describeBrokerRecordProblem(record) { if (record.pid != null && !(Number.isInteger(record.pid) && record.pid > 0)) { return "pid is not a positive integer"; } + if (record.pidIdentity != null && typeof record.pidIdentity !== "string") { + return "pidIdentity is not a string"; + } for (const key of ["pidFile", "logFile", "sessionDir"]) { if (record[key] != null && !(typeof record[key] === "string" && path.isAbsolute(record[key]))) { return `${key} is not an absolute path`; @@ -239,6 +242,7 @@ export async function ensureBrokerSession(cwd, options = {}) { // Only a live broker that is provably ours gets a signal (#762); a dead or // recycled pid is left alone (#749) — the files are stale either way. pid: liveOwned ? pid : null, + pidIdentity: existing.pidIdentity ?? null, killProcess: liveOwned ? killProcess : null, ownsProcess: () => true }); @@ -262,6 +266,9 @@ export async function ensureBrokerSession(cwd, options = {}) { logFile, env: options.env ?? process.env }); + // Captured before the readiness wait, so even the teardown of a broker that + // never came up signals only the process it spawned. + const pidIdentity = getProcessIdentity(child.pid ?? Number.NaN); const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000); if (!ready) { @@ -271,6 +278,7 @@ export async function ensureBrokerSession(cwd, options = {}) { logFile, sessionDir, pid: child.pid ?? null, + pidIdentity, killProcess }); return null; @@ -281,7 +289,8 @@ export async function ensureBrokerSession(cwd, options = {}) { pidFile, logFile, sessionDir, - pid: child.pid ?? null + pid: child.pid ?? null, + pidIdentity }; saveBrokerSession(cwd, session); return session; @@ -290,30 +299,40 @@ export async function ensureBrokerSession(cwd, options = {}) { // A recorded PID is only worth signalling while it still belongs to this // session's broker: an idle self-terminate (or any abnormal exit) can leave the // record behind long enough for the OS to hand the PID — and with it the process -// group `terminateProcessTree` kills — to something unrelated. Windows has no -// cheap equivalent probe, so it keeps the previous unconditional behavior. -export function ownsBrokerProcess(pid, endpoint, timeoutMs) { +// group `terminateProcessTree` kills — to something unrelated. This command-line +// check is what a record without an identity falls back to. It answers `true` on +// Windows, which has no cheap probe, but teardown itself refuses to signal there +// without an identity (`identity-unavailable`, CIM identity is v1.4.0). +export function ownsBrokerProcess(pid, endpoint, timeoutMs, commandLine = processCommandLine(pid, { timeoutMs })) { if (process.platform === "win32") { return true; } - const commandLine = processCommandLine(pid, { timeoutMs }); if (!commandLine || !commandLine.includes("app-server-broker.mjs")) { return false; } return !endpoint || commandLine.includes(endpoint); } -// Reports whether the recorded process was actually signalled: a PID that no -// longer looks like this broker is deliberately left alone, and a caller that +// Reports whether the recorded process was actually signalled, and why not: a +// PID whose identity (or, for a record without one, command line) no longer +// matches this broker is deliberately left alone (#743), and a caller that // wonders why a broker outlived its teardown needs to know which it was. -export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null, timeoutMs = undefined, ownsProcess = ownsBrokerProcess }) { +export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, pidIdentity = null, killProcess = null, timeoutMs = undefined, ownsProcess = ownsBrokerProcess }) { let signalled = false; - if (Number.isFinite(pid) && killProcess && ownsProcess(pid, endpoint, timeoutMs)) { + let reason = "no-pid"; + if (Number.isFinite(pid) && killProcess) { try { - killProcess(pid); - signalled = true; + const outcome = terminateRecordedProcess(pid, { + identity: pidIdentity, + commandLineMatch: (commandLine) => ownsProcess(pid, endpoint, timeoutMs, commandLine), + timeoutMs, + terminateImpl: (target) => killProcess(target) + }); + signalled = outcome.attempted && outcome.delivered; + reason = outcome.reason; } catch { // Ignore missing or already-exited broker processes. + reason = "kill-failed"; } } @@ -345,5 +364,5 @@ export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessi } } - return { signalled }; + return { signalled, reason }; } diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index e2060491d..a9799d689 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import process from "node:process"; export function runCommand(command, args = [], options = {}) { @@ -17,7 +18,9 @@ export function runCommand(command, args = [], options = {}) { return { command, args, - status: result.status ?? 0, + // A command killed by its timeout (or a signal) has no exit status; reading + // that as 0 would turn a hung probe into a success. + status: result.status ?? null, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", @@ -69,13 +72,114 @@ export function processCommandLine(pid, options = {}) { } const runCommandImpl = options.runCommandImpl ?? runCommand; - const result = runCommandImpl("ps", ["-o", "command=", "-p", String(pid)], { timeoutMs: options.timeoutMs }); + const result = runCommandImpl("ps", ["-o", "command=", "-p", String(pid)], { timeoutMs: options.timeoutMs, shell: false }); if (result.error || result.status !== 0) { return null; } return result.stdout.trim() || null; } +const ownIdentityCache = new Map(); +// `lstart` is fixed-width in the C locale; the rest of the line is `comm`, an +// executable path that may contain spaces. +const DARWIN_PS_LINE = /^(\w{3} \w{3} [ \d]\d \d\d:\d\d:\d\d \d{4})\s+(.+)$/; + +// Who a PID belongs to, beyond the number the OS recycles (#743): its start time +// (plus the executable on darwin, where `lstart` only has second resolution). +// Two reads for the same process always agree; a process that inherited the PID +// never does. `null` means "cannot tell" and must never authorise a kill. +export function getProcessIdentity(pid, options = {}) { + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + const platform = options.platform ?? process.platform; + if (platform === "win32") { + return null; // ponytail: CIM (CreationDate) identity lands in v1.4.0 + } + if (pid === process.pid && ownIdentityCache.has(platform)) { + return ownIdentityCache.get(platform); + } + let identity = null; + if (platform === "linux") { + try { + const readFileSyncImpl = options.readFileSyncImpl ?? fs.readFileSync; + const stat = String(readFileSyncImpl(`/proc/${pid}/stat`, "utf8")); + // Field 22 (starttime) counted from the start; `comm` (field 2) may hold + // spaces and parentheses, so count from the last ")": state is field 3. + const starttime = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]; + identity = starttime ? `linux:${starttime}` : null; + } catch { + identity = null; + } + } else { + // spawnSync reads a timeout of 0 as "no timeout": a spent budget is no probe. + const timeoutMs = options.timeoutMs ?? 10000; + if (!(timeoutMs > 0)) { + return null; + } + const runCommandImpl = options.runCommandImpl ?? runCommand; + // Identity is recorded by one process and checked by another: pin the + // locale and zone `lstart` is printed in, or they would never agree. + const result = runCommandImpl("ps", ["-o", "lstart=,comm=", "-p", String(pid)], { + timeoutMs, + shell: false, + env: { ...process.env, LC_ALL: "C", TZ: "UTC" } + }); + const match = !result.error && result.status === 0 ? DARWIN_PS_LINE.exec(result.stdout.trim()) : null; + identity = match ? `darwin:${match[1]}|${match[2].trim()}` : null; + } + if (pid === process.pid && identity) { + ownIdentityCache.set(platform, identity); + } + return identity; +} + +// What a background worker's command line looks like — the check a record +// without an identity (v1.2.x) falls back to. Job ids are generated +// `--`, so they need no escaping. +export function workerCommandLine(jobId) { + return new RegExp(`task-worker.*--job-id ${jobId}(\\s|$)`); +} + +// Signals a recorded PID only once it is proven to still be the recorded +// process: by identity when one was recorded, else (posix records from before +// identities) by its command line. Anything unprovable is left alone and the +// reason says why. +export function terminateRecordedProcess(pid, options = {}) { + if (!Number.isInteger(pid) || pid <= 0) { + return { attempted: false, delivered: false, reason: "no-pid" }; + } + const platform = options.platform ?? process.platform; + const identity = options.identity ?? null; + let reason; + if (identity) { + const actual = getProcessIdentity(pid, options); + if (!actual) { + return { attempted: false, delivered: false, reason: "identity-unavailable" }; + } + if (actual !== identity) { + return { attempted: false, delivered: false, reason: "identity-mismatch" }; + } + reason = "identity-match"; + } else { + if (platform === "win32") { + return { attempted: false, delivered: false, reason: "identity-unavailable" }; + } + const commandLine = processCommandLine(pid, options); + const match = options.commandLineMatch; + const matched = + Boolean(commandLine) && + (typeof match === "function" ? Boolean(match(commandLine)) : match instanceof RegExp ? match.test(commandLine) : false); + if (!matched) { + return { attempted: false, delivered: false, reason: "identity-mismatch" }; + } + reason = "command-line-match"; + } + // An injected terminator may report nothing; having been called is the attempt. + const outcome = (options.terminateImpl ?? terminateProcessTree)(pid, options); + return { ...(outcome && typeof outcome === "object" ? outcome : { attempted: true, delivered: true }), reason }; +} + // True when the PID is running, false when it is provably gone (ESRCH), null // when the question does not apply (no PID) — EPERM means it exists but belongs // to someone else. Known limitation: a zombie reads as alive, and a recycled PID diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 4c63724cc..3fc8e9b39 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { isPidAlive } from "./process.mjs"; +import { getProcessIdentity, isPidAlive } from "./process.mjs"; import { resolveWorkspaceRoot } from "./workspace.mjs"; const STATE_VERSION = 1; @@ -245,6 +245,7 @@ function sleepSync(ms) { const LOCK_ENTRY_GONE = "gone"; const LOCK_ENTRY_HELD = "held"; const LOCK_ENTRY_ABANDONED = "abandoned"; +const LOCK_IDENTITY_PROBE_MS = 2000; // Only the entry having disappeared is an answer. Every other stat failure — // EACCES, EIO, ELOOP — says nothing about the owner, and guessing there is how a @@ -302,6 +303,15 @@ function judgeLockEntry(entryPath) { if (owner) { const alive = isPidAlive(owner.pid); if (alive === true) { + // A live pid that is provably another process is a recycled one (#743). + // An identity we cannot read proves nothing and keeps the entry. + // ponytail: win32 lock entries stay PID-liveness only (no identity recorded there) + if (typeof owner.identity === "string") { + const actual = getProcessIdentity(owner.pid, { timeoutMs: LOCK_IDENTITY_PROBE_MS }); + if (actual && actual !== owner.identity) { + return LOCK_ENTRY_ABANDONED; + } + } return LOCK_ENTRY_HELD; } if (alive === false) { @@ -443,7 +453,8 @@ function lockTimeoutError(lockDir, blockers, waitMs) { function acquireTicket(lockDir, waitMs) { fs.mkdirSync(lockDir, { recursive: true }); const token = `${process.pid}-${randomBytes(8).toString("hex")}`; - const owner = `${JSON.stringify({ pid: process.pid, startedAt: nowIso() })}\n`; + const identity = process.platform === "win32" ? null : getProcessIdentity(process.pid); + const owner = `${JSON.stringify({ pid: process.pid, startedAt: nowIso(), identity })}\n`; const choosingName = `${LOCK_CHOOSING_PREFIX}${token}`; writeLockEntry(lockDir, choosingName, token, owner); @@ -634,8 +645,8 @@ export function upsertJob(cwd, jobPatch) { // threadId and turnId. The pid goes into an atomic sidecar plus the pid-only // index patch, and readers fall back to the sidecar (`resolveJobPid`) only while // the job is still active. -export function updateJobPid(cwd, jobId, pid) { - writeJobPidFile(cwd, jobId, pid); +export function updateJobPid(cwd, jobId, pid, identity = null) { + writeJobPidFile(cwd, jobId, pid, identity); // The index is patch-based, so it cannot lose a field — but a worker that // already reported `running` wrote its own pid there, and that record is the // newer one. A job that is gone from the index needs no pid at all. The read @@ -644,7 +655,7 @@ export function updateJobPid(cwd, jobId, pid) { withStateLock(cwd, () => { const indexed = listJobs(cwd).find((job) => job.id === jobId); if (indexed?.status === "queued") { - upsertJob(cwd, { id: jobId, pid }); + upsertJob(cwd, { id: jobId, pid, pidIdentity: identity }); } }); } @@ -735,18 +746,23 @@ export function resolveJobPidFile(cwd, jobId) { return path.join(resolveJobsDir(cwd), `${jobId}.pid`); } -export function writeJobPidFile(cwd, jobId, pid) { - return writeFileAtomic(resolveJobPidFile(cwd, jobId), `${pid}\n`); +export function writeJobPidFile(cwd, jobId, pid, identity = null) { + return writeFileAtomic(resolveJobPidFile(cwd, jobId), `${JSON.stringify({ pid, identity })}\n`); } export function removeJobPidFile(cwd, jobId) { removeFileIfExists(resolveJobPidFile(cwd, jobId)); } +// `{"pid":N,"identity":"..."}`, or the bare integer v1.2.x wrote. function readJobPidSidecar(cwd, jobId) { try { - const pid = Number.parseInt(fs.readFileSync(resolveJobPidFile(cwd, jobId), "utf8").trim(), 10); - return Number.isInteger(pid) && pid > 0 ? pid : null; + const raw = fs.readFileSync(resolveJobPidFile(cwd, jobId), "utf8").trim(); + const parsed = raw.startsWith("{") ? JSON.parse(raw) : { pid: Number.parseInt(raw, 10), identity: null }; + if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) { + return null; + } + return { pid: parsed.pid, identity: typeof parsed.identity === "string" ? parsed.identity : null }; } catch { return null; } @@ -755,15 +771,16 @@ function readJobPidSidecar(cwd, jobId) { // The pid every reader (cancel, reaper, SessionEnd cleanup) should use: the // record's own pid once the worker has taken the record over, the sidecar during // the queued window before that. A terminal job never reports one — its worker -// is gone and the sidecar may name a pid the OS has recycled. +// is gone and the sidecar may name a pid the OS has recycled. The identity +// recorded with the pid comes along (`null` for records from before v1.3.0). export function resolveJobPid(cwd, job) { if (job?.pid != null) { - return job.pid; + return { pid: job.pid, identity: typeof job.pidIdentity === "string" ? job.pidIdentity : null }; } if (job?.status !== "queued" && job?.status !== "running") { - return null; + return { pid: null, identity: null }; } - return readJobPidSidecar(cwd, job.id); + return readJobPidSidecar(cwd, job.id) ?? { pid: null, identity: null }; } // The full task request can carry secrets (`--config` values such as auth diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index ac24856d5..5f8179b43 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 { isPidAlive } from "./process.mjs"; +import { getProcessIdentity, isPidAlive } from "./process.mjs"; import { readJobFile, @@ -167,6 +167,7 @@ export async function runTrackedJob(job, runner, options = {}) { startedAt: nowIso(), phase: "starting", pid: process.pid, + pidIdentity: getProcessIdentity(process.pid), logFile: options.logFile ?? job.logFile ?? null }; writeJobFile(job.workspaceRoot, job.id, runningRecord); @@ -187,6 +188,7 @@ export async function runTrackedJob(job, runner, options = {}) { turnId: execution.turnId ?? null, resolved: execution.resolved ?? null, pid: null, + pidIdentity: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, result: execution.payload, @@ -202,6 +204,7 @@ export async function runTrackedJob(job, runner, options = {}) { summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, + pidIdentity: null, completedAt }); removeJobPidFile(job.workspaceRoot, job.id); @@ -221,6 +224,7 @@ export async function runTrackedJob(job, runner, options = {}) { phase: "failed", errorMessage, pid: null, + pidIdentity: null, completedAt, logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null }); @@ -229,6 +233,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: "failed", phase: "failed", pid: null, + pidIdentity: null, errorMessage, completedAt }); @@ -271,6 +276,7 @@ function markJobDeadLocked(workspaceRoot, jobSummary, errorMessage) { resolved: base.resolved ?? null, requestFile: base.requestFile ?? null, pid: null, + pidIdentity: null, completedAt: base.completedAt ?? null }); return base; @@ -288,6 +294,7 @@ function markJobDeadLocked(workspaceRoot, jobSummary, errorMessage) { phase: "failed", errorMessage, pid: null, + pidIdentity: null, requestFile: null, completedAt, // Keep updatedAt current so the reaped job sorts newest-first in the same @@ -301,6 +308,7 @@ function markJobDeadLocked(workspaceRoot, jobSummary, errorMessage) { status: "failed", phase: "failed", pid: null, + pidIdentity: null, requestFile: null, errorMessage, completedAt @@ -342,16 +350,17 @@ function isQueuedWithoutWorker(job, pid) { // liveness is only consulted for jobs whose own file still says they are active. // Below this there is no point starting another lock wait. const REAP_MIN_STEP_MS = 100; +const IDENTITY_PROBE_MS = 2000; /** - * @param {{ lockWaitMs?: number, remainingMs?: () => number }} [options] Bounds the + * @param {{ lockWaitMs?: number, remainingMs?: () => number, getProcessIdentityImpl?: typeof getProcessIdentity }} [options] Bounds the * reaper's own state-lock waits. Each dead job costs one acquisition, so a caller * working to a deadline passes `remainingMs` and every wait is clamped to what is * left of it; once that is spent the remaining jobs are left for the next run * rather than reaped past the caller's budget. */ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { - const { lockWaitMs, remainingMs } = options; + const { lockWaitMs, remainingMs, getProcessIdentityImpl = getProcessIdentity } = options; const waitFor = () => { if (!remainingMs) { return lockWaitMs; @@ -378,10 +387,23 @@ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { } // The queued record carries no pid of its own — the parent records it in an // atomic sidecar instead of rewriting the worker's job file. - const pid = resolveJobPid(workspaceRoot, job); + const { pid, identity } = resolveJobPid(workspaceRoot, job); if (isPidAlive(pid) === false || isQueuedWithoutWorker(job, pid)) { return markJobDead(workspaceRoot, job, DEAD_WORKER_MESSAGE, waitFor()); } + // Alive is not enough: the pid may now belong to another process (#743). + // A probe that fails or times out proves nothing, so the job is left alone. + if (pid && identity) { + let actual = null; + try { + actual = getProcessIdentityImpl(pid, { timeoutMs: remainingMs ? Math.min(IDENTITY_PROBE_MS, remainingMs()) : IDENTITY_PROBE_MS }); + } catch { + actual = null; + } + if (actual && actual !== identity) { + return markJobDead(workspaceRoot, job, `${DEAD_WORKER_MESSAGE} (pid reused: ${pid} now belongs to another process)`, waitFor()); + } + } return job; }); if (deferred.length > 0) { diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 6f8ff0b44..79164471c 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import process from "node:process"; -import { terminateProcessTree } from "./lib/process.mjs"; +import { terminateProcessTree, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; import { BROKER_ENDPOINT_ENV } from "./lib/app-server.mjs"; import { clearBrokerSession, @@ -40,6 +40,8 @@ const STATE_LOCK_STEP_MS = 5000; const BROKER_HANDSHAKE_STEP_MS = 5000; // Below this there is no point starting another bounded step. const MIN_STEP_MS = 100; +// Upper bound on one process-identity probe (`ps` on darwin). +const IDENTITY_PROBE_MS = 2000; // The override may only ever SHORTEN the budget. `hooks.json`'s timeout is a fixed // number that cannot be raised from the environment, so an override above the @@ -100,7 +102,7 @@ function appendEnvVar(name, value) { ); } -function cleanupSessionJobs(cwd, sessionId, lockWaitMs) { +function cleanupSessionJobs(cwd, sessionId, lockWaitMs, remainingMs) { if (!cwd || !sessionId) { return; } @@ -133,8 +135,15 @@ function cleanupSessionJobs(cwd, sessionId, lockWaitMs) { if (!stillRunning) { continue; } + // Only a pid still provably this job's process is signalled (#743), and + // proving it costs a probe the budget has to cover. + const probeMs = Math.min(IDENTITY_PROBE_MS, remainingMs()); + if (probeMs < MIN_STEP_MS) { + continue; + } try { - terminateProcessTree(resolveJobPid(workspaceRoot, job) ?? Number.NaN); + const { pid, identity } = resolveJobPid(workspaceRoot, job); + terminateRecordedProcess(pid, { identity, commandLineMatch: workerCommandLine(job.id), timeoutMs: probeMs }); } catch { // Ignore teardown failures during session shutdown. } @@ -194,10 +203,11 @@ async function handleSessionEnd(input) { const logFile = brokerSession?.logFile ?? null; const sessionDir = brokerSession?.sessionDir ?? null; const pid = brokerSession?.pid ?? null; + const pidIdentity = brokerSession?.pidIdentity ?? null; let activeJobs; try { - cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV], stepBudget(STATE_LOCK_STEP_MS)); + cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV], stepBudget(STATE_LOCK_STEP_MS), remainingMs); activeJobs = activeWorkspaceJobs(cwd, stepBudget(STATE_LOCK_STEP_MS), remainingMs); } catch (error) { // A lock this hook could not take says nothing about the broker, and a @@ -283,13 +293,14 @@ async function handleSessionEnd(input) { logFile, sessionDir, pid, + pidIdentity, killProcess: terminateProcessTree, - timeoutMs: stepBudget(STATE_LOCK_STEP_MS) + timeoutMs: stepBudget(IDENTITY_PROBE_MS) }); // Every branch of this hook says what it decided: when a broker outlives a // SessionEnd the only question worth asking is which of these four paths ran. process.stderr.write( - `[codex] Broker teardown: endpoint=${brokerEndpoint ?? "none"} pid=${pid ?? "none"} signalled=${teardown.signalled} busyRetries=${busyRetries} budgetExhausted=false\n` + `[codex] Broker teardown: endpoint=${brokerEndpoint ?? "none"} pid=${pid ?? "none"} signalled=${teardown.signalled} reason=${teardown.reason} busyRetries=${busyRetries} budgetExhausted=false\n` ); // A replacement broker can have started — and recorded itself — while this one diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 6dbd893f5..add31b4a7 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -18,6 +18,7 @@ import { sendBrokerShutdown, waitForBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -937,6 +938,18 @@ test("waitForBrokerEndpoint reads a connected probe whose close is slow as ready assert.ok(Date.now() - started < 1500, "must resolve within the attempt window"); }); +// Records every kill; a pid other than the test runner's own (a fresh broker that +// missed its readiness window on a slow machine) is really terminated, or it is +// orphaned. +function recordingKill(killed) { + return (pid) => { + killed.push(pid); + if (pid !== process.pid) { + terminateProcessTree(pid); + } + }; +} + function deadPid() { const result = run(process.execPath, ["-e", ""]); assert.equal(result.status, 0); @@ -956,7 +969,7 @@ test("ensureBrokerSession kills a live unreachable broker before replacing it (# env: buildEnv(binDir), isAliveImpl: () => true, ownsProcessImpl: () => { probes += 1; return true; }, - killProcess: (pid) => { killed.push(pid); }, + killProcess: recordingKill(killed), retryTimeoutMs: 300 }); try { @@ -977,7 +990,7 @@ test("ensureBrokerSession never signals a dead or foreign pid from a stale recor const sessionDir = makeTempDir("cxc-"); saveBrokerSession(workspace, { endpoint: createBrokerEndpoint(sessionDir), pidFile: null, logFile: null, sessionDir, pid: deadPid() }); const killed = []; - const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), killProcess: (pid) => killed.push(pid) }); + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), killProcess: recordingKill(killed) }); try { assert.deepEqual(killed, []); assert.ok(session); @@ -985,6 +998,25 @@ test("ensureBrokerSession never signals a dead or foreign pid from a stale recor if (session?.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } clearBrokerSession(workspace); } + + // Alive but not ours: the pid now belongs to something else. + const foreignWorkspace = makeTempDir(); + const foreignDir = makeTempDir("cxc-"); + saveBrokerSession(foreignWorkspace, { endpoint: createBrokerEndpoint(foreignDir), pidFile: null, logFile: null, sessionDir: foreignDir, pid: process.pid }); + const foreignKilled = []; + const replacement = await ensureBrokerSession(foreignWorkspace, { + env: buildEnv(binDir), + isAliveImpl: () => true, + ownsProcessImpl: () => false, + killProcess: recordingKill(foreignKilled) + }); + try { + assert.deepEqual(foreignKilled, []); + assert.ok(replacement); + } finally { + if (replacement?.pid) { try { process.kill(replacement.pid, "SIGTERM"); } catch {} } + clearBrokerSession(foreignWorkspace); + } }); test("ensureBrokerSession retries the readiness probe before giving up on a slow broker (#768)", async () => { @@ -1000,7 +1032,7 @@ test("ensureBrokerSession retries the readiness probe before giving up on a slow env: buildEnv(binDir), isAliveImpl: () => true, ownsProcessImpl: () => true, - killProcess: (pid) => killed.push(pid), + killProcess: recordingKill(killed), retryTimeoutMs: 2000 }); let session = null; @@ -1035,16 +1067,36 @@ test("loadBrokerSession ignores a malformed record instead of trusting it", () = "[]", JSON.stringify({ endpoint: "ftp://x", pid: 1 }), JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: -5 }), - JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: 1, pidFile: "relative/broker.pid" }) + JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: 1, pidFile: "relative/broker.pid" }), + JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: 1, pidIdentity: 42 }) ]) { fs.writeFileSync(file, bad); assert.equal(loadBrokerSession(workspace), null, bad); } fs.writeFileSync(file, JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: null, pidFile: null, logFile: null, sessionDir: null })); assert.ok(loadBrokerSession(workspace)); + fs.writeFileSync(file, JSON.stringify({ endpoint: "unix:/tmp/x.sock", pid: 1, pidIdentity: "linux:1" })); + assert.ok(loadBrokerSession(workspace)); } finally { process.stderr.write = originalWrite; } - assert.equal(notes.length, 4); + assert.equal(notes.length, 5); assert.ok(notes.every((note) => note.startsWith(`[codex] Ignoring malformed broker.json at ${file}: `))); }); + +// The recorded pid is alive (it is this test runner) but its start identity is +// not the one the broker recorded: the pid was recycled, so it must not be +// signalled (#743). +test("SessionEnd leaves a recorded broker pid alone when its identity no longer matches (#743)", { skip: process.platform === "win32" }, async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + const endpoint = createBrokerEndpoint(sessionDir); + saveBrokerSession(workspace, { endpoint, pidFile: null, logFile: null, sessionDir, pid: process.pid, pidIdentity: "darwin:definitely-not-this|nope" }); + const hook = run("node", [SESSION_HOOK, "SessionEnd"], { cwd: workspace, env: buildEnv(binDir), input: JSON.stringify({ cwd: workspace, session_id: "sess-identity" }) }); + assert.equal(hook.status, 0, hook.stderr); + assert.match(hook.stderr, /signalled=false/); + assert.match(hook.stderr, /identity-mismatch/); + clearBrokerSession(workspace); +}); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index f3d9ceca9..dc26b553a 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -2,8 +2,15 @@ import path from "node:path"; import process from "node:process"; import test from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; -import { processCommandLine, terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; +import { + getProcessIdentity, + processCommandLine, + runCommand, + terminateProcessTree, + terminateRecordedProcess +} from "../plugins/codex/scripts/lib/process.mjs"; test("terminateProcessTree uses taskkill on Windows", () => { let captured = null; @@ -65,3 +72,92 @@ test("processCommandLine reads the command line of a live process", { skip: proc test("processCommandLine returns null for a pid that is not running", { skip: process.platform === "win32" }, () => { assert.equal(processCommandLine(2 ** 31 - 1), null); }); + +// A pid alone cannot tell the process that was recorded from the one that +// inherited the number (#743): identity is the start time, which a recycled pid +// cannot share. +test("getProcessIdentity is stable for the same process and differs for another one", { skip: process.platform === "win32" }, () => { + const mine = getProcessIdentity(process.pid); + assert.ok(mine && mine.length > 0); + assert.equal(getProcessIdentity(process.pid), mine); + const child = spawnSync(process.execPath, ["-e", "setTimeout(()=>{}, 2000); console.log(process.pid)"], { encoding: "utf8", timeout: 100 }); + // The child was killed by the timeout; its identity, if any, must not equal ours. + assert.notEqual(getProcessIdentity(Number(child.stdout.trim()) || 999999), mine); +}); + +test("getProcessIdentity parses linux /proc stat and darwin ps output", () => { + assert.equal(getProcessIdentity(42, { platform: "linux", readFileSyncImpl: () => "42 (node (x)) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 20 0 1 0 123456 1 2 3" }), "linux:123456"); + assert.equal(getProcessIdentity(42, { platform: "darwin", runCommandImpl: () => ({ status: 0, stdout: "Mon Sep 27 10:00:00 2026 node\n", stderr: "", error: null }) }), "darwin:Mon Sep 27 10:00:00 2026|node"); + assert.equal(getProcessIdentity(42, { platform: "win32" }), null); +}); + +// `comm` on darwin is the executable path, which can hold spaces; and `lstart` +// follows the locale and time zone unless they are pinned — a process recorded +// under one locale and checked under another must not read as a different one. +test("getProcessIdentity pins the darwin ps locale and keeps a comm path with spaces whole", () => { + let seen = null; + const identity = getProcessIdentity(42, { + platform: "darwin", + runCommandImpl: (command, args, options) => { + seen = { command, args, options }; + return { status: 0, stdout: "Sun Sep 7 09:00:00 2026 /Applications/Visual Studio Code.app/Contents/MacOS/Electron\n", stderr: "", error: null }; + } + }); + assert.equal(identity, "darwin:Sun Sep 7 09:00:00 2026|/Applications/Visual Studio Code.app/Contents/MacOS/Electron"); + assert.equal(seen.options.shell, false); + assert.equal(seen.options.env.LC_ALL, "C"); + assert.equal(seen.options.env.TZ, "UTC"); + // A spent budget is not "no timeout": spawnSync reads 0 as unbounded. + assert.equal(getProcessIdentity(42, { platform: "darwin", timeoutMs: 0, runCommandImpl: () => assert.fail("must not probe") }), null); +}); + +test("runCommand reports a timed-out command as having no exit status", { skip: process.platform === "win32" }, () => { + const result = runCommand(process.execPath, ["-e", "setTimeout(()=>{}, 5000)"], { timeoutMs: 100 }); + assert.equal(result.status, null); + assert.notEqual(result.status, 0); +}); + +test("terminateRecordedProcess refuses on identity mismatch and without identity on win32", () => { + let killed = false; + const mismatch = terminateRecordedProcess(4242, { identity: "linux:1", platform: "linux", readFileSyncImpl: () => "4242 (node) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 1 0 999 0 0 0", killImpl: () => { killed = true; } }); + assert.equal(mismatch.attempted, false); + assert.equal(mismatch.reason, "identity-mismatch"); + assert.equal(killed, false); + const win = terminateRecordedProcess(4242, { identity: null, platform: "win32", killImpl: () => { killed = true; } }); + assert.deepEqual([win.attempted, win.reason, killed], [false, "identity-unavailable", false]); +}); + +test("terminateRecordedProcess signals on a matching identity and refuses when it is unavailable", () => { + const calls = []; + const stat = "4242 (node) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 1 0 999 0 0 0"; + const ok = terminateRecordedProcess(4242, { identity: "linux:999", platform: "linux", readFileSyncImpl: () => stat, killImpl: (pid, sig) => calls.push([pid, sig]) }); + assert.deepEqual([ok.attempted, ok.reason], [true, "identity-match"]); + assert.deepEqual(calls, [[-4242, "SIGTERM"]]); + const gone = terminateRecordedProcess(4242, { identity: "linux:999", platform: "linux", readFileSyncImpl: () => { throw new Error("ENOENT"); }, killImpl: () => calls.push("must not") }); + assert.deepEqual([gone.attempted, gone.reason], [false, "identity-unavailable"]); + assert.equal(terminateRecordedProcess(Number.NaN).reason, "no-pid"); + assert.equal(calls.length, 1); +}); + +test("terminateRecordedProcess falls back to the command line on posix when no identity was recorded", () => { + const calls = []; + const ok = terminateRecordedProcess(4242, { identity: null, platform: "linux", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "node app-server-broker.mjs serve\n", stderr: "", error: null }), killImpl: (pid, sig) => calls.push([pid, sig]) }); + assert.equal(ok.attempted, true); + assert.equal(ok.reason, "command-line-match"); + assert.deepEqual(calls[0], [-4242, "SIGTERM"]); + const no = terminateRecordedProcess(4242, { identity: null, platform: "linux", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "bash\n", stderr: "", error: null }), killImpl: () => calls.push("must not") }); + assert.equal(no.attempted, false); + assert.equal(calls.length, 1); +}); + +test("terminateRecordedProcess hands a verified pid to an injected terminateImpl", () => { + const terminated = []; + const outcome = terminateRecordedProcess(4242, { + platform: "linux", + commandLineMatch: () => true, + runCommandImpl: () => ({ status: 0, stdout: "node x\n", stderr: "", error: null }), + terminateImpl: (pid) => terminated.push(pid) + }); + assert.deepEqual(terminated, [4242]); + assert.deepEqual([outcome.attempted, outcome.delivered, outcome.reason], [true, true, "command-line-match"]); +}); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index ba01c8a54..2d08a168e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -8,13 +8,17 @@ import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { initGitRepo, makeTempDir, run } 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, readJobFile, resolveJobFile, + resolveJobPidFile, resolveJobRequestFile, - resolveStateDir + resolveStateDir, + upsertJob, + writeJobFile } from "../plugins/codex/scripts/lib/state.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -1815,7 +1819,9 @@ test("cancel stops an active background job and marks it cancelled", async (t) = const jobsDir = path.join(stateDir, "jobs"); fs.mkdirSync(jobsDir, { recursive: true }); - const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + // 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)", "task-worker", "--job-id", "task-live"], { cwd: workspace, detached: true, stdio: "ignore" @@ -1904,6 +1910,66 @@ test("cancel stops an active background job and marks it cancelled", async (t) = assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/); }); +// The #743 scenario: a record from before identities existed names a pid the OS +// has since handed to an unrelated process. Liveness says "alive", so the reaper +// keeps the job; cancel must still refuse to signal a process that is not this +// job's worker, and say so. +test("cancel refuses to signal a worker whose recorded identity no longer matches and says so", { skip: process.platform === "win32" }, async (t) => { + const repo = makeTempDir(); + initGitRepo(repo); + const stranger = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { 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); + + const cancel = run("node", [SCRIPT, "cancel", job.id], { cwd: repo }); + + assert.equal(cancel.status, 0, cancel.stderr); + assert.match(cancel.stdout, new RegExp(`worker pid ${stranger.pid} left running: identity-mismatch`)); + const json = run("node", [SCRIPT, "status", job.id, "--json"], { cwd: repo }); + assert.equal(json.status, 0, json.stderr); + process.kill(stranger.pid, 0); // still alive: throws ESRCH if cancel signalled it +}); + +// 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", { skip: process.platform === "win32" }, async () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const env = buildEnv(binDir, { FAKE_CODEX_TURN_DELAY_MS: "8000" }); + const launched = run("node", [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):/); + const cancel = run("node", [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 {} + } +}); + test("cancel without a job id ignores active jobs from other Claude sessions", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); @@ -2136,6 +2202,8 @@ test("session end fully cleans up jobs for the ending session", async (t) => { 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" @@ -2264,6 +2332,7 @@ test("session end preserves background jobs and their broker so workers survive 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" diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 5a5b678cb..eec989336 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -7,6 +7,7 @@ import { spawn } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { makeTempDir, run } from "./helpers.mjs"; +import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; import { consumeJobRequestFile, listJobs, @@ -300,9 +301,9 @@ function lockDirFor(workspace) { return lockDir; } -function seedLockEntry(lockDir, name, pid, startedAt = new Date().toISOString()) { +function seedLockEntry(lockDir, name, pid, startedAt = new Date().toISOString(), identity = undefined) { const entry = path.join(lockDir, name); - fs.writeFileSync(entry, `${JSON.stringify({ pid, startedAt })}\n`, "utf8"); + fs.writeFileSync(entry, `${JSON.stringify({ pid, startedAt, identity })}\n`, "utf8"); return entry; } @@ -342,6 +343,41 @@ test("two processes acquiring concurrently never overlap", async () => { assert.equal(Number.parseInt(fs.readFileSync(counter, "utf8"), 10), rounds * 2, "an overlap lost increments"); }); +// A live pid whose start identity no longer matches the one the holder recorded +// is not the holder: the OS gave the number to someone else (#743). +test("a ticket whose pid was recycled by another process is evicted at once", { skip: process.platform === "win32" }, () => { + const workspace = makeTempDir(); + saveState(workspace, { jobs: [] }); + const lockDir = lockDirFor(workspace); + const ticket = seedLockEntry(lockDir, `1.${process.pid}-recycled.ticket`, process.pid, undefined, "darwin:not-this-process|nope"); + + const started = Date.now(); + assert.equal(withStateLock(workspace, () => "ok", { waitMs: 2000 }), "ok"); + assert.ok(Date.now() - started < 1500, `a recycled pid must not cost a grace period, took ${Date.now() - started} ms`); + assert.equal(fs.existsSync(ticket), false, "the recycled holder's ticket must be cleared"); +}); + +test("a ticket whose recorded identity still matches its live holder is kept", { skip: process.platform === "win32" }, () => { + const workspace = makeTempDir(); + saveState(workspace, { jobs: [] }); + const lockDir = lockDirFor(workspace); + const ticket = seedLockEntry(lockDir, `1.${process.pid}-same.ticket`, process.pid, undefined, getProcessIdentity(process.pid)); + + assert.throws(() => withStateLock(workspace, () => "stolen", { waitMs: 200 }), /state lock/i); + assert.equal(fs.existsSync(ticket), true, "a live holder's ticket must survive"); +}); + +test("the lock owner record carries this process's identity", { skip: process.platform === "win32" }, () => { + const workspace = makeTempDir(); + saveState(workspace, { jobs: [] }); + const lockDir = lockDirFor(workspace); + const owners = withStateLock(workspace, () => + fs.readdirSync(lockDir).map((name) => JSON.parse(fs.readFileSync(path.join(lockDir, name), "utf8"))) + ); + assert.ok(owners.length > 0); + assert.ok(owners.every((owner) => owner.pid === process.pid && owner.identity === getProcessIdentity(process.pid))); +}); + // A holder that died with its ticket in the directory releases it to the next // acquirer immediately — its PID proves it is gone. test("a ticket whose holder is gone is removed and the waiter acquires at once", () => { diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index ca39b3815..3c228a67b 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -6,6 +6,7 @@ import { spawn } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { makeTempDir, run } from "./helpers.mjs"; +import { getProcessIdentity } from "../plugins/codex/scripts/lib/process.mjs"; import { reapDeadJobs, runTrackedJob } from "../plugins/codex/scripts/lib/tracked-jobs.mjs"; import { listJobs, @@ -275,7 +276,7 @@ test("updateJobPid records the worker pid without rewriting the job file", () => const stored = readJobFile(jobFile); assert.equal(stored.status, "queued"); assert.equal(stored.requestFile, job.requestFile); - assert.equal(resolveJobPid(workspace, stored), 424242, "readers must find the pid in the sidecar"); + assert.deepEqual(resolveJobPid(workspace, stored), { pid: 424242, identity: null }, "readers must find the pid in the sidecar"); assert.equal(listJobs(workspace).find((entry) => entry.id === "job-pid").pid, 424242); }); @@ -309,7 +310,7 @@ test("updateJobPid leaves a record the worker already completed intact", () => { const indexed = listJobs(workspace).find((entry) => entry.id === "job-raced"); assert.equal(indexed.status, "completed"); assert.equal(indexed.pid, null, "a finished job must not get its pid back"); - assert.equal(resolveJobPid(workspace, stored), null, "a terminal record never reports a pid"); + assert.deepEqual(resolveJobPid(workspace, stored), { pid: null, identity: null }, "a terminal record never reports a pid"); }); // A worker that took the record over but has not written its own pid yet is @@ -396,3 +397,56 @@ test("a terminal write releases the job's private request payload", async () => ); assert.equal(fs.existsSync(resolveJobRequestFile(workspace, "job-thrown")), false, "a failed job must not keep its payload"); }); + +// A live pid is not proof of a live worker: the OS may have handed the number to +// something else (#743). The recorded identity tells them apart. +test("reapDeadJobs fails a running job whose pid was recycled by another process", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-recycled", status: "running", phase: "delegating", pid: process.pid, pidIdentity: "linux:not-this-process", logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { getProcessIdentityImpl: () => "linux:something-else" }); + assert.equal(reaped[0].status, "failed"); + assert.match(reaped[0].errorMessage, /pid reused/); + assert.equal(reaped[0].pidIdentity, null); +}); + +test("reapDeadJobs leaves a running job alone when the identity probe fails", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-probe-fails", status: "running", phase: "delegating", pid: process.pid, pidIdentity: "linux:x", logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { getProcessIdentityImpl: () => { throw new Error("ps unavailable"); } }); + assert.equal(reaped[0].status, "running"); +}); + +test("reapDeadJobs keeps a running job whose identity still matches", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-same", status: "running", phase: "delegating", pid: process.pid, pidIdentity: "linux:same", logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { getProcessIdentityImpl: () => "linux:same" }); + assert.equal(reaped[0].status, "running"); +}); + +test("pid sidecar round-trips identity and still reads the legacy bare integer", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-sidecar", status: "queued", phase: "queued", pid: null, logFile: null }); + updateJobPid(workspace, "job-sidecar", 777, "linux:777"); + assert.deepEqual(resolveJobPid(workspace, listJobs(workspace)[0]), { pid: 777, identity: "linux:777" }); + fs.writeFileSync(resolveJobPidFile(workspace, "job-sidecar"), "778\n"); + assert.deepEqual(resolveJobPid(workspace, { id: "job-sidecar", status: "queued", pid: null }), { pid: 778, identity: null }); + updateJobPid(workspace, "job-sidecar", 779, "linux:779"); + assert.deepEqual(resolveJobPid(workspace, { id: "job-sidecar", status: "queued", pid: null }), { pid: 779, identity: "linux:779" }); +}); + +test("runTrackedJob records the worker identity and clears it with the pid", async () => { + const workspace = makeTempDir(); + const job = { id: "job-identity", workspaceRoot: workspace, status: "queued", logFile: null }; + seedJob(workspace, job); + let running = null; + await runTrackedJob(job, async () => { + running = readJobFile(resolveJobFile(workspace, "job-identity")); + return { exitStatus: 0, payload: {}, rendered: "", summary: "" }; + }); + assert.equal(running.pid, process.pid); + assert.equal(running.pidIdentity, process.platform === "win32" ? null : getProcessIdentity(process.pid)); + const done = readJobFile(resolveJobFile(workspace, "job-identity")); + assert.deepEqual([done.pid, done.pidIdentity], [null, null]); + const indexed = listJobs(workspace).find((entry) => entry.id === "job-identity"); + assert.deepEqual([indexed.pid, indexed.pidIdentity], [null, null]); +}); From 69f958dedf90b831f66fc03210d3b0818da6d51d Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:20:54 +0300 Subject: [PATCH 14/36] fix(broker): a freshly spawned broker that never becomes ready is killed regardless of identity The not-ready branch holds the child handle it just spawned, so its pid cannot have been recycled: kill it directly on every platform instead of routing it through the stored-record identity check (which cannot answer on win32). Co-authored-by: sylvesterkaczmarek Co-Authored-By: Claude Fable 5.1 --- .../codex/scripts/lib/broker-lifecycle.mjs | 25 ++++++++++--------- tests/broker-stale-pid.test.mjs | 23 +++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index e59b4f37b..a85bbb751 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -266,21 +266,22 @@ export async function ensureBrokerSession(cwd, options = {}) { logFile, env: options.env ?? process.env }); - // Captured before the readiness wait, so even the teardown of a broker that - // never came up signals only the process it spawned. - const pidIdentity = getProcessIdentity(child.pid ?? Number.NaN); + // Recorded for later teardowns, which only trust a stored pid by identity. + const pidIdentity = (options.getProcessIdentityImpl ?? getProcessIdentity)(child.pid ?? Number.NaN); const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000); if (!ready) { - teardownBrokerSession({ - endpoint, - pidFile, - logFile, - sessionDir, - pid: child.pid ?? null, - pidIdentity, - killProcess - }); + // The pid comes from the child handle just spawned, not from a stored + // record: it cannot have been recycled, so it is killed without the identity + // check (which cannot answer on win32 at all). + if (Number.isInteger(child.pid)) { + try { + killProcess(child.pid); + } catch { + // Already exited. + } + } + teardownBrokerSession({ endpoint, pidFile, logFile, sessionDir }); return null; } diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index add31b4a7..7e6a63bcb 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1100,3 +1100,26 @@ test("SessionEnd leaves a recorded broker pid alone when its identity no longer assert.match(hook.stderr, /identity-mismatch/); clearBrokerSession(workspace); }); + +// A broker this call just spawned that never becomes ready is killed through the +// child handle: its pid cannot have been recycled, so no identity is consulted. +test("ensureBrokerSession kills a fresh broker that never becomes ready", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const scriptPath = path.join(makeTempDir(), "never-listens.mjs"); + fs.writeFileSync(scriptPath, "setInterval(() => {}, 1000);\n"); + const killed = []; + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), scriptPath, timeoutMs: 300, killProcess: recordingKill(killed), + // An identity that cannot be read (win32) must not keep the child alive. + getProcessIdentityImpl: () => null + }); + assert.equal(session, null); + assert.equal(killed.length, 1, "the fresh child must be signalled"); + assert.equal(loadBrokerSession(workspace), null); + const deadline = Date.now() + 5000; + while (isAlive(killed[0]) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.equal(isAlive(killed[0]), false, "the fresh child must be gone"); +}); From 734f17ae3b0296a3e08af148d861b1791884005c Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:34:17 +0300 Subject: [PATCH 15/36] release: prepare v1.3.0 (version bump, changelog, README) Bump package.json, package-lock.json, plugins/codex/.claude-plugin/plugin.json and .claude-plugin/marketplace.json to 1.3.0 via scripts/bump-version.mjs. Add the 1.3.0 CHANGELOG.md section (Fixed/Added/Changed/Known limitations, upstream #/PR citations) and sync plugins/codex/CHANGELOG.md to match. Add a README FAQ note on the Windows kill limitation, resolved in v1.4.0. Co-Authored-By: Claude Fable 5.1 --- .claude-plugin/marketplace.json | 4 +- CHANGELOG.md | 29 ++++++ README.md | 4 + package-lock.json | 4 +- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 123 ++++++++++++++++++++++- 7 files changed, 160 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d5cc53e40..4c6fa1314 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.2.1" + "version": "1.3.0" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.2.1", + "version": "1.3.0", "author": { "name": "OpenAI" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index afdaff7fe..861c3a0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 1.3.0 — 2026-09-27 + +### Fixed +- Terminal `error` notifications end the turn as failed instead of hanging (#698, PR #710); an error with `willRetry: true` keeps the turn running; a subagent's terminal error no longer fails the main turn; `errorMessage`/summary on a silently failed turn reflect the real failure instead of raw output (#757, PR #763); `fileChange` items without `changes` no longer crash progress (#775); a `turn/start` response without `turn.id` no longer strands buffered notifications (#781). +- A broker connect that never resolves is now bounded, and a broker request falls back to a direct app-server on `ETIMEDOUT` (#773); `status --wait` exits 1 and prints a timeout line on expiry (#774), in `--json` mode too. +- A live but wedged broker is killed before it is replaced (#753, #762, #782), its readiness probe is retried for 2 s before that (#768), and a stale or dead pid recorded from an earlier session is never signalled (#749); a freshly spawned broker that never becomes ready is killed as well. +- `/codex:transfer` honours `CLAUDE_CONFIG_DIR` when resolving Claude session transcripts (#721). +- The fallback state root is private (0700), per-user and per-plugin, and a symlinked root is refused (#521, #609); `broker.json` is validated before use. +- Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; `cancel` reports `worker pid N left running: ` when it refuses to signal a pid it cannot verify. + +### Added +- `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). +- The stop gate's block reason names the signal that killed the review task and always ends with the `/codex:setup --disable-review-gate` escape hatch (#589, #483). +- Model aliases resolve against the local Codex catalogue (`$CODEX_HOME/models_cache.json`, then `codex debug models --bundled`, hardcoded fallback last); adds the `astra` alias; `--effort` is validated per resolved model (#468, #703, #485). +- `CODEX_COMPANION_MODEL_CATALOG` overrides the catalogue source for tests. + +### Changed +- `CODEX_REVIEW_GATE_MAX_ROUNDS` now defaults to 3 (was unbounded); set it to `0` explicitly to keep the pre-1.3.0 unbounded behavior (#548). +- `hooks.json` no longer carries a top-level `description` key (#459). +- `runCommand` reports `status: null`, not `0`, for a subprocess that timed out. + +### Known limitations +- On Windows, kills issued from stored process records (cancel worker, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused until process identity lands in v1.4.0; leaks are bounded by the broker idle timeout, and the turn interrupt is still sent regardless. +- Foreground job records written by v1.2.x (no `pidIdentity`) are not killed at `SessionEnd` after upgrading to v1.3.0 (one-off). +- The fallback state root under `os.tmpdir()` is keyed by plugin install path, so it is not carried over from a v1.2.x install. +- Darwin identity checks use `ps lstart`, which has 1 s resolution. + +Ported with reference to upstream PRs by ALV0612, Soumya95, kevin9327, mzl9039, sylvesterkaczmarek, mittalpk, SomSamantray, weivwang. + ## 1.2.1 — 2026-09-21 ### Fork changes diff --git a/README.md b/README.md index 399c2c013..18265a2ec 100644 --- a/README.md +++ b/README.md @@ -375,3 +375,7 @@ goes through. Yes. Because the plugin uses your local Codex CLI, your existing sign-in method and config still apply. If you need to point the built-in OpenAI provider at a different endpoint, set `openai_base_url` in your [Codex config](https://developers.openai.com/codex/config-advanced/#config-and-state-locations). + +### Windows + +As of v1.3.0, kills issued from stored process records (`/codex:cancel`, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused on Windows until process identity lands in v1.4.0. This bounds any leak by the broker idle timeout, and a turn interrupt is still sent regardless — it just cannot be followed by a forced kill on that platform yet. diff --git a/package-lock.json b/package-lock.json index 8ba232e71..36c8d0404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cbepx/codex-plugin-cc", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cbepx/codex-plugin-cc", - "version": "1.2.1", + "version": "1.3.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index e76ca1f52..c05ef21af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cbepx/codex-plugin-cc", - "version": "1.2.1", + "version": "1.3.0", "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 a2ec42c3d..c7412676d 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.2.1", + "version": "1.3.0", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index d647561bb..861c3a0a4 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,124 @@ # Changelog -## 1.0.0 +## 1.3.0 — 2026-09-27 -- Initial version of the Codex plugin for Claude Code +### Fixed +- Terminal `error` notifications end the turn as failed instead of hanging (#698, PR #710); an error with `willRetry: true` keeps the turn running; a subagent's terminal error no longer fails the main turn; `errorMessage`/summary on a silently failed turn reflect the real failure instead of raw output (#757, PR #763); `fileChange` items without `changes` no longer crash progress (#775); a `turn/start` response without `turn.id` no longer strands buffered notifications (#781). +- A broker connect that never resolves is now bounded, and a broker request falls back to a direct app-server on `ETIMEDOUT` (#773); `status --wait` exits 1 and prints a timeout line on expiry (#774), in `--json` mode too. +- A live but wedged broker is killed before it is replaced (#753, #762, #782), its readiness probe is retried for 2 s before that (#768), and a stale or dead pid recorded from an earlier session is never signalled (#749); a freshly spawned broker that never becomes ready is killed as well. +- `/codex:transfer` honours `CLAUDE_CONFIG_DIR` when resolving Claude session transcripts (#721). +- The fallback state root is private (0700), per-user and per-plugin, and a symlinked root is refused (#521, #609); `broker.json` is validated before use. +- Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; `cancel` reports `worker pid N left running: ` when it refuses to signal a pid it cannot verify. + +### Added +- `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). +- The stop gate's block reason names the signal that killed the review task and always ends with the `/codex:setup --disable-review-gate` escape hatch (#589, #483). +- Model aliases resolve against the local Codex catalogue (`$CODEX_HOME/models_cache.json`, then `codex debug models --bundled`, hardcoded fallback last); adds the `astra` alias; `--effort` is validated per resolved model (#468, #703, #485). +- `CODEX_COMPANION_MODEL_CATALOG` overrides the catalogue source for tests. + +### Changed +- `CODEX_REVIEW_GATE_MAX_ROUNDS` now defaults to 3 (was unbounded); set it to `0` explicitly to keep the pre-1.3.0 unbounded behavior (#548). +- `hooks.json` no longer carries a top-level `description` key (#459). +- `runCommand` reports `status: null`, not `0`, for a subprocess that timed out. + +### Known limitations +- On Windows, kills issued from stored process records (cancel worker, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused until process identity lands in v1.4.0; leaks are bounded by the broker idle timeout, and the turn interrupt is still sent regardless. +- Foreground job records written by v1.2.x (no `pidIdentity`) are not killed at `SessionEnd` after upgrading to v1.3.0 (one-off). +- The fallback state root under `os.tmpdir()` is keyed by plugin install path, so it is not carried over from a v1.2.x install. +- Darwin identity checks use `ps lstart`, which has 1 s resolution. + +Ported with reference to upstream PRs by ALV0612, Soumya95, kevin9327, mzl9039, sylvesterkaczmarek, mittalpk, SomSamantray, weivwang. + +## 1.2.1 — 2026-09-21 + +### Fork changes +- Align rescue guidance with the current Codex model family and generic `codex-prompting` skill name. +- Keep diagnosis and investigation read-only unless the user explicitly requested file changes. + +## 1.2.0 — 2026-08-28 + +### Merged from upstream pull requests +- #355 `SessionEnd` now terminates only the ending session's own foreground jobs; a workspace with any active job keeps its shared broker alive across `SessionEnd` instead of tearing it down out from under the worker, and the hook only clears its own broker-session record when the endpoint still matches (a replacement broker's record survives). +- #425 a PID-liveness reaper marks a `queued`/`running` job `failed` ("worker exited before completing") once its worker process is confirmed dead (`kill(pid, 0)` reports `ESRCH`), deletes the job's private one-shot request payload, and clears `requestFile`; a `queued` job with no recorded PID yet gets a 30 s grace window before it is reaped, covering a worker that died between spawn and the PID being recorded. + +### Fork changes +- `task --await [--await-timeout-ms ]` launches the same tracked job record as `--background`, then waits for it: exit 0 when the job completed, 1 when it failed or was cancelled, 3 when the default 540000 ms wait elapses while the job is still queued or running (prints a `Re-run: node "" result --wait --timeout-ms 540000` hint). +- `--prompt-stdin` takes the prompt as raw stdin, stripping exactly one trailing newline and nothing else; the mutual exclusion against `--args-stdin`, `--prompt-file`, and a positional prompt is checked on the raw argv before any stdin is read, so a bad combination fails fast instead of blocking on stdin. +- `result [--wait [--timeout-ms ]]` exits 0 for any terminal job record — completed, failed, or cancelled alike, since this is retrieval, not a pass/fail signal — and exits 3 with the same resumable hint when the job is still active and `--wait` was not given; `--json` returns `{ job, storedJob }`. +- Fixed upstream #498/#524: `result ` on a still-`queued`/`running` job used to throw "No job found for ``"; it now reports the job's real status plus the `result … --wait` hint, exit 3. +- `--await-timeout-ms` and `result --timeout-ms` are validated as finite positive integers (rejects `0`, negatives, fractional, and non-numeric values). +- Worker-survival caveat: the detached worker outlives the companion only when the companion returns on its own via exit 3 — a host process-tree kill (e.g. Claude Code's Bash tool timeout) kills the worker too, so keep `--await-timeout-ms` below the host's own timeout. +- The worker's PID is now recorded right after spawn (`updateJobPid`), so a `cancel` issued while a job is still `queued` has a process to signal; `cancel` also releases the job's private one-shot request payload itself, since a cancelled job is terminal and the reaper never revisits it. The parent never rewrites the job JSON after the spawn — that read-modify-write could put a queued snapshot over a record the worker had already completed, losing its `result`, `threadId` and `turnId` while the state index already said terminal. The PID goes into an atomic `jobs/.pid` sidecar (write + rename) plus the existing pid-only index patch; readers (`cancel`, the reaper, `SessionEnd` cleanup) take `job.pid ?? sidecar` and never read a sidecar for a terminal job, and the sidecar is deleted with the job's terminal write, reap, cancel or prune. +- `SessionEnd` now reaps dead background workers before deciding whether an active background job should keep the broker alive, so a hard-killed (e.g. OOM/`SIGKILL`) worker can no longer pin a broker and its job's private payload alive indefinitely. +- `/codex:rescue` and the `codex-rescue` agent are now a single `node … task --await --prompt-stdin <<'CODEX_PROMPT_'` call each, replacing the old two-Bash-call `mktemp`/`trap`/`status --wait`-poll-loop dance; the per-call random delimiter suffix now only has one heredoc to protect (the request prose) since flags travel on the command line and there is no second `--args-stdin` heredoc anymore; on exit 3 the only allowed follow-up is re-running the printed `Re-run:` hint for that same job. +- The resume decision (`task-resume-candidate --json` + one `AskUserQuestion`) is now made once, before the synchronous/`--background` split, so both paths get the same explicit `--resume-last`/`--fresh` flag; a `--background` handoff never resumes silently — the agent runs fresh unless it is handed an explicit `--resume-last` from that shared decision. +- `rescue.md`'s `allowed-tools` is back to `Bash(node:*), AskUserQuestion, Agent` (widened to bare `Bash` in 1.1.0 only to support the removed two-Bash-call flow). +- `--turn-timeout-ms ` (or `CODEX_TURN_TIMEOUT_MS`) on `task`, `review`, and `adversarial-review` bounds a single Codex turn: on expiry it sends `turn/interrupt` and returns a structured failed result ("turn timed out after `` ms") instead of hanging or throwing; default is `0` (unbounded, unchanged behavior); the budget is persisted into `--background`/`--await` job requests so detached workers run under the same limit; the timeout now waits up to 10 s for the turn's terminal notification after `turn/interrupt` instead of declaring the turn dead the moment the RPC answers, and an unacknowledged interrupt is reported as such ("interrupt not acknowledged — the turn may still be running in the shared runtime, check status or cancel") — on a non-broker transport the app-server it owns is closed so the runaway turn dies with the process. +- Documented: with `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0`, a broker kept alive by an active background job across `SessionEnd` never exits on its own — the idle self-terminate safety net from #457 is disabled in that configuration. +- Known limitations: `kill(pid, 0)` reads a zombie process as alive and cannot detect PID reuse, so the reaper can occasionally misjudge a dead worker's liveness (documented in `lib/tracked-jobs.mjs`); a `turn/start` call that never answers is still unbounded — `--turn-timeout-ms` only covers the window after `turn/start` resolves. +- `task --await` jobs are recorded as background jobs (they survive SessionEnd and keep the broker while active, bounded by the reaper and the idle timeout). +- The legacy-request move is limited to `queued` records, and every terminal write releases the job's private payload. A worker consumes its request *before* `runTrackedJob` flips the record to `running`, so staging a payload for a running legacy job would have written plaintext `--config` values that nothing reads and nothing deletes; `runTrackedJob` now drops the payload file alongside the PID sidecar on both its terminal paths, so a payload the worker never consumed cannot outlive the job either. +- Migrating a 1.1.1 record moves its raw request into a fresh 0600 `jobs/.request.json` before the record loses it, for jobs that are still `queued`. 1.1.1 wrote no private payload, so the record was the only copy — and it is exactly what a worker falls back to, which would have started Codex with `"[redacted]"` in place of real `--config` values (auth headers included). No record is left unredacted on disk any more. +- Records written by 1.1.1 are redacted too: `--config` values are stripped at the read boundary every `status`/`result` output crosses (`loadState` and the job-file reader), and the same read rewrites the index and the job file once so the values stop living in long-lived state. Redaction now has a single implementation, `redactConfigValues` in `lib/state.mjs`. +- A tracked job record never stores `--config` **values** any more: it keeps the keys and writes every value as `[redacted]`, because classifying secrets by key name (`/key|token|secret|auth|password/i`) missed real credentials such as a `http_headers.Cookie` override, which then reached `state.json`, the job file, `status --json` and `result --json`. The real values still reach Codex — they live only in the private 0600 `jobs/.request.json` the worker consumes. +- `SessionEnd` no longer shuts the shared broker down while **any** job in the workspace is still `queued`/`running` — the check used to count only jobs flagged `background: true`, so another Claude session's foreground run (which survives this session's own-jobs-only cleanup) had its runtime pulled out from under it. The check runs on the state left behind by that cleanup and, as before, reaps dead workers first so a killed worker cannot pin the broker. +- `state.json`, job files and the private request payload are now written atomically (sibling temp file + `rename`). A reader that caught a plain `writeFileSync` mid-flight parsed a truncated — often zero-byte — file, and since `loadState` answers a parse failure with an empty job list, that read looked exactly like an idle workspace: a `SessionEnd` landing in that window could shut the shared broker down under a live job (and the reaper/`cancel` paths could miss active jobs the same way). +- The reaper now reconciles a terminal job file back into the state index instead of returning it to the current caller only: a worker that died between its terminal `writeJobFile` and its `upsertJob` left an active index entry that `assertThreadIsFree` (which now also reads the reaped list) saw as a phantom running job, blocking every later resume of that thread. +- `close()` on a direct app-server is idempotent as well as bounded: the deadline used to apply to the first call only, and every later call awaited process exit with no bound at all. The turn timeout closes twice by design (once to kill the runaway turn, once on the way out of the run), so the single case the deadline exists for was the one that still hung. +- A bounded turn now always ends in a terminal job record. The acknowledgement window after `turn/interrupt` is a referenced timer raced against the transport's own exit, so an app-server that answers the interrupt and dies can no longer let the companion exit with the job still `running` (and no longer costs the full 10 s wait), and closing a directly owned app-server escalates stdin EOF → `SIGTERM` → `SIGKILL` with a hard 5 s deadline instead of awaiting process exit forever. +- Documented: partial output on a timed-out turn is best-effort — only items Codex had already completed are kept, so a turn interrupted mid-message reports less text than Codex produced. +- The reaper reads the authoritative job file **before** it looks at PID liveness, for every active index entry: `kill(pid, 0)` reads a zombie as alive and cannot see a recycled PID, so a job whose file was already terminal could keep a phantom `running` index entry — blocking resume on its thread and keeping `SessionEnd` from ever releasing the broker — for as long as anything held that PID. Liveness is now only consulted for jobs whose own file still says they are active. +- `review`/`adversarial-review` now persist `--background` on the job record, so a review dispatched to outlive its session (`nohup … --background &`) keeps its record — and with it `/codex:status` and `/codex:result` — after that session ends. +- Shutdown-if-idle counts every connected client, not only those that have already sent a message: a client is a client from the moment it is accepted, and `CodexAppServerClient` connects before it writes `initialize`, so a shutdown landing in that gap killed the broker under it. The readiness probe now closes its connection fully before reporting the broker up (an open probe would otherwise read as a phantom client), and a client whose broker connection is dropped before `initialize` is answered falls back to its own app-server instead of failing the run. +- The `broker/shutdown` handshake is framed by newline and matched by request id, and it is bounded (5 s). A socket carries bytes, not messages, so a `{"busy":true}` reply split across two `data` events used to fail to parse and be read as "not busy" — tearing down a broker in the middle of another session's turn — while a peer that connected and stayed silent blocked `SessionEnd` forever. An unanswered or unparsable handshake is now reported as unknown, and `SessionEnd` treats anything but a confirmed "not busy" as a reason to leave the broker (and its record, pid file and endpoint) alone. +- The state-lock timeout carries a typed `code` (`CODEX_STATE_LOCK_TIMEOUT`), and `SessionEnd` absorbs only that. It used to recognise the timeout by matching "state lock" in the message, which would equally have swallowed the lock's own integrity error — or any filesystem error whose path happens to contain the phrase — reporting a real cleanup failure as a spent budget and exiting 0. Anything that is not the typed timeout now fails the hook as before. +- `CODEX_COMPANION_SESSION_END_BUDGET_MS` can only *shorten* the SessionEnd budget. The hook timeout in `hooks.json` is a fixed number that no environment variable can raise, so an override above the 12 s ceiling would have put the deadline past the point where Claude Code kills the hook — the exact failure the budget exists to prevent. A larger value is now refused with a note naming it and the ceiling. +- `SessionEnd` runs to one absolute budget (`SESSION_END_BUDGET_MS`, 12 s; `CODEX_COMPANION_SESSION_END_BUDGET_MS` can only shorten it): the workspace state lock, each broker handshake, the busy retries and the teardown probe are each clamped to what is left of it, and a spent budget is logged (`budgetExhausted=true`) and the broker left alone. Those bounds add up past any single one of them — the reaper alone takes the lock once per dead job — so a `busy` answer followed by a broker that stopped answering, or a couple of dead jobs behind a wedged lock holder, used to run past the hook timeout and be killed mid-decision. A lock the hook cannot take is now reported in that decision line rather than crashing the hook. `hooks/hooks.json` raises the `SessionEnd` timeout from 5 s to 15 s, above the worst case, and a test asserts it stays above the budget so the two numbers cannot drift. +- `SessionEnd` no longer takes a single `busy` answer as final. The broker counts every connected socket as a client, so a worker this hook has just reaped can still show up as one until its close event is processed; the handshake is now retried every 100 ms for up to 1 s before the broker is left alone. A broker that is genuinely serving another session stays busy for the whole window and keeps its record, endpoint and pid file, as before. Every SessionEnd decision — active jobs, busy, unconfirmed, teardown — is now written to stderr; the ones taken after the handshake carry the retry count. +- The broker has exactly one shutdown and one exit. Both signals, the `broker/shutdown` RPC and the idle timeout now join the same memoized teardown, and the process leaves only once it has finished: a second trigger used to get an immediate `return` and exit out from under the first — orphaning the app-server child mid-kill and leaving the endpoint socket, the pid file and the ownership record behind. That record is now cleared last, with the rest of the cleanup, instead of before the teardown starts. +- The broker can no longer be made to ignore `SIGTERM`. Its shutdown awaited `server.close()`, which fires only once every connection has closed, and `socket.end()` is a graceful half-close — so one client that never answered the FIN (a wedged peer, or one whose process was already gone but whose close had not been processed yet) left the shutdown hanging and the signal handler never reached `process.exit(0)`. A `SessionEnd` that signalled such a broker went on with its teardown while the process stayed alive. Connections now get a 1 s grace to close and are destroyed after it. +- Releases ship as GitHub Releases (title `codex-plugin-cc vX.Y.Z`) with the `npm pack` tarball and its SHA-256 attached; `release-verify.yml` re-runs the gate on the published tag; procedure in `docs/RELEASING.md`. +- The broker itself now refuses `broker/shutdown` while any other client is connected (replying `{ "busy": true }` and continuing to serve), and `SessionEnd` treats that refusal as "leave everything alone". The hook's active-job check is only a snapshot: another session could enqueue a job and connect in the gap before the shutdown RPC, and the broker used to shut down regardless, killing that turn. A broker kept alive this way is reaped by its own idle timeout — except with `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=0`, where a client that stays connected keeps the broker alive until the operator stops it. +- The ticket lock fails closed: a directory listing, a read or a `stat` that fails (permissions, I/O) aborts the acquisition instead of being read as an empty queue, an unowned entry or an infinitely old one — only `ENOENT` is an answer, meaning the entry left the queue. Junk entries (content that reads but does not parse; entries are published by rename, so a live holder's is never half-written) are still aged out after 2 s. Every blocker is judged before any is evicted, so a verdict that fails partway leaves the queue exactly as it found it: a failed acquisition removes nothing but its own entries and never runs the callback. The bounded wait is checked before every scan after the first, so a blocker that cannot be cleared ends in the timeout error rather than a hot loop, and the poll pause is skipped only after an eviction that actually happened — an abandoned entry this process may not remove no longer hammers the filesystem for the whole budget — while an uncontended lock is still taken whatever the budget was. +- The workspace state lock is a Lamport bakery on files (`state.lock.d/`): an acquirer creates `choosing.`, takes a number one above the highest ticket on display, creates `..ticket`, drops its `choosing` file, and holds the lock once no foreign `choosing` file remains and no ticket sorts before its own (by number, ties by token). Releasing is one `unlink` of its own ticket. Nothing shared is ever replaced or removed: every name is unique to one acquisition and every file's content is immutable, so a verdict about a file cannot go stale before it is acted on — which is what every previous design (mkdir + holder file, rename-aside takeover, tombstone fences) could not guarantee, since POSIX has no conditional replace. Only entries whose owner is provably gone are cleared, on the unchanged contract: dead PID → at once; unreadable entry → after 2 s; unusable PID → after 30 s; live PID → never, with the wait error naming that PID and its exact ticket file. A pre-1.2.0 `state.lock` directory is left untouched. +- Every read-modify-write of `state.json` now runs under a cross-process workspace lock (the ticket lock above; 25 ms polling and a 5 s bound). Atomic writes only stopped torn reads: two processes could still read the same file, and the one that wrote last would treat every job the other had added as deleted — pruning that job's file, private payload, PID sidecar and log. `updateState`, `upsertJob`, `updateJobPid`, `saveState` (whose prune is a diff of the snapshot it read), the reaper's reconciliation, `cancel` and the `SessionEnd` cleanup all re-read inside the lock; readers (`status`, `result`, `listJobs`) stay lock-free. + +## 1.1.1 — 2026-08-28 + +- Broker idle self-terminate (upstream #457): the shared Codex runtime exits after 30 minutes without a connected client (`CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS` / `--idle-timeout`), so idle brokers and their app-server children no longer accumulate (#543). +- Broker lifecycle races found in #457: a broker that self-terminates on idle now drops its `broker.json` ownership record (a later `SessionEnd` could otherwise signal a recycled PID, and `status` could advertise a dead endpoint), teardown verifies the recorded PID really is this session's broker before signalling it, and the broker stops listening before it closes its app-server child so a client connecting mid-shutdown is refused instead of being served and then failing its first RPC. +- Test suite no longer leaks fake `codex app-server`/broker processes (5 s idle timeout in the test environment; CI fails if any `codex-plugin-test-*` process survives). +- Rescue shell blocks use `command rm -f --` in their cleanup trap (no noise from `rm` aliases such as `trash`). + +## 1.1.0 — 2026-08-27 + +Fork of [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) 1.0.6 (`db52e28`). Marketplace `cbepx`, plugin name unchanged (`codex`). + +### Merged from upstream pull requests +- #616 accept `max` and `ultra` reasoning efforts +- #688 resolve model aliases on `review` / `adversarial-review` +- #426 `on-request` approval policy for `--write` task runs +- #501 answer MCP elicitation requests instead of rejecting them +- #608 rescue agent awaits the delegated result instead of returning a placeholder +- #690 explicit Bash blocks in `status`/`result`/`cancel`/`transfer` commands (pass permission classifiers) +- #547 unknown flags are CLI errors, never part of the prompt; `--help` prints usage and exits 0 +- #645 / #644 job records store resolved model/effort/sandbox; reasoning start is logged +- #672 SessionStart hook timeout raised; #668 idempotent `CLAUDE_ENV_FILE` exports; #682 stop gate fails closed on malformed input; #396 `CODEX_REVIEW_GATE_MAX_ROUNDS` + +### Fork changes +- Slash-command arguments reach the companion through a quoted heredoc on stdin (`--args-stdin`) instead of a shell string: Claude Code substitutes `$ARGUMENTS` before bash runs, so `$(...)`/backticks in a prompt used to execute on the host shell, outside Codex's sandbox. Rescue job ids are validated before use. `/codex:rescue` keeps the two channels separate — the request prose goes to `--prompt-file` through its own quoted heredoc so quotes, backslashes and newlines survive byte-exact, while `--args-stdin` carries only runtime flags — and randomizes both heredoc delimiters per call; the other seven command bodies keep the fixed `CODEX_ARGS` delimiter because their payload is only flags and job ids. +- Approval requests (`execCommandApproval`, `applyPatchApproval`, `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`) are answered with each type's refusal variant instead of a `-32601` protocol error that made `--write` turns fail or hang. +- Background task records are written before the worker is spawned (a fast worker used to find no record and exit while the launch reported `queued`), and the worker reads the full request — including `--config` values — from a private one-shot `jobs/.request.json` (mode 0600); the job record `status`/`result` echo keeps secret-looking config values redacted. +- Model and reasoning effort are sent per thread via `thread/start.config` (`model`, `review_model` for native review, `model_reasoning_effort`); generic `--config` pairs are applied first, dedicated flags override them; `--effort` now works on `review` and `adversarial-review`. +- Repeatable `--config key=value` on `task`, `review`, `adversarial-review` forwards any `config.toml` override to the thread (values are JSON-parsed; quote a literal string as `'"true"'`). Prompt-taking commands stop option parsing at the first positional, so prompt text like `ls -R` is never mis-parsed. +- `--resume-last` opens a fresh app-server session (cold resume) so `--config`, sandbox and approval policy take effect, and never sends `model` on `thread/resume` (it would drop the persisted model); the resumed turn's model/effort ride on `turn/start`. +- All MCP elicitations are declined (no operator is present); URL/form flows must be completed in an interactive Codex session. +- `/codex:rescue` is synchronous by default without the `Agent` tool: `task --background` → `status --wait` in ≤9-minute slices (job id carried literally between Bash calls) → `result`; launch failures stop immediately with visible stderr; `Agent` only for `--background`; `--write` is never added unless the user explicitly asked Codex to modify files. +- `/codex:rescue` asks before continuing an existing Codex thread (`Continue current Codex thread` / `Start a new Codex thread`) instead of resuming silently; its `allowed-tools` is now `Bash, AskUserQuestion, Agent` because the body is multi-command shell. +- A resume refuses to start a second turn on a thread that a queued or running job is still using, including a job from another Claude session. +- Model aliases: `sol`, `luna`, `terra`, `mini` (plus `spark`); rescue agent has no pinned `model:`; runtime skill mentions `$agent-compat:skill-router` for uncommon domains. +- Stop-gate script timeout (13 min) is below the hook timeout (15 min); `spawnSync` uses `SIGKILL` and a 16 MiB buffer. +- Hermetic test environment (`tests/test-env.mjs`); CI on push; `npm run build` type-checks the JSDoc. + +## 1.0.6 and earlier +See upstream releases: https://github.com/openai/codex-plugin-cc/releases From 9237cd43f5ab9bf13739521f3455f6f0909ba1d6 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:51:37 +0300 Subject: [PATCH 16/36] fix(codex): name the main turn from turn/started when turn/start carries no id (#781) The timeout path could not send turn/interrupt without a turnId. Also correct the stale comments in failTurnOnTimeout and on the error notification. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/codex.mjs | 16 +++++++++++----- tests/runtime.test.mjs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 5f31ba1b2..ec65829ca 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -563,6 +563,11 @@ function applyTurnNotification(state, message) { case "turn/started": registerThread(state, message.params.threadId); state.threadTurnIds.set(message.params.threadId, message.params.turn.id); + // A turn/start response without an id (#781) leaves this the only place + // the main turn is named; the timeout path needs it to interrupt. + if ((message.params.threadId ?? null) === state.threadId && !state.turnId) { + state.turnId = message.params.turn.id ?? null; + } if ((message.params.threadId ?? null) !== state.threadId) { state.activeSubagentTurns.add(message.params.threadId); } @@ -601,7 +606,8 @@ function applyTurnNotification(state, message) { const errorThreadId = message.params.threadId ?? null; if (errorThreadId && errorThreadId !== state.threadId) { // A subagent's terminal error ends only that subagent's turn, like its turn/completed. - // An error without a threadId stays terminal for the main turn. + // The protocol requires a threadId on `error`, and one without it never + // passes `belongsToTurn`, so it does not reach this switch at all. const label = labelForThread(state, errorThreadId) ?? errorThreadId; emitProgress(state.onProgress, `Subagent ${label} error: ${error.message}`, null); state.activeSubagentTurns.delete(errorThreadId); @@ -713,10 +719,10 @@ async function failTurnOnTimeout(client, state, timeoutMs) { } } - // Wait for the turn to actually end. With no turnId there was nothing to - // interrupt (and notifications are still buffered), so this window only ever - // expires — the report then says the turn may still be running, which is the - // truth. `completeTurn` already ran if the notification arrived. + // Wait for the turn to actually end. With no turnId — neither the turn/start + // response nor a turn/started notification named the turn — there was nothing + // to interrupt, so this window usually expires and the report says the turn + // may still be running, which is the truth. `completeTurn` already ran if the notification arrived. if (await waitForTurnAcknowledgement(client, state, TURN_INTERRUPT_ACK_MS)) { return; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 2d08a168e..6443b269a 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3897,6 +3897,22 @@ test("task completes when the turn/start response carries no turn id (#781)", () 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("node", [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(readPersistedJob(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); From 88853b33442f1b042d5fa460262b60ad373115ab Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:04 +0300 Subject: [PATCH 17/36] fix(broker): never signal an exited fresh child or an unverified legacy pid A fresh broker that fails readiness is killed through its child handle, and not at all once it has exited; the numeric process-group kill is only a fallback behind identity or command-line proof. A legacy broker.json pid is re-verified at kill time after the readiness retry, and the record is only cleared while it still names the endpoint we tore down. Co-Authored-By: Claude Fable 5.1 --- .../codex/scripts/lib/broker-lifecycle.mjs | 33 ++++++--- tests/broker-stale-pid.test.mjs | 72 ++++++++++++++++--- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index a85bbb751..e05e3005e 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -244,9 +244,13 @@ export async function ensureBrokerSession(cwd, options = {}) { pid: liveOwned ? pid : null, pidIdentity: existing.pidIdentity ?? null, killProcess: liveOwned ? killProcess : null, - ownsProcess: () => true + // Re-checked at kill time: the pid may have been recycled during the retry. + ownsProcess: ownsProcessImpl }); - clearBrokerSession(cwd); + // Compare before delete: a concurrent caller may already have replaced it. + if (loadBrokerSession(cwd)?.endpoint === existing.endpoint) { + clearBrokerSession(cwd); + } } const sessionDir = createBrokerSessionDir(); @@ -271,17 +275,28 @@ export async function ensureBrokerSession(cwd, options = {}) { const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000); if (!ready) { - // The pid comes from the child handle just spawned, not from a stored - // record: it cannot have been recycled, so it is killed without the identity - // check (which cannot answer on win32 at all). - if (Number.isInteger(child.pid)) { + // A child that already exited is not signalled at all: its pid may belong to + // someone else by now. A live one is killed through its handle, which cannot + // reach a recycled pid; only if that fails does the numeric (process-group) + // kill run, and then only after identity or command-line proof. + let fallback = false; + if (child.exitCode === null && child.signalCode === null) { try { - killProcess(child.pid); + fallback = !child.kill("SIGTERM"); } catch { - // Already exited. + fallback = true; } } - teardownBrokerSession({ endpoint, pidFile, logFile, sessionDir }); + teardownBrokerSession({ + endpoint, + pidFile, + logFile, + sessionDir, + pid: fallback ? (child.pid ?? null) : null, + pidIdentity, + killProcess: fallback ? killProcess : null, + ownsProcess: ownsProcessImpl + }); return null; } diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 7e6a63bcb..394ac2c36 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1102,7 +1102,8 @@ test("SessionEnd leaves a recorded broker pid alone when its identity no longer }); // A broker this call just spawned that never becomes ready is killed through the -// child handle: its pid cannot have been recycled, so no identity is consulted. +// child handle: its pid cannot have been recycled while the handle says it has +// not exited, so no identity is consulted. test("ensureBrokerSession kills a fresh broker that never becomes ready", async () => { const binDir = makeTempDir(); installFakeCodex(binDir); @@ -1110,16 +1111,69 @@ test("ensureBrokerSession kills a fresh broker that never becomes ready", async const scriptPath = path.join(makeTempDir(), "never-listens.mjs"); fs.writeFileSync(scriptPath, "setInterval(() => {}, 1000);\n"); const killed = []; - const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), scriptPath, timeoutMs: 300, killProcess: recordingKill(killed), - // An identity that cannot be read (win32) must not keep the child alive. - getProcessIdentityImpl: () => null + const spawned = []; + try { + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), scriptPath, timeoutMs: 300, killProcess: recordingKill(killed), + // An identity that cannot be read (win32) must not keep the child alive. + getProcessIdentityImpl: (pid) => (spawned.push(pid), null) + }); + assert.equal(session, null); + assert.equal(spawned.length, 1); + assert.equal(loadBrokerSession(workspace), null); + const deadline = Date.now() + 5000; + while (isAlive(spawned[0]) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.equal(isAlive(spawned[0]), false, "the fresh child must be gone"); + } finally { + for (const pid of spawned) { try { process.kill(pid, "SIGKILL"); } catch {} } + } +}); + +// A fresh child that exited during the readiness wait has a pid the OS may +// already have handed on: nothing may be signalled by number. +test("ensureBrokerSession never signals the pid of a fresh broker that already exited", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const scriptPath = path.join(makeTempDir(), "exits-at-once.mjs"); + fs.writeFileSync(scriptPath, "process.exit(0);\n"); + const killed = []; + const spawned = []; + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), scriptPath, timeoutMs: 500, killProcess: recordingKill(killed), + getProcessIdentityImpl: (pid) => (spawned.push(pid), null) }); assert.equal(session, null); - assert.equal(killed.length, 1, "the fresh child must be signalled"); + assert.equal(spawned.length, 1); + assert.deepEqual(killed, [], "an exited child's pid must not be signalled"); assert.equal(loadBrokerSession(workspace), null); - const deadline = Date.now() + 5000; - while (isAlive(killed[0]) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 50)); +}); + +// A legacy record (no identity) is re-checked by command line when the kill +// happens, not only before the 2 s readiness retry: the pid may be recycled +// during that wait. +test("ensureBrokerSession re-verifies a legacy broker's ownership after the readiness retry", async () => { + const binDir = makeTempDir(); + installFakeCodex(binDir); + const workspace = makeTempDir(); + const sessionDir = makeTempDir("cxc-"); + saveBrokerSession(workspace, { endpoint: createBrokerEndpoint(sessionDir), pidFile: null, logFile: null, sessionDir, pid: process.pid }); + const killed = []; + let probes = 0; + const session = await ensureBrokerSession(workspace, { + env: buildEnv(binDir), + isAliveImpl: () => true, + // Ours before the retry; the command line changed during it. + ownsProcessImpl: () => (probes += 1) === 1, + killProcess: recordingKill(killed), + retryTimeoutMs: 300 + }); + try { + assert.ok(probes >= 2, "ownership must be checked again at kill time"); + assert.deepEqual(killed, []); + assert.ok(session); + } finally { + if (session?.pid) { try { process.kill(session.pid, "SIGTERM"); } catch {} } + clearBrokerSession(workspace); } - assert.equal(isAlive(killed[0]), false, "the fresh child must be gone"); }); From 41ca8143d52ccfc9d618c4b3c5881d0ec36deb57 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:46 +0300 Subject: [PATCH 18/36] fix(cancel): keep the job running when the worker kill was refused A worker cancel may not signal but that is still alive would later overwrite the cancelled record. The job now stays running with its pid sidecar, the log and output say cancellation is not confirmed, and cancel exits 1. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 16 ++++++++++- tests/runtime.test.mjs | 34 +++++++++++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 9fb3f2a33..a32efee3d 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -25,7 +25,7 @@ import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; import { loadModelCatalog, resolveModelAlias, supportedEfforts } from "./lib/model-catalog.mjs"; -import { binaryAvailable, getProcessIdentity, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; +import { binaryAvailable, getProcessIdentity, isPidAlive, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { consumeJobRequestFile, @@ -1322,6 +1322,20 @@ async function handleCancel(argv) { // Only a pid that is provably still this job's worker is signalled (#743). const { pid, identity } = resolveJobPid(workspaceRoot, job); const kill = terminateRecordedProcess(pid, { identity, commandLineMatch: workerCommandLine(job.id) }); + // A worker we may not signal but that is still alive would overwrite a + // `cancelled` record with its own result: the job stays running, and the + // sidecar stays so a later cancel or the reaper can still find it. + if (pid && !kill.attempted && isPidAlive(pid) === true) { + const pending = `cancellation not confirmed: worker pid ${pid} left running (${kill.reason})`; + appendLogLine(job.logFile, pending); + process.exitCode = 1; + outputCommandResult( + { jobId: job.id, status: "running", cancellationPending: true, reason: kill.reason }, + `${pending}\nThe turn interrupt was sent; the job stays running until the worker exits. Re-run cancel or wait for result.\n`, + options.json + ); + return; + } const leftRunning = pid && !kill.attempted ? `worker pid ${pid} left running: ${kill.reason}` : null; if (leftRunning) { appendLogLine(job.logFile, leftRunning); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 6443b269a..53fd25aab 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -18,7 +18,8 @@ import { resolveJobRequestFile, resolveStateDir, upsertJob, - writeJobFile + writeJobFile, + writeJobPidFile } from "../plugins/codex/scripts/lib/state.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -1910,11 +1911,12 @@ test("cancel stops an active background job and marks it cancelled", async (t) = assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/); }); -// The #743 scenario: a record from before identities existed names a pid the OS -// has since handed to an unrelated process. Liveness says "alive", so the reaper -// keeps the job; cancel must still refuse to signal a process that is not this -// job's worker, and say so. -test("cancel refuses to signal a worker whose recorded identity no longer matches and says so", { skip: process.platform === "win32" }, async (t) => { +// 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 an unrelated +// process. Liveness says "alive", so the reaper 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. +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); const stranger = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" }); @@ -1934,14 +1936,30 @@ test("cancel refuses to signal a worker whose recorded identity no longer matche }; writeJobFile(repo, job.id, job); upsertJob(repo, job); + writeJobPidFile(repo, job.id, stranger.pid); const cancel = run("node", [SCRIPT, "cancel", job.id], { cwd: repo }); - assert.equal(cancel.status, 0, cancel.stderr); - assert.match(cancel.stdout, new RegExp(`worker pid ${stranger.pid} left running: identity-mismatch`)); + 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("node", [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("node", [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("node", [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 From 6685f03516a31fb30c8be1133771ffd39068cc8b Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:54:59 +0300 Subject: [PATCH 19/36] fix(state): bound lock identity probes by the acquisition deadline The deadline now starts before the self-probe; every probe gets at most min(500 ms, time left) and is skipped (entry held) under 50 ms, and a failed self-probe is cached per process, so slow ps calls cannot push a lock wait past the SessionEnd budget. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/state.mjs | 44 +++++++++++++++++++++-------- tests/state.test.mjs | 27 ++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 3fc8e9b39..fb079e036 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -245,7 +245,13 @@ function sleepSync(ms) { const LOCK_ENTRY_GONE = "gone"; const LOCK_ENTRY_HELD = "held"; const LOCK_ENTRY_ABANDONED = "abandoned"; -const LOCK_IDENTITY_PROBE_MS = 2000; +// Every identity probe is bounded by this and by what is left of the wait, so +// probes can never push an acquisition past its deadline (the SessionEnd budget). +const LOCK_IDENTITY_PROBE_MS = 500; +const LOCK_IDENTITY_PROBE_MIN_MS = 50; +// This process's own identity, per probe implementation; a failed probe is +// cached too, so a slow `ps` is paid at most once per process. +const selfLockIdentity = new Map(); // Only the entry having disappeared is an answer. Every other stat failure — // EACCES, EIO, ELOOP — says nothing about the owner, and guessing there is how a @@ -294,7 +300,7 @@ function readLockEntryOwner(entryPath) { // check after a long one. (A PID that exists but belongs to another user reads // as alive, which is the safe answer.) A stuck live owner is the operator's call — // the timeout error names it. -function judgeLockEntry(entryPath) { +function judgeLockEntry(entryPath, { deadline = Infinity, getProcessIdentityImpl = getProcessIdentity } = {}) { const { present, owner } = readLockEntryOwner(entryPath); if (!present) { return LOCK_ENTRY_GONE; @@ -306,8 +312,10 @@ function judgeLockEntry(entryPath) { // A live pid that is provably another process is a recycled one (#743). // An identity we cannot read proves nothing and keeps the entry. // ponytail: win32 lock entries stay PID-liveness only (no identity recorded there) - if (typeof owner.identity === "string") { - const actual = getProcessIdentity(owner.pid, { timeoutMs: LOCK_IDENTITY_PROBE_MS }); + // No time left for a probe is the same as a probe that cannot answer. + const remaining = deadline - Date.now(); + if (typeof owner.identity === "string" && remaining >= LOCK_IDENTITY_PROBE_MIN_MS) { + const actual = getProcessIdentityImpl(owner.pid, { timeoutMs: Math.min(LOCK_IDENTITY_PROBE_MS, remaining) }); if (actual && actual !== owner.identity) { return LOCK_ENTRY_ABANDONED; } @@ -450,10 +458,12 @@ function lockTimeoutError(lockDir, blockers, waitMs) { // display, then stop announcing. A later acquirer is therefore always visible as // `choosing` to anyone still deciding, which is what stops it slipping in with a // lower number behind a holder's back. -function acquireTicket(lockDir, waitMs) { +function acquireTicket(lockDir, waitMs, getProcessIdentityImpl = getProcessIdentity) { + // The deadline starts before the self-probe: that probe spends the budget too. + const deadline = Date.now() + waitMs; fs.mkdirSync(lockDir, { recursive: true }); const token = `${process.pid}-${randomBytes(8).toString("hex")}`; - const identity = process.platform === "win32" ? null : getProcessIdentity(process.pid); + const identity = process.platform === "win32" ? null : selfIdentity(getProcessIdentityImpl, waitMs); const owner = `${JSON.stringify({ pid: process.pid, startedAt: nowIso(), identity })}\n`; const choosingName = `${LOCK_CHOOSING_PREFIX}${token}`; @@ -473,7 +483,7 @@ function acquireTicket(lockDir, waitMs) { } try { - waitForTurn(lockDir, ticket, waitMs); + waitForTurn(lockDir, ticket, waitMs, deadline, getProcessIdentityImpl); } catch (error) { // We are not holding the lock, so our ticket must leave the queue — this // process is alive, so nothing would ever judge it abandoned and everyone @@ -485,8 +495,20 @@ function acquireTicket(lockDir, waitMs) { return ticket; } -function waitForTurn(lockDir, ticket, waitMs) { - const deadline = Date.now() + waitMs; +function selfIdentity(getProcessIdentityImpl, waitMs) { + if (selfLockIdentity.has(getProcessIdentityImpl)) { + return selfLockIdentity.get(getProcessIdentityImpl); + } + const timeoutMs = Math.min(LOCK_IDENTITY_PROBE_MS, waitMs); + if (!(timeoutMs >= LOCK_IDENTITY_PROBE_MIN_MS)) { + return null; // no budget for a probe this time; not cached, a later wait may have one + } + const identity = getProcessIdentityImpl(process.pid, { timeoutMs }); + selfLockIdentity.set(getProcessIdentityImpl, identity); + return identity; +} + +function waitForTurn(lockDir, ticket, waitMs, deadline, getProcessIdentityImpl) { let blockers = []; let scanned = false; for (;;) { @@ -515,7 +537,7 @@ function waitForTurn(lockDir, ticket, waitMs) { // nothing but its own files. const verdicts = blockers.map((blocker) => ({ blocker, - verdict: judgeLockEntry(path.join(lockDir, blocker.name)) + verdict: judgeLockEntry(path.join(lockDir, blocker.name), { deadline, getProcessIdentityImpl }) })); let evicted = false; @@ -553,7 +575,7 @@ function withLockDir(lockDir, fn, options = {}) { } } - const ticket = acquireTicket(lockDir, options.waitMs ?? LOCK_WAIT_MS); + const ticket = acquireTicket(lockDir, options.waitMs ?? LOCK_WAIT_MS, options.getProcessIdentityImpl); heldLocks.set(lockDir, { depth: 1, ticket }); try { return fn(); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index eec989336..a9de46646 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -19,6 +19,7 @@ import { resolveStateDir, resolveStateFile, saveState, + STATE_LOCK_TIMEOUT_CODE, upsertJob, withStateLock, writeJobRequestFile @@ -367,6 +368,32 @@ test("a ticket whose recorded identity still matches its live holder is kept", { assert.equal(fs.existsSync(ticket), true, "a live holder's ticket must survive"); }); +// Identity probes spend the acquisition budget, they do not extend it: with +// several live blockers whose identity is slow to read, the wait still ends near +// its deadline instead of after one probe timeout per blocker. +test("slow identity probes stay inside the lock wait budget", { skip: process.platform === "win32" }, () => { + const workspace = makeTempDir(); + saveState(workspace, { jobs: [] }); + const lockDir = lockDirFor(workspace); + for (let index = 1; index <= 4; index += 1) { + seedLockEntry(lockDir, `${index}.${process.pid}-slow${index}.ticket`, process.pid, undefined, `darwin:blocker-${index}|x`); + } + let probes = 0; + const slowIdentity = (_pid, { timeoutMs } = {}) => { + probes += 1; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(300, timeoutMs ?? 300)); + return null; // cannot tell: the blockers stay held + }; + const started = Date.now(); + assert.throws( + () => withStateLock(workspace, () => "stolen", { waitMs: 400, getProcessIdentityImpl: slowIdentity }), + (error) => error.code === STATE_LOCK_TIMEOUT_CODE + ); + const elapsed = Date.now() - started; + assert.ok(probes >= 1, "the injected probe must be used"); + assert.ok(elapsed < 900, `the wait must end near its 400 ms budget, took ${elapsed} ms`); +}); + test("the lock owner record carries this process's identity", { skip: process.platform === "win32" }, () => { const workspace = makeTempDir(); saveState(workspace, { jobs: [] }); From ec3e295b356a24b416f2e6f3afbadefcfe3c5d88 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:55:36 +0300 Subject: [PATCH 20/36] fix(setup): validate the gate effort against the gate model before any write Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 24 +++++++++++++++-------- tests/runtime.test.mjs | 14 +++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index a32efee3d..a537d9fc8 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -329,6 +329,16 @@ async function handleSetup(argv) { const workspaceRoot = resolveCommandWorkspace(options); const actionsTaken = []; + // Validate everything before writing anything: a rejected effort must not + // leave a half-applied gate configuration behind. + const isInherit = (value) => String(value).trim().toLowerCase() === "inherit"; + const modelGiven = options["review-gate-model"] != null; + const effortGiven = options["review-gate-effort"] != null; + const newModel = modelGiven && !isInherit(options["review-gate-model"]) ? normalizeRequestedModel(options["review-gate-model"]) : null; + const effectiveModel = modelGiven ? newModel : (getConfig(workspaceRoot).stopReviewGateModel ?? null); + const newEffort = + effortGiven && !isInherit(options["review-gate-effort"]) ? normalizeReasoningEffort(options["review-gate-effort"], effectiveModel) : null; + if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); actionsTaken.push(`Enabled the stop-time review gate for ${workspaceRoot}.`); @@ -336,15 +346,13 @@ async function handleSetup(argv) { setConfig(workspaceRoot, "stopReviewGate", false); actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); } - if (options["review-gate-model"] != null) { - const value = String(options["review-gate-model"]).trim().toLowerCase() === "inherit" ? null : normalizeRequestedModel(options["review-gate-model"]); - setConfig(workspaceRoot, "stopReviewGateModel", value); - actionsTaken.push(value ? `Stop-time review gate model set to ${value}.` : "Stop-time review gate model now inherits Codex config."); + if (modelGiven) { + setConfig(workspaceRoot, "stopReviewGateModel", newModel); + actionsTaken.push(newModel ? `Stop-time review gate model set to ${newModel}.` : "Stop-time review gate model now inherits Codex config."); } - if (options["review-gate-effort"] != null) { - const value = String(options["review-gate-effort"]).trim().toLowerCase() === "inherit" ? null : normalizeReasoningEffort(options["review-gate-effort"]); - setConfig(workspaceRoot, "stopReviewGateEffort", value); - actionsTaken.push(value ? `Stop-time review gate effort set to ${value}.` : "Stop-time review gate effort now inherits Codex config."); + if (effortGiven) { + setConfig(workspaceRoot, "stopReviewGateEffort", newEffort); + actionsTaken.push(newEffort ? `Stop-time review gate effort set to ${newEffort}.` : "Stop-time review gate effort now inherits Codex config."); } const finalReport = await buildSetupReport(cwd, actionsTaken); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 53fd25aab..8669bf032 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2520,6 +2520,20 @@ test("stop gate forwards the configured model and effort to the review task (#76 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("node", [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("node", [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("stop gate stops blocking after three gate-induced rounds by default (#548)", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From c81c4b58ef7942754e300a1b9da4f91fcf271755 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:56:38 +0300 Subject: [PATCH 21/36] docs: v1.3.0 fix-wave wording (fallback root, cancel, alias resolution, triage) The fallback-root limitation now says state is orphaned on every plugin update when CLAUDE_PLUGIN_DATA is unset; the cancel entry describes the pending-cancellation outcome; rescue docs stop hardcoding the spark slug; triage marks the issues and PRs this release closes as fixed-in v1.3.0. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- README.md | 2 +- .../triage/2026-09-27-upstream-triage.md | 64 ++++++++++--------- plugins/codex/CHANGELOG.md | 4 +- plugins/codex/agents/codex-rescue.md | 3 +- .../codex/skills/codex-cli-runtime/SKILL.md | 5 +- tests/commands.test.mjs | 10 ++- 7 files changed, 49 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861c3a0a4..0887ea29d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - A live but wedged broker is killed before it is replaced (#753, #762, #782), its readiness probe is retried for 2 s before that (#768), and a stale or dead pid recorded from an earlier session is never signalled (#749); a freshly spawned broker that never becomes ready is killed as well. - `/codex:transfer` honours `CLAUDE_CONFIG_DIR` when resolving Claude session transcripts (#721). - The fallback state root is private (0700), per-user and per-plugin, and a symlinked root is refused (#521, #609); `broker.json` is validated before use. -- Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; `cancel` reports `worker pid N left running: ` when it refuses to signal a pid it cannot verify. +- Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; when `cancel` refuses to signal a still-live pid it cannot verify, it reports `cancellation not confirmed: worker pid N left running ()`, exits 1 and leaves the job `running` (turn interrupt still sent). ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). @@ -24,7 +24,7 @@ ### Known limitations - On Windows, kills issued from stored process records (cancel worker, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused until process identity lands in v1.4.0; leaks are bounded by the broker idle timeout, and the turn interrupt is still sent regardless. - Foreground job records written by v1.2.x (no `pidIdentity`) are not killed at `SessionEnd` after upgrading to v1.3.0 (one-off). -- The fallback state root under `os.tmpdir()` is keyed by plugin install path, so it is not carried over from a v1.2.x install. +- When `CLAUDE_PLUGIN_DATA` is unset (inside Claude Code the SessionStart hook normally sets it), the fallback state root under `os.tmpdir()` hashes `CLAUDE_PLUGIN_ROOT`, whose path includes the plugin version: job and broker state is orphaned on every plugin update, not only when upgrading from v1.2.x. - Darwin identity checks use `ps lstart`, which has 1 s resolution. Ported with reference to upstream PRs by ALV0612, Soumya95, kevin9327, mzl9039, sylvesterkaczmarek, mittalpk, SomSamantray, weivwang. diff --git a/README.md b/README.md index 18265a2ec..67d2fccea 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - `--effort` accepts `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. Which of those a given model supports comes from the local Codex model catalogue: when `--model` names a catalogued model, the plugin rejects an effort that model does not list, and otherwise leaves the check to Codex — run `codex debug models` to see the reasoning levels each model advertises. -- model aliases resolve against the local Codex model catalogue (`$CODEX_HOME/models_cache.json`, else `codex debug models --bundled`): an alias picks the listed model whose slug ends in `-`, lowest priority first, newest family on ties; today `sol` -> `gpt-6-sol`, `astra` -> `gpt-6-astra`, `luna` -> `gpt-6-luna`, `terra` -> `gpt-5.6-terra`, `spark` -> `gpt-5.3-codex-spark`, `mini` -> `gpt-5.4-mini`; run `codex debug models` to see yours. An exact model slug passes through unchanged, and when the model is in the catalogue `--effort` is checked against the reasoning levels it lists +- model aliases resolve against the local Codex model catalogue (`$CODEX_HOME/models_cache.json`, else `codex debug models --bundled`): an alias picks the listed model whose slug ends in `-`, lowest priority number first, newest family on ties; today `sol` -> `gpt-6-sol`, `astra` -> `gpt-6-astra`, `luna` -> `gpt-6-luna`, `terra` -> `gpt-5.6-terra`, `spark` -> `gpt-5.3-codex-spark`, `mini` -> `gpt-5.4-mini`; run `codex debug models` to see yours. An exact model slug passes through unchanged, and when the model is in the catalogue `--effort` is checked against the reasoning levels it lists - `--config key=value` (repeatable, also on `/codex:review` and `/codex:adversarial-review`) forwards a `config.toml` override to the Codex thread, e.g. `--config model_provider=ollama`. On `--resume-last` the plugin opens a fresh app-server session (cold resume) so `--config` overrides, sandbox and approval policy take effect; model and effort for the resumed turn are sent on the turn, never on the resume request. In a `--background`/`--await` job record the config **keys** are recorded and the **values** are never stored (they read back as `[redacted]` in `status`/`result`): the real values live only in the job's private 0600 `jobs/.request.json`, which the worker consumes and deletes. - follow-up rescue requests can continue the latest Codex task in the repo - under the hood, `/codex:rescue` and the `codex-rescue` agent are each a single `scripts/codex-companion.mjs task --await --prompt-stdin ` call: `--await [--await-timeout-ms ]` launches the same tracked background job as `--background`, then waits for it (default 540000 ms), and `--prompt-stdin` reads the prompt as stdin verbatim (so it cannot be combined with `--args-stdin`, `--prompt-file`, or prompt text on the command line). Exit code is 0 when the job completed, 1 when it failed or was cancelled, and 3 when the wait times out while the job is still queued or running — exit 3 prints a `Re-run: node "" result --wait --timeout-ms 540000` hint, which is the only follow-up call the rescue flow makes. diff --git a/docs/superpowers/triage/2026-09-27-upstream-triage.md b/docs/superpowers/triage/2026-09-27-upstream-triage.md index 483bd520c..aeaebf9fa 100644 --- a/docs/superpowers/triage/2026-09-27-upstream-triage.md +++ b/docs/superpowers/triage/2026-09-27-upstream-triage.md @@ -70,7 +70,7 @@ | #490 | pr | +199/-3 | Stop orphaned Codex companion brokers | verify | — | | #509 | issue | — | Rescue tasks intermittently hang forever: stale shared broker reused without a health check; headless app-server inherits desktop MCP servers | verify | — | | #518 | pr | +1320/-175 | fix: close detached broker and worker lifecycles | verify | — | -| #521 | issue | — | Predictable os.tmpdir() fallback state dir (0755) + unvalidated broker.json lets a co-located user MITM the Codex IPC and force arbitrary process-kill / file-delete | planned v1.3.0 | — | +| #521 | issue | — | Predictable os.tmpdir() fallback state dir (0755) + unvalidated broker.json lets a co-located user MITM the Codex IPC and force arbitrary process-kill / file-delete | fixed-in v1.3.0 | — | | #526 | issue | — | Prevent clients racing with idle-timeout broker shutdown | verify | — | | #540 | issue | — | Ending any Claude session kills the shared broker mid-turn: concurrent sessions' tasks die silently (exit 0) and stay "running" forever | verify | — | | #541 | pr | +2948/-204 | Fix test broker leaks, state races, and signal-masked command failures | verify | — | @@ -84,7 +84,7 @@ | #623 | pr | +4105/-94 | bug fix: stop session end from tearing down the shared broker under other sessions' jobs | reference-only | see #628 | | #628 | issue | — | Terminal-status repair: residual multi-fault interleavings, turn-identity race window, and broker readiness-probe kills (follow-up to #623) | verify | see #623 | | #629 | issue | — | Test suite leaks ~50 app-server-broker processes per full run | verify | — | -| #631 | issue | — | Companion writes all job state (broker.json, state.json, jobs/) into another plugin's data directory | planned v1.3.0 | — | +| #631 | issue | — | Companion writes all job state (broker.json, state.json, jobs/) into another plugin's data directory | fixed-in v1.3.0 | — | | #632 | issue | — | test: broker-spawn integration tests flake under host load — widen/tune the waitFor budget and reap leaked processes | verify | — | | #636 | issue | — | SessionEnd cannot find the broker when CLAUDE_PLUGIN_DATA differs between spawn and teardown — same cwd, same hash, different state root | planned v1.5.0 | — | | #642 | pr | +109/-3 | Stop brokers started by the test suite | verify | cherry-pick candidate | @@ -101,14 +101,14 @@ | #715 | pr | +289/-23 | fix(setup): fall back when broker auth is busy | verify | — | | #718 | issue | — | Windows: every command leaks an orphaned broker, and a live app-server makes the workspace directory undeletable | planned v1.4.0 | — | | #741 | issue | — | `npm test` leaves a detached broker and a fake app-server behind for every test workspace | verify | — | -| #743 | issue | — | SessionEnd kills whatever pid `broker.json` names, without checking it is still a broker (pid reuse → SIGTERM to an unrelated process group) | planned v1.3.0 | — | -| #749 | pr | +72/-7 | fix(broker): do not signal stale persisted pids | planned v1.3.0 | cherry-pick candidate | -| #753 | issue | — | ensureBrokerSession() deletes a live broker's state without killing it — the only production caller passes no killProcess | planned v1.3.0 | see #762 | -| #762 | pr | +5/-3 | fix: terminate broker process when ensureBrokerSession tears down (fixes #753) | planned v1.3.0 | fixes #753 | +| #743 | issue | — | SessionEnd kills whatever pid `broker.json` names, without checking it is still a broker (pid reuse → SIGTERM to an unrelated process group) | fixed-in v1.3.0 | — | +| #749 | pr | +72/-7 | fix(broker): do not signal stale persisted pids | fixed-in v1.3.0 | cherry-pick candidate | +| #753 | issue | — | ensureBrokerSession() deletes a live broker's state without killing it — the only production caller passes no killProcess | fixed-in v1.3.0 | see #762 | +| #762 | pr | +5/-3 | fix: terminate broker process when ensureBrokerSession tears down (fixes #753) | fixed-in v1.3.0 | fixes #753 | | #767 | issue | — | SessionEnd never reclaims the app-server broker when the Claude session cwd is not a git repository | planned v1.5.0 | — | -| #768 | pr | +169/-1 | fix(broker): stop tearing down a live broker that misses the readiness probe | planned v1.3.0 | — | -| #773 | pr | +83/-8 | fix: bound hung broker connects instead of waiting forever | planned v1.3.0 | cherry-pick candidate | -| #782 | issue | — | Broker processes leak on Windows: ensureBrokerSession tears down stale broker without killing it | planned v1.3.0 | — | +| #768 | pr | +169/-1 | fix(broker): stop tearing down a live broker that misses the readiness probe | fixed-in v1.3.0 | — | +| #773 | pr | +83/-8 | fix: bound hung broker connects instead of waiting forever | fixed-in v1.3.0 | cherry-pick candidate | +| #782 | issue | — | Broker processes leak on Windows: ensureBrokerSession tears down stale broker without killing it | fixed-in v1.3.0 | — | ## 2. CLAUDE_ENV_FILE @@ -258,16 +258,16 @@ Windows-специфика: taskkill, spawn/PATHEXT, PowerShell, EPERM/ENOENT н | #686 | issue | — | `codex-rescue` can generate duplicate `pgrep -f "codex-companion.mjs"` wait loops that keep each other alive on macOS (stuck background tasks) | verify | — | | #689 | pr | +1142/-274 | Fix job records lost on concurrent background task launches | reference-only | reference, huge PR | | #696 | pr | +88/-1 | Keep the inferred-completion timer referenced | verify | cherry-pick candidate | -| #698 | issue | — | `captureTurn` treats the `error` notification as non-terminal, so a Codex-side failure hangs the turn forever and wedges the job at `status: running` | planned v1.3.0 | — | +| #698 | issue | — | `captureTurn` treats the `error` notification as non-terminal, so a Codex-side failure hangs the turn forever and wedges the job at `status: running` | fixed-in v1.3.0 | — | | #700 | issue | — | task: add `--resume-thread ` — jobs killed by a usage limit cannot be resumed from the plugin | planned v1.5.0 | — | | #704 | issue | — | status reports a background job as running forever when its worker dies before writing a terminal status | verify | — | | #740 | issue | — | `thread/resume` sandbox is ignored while the thread is still live in the shared app-server, so `task --resume-last --write` cannot write after a read-only run | verify | — | | #742 | pr | +506/-14 | feat: add `--sandbox ` to `task` and `/codex:rescue` | planned v1.5.0 | — | | #754 | issue | — | codex-rescue reports completion without checking git state — reproducible false positives | verify | — | | #765 | issue | — | codex-rescue with --cwd : git write ops fail because the linked worktree's gitdir (hub .git/worktrees/) is outside the sandbox writable roots | verify | — | -| #774 | pr | +75/-3 | fix: surface status --wait timeouts instead of looking successful | planned v1.3.0 | cherry-pick candidate | -| #775 | pr | +30/-1 | fix: do not crash when a fileChange start event omits changes | planned v1.3.0 | cherry-pick candidate | -| #781 | issue | — | captureTurn drops every notification (including turn/completed) when the start response has no turn.id, hanging the job forever | planned v1.3.0 | — | +| #774 | pr | +75/-3 | fix: surface status --wait timeouts instead of looking successful | fixed-in v1.3.0 | cherry-pick candidate | +| #775 | pr | +30/-1 | fix: do not crash when a fileChange start event omits changes | fixed-in v1.3.0 | cherry-pick candidate | +| #781 | issue | — | captureTurn drops every notification (including turn/completed) when the start response has no turn.id, hanging the job forever | fixed-in v1.3.0 | — | | #786 | issue | — | cancel never signals a foreground companion on Linux/macOS: process-group kill fails with ESRCH and there is no fallback to the pid | verify | — | | #787 | pr | +42/-12 | fix: signal the pid when the process-group kill fails with ESRCH | verify | cherry-pick candidate | @@ -286,11 +286,11 @@ Windows-специфика: taskkill, spawn/PATHEXT, PowerShell, EPERM/ENOENT н | #625 | pr | +49/-5 | Suppress dynamic tool progress in stderr | verify | cherry-pick candidate | | #685 | pr | +786/-102 | fix: make app-server connection loss terminal | verify | — | | #707 | pr | +1364/-89 | fix(app-server): unsubscribe task threads after client disconnect | reference-only | reference, huge PR | -| #710 | pr | +261/-7 | fix(runtime): terminate turns on terminal errors | planned v1.3.0 | — | +| #710 | pr | +261/-7 | fix(runtime): terminate turns on terminal errors | fixed-in v1.3.0 | — | | #744 | issue | — | `runCommand` sets `maxBuffer: options.maxBuffer` — the ENOBUFS fix from #179 works only because a spread `undefined` deletes Node's default | verify | — | | #747 | pr | +27/-2 | fix: make runCommand maxBuffer explicit | verify | cherry-pick candidate | -| #757 | issue | — | A server-side turn failure that terminates stores no `errorMessage`, so `status` reports the reason as `Summary: {` | planned v1.3.0 | see #763 | -| #763 | pr | +105/-4 | fix: persist errorMessage on non-throwing turn failure and shorten summary (fixes #757) | planned v1.3.0 | fixes #757 | +| #757 | issue | — | A server-side turn failure that terminates stores no `errorMessage`, so `status` reports the reason as `Summary: {` | fixed-in v1.3.0 | see #763 | +| #763 | pr | +105/-4 | fix: persist errorMessage on non-throwing turn failure and shorten summary (fixes #757) | fixed-in v1.3.0 | fixes #757 | ## 6. stop-review gate @@ -309,13 +309,13 @@ Stop-хук ревью-гейта: fail-open/fail-closed, таймауты, mono | #422 | pr | +134/-19 | fix(stop-review-gate): fail open on infra errors instead of blocking | verify | — | | #442 | pr | +24/-2 | fix: surface the real task error in the stop-review gate instead of stderr noise | verify | cherry-pick candidate | | #452 | issue | — | Stop-review-gate hook masks the real failure: Node 24 DEP0190 warning displaces the actual error in stderr-first reporting | n-a | DEP0190 Node warning, posix quirk — n/a | -| #483 | issue | — | stop-review-gate-hook.mjs: fail-closed reason strings do not mention the /codex:setup --disable-review-gate escape valve | planned v1.3.0 | — | +| #483 | issue | — | stop-review-gate-hook.mjs: fail-closed reason strings do not mention the /codex:setup --disable-review-gate escape valve | fixed-in v1.3.0 | — | | #517 | issue | — | Jobs killed by host timeouts stay "running" forever (no pid liveness check); concurrent state writers can wipe all job state and silently disable stopReviewGate | verify | — | -| #548 | issue | — | Stop-review gate hook loops until CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (missing `stop_hook_active` guard) | planned v1.3.0 | — | -| #565 | pr | +67/-0 | fix: honor stop_hook_active in the stop-review-gate hook | planned v1.3.0 | cherry-pick candidate | +| #548 | issue | — | Stop-review gate hook loops until CLAUDE_CODE_STOP_HOOK_BLOCK_CAP (missing `stop_hook_active` guard) | fixed-in v1.3.0 | — | +| #565 | pr | +67/-0 | fix: honor stop_hook_active in the stop-review-gate hook | fixed-in v1.3.0 | cherry-pick candidate | | #568 | pr | +337/-15 | Archive completed stop-gate review threads | verify | — | -| #573 | pr | +128/-17 | fix(review-gate): name disable command in stop-hook infra failure messages | planned v1.3.0 | cherry-pick candidate | -| #589 | issue | — | stop-review-gate: signal-terminated review loses signal metadata in the fail-closed reason | planned v1.3.0 | — | +| #573 | pr | +128/-17 | fix(review-gate): name disable command in stop-hook infra failure messages | fixed-in v1.3.0 | cherry-pick candidate | +| #589 | issue | — | stop-review-gate: signal-terminated review loses signal metadata in the fail-closed reason | fixed-in v1.3.0 | — | | #611 | issue | — | Stop-review gate: hung jobs pile up into livelock; review --wait can exit 0 without a verdict | verify | — | | #662 | pr | +2972/-194 | Harden Codex stop gate supervision | reference-only | reference, huge PR | | #676 | issue | — | Stop review gate fails open when hook stdin is malformed JSON | fixed-in 1.1.0 | — | @@ -325,7 +325,7 @@ Stop-хук ревью-гейта: fail-open/fail-closed, таймауты, mono | #709 | pr | +225/-43 | fix(stop-gate): preserve review failure details | verify | — | | #764 | issue | — | Stop-review gate is lost in every new git worktree (state keyed by rev-parse --show-toplevel) | planned v1.5.0 | — | | #766 | issue | — | Stop-review gate: review timeout equals the hook's own 900s timeout, so a slow review ends the turn with no message | verify | — | -| #769 | issue | — | Stop review gate has no way to pin the model or reasoning effort it reviews with | planned v1.3.0 | — | +| #769 | issue | — | Stop review gate has no way to pin the model or reasoning effort it reviews with | fixed-in v1.3.0 | — | | #772 | pr | +23/-2 | fix: leave Stop-hook headroom so a timed-out review gate can report | verify | cherry-pick candidate | | #777 | issue | — | Positional CLI argv >~1KB gets node child SIGKILLed on macOS+EDR — breaks stop-review-gate and codex-rescue forwarding | verify | — | @@ -365,11 +365,11 @@ Stop-хук ревью-гейта: fail-open/fail-closed, таймауты, mono | #393 | issue | — | codex-companion task path: missing-cwd misread as 'not installed', prompt fragments parsed as --model (400 as result), dropped turn errors | verify | — | | #408 | pr | +20/-11 | fix(app-server): pass `-c model="..."` to `codex app-server` so options.model takes effect | fixed-in 1.1.0 | — | | #463 | issue | — | Make gpt-5-4-prompting skill model-neutral and multi-agent aware | planned v1.3.0 | — | -| #468 | issue | — | Current Plugin does not support gpt-5.6 model family | planned v1.3.0 | — | +| #468 | issue | — | Current Plugin does not support gpt-5.6 model family | fixed-in v1.3.0 | — | | #471 | pr | +1302/-161 | Support GPT-5.6 models and refresh stale brokers | verify | — | | #476 | issue | — | review / adversarial-review silently ignore reasoning effort — --effort unparsed, and turn/start effort omitted on the adversarial path | fixed-in 1.1.0 | — | | #481 | issue | — | Job records never capture the resolved model/effort/sandbox a job ran with | planned v1.5.0 | — | -| #485 | issue | — | codex-rescue agent references stale gpt-5-4-prompting skill; effort hint omits max/ultra (default model is now gpt-5.6-sol) | planned v1.3.0 | — | +| #485 | issue | — | codex-rescue agent references stale gpt-5-4-prompting skill; effort hint omits max/ultra (default model is now gpt-5.6-sol) | fixed-in v1.3.0 | — | | #496 | issue | — | adversarial-review / review can complete the turn without a schema-conforming final message on multi-tool-call reviews at high reasoning effort | verify | — | | #512 | issue | — | `task` prompts passed as a single argument are re-tokenized: quotes/backslashes stripped, prose `--model`/`--write` hijacked as real options | planned v1.5.0 | — | | #522 | issue | — | /codex:review rejects focus text — breaks interface parity with /codex:adversarial-review and blocks non-English model/effort entry | planned v1.5.0 | — | @@ -387,7 +387,7 @@ Stop-хук ревью-гейта: fail-open/fail-closed, таймауты, mono | #688 | pr | +48/-1 | fix: resolve model aliases on review and adversarial-review | fixed-in 1.1.0 | — | | #699 | issue | — | Background jobs: prompt text swallowed as options (-m pytest -> model 404); dead workers never finalized; cancel hangs; stderr discarded | planned v1.5.0 | see #702 | | #702 | pr | +104/-36 | fix(task): stop free-form prompt text from hijacking --model (defect 1 of #699) | planned v1.5.0 | fixes #699 | -| #703 | issue | — | Skill `gpt-5-4-prompting` still targets GPT-5.4, retired from the rate card on 2026-08-31 | planned v1.3.0 | — | +| #703 | issue | — | Skill `gpt-5-4-prompting` still targets GPT-5.4, retired from the rate card on 2026-08-31 | fixed-in v1.3.0 | — | | #705 | issue | — | adversarial-review threads are always ephemeral — no way to verify which model actually ran a review | planned v1.5.0 | — | | #746 | pr | +86/-4 | Support --effort on adversarial-review, and surface unrecognised options | verify | cherry-pick candidate | | #751 | issue | — | `VALID_REASONING_EFFORTS` rejects `max` and `ultra` locally, so the flagship model's top two reasoning tiers are unreachable from the plugin | verify | see #761 | @@ -466,7 +466,7 @@ Stop-хук ревью-гейта: fail-open/fail-closed, таймауты, mono | #576 | pr | +632/-35 | feat: add automatic user-approved expert handoff | verify | — | | #600 | issue | — | /codex:status, /codex:transfer, /codex:cancel, /codex:result fail the Bash permission check — inline `!`…`` body is unmatchable | verify | — | | #624 | pr | +157/-6 | Fix transfer resolution after Claude session forks | planned v1.5.0 | — | -| #721 | issue | — | /codex:transfer is broken when CLAUDE_CONFIG_DIR is set — Claude transcript root hardcoded to ~/.claude/projects | planned v1.3.0 | — | +| #721 | issue | — | /codex:transfer is broken when CLAUDE_CONFIG_DIR is set — Claude transcript root hardcoded to ~/.claude/projects | fixed-in v1.3.0 | — | | #750 | issue | — | `/codex:transfer` is one-shot per session: a second run silently imports nothing, and is indistinguishable from failure | planned v1.5.0 | — | ## 11. sandbox/config @@ -525,8 +525,8 @@ MCP elicitation/approval и clientInfo-обвязка app-server. | #290 | pr | +99/-11 | Use `--end-of-options` before user-controlled refs in git invocations | verify | cherry-pick candidate | | #326 | pr | +21/-0 | Create SECURITY.md for security policy | planned v1.3.0 | cherry-pick candidate | | #382 | issue | — | Concurrent Claude Code sessions race on shared ~/.codex — app-server spawned without an isolated CODEX_HOME | verify | — | -| #609 | issue | — | Plugin state dir has no plugin-identity segment: sibling plugins share one jobs array, and pruneJobs deletes the other plugin's records | planned v1.3.0 | — | -| #683 | pr | +132/-13 | fix: isolate companion state from sibling plugins | planned v1.3.0 | cherry-pick candidate | +| #609 | issue | — | Plugin state dir has no plugin-identity segment: sibling plugins share one jobs array, and pruneJobs deletes the other plugin's records | fixed-in v1.3.0 | — | +| #683 | pr | +132/-13 | fix: isolate companion state from sibling plugins | fixed-in v1.3.0 | cherry-pick candidate | ## 14. hooks stdin/EAGAIN & misc hooks @@ -550,7 +550,7 @@ EAGAIN/stdin в хуках, CLAUDE_PLUGIN_ROOT/DATA, прочие SessionStart/S | #397 | issue | — | /codex:rescue subagent silently fails to delegate — ${CLAUDE_PLUGIN_ROOT} is empty in subagent Bash | verify | — | | #448 | issue | — | Plugin hooks fail when CLAUDE_PLUGIN_ROOT is missing on macOS | verify | — | | #449 | pr | +143/-3 | fix: tolerate missing CLAUDE_PLUGIN_ROOT in hooks | verify | cherry-pick candidate | -| #459 | issue | — | Remove unsupported top-level description from hooks.json | planned v1.3.0 | — | +| #459 | issue | — | Remove unsupported top-level description from hooks.json | fixed-in v1.3.0 | — | | #474 | issue | — | Increase `SessionEnd` hook timeout to prevent premature cancellation | verify | — | | #491 | pr | +755/-66 | Prevent SessionEnd from killing shared Codex tasks | verify | — | | #562 | issue | — | SessionStart hook leaks per-plugin CLAUDE_PLUGIN_DATA into the shared session env file | fixed-in 1.1.0 | — | @@ -809,9 +809,13 @@ Issue (не PR) со статусом `fixed-in`/`planned`, сгруппиров #458, #498, #524 +### fixed-in 1.3.0 + +#459, #468, #483, #485, #521, #548, #589, #609, #631, #698, #703, #721, #743, #753, #757, #769, #781, #782 + ### planned v1.3.0 -#459, #463, #468, #483, #485, #521, #548, #589, #609, #631, #698, #703, #721, #743, #753, #757, #769, #781, #782 +#463 ### planned v1.4.0 diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 861c3a0a4..0887ea29d 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -8,7 +8,7 @@ - A live but wedged broker is killed before it is replaced (#753, #762, #782), its readiness probe is retried for 2 s before that (#768), and a stale or dead pid recorded from an earlier session is never signalled (#749); a freshly spawned broker that never becomes ready is killed as well. - `/codex:transfer` honours `CLAUDE_CONFIG_DIR` when resolving Claude session transcripts (#721). - The fallback state root is private (0700), per-user and per-plugin, and a symlinked root is refused (#521, #609); `broker.json` is validated before use. -- Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; `cancel` reports `worker pid N left running: ` when it refuses to signal a pid it cannot verify. +- Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; when `cancel` refuses to signal a still-live pid it cannot verify, it reports `cancellation not confirmed: worker pid N left running ()`, exits 1 and leaves the job `running` (turn interrupt still sent). ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). @@ -24,7 +24,7 @@ ### Known limitations - On Windows, kills issued from stored process records (cancel worker, `SessionEnd` cleanup, stale-broker replacement, broker teardown) are refused until process identity lands in v1.4.0; leaks are bounded by the broker idle timeout, and the turn interrupt is still sent regardless. - Foreground job records written by v1.2.x (no `pidIdentity`) are not killed at `SessionEnd` after upgrading to v1.3.0 (one-off). -- The fallback state root under `os.tmpdir()` is keyed by plugin install path, so it is not carried over from a v1.2.x install. +- When `CLAUDE_PLUGIN_DATA` is unset (inside Claude Code the SessionStart hook normally sets it), the fallback state root under `os.tmpdir()` hashes `CLAUDE_PLUGIN_ROOT`, whose path includes the plugin version: job and broker state is orphaned on every plugin update, not only when upgrading from v1.2.x. - Darwin identity checks use `ps lstart`, which has 1 s resolution. Ported with reference to upstream PRs by ALV0612, Soumya95, kevin9327, mzl9039, sylvesterkaczmarek, mittalpk, SomSamantray, weivwang. diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 800346db0..6ada701a1 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -36,8 +36,7 @@ CODEX_PROMPT_ - Do not call `review`, `adversarial-review`, or `cancel`. This subagent only forwards to `task` and, on exit 3, re-runs its own job's printed `result --wait` hint. - Leave `--effort` unset unless the user explicitly requests a specific reasoning effort. - Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. -- If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. -- If the user asks for `astra`, `sol`, `luna`, `terra` or `mini`, pass the alias through with `--model`; the companion resolves it against the local Codex model catalogue (e.g. `sol` becomes the newest listed `*-sol` model). +- If the user asks for a model alias (`spark`, `astra`, `sol`, `luna`, `terra` or `mini`), pass it through unchanged with `--model `; the companion resolves each against the local Codex model catalogue (primary sort by `priority`, newest family on ties), so do not map it yourself. - If the user asks for a concrete model name such as `gpt-6-astra`, pass it through with `--model`. - Treat `--effort `, `--model `, and `--config key=value` as runtime controls and do not include them in the task text you pass through. - Never add `--write` unless the user explicitly asked Codex to modify files. diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 71588f998..55cb19966 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -24,8 +24,7 @@ Execution rules: - That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text. - Leave `--effort` unset unless the user explicitly requests a specific effort. - Leave model unset by default. Add `--model` only when the user explicitly asks for one. -- Map `spark` to `--model gpt-5.3-codex-spark`. -- Pass the aliases `astra`, `sol`, `luna`, `terra` and `mini` through as `--model ` unchanged: the companion resolves each against the local Codex model catalogue (e.g. `sol` becomes the newest listed `*-sol` model). Pass a concrete slug through as-is. +- Pass the aliases `spark`, `astra`, `sol`, `luna`, `terra` and `mini` through as `--model ` unchanged: the companion resolves each against the local Codex model catalogue (primary sort by `priority`, newest family on ties), so do not map it yourself. Pass a concrete slug through as-is. - Never add `--write` unless the user explicitly asked Codex to modify files. Command selection: @@ -34,7 +33,7 @@ Command selection: - The detached worker outlives the companion only when the companion returns on its own (exit 3); a host process-tree kill — e.g. Claude Code's Bash timeout — also kills the worker, so keep `--await-timeout-ms` below the host limit (default 540000 < 600000). - There is no shell state between calls — the retry is the literal `Re-run:` hint text printed by the previous call, not a `$JOB` shell variable. If the retry itself is cut off by the Bash tool's own 10-minute timeout, re-issue the same literal id again; the job keeps running server-side. - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. -- If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. +- If the forwarded request includes `--model`, pass it through to `task` unchanged; aliases resolve in the companion. - If the forwarded request includes `--effort`, pass it through to `task`. - If the forwarded request includes `--config key=value`, pass every occurrence through to `task` unchanged. - If the forwarded request includes `--turn-timeout-ms `, pass it through to `task` unchanged; it bounds a single Codex turn (also settable via `CODEX_TURN_TIMEOUT_MS`) and is carried into the background worker with the job, so it applies whether the request resolves synchronously or through the exit-3 retry. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 5e132ae4a..2ae037777 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -130,7 +130,9 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Do not call `review`, `adversarial-review`, or `cancel`/i); assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i); assert.match(agent, /Leave model unset by default/i); - assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); + assert.match(agent, /If the user asks for a model alias \(`spark`, `astra`, `sol`, `luna`, `terra` or `mini`\), pass it through unchanged with `--model `/i); + assert.match(agent, /primary sort by `priority`, newest family on ties/i); + assert.doesNotMatch(agent, /gpt-5\.3-codex-spark/); assert.match(agent, /If the user asks for a concrete model name such as `gpt-6-astra`, pass it through with `--model`/i); assert.match(agent, /Return the `result` stdout exactly as-is/i); assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return the command's exit status and stderr verbatim/i); @@ -144,7 +146,9 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /That prompt drafting is the only Claude-side work allowed/i); assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i); assert.match(runtimeSkill, /Leave model unset by default/i); - assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i); + assert.match(runtimeSkill, /Pass the aliases `spark`, `astra`, `sol`, `luna`, `terra` and `mini` through as `--model ` unchanged/i); + assert.match(runtimeSkill, /primary sort by `priority`, newest family on ties/i); + assert.doesNotMatch(runtimeSkill, /gpt-5\.3-codex-spark/); assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`/i); @@ -187,7 +191,7 @@ test("rescue runs synchronously through the companion and uses Agent only for -- assert.doesNotMatch(agent, /own `status`/); assert.match(agent, /--config/); assert.doesNotMatch(runtimeSkill, /return nothing/i); - assert.match(runtimeSkill, /the newest listed `\*-sol` model/i); + assert.match(runtimeSkill, /resolves each against the local Codex model catalogue/i); assert.match(runtimeSkill, /\$agent-compat:skill-router/); assert.doesNotMatch(agent, /adding `--write` unless/i); assert.doesNotMatch(runtimeSkill, /adding `--write` unless/i); From 14378edad045da13b60bc2d1b3b63746d33909da Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:00:50 +0300 Subject: [PATCH 22/36] test(cancel): give the queued-window worker a provable identity The test relied on cancel recording cancelled after a refused kill, which is the behaviour 41ca814 removed. Co-Authored-By: Claude Fable 5.1 --- tests/runtime.test.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8669bf032..ed920bf47 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3788,8 +3788,10 @@ test("task rejects a non-positive turn budget", () => { // 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. -test("cancel removes the private request payload of a job killed in the queued window", async (t) => { +// never look at it again — cancel has to release the file itself. The worker's +// identity is recorded, so the kill is provable (win32 has no identity yet, and +// there a live unprovable worker keeps the job running instead). +test("cancel removes the private request payload of a job killed in the queued window", { skip: process.platform === "win32" }, async (t) => { const repo = seededRepo(); const stateDir = resolveStateDir(repo); const jobsDir = path.join(stateDir, "jobs"); @@ -3824,6 +3826,7 @@ test("cancel removes the private request payload of a job killed in the queued w title: "Codex Task", background: true, pid: sleeper.pid, + pidIdentity: getProcessIdentity(sleeper.pid), logFile: null, requestFile, request: { prompt: "hi", config: { auth_header: "[redacted]" } }, From faa5cfb9731c95e1fe9006caf5b1badcee8ff15c Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:20:04 +0300 Subject: [PATCH 23/36] fix(cancel): only acknowledge a delivered kill and keep the cancelled record A group kill raises ESRCH for a live pid that leads no process group; fall back to the pid itself before reporting not delivered. Cancel treats an undelivered kill of a live worker as pending, and a worker that outlives an acknowledged cancel no longer overwrites the cancelled record (checked under the state lock). Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 13 +- plugins/codex/scripts/lib/process.mjs | 22 ++-- plugins/codex/scripts/lib/tracked-jobs.mjs | 132 ++++++++++++--------- tests/process.test.mjs | 31 +++++ tests/runtime.test.mjs | 50 +++++++- 5 files changed, 172 insertions(+), 76 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index a537d9fc8..799ebd989 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -1330,15 +1330,16 @@ async function handleCancel(argv) { // Only a pid that is provably still this job's worker is signalled (#743). const { pid, identity } = resolveJobPid(workspaceRoot, job); const kill = terminateRecordedProcess(pid, { identity, commandLineMatch: workerCommandLine(job.id) }); - // A worker we may not signal but that is still alive would overwrite a - // `cancelled` record with its own result: the job stays running, and the - // sidecar stays so a later cancel or the reaper can still find it. - if (pid && !kill.attempted && isPidAlive(pid) === true) { - const pending = `cancellation not confirmed: worker pid ${pid} left running (${kill.reason})`; + // A worker we may not signal, or whose signal reached nothing, but that is + // still alive is not cancelled: the job stays running, and the sidecar stays + // so a later cancel or the reaper can still find it. + if (pid && (!kill.attempted || !kill.delivered) && isPidAlive(pid) === true) { + const reason = kill.attempted ? "not-delivered" : kill.reason; + const pending = `cancellation not confirmed: worker pid ${pid} left running (${reason})`; appendLogLine(job.logFile, pending); process.exitCode = 1; outputCommandResult( - { jobId: job.id, status: "running", cancellationPending: true, reason: kill.reason }, + { jobId: job.id, status: "running", cancellationPending: true, reason }, `${pending}\nThe turn interrupt was sent; the job stays running until the worker exits. Re-run cancel or wait for result.\n`, options.json ); diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index a9799d689..23ba32f72 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -242,20 +242,18 @@ export function terminateProcessTree(pid, options = {}) { try { killImpl(-pid, "SIGTERM"); return { attempted: true, delivered: true, method: "process-group" }; - } catch (error) { - if (error?.code !== "ESRCH") { - try { - killImpl(pid, "SIGTERM"); - return { attempted: true, delivered: true, method: "process" }; - } catch (innerError) { - if (innerError?.code === "ESRCH") { - return { attempted: true, delivered: false, method: "process" }; - } - throw innerError; + } catch { + // ESRCH here only means `pid` leads no process group (a foreground worker, + // a child spawned without `detached`) — the process itself may be alive. + try { + killImpl(pid, "SIGTERM"); + return { attempted: true, delivered: true, method: "process" }; + } catch (innerError) { + if (innerError?.code === "ESRCH") { + return { attempted: true, delivered: false, method: "process" }; } + throw innerError; } - - return { attempted: true, delivered: false, method: "process-group" }; } } diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 5f8179b43..2149839b2 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -160,6 +160,19 @@ function readStoredJobOrNull(workspaceRoot, jobId) { 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") { + appendLogLine(logFile, "Worker finished after the job was cancelled; the cancelled record is kept."); + return; + } + write(); + }); +} + export async function runTrackedJob(job, runner, options = {}) { const runningRecord = { ...job, @@ -180,68 +193,73 @@ export async function runTrackedJob(job, runner, options = {}) { // A run that fails without throwing (a timed-out or interrupted turn) still // has to say why: `status`/`result` read the reason off the record. const errorMessage = completionStatus === "failed" ? execution.errorMessage ?? null : null; - writeJobFile(job.workspaceRoot, job.id, { - ...runningRecord, - status: completionStatus, - errorMessage, - threadId: execution.threadId ?? null, - turnId: execution.turnId ?? null, - resolved: execution.resolved ?? null, - pid: null, - pidIdentity: null, - phase: completionStatus === "completed" ? "done" : "failed", - completedAt, - result: execution.payload, - rendered: execution.rendered - }); - upsertJob(job.workspaceRoot, { - id: job.id, - status: completionStatus, - errorMessage, - threadId: execution.threadId ?? null, - turnId: execution.turnId ?? null, - resolved: execution.resolved ?? null, - summary: execution.summary, - phase: completionStatus === "completed" ? "done" : "failed", - pid: null, - pidIdentity: null, - completedAt + const logFile = options.logFile ?? job.logFile ?? null; + writeTerminalUnlessCancelled(job.workspaceRoot, job.id, logFile, () => { + writeJobFile(job.workspaceRoot, job.id, { + ...runningRecord, + status: completionStatus, + errorMessage, + threadId: execution.threadId ?? null, + turnId: execution.turnId ?? null, + resolved: execution.resolved ?? null, + pid: null, + pidIdentity: null, + phase: completionStatus === "completed" ? "done" : "failed", + completedAt, + result: execution.payload, + rendered: execution.rendered + }); + upsertJob(job.workspaceRoot, { + id: job.id, + status: completionStatus, + errorMessage, + threadId: execution.threadId ?? null, + turnId: execution.turnId ?? null, + resolved: execution.resolved ?? null, + summary: execution.summary, + phase: completionStatus === "completed" ? "done" : "failed", + pid: null, + pidIdentity: null, + completedAt + }); + removeJobPidFile(job.workspaceRoot, job.id); + // Nothing revisits a terminal job, so this is the last chance to release a + // payload the worker never consumed (a crash before the read, or one staged + // by the legacy-record migration). + removeJobRequestFile(job.workspaceRoot, job.id); }); - removeJobPidFile(job.workspaceRoot, job.id); - // Nothing revisits a terminal job, so this is the last chance to release a - // payload the worker never consumed (a crash before the read, or one staged - // by the legacy-record migration). - removeJobRequestFile(job.workspaceRoot, job.id); - appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); + appendLogBlock(logFile, "Final output", execution.rendered); return execution; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; - const completedAt = nowIso(); - writeJobFile(job.workspaceRoot, job.id, { - ...existing, - status: "failed", - phase: "failed", - errorMessage, - pid: null, - pidIdentity: null, - completedAt, - logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null - }); - upsertJob(job.workspaceRoot, { - id: job.id, - status: "failed", - phase: "failed", - pid: null, - pidIdentity: null, - errorMessage, - completedAt + writeTerminalUnlessCancelled(job.workspaceRoot, job.id, options.logFile ?? job.logFile ?? null, () => { + const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord; + const completedAt = nowIso(); + writeJobFile(job.workspaceRoot, job.id, { + ...existing, + status: "failed", + phase: "failed", + errorMessage, + pid: null, + pidIdentity: null, + completedAt, + logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null + }); + upsertJob(job.workspaceRoot, { + id: job.id, + status: "failed", + phase: "failed", + pid: null, + pidIdentity: null, + errorMessage, + completedAt + }); + removeJobPidFile(job.workspaceRoot, job.id); + // Nothing revisits a terminal job, so this is the last chance to release a + // payload the worker never consumed (a crash before the read, or one staged + // by the legacy-record migration). + removeJobRequestFile(job.workspaceRoot, job.id); }); - removeJobPidFile(job.workspaceRoot, job.id); - // Nothing revisits a terminal job, so this is the last chance to release a - // payload the worker never consumed (a crash before the read, or one staged - // by the legacy-record migration). - removeJobRequestFile(job.workspaceRoot, job.id); throw error; } } diff --git a/tests/process.test.mjs b/tests/process.test.mjs index dc26b553a..362f92355 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -63,6 +63,37 @@ test("terminateProcessTree treats missing Windows processes as already stopped", assert.match(outcome.result.stdout, /not found/i); }); +test("terminateProcessTree falls back to the pid when it is not a process-group leader", () => { + const calls = []; + const outcome = terminateProcessTree(4242, { + platform: "linux", + killImpl(pid, signal) { + calls.push([pid, signal]); + if (pid < 0) { + throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); + } + } + }); + + assert.deepEqual(calls, [[-4242, "SIGTERM"], [4242, "SIGTERM"]]); + assert.equal(outcome.attempted, true); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "process"); +}); + +test("terminateProcessTree reports not delivered only when the pid itself is gone", () => { + const outcome = terminateProcessTree(4242, { + platform: "linux", + killImpl() { + throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); + } + }); + + assert.equal(outcome.attempted, true); + assert.equal(outcome.delivered, false); + assert.equal(outcome.method, "process"); +}); + test("processCommandLine reads the command line of a live process", { skip: process.platform === "win32" }, () => { const line = processCommandLine(process.pid); assert.ok(line, "expected a command line for the current process"); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index ed920bf47..d415fce5d 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -3,7 +3,7 @@ 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 { fileURLToPath, pathToFileURL } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; @@ -3632,6 +3632,54 @@ test("cancelling an awaited job ends the await with exit 1 and leaves a readable assert.equal(JSON.parse(stored.stdout).job.status, "cancelled"); }); +// A worker that outlives the SIGTERM (it only stops once its turn winds down) +// used to overwrite the acknowledged `cancelled` record with its own result. +test("an acknowledged cancellation survives a worker that finishes after it", { skip: process.platform === "win32" }, async () => { + const repo = seededRepo(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + // 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: "3000", + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import ${pathToFileURL(preload).href}`.trim() + }); + + const launch = run("node", [SCRIPT, "task", "--background", "--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 stateFile = path.join(resolveStateDir(repo), "state.json"); + const workerPid = await waitFor(() => { + if (!fs.existsSync(stateFile)) { + return null; + } + const job = JSON.parse(fs.readFileSync(stateFile, "utf8")).jobs?.find((entry) => entry.id === jobId); + return job && job.status === "running" && job.pid ? job.pid : null; + }, { timeoutMs: 15000 }); + + const cancelled = run("node", [SCRIPT, "cancel", jobId, "--json"], { cwd: repo, env }); + assert.equal(cancelled.status, 0, cancelled.stderr); + assert.equal(JSON.parse(cancelled.stdout).status, "cancelled"); + + const isAlive = () => { + try { + process.kill(workerPid, 0); + return true; + } catch { + return false; + } + }; + await waitFor(() => !isAlive(), { timeoutMs: 20000 }); + + const stored = run("node", [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"); +}); + // 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", () => { From fe8edf791268d69ea85bf9bcd101c33cc9e38c75 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:21:31 +0300 Subject: [PATCH 24/36] fix(broker): kill a fresh unready broker as a process group A broker stuck in connect has no cleanup handlers yet, so signalling only the broker left its app-server child behind. The live, unreaped child is a detached group leader, so its group is killed; the handle is the fallback. An exited child is still never signalled. Co-Authored-By: Claude Fable 5.1 --- .../codex/scripts/lib/broker-lifecycle.mjs | 29 ++++++++----------- tests/broker-stale-pid.test.mjs | 25 ++++++++++++---- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index e05e3005e..52cd60947 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -276,27 +276,22 @@ export async function ensureBrokerSession(cwd, options = {}) { const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000); if (!ready) { // A child that already exited is not signalled at all: its pid may belong to - // someone else by now. A live one is killed through its handle, which cannot - // reach a recycled pid; only if that fails does the numeric (process-group) - // kill run, and then only after identity or command-line proof. - let fallback = false; + // someone else by now. A live, unreaped one is a detached group leader whose + // pid/pgid cannot be reused while our handle has not seen it exit, so its + // whole group is killed — a broker stuck in connect has no cleanup handlers + // yet and would leave its app-server child behind. The handle is the fallback. if (child.exitCode === null && child.signalCode === null) { + let delivered = false; try { - fallback = !child.kill("SIGTERM"); - } catch { - fallback = true; + delivered = killProcess(child.pid)?.delivered !== false; + } catch {} + if (!delivered) { + try { + child.kill("SIGTERM"); + } catch {} } } - teardownBrokerSession({ - endpoint, - pidFile, - logFile, - sessionDir, - pid: fallback ? (child.pid ?? null) : null, - pidIdentity, - killProcess: fallback ? killProcess : null, - ownsProcess: ownsProcessImpl - }); + teardownBrokerSession({ endpoint, pidFile, logFile, sessionDir }); return null; } diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 394ac2c36..09e7ef38e 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1108,25 +1108,40 @@ test("ensureBrokerSession kills a fresh broker that never becomes ready", async const binDir = makeTempDir(); installFakeCodex(binDir); const workspace = makeTempDir(); - const scriptPath = path.join(makeTempDir(), "never-listens.mjs"); - fs.writeFileSync(scriptPath, "setInterval(() => {}, 1000);\n"); + const scriptDir = makeTempDir(); + const scriptPath = path.join(scriptDir, "never-listens.mjs"); + const descendantPidFile = path.join(scriptDir, "descendant.pid"); + // Like a broker stuck in connect: it has an app-server child and no cleanup + // handlers yet, so only a process-group kill takes the descendant down. + fs.writeFileSync( + scriptPath, + `import { spawn } from "node:child_process";\n` + + `import fs from "node:fs";\n` + + `const d = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });\n` + + `fs.writeFileSync(${JSON.stringify(descendantPidFile)}, String(d.pid));\n` + + `setInterval(() => {}, 1000);\n` + ); const killed = []; const spawned = []; + let descendant = null; try { - const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), scriptPath, timeoutMs: 300, killProcess: recordingKill(killed), + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir), scriptPath, timeoutMs: 500, killProcess: recordingKill(killed), // An identity that cannot be read (win32) must not keep the child alive. getProcessIdentityImpl: (pid) => (spawned.push(pid), null) }); assert.equal(session, null); assert.equal(spawned.length, 1); + assert.deepEqual(killed, [spawned[0]], "the live fresh child is killed as a process group"); assert.equal(loadBrokerSession(workspace), null); + descendant = Number(fs.readFileSync(descendantPidFile, "utf8")); const deadline = Date.now() + 5000; - while (isAlive(spawned[0]) && Date.now() < deadline) { + while ((isAlive(spawned[0]) || isAlive(descendant)) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)); } assert.equal(isAlive(spawned[0]), false, "the fresh child must be gone"); + assert.equal(isAlive(descendant), false, "its app-server descendant must be gone too"); } finally { - for (const pid of spawned) { try { process.kill(pid, "SIGKILL"); } catch {} } + for (const pid of [...spawned, descendant].filter(Boolean)) { try { process.kill(pid, "SIGKILL"); } catch {} } } }); From 85fbc6aceec60881d01f2ab89791ae6f1acf5c6d Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:27:11 +0300 Subject: [PATCH 25/36] fix(reaper): fail a legacy job whose pid now runs an unrelated process A running record without an identity whose pid was recycled by a long-lived unrelated process was never reaped, so a refused cancel stayed pending and the thread stayed in use. A readable command line without codex-companion.mjs now marks the job dead; nothing is signalled, and an unreadable one proves nothing. Test fixtures standing in for live workers now carry a companion command line. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/tracked-jobs.mjs | 24 ++++++++++++-- tests/broker-stale-pid.test.mjs | 2 +- tests/runtime.test.mjs | 6 ++-- tests/tracked-jobs.test.mjs | 37 ++++++++++++++++++++-- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 2149839b2..6db5718b5 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 { getProcessIdentity, isPidAlive } from "./process.mjs"; +import { getProcessIdentity, isPidAlive, processCommandLine } from "./process.mjs"; import { readJobFile, @@ -371,14 +371,20 @@ const REAP_MIN_STEP_MS = 100; const IDENTITY_PROBE_MS = 2000; /** - * @param {{ lockWaitMs?: number, remainingMs?: () => number, getProcessIdentityImpl?: typeof getProcessIdentity }} [options] Bounds the + * @param {{ lockWaitMs?: number, remainingMs?: () => number, getProcessIdentityImpl?: typeof getProcessIdentity, processCommandLineImpl?: typeof processCommandLine, platform?: string }} [options] Bounds the * reaper's own state-lock waits. Each dead job costs one acquisition, so a caller * working to a deadline passes `remainingMs` and every wait is clamped to what is * left of it; once that is spent the remaining jobs are left for the next run * rather than reaped past the caller's budget. */ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { - const { lockWaitMs, remainingMs, getProcessIdentityImpl = getProcessIdentity } = options; + const { + lockWaitMs, + remainingMs, + getProcessIdentityImpl = getProcessIdentity, + processCommandLineImpl = processCommandLine, + platform = process.platform + } = options; const waitFor = () => { if (!remainingMs) { return lockWaitMs; @@ -421,6 +427,18 @@ export function reapDeadJobs(workspaceRoot, jobs, options = {}) { if (actual && actual !== identity) { return markJobDead(workspaceRoot, job, `${DEAD_WORKER_MESSAGE} (pid reused: ${pid} now belongs to another process)`, waitFor()); } + } else if (pid && platform !== "win32") { + // A legacy record has no identity; a readable command line that is plainly + // not a companion is proof enough to stop waiting on it. Nothing is signalled. + let commandLine = null; + try { + commandLine = processCommandLineImpl(pid, { timeoutMs: remainingMs ? Math.min(IDENTITY_PROBE_MS, remainingMs()) : IDENTITY_PROBE_MS }); + } catch { + commandLine = null; + } + if (typeof commandLine === "string" && commandLine && !commandLine.includes("codex-companion.mjs")) { + return markJobDead(workspaceRoot, job, `${DEAD_WORKER_MESSAGE} (worker pid ${pid} now belongs to an unrelated process)`, waitFor()); + } } return job; }); diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 09e7ef38e..7757e8c9c 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -247,7 +247,7 @@ test("session end keeps the broker while another session's foreground job is run const child = spawnOwnedBroker(workspace, { binDir, sessionDir, endpoint }); // Stands in for the other session's live foreground worker. - const foreignWorker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + const foreignWorker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "codex-companion.mjs", "task"], { cwd: workspace, detached: true, stdio: "ignore" diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index d415fce5d..5b8a72e01 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1822,7 +1822,7 @@ test("cancel stops an active background job and marks it cancelled", async (t) = // 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)", "task-worker", "--job-id", "task-live"], { + const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "codex-companion.mjs", "task-worker", "--job-id", "task-live"], { cwd: workspace, detached: true, stdio: "ignore" @@ -1919,7 +1919,9 @@ test("cancel stops an active background job and marks it cancelled", async (t) = 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); - const stranger = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" }); + // 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 {} diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index 3c228a67b..3e1c01ddb 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -23,6 +23,8 @@ import { writeJobRequestFile } from "../plugins/codex/scripts/lib/state.mjs"; +// What a legacy (identity-less) record's live worker looks like to `ps`. +const LIVE_WORKER_COMMAND_LINE = "node /plugin/scripts/codex-companion.mjs task-worker --job-id job-live"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const TRACKED_JOBS_URL = pathToFileURL(path.join(ROOT, "plugins", "codex", "scripts", "lib", "tracked-jobs.mjs")).href; @@ -70,7 +72,7 @@ test("reapDeadJobs leaves a running job with a live pid untouched", () => { const workspace = makeTempDir(); seedJob(workspace, { id: "job-live", status: "running", phase: "delegating", pid: process.pid, logFile: null }); - const reaped = reapDeadJobs(workspace, listJobs(workspace)); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { processCommandLineImpl: () => LIVE_WORKER_COMMAND_LINE }); assert.equal(reaped[0].status, "running"); assert.equal(readJobFile(resolveJobFile(workspace, "job-live")).status, "running"); @@ -224,7 +226,7 @@ test("reapDeadJobs never touches a live worker or its request payload", () => { const workspace = makeTempDir(); const job = seedQueuedJobWithPayload(workspace, "job-live-payload", { pid: process.pid }); - const reaped = reapDeadJobs(workspace, listJobs(workspace)); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { processCommandLineImpl: () => LIVE_WORKER_COMMAND_LINE }); assert.equal(reaped[0].status, "queued"); assert.equal(reaped[0].requestFile, job.requestFile); @@ -423,6 +425,37 @@ test("reapDeadJobs keeps a running job whose identity still matches", () => { assert.equal(reaped[0].status, "running"); }); +// A legacy record (no identity) whose pid now runs something that is plainly not +// a companion worker can never be cancelled or finish: the reaper fails it, by +// command line, without signalling anything. +test("reapDeadJobs fails a legacy running job whose pid now runs an unrelated process", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-legacy-recycled", status: "running", phase: "delegating", pid: process.pid, logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { + platform: "linux", + processCommandLineImpl: () => "/usr/sbin/unrelated-daemon" + }); + assert.equal(reaped[0].status, "failed"); + assert.equal(reaped[0].errorMessage, `worker exited before completing (worker pid ${process.pid} now belongs to an unrelated process)`); +}); + +test("reapDeadJobs keeps a legacy running job whose pid still runs the companion", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-legacy-live", status: "running", phase: "delegating", pid: process.pid, logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { + platform: "linux", + processCommandLineImpl: () => "node /x/codex-companion.mjs task-worker --job-id job-legacy-live" + }); + assert.equal(reaped[0].status, "running"); +}); + +test("reapDeadJobs keeps a legacy running job whose command line cannot be read", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-legacy-unknown", status: "running", phase: "delegating", pid: process.pid, logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { platform: "linux", processCommandLineImpl: () => null }); + assert.equal(reaped[0].status, "running"); +}); + test("pid sidecar round-trips identity and still reads the legacy bare integer", () => { const workspace = makeTempDir(); seedJob(workspace, { id: "job-sidecar", status: "queued", phase: "queued", pid: null, logFile: null }); From 3cf11e16e56a37bed858d76ba97252cae6d2fa59 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:28:42 +0300 Subject: [PATCH 26/36] fix(setup): validate the stored gate effort against a new gate model A model-only --review-gate-model change skipped effort validation and could persist a model with an effort it cannot run. The stored effort is now checked against the new model before any write; on failure nothing is written. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 7 ++++++- tests/runtime.test.mjs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 799ebd989..e2a8b2b56 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -334,10 +334,15 @@ async function handleSetup(argv) { const isInherit = (value) => String(value).trim().toLowerCase() === "inherit"; const modelGiven = options["review-gate-model"] != null; const effortGiven = options["review-gate-effort"] != null; + const config = getConfig(workspaceRoot); const newModel = modelGiven && !isInherit(options["review-gate-model"]) ? normalizeRequestedModel(options["review-gate-model"]) : null; - const effectiveModel = modelGiven ? newModel : (getConfig(workspaceRoot).stopReviewGateModel ?? null); + const effectiveModel = modelGiven ? newModel : (config.stopReviewGateModel ?? null); const newEffort = effortGiven && !isInherit(options["review-gate-effort"]) ? normalizeReasoningEffort(options["review-gate-effort"], effectiveModel) : null; + // A model-only change must still fit the effort already stored with it. + if (modelGiven && !effortGiven) { + normalizeReasoningEffort(config.stopReviewGateEffort ?? null, effectiveModel); + } if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5b8a72e01..7dc409be0 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2536,6 +2536,22 @@ test("setup rejects a gate effort the gate model does not support and writes not 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("node", [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("node", [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("node", [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(); From b20c7b3572fd49f4f8bb84ceca975da96170083a Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:28:57 +0300 Subject: [PATCH 27/36] docs: v1.3.0 changelog for undelivered cancel and legacy-job reconciliation Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 ++ plugins/codex/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0887ea29d..8a30b8a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - `/codex:transfer` honours `CLAUDE_CONFIG_DIR` when resolving Claude session transcripts (#721). - The fallback state root is private (0700), per-user and per-plugin, and a symlinked root is refused (#521, #609); `broker.json` is validated before use. - Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; when `cancel` refuses to signal a still-live pid it cannot verify, it reports `cancellation not confirmed: worker pid N left running ()`, exits 1 and leaves the job `running` (turn interrupt still sent). +- `cancel` reports `cancelled` only when its signal reached the worker (a pid that leads no process group is signalled directly); an undelivered kill of a live worker is reported as pending (`not-delivered`), and a worker that finishes after an acknowledged cancel no longer overwrites the `cancelled` record. +- A `running` job recorded without an identity (v1.2.x) whose pid now runs an unrelated, non-companion process is reconciled as failed instead of staying `running` forever; nothing is signalled. ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 0887ea29d..8a30b8a7d 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -9,6 +9,8 @@ - `/codex:transfer` honours `CLAUDE_CONFIG_DIR` when resolving Claude session transcripts (#721). - The fallback state root is private (0700), per-user and per-plugin, and a symlinked root is refused (#521, #609); `broker.json` is validated before use. - Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; when `cancel` refuses to signal a still-live pid it cannot verify, it reports `cancellation not confirmed: worker pid N left running ()`, exits 1 and leaves the job `running` (turn interrupt still sent). +- `cancel` reports `cancelled` only when its signal reached the worker (a pid that leads no process group is signalled directly); an undelivered kill of a live worker is reported as pending (`not-delivered`), and a worker that finishes after an acknowledged cancel no longer overwrites the `cancelled` record. +- A `running` job recorded without an identity (v1.2.x) whose pid now runs an unrelated, non-companion process is reconciled as failed instead of staying `running` forever; nothing is signalled. ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). From 4ccb67575023d63b50b2cb72dd0cfdfe164de5c8 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:35:20 +0300 Subject: [PATCH 28/36] test(cancel): describe the refused-cancel fixture as a foreign companion pid Co-Authored-By: Claude Fable 5.1 --- tests/runtime.test.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 7dc409be0..00f44c0a3 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1912,10 +1912,12 @@ test("cancel stops an active background job and marks it cancelled", async (t) = }); // 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 an unrelated -// process. Liveness says "alive", so the reaper 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. +// 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); From 04b901bb4a6e7dfee7e5105f46dac0f6def9576d Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:50:28 +0300 Subject: [PATCH 29/36] fix(process): re-verify ownership before signalling a pid that leads no group (H1) terminateProcessTree no longer falls back to kill(pid) after the group kill raised ESRCH; it reports groupGone instead. terminateRecordedProcess re-proves identity (or the legacy command line) immediately before the bare-pid SIGTERM and refuses when the pid was recycled in between. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/process.mjs | 60 ++++++++++++++---------- tests/process.test.mjs | 67 ++++++++++++++++++++------- 2 files changed, 87 insertions(+), 40 deletions(-) diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 23ba32f72..ccb04e6ef 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -151,32 +151,47 @@ export function terminateRecordedProcess(pid, options = {}) { } const platform = options.platform ?? process.platform; const identity = options.identity ?? null; - let reason; - if (identity) { - const actual = getProcessIdentity(pid, options); - if (!actual) { - return { attempted: false, delivered: false, reason: "identity-unavailable" }; + // null when the pid is still provably the recorded process, else why not. + const refusal = () => { + if (identity) { + const actual = getProcessIdentity(pid, options); + return !actual ? "identity-unavailable" : actual !== identity ? "identity-mismatch" : null; } - if (actual !== identity) { - return { attempted: false, delivered: false, reason: "identity-mismatch" }; - } - reason = "identity-match"; - } else { if (platform === "win32") { - return { attempted: false, delivered: false, reason: "identity-unavailable" }; + return "identity-unavailable"; } const commandLine = processCommandLine(pid, options); const match = options.commandLineMatch; const matched = Boolean(commandLine) && (typeof match === "function" ? Boolean(match(commandLine)) : match instanceof RegExp ? match.test(commandLine) : false); - if (!matched) { - return { attempted: false, delivered: false, reason: "identity-mismatch" }; - } - reason = "command-line-match"; + return matched ? null : "identity-mismatch"; + }; + const refused = refusal(); + if (refused) { + return { attempted: false, delivered: false, reason: refused }; } + const reason = identity ? "identity-match" : "command-line-match"; // An injected terminator may report nothing; having been called is the attempt. const outcome = (options.terminateImpl ?? terminateProcessTree)(pid, options); + if (outcome?.groupGone) { + // The pid leads no group (a foreground worker): signal it alone, but only + // after proving again that it is still ours — it may have exited and been + // recycled since the first check. + const again = refusal(); + if (again) { + return { attempted: true, delivered: false, method: "process", reason: again }; + } + try { + (options.killImpl ?? process.kill.bind(process))(pid, "SIGTERM"); + return { attempted: true, delivered: true, method: "process", reason }; + } catch (error) { + if (error?.code === "ESRCH") { + return { attempted: true, delivered: false, method: "process", reason }; + } + throw error; + } + } return { ...(outcome && typeof outcome === "object" ? outcome : { attempted: true, delivered: true }), reason }; } @@ -242,18 +257,15 @@ export function terminateProcessTree(pid, options = {}) { try { killImpl(-pid, "SIGTERM"); return { attempted: true, delivered: true, method: "process-group" }; - } catch { + } catch (error) { // ESRCH here only means `pid` leads no process group (a foreground worker, // a child spawned without `detached`) — the process itself may be alive. - try { - killImpl(pid, "SIGTERM"); - return { attempted: true, delivered: true, method: "process" }; - } catch (innerError) { - if (innerError?.code === "ESRCH") { - return { attempted: true, delivered: false, method: "process" }; - } - throw innerError; + // Signalling the bare pid is the caller's call: only it can re-prove the + // pid is still the process it meant (see terminateRecordedProcess). + if (error?.code === "ESRCH") { + return { attempted: true, delivered: false, method: "process-group", groupGone: true }; } + throw error; } } diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 362f92355..a27b2781c 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -63,35 +63,70 @@ test("terminateProcessTree treats missing Windows processes as already stopped", assert.match(outcome.result.stdout, /not found/i); }); -test("terminateProcessTree falls back to the pid when it is not a process-group leader", () => { +// ESRCH on the group means the pid leads no group (or is gone). Signalling the +// bare pid is a second syscall on a number that may have been recycled since the +// caller proved it, so the primitive stops here and says so. +test("terminateProcessTree never signals the bare pid after the group kill fails", () => { const calls = []; const outcome = terminateProcessTree(4242, { platform: "linux", killImpl(pid, signal) { calls.push([pid, signal]); - if (pid < 0) { - throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); - } + throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); } }); - assert.deepEqual(calls, [[-4242, "SIGTERM"], [4242, "SIGTERM"]]); - assert.equal(outcome.attempted, true); - assert.equal(outcome.delivered, true); - assert.equal(outcome.method, "process"); + assert.deepEqual(calls, [[-4242, "SIGTERM"]]); + assert.deepEqual([outcome.attempted, outcome.delivered, outcome.method, outcome.groupGone], [true, false, "process-group", true]); }); -test("terminateProcessTree reports not delivered only when the pid itself is gone", () => { - const outcome = terminateProcessTree(4242, { +const LINUX_STAT = (starttime) => `42 (node) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 1 0 ${starttime} 0 0 0`; +const groupEsrchKill = (calls) => (pid, signal) => { + calls.push([pid, signal]); + if (pid < 0) { + throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); + } +}; + +test("terminateRecordedProcess re-verifies the identity before signalling a pid that leads no group", () => { + const calls = []; + let reads = 0; + const outcome = terminateRecordedProcess(42, { + identity: "linux:999", platform: "linux", - killImpl() { - throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); - } + readFileSyncImpl: () => { reads += 1; return LINUX_STAT(999); }, + killImpl: groupEsrchKill(calls) }); + assert.deepEqual(calls, [[-42, "SIGTERM"], [42, "SIGTERM"]]); + assert.equal(reads, 2); + assert.deepEqual([outcome.attempted, outcome.delivered, outcome.method, outcome.reason], [true, true, "process", "identity-match"]); +}); - assert.equal(outcome.attempted, true); - assert.equal(outcome.delivered, false); - assert.equal(outcome.method, "process"); +test("terminateRecordedProcess refuses the bare pid when its identity changed after the group kill", () => { + const calls = []; + const stats = [LINUX_STAT(999), LINUX_STAT(1000)]; + const outcome = terminateRecordedProcess(42, { + identity: "linux:999", + platform: "linux", + readFileSyncImpl: () => stats.shift(), + killImpl: groupEsrchKill(calls) + }); + assert.deepEqual(calls, [[-42, "SIGTERM"]]); + assert.deepEqual([outcome.attempted, outcome.delivered, outcome.reason], [true, false, "identity-mismatch"]); +}); + +test("terminateRecordedProcess refuses the bare pid when its command line changed after the group kill", () => { + const calls = []; + const lines = ["node codex-companion.mjs task-worker --job-id job-1\n", "bash\n"]; + const outcome = terminateRecordedProcess(42, { + identity: null, + platform: "darwin", + commandLineMatch: /task-worker/, + runCommandImpl: () => ({ status: 0, stdout: lines.shift(), stderr: "", error: null }), + killImpl: groupEsrchKill(calls) + }); + assert.deepEqual(calls, [[-42, "SIGTERM"]]); + assert.deepEqual([outcome.attempted, outcome.delivered, outcome.reason], [true, false, "identity-mismatch"]); }); test("processCommandLine reads the command line of a live process", { skip: process.platform === "win32" }, () => { From 1abc001a08577544f5ebb9d756f76bd3131aa6fd Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:51:45 +0300 Subject: [PATCH 30/36] fix(process): read whole command lines, never a COLUMNS-truncated one (H2) processCommandLine reads /proc//cmdline on linux and runs `ps -ww -o command=` with COLUMNS=10000 elsewhere; empty or unreadable output is null (unknown), which the reaper and broker ownership check already treat as no action. Legacy command-line fixtures that faked ps now say platform darwin. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/process.mjs | 24 ++++++++++++++--- tests/process.test.mjs | 39 ++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index ccb04e6ef..7c0d0e1a2 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -58,9 +58,11 @@ function looksLikeMissingProcessMessage(text) { return /not found|no running instance|cannot find|does not exist|no such process/i.test(text); } -// Command line of a running process, or null when it is gone (or the platform -// has no `ps`). Callers use it to prove a recorded PID is still the process they -// believe it is before signalling it — PIDs get recycled. +// Command line of a running process, or null when it is gone, unreadable or +// empty (or the platform has no `ps`). Callers use it to prove a recorded PID is +// still the process they believe it is before signalling it — PIDs get recycled. +// It must be whole: `ps` cuts at $COLUMNS (procps, even when piped), and a cut +// line can lose the marker a caller matches on. export function processCommandLine(pid, options = {}) { if (!Number.isFinite(pid)) { return null; @@ -71,8 +73,22 @@ export function processCommandLine(pid, options = {}) { return null; } + if (platform === "linux") { + try { + const readFileSyncImpl = options.readFileSyncImpl ?? fs.readFileSync; + const raw = String(readFileSyncImpl(`/proc/${pid}/cmdline`, "utf8")); + return raw.split("\0").filter(Boolean).join(" ").trim() || null; + } catch { + return null; + } + } + const runCommandImpl = options.runCommandImpl ?? runCommand; - const result = runCommandImpl("ps", ["-o", "command=", "-p", String(pid)], { timeoutMs: options.timeoutMs, shell: false }); + const result = runCommandImpl("ps", ["-ww", "-o", "command=", "-p", String(pid)], { + timeoutMs: options.timeoutMs, + shell: false, + env: { ...process.env, COLUMNS: "10000", LC_ALL: "C" } + }); if (result.error || result.status !== 0) { return null; } diff --git a/tests/process.test.mjs b/tests/process.test.mjs index a27b2781c..04fb78696 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -139,6 +139,39 @@ test("processCommandLine returns null for a pid that is not running", { skip: pr assert.equal(processCommandLine(2 ** 31 - 1), null); }); +// `ps -o command=` is cut at $COLUMNS on Linux procps even when piped: a long +// install path could lose the `codex-companion.mjs` marker the reaper and the +// teardowns look for. Linux reads the kernel's copy; other posix asks ps for +// the unlimited width. +test("processCommandLine reads /proc//cmdline on linux", () => { + let readPath = null; + const line = processCommandLine(42, { + platform: "linux", + readFileSyncImpl: (file) => { readPath = file; return `node\0/very/long/${"x/".repeat(80)}codex-companion.mjs\0task-worker\0`; }, + runCommandImpl: () => assert.fail("linux must not run ps") + }); + assert.equal(readPath, "/proc/42/cmdline"); + assert.ok(line.includes("codex-companion.mjs task-worker"), line); + assert.ok(!line.includes("\0")); + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: () => "" }), null); + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: () => { throw new Error("ENOENT"); } }), null); +}); + +test("processCommandLine asks ps for unlimited width off linux", () => { + let seen = null; + const line = processCommandLine(42, { + platform: "darwin", + runCommandImpl: (command, args, options) => { seen = { command, args, options }; return { status: 0, stdout: "node /x/codex-companion.mjs task-worker\n", stderr: "", error: null }; } + }); + assert.equal(line, "node /x/codex-companion.mjs task-worker"); + assert.equal(seen.command, "ps"); + assert.ok(seen.args.includes("-ww"), seen.args.join(" ")); + assert.equal(seen.options.env.COLUMNS, "10000"); + assert.equal(seen.options.env.LC_ALL, "C"); + assert.equal(seen.options.shell, false); + assert.equal(processCommandLine(42, { platform: "darwin", runCommandImpl: () => ({ status: 0, stdout: " \n", stderr: "", error: null }) }), null); +}); + // A pid alone cannot tell the process that was recorded from the one that // inherited the number (#743): identity is the start time, which a recycled pid // cannot share. @@ -207,11 +240,11 @@ test("terminateRecordedProcess signals on a matching identity and refuses when i test("terminateRecordedProcess falls back to the command line on posix when no identity was recorded", () => { const calls = []; - const ok = terminateRecordedProcess(4242, { identity: null, platform: "linux", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "node app-server-broker.mjs serve\n", stderr: "", error: null }), killImpl: (pid, sig) => calls.push([pid, sig]) }); + const ok = terminateRecordedProcess(4242, { identity: null, platform: "darwin", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "node app-server-broker.mjs serve\n", stderr: "", error: null }), killImpl: (pid, sig) => calls.push([pid, sig]) }); assert.equal(ok.attempted, true); assert.equal(ok.reason, "command-line-match"); assert.deepEqual(calls[0], [-4242, "SIGTERM"]); - const no = terminateRecordedProcess(4242, { identity: null, platform: "linux", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "bash\n", stderr: "", error: null }), killImpl: () => calls.push("must not") }); + const no = terminateRecordedProcess(4242, { identity: null, platform: "darwin", commandLineMatch: /app-server-broker\.mjs/, runCommandImpl: () => ({ status: 0, stdout: "bash\n", stderr: "", error: null }), killImpl: () => calls.push("must not") }); assert.equal(no.attempted, false); assert.equal(calls.length, 1); }); @@ -219,7 +252,7 @@ test("terminateRecordedProcess falls back to the command line on posix when no i test("terminateRecordedProcess hands a verified pid to an injected terminateImpl", () => { const terminated = []; const outcome = terminateRecordedProcess(4242, { - platform: "linux", + platform: "darwin", commandLineMatch: () => true, runCommandImpl: () => ({ status: 0, stdout: "node x\n", stderr: "", error: null }), terminateImpl: (pid) => terminated.push(pid) From 0b89636ba0ed98a2bd797fbb44e36b3e67f8ecbe Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:53:56 +0300 Subject: [PATCH 31/36] fix(hook): SessionEnd keeps records of foreground workers it did not stop (H3) cleanupSessionJobs now reads each kill outcome and removes a running foreground job's record only when the kill was delivered, the pid is provably gone, or the job has no pid. A refused, undelivered or failed kill, or a job the budget never reached, keeps its record and files, is logged as "[codex] SessionEnd left running: ", and keeps the broker up through activeWorkspaceJobs. Each identity probe now gets half of what is left (worker cleanup) or of the step budget (broker teardown): the H1 re-verify can add a second probe per kill, and both must fit inside the 12 s SessionEnd budget. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + plugins/codex/CHANGELOG.md | 1 + .../codex/scripts/session-lifecycle-hook.mjs | 40 ++++++--- tests/broker-stale-pid.test.mjs | 89 +++++++++++++++++++ 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a30b8a7d..feddff1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; when `cancel` refuses to signal a still-live pid it cannot verify, it reports `cancellation not confirmed: worker pid N left running ()`, exits 1 and leaves the job `running` (turn interrupt still sent). - `cancel` reports `cancelled` only when its signal reached the worker (a pid that leads no process group is signalled directly); an undelivered kill of a live worker is reported as pending (`not-delivered`), and a worker that finishes after an acknowledged cancel no longer overwrites the `cancelled` record. - A `running` job recorded without an identity (v1.2.x) whose pid now runs an unrelated, non-companion process is reconciled as failed instead of staying `running` forever; nothing is signalled. +- `SessionEnd` keeps the record (and its files) of a foreground job whose worker it could not stop — a refused or undelivered kill, or a job its time budget never reached — and logs `[codex] SessionEnd left running: `; only a stopped, provably gone or pid-less job's record is removed. ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 8a30b8a7d..feddff1db 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -11,6 +11,7 @@ - Identity-checked kills and reaping on posix (#743): job records and the pid sidecar carry `pidIdentity` (a JSON `{pid, identity}` sidecar; the legacy bare integer is still read), and `broker.json` carries `pidIdentity` too; when `cancel` refuses to signal a still-live pid it cannot verify, it reports `cancellation not confirmed: worker pid N left running ()`, exits 1 and leaves the job `running` (turn interrupt still sent). - `cancel` reports `cancelled` only when its signal reached the worker (a pid that leads no process group is signalled directly); an undelivered kill of a live worker is reported as pending (`not-delivered`), and a worker that finishes after an acknowledged cancel no longer overwrites the `cancelled` record. - A `running` job recorded without an identity (v1.2.x) whose pid now runs an unrelated, non-companion process is reconciled as failed instead of staying `running` forever; nothing is signalled. +- `SessionEnd` keeps the record (and its files) of a foreground job whose worker it could not stop — a refused or undelivered kill, or a job its time budget never reached — and logs `[codex] SessionEnd left running: `; only a stopped, provably gone or pid-less job's record is removed. ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 79164471c..53a57f377 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import process from "node:process"; -import { terminateProcessTree, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; +import { isPidAlive, terminateProcessTree, terminateRecordedProcess, workerCommandLine } from "./lib/process.mjs"; import { BROKER_ENDPOINT_ENV } from "./lib/app-server.mjs"; import { clearBrokerSession, @@ -124,6 +124,10 @@ function cleanupSessionJobs(cwd, sessionId, lockWaitMs, remainingMs) { return; } + // A record is only dropped once its worker is stopped or provably gone; one + // this hook refused to signal, failed to signal or never reached stays, so + // the worker is not orphaned and `activeWorkspaceJobs` still sees it. + const kept = new Set(); for (const job of sessionJobs) { // Background jobs are explicitly dispatched to outlive the session that // started them. Leave them running and leave their state entry intact so @@ -136,22 +140,33 @@ function cleanupSessionJobs(cwd, sessionId, lockWaitMs, remainingMs) { continue; } // Only a pid still provably this job's process is signalled (#743), and - // proving it costs a probe the budget has to cover. - const probeMs = Math.min(IDENTITY_PROBE_MS, remainingMs()); - if (probeMs < MIN_STEP_MS) { - continue; + // proving it costs up to two probes (a worker that leads no process group + // is re-proved before its own pid is signalled) the budget has to cover. + const probeMs = Math.min(IDENTITY_PROBE_MS, remainingMs() / 2); + let reason = "budget-exhausted"; + if (probeMs >= MIN_STEP_MS) { + let pid; + try { + const recorded = resolveJobPid(workspaceRoot, job); + pid = recorded.pid; + const outcome = terminateRecordedProcess(pid, { identity: recorded.identity, commandLineMatch: workerCommandLine(job.id), timeoutMs: probeMs }); + reason = outcome.reason === "no-pid" || (outcome.attempted && outcome.delivered) ? null : outcome.attempted ? "not-delivered" : outcome.reason; + } catch { + reason = "kill-failed"; + } + if (reason && isPidAlive(pid) === false) { + reason = null; + } } - try { - const { pid, identity } = resolveJobPid(workspaceRoot, job); - terminateRecordedProcess(pid, { identity, commandLineMatch: workerCommandLine(job.id), timeoutMs: probeMs }); - } catch { - // Ignore teardown failures during session shutdown. + if (reason) { + kept.add(job.id); + process.stderr.write(`[codex] SessionEnd left ${job.id} running: ${reason}\n`); } } saveState(workspaceRoot, { ...state, - jobs: state.jobs.filter((job) => job.sessionId !== sessionId || job.background) + jobs: state.jobs.filter((job) => job.sessionId !== sessionId || job.background || kept.has(job.id)) }); }, { waitMs: lockWaitMs }); } @@ -295,7 +310,8 @@ async function handleSessionEnd(input) { pid, pidIdentity, killProcess: terminateProcessTree, - timeoutMs: stepBudget(IDENTITY_PROBE_MS) + // Halved: a broker gone from its group is re-proved with a second probe. + timeoutMs: stepBudget(IDENTITY_PROBE_MS) / 2 }); // Every branch of this hook says what it decided: when a broker outlives a // SessionEnd the only question worth asking is which of these four paths ran. diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 7757e8c9c..a802d92e6 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1192,3 +1192,92 @@ test("ensureBrokerSession re-verifies a legacy broker's ownership after the read clearBrokerSession(workspace); } }); + +// SessionEnd may only drop a foreground job's record once its worker is stopped +// (or provably gone). A kill it refused or never reached leaves the worker +// running, and dropping the record would orphan it along with its files. +function spawnWorkerStandIn(t, commandLineTail) { + const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", ...commandLineTail], { detached: true, stdio: "ignore" }); + worker.unref(); + t.after(() => { + try { + process.kill(worker.pid, "SIGKILL"); + } catch { + // Already gone. + } + }); + return worker; +} + +function seedForegroundJobs(workspace, jobs) { + const stateDir = resolveStateDir(workspace); + const jobsDir = path.join(stateDir, "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + for (const job of jobs) { + fs.writeFileSync(path.join(jobsDir, `${job.id}.request.json`), "{}\n"); + fs.writeFileSync(path.join(jobsDir, `${job.id}.pid`), `${JSON.stringify({ pid: job.pid, identity: job.pidIdentity ?? null })}\n`); + } + fs.writeFileSync( + path.join(stateDir, "state.json"), + `${JSON.stringify({ + version: 1, + config: { stopReviewGate: false }, + jobs: jobs.map((job) => ({ + status: "running", + phase: "running", + title: "Codex Task", + jobClass: "task", + sessionId: "sess-current", + logFile: null, + createdAt: "2026-09-27T10:00:00.000Z", + updatedAt: "2026-09-27T10:01:00.000Z", + ...job + })) + }, null, 2)}\n` + ); + return { stateDir, jobsDir }; +} + +test("session end keeps the record of a foreground worker it refused to signal", { skip: process.platform === "win32" }, async (t) => { + const workspace = makeTempDir(); + // A companion, but not this job's worker: the legacy command-line check refuses it. + const foreign = spawnWorkerStandIn(t, ["codex-companion.mjs", "task-worker", "--job-id", "task-someone-else"]); + // A stored identity that is not the live pid's. + const mismatched = spawnWorkerStandIn(t, ["codex-companion.mjs", "task-worker", "--job-id", "task-own-mismatch"]); + const { stateDir, jobsDir } = seedForegroundJobs(workspace, [ + { id: "task-own-legacy", pid: foreign.pid }, + { id: "task-own-mismatch", pid: mismatched.pid, pidIdentity: "darwin:definitely-not-this|nope" } + ]); + + const hook = runSessionEndHook(workspace, { sessionId: "sess-current" }); + assert.equal(hook.status, 0, hook.stderr); + assert.match(hook.stderr, /\[codex\] SessionEnd left task-own-legacy running: identity-mismatch/); + assert.match(hook.stderr, /\[codex\] SessionEnd left task-own-mismatch running: identity-mismatch/); + assert.equal(isAlive(foreign.pid), true); + assert.equal(isAlive(mismatched.pid), true); + + const jobs = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs; + assert.deepEqual(jobs.map((job) => job.id).sort(), ["task-own-legacy", "task-own-mismatch"]); + const legacy = jobs.find((job) => job.id === "task-own-legacy"); + assert.equal(legacy.status, "running"); + assert.equal(fs.existsSync(path.join(jobsDir, "task-own-legacy.request.json")), true); + assert.equal(fs.existsSync(path.join(jobsDir, "task-own-legacy.pid")), true); + // The identity mismatch is the reaper's call afterwards (the recorded worker is + // gone from that pid); the record itself is never silently dropped. + assert.ok(jobs.find((job) => job.id === "task-own-mismatch")); +}); + +test("session end keeps the records of foreground jobs its budget never reached", { skip: process.platform === "win32" }, async (t) => { + const workspace = makeTempDir(); + // Would be signalled if reached: the command line is this job's worker. + const worker = spawnWorkerStandIn(t, ["codex-companion.mjs", "task-worker", "--job-id", "task-own-unreached"]); + const { stateDir, jobsDir } = seedForegroundJobs(workspace, [{ id: "task-own-unreached", pid: worker.pid }]); + + const hook = runSessionEndHook(workspace, { sessionId: "sess-current", env: { ...process.env, CODEX_COMPANION_SESSION_END_BUDGET_MS: "1" } }); + assert.equal(hook.status, 0, hook.stderr); + assert.match(hook.stderr, /\[codex\] SessionEnd left task-own-unreached running: /); + assert.equal(isAlive(worker.pid), true); + const jobs = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs; + assert.deepEqual(jobs.map((job) => [job.id, job.status]), [["task-own-unreached", "running"]]); + assert.equal(fs.existsSync(path.join(jobsDir, "task-own-unreached.request.json")), true); +}); From 2a3a428b72ce8a78cfb930a77606875ad64c5e1d Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:17:21 +0300 Subject: [PATCH 32/36] fix(process): pass spawnSync only integer timeouts >= 1 (I1) Halved SessionEnd probe budgets could be fractional (e.g. 500.5); spawnSync threw ERR_OUT_OF_RANGE, the hook logged kill-failed and kept a live worker. Floor both halving sites, and clamp in runCommand so no caller can pass a fractional, zero (unbounded) or negative timeout. Same clamp on the stop review gate's env override. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/process.mjs | 4 +++- .../codex/scripts/session-lifecycle-hook.mjs | 4 ++-- plugins/codex/scripts/stop-review-gate-hook.mjs | 2 +- tests/broker-stale-pid.test.mjs | 17 +++++++++++++++++ tests/process.test.mjs | 13 +++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 7c0d0e1a2..1474f5769 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -10,7 +10,9 @@ export function runCommand(command, args = [], options = {}) { input: options.input, maxBuffer: options.maxBuffer, stdio: options.stdio ?? "pipe", - timeout: options.timeoutMs, + // spawnSync throws on a fractional timeout and reads 0 as "no timeout": + // whatever budget arithmetic a caller did, a bound stays a bound. + timeout: Number.isFinite(options.timeoutMs) ? Math.max(1, Math.floor(options.timeoutMs)) : undefined, shell: options.shell ?? (process.platform === "win32" ? (process.env.SHELL || true) : false), windowsHide: true }); diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 53a57f377..4d6e278fc 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -142,7 +142,7 @@ function cleanupSessionJobs(cwd, sessionId, lockWaitMs, remainingMs) { // Only a pid still provably this job's process is signalled (#743), and // proving it costs up to two probes (a worker that leads no process group // is re-proved before its own pid is signalled) the budget has to cover. - const probeMs = Math.min(IDENTITY_PROBE_MS, remainingMs() / 2); + const probeMs = Math.floor(Math.min(IDENTITY_PROBE_MS, remainingMs() / 2)); let reason = "budget-exhausted"; if (probeMs >= MIN_STEP_MS) { let pid; @@ -311,7 +311,7 @@ async function handleSessionEnd(input) { pidIdentity, killProcess: terminateProcessTree, // Halved: a broker gone from its group is re-proved with a second probe. - timeoutMs: stepBudget(IDENTITY_PROBE_MS) / 2 + timeoutMs: Math.floor(stepBudget(IDENTITY_PROBE_MS) / 2) }); // Every branch of this hook says what it decided: when a broker outlives a // SessionEnd the only question worth asking is which of these four paths ran. diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index ca8a4cf1e..72e79d213 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -155,7 +155,7 @@ function runStopReview(cwd, input = {}, config = {}) { cwd, env: childEnv, encoding: "utf8", - timeout: STOP_REVIEW_TIMEOUT_OVERRIDE_MS || STOP_REVIEW_TIMEOUT_MS, + timeout: Math.max(1, Math.floor(STOP_REVIEW_TIMEOUT_OVERRIDE_MS || STOP_REVIEW_TIMEOUT_MS)), killSignal: "SIGKILL", maxBuffer: 16 * 1024 * 1024 }); diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index a802d92e6..0464b9455 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1281,3 +1281,20 @@ test("session end keeps the records of foreground jobs its budget never reached" assert.deepEqual(jobs.map((job) => [job.id, job.status]), [["task-own-unreached", "running"]]); assert.equal(fs.existsSync(path.join(jobsDir, "task-own-unreached.request.json")), true); }); + +// Probe timeouts are halves of what is left of the budget; an odd remainder gave +// spawnSync a fractional timeout, it threw, and the kill was logged `kill-failed` +// with the worker kept. Several workers make an odd remainder all but certain. +test("session end stops foreground workers on an odd budget", { skip: process.platform === "win32" }, async (t) => { + const workspace = makeTempDir(); + const ids = ["a", "b", "c", "d", "e", "f"].map((suffix) => `task-own-odd-${suffix}`); + const workers = ids.map((id) => spawnWorkerStandIn(t, ["codex-companion.mjs", "task-worker", "--job-id", id])); + const { stateDir } = seedForegroundJobs(workspace, ids.map((id, index) => ({ id, pid: workers[index].pid }))); + + const hook = runSessionEndHook(workspace, { sessionId: "sess-current", env: { ...process.env, CODEX_COMPANION_SESSION_END_BUDGET_MS: "1001" } }); + assert.equal(hook.status, 0, hook.stderr); + assert.doesNotMatch(hook.stderr, /kill-failed/); + assert.doesNotMatch(hook.stderr, /SessionEnd left/); + const jobs = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs; + assert.deepEqual(jobs, []); +}); diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 04fb78696..2e6e9815e 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -216,6 +216,19 @@ test("runCommand reports a timed-out command as having no exit status", { skip: assert.notEqual(result.status, 0); }); +// spawnSync throws ERR_OUT_OF_RANGE on a fractional timeout and reads 0 as +// "unbounded": budgets halved or spent must still reach it as an integer >= 1. +test("runCommand clamps a fractional, zero or negative timeout to an integer >= 1", { skip: process.platform === "win32" }, () => { + const fractional = runCommand(process.execPath, ["-e", ""], { timeoutMs: 500.5 }); + assert.equal(fractional.status, 0); + for (const timeoutMs of [0, 0.4, -5]) { + const started = Date.now(); + const result = runCommand(process.execPath, ["-e", "setTimeout(()=>{}, 5000)"], { timeoutMs }); + assert.equal(result.status, null, `timeoutMs ${timeoutMs} must stay bounded`); + assert.ok(Date.now() - started < 4000); + } +}); + test("terminateRecordedProcess refuses on identity mismatch and without identity on win32", () => { let killed = false; const mismatch = terminateRecordedProcess(4242, { identity: "linux:1", platform: "linux", readFileSyncImpl: () => "4242 (node) S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 1 0 999 0 0 0", killImpl: () => { killed = true; } }); From eef98a713410c1b7550f2e5a5f4580cf6ee64b4e Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:17:54 +0300 Subject: [PATCH 33/36] fix(process): report a linux zombie's command line as (I2) A dead legacy worker left as a zombie has an empty /proc//cmdline while kill 0 still sees it, so processCommandLine returned null (unknown) and the reaper kept the job running forever. On an empty cmdline read /proc//stat: state Z or X yields "" (as ps prints it, no companion marker), so the reaper's legacy rule reconciles the job without signalling. Any other state or an unreadable stat stays null. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/process.mjs | 13 +++++++++++-- tests/process.test.mjs | 12 ++++++++++++ tests/tracked-jobs.test.mjs | 10 ++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 1474f5769..2d511b35a 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -61,7 +61,7 @@ function looksLikeMissingProcessMessage(text) { } // Command line of a running process, or null when it is gone, unreadable or -// empty (or the platform has no `ps`). Callers use it to prove a recorded PID is +// empty (or the platform has no `ps`), and "" for a zombie. Callers use it to prove a recorded PID is // still the process they believe it is before signalling it — PIDs get recycled. // It must be whole: `ps` cuts at $COLUMNS (procps, even when piped), and a cut // line can lose the marker a caller matches on. @@ -79,7 +79,16 @@ export function processCommandLine(pid, options = {}) { try { const readFileSyncImpl = options.readFileSyncImpl ?? fs.readFileSync; const raw = String(readFileSyncImpl(`/proc/${pid}/cmdline`, "utf8")); - return raw.split("\0").filter(Boolean).join(" ").trim() || null; + const line = raw.split("\0").filter(Boolean).join(" ").trim(); + if (line) { + return line; + } + // A zombie's cmdline is empty too. Say so the way `ps` does — no companion + // marker, so callers treat it as not theirs and never signal it. State is + // field 3, right after the last ")" (`comm` may hold parentheses). + const stat = String(readFileSyncImpl(`/proc/${pid}/stat`, "utf8")); + const state = stat.charAt(stat.lastIndexOf(")") + 2); + return state === "Z" || state === "X" ? "" : null; } catch { return null; } diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 2e6e9815e..cadcef372 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -157,6 +157,18 @@ test("processCommandLine reads /proc//cmdline on linux", () => { assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: () => { throw new Error("ENOENT"); } }), null); }); +// A zombie keeps its pid (alive to kill 0) but its /proc cmdline is empty: the +// state field is what tells it from a live process whose line cannot be read. +test("processCommandLine reports a linux zombie as and nothing else", () => { + const read = (state) => (file) => (file.endsWith("/cmdline") ? "" : `42 (node (x) y) ${state} 1 42 42 0 -1 0`); + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: read("Z") }), ""); + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: read("X") }), ""); + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: read("S") }), null); + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: read("R") }), null); + const statThrows = (file) => { if (file.endsWith("/stat")) { throw new Error("ENOENT"); } return ""; }; + assert.equal(processCommandLine(42, { platform: "linux", readFileSyncImpl: statThrows }), null); +}); + test("processCommandLine asks ps for unlimited width off linux", () => { let seen = null; const line = processCommandLine(42, { diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index 3e1c01ddb..ba7149cac 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -449,6 +449,16 @@ test("reapDeadJobs keeps a legacy running job whose pid still runs the companion assert.equal(reaped[0].status, "running"); }); +// A legacy worker that died but was never reaped by its parent stays a zombie: +// alive to kill 0, `` to processCommandLine. It is failed, not signalled. +test("reapDeadJobs fails a legacy running job whose worker is a zombie", () => { + const workspace = makeTempDir(); + seedJob(workspace, { id: "job-legacy-zombie", status: "running", phase: "delegating", pid: process.pid, logFile: null }); + const reaped = reapDeadJobs(workspace, listJobs(workspace), { platform: "linux", processCommandLineImpl: () => "" }); + assert.equal(reaped[0].status, "failed"); + assert.equal(reaped[0].errorMessage, `worker exited before completing (worker pid ${process.pid} now belongs to an unrelated process)`); +}); + test("reapDeadJobs keeps a legacy running job whose command line cannot be read", () => { const workspace = makeTempDir(); seedJob(workspace, { id: "job-legacy-unknown", status: "running", phase: "delegating", pid: process.pid, logFile: null }); From ca3f74dc36771f865daf8c693b181b2d9c3ca704 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:43:32 +0300 Subject: [PATCH 34/36] fix(broker): tolerate a broker that already cleared its own record during teardown The SessionEnd hook's clearBrokerSession(cwd) guarded the unlink with an existsSync pre-check, but the broker's own SIGTERM handler (clearOwnSessionRecord) can delete the same broker.json concurrently: the check and the unlink are not atomic, so the hook could still hit ENOENT and exit 1. Replace the pre-check with try/unlink/catch-ENOENT in clearBrokerSession and in every unlinkSync site of teardownBrokerSession (pidFile, logFile, the unix socket path) so a concurrently self-cleaning broker can remove any of them without failing the hook. Other error codes (e.g. EPERM) still surface exactly as before. Co-Authored-By: Claude Fable 5.1 --- .../codex/scripts/lib/broker-lifecycle.mjs | 37 +++++++++++++++---- tests/broker-stale-pid.test.mjs | 30 +++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index 52cd60947..bef533b24 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -196,8 +196,15 @@ export function saveBrokerSession(cwd, session) { export function clearBrokerSession(cwd) { const stateFile = resolveBrokerStateFile(cwd); - if (fs.existsSync(stateFile)) { + try { fs.unlinkSync(stateFile); + } catch (error) { + // A concurrently self-cleaning broker (`clearOwnSessionRecord`) can already + // have removed this same file: an `existsSync` pre-check does not close that + // race, it only narrows it. + if (error?.code !== "ENOENT") { + throw error; + } } } @@ -347,22 +354,38 @@ export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessi } } - if (pidFile && fs.existsSync(pidFile)) { - fs.unlinkSync(pidFile); + // A concurrently self-cleaning broker can remove any of these between the + // `existsSync` check and the unlink; only ENOENT from that race is swallowed, + // every other error (e.g. EPERM) still surfaces as it did before. + if (pidFile) { + try { + fs.unlinkSync(pidFile); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } } - if (logFile && fs.existsSync(logFile)) { - fs.unlinkSync(logFile); + if (logFile) { + try { + fs.unlinkSync(logFile); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } } if (endpoint) { try { const target = parseBrokerEndpoint(endpoint); - if (target.kind === "unix" && fs.existsSync(target.path)) { + if (target.kind === "unix") { fs.unlinkSync(target.path); } } catch { - // Ignore malformed or already-removed broker endpoints during teardown. + // Ignore malformed or already-removed broker endpoints during teardown + // (this already swallowed ENOENT, and every other error, before this fix). } } diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index 0464b9455..ea70df415 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -16,6 +16,7 @@ import { loadBrokerSession, saveBrokerSession, sendBrokerShutdown, + teardownBrokerSession, waitForBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; @@ -1298,3 +1299,32 @@ test("session end stops foreground workers on an odd budget", { skip: process.pl const jobs = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs; assert.deepEqual(jobs, []); }); + +// The broker's own SIGTERM handler (`clearOwnSessionRecord`) deletes the same +// broker.json concurrently with the SessionEnd hook. A pre-check with +// `existsSync` still loses that race: the file can vanish between the check and +// the unlink. `clearBrokerSession` must tolerate a record that is simply not there. +test("clearBrokerSession on a workspace with no broker.json returns without throwing", () => { + const workspace = makeTempDir(); + assert.doesNotThrow(() => clearBrokerSession(workspace)); +}); + +test("clearBrokerSession tolerates a broker that already cleared its own record", () => { + const workspace = makeTempDir(); + saveBrokerSession(workspace, { endpoint: "unix:/tmp/x.sock", pid: null, pidFile: null, logFile: null, sessionDir: null }); + clearBrokerSession(workspace); + // The second call hits exactly the file-already-gone race the broker's own + // cleanup can win against the hook. + assert.doesNotThrow(() => clearBrokerSession(workspace)); + assert.equal(loadBrokerSession(workspace), null); +}); + +test("teardownBrokerSession tolerates a pidFile, logFile, and sessionDir the broker already removed", () => { + const sessionDir = makeTempDir("cxc-"); + const pidFile = path.join(sessionDir, "broker.pid"); + const logFile = path.join(sessionDir, "broker.log"); + fs.rmdirSync(sessionDir); + + const result = teardownBrokerSession({ pidFile, logFile, sessionDir }); + assert.deepEqual(result, { signalled: false, reason: "no-pid" }); +}); From 580a9d6a1e31366097bbc7f24a75190e042f5744 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:51:08 +0300 Subject: [PATCH 35/36] fix(broker): best-effort pid/log cleanup during teardown teardownBrokerSession's pidFile/logFile unlinks were only tolerant of ENOENT, so a locked or otherwise unremovable file (EPERM, notably reported on Windows upstream in #633/#626) still escaped and failed the SessionEnd hook. These two unlinks are best-effort cleanup, same as the socket-path unlink beside them (already a catch-all) and rmdirSync below them: swallow every error, not just ENOENT. clearBrokerSession is unchanged and stays ENOENT-only, since it is the one unlink whose target (broker.json) is the ownership record itself. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/broker-lifecycle.mjs | 18 +++++++----------- tests/broker-stale-pid.test.mjs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index bef533b24..45bfc4eb0 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -354,26 +354,22 @@ export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessi } } - // A concurrently self-cleaning broker can remove any of these between the - // `existsSync` check and the unlink; only ENOENT from that race is swallowed, - // every other error (e.g. EPERM) still surfaces as it did before. + // Best-effort: a self-cleaning broker or a locked file must not fail the hook. if (pidFile) { try { fs.unlinkSync(pidFile); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } + } catch { + // Ignore — missing, already removed, or not removable (e.g. EPERM/ENOTDIR; + // upstream #633/#626 report EPERM here on Windows). } } if (logFile) { try { fs.unlinkSync(logFile); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } + } catch { + // Ignore — missing, already removed, or not removable (e.g. EPERM/ENOTDIR; + // upstream #633/#626 report EPERM here on Windows). } } diff --git a/tests/broker-stale-pid.test.mjs b/tests/broker-stale-pid.test.mjs index ea70df415..bce6829f9 100644 --- a/tests/broker-stale-pid.test.mjs +++ b/tests/broker-stale-pid.test.mjs @@ -1328,3 +1328,19 @@ test("teardownBrokerSession tolerates a pidFile, logFile, and sessionDir the bro const result = teardownBrokerSession({ pidFile, logFile, sessionDir }); assert.deepEqual(result, { signalled: false, reason: "no-pid" }); }); + +// The pidFile/logFile unlinks are best-effort cleanup, not a contract the hook can +// fail on: a locked file (EPERM, notably on Windows per upstream #633/#626) or any +// other unlink failure must not escape teardown. +test("teardownBrokerSession swallows unlink failures on pidFile and logFile as best-effort cleanup", () => { + const workspace = makeTempDir(); + // pidFile's directory does not exist at all (ENOENT on the unlink). + const pidFile = path.join(workspace, "missing-dir", "broker.pid"); + // logFile's parent path component is a regular file, not a directory (ENOTDIR). + const regularFile = path.join(workspace, "not-a-directory"); + fs.writeFileSync(regularFile, ""); + const logFile = path.join(regularFile, "broker.log"); + + const result = teardownBrokerSession({ pidFile, logFile, sessionDir: null }); + assert.deepEqual(result, { signalled: false, reason: "no-pid" }); +}); From d966259fc2629b8e65e9404c67b7735ef90dfbca Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:18:44 +0300 Subject: [PATCH 36/36] fix(runtime): replay buffered thread/started like the live handler so subagent labels survive The replay of notifications buffered before the turn/start response ran every message through belongsToTurn, dropping a subagent's thread/started (its thread id is not yet known) and losing the label. Route live and replayed notifications through one function. Adds the FAKE_CODEX_SUBAGENT_EARLY_STARTED fixture knob and a deterministic test. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + plugins/codex/CHANGELOG.md | 1 + plugins/codex/scripts/lib/codex.mjs | 36 ++++++++++++----------------- tests/fake-codex-fixture.mjs | 18 +++++++++++++-- tests/runtime.test.mjs | 22 ++++++++++++++++++ 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feddff1db..195552b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - `cancel` reports `cancelled` only when its signal reached the worker (a pid that leads no process group is signalled directly); an undelivered kill of a live worker is reported as pending (`not-delivered`), and a worker that finishes after an acknowledged cancel no longer overwrites the `cancelled` record. - A `running` job recorded without an identity (v1.2.x) whose pid now runs an unrelated, non-companion process is reconciled as failed instead of staying `running` forever; nothing is signalled. - `SessionEnd` keeps the record (and its files) of a foreground job whose worker it could not stop — a refused or undelivered kill, or a job its time budget never reached — and logs `[codex] SessionEnd left running: `; only a stopped, provably gone or pid-less job's record is removed. +- Subagent labels no longer depend on notification timing: buffered `thread/started` notifications are applied on replay. ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index feddff1db..195552b42 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -12,6 +12,7 @@ - `cancel` reports `cancelled` only when its signal reached the worker (a pid that leads no process group is signalled directly); an undelivered kill of a live worker is reported as pending (`not-delivered`), and a worker that finishes after an acknowledged cancel no longer overwrites the `cancelled` record. - A `running` job recorded without an identity (v1.2.x) whose pid now runs an unrelated, non-companion process is reconciled as failed instead of staying `running` forever; nothing is signalled. - `SessionEnd` keeps the record (and its files) of a foreground job whose worker it could not stop — a refused or undelivered kill, or a job its time budget never reached — and logs `[codex] SessionEnd left running: `; only a stopped, provably gone or pid-less job's record is removed. +- Subagent labels no longer depend on notification timing: buffered `thread/started` notifications are applied on replay. ### Added - `setup --review-gate-model --review-gate-effort ` pins the stop-time review gate's model/effort independently of your Codex config (#769). diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index ec65829ca..038eb428b 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -746,25 +746,25 @@ async function captureTurn(client, threadId, startRequest, options = {}) { const timeoutMs = resolveTurnTimeoutMs(options.turnTimeoutMs); let timeoutTimer = null; - client.setNotificationHandler((message) => { - if (!state.started) { - state.bufferedNotifications.push(message); - return; - } - + // Shared by the live handler and the buffered replay so they cannot drift: + // thread metadata (a subagent's thread/started) must apply before its thread + // id is known to belong to this turn. + const routeNotification = (message) => { if (message.method === "thread/started" || message.method === "thread/name/updated") { applyTurnNotification(state, message); - return; + } else if (belongsToTurn(state, message)) { + applyTurnNotification(state, message); + } else { + previousHandler?.(message); } + }; - if (!belongsToTurn(state, message)) { - if (previousHandler) { - previousHandler(message); - } - return; + client.setNotificationHandler((message) => { + if (!state.started) { + state.bufferedNotifications.push(message); + return; } - - applyTurnNotification(state, message); + routeNotification(message); }); try { @@ -776,13 +776,7 @@ async function captureTurn(client, threadId, startRequest, options = {}) { } state.started = true; for (const message of state.bufferedNotifications) { - if (belongsToTurn(state, message)) { - applyTurnNotification(state, message); - } else { - if (previousHandler) { - previousHandler(message); - } - } + routeNotification(message); } state.bufferedNotifications.length = 0; diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 4c839f2cd..f2861da99 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -293,6 +293,10 @@ const CLOSE_DELAY_MS = Number(process.env.FAKE_CODEX_CLOSE_DELAY_MS || 0); // test can observe a job that is still running. const TURN_DELAY_MS = Number(process.env.FAKE_CODEX_TURN_DELAY_MS || 0); +// Test knob: for with-subagent, announce the sub-thread (thread/started) before +// the turn/start response, so the client must buffer and replay it. +const SUBAGENT_EARLY_STARTED = process.env.FAKE_CODEX_SUBAGENT_EARLY_STARTED === "1"; + // Test knob: answer turn/interrupt but keep running the turn, the way a real // app-server that has wedged on a tool call does. Also records that the client // closed the connection, which is the only thing that stops such a turn. @@ -507,6 +511,14 @@ rl.on("line", (line) => { prompt }; saveState(state); + let earlySubThread = null; + if (SUBAGENT_EARLY_STARTED && BEHAVIOR === "with-subagent") { + earlySubThread = nextThread(state, thread.cwd, true); + const earlyRecord = ensureThread(state, earlySubThread.id); + earlyRecord.name = "design-challenger"; + saveState(state); + send({ method: "thread/started", params: { thread: { ...buildThread(earlyRecord), name: "design-challenger", agentNickname: "design-challenger" } } }); + } send({ id: message.id, result: { turn: BEHAVIOR === "turn-start-without-id" ? { status: "inProgress", items: [] } : buildTurn(turnId) } }); const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict @@ -557,13 +569,15 @@ rl.on("line", (line) => { BEHAVIOR === "with-subagent-no-main-turn-completed" || BEHAVIOR === "subagent-error" ) { - const subThread = nextThread(state, thread.cwd, true); + const subThread = earlySubThread ?? nextThread(state, thread.cwd, true); const subThreadRecord = ensureThread(state, subThread.id); subThreadRecord.name = "design-challenger"; saveState(state); const subTurnId = nextTurnId(state); - send({ method: "thread/started", params: { thread: { ...buildThread(subThreadRecord), name: "design-challenger", agentNickname: "design-challenger" } } }); + if (!earlySubThread) { + send({ method: "thread/started", params: { thread: { ...buildThread(subThreadRecord), name: "design-challenger", agentNickname: "design-challenger" } } }); + } send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); send({ method: "item/started", diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 00f44c0a3..a012453b7 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1096,6 +1096,28 @@ test("task logs subagent reasoning and messages with a subagent prefix", () => { ); }); +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("node", [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();