ralph_workflow/interrupt.rs
1//! Interrupt signal handling for graceful checkpoint save.
2//!
3//! This module provides signal handling for the Ralph pipeline, ensuring
4//! clean shutdown when the user interrupts with Ctrl+C.
5//!
6//! When an interrupt is received, the handler will:
7//! 1. Save a checkpoint with the `Interrupted` phase
8//! 2. Clean up temporary files
9//! 3. Exit gracefully
10
11use std::sync::Mutex;
12
13/// Global interrupt context for checkpoint saving on interrupt.
14///
15/// This is set during pipeline initialization and used by the interrupt
16/// handler to save a checkpoint when the user presses Ctrl+C.
17static INTERRUPT_CONTEXT: Mutex<Option<InterruptContext>> = Mutex::new(None);
18
19/// Context needed to save a checkpoint when interrupted.
20///
21/// This structure holds references to all the state needed to create
22/// a checkpoint when the user interrupts the pipeline with Ctrl+C.
23pub struct InterruptContext {
24 /// Current pipeline phase
25 pub phase: crate::checkpoint::PipelinePhase,
26 /// Current iteration number
27 pub iteration: u32,
28 /// Total iterations configured
29 pub total_iterations: u32,
30 /// Current reviewer pass number
31 pub reviewer_pass: u32,
32 /// Total reviewer passes configured
33 pub total_reviewer_passes: u32,
34 /// Run context for tracking execution lineage
35 pub run_context: crate::checkpoint::RunContext,
36 /// Execution history tracking
37 pub execution_history: crate::checkpoint::ExecutionHistory,
38 /// Prompt history for deterministic resume
39 pub prompt_history: std::collections::HashMap<String, String>,
40}
41
42/// Set the global interrupt context.
43///
44/// This function should be called during pipeline initialization to
45/// provide the interrupt handler with the context needed to save
46/// a checkpoint when interrupted.
47///
48/// # Arguments
49///
50/// * `context` - The interrupt context to store
51///
52/// # Note
53///
54/// This function is typically called at the start of `run_pipeline()`
55/// to ensure the interrupt handler has the most up-to-date context.
56pub fn set_interrupt_context(context: InterruptContext) {
57 let mut ctx = INTERRUPT_CONTEXT.lock().unwrap_or_else(|poison| {
58 // If mutex is poisoned, recover the guard and clear the state
59 poison.into_inner()
60 });
61 *ctx = Some(context);
62}
63
64/// Clear the global interrupt context.
65///
66/// This should be called when the pipeline completes successfully
67/// to prevent saving an interrupt checkpoint after normal completion.
68pub fn clear_interrupt_context() {
69 let mut ctx = INTERRUPT_CONTEXT.lock().unwrap_or_else(|poison| {
70 // If mutex is poisoned, recover the guard and clear the state
71 poison.into_inner()
72 });
73 *ctx = None;
74}
75
76/// Set up the interrupt handler for graceful shutdown with checkpoint saving.
77///
78/// This function registers a SIGINT handler that will:
79/// 1. Save a checkpoint with the current pipeline state
80/// 2. Clean up generated files
81/// 3. Exit gracefully
82///
83/// Call this early in main() after initializing the pipeline context.
84pub fn setup_interrupt_handler() {
85 ctrlc::set_handler(|| {
86 eprintln!("\nā Interrupt received! Saving checkpoint...");
87
88 // Try to save checkpoint if context is available
89 let ctx = INTERRUPT_CONTEXT.lock().unwrap_or_else(|poison| {
90 // If mutex is poisoned, recover the guard (context may be inconsistent)
91 poison.into_inner()
92 });
93 if let Some(ref context) = *ctx {
94 if let Err(e) = save_interrupt_checkpoint(context) {
95 eprintln!("Warning: Failed to save checkpoint: {}", e);
96 } else {
97 eprintln!("ā Checkpoint saved. Resume with: ralph --resume");
98 }
99 }
100 drop(ctx); // Release lock before cleanup
101
102 eprintln!("Cleaning up...");
103 crate::git_helpers::cleanup_agent_phase_silent();
104 std::process::exit(130); // Standard exit code for SIGINT
105 })
106 .ok(); // Ignore errors if handler can't be set
107}
108
109/// Save a checkpoint when the pipeline is interrupted.
110///
111/// This function creates a checkpoint with the `Interrupted` phase,
112/// which has the highest phase rank so resuming will run the full pipeline.
113///
114/// The original phase information is preserved for display purposes
115/// by updating the checkpoint with current progress information.
116///
117/// # Arguments
118///
119/// * `context` - The interrupt context containing the current pipeline state
120fn save_interrupt_checkpoint(context: &InterruptContext) -> anyhow::Result<()> {
121 use crate::checkpoint::{save_checkpoint, PipelinePhase};
122
123 // Read checkpoint from file if exists, update it with Interrupted phase
124 if let Ok(Some(mut checkpoint)) = crate::checkpoint::load_checkpoint() {
125 // Store the original phase for reference (it will be overwritten below)
126 let _original_phase = context.phase;
127
128 // Update existing checkpoint to Interrupted phase with current progress
129 checkpoint.phase = PipelinePhase::Interrupted;
130 checkpoint.iteration = context.iteration;
131 checkpoint.total_iterations = context.total_iterations;
132 checkpoint.reviewer_pass = context.reviewer_pass;
133 checkpoint.total_reviewer_passes = context.total_reviewer_passes;
134 checkpoint.actual_developer_runs = context.run_context.actual_developer_runs;
135 checkpoint.actual_reviewer_runs = context.run_context.actual_reviewer_runs;
136 checkpoint.execution_history = Some(context.execution_history.clone());
137 checkpoint.prompt_history = Some(context.prompt_history.clone());
138 save_checkpoint(&checkpoint)?;
139 } else {
140 // No checkpoint exists yet - this is early interruption
141 // We can't save a full checkpoint without config/registry access
142 // Just save a minimal checkpoint marker
143 eprintln!("Note: Interrupted before first checkpoint. Minimal state saved.");
144 }
145
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn test_interrupt_context_creation() {
155 let context = InterruptContext {
156 phase: crate::checkpoint::PipelinePhase::Development,
157 iteration: 2,
158 total_iterations: 5,
159 reviewer_pass: 0,
160 total_reviewer_passes: 2,
161 run_context: crate::checkpoint::RunContext::new(),
162 execution_history: crate::checkpoint::ExecutionHistory::new(),
163 prompt_history: std::collections::HashMap::new(),
164 };
165
166 assert_eq!(context.phase, crate::checkpoint::PipelinePhase::Development);
167 assert_eq!(context.iteration, 2);
168 assert_eq!(context.total_iterations, 5);
169 }
170
171 #[test]
172 fn test_set_and_clear_interrupt_context() {
173 let context = InterruptContext {
174 phase: crate::checkpoint::PipelinePhase::Planning,
175 iteration: 1,
176 total_iterations: 3,
177 reviewer_pass: 0,
178 total_reviewer_passes: 1,
179 run_context: crate::checkpoint::RunContext::new(),
180 execution_history: crate::checkpoint::ExecutionHistory::new(),
181 prompt_history: std::collections::HashMap::new(),
182 };
183
184 set_interrupt_context(context);
185 {
186 let ctx = INTERRUPT_CONTEXT.lock().unwrap();
187 assert!(ctx.is_some());
188 assert_eq!(
189 ctx.as_ref().unwrap().phase,
190 crate::checkpoint::PipelinePhase::Planning
191 );
192 }
193
194 clear_interrupt_context();
195 let ctx = INTERRUPT_CONTEXT.lock().unwrap();
196 assert!(ctx.is_none());
197 }
198}