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
193 | crate::state::CheckpointStatus::Cancelled
194 ) {
195 return Err(JournalError::NotResumable {
196 status: format!("{:?}", checkpoint.status),
197 });
198 }
199
200 let mut index = HashMap::new();
203 for (agent_id, cache) in &checkpoint.agent_results {
204 index.insert(agent_id.to_string(), cache.clone());
205 if let Some(ref hash) = cache.cache_key_hash {
206 index.insert(hash.clone(), cache.clone());
207 }
208 }
209 *self.cache_index.write().unwrap() = index;
210
211 Ok(checkpoint)
212 }
213
214 #[allow(clippy::too_many_arguments)]
220 pub fn cache_agent(
221 &self,
222 cache_key: &AgentCacheKey,
223 agent_id: AgentId,
224 phase_id: PhaseId,
225 status: AgentStatus,
226 output: serde_json::Value,
227 findings: Vec<Finding>,
228 tokens: TokenUsage,
229 ) -> Result<AgentCacheKey, JournalError> {
230 let ts = current_timestamp();
231 let cache = AgentResultCache {
232 agent_id,
233 phase_id,
234 status: status.as_str().to_string(),
235 output,
236 findings,
237 tokens: tokens.total(),
238 completed_at: ts,
239 cache_key_hash: Some(cache_key.hash.clone()),
240 description: None,
241 role: None,
242 };
243
244 {
246 let mut index = self.cache_index.write().unwrap();
247 index.insert(cache_key.hash.clone(), cache.clone());
248 index.insert(agent_id.to_string(), cache.clone());
250 }
251
252 if let Err(e) = self.inner.upsert_agent_result(&cache) {
254 tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
255 }
256
257 let event = AgentEvent::AgentDone {
259 run_id: self
260 .inner
261 .get_checkpoint()
262 .map(|c| c.run_id)
263 .unwrap_or_else(uuid::Uuid::nil),
264 agent_id,
265 status,
266 tokens,
267 elapsed_ms: 0,
268 name: None,
269 agent_seq: 0,
270 output: serde_json::Value::Null,
271 findings: Vec::new(),
272 prompt: String::new(),
273 retry_count: 0,
274 };
275 self.inner.append_event(&event)?;
276
277 if let Some(ref tx) = self.event_tx {
279 let _ = tx.send(event);
280 }
281
282 Ok(cache_key.clone())
283 }
284
285 #[allow(clippy::too_many_arguments)]
293 pub fn record_result(
294 &self,
295 cache_key: &AgentCacheKey,
296 agent_id: AgentId,
297 phase_id: PhaseId,
298 status: AgentStatus,
299 output: serde_json::Value,
300 findings: Vec<Finding>,
301 tokens: TokenUsage,
302 ) {
303 let cache = AgentResultCache {
304 agent_id,
305 phase_id,
306 status: status.as_str().to_string(),
307 output,
308 findings,
309 tokens: tokens.total(),
310 completed_at: current_timestamp(),
311 cache_key_hash: Some(cache_key.hash.clone()),
312 description: None,
313 role: None,
314 };
315
316 {
317 let mut index = self.cache_index.write().unwrap();
318 index.insert(cache_key.hash.clone(), cache.clone());
319 index.insert(agent_id.to_string(), cache.clone());
320 }
321
322 if let Err(e) = self.inner.upsert_agent_result(&cache) {
323 tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
324 }
325 }
326
327 pub fn store(&self) -> Arc<RunStore> {
331 self.inner.clone()
332 }
333
334 pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
336 self.inner.append_event(event)?;
337 Ok(())
338 }
339
340 pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
343 let index = self.cache_index.read().unwrap();
344 index.contains_key(&cache_key.hash)
345 }
346
347 pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
350 let index = self.cache_index.read().unwrap();
351 index.get(&cache_key.hash).cloned()
352 }
353
354 pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
357 let index = self.cache_index.read().unwrap();
358 index
359 .keys()
360 .map(|k| AgentCacheKey {
361 hash: k.clone(),
362 prompt_preview: String::new(),
363 model: None,
364 phase_id: 0,
365 })
366 .collect()
367 }
368
369 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
371 self.inner.get_checkpoint()
372 }
373
374 pub fn flush(&self) -> Result<(), JournalError> {
376 Ok(())
378 }
379
380 pub fn cancel(&self) -> Result<(), JournalError> {
382 self.inner.cancel()?;
383 Ok(())
384 }
385}
386
387pub struct CompositeJournalCallback {
393 callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
394}
395
396impl CompositeJournalCallback {
397 pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
398 Self { callbacks }
399 }
400}
401
402#[async_trait::async_trait]
403impl crate::scheduler::JournalCallback for CompositeJournalCallback {
404 async fn on_agent_done(
405 &self,
406 agent_id: AgentId,
407 phase_id: PhaseId,
408 status: AgentStatus,
409 output: serde_json::Value,
410 tokens: TokenUsage,
411 ) {
412 for cb in &self.callbacks {
413 cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
414 .await;
415 }
416 }
417}
418
419#[async_trait::async_trait]
420impl crate::scheduler::JournalCallback for JournalStore {
421 async fn on_agent_done(
422 &self,
423 agent_id: AgentId,
424 phase_id: PhaseId,
425 status: AgentStatus,
426 output: serde_json::Value,
427 tokens: TokenUsage,
428 ) {
429 let ts = current_timestamp();
431 let cache = AgentResultCache {
432 agent_id,
433 phase_id,
434 status: status.as_str().to_string(),
435 output,
436 findings: vec![], tokens: tokens.total(),
438 completed_at: ts,
439 cache_key_hash: None, description: None,
441 role: None,
442 };
443
444 if let Err(e) = self.inner.upsert_agent_result(&cache) {
446 tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
447 }
448 }
449}
450
451#[derive(Debug)]
457pub struct ResumeContext {
458 pub run_id: RunId,
459 pub checkpoint: RunCheckpoint,
460 pub journal: Arc<JournalStore>,
461 pub scheduler_config: SchedulerConfig,
462 pub backend_registry: BackendRegistry,
463}
464
465#[derive(Debug, Clone)]
467pub enum RunCreationMode {
468 New { task: String },
470 Resume { run_id: RunId, run_dir_name: String },
472 Auto { task: String },
474}
475
476impl RunCreationMode {
477 pub fn resolve(
480 self,
481 journal_dir: &Path,
482 ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
483 match self {
484 RunCreationMode::New { task: _ } => {
485 let run_id = uuid::Uuid::now_v7();
486 Ok((run_id, None))
487 }
488 RunCreationMode::Resume {
489 run_id,
490 run_dir_name,
491 } => {
492 let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
493 let checkpoint = store.open(run_id)?;
494 Ok((run_id, Some(checkpoint)))
495 }
496 RunCreationMode::Auto { task: _ } => {
497 let run_dirs = crate::state::list_runs(journal_dir)?;
499 for dir_name in run_dirs.iter().rev() {
500 let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
501 if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
502 if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
503 if matches!(
504 checkpoint.status,
505 crate::state::CheckpointStatus::Running
506 ) {
507 let run_id = checkpoint.run_id;
508 return Ok((run_id, Some(checkpoint)));
509 }
510 }
511 }
512 }
513 let run_id = uuid::Uuid::now_v7();
515 Ok((run_id, None))
516 }
517 }
518 }
519}
520
521pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
533 let run_dirs = crate::state::list_runs(journal_dir)?;
534 let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
535
536 tracing::debug!("GC: scanning {} runs", run_dirs.len());
537 let mut cleaned = 0;
538 for dir_name in &run_dirs {
539 let run_dir = journal_dir.join(dir_name);
540 let checkpoint_path = run_dir.join("checkpoint.json");
542 if !checkpoint_path.exists() {
543 continue;
544 }
545
546 let content = std::fs::read_to_string(&checkpoint_path)?;
547 let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
548
549 let is_old = checkpoint.updated_at < cutoff;
550 let is_terminal = matches!(
551 checkpoint.status,
552 crate::state::CheckpointStatus::Completed
553 | crate::state::CheckpointStatus::Cancelled
554 | crate::state::CheckpointStatus::Failed
555 );
556
557 if is_old && is_terminal {
558 tracing::info!(dir = %dir_name, "GC: removing old terminal run");
559 std::fs::remove_dir_all(&run_dir)?;
560 cleaned += 1;
561 }
562 }
563
564 Ok(cleaned)
565}
566
567fn current_timestamp() -> u64 {
568 SystemTime::now()
569 .duration_since(UNIX_EPOCH)
570 .map(|d| d.as_secs())
571 .unwrap_or(0)
572}
573
574#[cfg(test)]
579mod tests {
580 use super::*;
581 use tempfile::tempdir;
582
583 #[test]
585 fn test_journal_lifecycle() {
586 let dir = tempdir().unwrap();
587 let run_id = uuid::Uuid::now_v7();
588 let journal = JournalStore::new(dir.path()).unwrap();
589
590 journal.init_run(run_id, "Test task").unwrap();
592 let cp = journal.get_checkpoint().unwrap();
593 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
594 assert_eq!(cp.task, "Test task");
595
596 let agent_id = uuid::Uuid::now_v7();
598 let key = AgentCacheKey::new("test prompt", Some("gpt-4"), 1);
599 journal
600 .cache_agent(
601 &key,
602 agent_id,
603 1,
604 AgentStatus::Ok,
605 serde_json::json!({"result": "ok"}),
606 vec![],
607 TokenUsage {
608 input: 100,
609 output: 50,
610 cache_read: 0,
611 cache_write: 0,
612 },
613 )
614 .unwrap();
615
616 assert!(journal.has_completed(&key));
618 let cached = journal.get_cached(&key).unwrap();
619 assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
620 assert_eq!(cached.tokens, 150);
621
622 journal.cancel().unwrap();
624 let cp = journal.get_checkpoint().unwrap();
625 assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
626 }
627
628 #[test]
630 fn test_cache_key_uniqueness() {
631 let k1 = AgentCacheKey::new("prompt A", Some("gpt-4"), 1);
632 let k2 = AgentCacheKey::new("prompt B", Some("gpt-4"), 1);
633 assert_ne!(k1.hash, k2.hash);
634
635 let k3 = AgentCacheKey::new("prompt A", Some("claude"), 1);
637 assert_ne!(k1.hash, k3.hash);
638
639 let k4 = AgentCacheKey::new("prompt A", Some("gpt-4"), 2);
641 assert_ne!(k1.hash, k4.hash);
642
643 let k5 = AgentCacheKey::new(" prompt \r\nA ", Some("gpt-4"), 1);
645 assert_eq!(k1.hash, k5.hash);
646 }
647
648 #[test]
650 fn test_resume_skip_cached() {
651 let dir = tempdir().unwrap();
652 let run_id = uuid::Uuid::now_v7();
653 let journal = JournalStore::new(dir.path()).unwrap();
654 journal.init_run(run_id, "Three agent test").unwrap();
655
656 let k1 = AgentCacheKey::new("task 1", None, 1);
658 let k2 = AgentCacheKey::new("task 2", None, 1);
659 let k3 = AgentCacheKey::new("task 3", None, 1);
660
661 journal
662 .cache_agent(
663 &k1,
664 uuid::Uuid::now_v7(),
665 1,
666 AgentStatus::Ok,
667 serde_json::json!({"done": 1}),
668 vec![],
669 TokenUsage {
670 input: 10,
671 output: 5,
672 cache_read: 0,
673 cache_write: 0,
674 },
675 )
676 .unwrap();
677 journal
678 .cache_agent(
679 &k2,
680 uuid::Uuid::now_v7(),
681 1,
682 AgentStatus::Ok,
683 serde_json::json!({"done": 2}),
684 vec![],
685 TokenUsage {
686 input: 10,
687 output: 5,
688 cache_read: 0,
689 cache_write: 0,
690 },
691 )
692 .unwrap();
693
694 assert!(journal.has_completed(&k1));
696 assert!(journal.has_completed(&k2));
697 assert!(!journal.has_completed(&k3));
698
699 assert!(journal.get_cached(&k3).is_none());
701 }
702
703 #[test]
705 fn test_journal_crash_recovery() {
706 let dir = tempdir().unwrap();
707 let run_id = uuid::Uuid::now_v7();
708
709 {
711 let j = JournalStore::new(dir.path()).unwrap();
712 j.init_run(run_id, "Crash test").unwrap();
713 let key = AgentCacheKey::new("important work", None, 0);
714 j.cache_agent(
715 &key,
716 uuid::Uuid::now_v7(),
717 0,
718 AgentStatus::Ok,
719 serde_json::json!({"survived": true}),
720 vec![],
721 TokenUsage {
722 input: 1,
723 output: 1,
724 cache_read: 0,
725 cache_write: 0,
726 },
727 )
728 .unwrap();
729 } {
733 let j2 = JournalStore::new(dir.path()).unwrap();
734 let cp = j2.open(run_id).unwrap();
735 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
736 assert!(!cp.agent_results.is_empty());
737
738 let key = AgentCacheKey::new("important work", None, 0);
739 let cached = j2.get_cached(&key).unwrap();
740 assert_eq!(cached.output, serde_json::json!({"survived": true}));
741 }
742 }
743
744 #[test]
746 fn test_gc_older_than() {
747 let dir = tempdir().unwrap();
748 let run_dir = dir.path().join("runs");
749 std::fs::create_dir_all(&run_dir).unwrap();
750
751 let run_id = uuid::Uuid::now_v7();
753 let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
754 journal.init_run(run_id, "GC me").unwrap();
755
756 if let Some(mut cp) = journal.get_checkpoint() {
758 cp.status = crate::state::CheckpointStatus::Completed;
759 cp.updated_at = 1000; let _ = journal.inner.save_checkpoint(&cp);
761 }
762
763let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
765 assert_eq!(cleaned, 1);
766 }
767
768 fn read_checkpoint_status_for(
780 run_dir: &std::path::Path,
781 agent_id: AgentId,
782 ) -> Option<String> {
783 let cp_path = run_dir.join("checkpoint.json");
784 let content = std::fs::read_to_string(&cp_path).ok()?;
785 let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
786 let ar = raw.get("agent_results")?.as_object()?;
787 for (_k, v) in ar {
788 if v.get("agent_id").and_then(|id| id.as_str())
789 == Some(&agent_id.to_string())
790 {
791 return v.get("status").and_then(|s| s.as_str()).map(String::from);
792 }
793 }
794 None
795 }
796
797 fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
798 TokenUsage {
799 input,
800 output,
801 cache_read: 0,
802 cache_write: 0,
803 }
804 }
805
806 #[test]
807 fn cache_agent_persists_snake_case_status_for_each_variant() {
808 let dir = tempdir().unwrap();
813 let run_id = uuid::Uuid::now_v7();
814 let journal = JournalStore::new(dir.path()).unwrap();
815 journal.init_run(run_id, "cache_agent F5").unwrap();
816
817 let cases: Vec<(AgentStatus, &str)> = vec![
818 (AgentStatus::Ok, "ok"),
819 (AgentStatus::Error, "error"),
820 (AgentStatus::Cancelled, "cancelled"),
821 (AgentStatus::TimedOut, "timed_out"),
822 ];
823 for (status, expected) in &cases {
824 let agent_id = uuid::Uuid::now_v7();
825 let key = AgentCacheKey::new("prompt", Some("gpt-4"), 1);
826 journal
827 .cache_agent(
828 &key,
829 agent_id,
830 1,
831 status.clone(),
832 serde_json::json!({"v": 1}),
833 vec![],
834 sample_token_usage(10, 5),
835 )
836 .unwrap();
837
838 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
839 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
840 assert_eq!(
841 persisted, *expected,
842 "cache_agent({status:?}) must persist status={expected:?} (snake_case); \
843 got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
844 for TimedOut and break the on-disk contract."
845 );
846 }
847 }
848
849 #[test]
850 fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
851 let dir = tempdir().unwrap();
855 let run_id = uuid::Uuid::now_v7();
856 let journal = JournalStore::new(dir.path()).unwrap();
857 journal.init_run(run_id, "timed-out guard").unwrap();
858
859 let agent_id = uuid::Uuid::now_v7();
860 let key = AgentCacheKey::new("p", None, 0);
861 journal
862 .cache_agent(
863 &key,
864 agent_id,
865 0,
866 AgentStatus::TimedOut,
867 serde_json::json!(null),
868 vec![],
869 sample_token_usage(1, 2),
870 )
871 .unwrap();
872
873 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
874 .expect("status on disk");
875 assert_eq!(
876 persisted, "timed_out",
877 "cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
878 );
879 assert_ne!(
880 persisted, "timedout",
881 "cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
882 );
883 }
884
885 #[test]
886 fn record_result_persists_snake_case_status_for_each_variant() {
887 let dir = tempdir().unwrap();
890 let run_id = uuid::Uuid::now_v7();
891 let journal = JournalStore::new(dir.path()).unwrap();
892 journal.init_run(run_id, "record_result F5").unwrap();
893
894 let cases: Vec<(AgentStatus, &str)> = vec![
895 (AgentStatus::Ok, "ok"),
896 (AgentStatus::Error, "error"),
897 (AgentStatus::Cancelled, "cancelled"),
898 (AgentStatus::TimedOut, "timed_out"),
899 ];
900 for (status, expected) in &cases {
901 let agent_id = uuid::Uuid::now_v7();
902 let key = AgentCacheKey::new("p", None, 1);
903 journal.record_result(
904 &key,
905 agent_id,
906 1,
907 status.clone(),
908 serde_json::json!({"r": 1}),
909 vec![],
910 sample_token_usage(2, 3),
911 );
912
913 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
914 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
915 assert_eq!(
916 persisted, *expected,
917 "record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
918 );
919 }
920 }
921
922 #[test]
923 fn record_result_timed_out_persists_with_underscore() {
924 let dir = tempdir().unwrap();
926 let run_id = uuid::Uuid::now_v7();
927 let journal = JournalStore::new(dir.path()).unwrap();
928 journal.init_run(run_id, "record_result timed-out").unwrap();
929
930 let agent_id = uuid::Uuid::now_v7();
931 let key = AgentCacheKey::new("p", None, 0);
932 journal.record_result(
933 &key,
934 agent_id,
935 0,
936 AgentStatus::TimedOut,
937 serde_json::json!(null),
938 vec![],
939 sample_token_usage(0, 0),
940 );
941
942 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
943 .expect("status on disk");
944 assert_eq!(persisted, "timed_out");
945 assert_ne!(persisted, "timedout");
946 }
947
948 #[tokio::test]
949 async fn journal_callback_on_agent_done_persists_snake_case_status() {
950 let dir = tempdir().unwrap();
954 let run_id = uuid::Uuid::now_v7();
955 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
956 journal.init_run(run_id, "callback F5").unwrap();
957
958 let cases: Vec<(AgentStatus, &str)> = vec![
959 (AgentStatus::Ok, "ok"),
960 (AgentStatus::Error, "error"),
961 (AgentStatus::Cancelled, "cancelled"),
962 (AgentStatus::TimedOut, "timed_out"),
963 ];
964 for (status, expected) in &cases {
965 let agent_id = uuid::Uuid::now_v7();
966 use crate::scheduler::JournalCallback;
967 journal
968 .on_agent_done(
969 agent_id,
970 1,
971 status.clone(),
972 serde_json::json!({}),
973 sample_token_usage(4, 6),
974 )
975 .await;
976
977 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
978 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
979 assert_eq!(
980 persisted, *expected,
981 "JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
982 got {persisted:?}"
983 );
984 }
985 }
986
987 #[test]
988 fn record_result_then_reopen_uses_snake_case_status() {
989 let dir = tempdir().unwrap();
992 let run_id = uuid::Uuid::now_v7();
993 let journal = JournalStore::new(dir.path()).unwrap();
994 journal.init_run(run_id, "reopen F5").unwrap();
995
996 let agent_id = uuid::Uuid::now_v7();
997 let key = AgentCacheKey::new("reopen prompt", Some("gpt-4"), 1);
998 journal.record_result(
999 &key,
1000 agent_id,
1001 1,
1002 AgentStatus::Cancelled,
1003 serde_json::json!({"result": "ok"}),
1004 vec![],
1005 sample_token_usage(7, 11),
1006 );
1007 drop(journal);
1008
1009 let j2 = JournalStore::new(dir.path()).unwrap();
1010 let cp = j2.open(run_id).expect("open after drop");
1011 let cached = cp
1012 .agent_results
1013 .get(&agent_id)
1014 .expect("entry survives reopen");
1015 assert_eq!(
1016 cached.status, "cancelled",
1017 "snake_case status must round-trip through close+reopen"
1018 );
1019 assert_eq!(cached.tokens, 18);
1020 }
1021
1022 #[test]
1023 fn cache_agent_persists_snake_case_status_to_event_log() {
1024 let dir = tempdir().unwrap();
1028 let run_id = uuid::Uuid::now_v7();
1029 let journal = JournalStore::new(dir.path()).unwrap();
1030 journal.init_run(run_id, "event log F5").unwrap();
1031
1032 let agent_id = uuid::Uuid::now_v7();
1033 let key = AgentCacheKey::new("p", None, 1);
1034 journal
1035 .cache_agent(
1036 &key,
1037 agent_id,
1038 1,
1039 AgentStatus::TimedOut,
1040 serde_json::json!(null),
1041 vec![],
1042 sample_token_usage(1, 1),
1043 )
1044 .unwrap();
1045
1046 let log = journal.store().get_event_log().expect("read events.jsonl");
1050 let agent_done = log
1051 .iter()
1052 .find_map(|e| match e {
1053 AgentEvent::AgentDone {
1054 agent_id: id,
1055 status,
1056 ..
1057 } if id == &agent_id => Some(status.clone()),
1058 _ => None,
1059 })
1060 .expect("AgentDone event in log");
1061 assert!(matches!(
1065 agent_done,
1066 AgentStatus::TimedOut
1067 ));
1068 }
1069}