Skip to main content

ralph_workflow/checkpoint/
restore.rs

1//! State restoration from checkpoints.
2//!
3//! This module provides functionality to restore pipeline state from a checkpoint,
4//! including CLI arguments and configuration overrides.
5
6use crate::checkpoint::execution_history::ExecutionHistory;
7use crate::checkpoint::state::{PipelineCheckpoint, PipelinePhase, RebaseState};
8use crate::config::Config;
9
10/// Rich context about a resumed session for use in agent prompts.
11///
12/// This struct contains information that helps AI agents understand where
13/// they are in the pipeline when resuming from a checkpoint, enabling them
14/// to provide more contextual and appropriate responses.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResumeContext {
17    /// The phase being resumed from
18    pub phase: PipelinePhase,
19    /// Current iteration number (for development)
20    pub iteration: u32,
21    /// Total iterations
22    pub total_iterations: u32,
23    /// Current reviewer pass
24    pub reviewer_pass: u32,
25    /// Total reviewer passes
26    pub total_reviewer_passes: u32,
27    /// Number of times this session has been resumed
28    pub resume_count: u32,
29    /// Rebase state if applicable
30    pub rebase_state: RebaseState,
31    /// Run ID for tracing
32    pub run_id: String,
33    /// Captured prompts from the original run for deterministic replay
34    pub prompt_history: Option<std::collections::HashMap<String, String>>,
35    /// Execution history from the checkpoint (if available)
36    pub execution_history: Option<ExecutionHistory>,
37}
38
39impl ResumeContext {
40    /// Display name for the current phase.
41    pub fn phase_name(&self) -> String {
42        match self.phase {
43            PipelinePhase::Rebase => "Rebase".to_string(),
44            PipelinePhase::Planning => "Planning".to_string(),
45            PipelinePhase::Development => format!(
46                "Development iteration {}/{}",
47                self.iteration + 1,
48                self.total_iterations
49            ),
50            PipelinePhase::Review => format!(
51                "Review (pass {}/{})",
52                self.reviewer_pass + 1,
53                self.total_reviewer_passes
54            ),
55            PipelinePhase::Fix => "Fix".to_string(),
56            PipelinePhase::ReviewAgain => format!(
57                "Verification review {}/{}",
58                self.reviewer_pass + 1,
59                self.total_reviewer_passes
60            ),
61            PipelinePhase::CommitMessage => "Commit Message Generation".to_string(),
62            PipelinePhase::FinalValidation => "Final Validation".to_string(),
63            PipelinePhase::Complete => "Complete".to_string(),
64            PipelinePhase::PreRebase => "Pre-Rebase".to_string(),
65            PipelinePhase::PreRebaseConflict => "Pre-Rebase Conflict".to_string(),
66            PipelinePhase::PostRebase => "Post-Rebase".to_string(),
67            PipelinePhase::PostRebaseConflict => "Post-Rebase Conflict".to_string(),
68            PipelinePhase::Interrupted => "Interrupted".to_string(),
69        }
70    }
71}
72
73impl PipelineCheckpoint {
74    /// Extract rich resume context from this checkpoint.
75    ///
76    /// This method creates a `ResumeContext` containing all the information
77    /// needed to generate informative prompts for agents when resuming.
78    pub fn resume_context(&self) -> ResumeContext {
79        ResumeContext {
80            phase: self.phase,
81            iteration: self.iteration,
82            total_iterations: self.total_iterations,
83            reviewer_pass: self.reviewer_pass,
84            total_reviewer_passes: self.total_reviewer_passes,
85            resume_count: self.resume_count,
86            rebase_state: self.rebase_state.clone(),
87            run_id: self.run_id.clone(),
88            prompt_history: self.prompt_history.clone(),
89            execution_history: self.execution_history.clone(),
90        }
91    }
92}
93
94/// Apply checkpoint CLI args to a config.
95///
96/// This function modifies the config to use values from the checkpoint's
97/// CLI args snapshot, ensuring the resumed pipeline uses the same settings
98/// as the original run.
99///
100/// # Arguments
101///
102/// * `config` - The config to modify
103/// * `checkpoint` - The checkpoint to restore from
104///
105/// # Returns
106///
107/// The modified config with checkpoint values applied.
108pub fn apply_checkpoint_to_config(config: &mut Config, checkpoint: &PipelineCheckpoint) {
109    let cli_args = &checkpoint.cli_args;
110
111    // Always restore developer_iters and reviewer_reviews from checkpoint
112    // to ensure exact state restoration, even if zero
113    config.developer_iters = cli_args.developer_iters;
114    config.reviewer_reviews = cli_args.reviewer_reviews;
115
116    if !cli_args.commit_msg.is_empty() {
117        config.commit_msg = cli_args.commit_msg.clone();
118    }
119
120    // Note: review_depth is stored as a string in the checkpoint
121    // but as an enum in Config. For now, we don't override it.
122    // This could be enhanced to parse the string back to an enum.
123
124    // Apply model overrides if they exist in the checkpoint
125    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
126        config.developer_model = Some(model.clone());
127    }
128    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
129        config.reviewer_model = Some(model.clone());
130    }
131
132    // Apply provider overrides if they exist in the checkpoint
133    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
134        config.developer_provider = Some(provider.clone());
135    }
136    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
137        config.reviewer_provider = Some(provider.clone());
138    }
139
140    // Apply context levels if they exist in the checkpoint
141    config.developer_context = checkpoint.developer_agent_config.context_level;
142    config.reviewer_context = checkpoint.reviewer_agent_config.context_level;
143
144    // Apply git identity if it exists in the checkpoint
145    if let Some(ref name) = checkpoint.git_user_name {
146        config.git_user_name = Some(name.clone());
147    }
148    if let Some(ref email) = checkpoint.git_user_email {
149        config.git_user_email = Some(email.clone());
150    }
151
152    // Always restore isolation_mode from checkpoint for exact state restoration
153    config.isolation_mode = cli_args.isolation_mode;
154
155    // Apply verbosity level from checkpoint
156    config.verbosity = crate::config::types::Verbosity::from(cli_args.verbosity);
157
158    // Apply show_streaming_metrics from checkpoint
159    config.show_streaming_metrics = cli_args.show_streaming_metrics;
160
161    // Apply reviewer_json_parser from checkpoint if it exists
162    if let Some(ref parser) = cli_args.reviewer_json_parser {
163        config.reviewer_json_parser = Some(parser.clone());
164    }
165}
166
167/// Restore environment variables from a checkpoint.
168///
169/// This function restores environment variables that were captured in the
170/// checkpoint's environment snapshot, ensuring the resumed pipeline uses
171/// the same environment configuration as the original run.
172///
173/// # Arguments
174///
175/// * `checkpoint` - The checkpoint to restore environment from
176///
177/// # Returns
178///
179/// The number of environment variables restored.
180pub fn restore_environment_from_checkpoint(checkpoint: &PipelineCheckpoint) -> usize {
181    let Some(ref env_snap) = checkpoint.env_snapshot else {
182        return 0;
183    };
184
185    let mut restored = 0;
186
187    // Restore RALPH_* variables
188    for (key, value) in &env_snap.ralph_vars {
189        std::env::set_var(key, value);
190        restored += 1;
191    }
192
193    // Restore other relevant variables
194    for (key, value) in &env_snap.other_vars {
195        std::env::set_var(key, value);
196        restored += 1;
197    }
198
199    restored
200}
201
202/// Calculate the starting iteration for development phase resume.
203///
204/// When resuming from a checkpoint in the development phase, this determines
205/// which iteration to start from based on the checkpoint state.
206///
207/// # Arguments
208///
209/// * `checkpoint` - The checkpoint to calculate from
210/// * `max_iterations` - Maximum iterations configured
211///
212/// # Returns
213///
214/// The iteration number to start from (1-indexed).
215pub fn calculate_start_iteration(checkpoint: &PipelineCheckpoint, max_iterations: u32) -> u32 {
216    match checkpoint.phase {
217        PipelinePhase::Planning | PipelinePhase::Development => {
218            checkpoint.iteration.clamp(1, max_iterations)
219        }
220        // For later phases, development is already complete
221        _ => max_iterations,
222    }
223}
224
225/// Calculate the starting reviewer pass for review phase resume.
226///
227/// When resuming from a checkpoint in the review phase, this determines
228/// which pass to start from based on the checkpoint state.
229///
230/// # Arguments
231///
232/// * `checkpoint` - The checkpoint to calculate from
233/// * `max_passes` - Maximum review passes configured
234///
235/// # Returns
236///
237/// The pass number to start from (1-indexed).
238pub fn calculate_start_reviewer_pass(checkpoint: &PipelineCheckpoint, max_passes: u32) -> u32 {
239    match checkpoint.phase {
240        PipelinePhase::Review | PipelinePhase::Fix | PipelinePhase::ReviewAgain => {
241            checkpoint.reviewer_pass.clamp(1, max_passes.max(1))
242        }
243        // For earlier phases, start from the beginning
244        PipelinePhase::Planning
245        | PipelinePhase::Development
246        | PipelinePhase::PreRebase
247        | PipelinePhase::PreRebaseConflict => 1,
248        // For later phases, review is already complete
249        _ => max_passes,
250    }
251}
252
253/// Determine if a phase should be skipped based on checkpoint.
254///
255/// Returns true if the checkpoint indicates this phase has already been completed.
256pub fn should_skip_phase(phase: PipelinePhase, checkpoint: &PipelineCheckpoint) -> bool {
257    phase_rank(phase) < phase_rank(checkpoint.phase)
258}
259
260/// Get the rank (position) of a phase in the pipeline.
261///
262/// Lower values indicate earlier phases in the pipeline.
263fn phase_rank(phase: PipelinePhase) -> u32 {
264    match phase {
265        PipelinePhase::Planning => 0,
266        PipelinePhase::Development => 1,
267        PipelinePhase::Review => 2,
268        PipelinePhase::CommitMessage => 3,
269        PipelinePhase::FinalValidation => 4,
270        PipelinePhase::Complete => 5,
271        PipelinePhase::Interrupted => 6,
272        // Fix and other intermediate phases map to Review
273        PipelinePhase::Fix
274        | PipelinePhase::ReviewAgain
275        | PipelinePhase::PreRebase
276        | PipelinePhase::PreRebaseConflict => 2,
277        // Rebase phases map between Development and Review
278        PipelinePhase::Rebase | PipelinePhase::PostRebase | PipelinePhase::PostRebaseConflict => 2,
279    }
280}
281///
282/// # Arguments
283///
284/// Restored context from a checkpoint.
285///
286/// Contains all the information needed to resume a pipeline from a checkpoint.
287#[cfg(test)]
288#[derive(Debug, Clone)]
289pub struct RestoredContext {
290    /// The phase to resume from.
291    pub phase: PipelinePhase,
292    /// The iteration to resume from (for development phase).
293    pub resume_iteration: u32,
294    /// The total number of iterations configured.
295    pub total_iterations: u32,
296    /// The reviewer pass to resume from.
297    pub resume_reviewer_pass: u32,
298    /// The total number of reviewer passes configured.
299    pub total_reviewer_passes: u32,
300    /// Developer agent name from checkpoint.
301    pub developer_agent: String,
302    /// Reviewer agent name from checkpoint.
303    pub reviewer_agent: String,
304    /// CLI arguments snapshot (if available).
305    pub cli_args: Option<crate::checkpoint::state::CliArgsSnapshot>,
306}
307
308#[cfg(test)]
309impl RestoredContext {
310    /// Create a restored context from a checkpoint.
311    pub fn from_checkpoint(checkpoint: &PipelineCheckpoint) -> Self {
312        // Determine if CLI args are meaningful (non-default values)
313        let cli_args = if checkpoint.cli_args.developer_iters > 0
314            || checkpoint.cli_args.reviewer_reviews > 0
315            || !checkpoint.cli_args.commit_msg.is_empty()
316        {
317            Some(checkpoint.cli_args.clone())
318        } else {
319            None
320        };
321
322        Self {
323            phase: checkpoint.phase,
324            resume_iteration: checkpoint.iteration,
325            total_iterations: checkpoint.total_iterations,
326            resume_reviewer_pass: checkpoint.reviewer_pass,
327            total_reviewer_passes: checkpoint.total_reviewer_passes,
328            developer_agent: checkpoint.developer_agent.clone(),
329            reviewer_agent: checkpoint.reviewer_agent.clone(),
330            cli_args,
331        }
332    }
333
334    /// Check if we should use checkpoint values for iteration counts.
335    ///
336    /// Returns true if the checkpoint has meaningful CLI args that should
337    /// override the current configuration.
338    pub fn should_use_checkpoint_iterations(&self) -> bool {
339        self.cli_args
340            .as_ref()
341            .is_some_and(|args| args.developer_iters > 0)
342    }
343
344    /// Check if we should use checkpoint values for reviewer counts.
345    pub fn should_use_checkpoint_reviewer_passes(&self) -> bool {
346        self.cli_args
347            .as_ref()
348            .is_some_and(|args| args.reviewer_reviews > 0)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::checkpoint::state::{
356        AgentConfigSnapshot, CheckpointParams, CliArgsSnapshot, RebaseState,
357    };
358
359    fn make_test_checkpoint(phase: PipelinePhase, iteration: u32, pass: u32) -> PipelineCheckpoint {
360        let cli_args = CliArgsSnapshot::new(
361            5,
362            3,
363            "test commit".to_string(),
364            None,
365            false,
366            true,
367            2,
368            false,
369            None,
370        );
371        let dev_config =
372            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
373        let rev_config =
374            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
375        let run_id = uuid::Uuid::new_v4().to_string();
376
377        PipelineCheckpoint::from_params(CheckpointParams {
378            phase,
379            iteration,
380            total_iterations: 5,
381            reviewer_pass: pass,
382            total_reviewer_passes: 3,
383            developer_agent: "claude",
384            reviewer_agent: "codex",
385            cli_args,
386            developer_agent_config: dev_config,
387            reviewer_agent_config: rev_config,
388            rebase_state: RebaseState::default(),
389            git_user_name: None,
390            git_user_email: None,
391            run_id: &run_id,
392            parent_run_id: None,
393            resume_count: 0,
394            actual_developer_runs: iteration,
395            actual_reviewer_runs: pass,
396        })
397    }
398
399    #[test]
400    fn test_restored_context_from_checkpoint() {
401        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
402        let context = RestoredContext::from_checkpoint(&checkpoint);
403
404        assert_eq!(context.phase, PipelinePhase::Development);
405        assert_eq!(context.resume_iteration, 3);
406        assert_eq!(context.total_iterations, 5);
407        assert_eq!(context.resume_reviewer_pass, 0);
408        assert_eq!(context.developer_agent, "claude");
409        assert!(context.cli_args.is_some());
410    }
411
412    #[test]
413    fn test_should_use_checkpoint_iterations() {
414        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
415        let context = RestoredContext::from_checkpoint(&checkpoint);
416
417        assert!(context.should_use_checkpoint_iterations());
418    }
419
420    #[test]
421    fn test_calculate_start_iteration_development() {
422        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
423        let start = calculate_start_iteration(&checkpoint, 5);
424        assert_eq!(start, 3);
425    }
426
427    #[test]
428    fn test_calculate_start_iteration_later_phase() {
429        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
430        let start = calculate_start_iteration(&checkpoint, 5);
431        assert_eq!(start, 5); // Development complete
432    }
433
434    #[test]
435    fn test_calculate_start_reviewer_pass() {
436        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 2);
437        let start = calculate_start_reviewer_pass(&checkpoint, 3);
438        assert_eq!(start, 2);
439    }
440
441    #[test]
442    fn test_calculate_start_reviewer_pass_early_phase() {
443        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
444        let start = calculate_start_reviewer_pass(&checkpoint, 3);
445        assert_eq!(start, 1); // Start from beginning
446    }
447
448    #[test]
449    fn test_should_skip_phase() {
450        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
451
452        // Earlier phases should be skipped
453        assert!(should_skip_phase(PipelinePhase::Planning, &checkpoint));
454        assert!(should_skip_phase(PipelinePhase::Development, &checkpoint));
455
456        // Current and later phases should not be skipped
457        assert!(!should_skip_phase(PipelinePhase::Review, &checkpoint));
458        assert!(!should_skip_phase(
459            PipelinePhase::FinalValidation,
460            &checkpoint
461        ));
462    }
463
464    #[test]
465    fn test_resume_context_from_checkpoint() {
466        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 1);
467        let resume_ctx = checkpoint.resume_context();
468
469        assert_eq!(resume_ctx.phase, PipelinePhase::Development);
470        assert_eq!(resume_ctx.iteration, 3);
471        assert_eq!(resume_ctx.total_iterations, 5);
472        assert_eq!(resume_ctx.reviewer_pass, 1);
473        assert_eq!(resume_ctx.total_reviewer_passes, 3);
474        assert_eq!(resume_ctx.resume_count, 0);
475        assert_eq!(resume_ctx.run_id, checkpoint.run_id);
476        assert!(resume_ctx.prompt_history.is_none());
477    }
478
479    #[test]
480    fn test_resume_context_phase_name_development() {
481        let ctx = ResumeContext {
482            phase: PipelinePhase::Development,
483            iteration: 2,
484            total_iterations: 5,
485            reviewer_pass: 0,
486            total_reviewer_passes: 3,
487            resume_count: 0,
488            rebase_state: RebaseState::default(),
489            run_id: "test".to_string(),
490            prompt_history: None,
491            execution_history: None,
492        };
493
494        assert_eq!(ctx.phase_name(), "Development iteration 3/5");
495    }
496
497    #[test]
498    fn test_resume_context_phase_name_review() {
499        let ctx = ResumeContext {
500            phase: PipelinePhase::Review,
501            iteration: 5,
502            total_iterations: 5,
503            reviewer_pass: 1,
504            total_reviewer_passes: 3,
505            resume_count: 0,
506            rebase_state: RebaseState::default(),
507            run_id: "test".to_string(),
508            prompt_history: None,
509            execution_history: None,
510        };
511
512        assert_eq!(ctx.phase_name(), "Review (pass 2/3)");
513    }
514
515    #[test]
516    fn test_resume_context_phase_name_review_again() {
517        let ctx = ResumeContext {
518            phase: PipelinePhase::ReviewAgain,
519            iteration: 5,
520            total_iterations: 5,
521            reviewer_pass: 2,
522            total_reviewer_passes: 3,
523            resume_count: 1,
524            rebase_state: RebaseState::default(),
525            run_id: "test".to_string(),
526            prompt_history: None,
527            execution_history: None,
528        };
529
530        assert_eq!(ctx.phase_name(), "Verification review 3/3");
531    }
532
533    #[test]
534    fn test_resume_context_phase_name_fix() {
535        let ctx = ResumeContext {
536            phase: PipelinePhase::Fix,
537            iteration: 5,
538            total_iterations: 5,
539            reviewer_pass: 1,
540            total_reviewer_passes: 3,
541            resume_count: 0,
542            rebase_state: RebaseState::default(),
543            run_id: "test".to_string(),
544            prompt_history: None,
545            execution_history: None,
546        };
547
548        assert_eq!(ctx.phase_name(), "Fix");
549    }
550}