Skip to main content

lc_langgraph/
checkpointer.rs

1// crates/lc-langgraph/src/checkpointer.rs
2//! Checkpointing for state persistence
3
4use crate::errors::{GraphError, GraphResult};
5use crate::state::StateSchema;
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use tokio::sync::Mutex;
11use uuid::Uuid;
12
13/// Checkpointer trait for state persistence
14#[async_trait]
15pub trait Checkpointer<S: StateSchema>: Send + Sync {
16    /// Insert a new checkpoint, recording how much of the recursion budget had
17    /// been consumed by the run at that point (M6).
18    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String>;
19    /// Load the state saved under the given checkpoint id.
20    async fn load(&self, checkpoint_id: &str) -> GraphResult<S>;
21    /// List checkpoint ids, ordered from oldest to most recent (H5).
22    async fn list(&self) -> GraphResult<Vec<String>>;
23    /// Delete the checkpoint with the given id.
24    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()>;
25    /// State and recursion budget of the most recently saved checkpoint.
26    async fn last(&self) -> GraphResult<Option<(S, usize)>>;
27
28    /// Replace the state stored in an existing checkpoint (the LangGraph
29    /// `updateState` analogue), using optimistic concurrency control.
30    ///
31    /// `expected_version` is the version of the state the edit is based on
32    /// (every fresh checkpoint starts at version `1`, and each successful
33    /// `update_state` bumps it). When the stored version has moved on because
34    /// another writer edited the checkpoint first, the call fails with
35    /// [`GraphError::CheckpointVersionConflict`] instead of overwriting that
36    /// edit. Returns the new version.
37    ///
38    /// Backends without edit support keep the default implementation, which
39    /// fails with [`GraphError::CheckpointError`].
40    async fn update_state(
41        &self,
42        checkpoint_id: &str,
43        state: &S,
44        expected_version: u64,
45    ) -> GraphResult<u64> {
46        let _ = (state, expected_version);
47        Err(GraphError::CheckpointError(format!(
48            "update_state is not supported on checkpoint '{checkpoint_id}' by this checkpointer",
49        )))
50    }
51}
52
53/// Checkpoint data structure
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(bound = "S: StateSchema")]
56pub struct CheckpointData<S: StateSchema> {
57    /// Unique identifier of the checkpoint.
58    pub id: String,
59    /// The state snapshot stored in the checkpoint.
60    pub state: S,
61    /// Unix timestamp (seconds) when the checkpoint was created.
62    pub timestamp: i64,
63    /// Arbitrary metadata associated with the checkpoint.
64    pub metadata: HashMap<String, serde_json::Value>,
65    /// Monotonic sequence number assigned by the checkpointer on save. Breaks
66    /// ties between checkpoints saved within the same `timestamp` second, so
67    /// "most recent" is well-defined even for fast back-to-back saves (H5).
68    #[serde(default)]
69    pub seq: u64,
70    /// Recursion budget consumed when this checkpoint was taken, so a resume
71    /// continues counting against the same `recursion_limit` instead of
72    /// restarting from zero (M6).
73    #[serde(default)]
74    pub recursion_count: usize,
75    /// Optimistic-concurrency version: `1` for a fresh checkpoint, bumped on
76    /// every [`Checkpointer::update_state`]. Backed by an atomic compare-and
77    /// swap in the durable checkpointers.
78    #[serde(default = "initial_version")]
79    pub version: u64,
80}
81
82/// Fresh checkpoints start at version 1 (0 only occurs in pre-0.22.4 files).
83fn initial_version() -> u64 {
84    1
85}
86
87impl<S: StateSchema> CheckpointData<S> {
88    /// Create a new checkpoint for the given state.
89    pub fn new(state: S) -> Self {
90        Self {
91            id: Uuid::new_v4().to_string(),
92            state,
93            timestamp: chrono::Utc::now().timestamp(),
94            metadata: HashMap::new(),
95            seq: 0,
96            recursion_count: 0,
97            version: 1,
98        }
99    }
100
101    /// Construct with the checkpointer-assigned sequence and the run's current
102    /// recursion budget.
103    pub fn with_progress(state: S, seq: u64, recursion_count: usize) -> Self {
104        let mut data = Self::new(state);
105        data.seq = seq;
106        data.recursion_count = recursion_count;
107        data
108    }
109}
110
111/// In-memory checkpointer for development
112pub struct MemoryCheckpointer<S: StateSchema> {
113    checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
114    next_seq: AtomicU64,
115}
116
117impl<S: StateSchema> MemoryCheckpointer<S> {
118    /// Create a new empty in-memory checkpointer.
119    pub fn new() -> Self {
120        Self {
121            checkpoints: Mutex::new(HashMap::new()),
122            next_seq: AtomicU64::new(0),
123        }
124    }
125}
126
127impl<S: StateSchema> Default for MemoryCheckpointer<S> {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133#[async_trait]
134impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
135    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
136        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
137        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
138        let id = data.id.clone();
139        self.checkpoints.lock().await.insert(id.clone(), data);
140        Ok(id)
141    }
142
143    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
144        self.checkpoints
145            .lock()
146            .await
147            .get(checkpoint_id)
148            .map(|d| d.state.clone())
149            .ok_or_else(|| {
150                GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
151            })
152    }
153
154    async fn list(&self) -> GraphResult<Vec<String>> {
155        let guard = self.checkpoints.lock().await;
156        let mut items: Vec<(i64, u64, String)> = guard
157            .values()
158            .map(|d| (d.timestamp, d.seq, d.id.clone()))
159            .collect();
160        // H5: the old HashMap.keys() order was nondeterministic; sort by (timestamp, seq)
161        // ascending instead, so callers taking `.last()` get the most recent checkpoint.
162        items.sort();
163        Ok(items.into_iter().map(|(_, _, id)| id).collect())
164    }
165
166    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
167        let guard = self.checkpoints.lock().await;
168        Ok(guard
169            .values()
170            .max_by_key(|d| (d.timestamp, d.seq))
171            .map(|d| (d.state.clone(), d.recursion_count)))
172    }
173
174    async fn update_state(
175        &self,
176        checkpoint_id: &str,
177        state: &S,
178        expected_version: u64,
179    ) -> GraphResult<u64> {
180        update_locked(&self.checkpoints, checkpoint_id, state, expected_version).await
181    }
182
183    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
184        self.checkpoints.lock().await.remove(checkpoint_id);
185        Ok(())
186    }
187}
188
189/// In-memory OCC edit shared by [`MemoryCheckpointer`] and
190/// [`ThreadSafeMemoryCheckpointer`]: bump the version only while holding the
191/// map lock, so two concurrent edits cannot both succeed against the same
192/// base version.
193async fn update_locked<S: StateSchema>(
194    checkpoints: &Mutex<HashMap<String, CheckpointData<S>>>,
195    checkpoint_id: &str,
196    state: &S,
197    expected_version: u64,
198) -> GraphResult<u64> {
199    let mut guard = checkpoints.lock().await;
200    let data = guard.get_mut(checkpoint_id).ok_or_else(|| {
201        GraphError::CheckpointError(format!("Checkpoint '{checkpoint_id}' not found"))
202    })?;
203    if data.version != expected_version {
204        return Err(GraphError::CheckpointVersionConflict {
205            checkpoint_id: checkpoint_id.to_string(),
206            expected: expected_version,
207            actual: data.version,
208        });
209    }
210    data.state = state.clone();
211    data.version += 1;
212    Ok(data.version)
213}
214
215/// Thread-safe memory checkpointer
216pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
217    checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
218    next_seq: AtomicU64,
219}
220
221impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
222    /// Create a new empty thread-safe memory checkpointer.
223    pub fn new() -> Self {
224        Self {
225            checkpoints: Mutex::new(HashMap::new()),
226            next_seq: AtomicU64::new(0),
227        }
228    }
229}
230
231impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237#[async_trait]
238impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
239    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
240        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
241        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
242        let id = data.id.clone();
243        self.checkpoints.lock().await.insert(id.clone(), data);
244        Ok(id)
245    }
246
247    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
248        let checkpoints = self.checkpoints.lock().await;
249        checkpoints
250            .get(checkpoint_id)
251            .map(|d| d.state.clone())
252            .ok_or_else(|| {
253                GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
254            })
255    }
256
257    async fn list(&self) -> GraphResult<Vec<String>> {
258        let guard = self.checkpoints.lock().await;
259        let mut items: Vec<(i64, u64, String)> = guard
260            .values()
261            .map(|d| (d.timestamp, d.seq, d.id.clone()))
262            .collect();
263        // H5: sort by (timestamp, seq) ascending; `.last()` is the most recent checkpoint.
264        items.sort();
265        Ok(items.into_iter().map(|(_, _, id)| id).collect())
266    }
267
268    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
269        let guard = self.checkpoints.lock().await;
270        Ok(guard
271            .values()
272            .max_by_key(|d| (d.timestamp, d.seq))
273            .map(|d| (d.state.clone(), d.recursion_count)))
274    }
275
276    async fn update_state(
277        &self,
278        checkpoint_id: &str,
279        state: &S,
280        expected_version: u64,
281    ) -> GraphResult<u64> {
282        update_locked(&self.checkpoints, checkpoint_id, state, expected_version).await
283    }
284
285    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
286        self.checkpoints.lock().await.remove(checkpoint_id);
287        Ok(())
288    }
289}
290
291/// File-based checkpointer for persistent storage
292pub struct FileCheckpointer<S: StateSchema> {
293    directory: std::path::PathBuf,
294    next_seq: AtomicU64,
295    /// Serializes the read-check-write critical section of `update_state`
296    /// within this process (cross-process OCC is provided by the SQLite /
297    /// Postgres / Redis backends, not by plain JSON files).
298    update_lock: Mutex<()>,
299    _phantom: std::marker::PhantomData<S>,
300}
301
302impl<S: StateSchema> FileCheckpointer<S> {
303    /// Create a file-based checkpointer that persists checkpoints under the given directory.
304    pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
305        let dir = directory.into();
306        if !dir.exists() {
307            std::fs::create_dir_all(&dir).map_err(|e| {
308                GraphError::CheckpointError(format!(
309                    "Failed to create directory '{}': {}",
310                    dir.display(),
311                    e
312                ))
313            })?;
314        }
315        Ok(Self {
316            directory: dir,
317            next_seq: AtomicU64::new(0),
318            update_lock: Mutex::new(()),
319            _phantom: std::marker::PhantomData,
320        })
321    }
322
323    fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
324        // Sanitize id to prevent path traversal: reject ".." and absolute paths
325        if id.contains("..") || id.contains('/') || id.contains('\\') {
326            return Err(GraphError::CheckpointError(format!(
327                "Invalid checkpoint id '{}': path traversal detected",
328                id
329            )));
330        }
331        if std::path::Path::new(id).is_absolute() {
332            return Err(GraphError::CheckpointError(format!(
333                "Invalid checkpoint id '{}': absolute path not allowed",
334                id
335            )));
336        }
337        Ok(self.directory.join(format!("{}.json", id)))
338    }
339
340    /// Read every checkpoint file's `(timestamp, seq, id)` sort keys.
341    async fn sorted_ids(&self) -> GraphResult<Vec<(i64, u64, String)>> {
342        let mut items: Vec<(i64, u64, String)> = Vec::new();
343        let mut entries = tokio::fs::read_dir(&self.directory)
344            .await
345            .map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
346        while let Some(entry) = entries
347            .next_entry()
348            .await
349            .map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
350        {
351            let path = entry.path();
352            if path.extension().is_some_and(|ext| ext == "json") {
353                let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
354                    continue;
355                };
356                let json = tokio::fs::read_to_string(&path)
357                    .await
358                    .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
359                let data: CheckpointData<S> = serde_json::from_str(&json).map_err(|e| {
360                    GraphError::CheckpointError(format!("Deserialize error: {}", e))
361                })?;
362                items.push((data.timestamp, data.seq, id));
363            }
364        }
365        // H5: sort by (timestamp, seq) ascending; seq breaks ties within the same second.
366        items.sort();
367        Ok(items)
368    }
369}
370
371// NOTE: `Default` is intentionally NOT implemented for `FileCheckpointer` (Q1).
372// The default constructor would have to create the `.checkpoints` directory, which
373// is I/O that can fail (read-only cwd, disk full, permissions) — `Default` cannot
374// report that failure, so it would have to panic. Use `FileCheckpointer::new(...)`
375// which returns a `GraphResult` and surfaces the error instead.
376
377#[async_trait]
378impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
379    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
380        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
381        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
382        let id = data.id.clone();
383        let path = self.checkpoint_path(&id)?;
384
385        let json = serde_json::to_string_pretty(&data)
386            .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
387
388        // Atomic write: write `{id}.json.tmp` first, then rename over the real file, so a
389        // crash or interrupt mid-JSON cannot corrupt the checkpoint (same pattern as
390        // FileResumeStore). The `.tmp` extension is never picked up by sorted_ids' `.json` filter.
391        let tmp_path = self.directory.join(format!("{id}.json.tmp"));
392        tokio::fs::write(&tmp_path, &json)
393            .await
394            .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
395        tokio::fs::rename(&tmp_path, &path)
396            .await
397            .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
398
399        Ok(id)
400    }
401
402    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
403        let path = self.checkpoint_path(checkpoint_id)?;
404
405        if !path.exists() {
406            return Err(GraphError::CheckpointError(format!(
407                "Checkpoint '{}' not found",
408                checkpoint_id
409            )));
410        }
411
412        let json = tokio::fs::read_to_string(&path)
413            .await
414            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
415
416        let data: CheckpointData<S> = serde_json::from_str(&json)
417            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
418
419        Ok(data.state)
420    }
421
422    async fn list(&self) -> GraphResult<Vec<String>> {
423        Ok(self
424            .sorted_ids()
425            .await?
426            .into_iter()
427            .map(|(_, _, id)| id)
428            .collect())
429    }
430
431    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
432        let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
433            return Ok(None);
434        };
435        let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
436            .await
437            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
438        let data: CheckpointData<S> = serde_json::from_str(&json)
439            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
440        Ok(Some((data.state, data.recursion_count)))
441    }
442
443    async fn update_state(
444        &self,
445        checkpoint_id: &str,
446        state: &S,
447        expected_version: u64,
448    ) -> GraphResult<u64> {
449        let _guard = self.update_lock.lock().await;
450        let path = self.checkpoint_path(checkpoint_id)?;
451        let json = tokio::fs::read_to_string(&path)
452            .await
453            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
454        let mut data: CheckpointData<S> = serde_json::from_str(&json)
455            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
456        if data.version != expected_version {
457            return Err(GraphError::CheckpointVersionConflict {
458                checkpoint_id: checkpoint_id.to_string(),
459                expected: expected_version,
460                actual: data.version,
461            });
462        }
463        data.state = state.clone();
464        data.version += 1;
465
466        let json = serde_json::to_string_pretty(&data)
467            .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
468        let tmp_path = self.directory.join(format!("{checkpoint_id}.json.tmp"));
469        tokio::fs::write(&tmp_path, &json)
470            .await
471            .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
472        tokio::fs::rename(&tmp_path, &path)
473            .await
474            .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
475        Ok(data.version)
476    }
477
478    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
479        let path = self.checkpoint_path(checkpoint_id)?;
480
481        if path.exists() {
482            tokio::fs::remove_file(&path)
483                .await
484                .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
485        }
486
487        Ok(())
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::state::AgentState;
495
496    #[tokio::test]
497    async fn test_thread_safe_checkpointer() {
498        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
499
500        let state = AgentState::new("test".to_string());
501        let id = checkpointer.save(&state, 0).await.unwrap();
502
503        let loaded = checkpointer.load(&id).await.unwrap();
504        assert_eq!(loaded.input, "test");
505
506        let list = checkpointer.list().await.unwrap();
507        assert_eq!(list.len(), 1);
508
509        checkpointer.delete(&id).await.unwrap();
510        let list = checkpointer.list().await.unwrap();
511        assert!(list.is_empty());
512    }
513
514    #[tokio::test]
515    async fn test_file_checkpointer() {
516        let temp_dir = tempfile::tempdir().unwrap();
517        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
518
519        let state = AgentState::new("file_test".to_string());
520        let id = checkpointer.save(&state, 0).await.unwrap();
521
522        let loaded = checkpointer.load(&id).await.unwrap();
523        assert_eq!(loaded.input, "file_test");
524
525        let list = checkpointer.list().await.unwrap();
526        assert_eq!(list.len(), 1);
527
528        checkpointer.delete(&id).await.unwrap();
529        let list = checkpointer.list().await.unwrap();
530        assert!(list.is_empty());
531    }
532
533    #[tokio::test]
534    async fn test_file_checkpointer_atomic_write() {
535        let temp_dir = tempfile::tempdir().unwrap();
536        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
537
538        let id = checkpointer
539            .save(&AgentState::new("atomic".to_string()), 0)
540            .await
541            .unwrap();
542
543        // The main file is complete and parseable; no `.tmp` leftover (rename cleaned it up).
544        let main = temp_dir.path().join(format!("{id}.json"));
545        assert!(main.exists(), "checkpoint file must exist");
546        let json = tokio::fs::read_to_string(&main).await.unwrap();
547        assert!(
548            serde_json::from_str::<CheckpointData<AgentState>>(&json).is_ok(),
549            "checkpoint file must be complete JSON after atomic write"
550        );
551        assert!(
552            !temp_dir.path().join(format!("{id}.json.tmp")).exists(),
553            "tmp file must be renamed away, not left behind"
554        );
555
556        // A stale `.tmp` file must not be read by list() (extension filter).
557        std::fs::write(temp_dir.path().join("stale.json.tmp"), b"{}").unwrap();
558        let list = checkpointer.list().await.unwrap();
559        assert_eq!(list, vec![id]);
560    }
561
562    #[tokio::test]
563    async fn test_file_checkpointer_multiple() {
564        let temp_dir = tempfile::tempdir().unwrap();
565        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
566
567        let id1 = checkpointer
568            .save(&AgentState::new("state1".to_string()), 0)
569            .await
570            .unwrap();
571        let id2 = checkpointer
572            .save(&AgentState::new("state2".to_string()), 0)
573            .await
574            .unwrap();
575        let _id3 = checkpointer
576            .save(&AgentState::new("state3".to_string()), 0)
577            .await
578            .unwrap();
579
580        let list = checkpointer.list().await.unwrap();
581        assert_eq!(list.len(), 3);
582
583        let loaded = checkpointer.load(&id2).await.unwrap();
584        assert_eq!(loaded.input, "state2");
585
586        checkpointer.delete(&id1).await.unwrap();
587        let list = checkpointer.list().await.unwrap();
588        assert_eq!(list.len(), 2);
589    }
590
591    #[tokio::test]
592    async fn test_file_checkpointer_path_traversal() {
593        let temp_dir = tempfile::tempdir().unwrap();
594        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
595
596        // Path traversal should be rejected
597        let result = checkpointer.load("..").await;
598        assert!(result.is_err());
599
600        let result = checkpointer.load("../etc/passwd").await;
601        assert!(result.is_err());
602    }
603
604    #[tokio::test]
605    async fn test_list_orders_oldest_to_newest() {
606        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
607        checkpointer
608            .save(&AgentState::new("first".to_string()), 0)
609            .await
610            .unwrap();
611        checkpointer
612            .save(&AgentState::new("second".to_string()), 1)
613            .await
614            .unwrap();
615        checkpointer
616            .save(&AgentState::new("third".to_string()), 2)
617            .await
618            .unwrap();
619
620        let list = checkpointer.list().await.unwrap();
621        assert_eq!(list.len(), 3);
622        // H5: the last id must be the most recent save (not HashMap arbitrary order).
623        let (state, _) = checkpointer.last().await.unwrap().unwrap();
624        assert_eq!(state.input, "third");
625    }
626
627    #[tokio::test]
628    async fn test_last_returns_recursion_count() {
629        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
630        checkpointer
631            .save(&AgentState::new("a".to_string()), 7)
632            .await
633            .unwrap();
634        checkpointer
635            .save(&AgentState::new("b".to_string()), 12)
636            .await
637            .unwrap();
638
639        // M6: last() returns the recursion_count of the most recent save
640        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
641        assert_eq!(state.input, "b");
642        assert_eq!(recursion_count, 12);
643    }
644
645    #[tokio::test]
646    async fn test_update_state_occ_memory() {
647        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
648        let id = checkpointer
649            .save(&AgentState::new("v1".to_string()), 0)
650            .await
651            .unwrap();
652
653        // First edit based on version 1 succeeds and bumps to 2.
654        let version = checkpointer
655            .update_state(&id, &AgentState::new("v2".to_string()), 1)
656            .await
657            .unwrap();
658        assert_eq!(version, 2);
659        assert_eq!(checkpointer.load(&id).await.unwrap().input, "v2");
660
661        // A stale edit still based on version 1 must be rejected, not overwrite.
662        let conflict = checkpointer
663            .update_state(&id, &AgentState::new("v3-stale".to_string()), 1)
664            .await
665            .unwrap_err();
666        match conflict {
667            GraphError::CheckpointVersionConflict {
668                expected, actual, ..
669            } => {
670                assert_eq!(expected, 1);
671                assert_eq!(actual, 2);
672            }
673            other => panic!("expected CheckpointVersionConflict, got {other:?}"),
674        }
675        assert_eq!(checkpointer.load(&id).await.unwrap().input, "v2");
676
677        // An edit based on the current version 2 succeeds.
678        checkpointer
679            .update_state(&id, &AgentState::new("v3".to_string()), 2)
680            .await
681            .unwrap();
682        assert_eq!(checkpointer.load(&id).await.unwrap().input, "v3");
683    }
684
685    #[tokio::test]
686    async fn test_update_state_missing_and_file() {
687        let temp_dir = tempfile::tempdir().unwrap();
688        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
689        let id = checkpointer
690            .save(&AgentState::new("disk-v1".to_string()), 0)
691            .await
692            .unwrap();
693        checkpointer
694            .update_state(&id, &AgentState::new("disk-v2".to_string()), 1)
695            .await
696            .unwrap();
697        assert_eq!(checkpointer.load(&id).await.unwrap().input, "disk-v2");
698        // Stale version conflicts on disk too.
699        assert!(checkpointer
700            .update_state(&id, &AgentState::new("stale".to_string()), 1)
701            .await
702            .is_err());
703        // Unknown checkpoint id errors rather than inserting.
704        assert!(checkpointer
705            .update_state("missing", &AgentState::new("x".to_string()), 1)
706            .await
707            .is_err());
708    }
709
710    #[tokio::test]
711    async fn test_file_checkpointer_last_orders_by_save() {
712        let temp_dir = tempfile::tempdir().unwrap();
713        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
714        checkpointer
715            .save(&AgentState::new("one".to_string()), 1)
716            .await
717            .unwrap();
718        checkpointer
719            .save(&AgentState::new("two".to_string()), 2)
720            .await
721            .unwrap();
722
723        let list = checkpointer.list().await.unwrap();
724        assert_eq!(list.len(), 2);
725        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
726        assert_eq!(state.input, "two");
727        assert_eq!(recursion_count, 2);
728    }
729}