Skip to main content

luft_core/
state.rs

1//! Progress persistence and resume for long-running workflows.
2//!
3//! This module implements checkpointing and recovery for dynamic workflows.
4//! Progress is saved as the run goes, so a job interrupted by a restart can resume.
5//!
6//! Key features:
7//! - Event log persistence (JSONL)
8//! - Agent result caching
9//! - Resume from last checkpoint
10//! - Run state management
11
12use crate::contract::event::AgentEvent;
13use crate::contract::finding::Finding;
14use crate::contract::ids::{AgentId, PhaseId, RunId};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::fs::{self, File, OpenOptions};
18use std::io::{BufRead, BufReader, Write};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, RwLock};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23/// Run state persisted to disk.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RunCheckpoint {
26    pub run_id: RunId,
27    pub task: String,
28    pub status: CheckpointStatus,
29    pub current_phase: u32,
30    pub completed_phases: Vec<PhaseSummary>,
31    pub agent_results: HashMap<AgentId, AgentResultCache>,
32    pub findings: Vec<Finding>,
33    pub total_tokens: u64,
34    pub created_at: u64,
35    pub updated_at: u64,
36    #[serde(default)]
37    pub workflow_meta: Option<serde_json::Value>,
38    /// Every agent_id that has received an `AgentStarted` event, in arrival
39    /// order. Used to compute "running" = started − done.
40    #[serde(default)]
41    pub started_agent_ids: Vec<AgentId>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "lowercase")]
46pub enum CheckpointStatus {
47    Running,
48    Completed,
49    Failed,
50    Cancelled,
51}
52
53impl std::fmt::Display for CheckpointStatus {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        let s = match self {
56            CheckpointStatus::Running => "Running",
57            CheckpointStatus::Completed => "Completed",
58            CheckpointStatus::Failed => "Failed",
59            CheckpointStatus::Cancelled => "Cancelled",
60        };
61        f.write_str(s)
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PhaseSummary {
67    pub phase_id: PhaseId,
68    pub label: String,
69    pub planned: usize,
70    pub ok: usize,
71    pub failed: usize,
72    #[serde(default)]
73    pub description: Option<String>,
74    #[serde(default)]
75    pub role: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct AgentResultCache {
80    pub agent_id: AgentId,
81    pub phase_id: PhaseId,
82    pub status: String,
83    pub output: serde_json::Value,
84    pub findings: Vec<Finding>,
85    pub tokens: u64,
86    pub completed_at: u64,
87    /// Deterministic cache key hash for resume lookups.
88    /// Populated by JournalStore::cache_agent(); None for legacy checkpoints.
89    #[serde(default)]
90    pub cache_key_hash: Option<String>,
91    #[serde(default)]
92    pub description: Option<String>,
93    #[serde(default)]
94    pub role: Option<String>,
95}
96
97/// Persistence store for a single run.
98#[derive(Debug)]
99pub struct RunStore {
100    run_dir: PathBuf,
101    checkpoint: RwLock<Option<RunCheckpoint>>,
102    events_file: RwLock<Option<File>>,
103}
104
105impl RunStore {
106    /// Create or open a run store at the given path.
107    pub fn new(run_dir: &Path) -> Result<Arc<Self>, std::io::Error> {
108        tracing::debug!(path = %run_dir.display(), "creating RunStore");
109        fs::create_dir_all(run_dir)?;
110
111        let store = Arc::new(Self {
112            run_dir: run_dir.to_path_buf(),
113            checkpoint: RwLock::new(None),
114            events_file: RwLock::new(None),
115        });
116
117        Ok(store)
118    }
119
120    /// Insert or update an agent result in the checkpoint directly.
121    /// Used by JournalStore to persist cache_key_hash before appending the event.
122    pub fn upsert_agent_result(&self, cache: &AgentResultCache) -> Result<(), std::io::Error> {
123        let mut guard = self.checkpoint.write().unwrap();
124        if let Some(ref mut checkpoint) = *guard {
125            checkpoint
126                .agent_results
127                .insert(cache.agent_id, cache.clone());
128            checkpoint.updated_at = current_timestamp();
129            let cp = checkpoint.clone();
130            drop(guard);
131            let cp_path = self.run_dir.join("checkpoint.json");
132            let content = serde_json::to_string_pretty(&cp)
133                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
134            fs::write(&cp_path, content)?;
135        }
136        Ok(())
137    }
138
139    /// Initialize a new run.
140    pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), std::io::Error> {
141        tracing::info!(%run_id, %task, "initializing run store");
142        let checkpoint = RunCheckpoint {
143            run_id,
144            task: task.to_string(),
145            status: CheckpointStatus::Running,
146            current_phase: 0,
147            completed_phases: vec![],
148            agent_results: HashMap::new(),
149            findings: vec![],
150            total_tokens: 0,
151            created_at: current_timestamp(),
152            updated_at: current_timestamp(),
153            workflow_meta: None,
154            started_agent_ids: vec![],
155        };
156
157        // Save checkpoint
158        self.save_checkpoint(&checkpoint)?;
159
160        // Open events file
161        let events_path = self.run_dir.join("events.jsonl");
162        let events_file = OpenOptions::new()
163            .create(true)
164            .append(true)
165            .open(events_path)?;
166
167        let mut checkpoint_guard = self.checkpoint.write().unwrap();
168        *checkpoint_guard = Some(checkpoint);
169
170        let mut events_guard = self.events_file.write().unwrap();
171        *events_guard = Some(events_file);
172
173        Ok(())
174    }
175
176    /// Initialize a new run with declarative workflow metadata.
177    pub fn init_run_with_meta(
178        &self,
179        run_id: RunId,
180        task: &str,
181        workflow_meta: serde_json::Value,
182    ) -> Result<(), std::io::Error> {
183        tracing::info!(%run_id, %task, "initializing run store with meta");
184        let checkpoint = RunCheckpoint {
185            run_id,
186            task: task.to_string(),
187            status: CheckpointStatus::Running,
188            current_phase: 0,
189            completed_phases: vec![],
190            agent_results: HashMap::new(),
191            findings: vec![],
192            total_tokens: 0,
193            created_at: current_timestamp(),
194            updated_at: current_timestamp(),
195            workflow_meta: Some(workflow_meta),
196            started_agent_ids: vec![],
197        };
198
199        self.save_checkpoint(&checkpoint)?;
200
201        let events_path = self.run_dir.join("events.jsonl");
202        let events_file = OpenOptions::new()
203            .create(true)
204            .append(true)
205            .open(events_path)?;
206
207        let mut checkpoint_guard = self.checkpoint.write().unwrap();
208        *checkpoint_guard = Some(checkpoint);
209
210        let mut events_guard = self.events_file.write().unwrap();
211        *events_guard = Some(events_file);
212
213        Ok(())
214    }
215
216    /// Open an existing run for resume.
217    pub fn open_run(&self, _run_id: RunId) -> Result<Option<RunCheckpoint>, std::io::Error> {
218        tracing::debug!(%_run_id, "opening existing run");
219        let checkpoint_path = self.run_dir.join("checkpoint.json");
220
221        if !checkpoint_path.exists() {
222            return Ok(None);
223        }
224
225        let content = fs::read_to_string(&checkpoint_path)?;
226        let checkpoint: RunCheckpoint = serde_json::from_str(&content)
227            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
228
229        // Open events file. Resume appends new events (phase_started, agent_started,
230        // log, agent_done) to the same file; opening read-only here would make every
231        // forwarded event fail with Access is denied (os error 5) and silently drop
232        // observability for the entire resumed run.
233        let events_path = self.run_dir.join("events.jsonl");
234        let events_file = OpenOptions::new()
235            .read(true)
236            .append(true)
237            .open(events_path)?;
238
239        let mut checkpoint_guard = self.checkpoint.write().unwrap();
240        *checkpoint_guard = Some(checkpoint.clone());
241
242        let mut events_guard = self.events_file.write().unwrap();
243        *events_guard = Some(events_file);
244
245        Ok(Some(checkpoint))
246    }
247
248    /// Append an event to the log.
249    pub fn append_event(&self, event: &AgentEvent) -> Result<(), std::io::Error> {
250        let json = serde_json::to_string(event)
251            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
252
253        let mut events_guard = self.events_file.write().unwrap();
254        if let Some(ref mut file) = *events_guard {
255            writeln!(file, "{}", json)?;
256            file.flush()?;
257        }
258
259        // Update checkpoint (this also persists to disk)
260        self.update_from_event(event);
261
262        Ok(())
263    }
264
265    /// Update checkpoint from an event and persist to disk.
266    fn update_from_event(&self, event: &AgentEvent) {
267        let mut checkpoint_guard = self.checkpoint.write().unwrap();
268        if let Some(ref mut checkpoint) = *checkpoint_guard {
269            match event {
270                AgentEvent::AgentDone {
271                    agent_id,
272                    status,
273                    tokens,
274                    ..
275                } => {
276                    let existing = checkpoint.agent_results.get(agent_id);
277                    let cache = AgentResultCache {
278                        agent_id: *agent_id,
279                        phase_id: existing.map(|c| c.phase_id).unwrap_or(0),
280                        status: status.as_str().to_string(),
281                        output: existing
282                            .map(|c| c.output.clone())
283                            .unwrap_or(serde_json::Value::Null),
284                        findings: existing.map(|c| c.findings.clone()).unwrap_or_default(),
285                        tokens: tokens.total(),
286                        completed_at: existing
287                            .map(|c| c.completed_at)
288                            .unwrap_or(current_timestamp()),
289                        cache_key_hash: existing.and_then(|c| c.cache_key_hash.clone()),
290                        description: existing.and_then(|c| c.description.clone()),
291                        role: existing.and_then(|c| c.role.clone()),
292                    };
293                    checkpoint.agent_results.insert(*agent_id, cache);
294                    checkpoint.total_tokens += tokens.total();
295                }
296                AgentEvent::AgentStarted { agent_id, .. } => {
297                    if !checkpoint.started_agent_ids.contains(agent_id) {
298                        checkpoint.started_agent_ids.push(*agent_id);
299                    }
300                }
301                AgentEvent::PhaseDone { phase_id, .. } => {
302                    if *phase_id > 0 {
303                        checkpoint.current_phase = *phase_id;
304                    }
305                }
306                AgentEvent::RunDone {
307                    status,
308                    total_tokens,
309                    ..
310                } => {
311                    // Cancellation is monotonic: a late RunDone or other
312                    // event from the blocking executor must not resurrect a
313                    // run that was already cancelled through the disk/API path.
314                    let cancelled_on_disk =
315                        fs::read_to_string(self.run_dir.join("checkpoint.json"))
316                            .ok()
317                            .and_then(|content| {
318                                serde_json::from_str::<RunCheckpoint>(&content).ok()
319                            })
320                            .is_some_and(|disk_checkpoint| {
321                                disk_checkpoint.status == CheckpointStatus::Cancelled
322                            });
323                    if cancelled_on_disk {
324                        checkpoint.status = CheckpointStatus::Cancelled;
325                    } else if checkpoint.status != CheckpointStatus::Cancelled {
326                        checkpoint.status = match status {
327                            crate::contract::event::RunStatus::Completed => {
328                                CheckpointStatus::Completed
329                            }
330                            crate::contract::event::RunStatus::Failed => CheckpointStatus::Failed,
331                            crate::contract::event::RunStatus::Cancelled => {
332                                CheckpointStatus::Cancelled
333                            }
334                            crate::contract::event::RunStatus::Partial => CheckpointStatus::Running,
335                        };
336                    }
337                    // Only overwrite if a real total was supplied; otherwise keep
338                    // the figure accumulated from AgentDone events.
339                    let t = total_tokens.total();
340                    if t > 0 {
341                        checkpoint.total_tokens = t;
342                    }
343                }
344                _ => {}
345            }
346            checkpoint.updated_at = current_timestamp();
347
348            // Persist updated checkpoint to disk (write-only, no lock needed - already held)
349            if let Err(e) = self.write_checkpoint_to_disk(checkpoint) {
350                tracing::warn!(error = %e, "failed to save checkpoint");
351            }
352        }
353    }
354
355    /// Write checkpoint to disk without acquiring any locks.
356    fn write_checkpoint_to_disk(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
357        let checkpoint_path = self.run_dir.join("checkpoint.json");
358        let temp_path = self.run_dir.join("checkpoint.json.tmp");
359        // Cancellation may be requested by another process while this store
360        // is applying a late event. Preserve the terminal marker when merging
361        // such a write; otherwise a stale RunDone(Completed/Failed) can
362        // resurrect a cancelled run on disk.
363        let mut checkpoint_to_write = checkpoint.clone();
364        if let Ok(existing) = std::fs::read_to_string(&checkpoint_path) {
365            if serde_json::from_str::<RunCheckpoint>(&existing)
366                .ok()
367                .is_some_and(|cp| cp.status == CheckpointStatus::Cancelled)
368            {
369                checkpoint_to_write.status = CheckpointStatus::Cancelled;
370            }
371        }
372        let content = serde_json::to_string_pretty(&checkpoint_to_write)
373            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
374        std::fs::write(&temp_path, &content)?;
375        std::fs::rename(&temp_path, &checkpoint_path)?;
376        Ok(())
377    }
378
379    /// Save checkpoint to disk (public API, acquires lock).
380    pub fn save_checkpoint(&self, checkpoint: &RunCheckpoint) -> Result<(), std::io::Error> {
381        let checkpoint_path = self.run_dir.join("checkpoint.json");
382        let temp_path = self.run_dir.join("checkpoint.json.tmp");
383        let content = serde_json::to_string_pretty(checkpoint)
384            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
385        std::fs::write(&temp_path, &content)?;
386        std::fs::rename(&temp_path, &checkpoint_path)?;
387
388        let mut checkpoint_guard = self.checkpoint.write().unwrap();
389        *checkpoint_guard = Some(checkpoint.clone());
390
391        Ok(())
392    }
393
394    /// Get current checkpoint.
395    pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
396        let guard = self.checkpoint.read().unwrap();
397        guard.clone()
398    }
399
400    /// Get all findings collected so far.
401    pub fn get_findings(&self) -> Vec<Finding> {
402        let guard = self.checkpoint.read().unwrap();
403        guard
404            .as_ref()
405            .map(|c| c.findings.clone())
406            .unwrap_or_default()
407    }
408
409    /// Get event log as a vector.
410    pub fn get_event_log(&self) -> Result<Vec<AgentEvent>, std::io::Error> {
411        let events_path = self.run_dir.join("events.jsonl");
412        let file = File::open(events_path)?;
413        let reader = BufReader::new(file);
414        let mut events = Vec::new();
415
416        for line in reader.lines() {
417            let line = line?;
418            if line.trim().is_empty() {
419                continue;
420            }
421            let event: AgentEvent = serde_json::from_str(&line)
422                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
423            events.push(event);
424        }
425
426        Ok(events)
427    }
428
429    /// Check if a run can be resumed.
430    pub fn can_resume(&self) -> bool {
431        let guard = self.checkpoint.read().unwrap();
432        matches!(
433            guard.as_ref().map(|c| c.status.clone()),
434            Some(CheckpointStatus::Running)
435        )
436    }
437
438    /// Reset checkpoint status to `Running`. Used when resuming a
439    /// failed/cancelled run — the terminal status is replaced so that
440    /// status queries reflect the active execution and a crash leaves a
441    /// resumable checkpoint.
442    pub fn reset_status_to_running(&self) -> Result<(), std::io::Error> {
443        let mut guard = self.checkpoint.write().unwrap();
444
445        if guard.is_none() {
446            let checkpoint_path = self.run_dir.join("checkpoint.json");
447            if !checkpoint_path.exists() {
448                return Ok(());
449            }
450            let content = fs::read_to_string(&checkpoint_path)?;
451            let cp: RunCheckpoint = serde_json::from_str(&content)
452                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
453            *guard = Some(cp);
454        }
455
456        if let Some(ref mut checkpoint) = *guard {
457            checkpoint.status = CheckpointStatus::Running;
458            checkpoint.updated_at = current_timestamp();
459            let cp_clone = checkpoint.clone();
460            drop(guard);
461            self.write_checkpoint_to_disk(&cp_clone)?;
462        }
463        Ok(())
464    }
465
466    /// Mark run as cancelled.
467    pub fn cancel(&self) -> Result<(), std::io::Error> {
468        tracing::info!("cancelling run");
469        let mut guard = self.checkpoint.write().unwrap();
470
471        // Cache miss: this `RunStore` was created by a *different* process
472        // (e.g. the MCP server cancelling a run started by `luft run`), so
473        // the in-memory cache was never populated by `init_run` /
474        // `update_from_event`. Load the checkpoint from disk so we can
475        // mutate + persist it. If no `checkpoint.json` exists at all, there
476        // is nothing to cancel — return Ok (preserves the prior no-op
477        // behaviour for unknown runs, and must not create a file).
478        if guard.is_none() {
479            let checkpoint_path = self.run_dir.join("checkpoint.json");
480            if !checkpoint_path.exists() {
481                return Ok(());
482            }
483            let content = fs::read_to_string(&checkpoint_path)?;
484            let cp: RunCheckpoint = serde_json::from_str(&content)
485                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
486            *guard = Some(cp);
487        }
488
489        if let Some(ref mut checkpoint) = *guard {
490            checkpoint.status = CheckpointStatus::Cancelled;
491            checkpoint.updated_at = current_timestamp();
492            let checkpoint = checkpoint.clone();
493            drop(guard);
494            // Use the same atomic write path as event updates. This keeps the
495            // cross-process cancellation marker durable even if the running
496            // process is concurrently flushing a late event.
497            self.write_checkpoint_to_disk(&checkpoint)?;
498        }
499        Ok(())
500    }
501}
502
503/// Get current timestamp.
504fn current_timestamp() -> u64 {
505    SystemTime::now()
506        .duration_since(UNIX_EPOCH)
507        .map(|d| d.as_secs())
508        .unwrap_or(0)
509}
510
511// ============================================================================
512// Global store management
513// ============================================================================
514
515use std::sync::OnceLock;
516
517static RUN_STORES: OnceLock<dashmap::DashMap<String, Arc<RunStore>>> = OnceLock::new();
518
519/// Get or create the global run stores.
520fn get_run_stores() -> &'static dashmap::DashMap<String, Arc<RunStore>> {
521    RUN_STORES.get_or_init(dashmap::DashMap::new)
522}
523
524/// Get or create a run store for a run directory.
525pub fn get_run_store(run_dir_name: &str, base_dir: &Path) -> Result<Arc<RunStore>, std::io::Error> {
526    let stores = get_run_stores();
527
528    if let Some(store) = stores.get(run_dir_name) {
529        return Ok(store.clone());
530    }
531
532    let run_dir = base_dir.join(run_dir_name);
533    let store = RunStore::new(&run_dir)?;
534    stores.insert(run_dir_name.to_string(), store.clone());
535
536    Ok(store)
537}
538
539/// List all run directory names (both new-format and legacy UUID).
540pub fn list_runs(base_dir: &Path) -> Result<Vec<String>, std::io::Error> {
541    if !base_dir.exists() {
542        return Ok(vec![]);
543    }
544
545    let mut run_dirs = Vec::new();
546    for entry in fs::read_dir(base_dir)? {
547        let entry = entry?;
548        let path = entry.path();
549        if path.is_dir() {
550            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
551                run_dirs.push(name.to_string());
552            }
553        }
554    }
555
556    run_dirs.sort();
557    Ok(run_dirs)
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use tempfile::tempdir;
564
565    #[test]
566    fn test_run_store_init() {
567        let dir = tempdir().unwrap();
568        let run_id = uuid::Uuid::now_v7();
569        let store = RunStore::new(dir.path()).unwrap();
570        store.init_run(run_id, "Test task").unwrap();
571
572        let checkpoint = store.get_checkpoint().unwrap();
573        assert_eq!(checkpoint.run_id, run_id);
574        assert_eq!(checkpoint.task, "Test task");
575        assert_eq!(checkpoint.status, CheckpointStatus::Running);
576    }
577
578    #[test]
579    fn test_run_store_resume() {
580        let dir = tempdir().unwrap();
581        let run_id = uuid::Uuid::now_v7();
582        let store = RunStore::new(dir.path()).unwrap();
583        store.init_run(run_id, "Test task").unwrap();
584
585        // Open in new store instance
586        let store2 = RunStore::new(dir.path()).unwrap();
587        let checkpoint = store2.open_run(run_id).unwrap().unwrap();
588        assert_eq!(checkpoint.run_id, run_id);
589        assert_eq!(checkpoint.task, "Test task");
590    }
591
592    #[test]
593    fn test_can_resume() {
594        let dir = tempdir().unwrap();
595        let run_id = uuid::Uuid::now_v7();
596        let store = RunStore::new(dir.path()).unwrap();
597        store.init_run(run_id, "Test task").unwrap();
598
599        assert!(store.can_resume());
600    }
601
602    #[test]
603    fn test_resume_appends_events() {
604        // Regression: open_run previously opened events.jsonl read-only, causing
605        // every forwarded event in the resumed run to fail with
606        // `Access is denied (os error 5)` and silently dropping observability.
607        let dir = tempdir().unwrap();
608        let run_id = uuid::Uuid::now_v7();
609        let store = RunStore::new(dir.path()).unwrap();
610        store.init_run(run_id, "Test task").unwrap();
611
612        let store2 = RunStore::new(dir.path()).unwrap();
613        store2.open_run(run_id).unwrap().unwrap();
614
615        // Writing through the resumed store must succeed and persist the event.
616        let evt = AgentEvent::Log {
617            run_id,
618            agent_id: None,
619            level: crate::contract::event::LogLevel::Info,
620            msg: "resume smoke test".to_string(),
621        };
622        store2
623            .append_event(&evt)
624            .expect("append_event after resume must succeed");
625
626        let log = store2.get_event_log().expect("read events.jsonl");
627        assert!(
628            log.iter().any(|e| matches!(
629                e,
630                AgentEvent::Log { msg, .. } if msg == "resume smoke test"
631            )),
632            "event written after open_run must appear in events.jsonl"
633        );
634    }
635
636    // ----------------------------------------------------------------------
637    // Tests for F1 / F4 / F5 / F8 (spec `docs/src/core/state.rs.md`).
638    //
639    // These exercise the consolidated write path
640    // (`write_checkpoint_to_disk`), the lock-dance-free `cancel`, the
641    // snake_case `AgentStatus::as_str()` mapping that no longer depends on
642    // `Debug` formatting, and the `serde_to_io` error mapping helper that
643    // funnels every `serde_json::Error` through `ErrorKind::InvalidData`.
644    // ----------------------------------------------------------------------
645
646    use crate::contract::backend::AgentStatus;
647    use crate::contract::ids::TokenUsage;
648    use std::collections::HashSet;
649
650    fn sample_token_usage() -> TokenUsage {
651        TokenUsage {
652            input: 10,
653            output: 5,
654            cache_read: 0,
655            cache_write: 0,
656        }
657    }
658
659    fn build_agent_done(
660        run_id: RunId,
661        agent_id: AgentId,
662        status: AgentStatus,
663        tokens: TokenUsage,
664    ) -> AgentEvent {
665        AgentEvent::AgentDone {
666            run_id,
667            agent_id,
668            status,
669            tokens,
670            elapsed_ms: 0,
671            name: None,
672            agent_seq: 0,
673            output: serde_json::Value::Null,
674            findings: vec![],
675            prompt: String::new(),
676            retry_count: 0,
677            ts: Default::default(),
678        }
679    }
680
681    fn read_raw_checkpoint(run_dir: &Path) -> serde_json::Value {
682        let path = run_dir.join("checkpoint.json");
683        let content =
684            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read checkpoint.json: {e}"));
685        serde_json::from_str(&content).unwrap_or_else(|e| panic!("parse checkpoint.json: {e}"))
686    }
687
688    // ----- upsert_agent_result (F1 delegation) ---------------------------
689
690    #[test]
691    fn upsert_agent_result_persists_to_disk() {
692        // F1: `upsert_agent_result` must persist via the same write path as
693        // `write_checkpoint_to_disk` so that a follow-up `open_run` sees the
694        // inserted entry without any in-process plumbing.
695        let dir = tempdir().unwrap();
696        let run_id = uuid::Uuid::now_v7();
697        let store = RunStore::new(dir.path()).unwrap();
698        store.init_run(run_id, "upsert test").unwrap();
699
700        let agent_id = uuid::Uuid::now_v7();
701        let cache = AgentResultCache {
702            agent_id,
703            phase_id: 1,
704            status: "ok".into(),
705            output: serde_json::json!({"v": 42}),
706            findings: vec![],
707            tokens: 100,
708            completed_at: 1_700_000_000,
709            cache_key_hash: Some("deadbeef".into()),
710            description: None,
711            role: None,
712        };
713        store.upsert_agent_result(&cache).unwrap();
714
715        // 1. In-memory state reflects the upsert.
716        let cp = store.get_checkpoint().expect("checkpoint present");
717        let cached = cp
718            .agent_results
719            .get(&agent_id)
720            .expect("agent_id indexed after upsert");
721        assert_eq!(cached.tokens, 100);
722        assert_eq!(cached.status, "ok");
723
724        // 2. On-disk JSON matches the in-memory state.
725        let raw = read_raw_checkpoint(dir.path());
726        let ar = raw
727            .get("agent_results")
728            .and_then(|v| v.as_object())
729            .expect("agent_results object");
730        assert_eq!(ar.len(), 1, "exactly one agent cached on disk");
731        let entry = ar.values().next().expect("non-empty agent_results on disk");
732        assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(100));
733        assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("ok"));
734        assert_eq!(
735            entry.get("cache_key_hash").and_then(|v| v.as_str()),
736            Some("deadbeef")
737        );
738
739        // 3. Re-opening the run restores the entry from disk.
740        drop(store);
741        let reopened = RunStore::new(dir.path()).unwrap();
742        let restored = reopened.open_run(run_id).unwrap().unwrap();
743        assert!(
744            restored.agent_results.contains_key(&agent_id),
745            "upserted entry must survive close+reopen"
746        );
747        assert_eq!(restored.agent_results[&agent_id].tokens, 100);
748    }
749
750    #[test]
751    fn upsert_agent_result_updates_existing_entry() {
752        // F1: re-upserting the same agent_id overwrites the prior entry,
753        // mirroring the HashMap semantics of agent_results.
754        let dir = tempdir().unwrap();
755        let run_id = uuid::Uuid::now_v7();
756        let store = RunStore::new(dir.path()).unwrap();
757        store.init_run(run_id, "overwrite test").unwrap();
758
759        let agent_id = uuid::Uuid::now_v7();
760        let first = AgentResultCache {
761            agent_id,
762            phase_id: 1,
763            status: "ok".into(),
764            output: serde_json::json!("first"),
765            findings: vec![],
766            tokens: 10,
767            completed_at: 1,
768            cache_key_hash: None,
769            description: None,
770            role: None,
771        };
772        let second = AgentResultCache {
773            agent_id,
774            phase_id: 1,
775            status: "error".into(),
776            output: serde_json::json!("second"),
777            findings: vec![],
778            tokens: 99,
779            completed_at: 2,
780            cache_key_hash: None,
781            description: None,
782            role: None,
783        };
784        store.upsert_agent_result(&first).unwrap();
785        store.upsert_agent_result(&second).unwrap();
786
787        let cp = store.get_checkpoint().unwrap();
788        assert_eq!(cp.agent_results.len(), 1, "no duplicate entries");
789        let cached = &cp.agent_results[&agent_id];
790        assert_eq!(cached.status, "error");
791        assert_eq!(cached.tokens, 99);
792        assert_eq!(cached.completed_at, 2);
793
794        // Disk must also reflect the second upsert, not the first.
795        let raw = read_raw_checkpoint(dir.path());
796        let ar = raw
797            .get("agent_results")
798            .and_then(|v| v.as_object())
799            .unwrap();
800        assert_eq!(ar.len(), 1);
801        let entry = ar.values().next().unwrap();
802        assert_eq!(entry.get("tokens").and_then(|v| v.as_u64()), Some(99));
803        assert_eq!(entry.get("status").and_then(|v| v.as_str()), Some("error"));
804    }
805
806    #[test]
807    fn upsert_agent_result_noop_when_uninitialized() {
808        // F1: before init_run the in-memory checkpoint is None and the helper
809        // must not create a checkpoint.json from nothing. This keeps the
810        // behaviour of "upsert only patches an existing checkpoint".
811        let dir = tempdir().unwrap();
812        let store = RunStore::new(dir.path()).unwrap();
813        assert!(store.get_checkpoint().is_none());
814        let cp_path = dir.path().join("checkpoint.json");
815        assert!(!cp_path.exists(), "no checkpoint.json before init");
816
817        let cache = AgentResultCache {
818            agent_id: uuid::Uuid::now_v7(),
819            phase_id: 1,
820            status: "ok".into(),
821            output: serde_json::json!(null),
822            findings: vec![],
823            tokens: 0,
824            completed_at: 0,
825            cache_key_hash: None,
826            description: None,
827            role: None,
828        };
829        store.upsert_agent_result(&cache).unwrap();
830        assert!(
831            !cp_path.exists(),
832            "upsert_agent_result must not create checkpoint.json before init_run"
833        );
834        assert!(store.get_checkpoint().is_none());
835    }
836
837    #[test]
838    fn upsert_agent_result_advances_updated_at() {
839        // F1: the delegated write path must still update the checkpoint's
840        // `updated_at` timestamp the same way the inline implementation did.
841        let dir = tempdir().unwrap();
842        let run_id = uuid::Uuid::now_v7();
843        let store = RunStore::new(dir.path()).unwrap();
844        store.init_run(run_id, "ts test").unwrap();
845        let before = store.get_checkpoint().unwrap().updated_at;
846
847        std::thread::sleep(std::time::Duration::from_millis(1100));
848
849        let cache = AgentResultCache {
850            agent_id: uuid::Uuid::now_v7(),
851            phase_id: 1,
852            status: "ok".into(),
853            output: serde_json::json!(null),
854            findings: vec![],
855            tokens: 0,
856            completed_at: 0,
857            cache_key_hash: None,
858            description: None,
859            role: None,
860        };
861        store.upsert_agent_result(&cache).unwrap();
862        let after = store.get_checkpoint().unwrap().updated_at;
863        assert!(
864            after > before,
865            "updated_at must advance after upsert (before={before}, after={after})"
866        );
867    }
868
869    // ----- cancel (F1 delegation + F4 lock-dance collapse) ---------------
870
871    #[test]
872    fn cancel_persists_cancelled_status_to_disk() {
873        // F1+F4: cancel delegates to write_checkpoint_to_disk with the
874        // already-mutated checkpoint (no redundant read-lock + inline
875        // serialize). The Cancelled status must appear on disk so a follow-up
876        // process sees the terminal state.
877        let dir = tempdir().unwrap();
878        let run_id = uuid::Uuid::now_v7();
879        let store = RunStore::new(dir.path()).unwrap();
880        store.init_run(run_id, "cancel me").unwrap();
881        assert!(store.can_resume());
882
883        store.cancel().unwrap();
884
885        // In-memory: status is Cancelled, can_resume() is false.
886        let cp = store.get_checkpoint().unwrap();
887        assert_eq!(cp.status, CheckpointStatus::Cancelled);
888        assert!(!store.can_resume());
889
890        // On-disk: same status, observable across processes.
891        let raw = read_raw_checkpoint(dir.path());
892        assert_eq!(
893            raw.get("status").and_then(|v| v.as_str()),
894            Some("cancelled")
895        );
896
897        // Reopen: the persisted status survives close+reopen.
898        drop(store);
899        let reopened = RunStore::new(dir.path()).unwrap();
900        let restored = reopened.open_run(run_id).unwrap().unwrap();
901        assert_eq!(restored.status, CheckpointStatus::Cancelled);
902        assert!(!reopened.can_resume());
903    }
904
905    #[test]
906    fn cancel_is_idempotent() {
907        // F4: the new cancel body only mutates under the write lock and
908        // delegates to write_checkpoint_to_disk once. Calling it twice must
909        // not panic, not deadlock, and must leave the persisted state
910        // consistent (Cancelled, monotonically newer updated_at).
911        let dir = tempdir().unwrap();
912        let run_id = uuid::Uuid::now_v7();
913        let store = RunStore::new(dir.path()).unwrap();
914        store.init_run(run_id, "double cancel").unwrap();
915
916        store.cancel().unwrap();
917        let after_first = store.get_checkpoint().unwrap().updated_at;
918        std::thread::sleep(std::time::Duration::from_millis(1100));
919
920        store.cancel().expect("second cancel must succeed");
921        let after_second = store.get_checkpoint().unwrap().updated_at;
922
923        assert_eq!(
924            store.get_checkpoint().unwrap().status,
925            CheckpointStatus::Cancelled
926        );
927        assert!(
928            after_second >= after_first,
929            "updated_at must not regress (was {after_first}, now {after_second})"
930        );
931
932        let raw = read_raw_checkpoint(dir.path());
933        assert_eq!(
934            raw.get("status").and_then(|v| v.as_str()),
935            Some("cancelled")
936        );
937    }
938
939    #[test]
940    fn cancel_before_init_is_safe_noop() {
941        // F4: cancel on an uninitialised store must not panic and must not
942        // create a checkpoint file. The cancelled-status guard requires
943        // `Some(checkpoint)` so the body simply skips.
944        let dir = tempdir().unwrap();
945        let store = RunStore::new(dir.path()).unwrap();
946        assert!(store.get_checkpoint().is_none());
947        store.cancel().expect("cancel before init must succeed");
948        assert!(store.get_checkpoint().is_none());
949        assert!(
950            !dir.path().join("checkpoint.json").exists(),
951            "cancel before init must not create checkpoint.json"
952        );
953    }
954
955    #[test]
956    fn cancel_loads_checkpoint_from_disk_when_cache_is_empty() {
957        // Cross-process regression: a different process (e.g. `luft run`)
958        // wrote a Running checkpoint to disk, then this process (e.g. the
959        // MCP server) opens a fresh `RunStore` whose in-memory cache is
960        // empty. `cancel()` must load from disk, flip the status to
961        // Cancelled, and persist — not silently no-op.
962        let dir = tempdir().unwrap();
963        let run_id = uuid::Uuid::now_v7();
964
965        // Write the checkpoint directly to disk, bypassing the in-memory
966        // cache entirely (mirrors what another process leaves behind).
967        let cp = RunCheckpoint {
968            run_id,
969            task: "started elsewhere".into(),
970            status: CheckpointStatus::Running,
971            current_phase: 0,
972            completed_phases: vec![],
973            agent_results: HashMap::new(),
974            findings: vec![],
975            total_tokens: 0,
976            created_at: current_timestamp(),
977            updated_at: current_timestamp(),
978            workflow_meta: None,
979            started_agent_ids: vec![],
980        };
981        let cp_path = dir.path().join("checkpoint.json");
982        std::fs::write(&cp_path, serde_json::to_string_pretty(&cp).unwrap()).unwrap();
983
984        // Fresh RunStore — cache is None, exactly like a query-only process.
985        let store = RunStore::new(dir.path()).unwrap();
986        assert!(store.get_checkpoint().is_none(), "cache must start empty");
987
988        store.cancel().expect("cancel must succeed cross-process");
989
990        // In-memory cache is now populated with the cancelled checkpoint.
991        let cached = store
992            .get_checkpoint()
993            .expect("cache populated after cancel");
994        assert_eq!(cached.status, CheckpointStatus::Cancelled);
995
996        // Disk reflects the same terminal status — observable to any process.
997        let raw = read_raw_checkpoint(dir.path());
998        assert_eq!(
999            raw.get("status").and_then(|v| v.as_str()),
1000            Some("cancelled")
1001        );
1002        assert_eq!(
1003            raw.get("run_id").and_then(|v| v.as_str()),
1004            Some(run_id.to_string()).as_deref()
1005        );
1006    }
1007
1008    #[test]
1009    fn cancel_preserves_agent_results_and_findings() {
1010        // F1+F4: cancel only mutates status/updated_at. Pre-existing
1011        // agent_results, findings, and total_tokens must be preserved
1012        // verbatim across the cancel write.
1013        let dir = tempdir().unwrap();
1014        let run_id = uuid::Uuid::now_v7();
1015        let store = RunStore::new(dir.path()).unwrap();
1016        store.init_run(run_id, "preserve").unwrap();
1017
1018        let agent_id = uuid::Uuid::now_v7();
1019        let cache = AgentResultCache {
1020            agent_id,
1021            phase_id: 1,
1022            status: "ok".into(),
1023            output: serde_json::json!({"x": 1}),
1024            findings: vec![],
1025            tokens: 250,
1026            completed_at: 7,
1027            cache_key_hash: Some("hash-1".into()),
1028            description: None,
1029            role: None,
1030        };
1031        store.upsert_agent_result(&cache).unwrap();
1032        let before = store.get_checkpoint().unwrap();
1033
1034        store.cancel().unwrap();
1035        let after = store.get_checkpoint().unwrap();
1036
1037        assert_eq!(after.status, CheckpointStatus::Cancelled);
1038        assert_eq!(after.agent_results.len(), 1);
1039        assert_eq!(after.agent_results[&agent_id].tokens, 250);
1040        assert_eq!(
1041            after.agent_results[&agent_id].cache_key_hash.as_deref(),
1042            Some("hash-1")
1043        );
1044        assert_eq!(after.total_tokens, before.total_tokens);
1045    }
1046
1047    #[test]
1048    fn late_event_from_another_process_cannot_resurrect_cancelled_run() {
1049        let dir = tempdir().unwrap();
1050        let run_id = uuid::Uuid::now_v7();
1051        let writer = RunStore::new(dir.path()).unwrap();
1052        writer.init_run(run_id, "cross process race").unwrap();
1053
1054        // A second process/store cancels the run after the first store has
1055        // loaded its running checkpoint into memory.
1056        let canceller = RunStore::new(dir.path()).unwrap();
1057        canceller.cancel().unwrap();
1058
1059        writer
1060            .append_event(&AgentEvent::RunDone {
1061                run_id,
1062                status: crate::contract::event::RunStatus::Completed,
1063                total_tokens: TokenUsage::default(),
1064                report: serde_json::Value::Null,
1065                ts: chrono::Utc::now(),
1066            })
1067            .unwrap();
1068
1069        let restored = RunStore::new(dir.path())
1070            .unwrap()
1071            .open_run(run_id)
1072            .unwrap()
1073            .unwrap();
1074        assert_eq!(restored.status, CheckpointStatus::Cancelled);
1075    }
1076
1077    // ----- AgentDone -> AgentResultCache.status (F5) ---------------------
1078
1079    #[test]
1080    fn agent_done_persists_snake_case_status_for_each_variant() {
1081        // F5 KEY test: the persisted AgentResultCache.status string MUST
1082        // come from AgentStatus::as_str() and NOT from Debug formatting.
1083        // For TimedOut this is the load-bearing regression: Debug lowercased
1084        // yields "timedout" (no underscore) but as_str() yields "timed_out".
1085        let dir = tempdir().unwrap();
1086        let run_id = uuid::Uuid::now_v7();
1087        let store = RunStore::new(dir.path()).unwrap();
1088        store.init_run(run_id, "F5 variants").unwrap();
1089
1090        let cases: Vec<(AgentStatus, &str)> = vec![
1091            (AgentStatus::Ok, "ok"),
1092            (AgentStatus::Error, "error"),
1093            (AgentStatus::Cancelled, "cancelled"),
1094            (AgentStatus::TimedOut, "timed_out"),
1095        ];
1096        for (status, expected) in &cases {
1097            let agent_id = uuid::Uuid::now_v7();
1098            let evt = build_agent_done(run_id, agent_id, status.clone(), sample_token_usage());
1099            store.append_event(&evt).unwrap();
1100
1101            let raw = read_raw_checkpoint(dir.path());
1102            let ar = raw
1103                .get("agent_results")
1104                .and_then(|v| v.as_object())
1105                .expect("agent_results object");
1106            let entry = ar
1107                .values()
1108                .find(|v| {
1109                    v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string())
1110                })
1111                .unwrap_or_else(|| panic!("entry for {agent_id} missing"));
1112            let persisted = entry
1113                .get("status")
1114                .and_then(|v| v.as_str())
1115                .unwrap_or_else(|| panic!("status missing for {status:?}"));
1116            assert_eq!(
1117                persisted, *expected,
1118                "AgentDone({status:?}) must persist status={expected:?} (snake_case); \
1119                 got {persisted:?}. If this fails with \"timedout\" for TimedOut, \
1120                 F5 has regressed to Debug formatting."
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn agent_done_timed_out_persists_with_underscore_not_collapsed() {
1127        // Strongest F5 regression guard: the buggy form would persist
1128        // "timedout" (no underscore) for TimedOut. The fixed form persists
1129        // "timed_out". This test fails loudly if anyone reintroduces the
1130        // `format!("{:?}", status).to_lowercase()` shortcut.
1131        let dir = tempdir().unwrap();
1132        let run_id = uuid::Uuid::now_v7();
1133        let store = RunStore::new(dir.path()).unwrap();
1134        store.init_run(run_id, "timed-out guard").unwrap();
1135
1136        let agent_id = uuid::Uuid::now_v7();
1137        let evt = build_agent_done(
1138            run_id,
1139            agent_id,
1140            AgentStatus::TimedOut,
1141            sample_token_usage(),
1142        );
1143        store.append_event(&evt).unwrap();
1144
1145        let raw = read_raw_checkpoint(dir.path());
1146        let ar = raw
1147            .get("agent_results")
1148            .and_then(|v| v.as_object())
1149            .unwrap();
1150        let entry = ar.values().next().expect("entry exists");
1151        let persisted = entry.get("status").and_then(|v| v.as_str()).unwrap();
1152
1153        assert_eq!(
1154            persisted, "timed_out",
1155            "AgentDone(TimedOut) must persist \"timed_out\" with an underscore; got {persisted:?}"
1156        );
1157        assert_ne!(
1158            persisted, "timedout",
1159            "AgentDone(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
1160        );
1161    }
1162
1163    #[test]
1164    fn agent_done_then_reopen_restores_snake_case_status() {
1165        // The persisted snake_case status must survive a close+reopen cycle,
1166        // since legacy checkpoints with Debug-lowercased "timedout" should be
1167        // distinguished from new checkpoints with "timed_out" — but new
1168        // checkpoints must round-trip cleanly through the JSON pipeline.
1169        let dir = tempdir().unwrap();
1170        let run_id = uuid::Uuid::now_v7();
1171        let store = RunStore::new(dir.path()).unwrap();
1172        store.init_run(run_id, "round-trip").unwrap();
1173
1174        let agent_id = uuid::Uuid::now_v7();
1175        let evt = build_agent_done(
1176            run_id,
1177            agent_id,
1178            AgentStatus::Cancelled,
1179            TokenUsage {
1180                input: 1,
1181                output: 2,
1182                cache_read: 0,
1183                cache_write: 0,
1184            },
1185        );
1186        store.append_event(&evt).unwrap();
1187        drop(store);
1188
1189        let reopened = RunStore::new(dir.path()).unwrap();
1190        let cp = reopened.open_run(run_id).unwrap().unwrap();
1191        let cached = cp
1192            .agent_results
1193            .get(&agent_id)
1194            .expect("agent cached on disk");
1195        assert_eq!(cached.status, "cancelled");
1196        assert_eq!(cached.tokens, 3);
1197    }
1198
1199    // ----- F8 serde_to_io error mapping (indirect) -----------------------
1200
1201    #[test]
1202    fn open_run_with_corrupt_checkpoint_returns_invalid_data() {
1203        // F8: every serde_json::Error → io::Error funnel passes through
1204        // ErrorKind::InvalidData. Verifies the consolidated helper is wired
1205        // into open_run's deserialization path.
1206        let dir = tempdir().unwrap();
1207        std::fs::create_dir_all(dir.path()).unwrap();
1208        std::fs::write(
1209            dir.path().join("checkpoint.json"),
1210            b"{ this is not valid json",
1211        )
1212        .unwrap();
1213
1214        let store = RunStore::new(dir.path()).unwrap();
1215        let err = store
1216            .open_run(uuid::Uuid::now_v7())
1217            .expect_err("corrupt JSON must surface as an io::Error");
1218        assert_eq!(
1219            err.kind(),
1220            std::io::ErrorKind::InvalidData,
1221            "corrupt checkpoint must map to InvalidData via serde_to_io; got {:?}",
1222            err.kind()
1223        );
1224    }
1225
1226    #[test]
1227    fn open_run_with_wrong_typed_checkpoint_returns_invalid_data() {
1228        // F8: even structurally-valid JSON that fails typed deserialisation
1229        // (missing required field) must come back as InvalidData.
1230        let dir = tempdir().unwrap();
1231        std::fs::create_dir_all(dir.path()).unwrap();
1232        // `task` is a required field on RunCheckpoint; omitting it triggers
1233        // a serde error which the helper must classify as InvalidData.
1234        std::fs::write(
1235            dir.path().join("checkpoint.json"),
1236            br#"{"run_id":"00000000-0000-0000-0000-000000000000","status":"running"}"#,
1237        )
1238        .unwrap();
1239
1240        let store = RunStore::new(dir.path()).unwrap();
1241        let err = store
1242            .open_run(uuid::Uuid::now_v7())
1243            .expect_err("missing-field JSON must surface as an io::Error");
1244        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1245    }
1246
1247    #[test]
1248    fn get_event_log_with_corrupt_line_returns_invalid_data() {
1249        // F8: the consolidated helper also covers get_event_log's per-line
1250        // deserialisation. A single bad line must surface as InvalidData
1251        // rather than SomeOtherKind so callers can distinguish "corrupted
1252        // journal" from "missing file".
1253        let dir = tempdir().unwrap();
1254        std::fs::create_dir_all(dir.path()).unwrap();
1255        std::fs::write(dir.path().join("events.jsonl"), b"not-json\n").unwrap();
1256
1257        let store = RunStore::new(dir.path()).unwrap();
1258        let err = store
1259            .get_event_log()
1260            .expect_err("corrupt event line must surface as an io::Error");
1261        assert_eq!(
1262            err.kind(),
1263            std::io::ErrorKind::InvalidData,
1264            "corrupt event line must map to InvalidData via serde_to_io; got {:?}",
1265            err.kind()
1266        );
1267    }
1268
1269    // ----- Cross-cutting safety nets -------------------------------------
1270
1271    #[test]
1272    fn as_str_variants_round_trip_through_checkpoint_pipeline() {
1273        // Property-style test: for every AgentStatus variant, the persisted
1274        // status string must equal AgentStatus::variant.as_str() exactly,
1275        // with no whitespace, no case drift, and no truncation. This catches
1276        // accidental future reverts to Debug-derived strings.
1277        let dir = tempdir().unwrap();
1278        let run_id = uuid::Uuid::now_v7();
1279        let store = RunStore::new(dir.path()).unwrap();
1280        store.init_run(run_id, "round-trip property").unwrap();
1281
1282        let variants = [
1283            AgentStatus::Ok,
1284            AgentStatus::Error,
1285            AgentStatus::Cancelled,
1286            AgentStatus::TimedOut,
1287        ];
1288        let mut seen: HashSet<String> = HashSet::new();
1289
1290        for variant in &variants {
1291            let agent_id = uuid::Uuid::now_v7();
1292            let evt = build_agent_done(run_id, agent_id, variant.clone(), sample_token_usage());
1293            store.append_event(&evt).unwrap();
1294
1295            let cp = store.get_checkpoint().unwrap();
1296            let cached = cp
1297                .agent_results
1298                .get(&agent_id)
1299                .expect("entry for {agent_id}");
1300            assert_eq!(
1301                cached.status,
1302                variant.as_str(),
1303                "{variant:?}.as_str() must round-trip via append_event→update_from_event"
1304            );
1305            // Also confirm uniqueness is preserved on disk.
1306            assert!(
1307                seen.insert(cached.status.clone()),
1308                "duplicate status {cached_status:?} persisted for {variant:?}",
1309                cached_status = cached.status
1310            );
1311        }
1312    }
1313}