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