Skip to main content

ares_agent/
checkpoint.rs

1//! Agent checkpoint/crash recovery system.
2//!
3//! Serializes agent state to disk before each step. On restart,
4//! restores from the latest checkpoint and resumes execution.
5//!
6//! Inspired by Octopoda-OS crash recovery patterns.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12/// Agent state captured at a point in time (alias for [`Checkpoint`]).
13pub type AgentCheckpoint = Checkpoint;
14
15/// A checkpoint captures agent state at a point in time.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Checkpoint {
18    /// Unique checkpoint ID
19    pub id: String,
20    /// Agent name/type
21    pub agent_name: String,
22    /// Session ID
23    pub session_id: String,
24    /// Step number (0-indexed)
25    pub step: usize,
26    /// Conversation messages so far
27    pub messages: Vec<CheckpointMessage>,
28    /// Tool calls made and their results
29    pub tool_calls: Vec<ToolCallRecord>,
30    /// Partial results accumulated
31    pub partial_results: Vec<String>,
32    /// Timestamp (Unix epoch seconds)
33    pub timestamp: u64,
34    /// Optional TTL in seconds from `timestamp`.
35    #[serde(default)]
36    pub ttl_secs: Option<u64>,
37    /// Status of this checkpoint
38    pub status: CheckpointStatus,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct CheckpointMessage {
43    pub role: String, // "user" | "assistant" | "system"
44    pub content: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct ToolCallRecord {
49    pub tool_name: String,
50    pub arguments: String,
51    pub result: Option<String>,
52    pub success: bool,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum CheckpointStatus {
57    /// Agent is actively running
58    InProgress,
59    /// Agent completed successfully
60    Completed,
61    /// Agent failed/crashed
62    Failed(String),
63    /// Agent was halted (e.g., by loop detector)
64    Halted(String),
65}
66
67
68
69/// On-disk / index metadata for a checkpoint.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct CheckpointMetadata {
72    pub agent_id: String,
73    pub run_id: String,
74    pub step: usize,
75    pub timestamp: u64,
76    /// Optional TTL in seconds from `timestamp`.
77    pub ttl_secs: Option<u64>,
78}
79
80/// Errors from checkpoint serialization and lookup.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CheckpointError {
83    NotFound(String),
84    Corrupt(String),
85    IoError(String),
86}
87
88impl fmt::Display for CheckpointError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::NotFound(msg) => write!(f, "checkpoint not found: {msg}"),
92            Self::Corrupt(msg) => write!(f, "checkpoint corrupt: {msg}"),
93            Self::IoError(msg) => write!(f, "checkpoint io error: {msg}"),
94        }
95    }
96}
97
98impl std::error::Error for CheckpointError {}
99
100impl fmt::Display for Checkpoint {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(
103            f,
104            "Checkpoint {{ id: {}, agent: {}, session: {}, step: {}, status: {:?} }}",
105            self.id, self.agent_name, self.session_id, self.step, self.status
106        )
107    }
108}
109
110impl fmt::Display for CheckpointMetadata {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "CheckpointMetadata {{ agent_id: {}, run_id: {}, step: {}, ts: {} }}",
115            self.agent_id, self.run_id, self.step, self.timestamp
116        )
117    }
118}
119
120impl From<&Checkpoint> for CheckpointMetadata {
121    fn from(cp: &Checkpoint) -> Self {
122        Self {
123            agent_id: cp.agent_name.clone(),
124            run_id: cp.session_id.clone(),
125            step: cp.step,
126            timestamp: cp.timestamp,
127            ttl_secs: cp.ttl_secs,
128        }
129    }
130}
131
132/// Stable storage key for an agent run (`agent_id/run_id`).
133pub fn checkpoint_key(agent_id: &str, run_id: &str) -> String {
134    format!("{agent_id}/{run_id}")
135}
136
137/// Build a new in-memory checkpoint for the given agent run and step.
138#[allow(clippy::too_many_arguments)]
139pub fn create_checkpoint(
140    agent_id: &str,
141    run_id: &str,
142    step: usize,
143    messages: Vec<CheckpointMessage>,
144    tool_calls: Vec<ToolCallRecord>,
145    partial_results: Vec<String>,
146    timestamp: u64,
147    status: CheckpointStatus,
148) -> AgentCheckpoint {
149    create_checkpoint_with_ttl(
150        agent_id, run_id, step, messages, tool_calls, partial_results, timestamp, None, status,
151    )
152}
153
154/// Like [`create_checkpoint`] but attaches an optional TTL.
155#[allow(clippy::too_many_arguments)]
156pub fn create_checkpoint_with_ttl(
157    agent_id: &str,
158    run_id: &str,
159    step: usize,
160    messages: Vec<CheckpointMessage>,
161    tool_calls: Vec<ToolCallRecord>,
162    partial_results: Vec<String>,
163    timestamp: u64,
164    ttl_secs: Option<u64>,
165    status: CheckpointStatus,
166) -> AgentCheckpoint {
167    let key = checkpoint_key(agent_id, run_id);
168    AgentCheckpoint {
169        id: format!("{key}:step{step}"),
170        agent_name: agent_id.to_string(),
171        session_id: run_id.to_string(),
172        step,
173        messages,
174        tool_calls,
175        partial_results,
176        timestamp,
177        ttl_secs,
178        status,
179    }
180}
181
182/// Serialize checkpoint state to JSON bytes.
183pub fn serialize_state(checkpoint: &AgentCheckpoint) -> Result<Vec<u8>, CheckpointError> {
184    serde_json::to_vec(checkpoint).map_err(|e| CheckpointError::IoError(e.to_string()))
185}
186
187/// Restore checkpoint state from serialized JSON bytes.
188pub fn restore_checkpoint(bytes: &[u8]) -> Result<AgentCheckpoint, CheckpointError> {
189    if bytes.is_empty() {
190        return Err(CheckpointError::Corrupt("empty payload".into()));
191    }
192    serde_json::from_slice(bytes).map_err(|e| CheckpointError::Corrupt(e.to_string()))
193}
194
195/// Returns true when `now` is past `metadata.timestamp + ttl_secs`.
196pub fn is_expired(metadata: &CheckpointMetadata, now: u64) -> bool {
197    metadata
198        .ttl_secs
199        .is_some_and(|ttl| now > metadata.timestamp.saturating_add(ttl))
200}
201
202/// Return metadata entries that have expired relative to `now`.
203pub fn expired_metadata<'a>(
204    entries: impl IntoIterator<Item = &'a CheckpointMetadata>,
205    now: u64,
206) -> Vec<&'a CheckpointMetadata> {
207    entries.into_iter().filter(|m| is_expired(m, now)).collect()
208}
209
210fn checkpoint_step_filename(run_id: &str, step: usize) -> String {
211    format!("{run_id}_{step}.json")
212}
213
214/// Manages checkpoints for agent crash recovery.
215pub struct CheckpointManager {
216    /// Directory to store checkpoint files
217    checkpoint_dir: PathBuf,
218}
219
220impl CheckpointManager {
221    /// Create a new checkpoint manager.
222    pub fn new(checkpoint_dir: &Path) -> std::io::Result<Self> {
223        std::fs::create_dir_all(checkpoint_dir)?;
224        Ok(Self {
225            checkpoint_dir: checkpoint_dir.to_path_buf(),
226        })
227    }
228
229    /// Create a default checkpoint manager (~/.ares/checkpoints/).
230    pub fn default_dir() -> std::io::Result<Self> {
231        let dir = dirs_or_default().join("checkpoints");
232        Self::new(&dir)
233    }
234
235    /// Save a checkpoint to disk.
236    pub fn save(&self, checkpoint: &Checkpoint) -> std::io::Result<()> {
237        let filename = checkpoint_step_filename(&checkpoint.session_id, checkpoint.step);
238        let path = self.checkpoint_dir.join(&filename);
239        let bytes = serialize_state(checkpoint)
240            .map_err(|e| std::io::Error::other(e.to_string()))?;
241        std::fs::write(&path, bytes)?;
242
243        // Also update the "latest" symlink/pointer
244        let latest_path = self.checkpoint_dir.join(format!("{}_latest.json", checkpoint.session_id));
245        std::fs::write(&latest_path, &filename)?;
246
247        Ok(())
248    }
249
250    /// Load the latest checkpoint for a session.
251    pub fn load_latest(&self, session_id: &str) -> std::io::Result<Option<Checkpoint>> {
252        let latest_path = self.checkpoint_dir.join(format!("{}_latest.json", session_id));
253        if !latest_path.exists() {
254            return Ok(None);
255        }
256
257        let filename = std::fs::read_to_string(&latest_path)?;
258        let checkpoint_path = self.checkpoint_dir.join(filename.trim());
259        if !checkpoint_path.exists() {
260            return Ok(None);
261        }
262
263        let bytes = std::fs::read(&checkpoint_path)?;
264        let checkpoint = restore_checkpoint(&bytes)
265            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
266        Ok(Some(checkpoint))
267    }
268
269    /// List all checkpoints for a session, ordered by step.
270    pub fn list_checkpoints(&self, session_id: &str) -> std::io::Result<Vec<Checkpoint>> {
271        let mut checkpoints = Vec::new();
272        let prefix = format!("{}_", session_id);
273
274        for entry in std::fs::read_dir(&self.checkpoint_dir)? {
275            let entry = entry?;
276            let name = entry.file_name().to_string_lossy().to_string();
277            if name.starts_with(&prefix) && name.ends_with(".json") && !name.contains("latest") {
278                let bytes = std::fs::read(entry.path())?;
279                if let Ok(cp) = restore_checkpoint(&bytes) {
280                    checkpoints.push(cp);
281                }
282            }
283        }
284
285        checkpoints.sort_by_key(|c| c.step);
286        Ok(checkpoints)
287    }
288
289    /// Clean up old checkpoints for a completed session.
290    pub fn cleanup(&self, session_id: &str) -> std::io::Result<usize> {
291        let mut removed = 0;
292        let prefix = format!("{}_", session_id);
293
294        for entry in std::fs::read_dir(&self.checkpoint_dir)? {
295            let entry = entry?;
296            let name = entry.file_name().to_string_lossy().to_string();
297            if name.starts_with(&prefix) {
298                std::fs::remove_file(entry.path())?;
299                removed += 1;
300            }
301        }
302
303        Ok(removed)
304    }
305
306
307    /// Remove checkpoint files whose metadata has expired relative to `now`.
308    pub fn cleanup_expired(&self, now: u64) -> std::io::Result<usize> {
309        let mut removed = 0;
310        for entry in std::fs::read_dir(&self.checkpoint_dir)? {
311            let entry = entry?;
312            let path = entry.path();
313            let name = entry.file_name().to_string_lossy().to_string();
314            if !name.ends_with(".json") || name.contains("latest") {
315                continue;
316            }
317            let bytes = match std::fs::read(&path) {
318                Ok(b) => b,
319                Err(_) => continue,
320            };
321            let Ok(cp) = restore_checkpoint(&bytes) else {
322                continue;
323            };
324            if is_expired(&CheckpointMetadata::from(&cp), now) {
325                std::fs::remove_file(path)?;
326                removed += 1;
327            }
328        }
329        Ok(removed)
330    }
331
332    /// Check if a session has a recoverable checkpoint.
333    pub fn has_checkpoint(&self, session_id: &str) -> bool {
334        let latest_path = self.checkpoint_dir.join(format!("{}_latest.json", session_id));
335        latest_path.exists()
336    }
337}
338
339fn dirs_or_default() -> PathBuf {
340    dirs::data_dir()
341        .unwrap_or_else(|| PathBuf::from("/tmp"))
342        .join("ares")
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::time::{SystemTime, UNIX_EPOCH};
349
350    fn now_secs() -> u64 {
351        SystemTime::now()
352            .duration_since(UNIX_EPOCH)
353            .unwrap()
354            .as_secs()
355    }
356
357    fn temp_dir() -> tempfile::TempDir {
358        tempfile::tempdir().unwrap()
359    }
360
361    fn sample_messages() -> Vec<CheckpointMessage> {
362        vec![
363            CheckpointMessage {
364                role: "user".into(),
365                content: "Hello".into(),
366            },
367            CheckpointMessage {
368                role: "assistant".into(),
369                content: "Hi there".into(),
370            },
371        ]
372    }
373
374    fn sample_tool_calls() -> Vec<ToolCallRecord> {
375        vec![ToolCallRecord {
376            tool_name: "search".into(),
377            arguments: "query".into(),
378            result: Some("found it".into()),
379            success: true,
380        }]
381    }
382
383    fn sample_checkpoint(session: &str, step: usize) -> Checkpoint {
384        create_checkpoint(
385            "test-agent",
386            session,
387            step,
388            sample_messages(),
389            sample_tool_calls(),
390            vec!["partial output".into()],
391            now_secs(),
392            CheckpointStatus::InProgress,
393        )
394    }
395
396    #[test]
397    fn checkpoint_key_uses_agent_id_run_id_format() {
398        assert_eq!(checkpoint_key("agent-a", "run-42"), "agent-a/run-42");
399    }
400
401    #[test]
402    fn checkpoint_key_unique_per_pair() {
403        assert_ne!(checkpoint_key("a1", "r1"), checkpoint_key("a1", "r2"));
404        assert_ne!(checkpoint_key("a1", "r1"), checkpoint_key("a2", "r1"));
405    }
406
407    #[test]
408    fn create_checkpoint_sets_ids_and_fields() {
409        let cp = create_checkpoint(
410            "researcher",
411            "run-9",
412            2,
413            sample_messages(),
414            sample_tool_calls(),
415            vec!["out".into()],
416            1_700_000_000,
417            CheckpointStatus::InProgress,
418        );
419        assert_eq!(cp.agent_name, "researcher");
420        assert_eq!(cp.session_id, "run-9");
421        assert_eq!(cp.step, 2);
422        assert!(cp.id.contains("researcher/run-9"));
423    }
424
425    #[test]
426    fn serialize_state_and_restore_checkpoint_are_symmetric() {
427        let cp = sample_checkpoint("sym", 1);
428        let bytes = serialize_state(&cp).unwrap();
429        assert_eq!(restore_checkpoint(&bytes).unwrap(), cp);
430    }
431
432    #[test]
433    fn restore_checkpoint_rejects_empty_bytes() {
434        assert!(matches!(
435            restore_checkpoint(&[]),
436            Err(CheckpointError::Corrupt(_))
437        ));
438    }
439
440    #[test]
441    fn restore_checkpoint_rejects_invalid_json() {
442        assert!(matches!(
443            restore_checkpoint(b"{not json"),
444            Err(CheckpointError::Corrupt(_))
445        ));
446    }
447
448    #[test]
449    fn agent_checkpoint_serde_json_roundtrip() {
450        let cp = sample_checkpoint("serde-cp", 0);
451        let json = serde_json::to_string(&cp).unwrap();
452        let back: AgentCheckpoint = serde_json::from_str(&json).unwrap();
453        assert_eq!(back, cp);
454    }
455
456    #[test]
457    fn checkpoint_metadata_serde_json_roundtrip() {
458        let meta = CheckpointMetadata {
459            agent_id: "agent-x".into(),
460            run_id: "run-y".into(),
461            step: 5,
462            timestamp: 100,
463            ttl_secs: Some(3600),
464        };
465        let back: CheckpointMetadata = serde_json::from_str(&serde_json::to_string(&meta).unwrap()).unwrap();
466        assert_eq!(back, meta);
467    }
468
469    #[test]
470    fn checkpoint_metadata_from_checkpoint() {
471        let cp = sample_checkpoint("run-meta", 3);
472        let meta = CheckpointMetadata::from(&cp);
473        assert_eq!(meta.agent_id, "test-agent");
474        assert_eq!(meta.run_id, "run-meta");
475        assert_eq!(meta.step, 3);
476        assert!(meta.ttl_secs.is_none());
477    }
478
479    #[test]
480    fn is_expired_false_without_ttl() {
481        let meta = CheckpointMetadata {
482            agent_id: "a".into(),
483            run_id: "r".into(),
484            step: 0,
485            timestamp: 100,
486            ttl_secs: None,
487        };
488        assert!(!is_expired(&meta, 999_999));
489    }
490
491    #[test]
492    fn is_expired_true_past_ttl() {
493        let meta = CheckpointMetadata {
494            agent_id: "a".into(),
495            run_id: "r".into(),
496            step: 0,
497            timestamp: 100,
498            ttl_secs: Some(60),
499        };
500        assert!(!is_expired(&meta, 160));
501        assert!(is_expired(&meta, 161));
502    }
503
504    #[test]
505    fn expired_metadata_filters_only_stale() {
506        let fresh = CheckpointMetadata {
507            agent_id: "a".into(),
508            run_id: "fresh".into(),
509            step: 0,
510            timestamp: 180,
511            ttl_secs: Some(60),
512        };
513        let stale = CheckpointMetadata {
514            agent_id: "a".into(),
515            run_id: "stale".into(),
516            step: 0,
517            timestamp: 100,
518            ttl_secs: Some(60),
519        };
520        let expired = expired_metadata([&fresh, &stale], 200);
521        assert_eq!(expired.len(), 1);
522        assert_eq!(expired[0].run_id, "stale");
523    }
524
525    #[test]
526    fn checkpoint_error_not_found_display() {
527        let err = CheckpointError::NotFound("sess-1".into());
528        assert_eq!(err.to_string(), "checkpoint not found: sess-1");
529    }
530
531    #[test]
532    fn checkpoint_error_corrupt_display() {
533        let err = CheckpointError::Corrupt("bad json".into());
534        assert_eq!(err.to_string(), "checkpoint corrupt: bad json");
535    }
536
537    #[test]
538    fn checkpoint_error_io_error_display() {
539        let err = CheckpointError::IoError("disk full".into());
540        assert_eq!(err.to_string(), "checkpoint io error: disk full");
541    }
542
543    #[test]
544    fn checkpoint_error_debug_contains_variant() {
545        let dbg = format!("{:?}", CheckpointError::NotFound("x".into()));
546        assert!(dbg.contains("NotFound"));
547    }
548
549    #[test]
550    fn checkpoint_clone_equals_original() {
551        let cp = sample_checkpoint("clone", 0);
552        assert_eq!(cp.clone(), cp);
553    }
554
555    #[test]
556    fn checkpoint_metadata_clone_equals_original() {
557        let meta = CheckpointMetadata::from(&sample_checkpoint("clone-meta", 1));
558        assert_eq!(meta.clone(), meta);
559    }
560
561    #[test]
562    fn checkpoint_display_includes_ids() {
563        let s = sample_checkpoint("disp", 4).to_string();
564        assert!(s.contains("disp"));
565        assert!(s.contains("test-agent"));
566    }
567
568    #[test]
569    fn checkpoint_metadata_display_includes_run_id() {
570        let s = CheckpointMetadata::from(&sample_checkpoint("disp-meta", 0)).to_string();
571        assert!(s.contains("disp-meta"));
572    }
573
574    #[test]
575    fn checkpoint_debug_impl_available() {
576        let dbg = format!("{:?}", sample_checkpoint("dbg", 0));
577        assert!(dbg.contains("Checkpoint"));
578    }
579
580    #[test]
581    fn test_save_and_load() {
582        let dir = temp_dir();
583        let mgr = CheckpointManager::new(dir.path()).unwrap();
584        let cp = sample_checkpoint("sess1", 0);
585        mgr.save(&cp).unwrap();
586        let loaded = mgr.load_latest("sess1").unwrap().unwrap();
587        assert_eq!(loaded.session_id, "sess1");
588        assert_eq!(loaded.step, 0);
589    }
590
591    #[test]
592    fn test_load_nonexistent() {
593        let dir = temp_dir();
594        let mgr = CheckpointManager::new(dir.path()).unwrap();
595        assert!(mgr.load_latest("nonexistent").unwrap().is_none());
596    }
597
598    #[test]
599    fn test_multiple_steps() {
600        let dir = temp_dir();
601        let mgr = CheckpointManager::new(dir.path()).unwrap();
602        mgr.save(&sample_checkpoint("sess1", 0)).unwrap();
603        mgr.save(&sample_checkpoint("sess1", 1)).unwrap();
604        mgr.save(&sample_checkpoint("sess1", 2)).unwrap();
605        assert_eq!(mgr.load_latest("sess1").unwrap().unwrap().step, 2);
606        assert_eq!(mgr.list_checkpoints("sess1").unwrap().len(), 3);
607    }
608
609    #[test]
610    fn test_cleanup() {
611        let dir = temp_dir();
612        let mgr = CheckpointManager::new(dir.path()).unwrap();
613        mgr.save(&sample_checkpoint("sess1", 0)).unwrap();
614        mgr.save(&sample_checkpoint("sess1", 1)).unwrap();
615        assert!(mgr.cleanup("sess1").unwrap() >= 2);
616        assert!(!mgr.has_checkpoint("sess1"));
617    }
618
619    #[test]
620    fn cleanup_expired_removes_stale_files_only() {
621        let dir = temp_dir();
622        let mgr = CheckpointManager::new(dir.path()).unwrap();
623        let now = now_secs();
624        let stale = create_checkpoint_with_ttl(
625            "test-agent",
626            "expired-run",
627            0,
628            sample_messages(),
629            sample_tool_calls(),
630            vec![],
631            now.saturating_sub(10_000),
632            Some(60),
633            CheckpointStatus::InProgress,
634        );
635        mgr.save(&stale).unwrap();
636        mgr.save(&sample_checkpoint("fresh-run", 0)).unwrap();
637        assert_eq!(mgr.cleanup_expired(now).unwrap(), 1);
638        assert!(mgr.load_latest("expired-run").unwrap().is_none());
639        assert!(mgr.load_latest("fresh-run").unwrap().is_some());
640    }
641
642    #[test]
643    fn test_separate_sessions() {
644        let dir = temp_dir();
645        let mgr = CheckpointManager::new(dir.path()).unwrap();
646        mgr.save(&sample_checkpoint("sess1", 0)).unwrap();
647        mgr.save(&sample_checkpoint("sess2", 0)).unwrap();
648        mgr.cleanup("sess1").unwrap();
649        assert!(!mgr.has_checkpoint("sess1"));
650        assert!(mgr.has_checkpoint("sess2"));
651    }
652
653    #[test]
654    fn test_checkpoint_status_serialization() {
655        let dir = temp_dir();
656        let mgr = CheckpointManager::new(dir.path()).unwrap();
657        let mut cp = sample_checkpoint("sess1", 0);
658        cp.status = CheckpointStatus::Failed("OOM".into());
659        mgr.save(&cp).unwrap();
660        assert_eq!(
661            mgr.load_latest("sess1").unwrap().unwrap().status,
662            CheckpointStatus::Failed("OOM".into())
663        );
664    }
665
666    #[test]
667    fn test_restore_preserves_all_checkpoint_fields() {
668        let dir = temp_dir();
669        let mgr = CheckpointManager::new(dir.path()).unwrap();
670        let mut cp = sample_checkpoint("sess-restore", 3);
671        cp.id = "cp-full".into();
672        cp.agent_name = "researcher".into();
673        cp.partial_results = vec!["chunk-a".into(), "chunk-b".into()];
674        cp.status = CheckpointStatus::Completed;
675        mgr.save(&cp).unwrap();
676        let loaded = mgr.load_latest("sess-restore").unwrap().unwrap();
677        assert_eq!(loaded.id, cp.id);
678        assert_eq!(loaded.messages.len(), cp.messages.len());
679        assert_eq!(loaded.status, cp.status);
680    }
681
682    #[test]
683    fn test_checkpoint_status_completed_and_halted_round_trip() {
684        let dir = temp_dir();
685        let mgr = CheckpointManager::new(dir.path()).unwrap();
686        let mut completed = sample_checkpoint("sess-status", 0);
687        completed.status = CheckpointStatus::Completed;
688        mgr.save(&completed).unwrap();
689        assert_eq!(
690            mgr.load_latest("sess-status").unwrap().unwrap().status,
691            CheckpointStatus::Completed
692        );
693        let mut halted = sample_checkpoint("sess-status", 1);
694        halted.status = CheckpointStatus::Halted("loop detected".into());
695        mgr.save(&halted).unwrap();
696        assert_eq!(
697            mgr.load_latest("sess-status").unwrap().unwrap().status,
698            CheckpointStatus::Halted("loop detected".into())
699        );
700    }
701
702    #[test]
703    fn new_fails_when_checkpoint_path_is_a_file() {
704        let dir = temp_dir();
705        let file_path = dir.path().join("not_a_dir");
706        std::fs::write(&file_path, "blocking file").unwrap();
707        assert!(CheckpointManager::new(&file_path).is_err());
708    }
709
710    #[test]
711    fn load_latest_invalid_json_returns_invalid_data() {
712        let dir = temp_dir();
713        let mgr = CheckpointManager::new(dir.path()).unwrap();
714        let session = "bad-json";
715        std::fs::write(dir.path().join(format!("{session}_0.json")), "not valid json").unwrap();
716        std::fs::write(
717            dir.path().join(format!("{session}_latest.json")),
718            format!("{session}_0.json"),
719        )
720        .unwrap();
721        assert_eq!(
722            mgr.load_latest(session).unwrap_err().kind(),
723            std::io::ErrorKind::InvalidData
724        );
725    }
726
727    #[test]
728    fn load_latest_stale_pointer_returns_none() {
729        let dir = temp_dir();
730        let mgr = CheckpointManager::new(dir.path()).unwrap();
731        std::fs::write(
732            dir.path().join("stale-pointer_latest.json"),
733            "missing_checkpoint_file.json",
734        )
735        .unwrap();
736        assert!(mgr.load_latest("stale-pointer").unwrap().is_none());
737    }
738
739    #[test]
740    fn list_checkpoints_skips_corrupt_entries() {
741        let dir = temp_dir();
742        let mgr = CheckpointManager::new(dir.path()).unwrap();
743        let session = "sess-skip";
744        mgr.save(&sample_checkpoint(session, 0)).unwrap();
745        mgr.save(&sample_checkpoint(session, 1)).unwrap();
746        std::fs::write(dir.path().join(format!("{session}_corrupt.json")), "{bad").unwrap();
747        let listed = mgr.list_checkpoints(session).unwrap();
748        assert_eq!(listed.len(), 2);
749    }
750
751    #[test]
752    fn has_checkpoint_false_without_latest_pointer() {
753        let dir = temp_dir();
754        let mgr = CheckpointManager::new(dir.path()).unwrap();
755        mgr.save(&sample_checkpoint("sess-has", 0)).unwrap();
756        std::fs::remove_file(dir.path().join("sess-has_latest.json")).unwrap();
757        assert!(!mgr.has_checkpoint("sess-has"));
758    }
759
760    #[test]
761    fn default_dir_initializes_manager() {
762        let mgr = CheckpointManager::default_dir().expect("default_dir should succeed");
763        assert!(mgr.checkpoint_dir.to_string_lossy().ends_with("checkpoints"));
764    }
765
766    #[test]
767    fn manager_save_restore_uses_serialize_state_symmetry() {
768        let dir = temp_dir();
769        let mgr = CheckpointManager::new(dir.path()).unwrap();
770        let cp = sample_checkpoint("roundtrip", 7);
771        mgr.save(&cp).unwrap();
772        let bytes = std::fs::read(dir.path().join("roundtrip_7.json")).unwrap();
773        assert_eq!(restore_checkpoint(&bytes).unwrap(), cp);
774    }
775}