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