From e8a1e8c97ce4b53a3b4182d3f7bf6e03116d1b31 Mon Sep 17 00:00:00 2001 From: Praveen Mittal Date: Mon, 27 Jul 2026 16:08:48 +0200 Subject: [PATCH] fix: honor stop_hook_active in the stop-review-gate hook (#548) When the review gate is enabled, main() ran the stop-time Codex review and emitted {"decision":"block"} on any non-ok outcome (no output, timeout, failure, invalid JSON) without checking whether this invocation was already a forced retry. Claude Code re-invokes Stop hooks with stop_hook_active: true after a block decision, so a review that times out or fails would re-run and re-block on every retry, until the harness's forced-retry cap force-ends the turn -- worst in exactly the cases where the gate is least useful. Fix: skip the review and return cleanly when stop_hook_active is set, matching the fix the issue reporter suggested. The issue also flagged codex-companion.mjs's `config.stopReviewGate` branches as worth auditing for the same problem; traced them and confirmed they're only config get/set/report code, not the review-blocking logic itself (that lives solely in stop-review-gate-hook.mjs), so no second file needs the fix. Added a regression test that snapshots the fake Codex binary's appServerStarts counter before and after a stop_hook_active retry, proving the review subprocess is not spawned a second time and no "decision":"block" is emitted. Confirmed the test fails against the pre-fix code (git stash isolation) with the exact stale block decision the issue describes. Full suite: 92/92 passed, no regressions. --- .../codex/scripts/stop-review-gate-hook.mjs | 11 +++++ tests/runtime.test.mjs | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 2346bdcf..52a8f144 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -156,6 +156,17 @@ function main() { return; } + if (input.stop_hook_active) { + // Claude Code re-invokes the Stop hook with stop_hook_active: true when a + // prior invocation returned a "block" decision. Running the review again + // here would just block again -- every non-ok outcome (no output, timeout, + // failure, invalid JSON) re-blocks unconditionally -- until the harness's + // forced-retry cap kicks in and ends the turn anyway. Skip the re-run and + // let this retry succeed instead of repeating it up to the cap. + logNote(runningTaskNote); + return; + } + const setupNote = buildSetupNote(cwd); if (setupNote) { logNote(setupNote); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835..17e412e1 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1979,6 +1979,50 @@ 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 hook does not re-run the review and does not block on a forced retry (stop_hook_active)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(setup.status, 0, setup.stderr); + + // `appServerStarts` only increments when the fake Codex binary's + // `app-server` subcommand actually runs (i.e. a real review turn was + // spawned). Snapshot it now so we can prove below that the forced retry + // does not spawn a second review. + const appServerStartsBeforeRetry = fs.existsSync(fakeStatePath) + ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0 + : 0; + + const retried = run("node", [STOP_HOOK], { + cwd: repo, + env: buildEnv(binDir), + input: JSON.stringify({ + cwd: repo, + session_id: "sess-stop-review-retry", + stop_hook_active: true, + last_assistant_message: "I completed the refactor and updated the retry logic." + }) + }); + + assert.equal(retried.status, 0, retried.stderr); + assert.equal(retried.stdout.trim(), ""); + + const appServerStartsAfterRetry = fs.existsSync(fakeStatePath) + ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0 + : 0; + assert.equal(appServerStartsAfterRetry, appServerStartsBeforeRetry); +}); + test("stop hook logs running tasks to stderr without blocking when the review gate is disabled", () => { const repo = makeTempDir(); initGitRepo(repo);