Skip to main content

luft_core/
journal.rs

1//! Journal / Resume — checkpoint persistence with replay semantics (M1).
2//!
3//! Provides:
4//! - `JournalStore` — wraps `RunStore` with cache-key index for O(1) lookups
5//! - `AgentCacheKey` — deterministic blake3-based key for agent invocations
6//! - `JournalCallback` trait — scheduler integration hook
7//! - `ResumeContext` — orchestrates run recovery
8//! - `gc_runs()` — cleanup old completed runs
9//!
10//! Thread safety: All public methods take `&self` (interior mutability via RwLock).
11//! The underlying checkpoint data is protected by a single writer lock.
12//!
13//! Lifecycle:
14//!   new() → init_run() → cache_agent()* → flush()
15//!   或:
16//!   open() → has_completed()/get_cached() → workflow resume logic
17
18use crate::contract::backend::AgentStatus;
19use crate::contract::event::{AgentEvent, EventSender};
20use crate::contract::finding::Finding;
21use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
22use crate::scheduler::{BackendRegistry, SchedulerConfig};
23use crate::state::{AgentResultCache, RunCheckpoint, RunStore};
24use blake3::Hasher;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::path::Path;
28use std::sync::{Arc, RwLock};
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use thiserror::Error;
31
32// ============================================================================
33// Error Types
34// ============================================================================
35
36#[derive(Error, Debug)]
37pub enum JournalError {
38    #[error("run not found: {0}")]
39    RunNotFound(RunId),
40    #[error("run is not resumable (status: {status:?})")]
41    NotResumable { status: String },
42    #[error("I/O error: {0}")]
43    Io(#[from] std::io::Error),
44    #[error("serialization error: {0}")]
45    Serde(#[from] serde_json::Error),
46    #[error("journal corrupted: {0}")]
47    Corrupted(String),
48}
49
50// ============================================================================
51// Agent Cache Key
52// ============================================================================
53
54/// Deterministic cache key for an agent invocation.
55/// Normalizes whitespace/unicode to ensure cache hits across formatting differences.
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub struct AgentCacheKey {
58    pub hash: String,
59    /// Human-readable for debugging
60    pub prompt_preview: String,
61    pub model: Option<String>,
62    pub phase_id: PhaseId,
63}
64
65impl AgentCacheKey {
66    /// Generate a cache key from agent parameters.
67    /// Uses blake3 with null separators to prevent field-concatenation collisions.
68    pub fn new(prompt: &str, model: Option<&str>, phase_id: PhaseId) -> Self {
69        let normalized = normalize_prompt(prompt);
70        let preview = if normalized.chars().count() > 80 {
71            format!("{}...", normalized.chars().take(80).collect::<String>())
72        } else {
73            normalized.clone()
74        };
75
76        let mut h = Hasher::new();
77        h.update(normalized.as_bytes());
78        h.update(b"\0");
79        if let Some(m) = model {
80            h.update(m.as_bytes());
81        }
82        h.update(b"\0");
83        h.update(&phase_id.to_le_bytes());
84
85        Self {
86            hash: h.finalize().to_hex().to_string(),
87            prompt_preview: preview,
88            model: model.map(|s| s.to_string()),
89            phase_id,
90        }
91    }
92}
93
94fn normalize_prompt(prompt: &str) -> String {
95    prompt
96        .replace("\r\n", "\n")
97        .replace('\r', "\n")
98        .split_whitespace()
99        .collect::<Vec<_>>()
100        .join(" ")
101}
102
103// ============================================================================
104// JournalStore — the journal abstraction over RunStore
105// ============================================================================
106
107/// JournalStore wraps RunStore with replay semantics.
108///
109/// Thread safety: All public methods take `&self` (interior mutability via RwLock).
110/// The underlying checkpoint data is protected by a single writer lock.
111///
112/// Usage lifecycle:
113///   new() → init_run() → cache_agent()* → flush()
114///   或:
115///   open() → has_completed()/get_cached() → workflow resume logic
116pub struct JournalStore {
117    /// Underlying persistence engine (checkpoint.json + events.jsonl).
118    inner: Arc<RunStore>,
119    /// In-memory index: AgentCacheKey hash → AgentResultCache.
120    /// Populated at open() time from the checkpoint's agent_results map.
121    cache_index: RwLock<HashMap<String, AgentResultCache>>,
122    /// Event sender for broadcasting journal updates.
123    event_tx: Option<EventSender>,
124}
125
126impl std::fmt::Debug for JournalStore {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("JournalStore")
129            .field("inner", &self.inner)
130            .field("cache_index_size", &self.cache_index.read().unwrap().len())
131            .field("has_event_tx", &self.event_tx.is_some())
132            .finish()
133    }
134}
135
136impl JournalStore {
137    /// Create a new journal store at the given directory.
138    /// Initializes the underlying RunStore and creates an empty cache index.
139    pub fn new(run_dir: &Path) -> Result<Self, JournalError> {
140        tracing::debug!(path = %run_dir.display(), "creating journal store");
141        let inner = RunStore::new(run_dir)?;
142        Ok(Self {
143            inner,
144            cache_index: RwLock::new(HashMap::new()),
145            event_tx: None,
146        })
147    }
148
149    /// Attach an event sender for broadcasting journal updates.
150    pub fn with_event_sender(mut self, tx: EventSender) -> Self {
151        self.event_tx = Some(tx);
152        self
153    }
154
155    /// Initialize a new run in the journal.
156    pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), JournalError> {
157        tracing::info!(%run_id, %task, "initializing run in journal");
158        self.inner.init_run(run_id, task)?;
159        Ok(())
160    }
161
162    /// Initialize a new run with declarative workflow metadata.
163    pub fn init_run_with_meta(
164        &self,
165        run_id: RunId,
166        task: &str,
167        workflow_meta: serde_json::Value,
168    ) -> Result<(), JournalError> {
169        tracing::info!(
170            %run_id, %task,
171            "initializing run in journal with meta"
172        );
173        self.inner.init_run_with_meta(run_id, task, workflow_meta)?;
174        Ok(())
175    }
176
177    /// Open an existing run and rebuild the cache index from persisted data.
178    ///
179    /// This is the entry point for `--resume`. It:
180    /// 1. Loads the checkpoint from disk
181    /// 2. Rebuilds the in-memory cache_index from agent_results
182    /// 3. Returns the checkpoint for the caller to inspect
183    pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
184        tracing::info!(%run_id, "opening journal for resume");
185        let checkpoint = self
186            .inner
187            .open_run(run_id)?
188            .ok_or(JournalError::RunNotFound(run_id))?;
189
190        if matches!(
191            checkpoint.status,
192            crate::state::CheckpointStatus::Completed
193                | crate::state::CheckpointStatus::Cancelled
194        ) {
195            return Err(JournalError::NotResumable {
196                status: format!("{:?}", checkpoint.status),
197            });
198        }
199
200        // Rebuild cache index — index by both agent_id and cache_key_hash
201        // so that the Lua SDK's has_completed(key) works after resume.
202        let mut index = HashMap::new();
203        for (agent_id, cache) in &checkpoint.agent_results {
204            index.insert(agent_id.to_string(), cache.clone());
205            if let Some(ref hash) = cache.cache_key_hash {
206                index.insert(hash.clone(), cache.clone());
207            }
208        }
209        *self.cache_index.write().unwrap() = index;
210
211        Ok(checkpoint)
212    }
213
214    /// Cache an agent's result in the journal.
215    ///
216    /// Called by the scheduler after an agent completes successfully or fails
217    /// with a non-retryable error. The result is persisted to disk immediately
218    /// (via append_event → update_from_event → write_checkpoint_to_disk).
219    #[allow(clippy::too_many_arguments)]
220    pub fn cache_agent(
221        &self,
222        cache_key: &AgentCacheKey,
223        agent_id: AgentId,
224        phase_id: PhaseId,
225        status: AgentStatus,
226        output: serde_json::Value,
227        findings: Vec<Finding>,
228        tokens: TokenUsage,
229    ) -> Result<AgentCacheKey, JournalError> {
230        let ts = current_timestamp();
231        let cache = AgentResultCache {
232            agent_id,
233            phase_id,
234            status: status.as_str().to_string(),
235            output,
236            findings,
237            tokens: tokens.total(),
238            completed_at: ts,
239            cache_key_hash: Some(cache_key.hash.clone()),
240            description: None,
241            role: None,
242        };
243
244        // Update in-memory index (instant lookup)
245        {
246            let mut index = self.cache_index.write().unwrap();
247            index.insert(cache_key.hash.clone(), cache.clone());
248            // Also index by agent_id for open() compatibility
249            index.insert(agent_id.to_string(), cache.clone());
250        }
251
252        // Persist the full cache entry directly to checkpoint disk (preserves cache_key_hash)
253        if let Err(e) = self.inner.upsert_agent_result(&cache) {
254            tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
255        }
256
257        // Also append event to log (this triggers update_from_event which finds the existing hash)
258        let event = AgentEvent::AgentDone {
259            run_id: self
260                .inner
261                .get_checkpoint()
262                .map(|c| c.run_id)
263                .unwrap_or_else(uuid::Uuid::nil),
264            agent_id,
265            status,
266            tokens,
267            elapsed_ms: 0,
268            name: None,
269            agent_seq: 0,
270            output: serde_json::Value::Null,
271            findings: Vec::new(),
272            prompt: String::new(),
273            retry_count: 0,
274        };
275        self.inner.append_event(&event)?;
276
277        // Broadcast via event bus (non-blocking — uses broadcast channel)
278        if let Some(ref tx) = self.event_tx {
279            let _ = tx.send(event);
280        }
281
282        Ok(cache_key.clone())
283    }
284
285    /// Record an agent's output for resume replay, keyed by `cache_key`.
286    ///
287    /// Unlike [`cache_agent`], this does **not** append an `AgentDone` event,
288    /// so it never double-counts tokens against the event-driven checkpoint
289    /// totals. It only upserts the checkpoint entry (preserving `cache_key_hash`
290    /// and the structured output) and refreshes the in-memory cache index.
291    /// Called by the Lua SDK after an agent completes during a live run.
292    #[allow(clippy::too_many_arguments)]
293    pub fn record_result(
294        &self,
295        cache_key: &AgentCacheKey,
296        agent_id: AgentId,
297        phase_id: PhaseId,
298        status: AgentStatus,
299        output: serde_json::Value,
300        findings: Vec<Finding>,
301        tokens: TokenUsage,
302    ) {
303        let cache = AgentResultCache {
304            agent_id,
305            phase_id,
306            status: status.as_str().to_string(),
307            output,
308            findings,
309            tokens: tokens.total(),
310            completed_at: current_timestamp(),
311            cache_key_hash: Some(cache_key.hash.clone()),
312            description: None,
313            role: None,
314        };
315
316        {
317            let mut index = self.cache_index.write().unwrap();
318            index.insert(cache_key.hash.clone(), cache.clone());
319            index.insert(agent_id.to_string(), cache.clone());
320        }
321
322        if let Err(e) = self.inner.upsert_agent_result(&cache) {
323            tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
324        }
325    }
326
327    /// Access the underlying run store (shared persistence engine).
328    /// Allows the CLI to route the scheduler event stream through the same
329    /// `RunStore` instance the journal uses, avoiding split-brain checkpoints.
330    pub fn store(&self) -> Arc<RunStore> {
331        self.inner.clone()
332    }
333
334    /// Append an event to the underlying run store (event log + checkpoint).
335    pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
336        self.inner.append_event(event)?;
337        Ok(())
338    }
339
340    /// Check if an agent with the given cache key has already completed.
341    /// Used by the Lua SDK's agent() function before submitting to the scheduler.
342    pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
343        let index = self.cache_index.read().unwrap();
344        index.contains_key(&cache_key.hash)
345    }
346
347    /// Get cached result for an agent.
348    /// Returns None if the agent hasn't completed yet.
349    pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
350        let index = self.cache_index.read().unwrap();
351        index.get(&cache_key.hash).cloned()
352    }
353
354    /// Get list of all completed agent cache keys.
355    /// Useful for debugging and progress reporting.
356    pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
357        let index = self.cache_index.read().unwrap();
358        index
359            .keys()
360            .map(|k| AgentCacheKey {
361                hash: k.clone(),
362                prompt_preview: String::new(),
363                model: None,
364                phase_id: 0,
365            })
366            .collect()
367    }
368
369    /// Get the underlying checkpoint (read-only snapshot).
370    pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
371        self.inner.get_checkpoint()
372    }
373
374    /// Flush all pending data to disk.
375    pub fn flush(&self) -> Result<(), JournalError> {
376        // RunStore auto-flushes on append_event; explicit flush for safety.
377        Ok(())
378    }
379
380    /// Mark the run as cancelled.
381    pub fn cancel(&self) -> Result<(), JournalError> {
382        self.inner.cancel()?;
383        Ok(())
384    }
385}
386
387// ============================================================================
388// Scheduler Integration — JournalCallback trait
389// ============================================================================
390
391/// Composite callback that chains multiple JournalCallback implementations.
392pub struct CompositeJournalCallback {
393    callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
394}
395
396impl CompositeJournalCallback {
397    pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
398        Self { callbacks }
399    }
400}
401
402#[async_trait::async_trait]
403impl crate::scheduler::JournalCallback for CompositeJournalCallback {
404    async fn on_agent_done(
405        &self,
406        agent_id: AgentId,
407        phase_id: PhaseId,
408        status: AgentStatus,
409        output: serde_json::Value,
410        tokens: TokenUsage,
411    ) {
412        for cb in &self.callbacks {
413            cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
414                .await;
415        }
416    }
417}
418
419#[async_trait::async_trait]
420impl crate::scheduler::JournalCallback for JournalStore {
421    async fn on_agent_done(
422        &self,
423        agent_id: AgentId,
424        phase_id: PhaseId,
425        status: AgentStatus,
426        output: serde_json::Value,
427        tokens: TokenUsage,
428    ) {
429        // Store into the checkpoint by agent_id directly (no cache_key index needed).
430        let ts = current_timestamp();
431        let cache = AgentResultCache {
432            agent_id,
433            phase_id,
434            status: status.as_str().to_string(),
435            output,
436            findings: vec![], // findings not available from scheduler callback
437            tokens: tokens.total(),
438            completed_at: ts,
439            cache_key_hash: None, // not indexed by cache key from this path
440            description: None,
441            role: None,
442        };
443
444        // Persist to checkpoint disk
445        if let Err(e) = self.inner.upsert_agent_result(&cache) {
446            tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
447        }
448    }
449}
450
451// ============================================================================
452// Resume Orchestration
453// ============================================================================
454
455/// Context for resuming a run.
456#[derive(Debug)]
457pub struct ResumeContext {
458    pub run_id: RunId,
459    pub checkpoint: RunCheckpoint,
460    pub journal: Arc<JournalStore>,
461    pub scheduler_config: SchedulerConfig,
462    pub backend_registry: BackendRegistry,
463}
464
465/// Options for creating a run (new or resume).
466#[derive(Debug, Clone)]
467pub enum RunCreationMode {
468    /// Start a fresh run.
469    New { task: String },
470    /// Resume from an existing checkpoint.
471    Resume { run_id: RunId, run_dir_name: String },
472    /// Auto-detect: resume if resumable run exists, else new.
473    Auto { task: String },
474}
475
476impl RunCreationMode {
477    /// Resolve the creation mode to concrete parameters.
478    /// For Auto mode, checks journal directory for resumable runs.
479    pub fn resolve(
480        self,
481        journal_dir: &Path,
482    ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
483        match self {
484            RunCreationMode::New { task: _ } => {
485                let run_id = uuid::Uuid::now_v7();
486                Ok((run_id, None))
487            }
488            RunCreationMode::Resume {
489                run_id,
490                run_dir_name,
491            } => {
492                let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
493                let checkpoint = store.open(run_id)?;
494                Ok((run_id, Some(checkpoint)))
495            }
496            RunCreationMode::Auto { task: _ } => {
497                // List all run dirs, find the most recent Running checkpoint
498                let run_dirs = crate::state::list_runs(journal_dir)?;
499                for dir_name in run_dirs.iter().rev() {
500                    let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
501                    if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
502                        if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
503                            if matches!(
504                                checkpoint.status,
505                                crate::state::CheckpointStatus::Running
506                            ) {
507                                let run_id = checkpoint.run_id;
508                                return Ok((run_id, Some(checkpoint)));
509                            }
510                        }
511                    }
512                }
513                // No resumable run — create new
514                let run_id = uuid::Uuid::now_v7();
515                Ok((run_id, None))
516            }
517        }
518    }
519}
520
521// ============================================================================
522// GC (Garbage Collection)
523// ============================================================================
524
525/// Clean up old completed/cancelled runs.
526///
527/// Policy:
528/// - Completed/Cancelled runs older than `older_than` are eligible for deletion.
529/// - Running runs are never cleaned.
530///
531/// Returns the number of runs cleaned.
532pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
533    let run_dirs = crate::state::list_runs(journal_dir)?;
534    let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
535
536    tracing::debug!("GC: scanning {} runs", run_dirs.len());
537    let mut cleaned = 0;
538    for dir_name in &run_dirs {
539        let run_dir = journal_dir.join(dir_name);
540        // Peek at checkpoint without full open
541        let checkpoint_path = run_dir.join("checkpoint.json");
542        if !checkpoint_path.exists() {
543            continue;
544        }
545
546        let content = std::fs::read_to_string(&checkpoint_path)?;
547        let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
548
549        let is_old = checkpoint.updated_at < cutoff;
550        let is_terminal = matches!(
551            checkpoint.status,
552            crate::state::CheckpointStatus::Completed
553                | crate::state::CheckpointStatus::Cancelled
554                | crate::state::CheckpointStatus::Failed
555        );
556
557        if is_old && is_terminal {
558            tracing::info!(dir = %dir_name, "GC: removing old terminal run");
559            std::fs::remove_dir_all(&run_dir)?;
560            cleaned += 1;
561        }
562    }
563
564    Ok(cleaned)
565}
566
567fn current_timestamp() -> u64 {
568    SystemTime::now()
569        .duration_since(UNIX_EPOCH)
570        .map(|d| d.as_secs())
571        .unwrap_or(0)
572}
573
574// ============================================================================
575// Tests
576// ============================================================================
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use tempfile::tempdir;
582
583    /// Test basic journal lifecycle: init → cache → read → cancel
584    #[test]
585    fn test_journal_lifecycle() {
586        let dir = tempdir().unwrap();
587        let run_id = uuid::Uuid::now_v7();
588        let journal = JournalStore::new(dir.path()).unwrap();
589
590        // 1. Init
591        journal.init_run(run_id, "Test task").unwrap();
592        let cp = journal.get_checkpoint().unwrap();
593        assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
594        assert_eq!(cp.task, "Test task");
595
596        // 2. Cache an agent result
597        let agent_id = uuid::Uuid::now_v7();
598        let key = AgentCacheKey::new("test prompt", Some("gpt-4"), 1);
599        journal
600            .cache_agent(
601                &key,
602                agent_id,
603                1,
604                AgentStatus::Ok,
605                serde_json::json!({"result": "ok"}),
606                vec![],
607                TokenUsage {
608                    input: 100,
609                    output: 50,
610                    cache_read: 0,
611                    cache_write: 0,
612                },
613            )
614            .unwrap();
615
616        // 3. Verify cache
617        assert!(journal.has_completed(&key));
618        let cached = journal.get_cached(&key).unwrap();
619        assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
620        assert_eq!(cached.tokens, 150);
621
622        // 4. Cancel
623        journal.cancel().unwrap();
624        let cp = journal.get_checkpoint().unwrap();
625        assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
626    }
627
628    /// Test that different prompts produce different cache keys
629    #[test]
630    fn test_cache_key_uniqueness() {
631        let k1 = AgentCacheKey::new("prompt A", Some("gpt-4"), 1);
632        let k2 = AgentCacheKey::new("prompt B", Some("gpt-4"), 1);
633        assert_ne!(k1.hash, k2.hash);
634
635        // Same prompt, different model
636        let k3 = AgentCacheKey::new("prompt A", Some("claude"), 1);
637        assert_ne!(k1.hash, k3.hash);
638
639        // Same prompt, different phase
640        let k4 = AgentCacheKey::new("prompt A", Some("gpt-4"), 2);
641        assert_ne!(k1.hash, k4.hash);
642
643        // Whitespace normalization
644        let k5 = AgentCacheKey::new("  prompt  \r\nA  ", Some("gpt-4"), 1);
645        assert_eq!(k1.hash, k5.hash);
646    }
647
648    /// Test resume: simulate workflow with 3 agents, 2 cached, 1 new
649    #[test]
650    fn test_resume_skip_cached() {
651        let dir = tempdir().unwrap();
652        let run_id = uuid::Uuid::now_v7();
653        let journal = JournalStore::new(dir.path()).unwrap();
654        journal.init_run(run_id, "Three agent test").unwrap();
655
656        // Cache agent 1 and 2
657        let k1 = AgentCacheKey::new("task 1", None, 1);
658        let k2 = AgentCacheKey::new("task 2", None, 1);
659        let k3 = AgentCacheKey::new("task 3", None, 1);
660
661        journal
662            .cache_agent(
663                &k1,
664                uuid::Uuid::now_v7(),
665                1,
666                AgentStatus::Ok,
667                serde_json::json!({"done": 1}),
668                vec![],
669                TokenUsage {
670                    input: 10,
671                    output: 5,
672                    cache_read: 0,
673                    cache_write: 0,
674                },
675            )
676            .unwrap();
677        journal
678            .cache_agent(
679                &k2,
680                uuid::Uuid::now_v7(),
681                1,
682                AgentStatus::Ok,
683                serde_json::json!({"done": 2}),
684                vec![],
685                TokenUsage {
686                    input: 10,
687                    output: 5,
688                    cache_read: 0,
689                    cache_write: 0,
690                },
691            )
692            .unwrap();
693
694        // Verify cache hits
695        assert!(journal.has_completed(&k1));
696        assert!(journal.has_completed(&k2));
697        assert!(!journal.has_completed(&k3));
698
699        // Agent 3 should NOT be cached → would go through scheduler
700        assert!(journal.get_cached(&k3).is_none());
701    }
702
703    /// Test that journal survives crash (simulated by re-opening)
704    #[test]
705    fn test_journal_crash_recovery() {
706        let dir = tempdir().unwrap();
707        let run_id = uuid::Uuid::now_v7();
708
709        // Part 1: Create and cache
710        {
711            let j = JournalStore::new(dir.path()).unwrap();
712            j.init_run(run_id, "Crash test").unwrap();
713            let key = AgentCacheKey::new("important work", None, 0);
714            j.cache_agent(
715                &key,
716                uuid::Uuid::now_v7(),
717                0,
718                AgentStatus::Ok,
719                serde_json::json!({"survived": true}),
720                vec![],
721                TokenUsage {
722                    input: 1,
723                    output: 1,
724                    cache_read: 0,
725                    cache_write: 0,
726                },
727            )
728            .unwrap();
729        } // j dropped — simulates crash
730
731        // Part 2: Re-open and verify data survived
732        {
733            let j2 = JournalStore::new(dir.path()).unwrap();
734            let cp = j2.open(run_id).unwrap();
735            assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
736            assert!(!cp.agent_results.is_empty());
737
738            let key = AgentCacheKey::new("important work", None, 0);
739            let cached = j2.get_cached(&key).unwrap();
740            assert_eq!(cached.output, serde_json::json!({"survived": true}));
741        }
742    }
743
744    /// Test GC reference
745    #[test]
746    fn test_gc_older_than() {
747        let dir = tempdir().unwrap();
748        let run_dir = dir.path().join("runs");
749        std::fs::create_dir_all(&run_dir).unwrap();
750
751        // Create a completed run
752        let run_id = uuid::Uuid::now_v7();
753        let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
754        journal.init_run(run_id, "GC me").unwrap();
755
756        // Manually mark as completed with old timestamp
757        if let Some(mut cp) = journal.get_checkpoint() {
758            cp.status = crate::state::CheckpointStatus::Completed;
759            cp.updated_at = 1000; // Very old
760            let _ = journal.inner.save_checkpoint(&cp);
761        }
762
763// GC with very short duration
764        let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
765        assert_eq!(cleaned, 1);
766    }
767
768    // ----------------------------------------------------------------------
769    // Tests for the F5 contract — AgentResultCache.status persistence.
770    //
771    // cache_agent, record_result, and the JournalCallback impl for
772    // JournalStore all persist AgentResultCache.status. Before F5 the value
773    // was derived from `format!("{:?}", status).to_lowercase()`, which
774    // silently mis-mapped TimedOut → "timedout" (no underscore). The
775    // implementations must now use `AgentStatus::as_str()` and produce
776    // snake_case strings that match the canonical on-disk mapping.
777    // ----------------------------------------------------------------------
778
779    fn read_checkpoint_status_for(
780        run_dir: &std::path::Path,
781        agent_id: AgentId,
782    ) -> Option<String> {
783        let cp_path = run_dir.join("checkpoint.json");
784        let content = std::fs::read_to_string(&cp_path).ok()?;
785        let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
786        let ar = raw.get("agent_results")?.as_object()?;
787        for (_k, v) in ar {
788            if v.get("agent_id").and_then(|id| id.as_str())
789                == Some(&agent_id.to_string())
790            {
791                return v.get("status").and_then(|s| s.as_str()).map(String::from);
792            }
793        }
794        None
795    }
796
797    fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
798        TokenUsage {
799            input,
800            output,
801            cache_read: 0,
802            cache_write: 0,
803        }
804    }
805
806    #[test]
807    fn cache_agent_persists_snake_case_status_for_each_variant() {
808        // F5 KEY test for JournalStore::cache_agent: the persisted status
809        // MUST equal AgentStatus::as_str() (snake_case), not Debug lowercased.
810        // Particularly important for TimedOut which would otherwise round-trip
811        // as "timedout" (no underscore) and break cross-process resume.
812        let dir = tempdir().unwrap();
813        let run_id = uuid::Uuid::now_v7();
814        let journal = JournalStore::new(dir.path()).unwrap();
815        journal.init_run(run_id, "cache_agent F5").unwrap();
816
817        let cases: Vec<(AgentStatus, &str)> = vec![
818            (AgentStatus::Ok, "ok"),
819            (AgentStatus::Error, "error"),
820            (AgentStatus::Cancelled, "cancelled"),
821            (AgentStatus::TimedOut, "timed_out"),
822        ];
823        for (status, expected) in &cases {
824            let agent_id = uuid::Uuid::now_v7();
825            let key = AgentCacheKey::new("prompt", Some("gpt-4"), 1);
826            journal
827                .cache_agent(
828                    &key,
829                    agent_id,
830                    1,
831                    status.clone(),
832                    serde_json::json!({"v": 1}),
833                    vec![],
834                    sample_token_usage(10, 5),
835                )
836                .unwrap();
837
838            let persisted = read_checkpoint_status_for(dir.path(), agent_id)
839                .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
840            assert_eq!(
841                persisted, *expected,
842                "cache_agent({status:?}) must persist status={expected:?} (snake_case); \
843                 got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
844                 for TimedOut and break the on-disk contract."
845            );
846        }
847    }
848
849    #[test]
850    fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
851        // Strongest F5 regression guard for cache_agent: TimedOut MUST persist
852        // as "timed_out" with an underscore. The buggy Debug-lowercased path
853        // would produce "timedout" and silently corrupt the journal.
854        let dir = tempdir().unwrap();
855        let run_id = uuid::Uuid::now_v7();
856        let journal = JournalStore::new(dir.path()).unwrap();
857        journal.init_run(run_id, "timed-out guard").unwrap();
858
859        let agent_id = uuid::Uuid::now_v7();
860        let key = AgentCacheKey::new("p", None, 0);
861        journal
862            .cache_agent(
863                &key,
864                agent_id,
865                0,
866                AgentStatus::TimedOut,
867                serde_json::json!(null),
868                vec![],
869                sample_token_usage(1, 2),
870            )
871            .unwrap();
872
873        let persisted = read_checkpoint_status_for(dir.path(), agent_id)
874            .expect("status on disk");
875        assert_eq!(
876            persisted, "timed_out",
877            "cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
878        );
879        assert_ne!(
880            persisted, "timedout",
881            "cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
882        );
883    }
884
885    #[test]
886    fn record_result_persists_snake_case_status_for_each_variant() {
887        // F5 test for JournalStore::record_result: same snake_case contract
888        // applies to the non-event-emitting path used by Lua SDK callbacks.
889        let dir = tempdir().unwrap();
890        let run_id = uuid::Uuid::now_v7();
891        let journal = JournalStore::new(dir.path()).unwrap();
892        journal.init_run(run_id, "record_result F5").unwrap();
893
894        let cases: Vec<(AgentStatus, &str)> = vec![
895            (AgentStatus::Ok, "ok"),
896            (AgentStatus::Error, "error"),
897            (AgentStatus::Cancelled, "cancelled"),
898            (AgentStatus::TimedOut, "timed_out"),
899        ];
900        for (status, expected) in &cases {
901            let agent_id = uuid::Uuid::now_v7();
902            let key = AgentCacheKey::new("p", None, 1);
903            journal.record_result(
904                &key,
905                agent_id,
906                1,
907                status.clone(),
908                serde_json::json!({"r": 1}),
909                vec![],
910                sample_token_usage(2, 3),
911            );
912
913            let persisted = read_checkpoint_status_for(dir.path(), agent_id)
914                .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
915            assert_eq!(
916                persisted, *expected,
917                "record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
918            );
919        }
920    }
921
922    #[test]
923    fn record_result_timed_out_persists_with_underscore() {
924        // Same regression guard for record_result.
925        let dir = tempdir().unwrap();
926        let run_id = uuid::Uuid::now_v7();
927        let journal = JournalStore::new(dir.path()).unwrap();
928        journal.init_run(run_id, "record_result timed-out").unwrap();
929
930        let agent_id = uuid::Uuid::now_v7();
931        let key = AgentCacheKey::new("p", None, 0);
932        journal.record_result(
933            &key,
934            agent_id,
935            0,
936            AgentStatus::TimedOut,
937            serde_json::json!(null),
938            vec![],
939            sample_token_usage(0, 0),
940        );
941
942        let persisted = read_checkpoint_status_for(dir.path(), agent_id)
943            .expect("status on disk");
944        assert_eq!(persisted, "timed_out");
945        assert_ne!(persisted, "timedout");
946    }
947
948    #[tokio::test]
949    async fn journal_callback_on_agent_done_persists_snake_case_status() {
950        // F5 test for the JournalCallback impl on JournalStore. The scheduler
951        // calls `on_agent_done` when an agent finishes; the persisted
952        // AgentResultCache.status MUST match AgentStatus::as_str() exactly.
953        let dir = tempdir().unwrap();
954        let run_id = uuid::Uuid::now_v7();
955        let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
956        journal.init_run(run_id, "callback F5").unwrap();
957
958        let cases: Vec<(AgentStatus, &str)> = vec![
959            (AgentStatus::Ok, "ok"),
960            (AgentStatus::Error, "error"),
961            (AgentStatus::Cancelled, "cancelled"),
962            (AgentStatus::TimedOut, "timed_out"),
963        ];
964        for (status, expected) in &cases {
965            let agent_id = uuid::Uuid::now_v7();
966            use crate::scheduler::JournalCallback;
967            journal
968                .on_agent_done(
969                    agent_id,
970                    1,
971                    status.clone(),
972                    serde_json::json!({}),
973                    sample_token_usage(4, 6),
974                )
975                .await;
976
977            let persisted = read_checkpoint_status_for(dir.path(), agent_id)
978                .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
979            assert_eq!(
980                persisted, *expected,
981                "JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
982                 got {persisted:?}"
983            );
984        }
985    }
986
987    #[test]
988    fn record_result_then_reopen_uses_snake_case_status() {
989        // Snake_case persistence must survive a close+reopen cycle so a
990        // resumed process sees the canonical strings (not Debug leftovers).
991        let dir = tempdir().unwrap();
992        let run_id = uuid::Uuid::now_v7();
993        let journal = JournalStore::new(dir.path()).unwrap();
994        journal.init_run(run_id, "reopen F5").unwrap();
995
996        let agent_id = uuid::Uuid::now_v7();
997        let key = AgentCacheKey::new("reopen prompt", Some("gpt-4"), 1);
998        journal.record_result(
999            &key,
1000            agent_id,
1001            1,
1002            AgentStatus::Cancelled,
1003            serde_json::json!({"result": "ok"}),
1004            vec![],
1005            sample_token_usage(7, 11),
1006        );
1007        drop(journal);
1008
1009        let j2 = JournalStore::new(dir.path()).unwrap();
1010        let cp = j2.open(run_id).expect("open after drop");
1011        let cached = cp
1012            .agent_results
1013            .get(&agent_id)
1014            .expect("entry survives reopen");
1015        assert_eq!(
1016            cached.status, "cancelled",
1017            "snake_case status must round-trip through close+reopen"
1018        );
1019        assert_eq!(cached.tokens, 18);
1020    }
1021
1022    #[test]
1023    fn cache_agent_persists_snake_case_status_to_event_log() {
1024        // The AgentDone event itself also travels through the same snake_case
1025        // contract (via update_from_event → as_str()). Read events.jsonl back
1026        // and confirm the event log carries the canonical status.
1027        let dir = tempdir().unwrap();
1028        let run_id = uuid::Uuid::now_v7();
1029        let journal = JournalStore::new(dir.path()).unwrap();
1030        journal.init_run(run_id, "event log F5").unwrap();
1031
1032        let agent_id = uuid::Uuid::now_v7();
1033        let key = AgentCacheKey::new("p", None, 1);
1034        journal
1035            .cache_agent(
1036                &key,
1037                agent_id,
1038                1,
1039                AgentStatus::TimedOut,
1040                serde_json::json!(null),
1041                vec![],
1042                sample_token_usage(1, 1),
1043            )
1044            .unwrap();
1045
1046        // The persisted AgentResultCache.status must already be verified by
1047        // the test above; this test only confirms the event log still parses
1048        // and carries the AgentDone event with the right status enum.
1049        let log = journal.store().get_event_log().expect("read events.jsonl");
1050        let agent_done = log
1051            .iter()
1052            .find_map(|e| match e {
1053                AgentEvent::AgentDone {
1054                    agent_id: id,
1055                    status,
1056                    ..
1057                } if id == &agent_id => Some(status.clone()),
1058                _ => None,
1059            })
1060            .expect("AgentDone event in log");
1061        // Status enum round-trip is enforced by serde, but the persisted
1062        // cache status string (verified above) is the part that the on-disk
1063        // contract depends on.
1064        assert!(matches!(
1065            agent_done,
1066            AgentStatus::TimedOut
1067        ));
1068    }
1069}