Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 0 additions & 3 deletions scripts/ralph/progress.txt

This file was deleted.

3 changes: 1 addition & 2 deletions src/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 25 additions & 8 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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!("═══════════════════════════════════════════════════");

Expand All @@ -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")
Expand Down Expand Up @@ -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 ---
Expand Down
46 changes: 46 additions & 0 deletions src/exit_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading