Skip to main content

ralph_workflow/app/
resume.rs

1//! Resume functionality for pipeline checkpoints.
2//!
3//! This module handles the --resume flag and checkpoint loading logic,
4//! including validation and state restoration.
5
6use crate::agents::AgentRegistry;
7use crate::checkpoint::file_state::{FileSystemState, ValidationError};
8use crate::checkpoint::{
9    checkpoint_exists, load_checkpoint, validate_checkpoint, PipelineCheckpoint, PipelinePhase,
10};
11use crate::config::Config;
12use crate::git_helpers::rebase_in_progress;
13use crate::logger::Logger;
14use std::fs;
15use std::io::{self, IsTerminal};
16
17/// Result of handling resume, containing the checkpoint.
18pub struct ResumeResult {
19    /// The loaded checkpoint.
20    pub checkpoint: PipelineCheckpoint,
21}
22
23/// Offer interactive resume prompt when checkpoint exists without --resume flag.
24///
25/// This function checks if a checkpoint exists when the user did NOT specify
26/// the --resume flag, and if so, offers to resume via an interactive prompt.
27/// This provides a better user experience by detecting incomplete runs and
28/// offering to continue them.
29///
30/// # Arguments
31///
32/// * `args` - CLI arguments (to check if --resume was already specified)
33/// * `config` - Current configuration (for validation comparison)
34/// * `registry` - Agent registry (for agent validation)
35/// * `logger` - Logger for output
36/// * `developer_agent` - Current developer agent name
37/// * `reviewer_agent` - Current reviewer agent name
38///
39/// # Returns
40///
41/// `Some(ResumeResult)` if user chose to resume from a valid checkpoint,
42/// `None` if no checkpoint exists, not in a TTY, user declined, or validation failed.
43pub fn offer_resume_if_checkpoint_exists(
44    args: &crate::cli::Args,
45    config: &Config,
46    registry: &AgentRegistry,
47    logger: &Logger,
48    developer_agent: &str,
49    reviewer_agent: &str,
50) -> Option<ResumeResult> {
51    // Skip if --resume flag was already specified (handled by handle_resume_with_validation)
52    if args.recovery.resume {
53        return None;
54    }
55
56    // Skip if --no-resume flag is specified
57    if args.recovery.no_resume {
58        return None;
59    }
60
61    // Skip if RALPH_NO_RESUME_PROMPT env var is set (for CI/automation)
62    if std::env::var("RALPH_NO_RESUME_PROMPT").is_ok() {
63        return None;
64    }
65
66    // Skip if not in a TTY (can't prompt user)
67    if !can_prompt_user() {
68        return None;
69    }
70
71    // Check if checkpoint exists
72    if !checkpoint_exists() {
73        return None;
74    }
75
76    // Load checkpoint to display summary
77    let checkpoint = match load_checkpoint() {
78        Ok(Some(cp)) => cp,
79        Ok(None) => return None,
80        Err(e) => {
81            logger.warn(&format!("Checkpoint exists but failed to load: {e}"));
82            return None;
83        }
84    };
85
86    // Display user-friendly checkpoint summary with time elapsed
87    logger.header("FOUND PREVIOUS RUN", crate::logger::Colors::cyan);
88    display_user_friendly_checkpoint_summary(&checkpoint, logger);
89
90    // Prompt user to resume
91    if !prompt_user_to_resume(logger) {
92        // User declined - delete checkpoint and start fresh
93        logger.info("Deleting checkpoint and starting fresh...");
94        let _ = crate::checkpoint::clear_checkpoint();
95        return None;
96    }
97
98    // User chose to resume - validate and proceed
99    logger.header("RESUME: Loading Checkpoint", crate::logger::Colors::yellow);
100
101    let validation = validate_checkpoint(&checkpoint, config, registry);
102
103    for warning in &validation.warnings {
104        logger.warn(warning);
105    }
106    for error in &validation.errors {
107        logger.error(error);
108    }
109
110    if !validation.is_valid {
111        logger.error("Checkpoint validation failed. Cannot resume.");
112        logger.info("Delete .agent/checkpoint.json and start fresh, or fix the issues above.");
113        return None;
114    }
115
116    if checkpoint.developer_agent != developer_agent {
117        logger.warn(&format!(
118            "Developer agent changed: {} -> {}",
119            checkpoint.developer_agent, developer_agent
120        ));
121    }
122    if checkpoint.reviewer_agent != reviewer_agent {
123        logger.warn(&format!(
124            "Reviewer agent changed: {} -> {}",
125            checkpoint.reviewer_agent, reviewer_agent
126        ));
127    }
128
129    check_rebase_state_on_resume(&checkpoint, logger);
130
131    let validation_outcome = if let Some(ref file_system_state) = checkpoint.file_system_state {
132        validate_file_system_state(
133            file_system_state,
134            logger,
135            args.recovery.recovery_strategy.into(),
136        )
137    } else {
138        ValidationOutcome::Passed
139    };
140
141    if matches!(validation_outcome, ValidationOutcome::Failed(_)) {
142        return None;
143    }
144
145    Some(ResumeResult { checkpoint })
146}
147
148/// Check if we can prompt the user (stdin/stdout is a TTY).
149fn can_prompt_user() -> bool {
150    io::stdin().is_terminal() && (io::stdout().is_terminal() || io::stderr().is_terminal())
151}
152
153/// Display a user-friendly checkpoint summary with time elapsed.
154fn display_user_friendly_checkpoint_summary(checkpoint: &PipelineCheckpoint, logger: &Logger) {
155    use chrono::{DateTime, Local, NaiveDateTime};
156
157    // Display phase with emoji indicator
158    let phase_emoji = get_phase_emoji(checkpoint.phase);
159    logger.info(&format!("{} {}", phase_emoji, checkpoint.description()));
160
161    // Calculate and display time elapsed
162    // Parse the timestamp string which is in "YYYY-MM-DD HH:MM:SS" format
163    let checkpoint_time =
164        match NaiveDateTime::parse_from_str(&checkpoint.timestamp, "%Y-%m-%d %H:%M:%S") {
165            Ok(dt) => {
166                DateTime::<Local>::from_naive_utc_and_offset(dt, Local::now().offset().to_owned())
167            }
168            Err(_) => {
169                // If parsing fails, just show the timestamp string
170                logger.info(&format!(
171                    "Session was interrupted at: {}",
172                    checkpoint.timestamp
173                ));
174                return;
175            }
176        };
177    let now = Local::now();
178    let duration = now.signed_duration_since(checkpoint_time);
179
180    let time_str = if duration.num_days() > 0 {
181        format!("{} day(s) ago", duration.num_days())
182    } else if duration.num_hours() > 0 {
183        format!("{} hour(s) ago", duration.num_hours())
184    } else if duration.num_minutes() > 0 {
185        format!("{} minute(s) ago", duration.num_minutes())
186    } else {
187        "just now".to_string()
188    };
189
190    logger.info(&format!("Session was interrupted: {}", time_str));
191
192    // Show rebase conflict information if applicable
193    if matches!(
194        checkpoint.rebase_state,
195        crate::checkpoint::RebaseState::HasConflicts { .. }
196    ) {
197        if let crate::checkpoint::RebaseState::HasConflicts { files } = &checkpoint.rebase_state {
198            logger.warn(&format!(
199                "Rebase conflicts detected in {} file(s)",
200                files.len()
201            ));
202            // Show up to 5 conflicted files
203            let display_files: Vec<_> = files.iter().take(5).cloned().collect();
204            for file in display_files {
205                logger.info(&format!("  - {}", file));
206            }
207            if files.len() > 5 {
208                logger.info(&format!("  ... and {} more", files.len() - 5));
209            }
210        }
211    }
212
213    // Show progress with visual bar
214    if checkpoint.total_iterations > 0 {
215        let progress_bar = create_progress_bar(
216            checkpoint.actual_developer_runs,
217            checkpoint.total_iterations,
218        );
219        logger.info(&format!(
220            "Development: {} {}/{} completed",
221            progress_bar, checkpoint.actual_developer_runs, checkpoint.total_iterations
222        ));
223    }
224    if checkpoint.total_reviewer_passes > 0 {
225        let progress_bar = create_progress_bar(
226            checkpoint.actual_reviewer_runs,
227            checkpoint.total_reviewer_passes,
228        );
229        logger.info(&format!(
230            "Review: {} {}/{} completed",
231            progress_bar, checkpoint.actual_reviewer_runs, checkpoint.total_reviewer_passes
232        ));
233    }
234
235    // Show resume count if this is a resumed session
236    if checkpoint.resume_count > 0 {
237        logger.info(&format!(
238            "This session has been resumed {} time(s) before",
239            checkpoint.resume_count
240        ));
241    }
242
243    // Show the reconstructed command that was used
244    if let Some(reconstructed_command) = reconstruct_command(checkpoint) {
245        logger.info(&format!("Original command: {}", reconstructed_command));
246    }
247
248    // Show agent configuration details
249    logger.info(&format!("Developer agent: {}", checkpoint.developer_agent));
250    logger.info(&format!("Reviewer agent: {}", checkpoint.reviewer_agent));
251
252    // Show model overrides if present
253    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
254        logger.info(&format!("Developer model: {}", model));
255    }
256    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
257        logger.info(&format!("Reviewer model: {}", model));
258    }
259
260    // Show provider overrides if present
261    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
262        logger.info(&format!("Developer provider: {}", provider));
263    }
264    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
265        logger.info(&format!("Reviewer provider: {}", provider));
266    }
267
268    // Show execution history info if available
269    if let Some(ref history) = checkpoint.execution_history {
270        if !history.steps.is_empty() {
271            logger.info(&format!(
272                "Execution history: {} step(s) recorded",
273                history.steps.len()
274            ));
275
276            // Show recent activity (last 5 steps) with user-friendly details
277            let recent_steps: Vec<_> = history
278                .steps
279                .iter()
280                .rev()
281                .take(5)
282                .collect::<Vec<_>>()
283                .into_iter()
284                .rev()
285                .collect();
286
287            logger.info("");
288            logger.info("Recent Activity:");
289
290            for step in &recent_steps {
291                let outcome_emoji = match step.outcome {
292                    crate::checkpoint::execution_history::StepOutcome::Success { .. } => "✓",
293                    crate::checkpoint::execution_history::StepOutcome::Failure { .. } => "✗",
294                    crate::checkpoint::execution_history::StepOutcome::Partial { .. } => "◐",
295                    crate::checkpoint::execution_history::StepOutcome::Skipped { .. } => "○",
296                };
297
298                logger.info(&format!(
299                    "  {} {} ({})",
300                    outcome_emoji, step.step_type, step.phase
301                ));
302
303                // Add files modified count if available
304                if let Some(ref detail) = step.modified_files_detail {
305                    let total_files =
306                        detail.added.len() + detail.modified.len() + detail.deleted.len();
307                    if total_files > 0 {
308                        let mut file_summary = String::from("    Files: ");
309                        let mut parts = Vec::new();
310                        if !detail.added.is_empty() {
311                            parts.push(format!("{} added", detail.added.len()));
312                        }
313                        if !detail.modified.is_empty() {
314                            parts.push(format!("{} modified", detail.modified.len()));
315                        }
316                        if !detail.deleted.is_empty() {
317                            parts.push(format!("{} deleted", detail.deleted.len()));
318                        }
319                        file_summary.push_str(&parts.join(", "));
320                        logger.info(&file_summary);
321                    }
322                }
323
324                // Add issues summary if available
325                if let Some(ref issues) = step.issues_summary {
326                    if issues.found > 0 || issues.fixed > 0 {
327                        logger.info(&format!(
328                            "    Issues: {} found, {} fixed",
329                            issues.found, issues.fixed
330                        ));
331                    }
332                }
333
334                // Add git commit if available (shortened)
335                if let Some(ref oid) = step.git_commit_oid {
336                    let short_oid = if oid.len() > 8 { &oid[..8] } else { oid };
337                    logger.info(&format!("    Commit: {}", short_oid));
338                }
339            }
340        }
341    }
342
343    // Show helpful next step based on current phase
344    if let Some(next_step) = suggest_next_step(checkpoint) {
345        logger.info("");
346        logger.info(&format!("Next: {}", next_step));
347    }
348
349    // Show example commands for inspecting state
350    logger.info("");
351    logger.info("To inspect the current state, you can run:");
352    logger.info("  git status        - See current changes");
353    logger.info("  git log --oneline -5 - See recent commits");
354}
355
356/// Reconstruct the original command from checkpoint data.
357///
358/// This function attempts to reconstruct the exact command that was used
359/// to create the checkpoint, including all relevant flags and options.
360fn reconstruct_command(checkpoint: &PipelineCheckpoint) -> Option<String> {
361    let cli = &checkpoint.cli_args;
362    let mut parts = vec!["ralph".to_string()];
363
364    // Add -D flag
365    if cli.developer_iters > 0 {
366        parts.push(format!("-D {}", cli.developer_iters));
367    }
368
369    // Add -R flag
370    if cli.reviewer_reviews > 0 {
371        parts.push(format!("-R {}", cli.reviewer_reviews));
372    }
373
374    // Add --commit-msg if specified
375    if !cli.commit_msg.is_empty() {
376        parts.push(format!("--commit-msg \"{}\"", cli.commit_msg));
377    }
378
379    // Add --review-depth if specified
380    if let Some(ref depth) = cli.review_depth {
381        parts.push(format!("--review-depth {}", depth));
382    }
383
384    // Add --skip-rebase if true
385    if cli.skip_rebase {
386        parts.push("--skip-rebase".to_string());
387    }
388
389    // Add --no-isolation if false (isolation_mode defaults to true)
390    if !cli.isolation_mode {
391        parts.push("--no-isolation".to_string());
392    }
393
394    // Add verbosity flags
395    match cli.verbosity {
396        0 => parts.push("--quiet".to_string()),
397        1 => {} // Normal is default
398        2 => parts.push("--verbose".to_string()),
399        3 => parts.push("--full".to_string()),
400        4 => parts.push("--debug".to_string()),
401        _ => {}
402    }
403
404    // Add --show-streaming-metrics if true
405    if cli.show_streaming_metrics {
406        parts.push("--show-streaming-metrics".to_string());
407    }
408
409    // Add --reviewer-json-parser if specified
410    if let Some(ref parser) = cli.reviewer_json_parser {
411        parts.push(format!("--reviewer-json-parser {}", parser));
412    }
413
414    // Add --agent flags if agents differ from defaults
415    // Note: We can't determine defaults here, so we always show them
416    parts.push(format!("--agent {}", checkpoint.developer_agent));
417    parts.push(format!("--reviewer-agent {}", checkpoint.reviewer_agent));
418
419    // Add model overrides if present
420    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
421        parts.push(format!("--model \"{}\"", model));
422    }
423    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
424        parts.push(format!("--reviewer-model \"{}\"", model));
425    }
426
427    // Add provider overrides if present
428    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
429        parts.push(format!("--provider \"{}\"", provider));
430    }
431    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
432        parts.push(format!("--reviewer-provider \"{}\"", provider));
433    }
434
435    if parts.len() > 1 {
436        Some(parts.join(" "))
437    } else {
438        None
439    }
440}
441
442/// Suggest the next step based on the current checkpoint phase.
443///
444/// Returns a detailed, actionable description of what will happen next
445/// when the user resumes from this checkpoint.
446fn suggest_next_step(checkpoint: &PipelineCheckpoint) -> Option<String> {
447    match checkpoint.phase {
448        PipelinePhase::Planning => {
449            Some("continue creating implementation plan from PROMPT.md".to_string())
450        }
451        PipelinePhase::PreRebase => Some("complete rebase before starting development".to_string()),
452        PipelinePhase::PreRebaseConflict => {
453            Some("resolve rebase conflicts then continue to development".to_string())
454        }
455        PipelinePhase::Development => {
456            if checkpoint.iteration < checkpoint.total_iterations {
457                Some(format!(
458                    "continue development iteration {} of {} (will use same prompts as before)",
459                    checkpoint.iteration + 1,
460                    checkpoint.total_iterations
461                ))
462            } else {
463                Some("move to review phase".to_string())
464            }
465        }
466        PipelinePhase::Review => {
467            if checkpoint.reviewer_pass < checkpoint.total_reviewer_passes {
468                Some(format!(
469                    "continue review pass {} of {} (will review recent changes)",
470                    checkpoint.reviewer_pass + 1,
471                    checkpoint.total_reviewer_passes
472                ))
473            } else {
474                Some("complete review cycle".to_string())
475            }
476        }
477        PipelinePhase::Fix => Some("address issues from code review".to_string()),
478        PipelinePhase::ReviewAgain => Some("complete verification review".to_string()),
479        PipelinePhase::PostRebase => Some("complete post-development rebase".to_string()),
480        PipelinePhase::PostRebaseConflict => Some("resolve post-rebase conflicts".to_string()),
481        PipelinePhase::CommitMessage => Some("finalize commit message".to_string()),
482        PipelinePhase::FinalValidation => Some("complete final validation".to_string()),
483        PipelinePhase::Complete => Some("pipeline complete!".to_string()),
484        PipelinePhase::Rebase => Some("complete rebase operation".to_string()),
485        PipelinePhase::Interrupted => {
486            // Provide more detailed information for interrupted state
487            // The interrupted phase can occur at any point, so we need to describe
488            // what the user was doing when interrupted
489            let mut context = vec!["resume from interrupted state".to_string()];
490
491            // Add context about what was being worked on
492            if checkpoint.iteration > 0 {
493                context.push(format!(
494                    "(development iteration {}/{})",
495                    checkpoint.iteration, checkpoint.total_iterations
496                ));
497            }
498            if checkpoint.reviewer_pass > 0 {
499                context.push(format!(
500                    "(review pass {}/{})",
501                    checkpoint.reviewer_pass, checkpoint.total_reviewer_passes
502                ));
503            }
504
505            // Explain what will happen on resume
506            context.push("full pipeline will run from interrupted point".to_string());
507
508            Some(context.join(" - "))
509        }
510    }
511}
512
513/// Prompt user to decide whether to resume or start fresh.
514///
515/// Returns `true` if user wants to resume, `false` if they want to start fresh.
516fn prompt_user_to_resume(logger: &Logger) -> bool {
517    use std::io::Write;
518
519    println!();
520    logger.info("Would you like to resume from where you left off?");
521
522    let prompt = "Resume? [y/N] ";
523    let colors = crate::logger::Colors::new();
524
525    let mut input = String::new();
526    // Print prompt directly to stdout for better UX
527    print!("{}", colors.yellow());
528    let _ = io::stdout().write_all(prompt.as_bytes());
529    let _ = io::stdout().flush();
530    print!("{}", colors.reset());
531
532    match io::stdin().read_line(&mut input) {
533        Ok(0) => {
534            // EOF
535            println!();
536            false
537        }
538        Ok(_) => {
539            let response = input.trim().to_lowercase();
540            println!();
541
542            matches!(response.as_str(), "y" | "yes" | "Y" | "YES")
543        }
544        Err(_) => false,
545    }
546}
547
548/// Result of file system validation.
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub enum ValidationOutcome {
551    /// Validation passed, safe to resume
552    Passed,
553    /// Validation failed, cannot resume
554    Failed(String),
555}
556
557/// Handles the --resume flag and loads checkpoint if applicable.
558///
559/// This function loads and validates the checkpoint, providing detailed
560/// feedback about what state is being restored and any configuration changes.
561///
562/// # Arguments
563///
564/// * `args` - CLI arguments
565/// * `config` - Current configuration (for validation comparison)
566/// * `registry` - Agent registry (for agent validation)
567/// * `logger` - Logger for output
568/// * `developer_agent` - Current developer agent name
569/// * `reviewer_agent` - Current reviewer agent name
570///
571/// # Returns
572///
573/// `Some(ResumeResult)` if a valid checkpoint was found and loaded,
574/// `None` if no checkpoint exists or --resume was not specified.
575pub fn handle_resume_with_validation(
576    args: &crate::cli::Args,
577    config: &Config,
578    registry: &AgentRegistry,
579    logger: &Logger,
580    developer_agent: &str,
581    reviewer_agent: &str,
582) -> Option<ResumeResult> {
583    // Handle --inspect-checkpoint flag
584    if args.recovery.inspect_checkpoint {
585        match load_checkpoint() {
586            Ok(Some(checkpoint)) => {
587                logger.header("CHECKPOINT INSPECTION", crate::logger::Colors::cyan);
588                display_detailed_checkpoint_info(&checkpoint, logger);
589                std::process::exit(0);
590            }
591            Ok(None) => {
592                logger.error("No checkpoint found to inspect.");
593                std::process::exit(1);
594            }
595            Err(e) => {
596                logger.error(&format!("Failed to load checkpoint: {}", e));
597                std::process::exit(1);
598            }
599        }
600    }
601
602    if !args.recovery.resume {
603        return None;
604    }
605
606    match load_checkpoint() {
607        Ok(Some(checkpoint)) => {
608            logger.header("RESUME: Loading Checkpoint", crate::logger::Colors::yellow);
609            display_checkpoint_summary(&checkpoint, logger);
610
611            // Validate checkpoint
612            let validation = validate_checkpoint(&checkpoint, config, registry);
613
614            // Display validation results
615            for warning in &validation.warnings {
616                logger.warn(warning);
617            }
618            for error in &validation.errors {
619                logger.error(error);
620            }
621
622            if !validation.is_valid {
623                logger.error("Checkpoint validation failed. Cannot resume.");
624                logger.info(
625                    "Delete .agent/checkpoint.json and start fresh, or fix the issues above.",
626                );
627                return None;
628            }
629
630            // Verify agents match (additional agent-specific warnings)
631            if checkpoint.developer_agent != developer_agent {
632                logger.warn(&format!(
633                    "Developer agent changed: {} -> {}",
634                    checkpoint.developer_agent, developer_agent
635                ));
636            }
637            if checkpoint.reviewer_agent != reviewer_agent {
638                logger.warn(&format!(
639                    "Reviewer agent changed: {} -> {}",
640                    checkpoint.reviewer_agent, reviewer_agent
641                ));
642            }
643
644            // Check for in-progress git rebase
645            check_rebase_state_on_resume(&checkpoint, logger);
646
647            // Perform file system state validation
648            let validation_outcome = if let Some(file_system_state) = &checkpoint.file_system_state
649            {
650                validate_file_system_state(
651                    file_system_state,
652                    logger,
653                    args.recovery.recovery_strategy.into(),
654                )
655            } else {
656                ValidationOutcome::Passed
657            };
658
659            if matches!(validation_outcome, ValidationOutcome::Failed(_)) {
660                return None;
661            }
662
663            Some(ResumeResult { checkpoint })
664        }
665        Ok(None) => {
666            logger.warn("No checkpoint found. Starting fresh pipeline...");
667            None
668        }
669        Err(e) => {
670            logger.warn(&format!("Failed to load checkpoint (starting fresh): {e}"));
671            None
672        }
673    }
674}
675
676/// Validate file system state when resuming.
677///
678/// This function validates that the current file system state matches
679/// the state captured in the checkpoint. This is part of the hardened
680/// resume feature that ensures idempotent recovery.
681///
682/// Returns a `ValidationOutcome` indicating whether validation passed
683/// or failed with a reason.
684fn validate_file_system_state(
685    file_system_state: &FileSystemState,
686    logger: &Logger,
687    strategy: crate::checkpoint::recovery::RecoveryStrategy,
688) -> ValidationOutcome {
689    let errors = file_system_state.validate_with_executor_impl(None);
690
691    if errors.is_empty() {
692        logger.info("File system state validation passed.");
693        return ValidationOutcome::Passed;
694    }
695
696    logger.warn("File system state validation detected changes:");
697
698    for error in &errors {
699        let (problem, commands) = error.recovery_commands();
700        logger.warn(&format!("  - {}", error));
701        logger.info(&format!("    What's wrong: {}", problem));
702        logger.info("    How to fix:");
703        for cmd in commands {
704            logger.info(&format!("      {}", cmd));
705        }
706    }
707
708    // Handle based on the recovery strategy
709    match strategy {
710        crate::checkpoint::recovery::RecoveryStrategy::Fail => {
711            logger.error("File system state validation failed (strategy: fail).");
712            logger.info("Use --recovery-strategy=auto to attempt automatic recovery.");
713            logger.info("Use --recovery-strategy=force to proceed anyway (not recommended).");
714            ValidationOutcome::Failed(
715                "File system state changed - see errors above or use --recovery-strategy=force to proceed anyway".to_string()
716            )
717        }
718        crate::checkpoint::recovery::RecoveryStrategy::Force => {
719            logger.warn("Proceeding with resume despite file changes (strategy: force).");
720            logger.info("Note: Pipeline behavior may be unpredictable.");
721            ValidationOutcome::Passed
722        }
723        crate::checkpoint::recovery::RecoveryStrategy::Auto => {
724            // Attempt automatic recovery for recoverable errors
725            let (_recovered, remaining) = attempt_auto_recovery(file_system_state, &errors, logger);
726
727            if remaining.is_empty() {
728                logger.success("Automatic recovery completed successfully.");
729                ValidationOutcome::Passed
730            } else {
731                logger.warn("Some issues could not be automatically recovered:");
732                for error in &remaining {
733                    logger.warn(&format!("  - {}", error));
734                }
735                logger.warn("Proceeding with resume despite unrecovered issues (strategy: auto).");
736                logger.info("Note: Pipeline behavior may be unpredictable.");
737                ValidationOutcome::Passed
738            }
739        }
740    }
741}
742
743/// Attempt automatic recovery from file system state changes.
744///
745/// This function attempts to automatically fix recoverable issues:
746/// - Restores small files from content stored in snapshot
747/// - Warns about unrecoverable issues (large files, git changes)
748///
749/// # Arguments
750///
751/// * `file_system_state` - The file system state from checkpoint
752/// * `errors` - Validation errors that were detected
753/// * `logger` - Logger for output
754///
755/// # Returns
756///
757/// A tuple of (number of issues recovered, remaining errors)
758fn attempt_auto_recovery(
759    file_system_state: &FileSystemState,
760    errors: &[ValidationError],
761    logger: &Logger,
762) -> (usize, Vec<ValidationError>) {
763    let mut recovered = 0;
764    let mut remaining = Vec::new();
765
766    for error in errors {
767        match attempt_recovery_for_error(file_system_state, error, logger) {
768            Ok(()) => {
769                recovered += 1;
770                logger.success(&format!("Recovered: {}", error));
771            }
772            Err(e) => {
773                remaining.push(error.clone());
774                logger.warn(&format!("Could not recover: {} - {}", error, e));
775            }
776        }
777    }
778
779    (recovered, remaining)
780}
781
782/// Attempt to recover from a single validation error.
783///
784/// # Returns
785///
786/// `Ok(())` if recovery succeeded, `Err(reason)` if it failed.
787fn attempt_recovery_for_error(
788    file_system_state: &FileSystemState,
789    error: &ValidationError,
790    logger: &Logger,
791) -> Result<(), String> {
792    match error {
793        ValidationError::FileContentChanged { path } => {
794            // Try to restore from snapshot if content is available
795            if let Some(snapshot) = file_system_state.files.get(path) {
796                if let Some(content) = snapshot.get_content() {
797                    fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?;
798                    logger.info(&format!("Restored {} from checkpoint content.", path));
799                    return Ok(());
800                }
801            }
802            Err("No content available in snapshot".to_string())
803        }
804        ValidationError::GitHeadChanged { .. } => {
805            // Git state changes are not automatically recoverable
806            // They require user intervention to reset or accept the new state
807            Err("Git HEAD changes require manual intervention".to_string())
808        }
809        ValidationError::GitStateInvalid { .. } => {
810            Err("Git state validation requires manual intervention".to_string())
811        }
812        ValidationError::GitWorkingTreeChanged { .. } => {
813            // Working tree changes are not automatically recoverable
814            Err("Git working tree changes require manual intervention".to_string())
815        }
816        ValidationError::FileMissing { path } => {
817            // Can't recover a missing file unless we have content
818            if let Some(snapshot) = file_system_state.files.get(path) {
819                if let Some(content) = snapshot.get_content() {
820                    fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?;
821                    logger.info(&format!("Restored missing {} from checkpoint.", path));
822                    return Ok(());
823                }
824            }
825            Err(format!("Cannot recover missing file {}", path))
826        }
827        ValidationError::FileUnexpectedlyExists { path } => {
828            // Unexpected files should be removed by user
829            Err(format!(
830                "File {} should not exist - requires manual removal",
831                path
832            ))
833        }
834    }
835}
836
837/// Check for in-progress git rebase when resuming.
838///
839/// This function detects if a git rebase is in progress and provides
840/// appropriate guidance to the user.
841fn check_rebase_state_on_resume(checkpoint: &PipelineCheckpoint, logger: &Logger) {
842    // Only check for rebase if we're resuming from a rebase-related phase
843    let is_rebase_phase = matches!(
844        checkpoint.phase,
845        PipelinePhase::PreRebase
846            | PipelinePhase::PreRebaseConflict
847            | PipelinePhase::PostRebase
848            | PipelinePhase::PostRebaseConflict
849    );
850
851    if !is_rebase_phase {
852        return;
853    }
854
855    match rebase_in_progress() {
856        Ok(true) => {
857            logger.warn("A git rebase is currently in progress.");
858            logger.info("The checkpoint indicates you were in a rebase phase.");
859            logger.info("Options:");
860            logger.info("  - Continue: Let ralph complete the rebase process");
861            logger.info("  - Abort manually: Run 'git rebase --abort' then use --resume");
862        }
863        Ok(false) => {
864            // No rebase in progress - this is expected if rebase completed
865            // but checkpoint wasn't cleared (e.g., pipeline was interrupted)
866            logger.info("No git rebase is currently in progress.");
867        }
868        Err(e) => {
869            logger.warn(&format!("Could not check rebase state: {e}"));
870        }
871    }
872}
873
874/// Display a summary of the checkpoint being loaded.
875fn display_checkpoint_summary(checkpoint: &PipelineCheckpoint, logger: &Logger) {
876    logger.info(&format!("Resuming from: {}", checkpoint.description()));
877    logger.info(&format!("Checkpoint saved at: {}", checkpoint.timestamp));
878    logger.info(&format!("Checkpoint version: {}", checkpoint.version));
879
880    // Show run ID and resume count
881    logger.info(&format!("Run ID: {}", checkpoint.run_id));
882    if checkpoint.resume_count > 0 {
883        logger.info(&format!(
884            "Resume count: {} (this is resume #{} of this session)",
885            checkpoint.resume_count,
886            checkpoint.resume_count + 1
887        ));
888    }
889    if let Some(ref parent_id) = checkpoint.parent_run_id {
890        logger.info(&format!("Parent run ID: {}", parent_id));
891    }
892
893    // Show actual execution counts vs configured counts
894    logger.info(&format!(
895        "Development: {} iteration(s) configured, {} completed",
896        checkpoint.total_iterations, checkpoint.actual_developer_runs
897    ));
898    logger.info(&format!(
899        "Review: {} pass(es) configured, {} completed",
900        checkpoint.total_reviewer_passes, checkpoint.actual_reviewer_runs
901    ));
902
903    // Show iteration progress
904    if checkpoint.total_iterations > 0 {
905        logger.info(&format!(
906            "Current position: iteration {}/{}",
907            checkpoint.iteration, checkpoint.total_iterations
908        ));
909    }
910    if checkpoint.total_reviewer_passes > 0 {
911        logger.info(&format!(
912            "Current position: pass {}/{}",
913            checkpoint.reviewer_pass, checkpoint.total_reviewer_passes
914        ));
915    }
916
917    // Show CLI args if available
918    let cli = &checkpoint.cli_args;
919    if cli.developer_iters > 0 || cli.reviewer_reviews > 0 {
920        logger.info(&format!(
921            "Original config: -D {} -R {}",
922            cli.developer_iters, cli.reviewer_reviews
923        ));
924    }
925
926    // Show agent configs
927    logger.info(&format!("Developer agent: {}", checkpoint.developer_agent));
928    logger.info(&format!("Reviewer agent: {}", checkpoint.reviewer_agent));
929
930    // Show model overrides if present
931    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
932        logger.info(&format!("Developer model override: {}", model));
933    }
934    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
935        logger.info(&format!("Reviewer model override: {}", model));
936    }
937
938    // Show provider overrides if present
939    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
940        logger.info(&format!("Developer provider: {}", provider));
941    }
942    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
943        logger.info(&format!("Reviewer provider: {}", provider));
944    }
945
946    // Show rebase state if applicable
947    match &checkpoint.rebase_state {
948        crate::checkpoint::RebaseState::PreRebaseInProgress { upstream_branch } => {
949            logger.warn(&format!("Pre-rebase in progress to: {}", upstream_branch));
950        }
951        crate::checkpoint::RebaseState::HasConflicts { files } => {
952            logger.warn(&format!("Rebase has conflicts in {} files", files.len()));
953            for file in files.iter().take(3) {
954                logger.warn(&format!("  - {}", file));
955            }
956            if files.len() > 3 {
957                logger.warn(&format!("  ... and {} more", files.len() - 3));
958            }
959        }
960        _ => {}
961    }
962}
963
964/// Helper to get phase rank for resume logic.
965/// Create a visual progress bar for checkpoint summary display.
966fn create_progress_bar(current: u32, total: u32) -> String {
967    if total == 0 {
968        return "[----]".to_string();
969    }
970
971    let width = 20; // Total width of progress bar
972    let filled = ((current as f64 / total as f64) * width as f64).round() as usize;
973    let filled = filled.min(width);
974
975    let mut bar = String::from("[");
976    for i in 0..width {
977        if i < filled {
978            bar.push('=');
979        } else {
980            bar.push('-');
981        }
982    }
983    bar.push(']');
984
985    let percentage = ((current as f64 / total as f64) * 100.0).round() as u32;
986    format!("{} {}%", bar, percentage)
987}
988
989/// Display detailed checkpoint information for inspection.
990///
991/// This function shows comprehensive checkpoint details when the user
992/// runs with the --inspect-checkpoint flag.
993fn display_detailed_checkpoint_info(checkpoint: &PipelineCheckpoint, logger: &Logger) {
994    use chrono::{DateTime, Local, NaiveDateTime};
995
996    logger.info(&format!("Phase: {}", checkpoint.phase));
997    logger.info(&format!("Timestamp: {}", checkpoint.timestamp));
998
999    // Calculate and display time elapsed
1000    if let Ok(dt) = NaiveDateTime::parse_from_str(&checkpoint.timestamp, "%Y-%m-%d %H:%M:%S") {
1001        let checkpoint_time =
1002            DateTime::<Local>::from_naive_utc_and_offset(dt, Local::now().offset().to_owned());
1003        let now = Local::now();
1004        let duration = now.signed_duration_since(checkpoint_time);
1005
1006        let time_str = if duration.num_days() > 0 {
1007            format!("{} day(s) ago", duration.num_days())
1008        } else if duration.num_hours() > 0 {
1009            format!("{} hour(s) ago", duration.num_hours())
1010        } else if duration.num_minutes() > 0 {
1011            format!("{} minute(s) ago", duration.num_minutes())
1012        } else {
1013            "just now".to_string()
1014        };
1015        logger.info(&format!("Time elapsed: {}", time_str));
1016    }
1017
1018    logger.info("");
1019    logger.info("Configuration:");
1020
1021    // Show iterations and reviews
1022    if checkpoint.total_iterations > 0 {
1023        let progress_bar = create_progress_bar(
1024            checkpoint.actual_developer_runs,
1025            checkpoint.total_iterations,
1026        );
1027        logger.info(&format!(
1028            "  Development: {} {}/{}",
1029            progress_bar, checkpoint.actual_developer_runs, checkpoint.total_iterations
1030        ));
1031    }
1032    if checkpoint.total_reviewer_passes > 0 {
1033        let progress_bar = create_progress_bar(
1034            checkpoint.actual_reviewer_runs,
1035            checkpoint.total_reviewer_passes,
1036        );
1037        logger.info(&format!(
1038            "  Review: {} {}/{}",
1039            progress_bar, checkpoint.actual_reviewer_runs, checkpoint.total_reviewer_passes
1040        ));
1041    }
1042
1043    logger.info("");
1044    logger.info("Agents:");
1045    logger.info(&format!("  Developer: {}", checkpoint.developer_agent));
1046    logger.info(&format!("  Reviewer: {}", checkpoint.reviewer_agent));
1047
1048    // Show model overrides
1049    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
1050        logger.info(&format!("  Developer model: {}", model));
1051    }
1052    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
1053        logger.info(&format!("  Reviewer model: {}", model));
1054    }
1055    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
1056        logger.info(&format!("  Developer provider: {}", provider));
1057    }
1058    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
1059        logger.info(&format!("  Reviewer provider: {}", provider));
1060    }
1061
1062    // Show CLI args
1063    if let Some(ref cmd) = reconstruct_command(checkpoint) {
1064        logger.info("");
1065        logger.info(&format!("Command: {}", cmd));
1066    }
1067
1068    // Show resume count
1069    if checkpoint.resume_count > 0 {
1070        logger.info("");
1071        logger.info(&format!(
1072            "Resumed {} time(s) before",
1073            checkpoint.resume_count
1074        ));
1075    }
1076
1077    // Show run ID
1078    logger.info("");
1079    logger.info(&format!("Run ID: {}", checkpoint.run_id));
1080    if let Some(ref parent_id) = checkpoint.parent_run_id {
1081        logger.info(&format!("Parent Run ID: {}", parent_id));
1082    }
1083
1084    // Show rebase state if applicable
1085    if matches!(
1086        checkpoint.rebase_state,
1087        crate::checkpoint::RebaseState::HasConflicts { .. }
1088    ) {
1089        logger.info("");
1090        logger.warn("Rebase conflicts detected:");
1091        if let crate::checkpoint::RebaseState::HasConflicts { files } = &checkpoint.rebase_state {
1092            for file in files.iter().take(10) {
1093                logger.info(&format!("  - {}", file));
1094            }
1095            if files.len() > 10 {
1096                logger.info(&format!("  ... and {} more", files.len() - 10));
1097            }
1098        }
1099    }
1100
1101    // Show execution history if available
1102    if let Some(ref history) = checkpoint.execution_history {
1103        if !history.steps.is_empty() {
1104            logger.info("");
1105            logger.info(&format!(
1106                "Execution History: {} step(s)",
1107                history.steps.len()
1108            ));
1109            for (i, step) in history.steps.iter().take(10).enumerate() {
1110                let outcome_str = match &step.outcome {
1111                    crate::checkpoint::execution_history::StepOutcome::Success { .. } => "✓",
1112                    crate::checkpoint::execution_history::StepOutcome::Failure { .. } => "✗",
1113                    crate::checkpoint::execution_history::StepOutcome::Partial { .. } => "◐",
1114                    crate::checkpoint::execution_history::StepOutcome::Skipped { .. } => "○",
1115                };
1116                logger.info(&format!(
1117                    "  {}. {} {} ({})",
1118                    i + 1,
1119                    outcome_str,
1120                    step.step_type,
1121                    step.phase
1122                ));
1123            }
1124            if history.steps.len() > 10 {
1125                logger.info(&format!(
1126                    "  ... and {} more steps",
1127                    history.steps.len() - 10
1128                ));
1129            }
1130        }
1131    }
1132
1133    // Show file system state if available
1134    if let Some(ref fs_state) = checkpoint.file_system_state {
1135        logger.info("");
1136        logger.info(&format!(
1137            "File System State: {} file(s) tracked",
1138            fs_state.files.len()
1139        ));
1140
1141        // Show git state
1142        if let Some(ref branch) = fs_state.git_branch {
1143            logger.info(&format!("  Git branch: {}", branch));
1144        }
1145        if let Some(ref head) = fs_state.git_head_oid {
1146            logger.info(&format!("  Git HEAD: {}", head));
1147        }
1148        if let Some(ref status) = fs_state.git_status {
1149            if !status.is_empty() {
1150                logger.warn("  Git working tree has changes:");
1151                for line in status.lines().take(5) {
1152                    logger.info(&format!("    {}", line));
1153                }
1154            }
1155        }
1156    }
1157
1158    // Show environment snapshot if available
1159    if let Some(ref env_snap) = checkpoint.env_snapshot {
1160        if !env_snap.ralph_vars.is_empty() {
1161            logger.info("");
1162            logger.info(&format!(
1163                "Environment Variables: {} RALPH_* var(s)",
1164                env_snap.ralph_vars.len()
1165            ));
1166            for (key, value) in env_snap.ralph_vars.iter().take(10) {
1167                logger.info(&format!("  {}={}", key, value));
1168            }
1169            if env_snap.ralph_vars.len() > 10 {
1170                logger.info(&format!(
1171                    "  ... and {} more",
1172                    env_snap.ralph_vars.len() - 10
1173                ));
1174            }
1175        }
1176    }
1177
1178    // Show working directory
1179    logger.info("");
1180    logger.info(&format!("Working directory: {}", checkpoint.working_dir));
1181}
1182
1183/// Get an emoji indicator for a pipeline phase.
1184fn get_phase_emoji(phase: PipelinePhase) -> &'static str {
1185    match phase {
1186        PipelinePhase::Rebase => "🔄",
1187        PipelinePhase::Planning => "📋",
1188        PipelinePhase::Development => "🔨",
1189        PipelinePhase::Review => "👀",
1190        PipelinePhase::Fix => "🔧",
1191        PipelinePhase::ReviewAgain => "🔍",
1192        PipelinePhase::CommitMessage => "📝",
1193        PipelinePhase::FinalValidation => "✅",
1194        PipelinePhase::Complete => "🎉",
1195        PipelinePhase::PreRebase => "⏪",
1196        PipelinePhase::PreRebaseConflict => "⚠️",
1197        PipelinePhase::PostRebase => "⏩",
1198        PipelinePhase::PostRebaseConflict => "⚠️",
1199        PipelinePhase::Interrupted => "⏸️",
1200    }
1201}