1use crate::contract::backend::AgentStatus;
19use crate::contract::event::{AgentEvent, EventSender};
20use crate::contract::finding::Finding;
21use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
22use crate::scheduler::{BackendRegistry, SchedulerConfig};
23use crate::state::{AgentResultCache, RunCheckpoint, RunStore};
24use blake3::Hasher;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::path::Path;
28use std::sync::{Arc, RwLock};
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use thiserror::Error;
31
32#[derive(Error, Debug)]
37pub enum JournalError {
38 #[error("run not found: {0}")]
39 RunNotFound(RunId),
40 #[error("run is not resumable (status: {status:?})")]
41 NotResumable { status: String },
42 #[error("I/O error: {0}")]
43 Io(#[from] std::io::Error),
44 #[error("serialization error: {0}")]
45 Serde(#[from] serde_json::Error),
46 #[error("journal corrupted: {0}")]
47 Corrupted(String),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub struct AgentCacheKey {
58 pub hash: String,
59 pub prompt_preview: String,
61 pub model: Option<String>,
62 pub phase_id: PhaseId,
63}
64
65impl AgentCacheKey {
66 pub fn new(prompt: &str, model: Option<&str>, phase_id: PhaseId) -> Self {
69 let normalized = normalize_prompt(prompt);
70 let preview = if normalized.chars().count() > 80 {
71 format!("{}...", normalized.chars().take(80).collect::<String>())
72 } else {
73 normalized.clone()
74 };
75
76 let mut h = Hasher::new();
77 h.update(normalized.as_bytes());
78 h.update(b"\0");
79 if let Some(m) = model {
80 h.update(m.as_bytes());
81 }
82 h.update(b"\0");
83 h.update(&phase_id.to_le_bytes());
84
85 Self {
86 hash: h.finalize().to_hex().to_string(),
87 prompt_preview: preview,
88 model: model.map(|s| s.to_string()),
89 phase_id,
90 }
91 }
92}
93
94fn normalize_prompt(prompt: &str) -> String {
95 prompt
96 .replace("\r\n", "\n")
97 .replace('\r', "\n")
98 .split_whitespace()
99 .collect::<Vec<_>>()
100 .join(" ")
101}
102
103pub struct JournalStore {
117 inner: Arc<RunStore>,
119 cache_index: RwLock<HashMap<String, AgentResultCache>>,
122 event_tx: Option<EventSender>,
124}
125
126impl std::fmt::Debug for JournalStore {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("JournalStore")
129 .field("inner", &self.inner)
130 .field("cache_index_size", &self.cache_index.read().unwrap().len())
131 .field("has_event_tx", &self.event_tx.is_some())
132 .finish()
133 }
134}
135
136impl JournalStore {
137 pub fn new(run_dir: &Path) -> Result<Self, JournalError> {
140 tracing::debug!(path = %run_dir.display(), "creating journal store");
141 let inner = RunStore::new(run_dir)?;
142 Ok(Self {
143 inner,
144 cache_index: RwLock::new(HashMap::new()),
145 event_tx: None,
146 })
147 }
148
149 pub fn with_event_sender(mut self, tx: EventSender) -> Self {
151 self.event_tx = Some(tx);
152 self
153 }
154
155 pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), JournalError> {
157 tracing::info!(%run_id, %task, "initializing run in journal");
158 self.inner.init_run(run_id, task)?;
159 Ok(())
160 }
161
162 pub fn init_run_with_meta(
164 &self,
165 run_id: RunId,
166 task: &str,
167 workflow_meta: serde_json::Value,
168 ) -> Result<(), JournalError> {
169 tracing::info!(
170 %run_id, %task,
171 "initializing run in journal with meta"
172 );
173 self.inner.init_run_with_meta(run_id, task, workflow_meta)?;
174 Ok(())
175 }
176
177 pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
184 tracing::info!(%run_id, "opening journal for resume");
185 let checkpoint = self
186 .inner
187 .open_run(run_id)?
188 .ok_or(JournalError::RunNotFound(run_id))?;
189
190 if matches!(
191 checkpoint.status,
192 crate::state::CheckpointStatus::Completed | crate::state::CheckpointStatus::Cancelled
193 ) {
194 return Err(JournalError::NotResumable {
195 status: format!("{:?}", checkpoint.status),
196 });
197 }
198
199 let mut index = HashMap::new();
202 for (agent_id, cache) in &checkpoint.agent_results {
203 index.insert(agent_id.to_string(), cache.clone());
204 if let Some(ref hash) = cache.cache_key_hash {
205 index.insert(hash.clone(), cache.clone());
206 }
207 }
208 *self.cache_index.write().unwrap() = index;
209
210 Ok(checkpoint)
211 }
212
213 #[allow(clippy::too_many_arguments)]
219 pub fn cache_agent(
220 &self,
221 cache_key: &AgentCacheKey,
222 agent_id: AgentId,
223 phase_id: PhaseId,
224 status: AgentStatus,
225 output: serde_json::Value,
226 findings: Vec<Finding>,
227 tokens: TokenUsage,
228 ) -> Result<AgentCacheKey, JournalError> {
229 let ts = current_timestamp();
230 let cache = AgentResultCache {
231 agent_id,
232 phase_id,
233 status: status.as_str().to_string(),
234 output,
235 findings,
236 tokens: tokens.total(),
237 completed_at: ts,
238 cache_key_hash: Some(cache_key.hash.clone()),
239 description: None,
240 role: None,
241 };
242
243 {
245 let mut index = self.cache_index.write().unwrap();
246 index.insert(cache_key.hash.clone(), cache.clone());
247 index.insert(agent_id.to_string(), cache.clone());
249 }
250
251 if let Err(e) = self.inner.upsert_agent_result(&cache) {
253 tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
254 }
255
256 let event = AgentEvent::AgentDone {
258 run_id: self
259 .inner
260 .get_checkpoint()
261 .map(|c| c.run_id)
262 .unwrap_or_else(uuid::Uuid::nil),
263 agent_id,
264 status,
265 tokens,
266 elapsed_ms: 0,
267 name: None,
268 agent_seq: 0,
269 output: serde_json::Value::Null,
270 findings: Vec::new(),
271 prompt: String::new(),
272 retry_count: 0,
273 };
274 self.inner.append_event(&event)?;
275
276 if let Some(ref tx) = self.event_tx {
278 let _ = tx.send(event);
279 }
280
281 Ok(cache_key.clone())
282 }
283
284 #[allow(clippy::too_many_arguments)]
292 pub fn record_result(
293 &self,
294 cache_key: &AgentCacheKey,
295 agent_id: AgentId,
296 phase_id: PhaseId,
297 status: AgentStatus,
298 output: serde_json::Value,
299 findings: Vec<Finding>,
300 tokens: TokenUsage,
301 ) {
302 let cache = AgentResultCache {
303 agent_id,
304 phase_id,
305 status: status.as_str().to_string(),
306 output,
307 findings,
308 tokens: tokens.total(),
309 completed_at: current_timestamp(),
310 cache_key_hash: Some(cache_key.hash.clone()),
311 description: None,
312 role: None,
313 };
314
315 {
316 let mut index = self.cache_index.write().unwrap();
317 index.insert(cache_key.hash.clone(), cache.clone());
318 index.insert(agent_id.to_string(), cache.clone());
319 }
320
321 if let Err(e) = self.inner.upsert_agent_result(&cache) {
322 tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
323 }
324 }
325
326 pub fn store(&self) -> Arc<RunStore> {
330 self.inner.clone()
331 }
332
333 pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
335 self.inner.append_event(event)?;
336 Ok(())
337 }
338
339 pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
342 let index = self.cache_index.read().unwrap();
343 index.contains_key(&cache_key.hash)
344 }
345
346 pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
349 let index = self.cache_index.read().unwrap();
350 index.get(&cache_key.hash).cloned()
351 }
352
353 pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
356 let index = self.cache_index.read().unwrap();
357 index
358 .keys()
359 .map(|k| AgentCacheKey {
360 hash: k.clone(),
361 prompt_preview: String::new(),
362 model: None,
363 phase_id: 0,
364 })
365 .collect()
366 }
367
368 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
370 self.inner.get_checkpoint()
371 }
372
373 pub fn flush(&self) -> Result<(), JournalError> {
375 Ok(())
377 }
378
379 pub fn cancel(&self) -> Result<(), JournalError> {
381 self.inner.cancel()?;
382 Ok(())
383 }
384}
385
386pub struct CompositeJournalCallback {
392 callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
393}
394
395impl CompositeJournalCallback {
396 pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
397 Self { callbacks }
398 }
399}
400
401#[async_trait::async_trait]
402impl crate::scheduler::JournalCallback for CompositeJournalCallback {
403 async fn on_agent_done(
404 &self,
405 agent_id: AgentId,
406 phase_id: PhaseId,
407 status: AgentStatus,
408 output: serde_json::Value,
409 tokens: TokenUsage,
410 ) {
411 for cb in &self.callbacks {
412 cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
413 .await;
414 }
415 }
416}
417
418#[async_trait::async_trait]
419impl crate::scheduler::JournalCallback for JournalStore {
420 async fn on_agent_done(
421 &self,
422 agent_id: AgentId,
423 phase_id: PhaseId,
424 status: AgentStatus,
425 output: serde_json::Value,
426 tokens: TokenUsage,
427 ) {
428 let ts = current_timestamp();
430 let cache = AgentResultCache {
431 agent_id,
432 phase_id,
433 status: status.as_str().to_string(),
434 output,
435 findings: vec![], tokens: tokens.total(),
437 completed_at: ts,
438 cache_key_hash: None, description: None,
440 role: None,
441 };
442
443 if let Err(e) = self.inner.upsert_agent_result(&cache) {
445 tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
446 }
447 }
448}
449
450#[derive(Debug)]
456pub struct ResumeContext {
457 pub run_id: RunId,
458 pub checkpoint: RunCheckpoint,
459 pub journal: Arc<JournalStore>,
460 pub scheduler_config: SchedulerConfig,
461 pub backend_registry: BackendRegistry,
462}
463
464#[derive(Debug, Clone)]
466pub enum RunCreationMode {
467 New { task: String },
469 Resume { run_id: RunId, run_dir_name: String },
471 Auto { task: String },
473}
474
475impl RunCreationMode {
476 pub fn resolve(
479 self,
480 journal_dir: &Path,
481 ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
482 match self {
483 RunCreationMode::New { task: _ } => {
484 let run_id = uuid::Uuid::now_v7();
485 Ok((run_id, None))
486 }
487 RunCreationMode::Resume {
488 run_id,
489 run_dir_name,
490 } => {
491 let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
492 let checkpoint = store.open(run_id)?;
493 Ok((run_id, Some(checkpoint)))
494 }
495 RunCreationMode::Auto { task: _ } => {
496 let run_dirs = crate::state::list_runs(journal_dir)?;
498 for dir_name in run_dirs.iter().rev() {
499 let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
500 if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
501 if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
502 if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
503 {
504 let run_id = checkpoint.run_id;
505 return Ok((run_id, Some(checkpoint)));
506 }
507 }
508 }
509 }
510 let run_id = uuid::Uuid::now_v7();
512 Ok((run_id, None))
513 }
514 }
515 }
516}
517
518pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
530 let run_dirs = crate::state::list_runs(journal_dir)?;
531 let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
532
533 tracing::debug!("GC: scanning {} runs", run_dirs.len());
534 let mut cleaned = 0;
535 for dir_name in &run_dirs {
536 let run_dir = journal_dir.join(dir_name);
537 let checkpoint_path = run_dir.join("checkpoint.json");
539 if !checkpoint_path.exists() {
540 continue;
541 }
542
543 let content = std::fs::read_to_string(&checkpoint_path)?;
544 let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
545
546 let is_old = checkpoint.updated_at < cutoff;
547 let is_terminal = matches!(
548 checkpoint.status,
549 crate::state::CheckpointStatus::Completed
550 | crate::state::CheckpointStatus::Cancelled
551 | crate::state::CheckpointStatus::Failed
552 );
553
554 if is_old && is_terminal {
555 tracing::info!(dir = %dir_name, "GC: removing old terminal run");
556 std::fs::remove_dir_all(&run_dir)?;
557 cleaned += 1;
558 }
559 }
560
561 Ok(cleaned)
562}
563
564fn current_timestamp() -> u64 {
565 SystemTime::now()
566 .duration_since(UNIX_EPOCH)
567 .map(|d| d.as_secs())
568 .unwrap_or(0)
569}
570
571#[cfg(test)]
576mod tests {
577 use super::*;
578 use tempfile::tempdir;
579
580 #[test]
582 fn test_journal_lifecycle() {
583 let dir = tempdir().unwrap();
584 let run_id = uuid::Uuid::now_v7();
585 let journal = JournalStore::new(dir.path()).unwrap();
586
587 journal.init_run(run_id, "Test task").unwrap();
589 let cp = journal.get_checkpoint().unwrap();
590 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
591 assert_eq!(cp.task, "Test task");
592
593 let agent_id = uuid::Uuid::now_v7();
595 let key = AgentCacheKey::new("test prompt", Some("gpt-4"), 1);
596 journal
597 .cache_agent(
598 &key,
599 agent_id,
600 1,
601 AgentStatus::Ok,
602 serde_json::json!({"result": "ok"}),
603 vec![],
604 TokenUsage {
605 input: 100,
606 output: 50,
607 cache_read: 0,
608 cache_write: 0,
609 },
610 )
611 .unwrap();
612
613 assert!(journal.has_completed(&key));
615 let cached = journal.get_cached(&key).unwrap();
616 assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
617 assert_eq!(cached.tokens, 150);
618
619 journal.cancel().unwrap();
621 let cp = journal.get_checkpoint().unwrap();
622 assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
623 }
624
625 #[test]
627 fn test_cache_key_uniqueness() {
628 let k1 = AgentCacheKey::new("prompt A", Some("gpt-4"), 1);
629 let k2 = AgentCacheKey::new("prompt B", Some("gpt-4"), 1);
630 assert_ne!(k1.hash, k2.hash);
631
632 let k3 = AgentCacheKey::new("prompt A", Some("claude"), 1);
634 assert_ne!(k1.hash, k3.hash);
635
636 let k4 = AgentCacheKey::new("prompt A", Some("gpt-4"), 2);
638 assert_ne!(k1.hash, k4.hash);
639
640 let k5 = AgentCacheKey::new(" prompt \r\nA ", Some("gpt-4"), 1);
642 assert_eq!(k1.hash, k5.hash);
643 }
644
645 #[test]
647 fn test_resume_skip_cached() {
648 let dir = tempdir().unwrap();
649 let run_id = uuid::Uuid::now_v7();
650 let journal = JournalStore::new(dir.path()).unwrap();
651 journal.init_run(run_id, "Three agent test").unwrap();
652
653 let k1 = AgentCacheKey::new("task 1", None, 1);
655 let k2 = AgentCacheKey::new("task 2", None, 1);
656 let k3 = AgentCacheKey::new("task 3", None, 1);
657
658 journal
659 .cache_agent(
660 &k1,
661 uuid::Uuid::now_v7(),
662 1,
663 AgentStatus::Ok,
664 serde_json::json!({"done": 1}),
665 vec![],
666 TokenUsage {
667 input: 10,
668 output: 5,
669 cache_read: 0,
670 cache_write: 0,
671 },
672 )
673 .unwrap();
674 journal
675 .cache_agent(
676 &k2,
677 uuid::Uuid::now_v7(),
678 1,
679 AgentStatus::Ok,
680 serde_json::json!({"done": 2}),
681 vec![],
682 TokenUsage {
683 input: 10,
684 output: 5,
685 cache_read: 0,
686 cache_write: 0,
687 },
688 )
689 .unwrap();
690
691 assert!(journal.has_completed(&k1));
693 assert!(journal.has_completed(&k2));
694 assert!(!journal.has_completed(&k3));
695
696 assert!(journal.get_cached(&k3).is_none());
698 }
699
700 #[test]
702 fn test_journal_crash_recovery() {
703 let dir = tempdir().unwrap();
704 let run_id = uuid::Uuid::now_v7();
705
706 {
708 let j = JournalStore::new(dir.path()).unwrap();
709 j.init_run(run_id, "Crash test").unwrap();
710 let key = AgentCacheKey::new("important work", None, 0);
711 j.cache_agent(
712 &key,
713 uuid::Uuid::now_v7(),
714 0,
715 AgentStatus::Ok,
716 serde_json::json!({"survived": true}),
717 vec![],
718 TokenUsage {
719 input: 1,
720 output: 1,
721 cache_read: 0,
722 cache_write: 0,
723 },
724 )
725 .unwrap();
726 } {
730 let j2 = JournalStore::new(dir.path()).unwrap();
731 let cp = j2.open(run_id).unwrap();
732 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
733 assert!(!cp.agent_results.is_empty());
734
735 let key = AgentCacheKey::new("important work", None, 0);
736 let cached = j2.get_cached(&key).unwrap();
737 assert_eq!(cached.output, serde_json::json!({"survived": true}));
738 }
739 }
740
741 #[test]
743 fn test_gc_older_than() {
744 let dir = tempdir().unwrap();
745 let run_dir = dir.path().join("runs");
746 std::fs::create_dir_all(&run_dir).unwrap();
747
748 let run_id = uuid::Uuid::now_v7();
750 let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
751 journal.init_run(run_id, "GC me").unwrap();
752
753 if let Some(mut cp) = journal.get_checkpoint() {
755 cp.status = crate::state::CheckpointStatus::Completed;
756 cp.updated_at = 1000; let _ = journal.inner.save_checkpoint(&cp);
758 }
759
760 let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
762 assert_eq!(cleaned, 1);
763 }
764
765 fn read_checkpoint_status_for(run_dir: &std::path::Path, agent_id: AgentId) -> Option<String> {
777 let cp_path = run_dir.join("checkpoint.json");
778 let content = std::fs::read_to_string(&cp_path).ok()?;
779 let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
780 let ar = raw.get("agent_results")?.as_object()?;
781 for (_k, v) in ar {
782 if v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string()) {
783 return v.get("status").and_then(|s| s.as_str()).map(String::from);
784 }
785 }
786 None
787 }
788
789 fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
790 TokenUsage {
791 input,
792 output,
793 cache_read: 0,
794 cache_write: 0,
795 }
796 }
797
798 #[test]
799 fn cache_agent_persists_snake_case_status_for_each_variant() {
800 let dir = tempdir().unwrap();
805 let run_id = uuid::Uuid::now_v7();
806 let journal = JournalStore::new(dir.path()).unwrap();
807 journal.init_run(run_id, "cache_agent F5").unwrap();
808
809 let cases: Vec<(AgentStatus, &str)> = vec![
810 (AgentStatus::Ok, "ok"),
811 (AgentStatus::Error, "error"),
812 (AgentStatus::Cancelled, "cancelled"),
813 (AgentStatus::TimedOut, "timed_out"),
814 ];
815 for (status, expected) in &cases {
816 let agent_id = uuid::Uuid::now_v7();
817 let key = AgentCacheKey::new("prompt", Some("gpt-4"), 1);
818 journal
819 .cache_agent(
820 &key,
821 agent_id,
822 1,
823 status.clone(),
824 serde_json::json!({"v": 1}),
825 vec![],
826 sample_token_usage(10, 5),
827 )
828 .unwrap();
829
830 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
831 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
832 assert_eq!(
833 persisted, *expected,
834 "cache_agent({status:?}) must persist status={expected:?} (snake_case); \
835 got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
836 for TimedOut and break the on-disk contract."
837 );
838 }
839 }
840
841 #[test]
842 fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
843 let dir = tempdir().unwrap();
847 let run_id = uuid::Uuid::now_v7();
848 let journal = JournalStore::new(dir.path()).unwrap();
849 journal.init_run(run_id, "timed-out guard").unwrap();
850
851 let agent_id = uuid::Uuid::now_v7();
852 let key = AgentCacheKey::new("p", None, 0);
853 journal
854 .cache_agent(
855 &key,
856 agent_id,
857 0,
858 AgentStatus::TimedOut,
859 serde_json::json!(null),
860 vec![],
861 sample_token_usage(1, 2),
862 )
863 .unwrap();
864
865 let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
866 assert_eq!(
867 persisted, "timed_out",
868 "cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
869 );
870 assert_ne!(
871 persisted, "timedout",
872 "cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
873 );
874 }
875
876 #[test]
877 fn record_result_persists_snake_case_status_for_each_variant() {
878 let dir = tempdir().unwrap();
881 let run_id = uuid::Uuid::now_v7();
882 let journal = JournalStore::new(dir.path()).unwrap();
883 journal.init_run(run_id, "record_result F5").unwrap();
884
885 let cases: Vec<(AgentStatus, &str)> = vec![
886 (AgentStatus::Ok, "ok"),
887 (AgentStatus::Error, "error"),
888 (AgentStatus::Cancelled, "cancelled"),
889 (AgentStatus::TimedOut, "timed_out"),
890 ];
891 for (status, expected) in &cases {
892 let agent_id = uuid::Uuid::now_v7();
893 let key = AgentCacheKey::new("p", None, 1);
894 journal.record_result(
895 &key,
896 agent_id,
897 1,
898 status.clone(),
899 serde_json::json!({"r": 1}),
900 vec![],
901 sample_token_usage(2, 3),
902 );
903
904 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
905 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
906 assert_eq!(
907 persisted, *expected,
908 "record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
909 );
910 }
911 }
912
913 #[test]
914 fn record_result_timed_out_persists_with_underscore() {
915 let dir = tempdir().unwrap();
917 let run_id = uuid::Uuid::now_v7();
918 let journal = JournalStore::new(dir.path()).unwrap();
919 journal.init_run(run_id, "record_result timed-out").unwrap();
920
921 let agent_id = uuid::Uuid::now_v7();
922 let key = AgentCacheKey::new("p", None, 0);
923 journal.record_result(
924 &key,
925 agent_id,
926 0,
927 AgentStatus::TimedOut,
928 serde_json::json!(null),
929 vec![],
930 sample_token_usage(0, 0),
931 );
932
933 let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
934 assert_eq!(persisted, "timed_out");
935 assert_ne!(persisted, "timedout");
936 }
937
938 #[tokio::test]
939 async fn journal_callback_on_agent_done_persists_snake_case_status() {
940 let dir = tempdir().unwrap();
944 let run_id = uuid::Uuid::now_v7();
945 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
946 journal.init_run(run_id, "callback F5").unwrap();
947
948 let cases: Vec<(AgentStatus, &str)> = vec![
949 (AgentStatus::Ok, "ok"),
950 (AgentStatus::Error, "error"),
951 (AgentStatus::Cancelled, "cancelled"),
952 (AgentStatus::TimedOut, "timed_out"),
953 ];
954 for (status, expected) in &cases {
955 let agent_id = uuid::Uuid::now_v7();
956 use crate::scheduler::JournalCallback;
957 journal
958 .on_agent_done(
959 agent_id,
960 1,
961 status.clone(),
962 serde_json::json!({}),
963 sample_token_usage(4, 6),
964 )
965 .await;
966
967 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
968 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
969 assert_eq!(
970 persisted, *expected,
971 "JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
972 got {persisted:?}"
973 );
974 }
975 }
976
977 #[test]
978 fn record_result_then_reopen_uses_snake_case_status() {
979 let dir = tempdir().unwrap();
982 let run_id = uuid::Uuid::now_v7();
983 let journal = JournalStore::new(dir.path()).unwrap();
984 journal.init_run(run_id, "reopen F5").unwrap();
985
986 let agent_id = uuid::Uuid::now_v7();
987 let key = AgentCacheKey::new("reopen prompt", Some("gpt-4"), 1);
988 journal.record_result(
989 &key,
990 agent_id,
991 1,
992 AgentStatus::Cancelled,
993 serde_json::json!({"result": "ok"}),
994 vec![],
995 sample_token_usage(7, 11),
996 );
997 drop(journal);
998
999 let j2 = JournalStore::new(dir.path()).unwrap();
1000 let cp = j2.open(run_id).expect("open after drop");
1001 let cached = cp
1002 .agent_results
1003 .get(&agent_id)
1004 .expect("entry survives reopen");
1005 assert_eq!(
1006 cached.status, "cancelled",
1007 "snake_case status must round-trip through close+reopen"
1008 );
1009 assert_eq!(cached.tokens, 18);
1010 }
1011
1012 #[test]
1013 fn cache_agent_persists_snake_case_status_to_event_log() {
1014 let dir = tempdir().unwrap();
1018 let run_id = uuid::Uuid::now_v7();
1019 let journal = JournalStore::new(dir.path()).unwrap();
1020 journal.init_run(run_id, "event log F5").unwrap();
1021
1022 let agent_id = uuid::Uuid::now_v7();
1023 let key = AgentCacheKey::new("p", None, 1);
1024 journal
1025 .cache_agent(
1026 &key,
1027 agent_id,
1028 1,
1029 AgentStatus::TimedOut,
1030 serde_json::json!(null),
1031 vec![],
1032 sample_token_usage(1, 1),
1033 )
1034 .unwrap();
1035
1036 let log = journal.store().get_event_log().expect("read events.jsonl");
1040 let agent_done = log
1041 .iter()
1042 .find_map(|e| match e {
1043 AgentEvent::AgentDone {
1044 agent_id: id,
1045 status,
1046 ..
1047 } if id == &agent_id => Some(status.clone()),
1048 _ => None,
1049 })
1050 .expect("AgentDone event in log");
1051 assert!(matches!(agent_done, AgentStatus::TimedOut));
1055 }
1056}