@@ -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)
20162068loop_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