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