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    // Note: review_depth is stored as a string in the checkpoint
117    // but as an enum in Config. For now, we don't override it.
118    // This could be enhanced to parse the string back to an enum.
119
120    // Apply model overrides if they exist in the checkpoint
121    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
122        config.developer_model = Some(model.clone());
123    }
124    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
125        config.reviewer_model = Some(model.clone());
126    }
127
128    // Apply provider overrides if they exist in the checkpoint
129    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
130        config.developer_provider = Some(provider.clone());
131    }
132    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
133        config.reviewer_provider = Some(provider.clone());
134    }
135
136    // Apply context levels if they exist in the checkpoint
137    config.developer_context = checkpoint.developer_agent_config.context_level;
138    config.reviewer_context = checkpoint.reviewer_agent_config.context_level;
139
140    // Apply git identity if it exists in the checkpoint
141    if let Some(ref name) = checkpoint.git_user_name {
142        config.git_user_name = Some(name.clone());
143    }
144    if let Some(ref email) = checkpoint.git_user_email {
145        config.git_user_email = Some(email.clone());
146    }
147
148    // Always restore isolation_mode from checkpoint for exact state restoration
149    config.isolation_mode = cli_args.isolation_mode;
150
151    // Apply verbosity level from checkpoint
152    config.verbosity = crate::config::types::Verbosity::from(cli_args.verbosity);
153
154    // Apply show_streaming_metrics from checkpoint
155    config.show_streaming_metrics = cli_args.show_streaming_metrics;
156
157    // Apply reviewer_json_parser from checkpoint if it exists
158    if let Some(ref parser) = cli_args.reviewer_json_parser {
159        config.reviewer_json_parser = Some(parser.clone());
160    }
161}
162
163/// Restore environment variables from a checkpoint.
164///
165/// This function restores environment variables that were captured in the
166/// checkpoint's environment snapshot, ensuring the resumed pipeline uses
167/// the same environment configuration as the original run.
168///
169/// # Arguments
170///
171/// * `checkpoint` - The checkpoint to restore environment from
172///
173/// # Returns
174///
175/// The number of environment variables restored.
176pub fn restore_environment_from_checkpoint(checkpoint: &PipelineCheckpoint) -> usize {
177    let Some(ref env_snap) = checkpoint.env_snapshot else {
178        return 0;
179    };
180
181    let mut restored = 0;
182
183    // Restore RALPH_* variables
184    for (key, value) in &env_snap.ralph_vars {
185        std::env::set_var(key, value);
186        restored += 1;
187    }
188
189    // Restore other relevant variables
190    for (key, value) in &env_snap.other_vars {
191        std::env::set_var(key, value);
192        restored += 1;
193    }
194
195    restored
196}
197
198/// Calculate the starting iteration for development phase resume.
199///
200/// When resuming from a checkpoint in the development phase, this determines
201/// which iteration to start from based on the checkpoint state.
202///
203/// # Arguments
204///
205/// * `checkpoint` - The checkpoint to calculate from
206/// * `max_iterations` - Maximum iterations configured
207///
208/// # Returns
209///
210/// The iteration number to start from (1-indexed).
211pub fn calculate_start_iteration(checkpoint: &PipelineCheckpoint, max_iterations: u32) -> u32 {
212    match checkpoint.phase {
213        PipelinePhase::Planning | PipelinePhase::Development => {
214            checkpoint.iteration.clamp(1, max_iterations)
215        }
216        // For later phases, development is already complete
217        _ => max_iterations,
218    }
219}
220
221/// Calculate the starting reviewer pass for review phase resume.
222///
223/// When resuming from a checkpoint in the review phase, this determines
224/// which pass to start from based on the checkpoint state.
225///
226/// # Arguments
227///
228/// * `checkpoint` - The checkpoint to calculate from
229/// * `max_passes` - Maximum review passes configured
230///
231/// # Returns
232///
233/// The pass number to start from (1-indexed).
234pub fn calculate_start_reviewer_pass(checkpoint: &PipelineCheckpoint, max_passes: u32) -> u32 {
235    match checkpoint.phase {
236        PipelinePhase::Review | PipelinePhase::Fix | PipelinePhase::ReviewAgain => {
237            checkpoint.reviewer_pass.clamp(1, max_passes.max(1))
238        }
239        // For earlier phases, start from the beginning
240        PipelinePhase::Planning
241        | PipelinePhase::Development
242        | PipelinePhase::PreRebase
243        | PipelinePhase::PreRebaseConflict => 1,
244        // For later phases, review is already complete
245        _ => max_passes,
246    }
247}
248
249/// Determine if a phase should be skipped based on checkpoint.
250///
251/// Returns true if the checkpoint indicates this phase has already been completed.
252pub fn should_skip_phase(phase: PipelinePhase, checkpoint: &PipelineCheckpoint) -> bool {
253    phase_rank(phase) < phase_rank(checkpoint.phase)
254}
255
256/// Get the rank (position) of a phase in the pipeline.
257///
258/// Lower values indicate earlier phases in the pipeline.
259fn phase_rank(phase: PipelinePhase) -> u32 {
260    match phase {
261        PipelinePhase::Planning => 0,
262        PipelinePhase::Development => 1,
263        PipelinePhase::Review => 2,
264        PipelinePhase::CommitMessage => 3,
265        PipelinePhase::FinalValidation => 4,
266        PipelinePhase::Complete => 5,
267        PipelinePhase::Interrupted => 6,
268        // Fix and other intermediate phases map to Review
269        PipelinePhase::Fix
270        | PipelinePhase::ReviewAgain
271        | PipelinePhase::PreRebase
272        | PipelinePhase::PreRebaseConflict => 2,
273        // Rebase phases map between Development and Review
274        PipelinePhase::Rebase | PipelinePhase::PostRebase | PipelinePhase::PostRebaseConflict => 2,
275    }
276}
277///
278/// # Arguments
279///
280/// Restored context from a checkpoint.
281///
282/// Contains all the information needed to resume a pipeline from a checkpoint.
283#[cfg(test)]
284#[derive(Debug, Clone)]
285pub struct RestoredContext {
286    /// The phase to resume from.
287    pub phase: PipelinePhase,
288    /// The iteration to resume from (for development phase).
289    pub resume_iteration: u32,
290    /// The total number of iterations configured.
291    pub total_iterations: u32,
292    /// The reviewer pass to resume from.
293    pub resume_reviewer_pass: u32,
294    /// The total number of reviewer passes configured.
295    pub total_reviewer_passes: u32,
296    /// Developer agent name from checkpoint.
297    pub developer_agent: String,
298    /// Reviewer agent name from checkpoint.
299    pub reviewer_agent: String,
300    /// CLI arguments snapshot (if available).
301    pub cli_args: Option<crate::checkpoint::state::CliArgsSnapshot>,
302}
303
304#[cfg(test)]
305impl RestoredContext {
306    /// Create a restored context from a checkpoint.
307    pub fn from_checkpoint(checkpoint: &PipelineCheckpoint) -> Self {
308        // Determine if CLI args are meaningful (non-default values)
309        let cli_args = if checkpoint.cli_args.developer_iters > 0
310            || checkpoint.cli_args.reviewer_reviews > 0
311        {
312            Some(checkpoint.cli_args.clone())
313        } else {
314            None
315        };
316
317        Self {
318            phase: checkpoint.phase,
319            resume_iteration: checkpoint.iteration,
320            total_iterations: checkpoint.total_iterations,
321            resume_reviewer_pass: checkpoint.reviewer_pass,
322            total_reviewer_passes: checkpoint.total_reviewer_passes,
323            developer_agent: checkpoint.developer_agent.clone(),
324            reviewer_agent: checkpoint.reviewer_agent.clone(),
325            cli_args,
326        }
327    }
328
329    /// Check if we should use checkpoint values for iteration counts.
330    ///
331    /// Returns true if the checkpoint has meaningful CLI args that should
332    /// override the current configuration.
333    pub fn should_use_checkpoint_iterations(&self) -> bool {
334        self.cli_args
335            .as_ref()
336            .is_some_and(|args| args.developer_iters > 0)
337    }
338
339    /// Check if we should use checkpoint values for reviewer counts.
340    pub fn should_use_checkpoint_reviewer_passes(&self) -> bool {
341        self.cli_args
342            .as_ref()
343            .is_some_and(|args| args.reviewer_reviews > 0)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::checkpoint::state::{
351        AgentConfigSnapshot, CheckpointParams, CliArgsSnapshot, RebaseState,
352    };
353
354    fn make_test_checkpoint(phase: PipelinePhase, iteration: u32, pass: u32) -> PipelineCheckpoint {
355        let cli_args = CliArgsSnapshot::new(5, 3, None, false, true, 2, false, None);
356        let dev_config =
357            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
358        let rev_config =
359            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
360        let run_id = uuid::Uuid::new_v4().to_string();
361
362        PipelineCheckpoint::from_params(CheckpointParams {
363            phase,
364            iteration,
365            total_iterations: 5,
366            reviewer_pass: pass,
367            total_reviewer_passes: 3,
368            developer_agent: "claude",
369            reviewer_agent: "codex",
370            cli_args,
371            developer_agent_config: dev_config,
372            reviewer_agent_config: rev_config,
373            rebase_state: RebaseState::default(),
374            git_user_name: None,
375            git_user_email: None,
376            run_id: &run_id,
377            parent_run_id: None,
378            resume_count: 0,
379            actual_developer_runs: iteration,
380            actual_reviewer_runs: pass,
381        })
382    }
383
384    #[test]
385    fn test_restored_context_from_checkpoint() {
386        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
387        let context = RestoredContext::from_checkpoint(&checkpoint);
388
389        assert_eq!(context.phase, PipelinePhase::Development);
390        assert_eq!(context.resume_iteration, 3);
391        assert_eq!(context.total_iterations, 5);
392        assert_eq!(context.resume_reviewer_pass, 0);
393        assert_eq!(context.developer_agent, "claude");
394        assert!(context.cli_args.is_some());
395    }
396
397    #[test]
398    fn test_should_use_checkpoint_iterations() {
399        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
400        let context = RestoredContext::from_checkpoint(&checkpoint);
401
402        assert!(context.should_use_checkpoint_iterations());
403    }
404
405    #[test]
406    fn test_calculate_start_iteration_development() {
407        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
408        let start = calculate_start_iteration(&checkpoint, 5);
409        assert_eq!(start, 3);
410    }
411
412    #[test]
413    fn test_calculate_start_iteration_later_phase() {
414        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
415        let start = calculate_start_iteration(&checkpoint, 5);
416        assert_eq!(start, 5); // Development complete
417    }
418
419    #[test]
420    fn test_calculate_start_reviewer_pass() {
421        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 2);
422        let start = calculate_start_reviewer_pass(&checkpoint, 3);
423        assert_eq!(start, 2);
424    }
425
426    #[test]
427    fn test_calculate_start_reviewer_pass_early_phase() {
428        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
429        let start = calculate_start_reviewer_pass(&checkpoint, 3);
430        assert_eq!(start, 1); // Start from beginning
431    }
432
433    #[test]
434    fn test_should_skip_phase() {
435        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
436
437        // Earlier phases should be skipped
438        assert!(should_skip_phase(PipelinePhase::Planning, &checkpoint));
439        assert!(should_skip_phase(PipelinePhase::Development, &checkpoint));
440
441        // Current and later phases should not be skipped
442        assert!(!should_skip_phase(PipelinePhase::Review, &checkpoint));
443        assert!(!should_skip_phase(
444            PipelinePhase::FinalValidation,
445            &checkpoint
446        ));
447    }
448
449    #[test]
450    fn test_resume_context_from_checkpoint() {
451        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 1);
452        let resume_ctx = checkpoint.resume_context();
453
454        assert_eq!(resume_ctx.phase, PipelinePhase::Development);
455        assert_eq!(resume_ctx.iteration, 3);
456        assert_eq!(resume_ctx.total_iterations, 5);
457        assert_eq!(resume_ctx.reviewer_pass, 1);
458        assert_eq!(resume_ctx.total_reviewer_passes, 3);
459        assert_eq!(resume_ctx.resume_count, 0);
460        assert_eq!(resume_ctx.run_id, checkpoint.run_id);
461        assert!(resume_ctx.prompt_history.is_none());
462    }
463
464    #[test]
465    fn test_resume_context_phase_name_development() {
466        let ctx = ResumeContext {
467            phase: PipelinePhase::Development,
468            iteration: 2,
469            total_iterations: 5,
470            reviewer_pass: 0,
471            total_reviewer_passes: 3,
472            resume_count: 0,
473            rebase_state: RebaseState::default(),
474            run_id: "test".to_string(),
475            prompt_history: None,
476            execution_history: None,
477        };
478
479        assert_eq!(ctx.phase_name(), "Development iteration 3/5");
480    }
481
482    #[test]
483    fn test_resume_context_phase_name_review() {
484        let ctx = ResumeContext {
485            phase: PipelinePhase::Review,
486            iteration: 5,
487            total_iterations: 5,
488            reviewer_pass: 1,
489            total_reviewer_passes: 3,
490            resume_count: 0,
491            rebase_state: RebaseState::default(),
492            run_id: "test".to_string(),
493            prompt_history: None,
494            execution_history: None,
495        };
496
497        assert_eq!(ctx.phase_name(), "Review (pass 2/3)");
498    }
499
500    #[test]
501    fn test_resume_context_phase_name_review_again() {
502        let ctx = ResumeContext {
503            phase: PipelinePhase::ReviewAgain,
504            iteration: 5,
505            total_iterations: 5,
506            reviewer_pass: 2,
507            total_reviewer_passes: 3,
508            resume_count: 1,
509            rebase_state: RebaseState::default(),
510            run_id: "test".to_string(),
511            prompt_history: None,
512            execution_history: None,
513        };
514
515        assert_eq!(ctx.phase_name(), "Verification review 3/3");
516    }
517
518    #[test]
519    fn test_resume_context_phase_name_fix() {
520        let ctx = ResumeContext {
521            phase: PipelinePhase::Fix,
522            iteration: 5,
523            total_iterations: 5,
524            reviewer_pass: 1,
525            total_reviewer_passes: 3,
526            resume_count: 0,
527            rebase_state: RebaseState::default(),
528            run_id: "test".to_string(),
529            prompt_history: None,
530            execution_history: None,
531        };
532
533        assert_eq!(ctx.phase_name(), "Fix");
534    }
535}