diff --git a/skills/review-gate/scripts/review-gate b/skills/review-gate/scripts/review-gate index 84016bb..014200d 100755 --- a/skills/review-gate/scripts/review-gate +++ b/skills/review-gate/scripts/review-gate @@ -40,24 +40,44 @@ get_state_file() { echo "${STATE_DIR}/${session_id}.json" } +# Circuit breaker settings +MAX_BLOCK_ATTEMPTS="${REVIEW_MAX_ATTEMPTS:-3}" + # Commands cmd_check() { local session_id=$(get_session_id "${1:-}") local state_file=$(get_state_file "$session_id") + local attempts_file="${STATE_DIR}/${session_id}.attempts" - # Check for stop_hook_active to prevent infinite loops - # Claude Code passes JSON on stdin with this flag when we're in a continuation + # Check for stop_hook_active (continuation after previous block) local hook_input="" + local stop_hook_active="false" if [[ ! -t 0 ]]; then - # stdin is not a terminal, try to read hook input hook_input=$(cat 2>/dev/null || true) fi if [[ -n "$hook_input" ]]; then - local stop_hook_active=$(echo "$hook_input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") - if [[ "$stop_hook_active" == "true" ]]; then - # Already in a Stop hook continuation - allow exit to prevent infinite loop - echo "Stop hook continuation detected - allowing exit to prevent loop" + stop_hook_active=$(echo "$hook_input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false") + fi + + # Track block attempts for circuit breaker + local attempts=0 + if [[ -f "$attempts_file" ]]; then + attempts=$(cat "$attempts_file" 2>/dev/null || echo "0") + fi + + # Circuit breaker: if we've hit max attempts, allow exit + if [[ "$stop_hook_active" == "true" ]]; then + attempts=$((attempts + 1)) + echo "$attempts" > "$attempts_file" + + if [[ $attempts -ge $MAX_BLOCK_ATTEMPTS ]]; then + # Circuit breaker tripped + echo "⚠ CIRCUIT BREAKER: Max block attempts ($MAX_BLOCK_ATTEMPTS) reached" + echo "Review was not completed. Allowing exit to prevent infinite loop." + echo "Session: $session_id" + # Reset attempts for next time + rm -f "$attempts_file" exit 0 fi fi @@ -124,6 +144,7 @@ EOF cmd_approve() { local session_id=$(get_session_id "${1:-}") local state_file=$(get_state_file "$session_id") + local attempts_file="${STATE_DIR}/${session_id}.attempts" if [[ ! -f "$state_file" ]]; then echo "No review state for session: $session_id" @@ -140,6 +161,9 @@ cmd_approve() { } EOF + # Clear circuit breaker attempts + rm -f "$attempts_file" + echo "✓ Review approved for session: $session_id" } diff --git a/skills/review-gate/tests/test-review-gate.sh b/skills/review-gate/tests/test-review-gate.sh index 9c5d159..b51f949 100755 --- a/skills/review-gate/tests/test-review-gate.sh +++ b/skills/review-gate/tests/test-review-gate.sh @@ -290,6 +290,34 @@ output=$(run_gate check reset-session) exit_code=$? assert_exit_code "Check blocks after re-enable" "2" "$exit_code" +# --- Circuit Breaker --- +echo "" +echo "## Circuit Breaker (Prevents Infinite Loop)" + +# Enable a review +run_gate enable test-circuit > /dev/null + +# Simulate multiple stop_hook_active=true continuations +# Default MAX_BLOCK_ATTEMPTS is 3 +echo '{"stop_hook_active": true}' | run_gate check test-circuit > /dev/null 2>&1 || true # attempt 1 +echo '{"stop_hook_active": true}' | run_gate check test-circuit > /dev/null 2>&1 || true # attempt 2 + +# Third attempt should trip circuit breaker +output=$(echo '{"stop_hook_active": true}' | run_gate check test-circuit 2>&1) +exit_code=$? + +assert_exit_code "Circuit breaker trips after max attempts" "0" "$exit_code" +assert_output_contains "Circuit breaker message shown" "CIRCUIT BREAKER" "$output" + +# Attempts file should be cleaned up +if [[ ! -f "$TEST_STATE_DIR/test-circuit.attempts" ]]; then + echo -e "${GREEN}PASS${NC}: Attempts file cleaned after circuit breaker" + ((PASSED++)) +else + echo -e "${RED}FAIL${NC}: Attempts file should be cleaned after circuit breaker" + ((FAILED++)) +fi + # --- Clean Command --- echo "" echo "## Clean Command"