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.
256///
257/// # Arguments
258///
259/// * `phase` - The phase to check
260/// * `checkpoint` - The checkpoint to compare against
261pub fn should_skip_phase(phase: PipelinePhase, checkpoint: &PipelineCheckpoint) -> bool {
262    use crate::app::resume::phase_rank;
263    phase_rank(phase) < phase_rank(checkpoint.phase)
264}
265
266/// Restored context from a checkpoint.
267///
268/// Contains all the information needed to resume a pipeline from a checkpoint.
269#[cfg(test)]
270#[derive(Debug, Clone)]
271pub struct RestoredContext {
272    /// The phase to resume from.
273    pub phase: PipelinePhase,
274    /// The iteration to resume from (for development phase).
275    pub resume_iteration: u32,
276    /// The total number of iterations configured.
277    pub total_iterations: u32,
278    /// The reviewer pass to resume from.
279    pub resume_reviewer_pass: u32,
280    /// The total number of reviewer passes configured.
281    pub total_reviewer_passes: u32,
282    /// Developer agent name from checkpoint.
283    pub developer_agent: String,
284    /// Reviewer agent name from checkpoint.
285    pub reviewer_agent: String,
286    /// CLI arguments snapshot (if available).
287    pub cli_args: Option<crate::checkpoint::state::CliArgsSnapshot>,
288}
289
290#[cfg(test)]
291impl RestoredContext {
292    /// Create a restored context from a checkpoint.
293    pub fn from_checkpoint(checkpoint: &PipelineCheckpoint) -> Self {
294        // Determine if CLI args are meaningful (non-default values)
295        let cli_args = if checkpoint.cli_args.developer_iters > 0
296            || checkpoint.cli_args.reviewer_reviews > 0
297            || !checkpoint.cli_args.commit_msg.is_empty()
298        {
299            Some(checkpoint.cli_args.clone())
300        } else {
301            None
302        };
303
304        Self {
305            phase: checkpoint.phase,
306            resume_iteration: checkpoint.iteration,
307            total_iterations: checkpoint.total_iterations,
308            resume_reviewer_pass: checkpoint.reviewer_pass,
309            total_reviewer_passes: checkpoint.total_reviewer_passes,
310            developer_agent: checkpoint.developer_agent.clone(),
311            reviewer_agent: checkpoint.reviewer_agent.clone(),
312            cli_args,
313        }
314    }
315
316    /// Check if we should use checkpoint values for iteration counts.
317    ///
318    /// Returns true if the checkpoint has meaningful CLI args that should
319    /// override the current configuration.
320    pub fn should_use_checkpoint_iterations(&self) -> bool {
321        self.cli_args
322            .as_ref()
323            .is_some_and(|args| args.developer_iters > 0)
324    }
325
326    /// Check if we should use checkpoint values for reviewer counts.
327    pub fn should_use_checkpoint_reviewer_passes(&self) -> bool {
328        self.cli_args
329            .as_ref()
330            .is_some_and(|args| args.reviewer_reviews > 0)
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::checkpoint::state::{
338        AgentConfigSnapshot, CheckpointParams, CliArgsSnapshot, RebaseState,
339    };
340
341    fn make_test_checkpoint(phase: PipelinePhase, iteration: u32, pass: u32) -> PipelineCheckpoint {
342        let cli_args = CliArgsSnapshot::new(
343            5,
344            3,
345            "test commit".to_string(),
346            None,
347            false,
348            true,
349            2,
350            false,
351            None,
352        );
353        let dev_config =
354            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
355        let rev_config =
356            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
357        let run_id = uuid::Uuid::new_v4().to_string();
358
359        PipelineCheckpoint::from_params(CheckpointParams {
360            phase,
361            iteration,
362            total_iterations: 5,
363            reviewer_pass: pass,
364            total_reviewer_passes: 3,
365            developer_agent: "claude",
366            reviewer_agent: "codex",
367            cli_args,
368            developer_agent_config: dev_config,
369            reviewer_agent_config: rev_config,
370            rebase_state: RebaseState::default(),
371            git_user_name: None,
372            git_user_email: None,
373            run_id: &run_id,
374            parent_run_id: None,
375            resume_count: 0,
376            actual_developer_runs: iteration,
377            actual_reviewer_runs: pass,
378        })
379    }
380
381    #[test]
382    fn test_restored_context_from_checkpoint() {
383        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
384        let context = RestoredContext::from_checkpoint(&checkpoint);
385
386        assert_eq!(context.phase, PipelinePhase::Development);
387        assert_eq!(context.resume_iteration, 3);
388        assert_eq!(context.total_iterations, 5);
389        assert_eq!(context.resume_reviewer_pass, 0);
390        assert_eq!(context.developer_agent, "claude");
391        assert!(context.cli_args.is_some());
392    }
393
394    #[test]
395    fn test_should_use_checkpoint_iterations() {
396        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
397        let context = RestoredContext::from_checkpoint(&checkpoint);
398
399        assert!(context.should_use_checkpoint_iterations());
400    }
401
402    #[test]
403    fn test_calculate_start_iteration_development() {
404        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
405        let start = calculate_start_iteration(&checkpoint, 5);
406        assert_eq!(start, 3);
407    }
408
409    #[test]
410    fn test_calculate_start_iteration_later_phase() {
411        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
412        let start = calculate_start_iteration(&checkpoint, 5);
413        assert_eq!(start, 5); // Development complete
414    }
415
416    #[test]
417    fn test_calculate_start_reviewer_pass() {
418        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 2);
419        let start = calculate_start_reviewer_pass(&checkpoint, 3);
420        assert_eq!(start, 2);
421    }
422
423    #[test]
424    fn test_calculate_start_reviewer_pass_early_phase() {
425        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
426        let start = calculate_start_reviewer_pass(&checkpoint, 3);
427        assert_eq!(start, 1); // Start from beginning
428    }
429
430    #[test]
431    fn test_should_skip_phase() {
432        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
433
434        // Earlier phases should be skipped
435        assert!(should_skip_phase(PipelinePhase::Planning, &checkpoint));
436        assert!(should_skip_phase(PipelinePhase::Development, &checkpoint));
437
438        // Current and later phases should not be skipped
439        assert!(!should_skip_phase(PipelinePhase::Review, &checkpoint));
440        assert!(!should_skip_phase(
441            PipelinePhase::FinalValidation,
442            &checkpoint
443        ));
444    }
445
446    #[test]
447    fn test_resume_context_from_checkpoint() {
448        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 1);
449        let resume_ctx = checkpoint.resume_context();
450
451        assert_eq!(resume_ctx.phase, PipelinePhase::Development);
452        assert_eq!(resume_ctx.iteration, 3);
453        assert_eq!(resume_ctx.total_iterations, 5);
454        assert_eq!(resume_ctx.reviewer_pass, 1);
455        assert_eq!(resume_ctx.total_reviewer_passes, 3);
456        assert_eq!(resume_ctx.resume_count, 0);
457        assert_eq!(resume_ctx.run_id, checkpoint.run_id);
458        assert!(resume_ctx.prompt_history.is_none());
459    }
460
461    #[test]
462    fn test_resume_context_phase_name_development() {
463        let ctx = ResumeContext {
464            phase: PipelinePhase::Development,
465            iteration: 2,
466            total_iterations: 5,
467            reviewer_pass: 0,
468            total_reviewer_passes: 3,
469            resume_count: 0,
470            rebase_state: RebaseState::default(),
471            run_id: "test".to_string(),
472            prompt_history: None,
473            execution_history: None,
474        };
475
476        assert_eq!(ctx.phase_name(), "Development iteration 3/5");
477    }
478
479    #[test]
480    fn test_resume_context_phase_name_review() {
481        let ctx = ResumeContext {
482            phase: PipelinePhase::Review,
483            iteration: 5,
484            total_iterations: 5,
485            reviewer_pass: 1,
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(), "Review (pass 2/3)");
495    }
496
497    #[test]
498    fn test_resume_context_phase_name_review_again() {
499        let ctx = ResumeContext {
500            phase: PipelinePhase::ReviewAgain,
501            iteration: 5,
502            total_iterations: 5,
503            reviewer_pass: 2,
504            total_reviewer_passes: 3,
505            resume_count: 1,
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(), "Verification review 3/3");
513    }
514
515    #[test]
516    fn test_resume_context_phase_name_fix() {
517        let ctx = ResumeContext {
518            phase: PipelinePhase::Fix,
519            iteration: 5,
520            total_iterations: 5,
521            reviewer_pass: 1,
522            total_reviewer_passes: 3,
523            resume_count: 0,
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(), "Fix");
531    }
532}