ralph_workflow/checkpoint/
state.rs

1//! Pipeline checkpoint state and persistence.
2//!
3//! This module contains the checkpoint data structures and file operations
4//! for saving and loading pipeline state.
5
6use chrono::Local;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::io;
12use std::path::Path;
13
14/// Default directory for Ralph's internal files.
15const AGENT_DIR: &str = ".agent";
16
17/// Default checkpoint file name.
18const CHECKPOINT_FILE: &str = "checkpoint.json";
19
20/// Current checkpoint format version.
21///
22/// Increment this when making breaking changes to the checkpoint format.
23/// This allows for future migration logic if needed.
24/// v1: Initial checkpoint format
25/// v2: Added run_id, parent_run_id, resume_count, actual_developer_runs, actual_reviewer_runs
26/// v3: Added execution_history, file_system_state for hardened resume
27const CHECKPOINT_VERSION: u32 = 3;
28
29/// Get the checkpoint file path.
30///
31/// By default, the checkpoint is stored in `.agent/checkpoint.json`
32/// relative to the current working directory. This function provides
33/// a single point of control for the checkpoint location, making it
34/// easier to configure or override in the future if needed.
35fn checkpoint_path() -> String {
36    format!("{AGENT_DIR}/{CHECKPOINT_FILE}")
37}
38
39/// Calculate SHA-256 checksum of a file's contents.
40///
41/// Returns None if the file doesn't exist or cannot be read.
42pub(crate) fn calculate_file_checksum(path: &Path) -> Option<String> {
43    let content = fs::read(path).ok()?;
44    let mut hasher = Sha256::new();
45    hasher.update(&content);
46    Some(format!("{:x}", hasher.finalize()))
47}
48
49/// Snapshot of CLI arguments for exact restoration.
50///
51/// Captures all relevant CLI arguments so that resuming a pipeline
52/// uses the exact same configuration as the original run.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CliArgsSnapshot {
55    /// Number of developer iterations (-D flag)
56    pub developer_iters: u32,
57    /// Number of reviewer reviews (-R flag)
58    pub reviewer_reviews: u32,
59    /// Commit message for the final commit
60    pub commit_msg: String,
61    /// Review depth level (if specified)
62    pub review_depth: Option<String>,
63    /// Whether to skip automatic rebase
64    pub skip_rebase: bool,
65    /// Isolation mode: when false, NOTES.md and ISSUES.md persist between iterations
66    /// Default is true for backward compatibility with v1/v2 checkpoints.
67    #[serde(default = "default_isolation_mode")]
68    pub isolation_mode: bool,
69    /// Verbosity level (0=Quiet, 1=Normal, 2=Verbose, 3=Full, 4=Debug)
70    /// Default is 2 (Verbose) for backward compatibility.
71    #[serde(default = "default_verbosity")]
72    pub verbosity: u8,
73    /// Show streaming quality metrics at the end of agent output
74    /// Default is false for backward compatibility.
75    #[serde(default)]
76    pub show_streaming_metrics: bool,
77    /// JSON parser override for the reviewer agent (claude, codex, gemini, opencode, generic)
78    #[serde(default)]
79    pub reviewer_json_parser: Option<String>,
80}
81
82/// Default value for isolation_mode (true = isolation enabled).
83fn default_isolation_mode() -> bool {
84    true
85}
86
87/// Default value for verbosity (2 = Verbose).
88fn default_verbosity() -> u8 {
89    2
90}
91
92/// Builder for creating [`CliArgsSnapshot`] instances.
93///
94/// Provides a fluent interface for constructing CLI argument snapshots
95/// without exceeding function argument limits.
96pub struct CliArgsSnapshotBuilder {
97    developer_iters: u32,
98    reviewer_reviews: u32,
99    commit_msg: String,
100    review_depth: Option<String>,
101    skip_rebase: bool,
102    isolation_mode: bool,
103    verbosity: u8,
104    show_streaming_metrics: bool,
105    reviewer_json_parser: Option<String>,
106}
107
108impl CliArgsSnapshotBuilder {
109    /// Create a new builder with required fields.
110    pub fn new(
111        developer_iters: u32,
112        reviewer_reviews: u32,
113        commit_msg: String,
114        review_depth: Option<String>,
115        skip_rebase: bool,
116        isolation_mode: bool,
117    ) -> Self {
118        Self {
119            developer_iters,
120            reviewer_reviews,
121            commit_msg,
122            review_depth,
123            skip_rebase,
124            isolation_mode,
125            verbosity: 2,
126            show_streaming_metrics: false,
127            reviewer_json_parser: None,
128        }
129    }
130
131    /// Set the verbosity level.
132    pub fn verbosity(mut self, verbosity: u8) -> Self {
133        self.verbosity = verbosity;
134        self
135    }
136
137    /// Set whether to show streaming metrics.
138    pub fn show_streaming_metrics(mut self, show: bool) -> Self {
139        self.show_streaming_metrics = show;
140        self
141    }
142
143    /// Set the reviewer JSON parser override.
144    pub fn reviewer_json_parser(mut self, parser: Option<String>) -> Self {
145        self.reviewer_json_parser = parser;
146        self
147    }
148
149    /// Build the snapshot.
150    pub fn build(self) -> CliArgsSnapshot {
151        CliArgsSnapshot {
152            developer_iters: self.developer_iters,
153            reviewer_reviews: self.reviewer_reviews,
154            commit_msg: self.commit_msg,
155            review_depth: self.review_depth,
156            skip_rebase: self.skip_rebase,
157            isolation_mode: self.isolation_mode,
158            verbosity: self.verbosity,
159            show_streaming_metrics: self.show_streaming_metrics,
160            reviewer_json_parser: self.reviewer_json_parser,
161        }
162    }
163}
164
165impl CliArgsSnapshot {
166    /// Create a snapshot from CLI argument values.
167    ///
168    /// This is a convenience method for test code.
169    /// For production code, use [`CliArgsSnapshotBuilder`] for better readability.
170    #[cfg(test)]
171    pub fn new(
172        developer_iters: u32,
173        reviewer_reviews: u32,
174        commit_msg: String,
175        review_depth: Option<String>,
176        skip_rebase: bool,
177        isolation_mode: bool,
178        verbosity: u8,
179        show_streaming_metrics: bool,
180        reviewer_json_parser: Option<String>,
181    ) -> Self {
182        CliArgsSnapshotBuilder::new(
183            developer_iters,
184            reviewer_reviews,
185            commit_msg,
186            review_depth,
187            skip_rebase,
188            isolation_mode,
189        )
190        .verbosity(verbosity)
191        .show_streaming_metrics(show_streaming_metrics)
192        .reviewer_json_parser(reviewer_json_parser)
193        .build()
194    }
195}
196
197/// Snapshot of agent configuration.
198///
199/// Captures the complete agent configuration to ensure
200/// the exact same agent behavior is used when resuming.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct AgentConfigSnapshot {
203    /// Agent name
204    pub name: String,
205    /// Agent command
206    pub cmd: String,
207    /// Output flag for JSON extraction
208    pub output_flag: String,
209    /// YOLO flag (if any)
210    pub yolo_flag: Option<String>,
211    /// Whether this agent can commit
212    pub can_commit: bool,
213    /// Model override (e.g., "-m opencode/glm-4.7-free")
214    /// Default is None for backward compatibility with v1/v2 checkpoints.
215    #[serde(default)]
216    pub model_override: Option<String>,
217    /// Provider override (e.g., "opencode", "anthropic")
218    /// Default is None for backward compatibility with v1/v2 checkpoints.
219    #[serde(default)]
220    pub provider_override: Option<String>,
221    /// Context level (0=minimal, 1=normal)
222    /// Default is 1 (normal context) for backward compatibility with v1/v2 checkpoints.
223    #[serde(default = "default_context_level")]
224    pub context_level: u8,
225}
226
227/// Default value for context_level (1 = normal context).
228fn default_context_level() -> u8 {
229    1
230}
231
232impl AgentConfigSnapshot {
233    /// Create a snapshot from agent configuration.
234    pub fn new(
235        name: String,
236        cmd: String,
237        output_flag: String,
238        yolo_flag: Option<String>,
239        can_commit: bool,
240    ) -> Self {
241        Self {
242            name,
243            cmd,
244            output_flag,
245            yolo_flag,
246            can_commit,
247            model_override: None,
248            provider_override: None,
249            context_level: default_context_level(),
250        }
251    }
252
253    /// Set model override.
254    pub fn with_model_override(mut self, model: Option<String>) -> Self {
255        self.model_override = model;
256        self
257    }
258
259    /// Set provider override.
260    pub fn with_provider_override(mut self, provider: Option<String>) -> Self {
261        self.provider_override = provider;
262        self
263    }
264
265    /// Set context level.
266    pub fn with_context_level(mut self, level: u8) -> Self {
267        self.context_level = level;
268        self
269    }
270}
271
272/// Snapshot of environment variables for idempotent recovery.
273///
274/// Captures environment variables that affect pipeline execution,
275/// particularly RALPH_* variables, to ensure the same configuration
276/// when resuming.
277#[derive(Debug, Clone, Serialize, Deserialize, Default)]
278pub struct EnvironmentSnapshot {
279    /// All RALPH_* environment variables at checkpoint time
280    #[serde(default)]
281    pub ralph_vars: HashMap<String, String>,
282    /// Other relevant environment variables
283    #[serde(default)]
284    pub other_vars: HashMap<String, String>,
285}
286
287impl EnvironmentSnapshot {
288    /// Capture the current environment variables relevant to Ralph.
289    pub fn capture_current() -> Self {
290        let mut ralph_vars = HashMap::new();
291        let mut other_vars = HashMap::new();
292
293        // Capture all RALPH_* environment variables
294        for (key, value) in std::env::vars() {
295            if key.starts_with("RALPH_") {
296                ralph_vars.insert(key, value);
297            }
298        }
299
300        // Capture other relevant variables
301        let relevant_keys = [
302            "EDITOR",
303            "VISUAL",
304            "GIT_AUTHOR_NAME",
305            "GIT_AUTHOR_EMAIL",
306            "GIT_COMMITTER_NAME",
307            "GIT_COMMITTER_EMAIL",
308        ];
309        for key in &relevant_keys {
310            if let Ok(value) = std::env::var(key) {
311                other_vars.insert(key.to_string(), value);
312            }
313        }
314
315        Self {
316            ralph_vars,
317            other_vars,
318        }
319    }
320}
321
322/// Parameters for creating a new checkpoint.
323///
324/// Groups all the parameters needed to create a checkpoint, avoiding
325/// functions with too many individual parameters.
326pub struct CheckpointParams<'a> {
327    /// Current pipeline phase
328    pub phase: PipelinePhase,
329    /// Current developer iteration number
330    pub iteration: u32,
331    /// Total developer iterations configured
332    pub total_iterations: u32,
333    /// Current reviewer pass number
334    pub reviewer_pass: u32,
335    /// Total reviewer passes configured
336    pub total_reviewer_passes: u32,
337    /// Display name of the developer agent
338    pub developer_agent: &'a str,
339    /// Display name of the reviewer agent
340    pub reviewer_agent: &'a str,
341    /// Snapshot of CLI arguments
342    pub cli_args: CliArgsSnapshot,
343    /// Snapshot of developer agent configuration
344    pub developer_agent_config: AgentConfigSnapshot,
345    /// Snapshot of reviewer agent configuration
346    pub reviewer_agent_config: AgentConfigSnapshot,
347    /// Current rebase state
348    pub rebase_state: RebaseState,
349    /// Git user name for commits (if overridden)
350    pub git_user_name: Option<&'a str>,
351    /// Git user email for commits (if overridden)
352    pub git_user_email: Option<&'a str>,
353    /// Unique identifier for this run (UUID v4)
354    pub run_id: &'a str,
355    /// Parent run ID if this is a resumed session
356    pub parent_run_id: Option<&'a str>,
357    /// Number of times this session has been resumed
358    pub resume_count: u32,
359    /// Actual completed developer iterations
360    pub actual_developer_runs: u32,
361    /// Actual completed reviewer passes
362    pub actual_reviewer_runs: u32,
363}
364
365/// Rebase state tracking.
366///
367/// Tracks the state of rebase operations to enable
368/// proper recovery from interruptions during rebase.
369#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
370pub enum RebaseState {
371    /// Rebase not started yet
372    #[default]
373    NotStarted,
374    /// Pre-development rebase in progress
375    PreRebaseInProgress { upstream_branch: String },
376    /// Pre-development rebase completed
377    PreRebaseCompleted { commit_oid: String },
378    /// Post-review rebase in progress
379    PostRebaseInProgress { upstream_branch: String },
380    /// Post-review rebase completed
381    PostRebaseCompleted { commit_oid: String },
382    /// Rebase has conflicts that need resolution
383    HasConflicts { files: Vec<String> },
384    /// Rebase failed
385    Failed { error: String },
386}
387
388/// Pipeline phases for checkpoint tracking.
389///
390/// These phases represent the major stages of the Ralph pipeline.
391/// Checkpoints are saved at phase boundaries to enable resume functionality.
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393pub enum PipelinePhase {
394    /// Rebase phase (synchronizing with upstream branch)
395    Rebase,
396    /// Planning phase (creating PLAN.md)
397    Planning,
398    /// Development/implementation phase
399    Development,
400    /// Review-fix cycles phase (N iterations of review + fix)
401    Review,
402    /// Fix phase (deprecated: kept for backward compatibility with old checkpoints)
403    Fix,
404    /// Verification review phase (deprecated: kept for backward compatibility with old checkpoints)
405    ReviewAgain,
406    /// Commit message generation
407    CommitMessage,
408    /// Final validation phase
409    FinalValidation,
410    /// Pipeline complete
411    Complete,
412    /// Before initial rebase
413    PreRebase,
414    /// During pre-rebase conflict resolution
415    PreRebaseConflict,
416    /// Before post-review rebase
417    PostRebase,
418    /// During post-review conflict resolution
419    PostRebaseConflict,
420    /// Pipeline was interrupted (e.g., by Ctrl+C)
421    Interrupted,
422}
423
424impl std::fmt::Display for PipelinePhase {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        match self {
427            Self::Rebase => write!(f, "Rebase"),
428            Self::Planning => write!(f, "Planning"),
429            Self::Development => write!(f, "Development"),
430            Self::Review => write!(f, "Review"),
431            Self::Fix => write!(f, "Fix"),
432            Self::ReviewAgain => write!(f, "Verification Review"),
433            Self::CommitMessage => write!(f, "Commit Message Generation"),
434            Self::FinalValidation => write!(f, "Final Validation"),
435            Self::Complete => write!(f, "Complete"),
436            Self::PreRebase => write!(f, "Pre-Rebase"),
437            Self::PreRebaseConflict => write!(f, "Pre-Rebase Conflict"),
438            Self::PostRebase => write!(f, "Post-Rebase"),
439            Self::PostRebaseConflict => write!(f, "Post-Rebase Conflict"),
440            Self::Interrupted => write!(f, "Interrupted"),
441        }
442    }
443}
444
445/// Enhanced pipeline checkpoint for resume functionality.
446///
447/// Contains comprehensive state needed to resume an interrupted pipeline
448/// exactly where it left off, including CLI arguments, agent configurations,
449/// rebase state, and file checksums for validation.
450///
451/// This is inspired by video game save states - capturing the complete
452/// execution context to enable seamless recovery.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct PipelineCheckpoint {
455    /// Checkpoint format version (for future compatibility)
456    pub version: u32,
457
458    // === Core pipeline state ===
459    /// Current pipeline phase
460    pub phase: PipelinePhase,
461    /// Current iteration number (for developer iterations)
462    pub iteration: u32,
463    /// Total iterations configured
464    pub total_iterations: u32,
465    /// Current reviewer pass number
466    pub reviewer_pass: u32,
467    /// Total reviewer passes configured
468    pub total_reviewer_passes: u32,
469
470    // === Metadata ===
471    /// Timestamp when checkpoint was saved
472    pub timestamp: String,
473    /// Developer agent display name
474    pub developer_agent: String,
475    /// Reviewer agent display name
476    pub reviewer_agent: String,
477
478    // === Enhanced state capture ===
479    /// CLI argument snapshot
480    pub cli_args: CliArgsSnapshot,
481    /// Developer agent configuration snapshot
482    pub developer_agent_config: AgentConfigSnapshot,
483    /// Reviewer agent configuration snapshot
484    pub reviewer_agent_config: AgentConfigSnapshot,
485    /// Rebase state tracking
486    pub rebase_state: RebaseState,
487
488    // === Validation data ===
489    /// Path to config file used for this run (if any)
490    pub config_path: Option<String>,
491    /// Checksum of config file (for validation on resume)
492    pub config_checksum: Option<String>,
493    /// Working directory when checkpoint was created
494    pub working_dir: String,
495    /// Checksum of PROMPT.md (for validation on resume)
496    pub prompt_md_checksum: Option<String>,
497
498    // === Additional state for exact restoration ===
499    /// Git user name for commits (if overridden)
500    pub git_user_name: Option<String>,
501    /// Git user email for commits (if overridden)
502    pub git_user_email: Option<String>,
503
504    // === Run identification and lineage (v2+) ===
505    /// Unique identifier for this run (UUID v4)
506    pub run_id: String,
507    /// Parent run ID if this is a resumed session
508    pub parent_run_id: Option<String>,
509    /// Number of times this session has been resumed
510    pub resume_count: u32,
511
512    // === Actual execution state (v2+) ===
513    /// Actual number of developer iterations that completed
514    pub actual_developer_runs: u32,
515    /// Actual number of reviewer passes that completed
516    pub actual_reviewer_runs: u32,
517
518    // === Hardened resume state (v3+) ===
519    /// Execution history tracking for idempotent recovery
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub execution_history: Option<crate::checkpoint::ExecutionHistory>,
522    /// File system state for validation on resume
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub file_system_state: Option<crate::checkpoint::FileSystemState>,
525    /// Stored prompts used during this run
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub prompt_history: Option<std::collections::HashMap<String, String>>,
528    /// Environment snapshot for idempotent recovery
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub env_snapshot: Option<EnvironmentSnapshot>,
531}
532
533impl PipelineCheckpoint {
534    /// Create a new checkpoint with comprehensive state capture.
535    ///
536    /// This is the main constructor for creating checkpoints during pipeline execution.
537    /// It captures all necessary state to enable exact restoration of the pipeline.
538    ///
539    /// # Arguments
540    ///
541    /// * `params` - All checkpoint parameters bundled in a struct
542    pub fn from_params(params: CheckpointParams<'_>) -> Self {
543        // Get current working directory
544        let working_dir = std::env::current_dir()
545            .map(|p| p.to_string_lossy().to_string())
546            .unwrap_or_default();
547
548        // Calculate PROMPT.md checksum if it exists
549        let prompt_md_checksum = calculate_file_checksum(Path::new("PROMPT.md"));
550
551        Self {
552            version: CHECKPOINT_VERSION,
553            phase: params.phase,
554            iteration: params.iteration,
555            total_iterations: params.total_iterations,
556            reviewer_pass: params.reviewer_pass,
557            total_reviewer_passes: params.total_reviewer_passes,
558            timestamp: timestamp(),
559            developer_agent: params.developer_agent.to_string(),
560            reviewer_agent: params.reviewer_agent.to_string(),
561            cli_args: params.cli_args,
562            developer_agent_config: params.developer_agent_config,
563            reviewer_agent_config: params.reviewer_agent_config,
564            rebase_state: params.rebase_state,
565            config_path: None,     // Will be set by caller if needed
566            config_checksum: None, // Will be set by caller if needed
567            working_dir,
568            prompt_md_checksum,
569            git_user_name: params.git_user_name.map(String::from),
570            git_user_email: params.git_user_email.map(String::from),
571            // New v2 fields
572            run_id: params.run_id.to_string(),
573            parent_run_id: params.parent_run_id.map(String::from),
574            resume_count: params.resume_count,
575            actual_developer_runs: params.actual_developer_runs,
576            actual_reviewer_runs: params.actual_reviewer_runs,
577            // New v3 fields - initialize as None, will be populated by caller
578            execution_history: None,
579            file_system_state: None,
580            prompt_history: None,
581            env_snapshot: None,
582        }
583    }
584
585    /// Get a human-readable description of the checkpoint.
586    ///
587    /// Returns a string describing the current phase and progress,
588    /// suitable for display to the user when resuming.
589    pub fn description(&self) -> String {
590        match self.phase {
591            PipelinePhase::Rebase => "Rebase in progress".to_string(),
592            PipelinePhase::Planning => {
593                format!(
594                    "Planning phase, iteration {}/{}",
595                    self.iteration, self.total_iterations
596                )
597            }
598            PipelinePhase::Development => {
599                format!(
600                    "Development iteration {}/{}",
601                    self.iteration, self.total_iterations
602                )
603            }
604            PipelinePhase::Review => "Initial review".to_string(),
605            PipelinePhase::Fix => "Applying fixes".to_string(),
606            PipelinePhase::ReviewAgain => {
607                format!(
608                    "Verification review {}/{}",
609                    self.reviewer_pass, self.total_reviewer_passes
610                )
611            }
612            PipelinePhase::CommitMessage => "Commit message generation".to_string(),
613            PipelinePhase::FinalValidation => "Final validation".to_string(),
614            PipelinePhase::Complete => "Pipeline complete".to_string(),
615            PipelinePhase::PreRebase => "Pre-development rebase".to_string(),
616            PipelinePhase::PreRebaseConflict => "Pre-rebase conflict resolution".to_string(),
617            PipelinePhase::PostRebase => "Post-review rebase".to_string(),
618            PipelinePhase::PostRebaseConflict => "Post-rebase conflict resolution".to_string(),
619            PipelinePhase::Interrupted => {
620                // Provide more detailed information for interrupted state
621                let mut parts = vec!["Interrupted".to_string()];
622
623                // Add context about what phase was interrupted
624                if self.iteration > 0 && self.iteration < self.total_iterations {
625                    parts.push(format!(
626                        "during development (iteration {}/{})",
627                        self.iteration, self.total_iterations
628                    ));
629                } else if self.iteration >= self.total_iterations {
630                    if self.reviewer_pass > 0 {
631                        parts.push(format!(
632                            "during review (pass {}/{})",
633                            self.reviewer_pass, self.total_reviewer_passes
634                        ));
635                    } else {
636                        parts.push("after development phase".to_string());
637                    }
638                } else {
639                    parts.push("during pipeline initialization".to_string());
640                }
641
642                parts.join(" ")
643            }
644        }
645    }
646
647    /// Set the config path and calculate its checksum.
648    pub fn with_config(mut self, path: Option<std::path::PathBuf>) -> Self {
649        if let Some(p) = path {
650            self.config_path = Some(p.to_string_lossy().to_string());
651            self.config_checksum = calculate_file_checksum(&p);
652        }
653        self
654    }
655}
656
657/// Try to load a checkpoint, handling v1, v2, and v3 formats.
658fn load_checkpoint_with_fallback(
659    content: &str,
660) -> Result<PipelineCheckpoint, Box<dyn std::error::Error>> {
661    // Try v3 format first (current)
662    match serde_json::from_str::<PipelineCheckpoint>(content) {
663        Ok(mut checkpoint) => {
664            // Accept v3 (current) or higher
665            if checkpoint.version >= 3 {
666                return Ok(checkpoint);
667            }
668            // v2 or v1 checkpoint parsed successfully as v3 struct - upgrade version
669            // and add v3 defaults
670            checkpoint.version = CHECKPOINT_VERSION;
671            return Ok(checkpoint);
672        }
673        Err(_e) => {
674            // First parse attempt failed, try v2 format below
675        }
676    }
677
678    // If v3 struct parsing failed, try v2 format and migrate to v3
679    #[derive(Debug, Clone, Serialize, Deserialize)]
680    struct V2Checkpoint {
681        version: u32,
682        phase: PipelinePhase,
683        iteration: u32,
684        total_iterations: u32,
685        reviewer_pass: u32,
686        total_reviewer_passes: u32,
687        timestamp: String,
688        developer_agent: String,
689        reviewer_agent: String,
690        cli_args: CliArgsSnapshot,
691        developer_agent_config: AgentConfigSnapshot,
692        reviewer_agent_config: AgentConfigSnapshot,
693        rebase_state: RebaseState,
694        config_path: Option<String>,
695        config_checksum: Option<String>,
696        working_dir: String,
697        prompt_md_checksum: Option<String>,
698        git_user_name: Option<String>,
699        git_user_email: Option<String>,
700        run_id: String,
701        parent_run_id: Option<String>,
702        resume_count: u32,
703        actual_developer_runs: u32,
704        actual_reviewer_runs: u32,
705    }
706
707    if let Ok(v2) = serde_json::from_str::<V2Checkpoint>(content) {
708        // Migrate v2 to v3: add new hardened resume fields (empty defaults)
709        // Note: working_dir from v2 checkpoint is preserved
710        return Ok(PipelineCheckpoint {
711            version: CHECKPOINT_VERSION,
712            phase: v2.phase,
713            iteration: v2.iteration,
714            total_iterations: v2.total_iterations,
715            reviewer_pass: v2.reviewer_pass,
716            total_reviewer_passes: v2.total_reviewer_passes,
717            timestamp: v2.timestamp,
718            developer_agent: v2.developer_agent,
719            reviewer_agent: v2.reviewer_agent,
720            cli_args: v2.cli_args,
721            developer_agent_config: v2.developer_agent_config,
722            reviewer_agent_config: v2.reviewer_agent_config,
723            rebase_state: v2.rebase_state,
724            config_path: v2.config_path,
725            config_checksum: v2.config_checksum,
726            working_dir: v2.working_dir,
727            prompt_md_checksum: v2.prompt_md_checksum,
728            git_user_name: v2.git_user_name,
729            git_user_email: v2.git_user_email,
730            run_id: v2.run_id,
731            parent_run_id: v2.parent_run_id,
732            resume_count: v2.resume_count,
733            actual_developer_runs: v2.actual_developer_runs,
734            actual_reviewer_runs: v2.actual_reviewer_runs,
735            // New v3 fields - use empty defaults for migrated checkpoints
736            execution_history: None,
737            file_system_state: None,
738            prompt_history: None,
739            env_snapshot: None,
740        });
741    }
742
743    // Try v1 format and migrate to v3
744    #[derive(Debug, Clone, Serialize, Deserialize)]
745    struct V1Checkpoint {
746        version: u32,
747        phase: PipelinePhase,
748        iteration: u32,
749        total_iterations: u32,
750        reviewer_pass: u32,
751        total_reviewer_passes: u32,
752        timestamp: String,
753        developer_agent: String,
754        reviewer_agent: String,
755        cli_args: CliArgsSnapshot,
756        developer_agent_config: AgentConfigSnapshot,
757        reviewer_agent_config: AgentConfigSnapshot,
758        rebase_state: RebaseState,
759        config_path: Option<String>,
760        config_checksum: Option<String>,
761        working_dir: String,
762        prompt_md_checksum: Option<String>,
763        git_user_name: Option<String>,
764        git_user_email: Option<String>,
765    }
766
767    if let Ok(v1) = serde_json::from_str::<V1Checkpoint>(content) {
768        // Migrate v1 to v3: generate new run_id, set defaults for new fields
769        let new_run_id = uuid::Uuid::new_v4().to_string();
770        return Ok(PipelineCheckpoint {
771            version: CHECKPOINT_VERSION,
772            phase: v1.phase,
773            iteration: v1.iteration,
774            total_iterations: v1.total_iterations,
775            reviewer_pass: v1.reviewer_pass,
776            total_reviewer_passes: v1.total_reviewer_passes,
777            timestamp: v1.timestamp,
778            developer_agent: v1.developer_agent,
779            reviewer_agent: v1.reviewer_agent,
780            cli_args: v1.cli_args,
781            developer_agent_config: v1.developer_agent_config,
782            reviewer_agent_config: v1.reviewer_agent_config,
783            rebase_state: v1.rebase_state,
784            config_path: v1.config_path,
785            config_checksum: v1.config_checksum,
786            working_dir: v1.working_dir,
787            prompt_md_checksum: v1.prompt_md_checksum,
788            git_user_name: v1.git_user_name,
789            git_user_email: v1.git_user_email,
790            // New v2 fields - use defaults for migrated checkpoints
791            run_id: new_run_id,
792            parent_run_id: None,
793            resume_count: 0,
794            actual_developer_runs: v1.iteration,
795            actual_reviewer_runs: v1.reviewer_pass,
796            // New v3 fields - use empty defaults for migrated checkpoints
797            execution_history: None,
798            file_system_state: None,
799            prompt_history: None,
800            env_snapshot: None,
801        });
802    }
803
804    // Try truly legacy format (pre-v1)
805    #[derive(Debug, Clone, Serialize, Deserialize)]
806    struct LegacyCheckpoint {
807        phase: PipelinePhase,
808        iteration: u32,
809        total_iterations: u32,
810        reviewer_pass: u32,
811        total_reviewer_passes: u32,
812        timestamp: String,
813        developer_agent: String,
814        reviewer_agent: String,
815    }
816
817    if let Ok(legacy) = serde_json::from_str::<LegacyCheckpoint>(content) {
818        let new_run_id = uuid::Uuid::new_v4().to_string();
819        return Ok(PipelineCheckpoint {
820            version: CHECKPOINT_VERSION,
821            phase: legacy.phase,
822            iteration: legacy.iteration,
823            total_iterations: legacy.total_iterations,
824            reviewer_pass: legacy.reviewer_pass,
825            total_reviewer_passes: legacy.total_reviewer_passes,
826            timestamp: legacy.timestamp,
827            developer_agent: legacy.developer_agent.clone(),
828            reviewer_agent: legacy.reviewer_agent.clone(),
829            cli_args: CliArgsSnapshotBuilder::new(0, 0, String::new(), None, false, true).build(),
830            developer_agent_config: AgentConfigSnapshot::new(
831                legacy.developer_agent.clone(),
832                String::new(),
833                String::new(),
834                None,
835                false,
836            ),
837            reviewer_agent_config: AgentConfigSnapshot::new(
838                legacy.reviewer_agent.clone(),
839                String::new(),
840                String::new(),
841                None,
842                false,
843            ),
844            rebase_state: RebaseState::default(),
845            config_path: None,
846            config_checksum: None,
847            working_dir: String::new(),
848            prompt_md_checksum: None,
849            git_user_name: None,
850            git_user_email: None,
851            run_id: new_run_id,
852            parent_run_id: None,
853            resume_count: 0,
854            actual_developer_runs: legacy.iteration,
855            actual_reviewer_runs: legacy.reviewer_pass,
856            // New v3 fields - use empty defaults for migrated checkpoints
857            execution_history: None,
858            file_system_state: None,
859            prompt_history: None,
860            env_snapshot: None,
861        });
862    }
863
864    Err("Invalid checkpoint format".into())
865}
866
867/// Get current timestamp in "YYYY-MM-DD HH:MM:SS" format.
868pub fn timestamp() -> String {
869    Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
870}
871
872/// Save a pipeline checkpoint to disk.
873///
874/// Writes the checkpoint atomically by writing to a temp file first,
875/// then renaming to the final path. This prevents corruption if the
876/// process is interrupted during the write.
877///
878/// # Errors
879///
880/// Returns an error if serialization fails or the file cannot be written.
881pub fn save_checkpoint(checkpoint: &PipelineCheckpoint) -> io::Result<()> {
882    let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
883        io::Error::new(
884            io::ErrorKind::InvalidData,
885            format!("Failed to serialize checkpoint: {e}"),
886        )
887    })?;
888
889    // Ensure the .agent directory exists before attempting to write
890    fs::create_dir_all(AGENT_DIR)?;
891
892    // Write atomically by writing to temp file then renaming
893    let checkpoint_path_str = checkpoint_path();
894    let temp_path = format!("{checkpoint_path_str}.tmp");
895
896    // Ensure temp file is cleaned up even if write or rename fails
897    let write_result = fs::write(&temp_path, &json);
898    if write_result.is_err() {
899        let _ = fs::remove_file(&temp_path);
900        return write_result;
901    }
902
903    let rename_result = fs::rename(&temp_path, &checkpoint_path_str);
904    if rename_result.is_err() {
905        let _ = fs::remove_file(&temp_path);
906        return rename_result;
907    }
908
909    Ok(())
910}
911
912/// Load an existing checkpoint if one exists.
913///
914/// Returns `Ok(Some(checkpoint))` if a valid checkpoint was loaded,
915/// `Ok(None)` if no checkpoint file exists, or an error if the file
916/// exists but cannot be parsed.
917///
918/// # Errors
919///
920/// Returns an error if the checkpoint file exists but cannot be read
921/// or contains invalid JSON.
922///
923/// # Note
924///
925/// This function handles both new format (v1) and legacy checkpoints
926/// for backward compatibility. Legacy checkpoints are migrated to the
927/// new format automatically.
928pub fn load_checkpoint() -> io::Result<Option<PipelineCheckpoint>> {
929    let checkpoint = checkpoint_path();
930    let path = Path::new(&checkpoint);
931    if !path.exists() {
932        return Ok(None);
933    }
934
935    let content = fs::read_to_string(path)?;
936    let loaded_checkpoint = load_checkpoint_with_fallback(&content).map_err(|e| {
937        io::Error::new(
938            io::ErrorKind::InvalidData,
939            format!("Failed to parse checkpoint: {e}"),
940        )
941    })?;
942
943    Ok(Some(loaded_checkpoint))
944}
945
946/// Delete the checkpoint file.
947///
948/// Called on successful pipeline completion to clean up the checkpoint.
949/// Does nothing if the checkpoint file doesn't exist.
950///
951/// # Errors
952///
953/// Returns an error if the file exists but cannot be deleted.
954pub fn clear_checkpoint() -> io::Result<()> {
955    let checkpoint = checkpoint_path();
956    let path = Path::new(&checkpoint);
957    if path.exists() {
958        fs::remove_file(path)?;
959    }
960    Ok(())
961}
962
963/// Check if a checkpoint exists.
964///
965/// Returns `true` if a checkpoint file exists, `false` otherwise.
966pub fn checkpoint_exists() -> bool {
967    Path::new(&checkpoint_path()).exists()
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use test_helpers::with_temp_cwd;
974
975    /// Helper function to create a checkpoint for testing.
976    fn make_test_checkpoint(phase: PipelinePhase, iteration: u32) -> PipelineCheckpoint {
977        let cli_args = CliArgsSnapshot::new(
978            5,
979            2,
980            "test commit".to_string(),
981            None,
982            false,
983            true,
984            2,
985            false,
986            None,
987        );
988        let dev_config =
989            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
990        let rev_config =
991            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
992        let run_id = uuid::Uuid::new_v4().to_string();
993        PipelineCheckpoint::from_params(CheckpointParams {
994            phase,
995            iteration,
996            total_iterations: 5,
997            reviewer_pass: 0,
998            total_reviewer_passes: 2,
999            developer_agent: "claude",
1000            reviewer_agent: "codex",
1001            cli_args,
1002            developer_agent_config: dev_config,
1003            reviewer_agent_config: rev_config,
1004            rebase_state: RebaseState::default(),
1005            git_user_name: None,
1006            git_user_email: None,
1007            run_id: &run_id,
1008            parent_run_id: None,
1009            resume_count: 0,
1010            actual_developer_runs: iteration,
1011            actual_reviewer_runs: 0,
1012        })
1013    }
1014
1015    #[test]
1016    fn test_timestamp_format() {
1017        let ts = timestamp();
1018        assert!(ts.contains('-'));
1019        assert!(ts.contains(':'));
1020        assert_eq!(ts.len(), 19);
1021    }
1022
1023    #[test]
1024    fn test_pipeline_phase_display() {
1025        assert_eq!(format!("{}", PipelinePhase::Rebase), "Rebase");
1026        assert_eq!(format!("{}", PipelinePhase::Planning), "Planning");
1027        assert_eq!(format!("{}", PipelinePhase::Development), "Development");
1028        assert_eq!(format!("{}", PipelinePhase::Review), "Review");
1029        assert_eq!(format!("{}", PipelinePhase::Fix), "Fix");
1030        assert_eq!(
1031            format!("{}", PipelinePhase::ReviewAgain),
1032            "Verification Review"
1033        );
1034        assert_eq!(
1035            format!("{}", PipelinePhase::CommitMessage),
1036            "Commit Message Generation"
1037        );
1038        assert_eq!(
1039            format!("{}", PipelinePhase::FinalValidation),
1040            "Final Validation"
1041        );
1042        assert_eq!(format!("{}", PipelinePhase::Complete), "Complete");
1043        assert_eq!(format!("{}", PipelinePhase::PreRebase), "Pre-Rebase");
1044        assert_eq!(
1045            format!("{}", PipelinePhase::PreRebaseConflict),
1046            "Pre-Rebase Conflict"
1047        );
1048        assert_eq!(format!("{}", PipelinePhase::PostRebase), "Post-Rebase");
1049        assert_eq!(
1050            format!("{}", PipelinePhase::PostRebaseConflict),
1051            "Post-Rebase Conflict"
1052        );
1053        assert_eq!(format!("{}", PipelinePhase::Interrupted), "Interrupted");
1054    }
1055
1056    #[test]
1057    fn test_checkpoint_from_params() {
1058        let cli_args =
1059            CliArgsSnapshot::new(5, 2, "test".to_string(), None, false, true, 2, false, None);
1060        let dev_config =
1061            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1062        let rev_config =
1063            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1064        let run_id = uuid::Uuid::new_v4().to_string();
1065        let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1066            phase: PipelinePhase::Development,
1067            iteration: 2,
1068            total_iterations: 5,
1069            reviewer_pass: 0,
1070            total_reviewer_passes: 2,
1071            developer_agent: "claude",
1072            reviewer_agent: "codex",
1073            cli_args,
1074            developer_agent_config: dev_config,
1075            reviewer_agent_config: rev_config,
1076            rebase_state: RebaseState::default(),
1077            git_user_name: None,
1078            git_user_email: None,
1079            run_id: &run_id,
1080            parent_run_id: None,
1081            resume_count: 0,
1082            actual_developer_runs: 2,
1083            actual_reviewer_runs: 0,
1084        });
1085
1086        assert_eq!(checkpoint.phase, PipelinePhase::Development);
1087        assert_eq!(checkpoint.iteration, 2);
1088        assert_eq!(checkpoint.total_iterations, 5);
1089        assert_eq!(checkpoint.reviewer_pass, 0);
1090        assert_eq!(checkpoint.total_reviewer_passes, 2);
1091        assert_eq!(checkpoint.developer_agent, "claude");
1092        assert_eq!(checkpoint.reviewer_agent, "codex");
1093        assert_eq!(checkpoint.version, CHECKPOINT_VERSION);
1094        assert!(!checkpoint.timestamp.is_empty());
1095        assert_eq!(checkpoint.run_id, run_id);
1096        assert_eq!(checkpoint.resume_count, 0);
1097        assert_eq!(checkpoint.actual_developer_runs, 2);
1098        assert!(checkpoint.parent_run_id.is_none());
1099    }
1100
1101    #[test]
1102    fn test_checkpoint_description() {
1103        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3);
1104        assert_eq!(checkpoint.description(), "Development iteration 3/5");
1105
1106        let run_id = uuid::Uuid::new_v4().to_string();
1107        let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1108            phase: PipelinePhase::ReviewAgain,
1109            iteration: 5,
1110            total_iterations: 5,
1111            reviewer_pass: 2,
1112            total_reviewer_passes: 3,
1113            developer_agent: "claude",
1114            reviewer_agent: "codex",
1115            cli_args: CliArgsSnapshot::new(
1116                5,
1117                3,
1118                "test".to_string(),
1119                None,
1120                false,
1121                true,
1122                2,
1123                false,
1124                None,
1125            ),
1126            developer_agent_config: AgentConfigSnapshot::new(
1127                "claude".into(),
1128                "cmd".into(),
1129                "-o".into(),
1130                None,
1131                true,
1132            ),
1133            reviewer_agent_config: AgentConfigSnapshot::new(
1134                "codex".into(),
1135                "cmd".into(),
1136                "-o".into(),
1137                None,
1138                true,
1139            ),
1140            rebase_state: RebaseState::default(),
1141            git_user_name: None,
1142            git_user_email: None,
1143            run_id: &run_id,
1144            parent_run_id: None,
1145            resume_count: 0,
1146            actual_developer_runs: 5,
1147            actual_reviewer_runs: 2,
1148        });
1149        assert_eq!(checkpoint.description(), "Verification review 2/3");
1150    }
1151
1152    #[test]
1153    fn test_checkpoint_save_load() {
1154        with_temp_cwd(|_dir| {
1155            fs::create_dir_all(".agent").unwrap();
1156
1157            let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5);
1158
1159            save_checkpoint(&checkpoint).unwrap();
1160            assert!(checkpoint_exists());
1161
1162            let loaded = load_checkpoint()
1163                .unwrap()
1164                .expect("checkpoint should exist after save_checkpoint");
1165            assert_eq!(loaded.phase, PipelinePhase::Review);
1166            assert_eq!(loaded.iteration, 5);
1167            assert_eq!(loaded.developer_agent, "claude");
1168            assert_eq!(loaded.reviewer_agent, "codex");
1169            assert_eq!(loaded.version, CHECKPOINT_VERSION);
1170        });
1171    }
1172
1173    #[test]
1174    fn test_checkpoint_clear() {
1175        with_temp_cwd(|_dir| {
1176            fs::create_dir_all(".agent").unwrap();
1177
1178            let checkpoint = make_test_checkpoint(PipelinePhase::Development, 1);
1179
1180            save_checkpoint(&checkpoint).unwrap();
1181            assert!(checkpoint_exists());
1182
1183            clear_checkpoint().unwrap();
1184            assert!(!checkpoint_exists());
1185        });
1186    }
1187
1188    #[test]
1189    fn test_load_checkpoint_nonexistent() {
1190        with_temp_cwd(|_dir| {
1191            fs::create_dir_all(".agent").unwrap();
1192
1193            let result = load_checkpoint().unwrap();
1194            assert!(result.is_none());
1195        });
1196    }
1197
1198    #[test]
1199    fn test_checkpoint_serialization() {
1200        let run_id = uuid::Uuid::new_v4().to_string();
1201        let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1202            phase: PipelinePhase::Fix,
1203            iteration: 3,
1204            total_iterations: 5,
1205            reviewer_pass: 1,
1206            total_reviewer_passes: 2,
1207            developer_agent: "aider",
1208            reviewer_agent: "opencode",
1209            cli_args: CliArgsSnapshot::new(
1210                5,
1211                2,
1212                "fix".to_string(),
1213                Some("standard".into()),
1214                false,
1215                true,
1216                2,
1217                false,
1218                None,
1219            ),
1220            developer_agent_config: AgentConfigSnapshot::new(
1221                "aider".into(),
1222                "aider".into(),
1223                "-o".into(),
1224                Some("--yes".into()),
1225                true,
1226            ),
1227            reviewer_agent_config: AgentConfigSnapshot::new(
1228                "opencode".into(),
1229                "opencode".into(),
1230                "-o".into(),
1231                None,
1232                false,
1233            ),
1234            rebase_state: RebaseState::PreRebaseCompleted {
1235                commit_oid: "abc123".into(),
1236            },
1237            git_user_name: None,
1238            git_user_email: None,
1239            run_id: &run_id,
1240            parent_run_id: None,
1241            resume_count: 0,
1242            actual_developer_runs: 3,
1243            actual_reviewer_runs: 1,
1244        });
1245
1246        let json = serde_json::to_string(&checkpoint).unwrap();
1247        assert!(json.contains("Fix"));
1248        assert!(json.contains("aider"));
1249        assert!(json.contains("opencode"));
1250        assert!(json.contains("\"version\":"));
1251
1252        let deserialized: PipelineCheckpoint = serde_json::from_str(&json).unwrap();
1253        assert_eq!(deserialized.phase, checkpoint.phase);
1254        assert_eq!(deserialized.iteration, checkpoint.iteration);
1255        assert_eq!(deserialized.cli_args.developer_iters, 5);
1256        assert_eq!(deserialized.cli_args.commit_msg, "fix");
1257        assert!(matches!(
1258            deserialized.rebase_state,
1259            RebaseState::PreRebaseCompleted { .. }
1260        ));
1261        assert_eq!(deserialized.run_id, run_id);
1262        assert_eq!(deserialized.actual_developer_runs, 3);
1263        assert_eq!(deserialized.actual_reviewer_runs, 1);
1264    }
1265
1266    #[test]
1267    fn test_cli_args_snapshot() {
1268        let snapshot = CliArgsSnapshot::new(
1269            10,
1270            3,
1271            "feat: new feature".to_string(),
1272            Some("comprehensive".into()),
1273            true,
1274            true,
1275            3,
1276            true,
1277            Some("claude".to_string()),
1278        );
1279
1280        assert_eq!(snapshot.developer_iters, 10);
1281        assert_eq!(snapshot.reviewer_reviews, 3);
1282        assert_eq!(snapshot.commit_msg, "feat: new feature");
1283        assert_eq!(snapshot.review_depth, Some("comprehensive".to_string()));
1284        assert!(snapshot.skip_rebase);
1285        assert!(snapshot.isolation_mode);
1286        assert_eq!(snapshot.verbosity, 3);
1287        assert!(snapshot.show_streaming_metrics);
1288        assert_eq!(snapshot.reviewer_json_parser, Some("claude".to_string()));
1289    }
1290
1291    #[test]
1292    fn test_agent_config_snapshot() {
1293        let config = AgentConfigSnapshot::new(
1294            "test-agent".into(),
1295            "/usr/bin/test".into(),
1296            "--output".into(),
1297            Some("--yolo".into()),
1298            false,
1299        );
1300
1301        assert_eq!(config.name, "test-agent");
1302        assert_eq!(config.cmd, "/usr/bin/test");
1303        assert_eq!(config.output_flag, "--output");
1304        assert_eq!(config.yolo_flag, Some("--yolo".to_string()));
1305        assert!(!config.can_commit);
1306    }
1307
1308    #[test]
1309    fn test_rebase_state() {
1310        let state = RebaseState::PreRebaseInProgress {
1311            upstream_branch: "main".into(),
1312        };
1313        assert!(matches!(state, RebaseState::PreRebaseInProgress { .. }));
1314
1315        let state = RebaseState::Failed {
1316            error: "conflict".into(),
1317        };
1318        assert!(matches!(state, RebaseState::Failed { .. }));
1319    }
1320
1321    #[test]
1322    fn test_calculate_file_checksum() {
1323        with_temp_cwd(|_dir| {
1324            fs::create_dir_all(".agent").unwrap();
1325
1326            // Create a test file in current directory (temp cwd)
1327            let test_file = Path::new("test.txt");
1328            fs::write(&test_file, "test content").unwrap();
1329
1330            let checksum1 = calculate_file_checksum(test_file);
1331            assert!(checksum1.is_some());
1332
1333            // Same content should give same checksum
1334            let checksum2 = calculate_file_checksum(test_file);
1335            assert_eq!(checksum1, checksum2);
1336
1337            // Different content should give different checksum
1338            fs::write(&test_file, "different content").unwrap();
1339            let checksum3 = calculate_file_checksum(test_file);
1340            assert_ne!(checksum1, checksum3);
1341
1342            // Non-existent file should return None
1343            let nonexistent = Path::new("nonexistent.txt");
1344            assert!(calculate_file_checksum(nonexistent).is_none());
1345        });
1346    }
1347
1348    #[test]
1349    fn test_load_checkpoint_preserves_working_dir() {
1350        with_temp_cwd(|_dir| {
1351            fs::create_dir_all(".agent").unwrap();
1352
1353            let json = r#"{
1354                "version": 1,
1355                "phase": "Development",
1356                "iteration": 1,
1357                "total_iterations": 1,
1358                "reviewer_pass": 0,
1359                "total_reviewer_passes": 0,
1360                "timestamp": "2024-01-01 12:00:00",
1361                "developer_agent": "test-agent",
1362                "reviewer_agent": "test-agent",
1363                "cli_args": {
1364                    "developer_iters": 1,
1365                    "reviewer_reviews": 0,
1366                    "commit_msg": "",
1367                    "review_depth": null,
1368                    "skip_rebase": false
1369                },
1370                "developer_agent_config": {
1371                    "name": "test-agent",
1372                    "cmd": "echo",
1373                    "output_flag": "",
1374                    "yolo_flag": null,
1375                    "can_commit": false,
1376                    "model_override": null,
1377                    "provider_override": null,
1378                    "context_level": 1
1379                },
1380                "reviewer_agent_config": {
1381                    "name": "test-agent",
1382                    "cmd": "echo",
1383                    "output_flag": "",
1384                    "yolo_flag": null,
1385                    "can_commit": false,
1386                    "model_override": null,
1387                    "provider_override": null,
1388                    "context_level": 1
1389                },
1390                "rebase_state": "NotStarted",
1391                "config_path": null,
1392                "config_checksum": null,
1393                "working_dir": "/some/other/directory",
1394                "prompt_md_checksum": null,
1395                "git_user_name": null,
1396                "git_user_email": null
1397            }"#;
1398
1399            fs::write(".agent/checkpoint.json", json).unwrap();
1400
1401            let loaded = load_checkpoint().unwrap().expect("should load checkpoint");
1402            assert_eq!(
1403                loaded.working_dir, "/some/other/directory",
1404                "working_dir should be preserved from JSON"
1405            );
1406        });
1407    }
1408}