Skip to content

Commit bf4797a

Browse files
visigothclaude
andcommitted
fix: grep -c || echo "0" produces "0\n0" when no matches found
grep -c outputs "0" to stdout even when exiting with code 1 (no matches). The old pattern $(grep -c ... || echo "0") concatenated both outputs, producing "0\n0" which breaks arithmetic expressions. Fixed across all files: ralph_loop.sh, create_files.sh, response_analyzer.sh, ralph_enable_ci.sh. Uses var=$(grep -c ...) || var=0 pattern instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e13a3cb commit bf4797a

4 files changed

Lines changed: 110 additions & 41 deletions

File tree

create_files.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,10 @@ should_exit_gracefully() {
207207
# Fix #144: Only match valid markdown checkboxes, not date entries like [2026-01-29]
208208
# Valid patterns: "- [ ]" (uncompleted) and "- [x]" or "- [X]" (completed)
209209
if [[ -f "$RALPH_DIR/fix_plan.md" ]]; then
210-
local uncompleted_items=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/fix_plan.md" 2>/dev/null || echo "0")
211-
local completed_items=$(grep -cE "^[[:space:]]*- \[[xX]\]" "$RALPH_DIR/fix_plan.md" 2>/dev/null || echo "0")
210+
local uncompleted_items
211+
uncompleted_items=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/fix_plan.md" 2>/dev/null) || uncompleted_items=0
212+
local completed_items
213+
completed_items=$(grep -cE "^[[:space:]]*- \[[xX]\]" "$RALPH_DIR/fix_plan.md" 2>/dev/null) || completed_items=0
212214
local total_items=$((uncompleted_items + completed_items))
213215
214216
if [[ $total_items -gt 0 ]] && [[ $completed_items -eq $total_items ]]; then

lib/response_analyzer.sh

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ detect_questions() {
4040
# Count lines matching question patterns (case-insensitive)
4141
for pattern in "${QUESTION_PATTERNS[@]}"; do
4242
local matches
43-
matches=$(echo "$content" | grep -ciw "$pattern" 2>/dev/null || echo "0")
43+
matches=$(echo "$content" | grep -ciw "$pattern" 2>/dev/null) || matches=0
4444
matches=$(echo "$matches" | tr -d '[:space:]')
4545
matches=${matches:-0}
4646
question_count=$((question_count + matches))
@@ -524,8 +524,8 @@ analyze_response() {
524524
local implementation_count=0
525525
local error_count=0
526526

527-
test_command_count=$(grep -c -i "running tests\|npm test\|bats\|pytest\|jest" "$output_file" 2>/dev/null | head -1 || echo "0")
528-
implementation_count=$(grep -c -i "implementing\|creating\|writing\|adding\|function\|class" "$output_file" 2>/dev/null | head -1 || echo "0")
527+
test_command_count=$(grep -c -i "running tests\|npm test\|bats\|pytest\|jest" "$output_file" 2>/dev/null | head -1) || test_command_count=0
528+
implementation_count=$(grep -c -i "implementing\|creating\|writing\|adding\|function\|class" "$output_file" 2>/dev/null | head -1) || implementation_count=0
529529

530530
# Strip whitespace and ensure it's a number
531531
test_command_count=$(echo "$test_command_count" | tr -d '[:space:]')
@@ -726,17 +726,15 @@ update_exit_signals() {
726726
fi
727727
fi
728728

729-
# Update done_signals array
730-
if [[ "$has_completion_signal" == "true" ]]; then
731-
signals=$(echo "$signals" | jq ".done_signals += [$loop_number]")
732-
fi
733-
734-
# Update completion_indicators array (only when Claude explicitly signals exit)
735-
# Note: Previously used confidence >= 60, but JSON mode always has confidence >= 70
736-
# due to deterministic scoring (+50 for JSON format, +20 for result field).
737-
# This caused premature exits after 5 loops. Now we respect Claude's explicit intent.
729+
# Update done_signals and completion_indicators arrays
730+
# Both require EXIT_SIGNAL=true — STATUS: COMPLETE with EXIT_SIGNAL: false means
731+
# "I finished this task but there's more work to do", not "the project is done".
732+
# Without this gate, completing 2 individual tasks would trigger premature exit.
738733
local exit_signal=$(jq -r '.analysis.exit_signal // false' "$analysis_file")
739734
if [[ "$exit_signal" == "true" ]]; then
735+
if [[ "$has_completion_signal" == "true" ]]; then
736+
signals=$(echo "$signals" | jq ".done_signals += [$loop_number]")
737+
fi
740738
signals=$(echo "$signals" | jq ".completion_indicators += [$loop_number]")
741739
fi
742740

ralph_enable_ci.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,22 +326,22 @@ main() {
326326
beads)
327327
if beads_tasks=$(fetch_beads_tasks 2>/dev/null); then
328328
imported_tasks="$beads_tasks"
329-
TASKS_IMPORTED=$(echo "$imported_tasks" | grep -c '^\- \[' || echo "0")
329+
TASKS_IMPORTED=$(echo "$imported_tasks" | grep -c '^\- \[') || TASKS_IMPORTED=0
330330
output_message "Imported $TASKS_IMPORTED tasks from beads"
331331
fi
332332
;;
333333
github)
334334
if github_tasks=$(fetch_github_tasks "$GITHUB_LABEL" 2>/dev/null); then
335335
imported_tasks="$github_tasks"
336-
TASKS_IMPORTED=$(echo "$imported_tasks" | grep -c '^\- \[' || echo "0")
336+
TASKS_IMPORTED=$(echo "$imported_tasks" | grep -c '^\- \[') || TASKS_IMPORTED=0
337337
output_message "Imported $TASKS_IMPORTED tasks from GitHub"
338338
fi
339339
;;
340340
prd)
341341
if [[ -n "$PRD_FILE" && -f "$PRD_FILE" ]]; then
342342
if prd_tasks=$(extract_prd_tasks "$PRD_FILE" 2>/dev/null); then
343343
imported_tasks="$prd_tasks"
344-
TASKS_IMPORTED=$(echo "$imported_tasks" | grep -c '^\- \[' || echo "0")
344+
TASKS_IMPORTED=$(echo "$imported_tasks" | grep -c '^\- \[') || TASKS_IMPORTED=0
345345
output_message "Extracted $TASKS_IMPORTED tasks from PRD"
346346
fi
347347
else

ralph_loop.sh

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,8 @@ wait_for_reset() {
616616
local seconds=$((wait_time % 60))
617617

618618
printf "\r${YELLOW}Time until reset: %02d:%02d:%02d${NC}" $hours $minutes $seconds
619-
sleep 1
619+
sleep 1 &
620+
wait $! 2>/dev/null || return 130
620621
((wait_time--))
621622
done
622623
printf "\n"
@@ -711,8 +712,10 @@ should_exit_gracefully() {
711712
# Fix #144: Only match valid markdown checkboxes, not date entries like [2026-01-29]
712713
# Valid patterns: "- [ ]" (uncompleted) and "- [x]" or "- [X]" (completed)
713714
if [[ -f "$RALPH_DIR/fix_plan.md" ]]; then
714-
local uncompleted_items=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/fix_plan.md" 2>/dev/null || echo "0")
715-
local completed_items=$(grep -cE "^[[:space:]]*- \[[xX]\]" "$RALPH_DIR/fix_plan.md" 2>/dev/null || echo "0")
715+
local uncompleted_items
716+
uncompleted_items=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/fix_plan.md" 2>/dev/null) || uncompleted_items=0
717+
local completed_items
718+
completed_items=$(grep -cE "^[[:space:]]*- \[[xX]\]" "$RALPH_DIR/fix_plan.md" 2>/dev/null) || completed_items=0
716719
local total_items=$((uncompleted_items + completed_items))
717720

718721
if [[ $total_items -gt 0 ]] && [[ $completed_items -eq $total_items ]]; then
@@ -877,7 +880,8 @@ build_loop_context() {
877880
# Extract incomplete tasks from fix_plan.md
878881
# Bug #3 Fix: Support indented markdown checkboxes with [[:space:]]* pattern
879882
if [[ -f "$RALPH_DIR/fix_plan.md" ]]; then
880-
local incomplete_tasks=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/fix_plan.md" 2>/dev/null || echo "0")
883+
local incomplete_tasks
884+
incomplete_tasks=$(grep -cE "^[[:space:]]*- \[ \]" "$RALPH_DIR/fix_plan.md" 2>/dev/null) || incomplete_tasks=0
881885
context+="Remaining tasks: ${incomplete_tasks}. "
882886
fi
883887

@@ -1568,11 +1572,29 @@ execute_claude_code() {
15681572
portable_timeout ${timeout_seconds}s stdbuf -oL "${LIVE_CMD_ARGS[@]}" \
15691573
< /dev/null 2>"$stderr_file" | stdbuf -oL tee "$output_file" | stdbuf -oL jq --unbuffered -j "$jq_filter" 2>/dev/null | tee "$LIVE_LOG_FILE"
15701574

1571-
# Capture exit codes from pipeline
1572-
local -a pipe_status=("${PIPESTATUS[@]}")
1575+
# Run pipeline in a subshell so ^C can interrupt via our trap handler.
1576+
# Without this, bash waits for the full pipeline to finish even after SIGINT.
1577+
# The subshell runs in its own process group, so kill_child_processes() can
1578+
# terminate the entire pipeline tree (claude + tee + jq + tee).
1579+
(
1580+
portable_timeout ${timeout_seconds}s "${LIVE_CMD_ARGS[@]}" \
1581+
< /dev/null 2>"$stderr_file" | tee "$output_file" | jq --unbuffered -j "$jq_filter" 2>/dev/null | tee "$LIVE_LOG_FILE"
1582+
) &
1583+
local pipeline_pid=$!
1584+
1585+
# Wait for the pipeline subshell — `wait` is interruptible by signals,
1586+
# so ^C will trigger our trap handler which calls kill_child_processes()
1587+
wait $pipeline_pid 2>/dev/null
1588+
exit_code=$?
15731589

1574-
# Primary exit code is from Claude/timeout (first command in pipeline)
1575-
exit_code=${pipe_status[0]}
1590+
# Translate signal-killed exit codes
1591+
# 128+2=130 (SIGINT), 128+15=143 (SIGTERM)
1592+
if [[ $exit_code -ge 128 ]]; then
1593+
# If we received a signal, re-check if it was us who killed it
1594+
if [[ "${_SIGNAL_RECEIVED:-}" == "true" ]]; then
1595+
return 130 # Propagate as interrupted
1596+
fi
1597+
fi
15761598

15771599
# Log timeout events explicitly (exit code 124 from portable_timeout)
15781600
if [[ $exit_code -eq 124 ]]; then
@@ -1586,14 +1608,12 @@ execute_claude_code() {
15861608
rm -f "$stderr_file" 2>/dev/null
15871609
fi
15881610

1589-
# Check for tee failures (second command) - could break logging/session
1590-
if [[ ${pipe_status[1]} -ne 0 ]]; then
1591-
log_status "WARN" "Failed to write stream output to log file (exit code ${pipe_status[1]})"
1592-
fi
1593-
1594-
# Check for jq failures (third command) - warn but don't fail
1595-
if [[ ${pipe_status[2]} -ne 0 ]]; then
1596-
log_status "WARN" "jq filter had issues parsing some stream events (exit code ${pipe_status[2]})"
1611+
# Note: individual pipeline component exit codes (PIPESTATUS) are not
1612+
# available since the pipeline runs in a subshell for signal handling.
1613+
# The subshell exit code reflects the last pipeline command's status.
1614+
# Check output files to detect issues instead.
1615+
if [[ -f "$output_file" && ! -s "$output_file" ]]; then
1616+
log_status "WARN" "Stream output file is empty — pipeline may have failed"
15971617
fi
15981618

15991619
echo ""
@@ -1744,11 +1764,13 @@ EOF
17441764
fi
17451765
fi
17461766

1747-
sleep 10
1767+
# Use backgrounded sleep so ^C can interrupt via trap
1768+
sleep 10 &
1769+
wait $! 2>/dev/null || break
17481770
done
17491771

17501772
# Wait for the process to finish and get exit code
1751-
wait $claude_pid
1773+
wait $claude_pid 2>/dev/null
17521774
exit_code=$?
17531775
fi
17541776

@@ -1999,18 +2021,48 @@ cleanup() {
19992021
if [[ "$_CLEANUP_DONE" == "true" ]]; then return; fi
20002022
_CLEANUP_DONE=true
20012023

2024+
# Kill all child processes in our process group
2025+
# This handles: live mode pipeline (claude | tee | jq | tee),
2026+
# background mode (claude &), and any subprocesses they spawned
2027+
kill_child_processes
2028+
20022029
# Only record "interrupted" status for abnormal exits (non-zero exit code)
20032030
# Normal exit (code 0) preserves the status already written by the main loop
20042031
if [[ $loop_count -gt 0 && $trap_exit_code -ne 0 ]]; then
20052032
log_status "INFO" "Ralph loop interrupted. Cleaning up..."
20062033
reset_session "manual_interrupt"
20072034
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE" 2>/dev/null || echo "0")" "interrupted" "stopped"
20082035
fi
2009-
# No exit here — EXIT trap handles natural termination
2036+
2037+
# Force exit on signal — without this, bash continues the pipeline/loop
2038+
# after the trap handler returns
2039+
if [[ $trap_exit_code -ne 0 ]] || [[ "${_SIGNAL_RECEIVED:-}" == "true" ]]; then
2040+
exit "${trap_exit_code:-130}"
2041+
fi
2042+
}
2043+
2044+
# Kill all child processes recursively
2045+
# Uses pkill -P to walk the process tree from our PID downward
2046+
kill_child_processes() {
2047+
local my_pid=$$
2048+
2049+
# First try SIGTERM for graceful shutdown
2050+
pkill -TERM -P "$my_pid" 2>/dev/null || true
2051+
2052+
# Brief grace period for processes to exit
2053+
sleep 0.5
2054+
2055+
# Then SIGKILL anything still alive
2056+
pkill -KILL -P "$my_pid" 2>/dev/null || true
2057+
2058+
# Also kill any processes in our process group that aren't us
2059+
# This catches grandchildren that were reparented
2060+
kill -- -"$my_pid" 2>/dev/null || true
20102061
}
20112062

20122063
# Set up signal handlers
2013-
trap cleanup SIGINT SIGTERM
2064+
# Use a wrapper that sets a flag so cleanup() knows it was signal-triggered
2065+
trap '_SIGNAL_RECEIVED=true; cleanup' SIGINT SIGTERM
20142066

20152067
# Global variable for loop count (needed by cleanup function)
20162068
loop_count=0
@@ -2115,6 +2167,14 @@ main() {
21152167
log_status "INFO" "Starting main loop..."
21162168

21172169
while true; do
2170+
# Check for graceful stop request (ralph --stop)
2171+
if [[ -f "$RALPH_DIR/.stop" ]]; then
2172+
rm -f "$RALPH_DIR/.stop"
2173+
log_status "INFO" "⏹ Stop requested — exiting gracefully"
2174+
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE" 2>/dev/null || echo 0)" "stopped" "stopped" "user_stop"
2175+
break
2176+
fi
2177+
21182178
loop_count=$((loop_count + 1))
21192179

21202180
# Rotate log if it exceeds 10MB (Issue #18)
@@ -2244,8 +2304,9 @@ main() {
22442304
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "completed" "success"
22452305
send_notification "Ralph - Loop Complete" "Loop #$loop_count completed successfully"
22462306

2247-
# Brief pause between successful executions
2248-
sleep 5
2307+
# Brief pause between successful executions (interruptible)
2308+
sleep 5 &
2309+
wait $! 2>/dev/null || break
22492310
elif [ $exec_result -eq 3 ]; then
22502311
# Circuit breaker opened
22512312
reset_session "circuit_breaker_trip"
@@ -2289,7 +2350,8 @@ main() {
22892350
local minutes=$((wait_seconds / 60))
22902351
local seconds=$((wait_seconds % 60))
22912352
printf "\r${YELLOW}Time until retry: %02d:%02d${NC}" $minutes $seconds
2292-
sleep 1
2353+
sleep 1 &
2354+
wait $! 2>/dev/null || break
22932355
((wait_seconds--))
22942356
done
22952357
printf "\n"
@@ -2298,7 +2360,8 @@ main() {
22982360
update_status "$loop_count" "$(cat "$CALL_COUNT_FILE")" "failed" "error"
22992361
log_status "WARN" "Execution failed, waiting 30 seconds before retry..."
23002362
send_notification "Ralph - Error" "Claude Code execution failed. Check logs for details."
2301-
sleep 30
2363+
sleep 30 &
2364+
wait $! 2>/dev/null || break
23022365
fi
23032366

23042367
log_status "LOOP" "=== Completed Loop #$loop_count ==="
@@ -2489,6 +2552,12 @@ while [[ $# -gt 0 ]]; do
24892552
rollback_to_backup "${2:-}"
24902553
exit $?
24912554
;;
2555+
--stop)
2556+
# Create stop file to gracefully stop the loop after the current iteration
2557+
touch "$RALPH_DIR/.stop"
2558+
echo -e "\033[0;33m⏹ Stop requested — Ralph will exit after the current loop completes\033[0m"
2559+
exit 0
2560+
;;
24922561
*)
24932562
echo "Unknown option: $1"
24942563
show_help

0 commit comments

Comments
 (0)