From 42973588b7c1aff8b70270d4b77174b20e22ea13 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Mon, 13 Apr 2026 16:43:07 +1000 Subject: [PATCH 1/4] fix: interrupted runs no longer report as passed Ctrl+C during a run would falsely report "Run PASSED" because all_passed only checked collected outcomes (which were all passing since remaining steps were skipped). Now checks the interrupted flag in both the outcome computation and the summary display. Also removes hardcoded --no-session-persistence from the Claude Code adapter, allowing it to be configured via agent_args instead. --- CHANGELOG.md | 8 +++++++- scripts/ralph/progress.txt | 3 --- src/claude_code.rs | 3 +-- src/executor.rs | 34 ++++++++++++++++++++++++++-------- src/main.rs | 6 +++++- 5 files changed, 39 insertions(+), 15 deletions(-) delete mode 100644 scripts/ralph/progress.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 89eb270..42e31e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.4.1] - 2026-04-09 +## [0.4.1] - 2026-04-13 ### Fixed - Config commands now execute in declaration order instead of alphabetical key order (#39) +- Interrupted runs (Ctrl+C) now correctly report as "INTERRUPTED" instead of falsely reporting "PASSED" +- Exit code is now `5` (interrupted) instead of `0` (passed) when a run is interrupted + +### Changed + +- Removed hardcoded `--no-session-persistence` from the Claude Code adapter; this flag can still be passed via `agent_args` in config ## [0.4.0] - 2026-04-08 diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt deleted file mode 100644 index ffde1ca..0000000 --- a/scripts/ralph/progress.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Ralph Progress Log -Started: Fri 27 Mar 2026 17:38:40 AEST ---- diff --git a/src/claude_code.rs b/src/claude_code.rs index 884a5df..bfa3a0f 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -145,8 +145,7 @@ impl ClaudeCodeAdapter { .arg("--input-format") .arg("stream-json") .arg("--output-format") - .arg("stream-json") - .arg("--no-session-persistence"); + .arg("stream-json"); for arg in &self.agent_args { cmd.arg(arg); diff --git a/src/executor.rs b/src/executor.rs index 2aaeea5..23da326 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -706,14 +706,21 @@ pub fn execute_steps( // Setup steps don't count toward pass/fail — only test steps determine the verdict. // A failed setup step aborts the run (handled above), so if we reach here, // all setup steps succeeded. - let all_passed = outcomes - .iter() - .filter(|o| !o.setup) - .all(|o| o.result.is_pass()); + // If the run was interrupted, force all_passed to false — partial runs are not passing. + let all_passed = !interrupted.load(Ordering::Relaxed) + && outcomes + .iter() + .filter(|o| !o.setup) + .all(|o| o.result.is_pass()); let total_duration = run_start.elapsed(); // Print final run status (after teardown) - print_run_summary(&outcomes, total_duration, total_steps); + print_run_summary( + &outcomes, + total_duration, + total_steps, + interrupted.load(Ordering::Relaxed), + ); // Flush/close the full transcript file drop(full_transcript_file); @@ -774,7 +781,12 @@ fn print_step_result(result: &StepResult, duration: Duration) { } /// Print a summary of the full run after all steps have completed. -fn print_run_summary(outcomes: &[StepOutcome], total_duration: Duration, total_steps: usize) { +fn print_run_summary( + outcomes: &[StepOutcome], + total_duration: Duration, + total_steps: usize, + was_interrupted: bool, +) { println!(); println!("═══════════════════════════════════════════════════"); @@ -795,8 +807,14 @@ fn print_run_summary(outcomes: &[StepOutcome], total_duration: Duration, total_s .count(); let skipped = total_steps - completed - setup_count; - let all_passed = test_outcomes.iter().all(|o| o.result.is_pass()); - let status = if all_passed { "PASSED" } else { "FAILED" }; + let all_passed = !was_interrupted && test_outcomes.iter().all(|o| o.result.is_pass()); + let status = if was_interrupted { + "INTERRUPTED" + } else if all_passed { + "PASSED" + } else { + "FAILED" + }; let setup_part = if setup_count > 0 { format!(", {setup_count} setup") diff --git a/src/main.rs b/src/main.rs index e6ece35..313be03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -563,7 +563,11 @@ fn run_test_with_artifacts( // Phase 15: Write report let end_time = chrono::Utc::now(); - let exit_code = exit_code::exit_code_for_run_strict(&outcome, ctx.strict_warnings); + let exit_code = if INTERRUPTED.load(Ordering::Relaxed) { + EXIT_INTERRUPTED + } else { + exit_code::exit_code_for_run_strict(&outcome, ctx.strict_warnings) + }; let _ = ctx.write_report(&outcome, &end_time); From 7642d13fd1b2de81d67c98038a881a7969c8a8da Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Tue, 14 Apr 2026 10:22:36 +1000 Subject: [PATCH 2/4] fix: address PR review feedback - Load interrupted flag once into local to avoid TOCTOU race - Use is_interrupted() helper in main.rs for consistency - Assert outcome.all_passed == false in interrupt test --- src/executor.rs | 13 ++++++------- src/main.rs | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/executor.rs b/src/executor.rs index 23da326..5e392eb 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -707,7 +707,9 @@ pub fn execute_steps( // A failed setup step aborts the run (handled above), so if we reach here, // all setup steps succeeded. // If the run was interrupted, force all_passed to false — partial runs are not passing. - let all_passed = !interrupted.load(Ordering::Relaxed) + // Load once to avoid TOCTOU race between all_passed and print_run_summary. + let was_interrupted = interrupted.load(Ordering::Relaxed); + let all_passed = !was_interrupted && outcomes .iter() .filter(|o| !o.setup) @@ -715,12 +717,7 @@ pub fn execute_steps( let total_duration = run_start.elapsed(); // Print final run status (after teardown) - print_run_summary( - &outcomes, - total_duration, - total_steps, - interrupted.load(Ordering::Relaxed), - ); + print_run_summary(&outcomes, total_duration, total_steps, was_interrupted); // Flush/close the full transcript file drop(full_transcript_file); @@ -1963,6 +1960,8 @@ mod tests { // No steps should have executed assert_eq!(outcome.steps.len(), 0); + // Interrupted runs must not report as passed + assert!(!outcome.all_passed); } // --- Setup step tests --- diff --git a/src/main.rs b/src/main.rs index 313be03..0fe3231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -563,7 +563,7 @@ fn run_test_with_artifacts( // Phase 15: Write report let end_time = chrono::Utc::now(); - let exit_code = if INTERRUPTED.load(Ordering::Relaxed) { + let exit_code = if is_interrupted() { EXIT_INTERRUPTED } else { exit_code::exit_code_for_run_strict(&outcome, ctx.strict_warnings) From de3be3cae515d0a1dd4fd28928d1708b5fcb1b93 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Tue, 14 Apr 2026 10:40:05 +1000 Subject: [PATCH 3/4] refactor: extract exit_code_for_run_or_interrupted helper with tests Moves the interrupted-exit-code decision into a pure, testable helper in exit_code.rs. Adds tests covering both interrupted and non-interrupted paths. --- src/exit_code.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 ++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/exit_code.rs b/src/exit_code.rs index 843baaa..5735015 100644 --- a/src/exit_code.rs +++ b/src/exit_code.rs @@ -91,6 +91,22 @@ pub fn exit_code_for_run_strict(outcome: &RunOutcome, strict_warnings: bool) -> } } +/// Compute the exit code for a run, accounting for interruption. +/// +/// If the run was interrupted, returns EXIT_INTERRUPTED regardless of step outcomes. +/// Otherwise delegates to `exit_code_for_run_strict`. +pub fn exit_code_for_run_or_interrupted( + outcome: &RunOutcome, + strict_warnings: bool, + was_interrupted: bool, +) -> i32 { + if was_interrupted { + EXIT_INTERRUPTED + } else { + exit_code_for_run_strict(outcome, strict_warnings) + } +} + /// Compute aggregate exit code from multiple run results. /// /// Returns the highest (most severe) exit code among all runs. @@ -246,6 +262,36 @@ mod tests { assert_eq!(exit_code_for_run_strict(&outcome, false), EXIT_OK); } + #[test] + fn exit_code_interrupted_overrides_passing() { + let outcome = make_outcome(vec![ + StepResult::Verdict(StepVerdict::Ok), + StepResult::Verdict(StepVerdict::Ok), + ]); + assert_eq!( + exit_code_for_run_or_interrupted(&outcome, false, true), + EXIT_INTERRUPTED + ); + } + + #[test] + fn exit_code_not_interrupted_delegates_to_strict() { + let outcome = make_outcome(vec![ + StepResult::Verdict(StepVerdict::Ok), + StepResult::Verdict(StepVerdict::Warn("slow".to_string())), + ]); + // Not interrupted, strict=false: warn is passing + assert_eq!( + exit_code_for_run_or_interrupted(&outcome, false, false), + EXIT_OK + ); + // Not interrupted, strict=true: warn is failing + assert_eq!( + exit_code_for_run_or_interrupted(&outcome, true, false), + EXIT_STEP_ERROR + ); + } + #[test] fn describe_known_codes() { assert_eq!(describe_exit_code(EXIT_OK), "all tests passed"); diff --git a/src/main.rs b/src/main.rs index 0fe3231..b84497d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -563,11 +563,8 @@ fn run_test_with_artifacts( // Phase 15: Write report let end_time = chrono::Utc::now(); - let exit_code = if is_interrupted() { - EXIT_INTERRUPTED - } else { - exit_code::exit_code_for_run_strict(&outcome, ctx.strict_warnings) - }; + let exit_code = + exit_code::exit_code_for_run_or_interrupted(&outcome, ctx.strict_warnings, is_interrupted()); let _ = ctx.write_report(&outcome, &end_time); From 61df5a978b465fce88b12ad450c002f36b2198e8 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Tue, 14 Apr 2026 10:51:23 +1000 Subject: [PATCH 4/4] style: cargo fmt --- src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index b84497d..ced57bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -563,8 +563,11 @@ fn run_test_with_artifacts( // Phase 15: Write report let end_time = chrono::Utc::now(); - let exit_code = - exit_code::exit_code_for_run_or_interrupted(&outcome, ctx.strict_warnings, is_interrupted()); + let exit_code = exit_code::exit_code_for_run_or_interrupted( + &outcome, + ctx.strict_warnings, + is_interrupted(), + ); let _ = ctx.write_report(&outcome, &end_time);