Skip to main content

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}