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