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);