From 7b573b3b8bd77bf385d4f247e44558c6ea64aeda Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:53:07 +0900 Subject: [PATCH] fix: leave Stop-hook headroom so a timed-out review gate can report The stop-review child timeout matched the Stop hook's 900s budget, so Claude Code killed the hook before it could emit the ETIMEDOUT reason. Lower the inner spawnSync timeout to 14 minutes so a slow review still returns the bypass/manual-review message instead of ending the turn silently. Fixes #766. --- .../codex/scripts/stop-review-gate-hook.mjs | 4 ++-- tests/commands.test.mjs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 2346bdcf4..bab91a59a 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -13,7 +13,7 @@ import { sortJobsNewestFirst } from "./lib/job-control.mjs"; import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; -const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000; +const STOP_REVIEW_TIMEOUT_MS = 14 * 60 * 1000; 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."; @@ -113,7 +113,7 @@ function runStopReview(cwd, input = {}) { return { ok: false, reason: - "The stop-time Codex review task timed out after 15 minutes. Run /codex:review --wait manually or bypass the gate." + "The stop-time Codex review task timed out after 14 minutes. Run /codex:review --wait manually or bypass the gate." }; } diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..11797760d 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -210,6 +210,27 @@ test("hooks keep session-end cleanup and stop gating enabled", () => { assert.match(source, /session-lifecycle-hook\.mjs/); }); +test("stop-review task timeout leaves headroom under the Stop hook timeout", () => { + const hookSource = read("scripts/stop-review-gate-hook.mjs"); + const hooks = JSON.parse(read("hooks/hooks.json")); + const stopHook = hooks.hooks.Stop.flatMap((entry) => entry.hooks).find((hook) => + String(hook.command ?? "").includes("stop-review-gate-hook.mjs") + ); + const hookTimeoutMs = Number(stopHook?.timeout) * 1000; + const timeoutMatch = hookSource.match(/const STOP_REVIEW_TIMEOUT_MS = ([^;]+);/); + assert.ok(timeoutMatch, "STOP_REVIEW_TIMEOUT_MS should be declared"); + assert.match(timeoutMatch[1], /^[\d\s.*+\-/()]+$/); + const innerTimeoutMs = new Function(`return (${timeoutMatch[1]});`)(); + const timeoutMessageMatch = hookSource.match(/timed out after ([0-9]+) minutes/); + + assert.equal(Number.isFinite(hookTimeoutMs), true); + assert.equal(hookTimeoutMs, 900000); + assert.equal(Number.isFinite(innerTimeoutMs), true); + assert.equal(innerTimeoutMs < hookTimeoutMs, true); + assert.equal(hookTimeoutMs - innerTimeoutMs >= 30000, true); + assert.equal(Number(timeoutMessageMatch?.[1]) * 60 * 1000, innerTimeoutMs); +}); + test("setup command can offer Codex install and still points users to codex login", () => { const setup = read("commands/setup.md"); const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");