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..5e392eb 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -706,14 +706,18 @@ 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. + // 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) + .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, was_interrupted); // Flush/close the full transcript file drop(full_transcript_file); @@ -774,7 +778,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 +804,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") @@ -1945,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/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 e6ece35..ced57bc 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 = exit_code::exit_code_for_run_or_interrupted( + &outcome, + ctx.strict_warnings, + is_interrupted(), + ); let _ = ctx.write_report(&outcome, &end_time);