Start Debugging

Fix: a coding agent loops forever re-running a check it can't satisfy

When a Stop hook, /goal condition, or CI-style gate demands a check the agent's environment can never pass, Claude Code keeps re-running it until the 8-consecutive-block cap (2.1.143+) ends the turn. Here is why it happens and the hook patterns that let the agent give up correctly.

Short answer: the agent is not stupid, your gate is. A Stop hook (or a /goal condition, or a “run the tests before you finish” rule) that blocks on a check the agent has no way to pass, such as integration tests that need a database the sandbox does not have, turns every attempt to finish into another instruction to try again. On Claude Code 2.1.143 and later the loop ends after 8 consecutive blocks with a warning. Before that it ran until you pressed Esc or the budget ran out. The fix is to make the gate tell “failed” apart from “cannot run here”, give it a memory so an identical failure is not retried forever, and put hard ceilings (--max-turns, --max-budget-usd, CLAUDE_CODE_STOP_HOOK_BLOCK_CAP) on anything headless.

Everything below targets Claude Code 2.1.270 (the current release on 2026-09-14). The hook fields it relies on are stop_hook_active and the 8-block cap (2.1.143+), plus hookSpecificOutput.additionalContext on Stop (2.1.163+).

What the loop looks like

The shape varies, but the transcript always reads the same way: the agent runs a command, it fails, the agent says it has fixed the problem, and then it runs the same command again. Three variants show up in bug reports and in my own sessions:

  1. A gate that cannot be satisfied. A Stop hook runs npm run test:integration, which needs Postgres. The agent’s sandbox has no Postgres. The hook blocks with “tests failed, fix them”, the agent edits code that was already correct, re-runs the tests, gets ECONNREFUSED 127.0.0.1:5432, tries to stop, and gets blocked again.
  2. An identical failing tool call. No hook involved: the model simply re-issues the same command. anthropics/claude-code#19699 shows Claude Code 2.1.12 on claude-opus-4-5-20251101 running the same ssh ... "make run-runner" seven times in a row after No rule to make target 'run-runner', without changing a character.
  3. A polling loop that feeds itself. anthropics/claude-code#90930 (Claude Code 2.1.236, opened 2026-08-31) describes an agent that waited for subagents by backgrounding sleep N; echo ok. Each finished sleep re-woke the agent, which scheduled another sleep. One /code-review run took 33 minutes of wall clock, the last 5 of which were pure timer noise, and needed a manual TaskStop.

The first variant is the one you cause, and the one you can fix completely, so most of this post is about it.

Why the agent can’t get out on its own

A Stop hook that returns {"decision": "block", "reason": "..."} does not just veto the stop. Per the hooks reference, the reason is delivered to Claude as its next instruction. So when your hook says “check.sh failed: DATABASE_URL is not set. Fix it before finishing.”, the model does exactly what a diligent engineer would do with that sentence from their lead: it looks for something to fix. There is nothing to fix in the code, so it reruns the check to see if the problem went away, or it “fixes” something harmless to have something to report.

Three properties combine to make this a loop instead of a single wasted turn:

A minimal repro

This is the smallest setup that reproduces variant 1. The check needs a database the environment does not have:

#!/bin/sh
# check.sh: integration check that needs a database the agent's sandbox does not have.
if [ -z "$DATABASE_URL" ]; then
  echo "integration: DATABASE_URL is not set, cannot reach Postgres" >&2
  exit 1
fi
echo "integration: ok"

The gate is the kind of Stop hook that gets copy-pasted into a lot of repositories:

#!/bin/sh
# .claude/hooks/require-check.sh: naive gate. Claude Code 2.1.x
out=$(cd "$CLAUDE_PROJECT_DIR" && ./check.sh 2>&1)
if [ $? -ne 0 ]; then
  jq -n --arg r "check.sh failed: $out. Fix it before finishing." '{decision:"block", reason:$r}'
fi
{
  "hooks": {
    "Stop": [
      { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-check.sh" } ] }
    ]
  }
}

I drove this hook with the exact Stop input Claude Code sends (session_id, hook_event_name: "Stop", stop_hook_active set to false on the first call and true after that), nine times in a row, with DATABASE_URL unset. The headless CLI on this machine was not logged in, so this is a hook harness, not a live model loop. That is enough here, because the hook’s output is the only thing that decides whether the turn can end:

Callstop_hook_activeNaive gate returns
1falsedecision: "block"
2 to 8truedecision: "block" every time
9truedecision: "block"

The gate never lets go. What ends the loop in a real session is not your hook but Claude Code itself: since 2.1.143 the changelog entry reads “the turn now ends with a warning after 8 consecutive blocks”, and the hooks guide documents it under “Stop hook hits the block cap”. So each time the agent tries to finish, you pay for up to eight extra model turns, each one re-running a check that cannot pass. On Claude Code older than 2.1.143 there is no cap at all.

Fix 1: make “cannot run here” a distinct outcome

The most effective fix lives in the check, not in the agent. Give “this environment cannot run me” its own exit code, and let the gate treat it as a pass-with-warning. 78 is EX_CONFIG from sysexits.h, which is as close to a convention as shell scripts have:

#!/bin/sh
# check.sh: integration check that needs a database the agent's sandbox does not have.
if [ -z "$DATABASE_URL" ]; then
  echo "integration: DATABASE_URL is not set, cannot reach Postgres" >&2
  exit 78   # EX_CONFIG: this environment cannot run the check
fi
echo "integration: ok"

In the hook, exit 78 allows the stop and uses systemMessage so the human sees why the gate stepped aside. systemMessage is shown to the user and does not become an instruction for Claude:

if [ $code -eq 78 ]; then
  jq -n --arg m "Stop gate skipped: $out" '{systemMessage:$m}'
  exit 0
fi

Re-running the same nine-call harness against this version, every call returned {"systemMessage": "Stop gate skipped: integration: DATABASE_URL is not set, cannot reach Postgres"} and no decision field, so the turn ends on the first attempt. The same idea applies to any gate: a missing API key, a missing Docker socket, or a test that requires a GPU should say so explicitly instead of failing like a broken assertion.

Fix 2: give the gate a memory

Environment detection does not catch everything. Sometimes the check really does fail on the code, but the agent has already shown it cannot fix it. The gate needs to remember what it saw last time. Here is the full hook, combining both fixes:

#!/usr/bin/env bash
# .claude/hooks/require-check.sh: gate that knows when to give up.
# Claude Code 2.1.143+ (stop_hook_active, 8-block cap), jq 1.7
set -u
input=$(cat)
session=$(jq -r .session_id <<<"$input")
state="${TMPDIR:-/tmp}/stop-gate-$session"

out=$(cd "$CLAUDE_PROJECT_DIR" && ./check.sh 2>&1); code=$?
if [ $code -eq 0 ]; then rm -f "$state"; exit 0; fi

# 1. The environment cannot run the check: no amount of editing will fix that.
if [ $code -eq 78 ]; then
  jq -n --arg m "Stop gate skipped: $out" '{systemMessage:$m}'
  exit 0
fi

# 2. Same failure as last time: the agent already had its chance.
sig=$(printf '%s' "$out" | shasum | cut -c1-12)
prev=$(cat "$state" 2>/dev/null || true)
echo "$sig" > "$state"
if [ "$sig" = "$prev" ]; then
  jq -n --arg m "Stop gate gave up: identical failure twice ($out)" '{systemMessage:$m}'
  exit 0
fi

jq -n --arg r "check.sh failed (exit $code): $out" '{decision:"block", reason:$r}'

With check.sh switched back to exit 1, the harness shows the intended behaviour: call 1 blocks with the failure text, call 2 sees the same fingerprint and lets the agent stop with “Stop gate gave up: identical failure twice”. If the agent’s edit changes the failure (a different assertion fails, a new error appears), the fingerprint changes and the gate blocks again, so real progress still gets another round.

Why not the one-liner from the docs? The hooks guide suggests exiting early when stop_hook_active is true. That works, but it is blunt: it allows exactly one continuation per stop attempt, regardless of whether the agent is converging. The fingerprint version allows as many rounds as the agent keeps producing different results, and stops as soon as it is going in circles. Both are far better than a gate with no exit.

Two details matter in the hook:

Fix 3: catch identical failing commands inside a turn

Variant 2 never reaches the Stop hook, because the model re-runs the command inside the same turn. PostToolUseFailure fires after every tool call that started and failed, and its input carries the command and an error string whose first line is Exit code N for Bash. You can count identical failures and inject a nudge through additionalContext:

#!/usr/bin/env bash
# .claude/hooks/repeat-failure.sh: PostToolUseFailure, matcher "Bash". Claude Code 2.1.143+
set -u
input=$(cat)
session=$(jq -r .session_id <<<"$input")
key=$(jq -r '[.tool_input.command, (.error | split("\n")[0:3] | join("\n"))] | @json' <<<"$input" | shasum | cut -c1-12)
log="${TMPDIR:-/tmp}/repeat-fail-$session"
echo "$key" >> "$log"
count=$(grep -c "^$key\$" "$log")
if [ "$count" -ge 3 ]; then
  jq -n --arg c "$(jq -r .tool_input.command <<<"$input")" --argjson n "$count" '{
    hookSpecificOutput: {
      hookEventName: "PostToolUseFailure",
      additionalContext: "You have run `\($c)` \($n) times and it failed the same way each time. Do not run it again unchanged. Either change the command or the code it depends on, or stop and report what blocks you."
    }
  }'
fi
{
  "hooks": {
    "PostToolUseFailure": [
      { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/repeat-failure.sh" } ] }
    ]
  }
}

Fed four identical failures of npm run test:integration with Error: connect ECONNREFUSED 127.0.0.1:5432, the hook stayed silent on calls 1 and 2 and returned the nudge on calls 3 and 4. Only the first three lines of the error go into the key, because Claude Code middle-truncates long output and can append lines like Command timed out after 2m 0s, which would otherwise make two identical failures look different.

Know the blind spots: per the reference, PostToolUseFailure does not fire for permission denials or for tool input that fails validation. A loop of denied calls needs a PreToolUse or PermissionDenied hook instead.

Fix 4: let a model-evaluated gate say “impossible”

If your gate is a prompt-based Stop hook or a /goal condition, a small fast model decides whether the agent may stop, and it needs an explicit way out. The prompt hook response schema has one: {"ok": false, "reason": "...", "impossible": true}. On Stop and SubagentStop, impossible: true lets the turn end instead of feeding the reason back. Say so in the prompt, or the evaluator will almost never use it:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Decide whether Claude may stop. Input: $ARGUMENTS\nReturn {\"ok\": true} if the integration tests passed. If they failed for a reason Claude cannot change from this environment (missing service, missing credentials, no network), return {\"ok\": false, \"reason\": \"...\", \"impossible\": true}. Otherwise return {\"ok\": false, \"reason\": \"...\"}."
          }
        ]
      }
    ]
  }
}

/goal is a wrapper around exactly this kind of session-scoped prompt hook. According to the /goal docs, the goal clears itself when the evaluator judges the condition impossible, and Claude Code stops the loop when Claude keeps answering the evaluator without tool use for several turns. The evaluator only reads the transcript, it does not run anything itself. So “all tests in test/integration pass” in a sandbox without a database is a goal that can only end by being judged impossible. Append a bound such as or stop after 20 turns to any goal you leave running.

Fix 5: hard ceilings for anything headless

Hooks fix the cause. Ceilings cap the damage when you did not anticipate the cause. For claude -p in CI or on a schedule, set both:

# Claude Code 2.1.217+ for the budget to also cover and stop subagents
claude -p "Fix the failing unit tests in src/billing" \
  --max-turns 30 \
  --max-budget-usd 3.00 \
  --output-format json

--max-turns exits with an error when reached. --max-budget-usd counts subagent spend and, from 2.1.217, stops running background subagents at the cap. In the Agent SDK the same limits are maxTurns / max_turns and maxBudgetUsd / max_budget_usd, and the result arrives as error_max_turns or error_max_budget_usd, which a scheduler should treat as “needs a human”, not “retry” (agent loop docs).

Claude Code also ships its own circuit breakers, which are worth knowing so you can tell them apart from your hooks:

Gotchas that recreate the loop

If you do only one thing, change the check’s exit code and the reason text. An agent that is told “this cannot run here” stops and says so, and that is the behaviour you wanted from the gate in the first place.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back