1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub struct AgentCacheKey {
60 pub hash: String,
61 pub prompt_preview: String,
63 pub model: Option<String>,
64 pub phase_id: PhaseId,
65}
66
67impl AgentCacheKey {
68 pub fn new(prompt: &str, model: Option<&str>, phase_id: PhaseId) -> Self {
71 let normalized = normalize_prompt(prompt);
72 let preview = if normalized.chars().count() > 80 {
73 format!("{}...", normalized.chars().take(80).collect::<String>())
74 } else {
75 normalized.clone()
76 };
77
78 let mut h = Hasher::new();
79 h.update(normalized.as_bytes());
80 h.update(b"\0");
81 if let Some(m) = model {
82 h.update(m.as_bytes());
83 }
84 h.update(b"\0");
85 h.update(&phase_id.to_le_bytes());
86
87 Self {
88 hash: h.finalize().to_hex().to_string(),
89 prompt_preview: preview,
90 model: model.map(|s| s.to_string()),
91 phase_id,
92 }
93 }
94}
95
96fn normalize_prompt(prompt: &str) -> String {
97 prompt
98 .replace("\r\n", "\n")
99 .replace('\r', "\n")
100 .split_whitespace()
101 .collect::<Vec<_>>()
102 .join(" ")
103}
104
105pub struct JournalStore {
119 inner: Arc<RunStore>,
121 cache_index: RwLock<HashMap<String, AgentResultCache>>,
124 event_tx: Option<EventSender>,
126}
127
128impl std::fmt::Debug for JournalStore {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("JournalStore")
131 .field("inner", &self.inner)
132 .field("cache_index_size", &self.cache_index.read().unwrap().len())
133 .field("has_event_tx", &self.event_tx.is_some())
134 .finish()
135 }
136}
137
138impl JournalStore {
139 pub fn new(run_dir: &Path) -> Result<Self, JournalError> {
142 tracing::debug!(path = %run_dir.display(), "creating journal store");
143 let inner = RunStore::new(run_dir)?;
144 Ok(Self {
145 inner,
146 cache_index: RwLock::new(HashMap::new()),
147 event_tx: None,
148 })
149 }
150
151 pub fn with_event_sender(mut self, tx: EventSender) -> Self {
153 self.event_tx = Some(tx);
154 self
155 }
156
157 pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), JournalError> {
159 tracing::info!(%run_id, %task, "initializing run in journal");
160 self.inner.init_run(run_id, task)?;
161 Ok(())
162 }
163
164 pub fn init_run_with_meta(
166 &self,
167 run_id: RunId,
168 task: &str,
169 workflow_meta: serde_json::Value,
170 ) -> Result<(), JournalError> {
171 tracing::info!(
172 %run_id, %task,
173 "initializing run in journal with meta"
174 );
175 self.inner.init_run_with_meta(run_id, task, workflow_meta)?;
176 Ok(())
177 }
178
179 pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
186 tracing::info!(%run_id, "opening journal for resume");
187 let checkpoint = self
188 .inner
189 .open_run(run_id)?
190 .ok_or(JournalError::RunNotFound(run_id))?;
191
192 if matches!(
193 checkpoint.status,
194 crate::state::CheckpointStatus::Completed | crate::state::CheckpointStatus::Cancelled
195 ) {
196 return Err(JournalError::NotResumable {
197 status: format!("{:?}", checkpoint.status),
198 });
199 }
200
201 let mut index = HashMap::new();
204 for (agent_id, cache) in &checkpoint.agent_results {
205 index.insert(agent_id.to_string(), cache.clone());
206 if let Some(ref hash) = cache.cache_key_hash {
207 index.insert(hash.clone(), cache.clone());
208 }
209 }
210 *self.cache_index.write().unwrap() = index;
211
212 Ok(checkpoint)
213 }
214
215 #[allow(clippy::too_many_arguments)]
221 pub fn cache_agent(
222 &self,
223 cache_key: &AgentCacheKey,
224 agent_id: AgentId,
225 phase_id: PhaseId,
226 status: AgentStatus,
227 output: serde_json::Value,
228 findings: Vec<Finding>,
229 tokens: TokenUsage,
230 ) -> Result<AgentCacheKey, JournalError> {
231 let ts = current_timestamp();
232 let cache = AgentResultCache {
233 agent_id,
234 phase_id,
235 status: status.as_str().to_string(),
236 output,
237 findings,
238 tokens: tokens.total(),
239 completed_at: ts,
240 cache_key_hash: Some(cache_key.hash.clone()),
241 description: None,
242 role: None,
243 };
244
245 {
247 let mut index = self.cache_index.write().unwrap();
248 index.insert(cache_key.hash.clone(), cache.clone());
249 index.insert(agent_id.to_string(), cache.clone());
251 }
252
253 if let Err(e) = self.inner.upsert_agent_result(&cache) {
255 tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
256 }
257
258 let event = AgentEvent::AgentDone {
260 run_id: self
261 .inner
262 .get_checkpoint()
263 .map(|c| c.run_id)
264 .unwrap_or_else(uuid::Uuid::nil),
265 agent_id,
266 status,
267 tokens,
268 elapsed_ms: 0,
269 name: None,
270 agent_seq: 0,
271 output: serde_json::Value::Null,
272 findings: Vec::new(),
273 prompt: String::new(),
274 retry_count: 0,
275 ts: Utc::now(),
276 };
277 self.inner.append_event(&event)?;
278
279 if let Some(ref tx) = self.event_tx {
281 let _ = tx.send(event);
282 }
283
284 Ok(cache_key.clone())
285 }
286
287 #[allow(clippy::too_many_arguments)]
295 pub fn record_result(
296 &self,
297 cache_key: &AgentCacheKey,
298 agent_id: AgentId,
299 phase_id: PhaseId,
300 status: AgentStatus,
301 output: serde_json::Value,
302 findings: Vec<Finding>,
303 tokens: TokenUsage,
304 ) {
305 let cache = AgentResultCache {
306 agent_id,
307 phase_id,
308 status: status.as_str().to_string(),
309 output,
310 findings,
311 tokens: tokens.total(),
312 completed_at: current_timestamp(),
313 cache_key_hash: Some(cache_key.hash.clone()),
314 description: None,
315 role: None,
316 };
317
318 {
319 let mut index = self.cache_index.write().unwrap();
320 index.insert(cache_key.hash.clone(), cache.clone());
321 index.insert(agent_id.to_string(), cache.clone());
322 }
323
324 if let Err(e) = self.inner.upsert_agent_result(&cache) {
325 tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
326 }
327 }
328
329 pub fn store(&self) -> Arc<RunStore> {
333 self.inner.clone()
334 }
335
336 pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
338 self.inner.append_event(event)?;
339 Ok(())
340 }
341
342 pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
345 let index = self.cache_index.read().unwrap();
346 index.contains_key(&cache_key.hash)
347 }
348
349 pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
352 let index = self.cache_index.read().unwrap();
353 index.get(&cache_key.hash).cloned()
354 }
355
356 pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
359 let index = self.cache_index.read().unwrap();
360 index
361 .keys()
362 .map(|k| AgentCacheKey {
363 hash: k.clone(),
364 prompt_preview: String::new(),
365 model: None,
366 phase_id: 0,
367 })
368 .collect()
369 }
370
371 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
373 self.inner.get_checkpoint()
374 }
375
376 pub fn flush(&self) -> Result<(), JournalError> {
378 Ok(())
380 }
381
382 pub fn cancel(&self) -> Result<(), JournalError> {
384 self.inner.cancel()?;
385 Ok(())
386 }
387}
388
389pub struct CompositeJournalCallback {
395 callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
396}
397
398impl CompositeJournalCallback {
399 pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
400 Self { callbacks }
401 }
402}
403
404#[async_trait::async_trait]
405impl crate::scheduler::JournalCallback for CompositeJournalCallback {
406 async fn on_agent_done(
407 &self,
408 agent_id: AgentId,
409 phase_id: PhaseId,
410 status: AgentStatus,
411 output: serde_json::Value,
412 tokens: TokenUsage,
413 ) {
414 for cb in &self.callbacks {
415 cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
416 .await;
417 }
418 }
419}
420
421#[async_trait::async_trait]
422impl crate::scheduler::JournalCallback for JournalStore {
423 async fn on_agent_done(
424 &self,
425 agent_id: AgentId,
426 phase_id: PhaseId,
427 status: AgentStatus,
428 output: serde_json::Value,
429 tokens: TokenUsage,
430 ) {
431 let ts = current_timestamp();
432
433 let existing = {
438 let index = self.cache_index.read().unwrap();
439 index.get(&agent_id.to_string()).cloned()
440 };
441
442 let cache = AgentResultCache {
443 agent_id,
444 phase_id: existing.as_ref().map(|c| c.phase_id).unwrap_or(phase_id),
445 status: status.as_str().to_string(),
446 output: existing
447 .as_ref()
448 .filter(|c| !c.output.is_null())
449 .map(|c| c.output.clone())
450 .unwrap_or(output),
451 findings: existing
452 .as_ref()
453 .filter(|c| !c.findings.is_empty())
454 .map(|c| c.findings.clone())
455 .unwrap_or_default(),
456 tokens: tokens.total(),
457 completed_at: ts,
458 cache_key_hash: existing.as_ref().and_then(|c| c.cache_key_hash.clone()),
459 description: existing.as_ref().and_then(|c| c.description.clone()),
460 role: existing.as_ref().and_then(|c| c.role.clone()),
461 };
462
463 {
466 let mut index = self.cache_index.write().unwrap();
467 index.insert(agent_id.to_string(), cache.clone());
468 if let Some(ref hash) = cache.cache_key_hash {
469 index.insert(hash.clone(), cache.clone());
470 }
471 }
472
473 if let Err(e) = self.inner.upsert_agent_result(&cache) {
475 tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
476 }
477 }
478}
479
480#[derive(Debug)]
486pub struct ResumeContext {
487 pub run_id: RunId,
488 pub checkpoint: RunCheckpoint,
489 pub journal: Arc<JournalStore>,
490 pub scheduler_config: SchedulerConfig,
491 pub backend_registry: BackendRegistry,
492}
493
494#[derive(Debug, Clone)]
496pub enum RunCreationMode {
497 New { task: String },
499 Resume { run_id: RunId, run_dir_name: String },
501 Auto { task: String },
503}
504
505impl RunCreationMode {
506 pub fn resolve(
509 self,
510 journal_dir: &Path,
511 ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
512 match self {
513 RunCreationMode::New { task: _ } => {
514 let run_id = uuid::Uuid::now_v7();
515 Ok((run_id, None))
516 }
517 RunCreationMode::Resume {
518 run_id,
519 run_dir_name,
520 } => {
521 let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
522 let checkpoint = store.open(run_id)?;
523 Ok((run_id, Some(checkpoint)))
524 }
525 RunCreationMode::Auto { task: _ } => {
526 let run_dirs = crate::state::list_runs(journal_dir)?;
528 for dir_name in run_dirs.iter().rev() {
529 let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
530 if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
531 if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
532 if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
533 {
534 let run_id = checkpoint.run_id;
535 return Ok((run_id, Some(checkpoint)));
536 }
537 }
538 }
539 }
540 let run_id = uuid::Uuid::now_v7();
542 Ok((run_id, None))
543 }
544 }
545 }
546}
547
548pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
560 let run_dirs = crate::state::list_runs(journal_dir)?;
561 let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
562
563 tracing::debug!("GC: scanning {} runs", run_dirs.len());
564 let mut cleaned = 0;
565 for dir_name in &run_dirs {
566 let run_dir = journal_dir.join(dir_name);
567 let checkpoint_path = run_dir.join("checkpoint.json");
569 if !checkpoint_path.exists() {
570 continue;
571 }
572
573 let content = std::fs::read_to_string(&checkpoint_path)?;
574 let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
575
576 let is_old = checkpoint.updated_at < cutoff;
577 let is_terminal = matches!(
578 checkpoint.status,
579 crate::state::CheckpointStatus::Completed
580 | crate::state::CheckpointStatus::Cancelled
581 | crate::state::CheckpointStatus::Failed
582 );
583
584 if is_old && is_terminal {
585 tracing::info!(dir = %dir_name, "GC: removing old terminal run");
586 std::fs::remove_dir_all(&run_dir)?;
587 cleaned += 1;
588 }
589 }
590
591 Ok(cleaned)
592}
593
594fn current_timestamp() -> u64 {
595 SystemTime::now()
596 .duration_since(UNIX_EPOCH)
597 .map(|d| d.as_secs())
598 .unwrap_or(0)
599}
600
601#[cfg(test)]
606mod tests {
607 use super::*;
608 use tempfile::tempdir;
609
610 #[test]
612 fn test_journal_lifecycle() {
613 let dir = tempdir().unwrap();
614 let run_id = uuid::Uuid::now_v7();
615 let journal = JournalStore::new(dir.path()).unwrap();
616
617 journal.init_run(run_id, "Test task").unwrap();
619 let cp = journal.get_checkpoint().unwrap();
620 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
621 assert_eq!(cp.task, "Test task");
622
623 let agent_id = uuid::Uuid::now_v7();
625 let key = AgentCacheKey::new("test prompt", Some("gpt-4"), 1);
626 journal
627 .cache_agent(
628 &key,
629 agent_id,
630 1,
631 AgentStatus::Ok,
632 serde_json::json!({"result": "ok"}),
633 vec![],
634 TokenUsage {
635 input: 100,
636 output: 50,
637 cache_read: 0,
638 cache_write: 0,
639 },
640 )
641 .unwrap();
642
643 assert!(journal.has_completed(&key));
645 let cached = journal.get_cached(&key).unwrap();
646 assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
647 assert_eq!(cached.tokens, 150);
648
649 journal.cancel().unwrap();
651 let cp = journal.get_checkpoint().unwrap();
652 assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
653 }
654
655 #[test]
657 fn test_cache_key_uniqueness() {
658 let k1 = AgentCacheKey::new("prompt A", Some("gpt-4"), 1);
659 let k2 = AgentCacheKey::new("prompt B", Some("gpt-4"), 1);
660 assert_ne!(k1.hash, k2.hash);
661
662 let k3 = AgentCacheKey::new("prompt A", Some("claude"), 1);
664 assert_ne!(k1.hash, k3.hash);
665
666 let k4 = AgentCacheKey::new("prompt A", Some("gpt-4"), 2);
668 assert_ne!(k1.hash, k4.hash);
669
670 let k5 = AgentCacheKey::new(" prompt \r\nA ", Some("gpt-4"), 1);
672 assert_eq!(k1.hash, k5.hash);
673 }
674
675 #[test]
677 fn test_resume_skip_cached() {
678 let dir = tempdir().unwrap();
679 let run_id = uuid::Uuid::now_v7();
680 let journal = JournalStore::new(dir.path()).unwrap();
681 journal.init_run(run_id, "Three agent test").unwrap();
682
683 let k1 = AgentCacheKey::new("task 1", None, 1);
685 let k2 = AgentCacheKey::new("task 2", None, 1);
686 let k3 = AgentCacheKey::new("task 3", None, 1);
687
688 journal
689 .cache_agent(
690 &k1,
691 uuid::Uuid::now_v7(),
692 1,
693 AgentStatus::Ok,
694 serde_json::json!({"done": 1}),
695 vec![],
696 TokenUsage {
697 input: 10,
698 output: 5,
699 cache_read: 0,
700 cache_write: 0,
701 },
702 )
703 .unwrap();
704 journal
705 .cache_agent(
706 &k2,
707 uuid::Uuid::now_v7(),
708 1,
709 AgentStatus::Ok,
710 serde_json::json!({"done": 2}),
711 vec![],
712 TokenUsage {
713 input: 10,
714 output: 5,
715 cache_read: 0,
716 cache_write: 0,
717 },
718 )
719 .unwrap();
720
721 assert!(journal.has_completed(&k1));
723 assert!(journal.has_completed(&k2));
724 assert!(!journal.has_completed(&k3));
725
726 assert!(journal.get_cached(&k3).is_none());
728 }
729
730 #[test]
732 fn test_journal_crash_recovery() {
733 let dir = tempdir().unwrap();
734 let run_id = uuid::Uuid::now_v7();
735
736 {
738 let j = JournalStore::new(dir.path()).unwrap();
739 j.init_run(run_id, "Crash test").unwrap();
740 let key = AgentCacheKey::new("important work", None, 0);
741 j.cache_agent(
742 &key,
743 uuid::Uuid::now_v7(),
744 0,
745 AgentStatus::Ok,
746 serde_json::json!({"survived": true}),
747 vec![],
748 TokenUsage {
749 input: 1,
750 output: 1,
751 cache_read: 0,
752 cache_write: 0,
753 },
754 )
755 .unwrap();
756 } {
760 let j2 = JournalStore::new(dir.path()).unwrap();
761 let cp = j2.open(run_id).unwrap();
762 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
763 assert!(!cp.agent_results.is_empty());
764
765 let key = AgentCacheKey::new("important work", None, 0);
766 let cached = j2.get_cached(&key).unwrap();
767 assert_eq!(cached.output, serde_json::json!({"survived": true}));
768 }
769 }
770
771 #[test]
773 fn test_gc_older_than() {
774 let dir = tempdir().unwrap();
775 let run_dir = dir.path().join("runs");
776 std::fs::create_dir_all(&run_dir).unwrap();
777
778 let run_id = uuid::Uuid::now_v7();
780 let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
781 journal.init_run(run_id, "GC me").unwrap();
782
783 if let Some(mut cp) = journal.get_checkpoint() {
785 cp.status = crate::state::CheckpointStatus::Completed;
786 cp.updated_at = 1000; let _ = journal.inner.save_checkpoint(&cp);
788 }
789
790 let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
792 assert_eq!(cleaned, 1);
793 }
794
795 fn read_checkpoint_status_for(run_dir: &std::path::Path, agent_id: AgentId) -> Option<String> {
807 let cp_path = run_dir.join("checkpoint.json");
808 let content = std::fs::read_to_string(&cp_path).ok()?;
809 let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
810 let ar = raw.get("agent_results")?.as_object()?;
811 for (_k, v) in ar {
812 if v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string()) {
813 return v.get("status").and_then(|s| s.as_str()).map(String::from);
814 }
815 }
816 None
817 }
818
819 fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
820 TokenUsage {
821 input,
822 output,
823 cache_read: 0,
824 cache_write: 0,
825 }
826 }
827
828 #[test]
829 fn cache_agent_persists_snake_case_status_for_each_variant() {
830 let dir = tempdir().unwrap();
835 let run_id = uuid::Uuid::now_v7();
836 let journal = JournalStore::new(dir.path()).unwrap();
837 journal.init_run(run_id, "cache_agent F5").unwrap();
838
839 let cases: Vec<(AgentStatus, &str)> = vec![
840 (AgentStatus::Ok, "ok"),
841 (AgentStatus::Error, "error"),
842 (AgentStatus::Cancelled, "cancelled"),
843 (AgentStatus::TimedOut, "timed_out"),
844 ];
845 for (status, expected) in &cases {
846 let agent_id = uuid::Uuid::now_v7();
847 let key = AgentCacheKey::new("prompt", Some("gpt-4"), 1);
848 journal
849 .cache_agent(
850 &key,
851 agent_id,
852 1,
853 status.clone(),
854 serde_json::json!({"v": 1}),
855 vec![],
856 sample_token_usage(10, 5),
857 )
858 .unwrap();
859
860 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
861 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
862 assert_eq!(
863 persisted, *expected,
864 "cache_agent({status:?}) must persist status={expected:?} (snake_case); \
865 got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
866 for TimedOut and break the on-disk contract."
867 );
868 }
869 }
870
871 #[test]
872 fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
873 let dir = tempdir().unwrap();
877 let run_id = uuid::Uuid::now_v7();
878 let journal = JournalStore::new(dir.path()).unwrap();
879 journal.init_run(run_id, "timed-out guard").unwrap();
880
881 let agent_id = uuid::Uuid::now_v7();
882 let key = AgentCacheKey::new("p", None, 0);
883 journal
884 .cache_agent(
885 &key,
886 agent_id,
887 0,
888 AgentStatus::TimedOut,
889 serde_json::json!(null),
890 vec![],
891 sample_token_usage(1, 2),
892 )
893 .unwrap();
894
895 let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
896 assert_eq!(
897 persisted, "timed_out",
898 "cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
899 );
900 assert_ne!(
901 persisted, "timedout",
902 "cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
903 );
904 }
905
906 #[test]
907 fn record_result_persists_snake_case_status_for_each_variant() {
908 let dir = tempdir().unwrap();
911 let run_id = uuid::Uuid::now_v7();
912 let journal = JournalStore::new(dir.path()).unwrap();
913 journal.init_run(run_id, "record_result F5").unwrap();
914
915 let cases: Vec<(AgentStatus, &str)> = vec![
916 (AgentStatus::Ok, "ok"),
917 (AgentStatus::Error, "error"),
918 (AgentStatus::Cancelled, "cancelled"),
919 (AgentStatus::TimedOut, "timed_out"),
920 ];
921 for (status, expected) in &cases {
922 let agent_id = uuid::Uuid::now_v7();
923 let key = AgentCacheKey::new("p", None, 1);
924 journal.record_result(
925 &key,
926 agent_id,
927 1,
928 status.clone(),
929 serde_json::json!({"r": 1}),
930 vec![],
931 sample_token_usage(2, 3),
932 );
933
934 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
935 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
936 assert_eq!(
937 persisted, *expected,
938 "record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
939 );
940 }
941 }
942
943 #[test]
944 fn record_result_timed_out_persists_with_underscore() {
945 let dir = tempdir().unwrap();
947 let run_id = uuid::Uuid::now_v7();
948 let journal = JournalStore::new(dir.path()).unwrap();
949 journal.init_run(run_id, "record_result timed-out").unwrap();
950
951 let agent_id = uuid::Uuid::now_v7();
952 let key = AgentCacheKey::new("p", None, 0);
953 journal.record_result(
954 &key,
955 agent_id,
956 0,
957 AgentStatus::TimedOut,
958 serde_json::json!(null),
959 vec![],
960 sample_token_usage(0, 0),
961 );
962
963 let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
964 assert_eq!(persisted, "timed_out");
965 assert_ne!(persisted, "timedout");
966 }
967
968 #[tokio::test]
969 async fn journal_callback_on_agent_done_persists_snake_case_status() {
970 let dir = tempdir().unwrap();
974 let run_id = uuid::Uuid::now_v7();
975 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
976 journal.init_run(run_id, "callback F5").unwrap();
977
978 let cases: Vec<(AgentStatus, &str)> = vec![
979 (AgentStatus::Ok, "ok"),
980 (AgentStatus::Error, "error"),
981 (AgentStatus::Cancelled, "cancelled"),
982 (AgentStatus::TimedOut, "timed_out"),
983 ];
984 for (status, expected) in &cases {
985 let agent_id = uuid::Uuid::now_v7();
986 use crate::scheduler::JournalCallback;
987 journal
988 .on_agent_done(
989 agent_id,
990 1,
991 status.clone(),
992 serde_json::json!({}),
993 sample_token_usage(4, 6),
994 )
995 .await;
996
997 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
998 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
999 assert_eq!(
1000 persisted, *expected,
1001 "JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
1002 got {persisted:?}"
1003 );
1004 }
1005 }
1006
1007 #[test]
1008 fn record_result_then_reopen_uses_snake_case_status() {
1009 let dir = tempdir().unwrap();
1012 let run_id = uuid::Uuid::now_v7();
1013 let journal = JournalStore::new(dir.path()).unwrap();
1014 journal.init_run(run_id, "reopen F5").unwrap();
1015
1016 let agent_id = uuid::Uuid::now_v7();
1017 let key = AgentCacheKey::new("reopen prompt", Some("gpt-4"), 1);
1018 journal.record_result(
1019 &key,
1020 agent_id,
1021 1,
1022 AgentStatus::Cancelled,
1023 serde_json::json!({"result": "ok"}),
1024 vec![],
1025 sample_token_usage(7, 11),
1026 );
1027 drop(journal);
1028
1029 let j2 = JournalStore::new(dir.path()).unwrap();
1030 let cp = j2.open(run_id).expect("open after drop");
1031 let cached = cp
1032 .agent_results
1033 .get(&agent_id)
1034 .expect("entry survives reopen");
1035 assert_eq!(
1036 cached.status, "cancelled",
1037 "snake_case status must round-trip through close+reopen"
1038 );
1039 assert_eq!(cached.tokens, 18);
1040 }
1041
1042 #[test]
1043 fn cache_agent_persists_snake_case_status_to_event_log() {
1044 let dir = tempdir().unwrap();
1048 let run_id = uuid::Uuid::now_v7();
1049 let journal = JournalStore::new(dir.path()).unwrap();
1050 journal.init_run(run_id, "event log F5").unwrap();
1051
1052 let agent_id = uuid::Uuid::now_v7();
1053 let key = AgentCacheKey::new("p", None, 1);
1054 journal
1055 .cache_agent(
1056 &key,
1057 agent_id,
1058 1,
1059 AgentStatus::TimedOut,
1060 serde_json::json!(null),
1061 vec![],
1062 sample_token_usage(1, 1),
1063 )
1064 .unwrap();
1065
1066 let log = journal.store().get_event_log().expect("read events.jsonl");
1070 let agent_done = log
1071 .iter()
1072 .find_map(|e| match e {
1073 AgentEvent::AgentDone {
1074 agent_id: id,
1075 status,
1076 ..
1077 } if id == &agent_id => Some(status.clone()),
1078 _ => None,
1079 })
1080 .expect("AgentDone event in log");
1081 assert!(matches!(agent_done, AgentStatus::TimedOut));
1085 }
1086
1087 #[tokio::test]
1092 async fn on_agent_done_preserves_cache_key_hash_from_record_result() {
1093 let dir = tempdir().unwrap();
1097 let run_id = uuid::Uuid::now_v7();
1098 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
1099 journal.init_run(run_id, "hash preservation").unwrap();
1100
1101 let agent_id = uuid::Uuid::now_v7();
1102 let key = AgentCacheKey::new("preserve me", Some("gpt-4"), 1);
1103
1104 journal.record_result(
1106 &key,
1107 agent_id,
1108 1,
1109 AgentStatus::Ok,
1110 serde_json::json!({"answer": 42}),
1111 vec![],
1112 sample_token_usage(10, 5),
1113 );
1114
1115 use crate::scheduler::JournalCallback;
1117 journal
1118 .on_agent_done(
1119 agent_id,
1120 1,
1121 AgentStatus::Ok,
1122 serde_json::json!({}),
1123 sample_token_usage(10, 5),
1124 )
1125 .await;
1126
1127 assert!(
1129 journal.has_completed(&key),
1130 "cache_key_hash must survive on_agent_done"
1131 );
1132
1133 drop(journal);
1135 let j2 = JournalStore::new(dir.path()).unwrap();
1136 j2.open(run_id).expect("reopen");
1137 assert!(
1138 j2.has_completed(&key),
1139 "cache_key_hash must survive reopen after on_agent_done"
1140 );
1141 }
1142
1143 #[tokio::test]
1144 async fn on_agent_done_preserves_cache_key_hash_from_cache_agent() {
1145 let dir = tempdir().unwrap();
1147 let run_id = uuid::Uuid::now_v7();
1148 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
1149 journal.init_run(run_id, "hash preservation 2").unwrap();
1150
1151 let agent_id = uuid::Uuid::now_v7();
1152 let key = AgentCacheKey::new("preserve me 2", None, 0);
1153
1154 journal
1155 .cache_agent(
1156 &key,
1157 agent_id,
1158 0,
1159 AgentStatus::Ok,
1160 serde_json::json!({"r": 1}),
1161 vec![],
1162 sample_token_usage(1, 1),
1163 )
1164 .unwrap();
1165
1166 use crate::scheduler::JournalCallback;
1167 journal
1168 .on_agent_done(
1169 agent_id,
1170 0,
1171 AgentStatus::Ok,
1172 serde_json::json!({}),
1173 sample_token_usage(1, 1),
1174 )
1175 .await;
1176
1177 assert!(
1178 journal.has_completed(&key),
1179 "cache_key_hash must survive on_agent_done after cache_agent"
1180 );
1181 }
1182}