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::session::{resolve_session, restore_session};
25use crate::state::{AgentResultCache, AgentSessionCheckpoint, RunCheckpoint, RunStore};
26use blake3::Hasher;
27use chrono::Utc;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::path::Path;
31use std::sync::{Arc, RwLock};
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33use thiserror::Error;
34
35#[derive(Error, Debug)]
40pub enum JournalError {
41 #[error("run not found: {0}")]
42 RunNotFound(RunId),
43 #[error("run is not resumable (status: {status:?})")]
44 NotResumable { status: String },
45 #[error("I/O error: {0}")]
46 Io(#[from] std::io::Error),
47 #[error("serialization error: {0}")]
48 Serde(#[from] serde_json::Error),
49 #[error("journal corrupted: {0}")]
50 Corrupted(String),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct AgentCacheKey {
61 pub hash: String,
62 pub prompt_preview: String,
64 pub phase_id: PhaseId,
65}
66
67impl AgentCacheKey {
68 pub fn new(prompt: &str, phase_id: PhaseId) -> Self {
71 let normalized = normalize_prompt(prompt);
72 let preview = if normalized.chars().count() > 80 {
73 format!("{}...", normalized.chars().take(80).collect::<String>())
74 } else {
75 normalized.clone()
76 };
77
78 let mut h = Hasher::new();
79 h.update(normalized.as_bytes());
80 h.update(b"\0");
81 h.update(&phase_id.to_le_bytes());
82
83 Self {
84 hash: h.finalize().to_hex().to_string(),
85 prompt_preview: preview,
86 phase_id,
87 }
88 }
89}
90
91fn normalize_prompt(prompt: &str) -> String {
92 prompt
93 .replace("\r\n", "\n")
94 .replace('\r', "\n")
95 .split_whitespace()
96 .collect::<Vec<_>>()
97 .join(" ")
98}
99
100pub struct JournalStore {
114 inner: Arc<RunStore>,
116 cache_index: RwLock<HashMap<String, AgentResultCache>>,
119 event_tx: Option<EventSender>,
121}
122
123impl std::fmt::Debug for JournalStore {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("JournalStore")
126 .field("inner", &self.inner)
127 .field("cache_index_size", &self.cache_index.read().unwrap().len())
128 .field("has_event_tx", &self.event_tx.is_some())
129 .finish()
130 }
131}
132
133impl JournalStore {
134 pub fn new(run_dir: &Path) -> Result<Self, JournalError> {
137 tracing::debug!(path = %run_dir.display(), "creating journal store");
138 let inner = RunStore::new(run_dir)?;
139 Ok(Self {
140 inner,
141 cache_index: RwLock::new(HashMap::new()),
142 event_tx: None,
143 })
144 }
145
146 pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), JournalError> {
148 tracing::info!(%run_id, %task, "initializing run in journal");
149 self.inner.init_run(run_id, task)?;
150 Ok(())
151 }
152
153 pub fn init_run_with_meta(
155 &self,
156 run_id: RunId,
157 task: &str,
158 workflow_meta: serde_json::Value,
159 ) -> Result<(), JournalError> {
160 tracing::info!(
161 %run_id, %task,
162 "initializing run in journal with meta"
163 );
164 self.inner.init_run_with_meta(run_id, task, workflow_meta)?;
165 Ok(())
166 }
167
168 pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
175 tracing::info!(%run_id, "opening journal for resume");
176 let checkpoint = self
177 .inner
178 .open_run(run_id)?
179 .ok_or(JournalError::RunNotFound(run_id))?;
180
181 if matches!(
182 checkpoint.status,
183 crate::state::CheckpointStatus::Completed
184 ) {
185 return Err(JournalError::NotResumable {
186 status: format!("{:?}", checkpoint.status),
187 });
188 }
189
190 let mut index = HashMap::new();
193 for (agent_id, cache) in &checkpoint.agent_results {
194 index.insert(agent_id.to_string(), cache.clone());
195 if let Some(ref hash) = cache.cache_key_hash {
196 index.insert(hash.clone(), cache.clone());
197 }
198 }
199 *self.cache_index.write().unwrap() = index;
200
201 Ok(checkpoint)
202 }
203
204 #[allow(clippy::too_many_arguments)]
210 pub fn cache_agent(
211 &self,
212 cache_key: &AgentCacheKey,
213 agent_id: AgentId,
214 phase_id: PhaseId,
215 status: AgentStatus,
216 output: serde_json::Value,
217 findings: Vec<Finding>,
218 tokens: TokenUsage,
219 ) -> Result<AgentCacheKey, JournalError> {
220 let ts = current_timestamp();
221 let cache = AgentResultCache {
222 agent_id,
223 phase_id,
224 status: status.as_str().to_string(),
225 output,
226 findings,
227 tokens: tokens.total(),
228 completed_at: ts,
229 cache_key_hash: Some(cache_key.hash.clone()),
230 description: None,
231 role: None,
232 };
233
234 {
236 let mut index = self.cache_index.write().unwrap();
237 index.insert(cache_key.hash.clone(), cache.clone());
238 index.insert(agent_id.to_string(), cache.clone());
240 }
241
242 if let Err(e) = self.inner.upsert_agent_result(&cache) {
244 tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
245 }
246
247 let event = AgentEvent::AgentDone {
249 run_id: self
250 .inner
251 .get_checkpoint()
252 .map(|c| c.run_id)
253 .unwrap_or_else(uuid::Uuid::nil),
254 agent_id,
255 status,
256 tokens,
257 elapsed_ms: 0,
258 name: None,
259 agent_seq: 0,
260 output: serde_json::Value::Null,
261 findings: Vec::new(),
262 prompt: String::new(),
263 retry_count: 0,
264 ts: Utc::now(),
265 };
266 self.inner.append_event(&event)?;
267
268 if let Some(ref tx) = self.event_tx {
270 let _ = tx.send(event);
271 }
272
273 Ok(cache_key.clone())
274 }
275
276 #[allow(clippy::too_many_arguments)]
284 pub fn record_result(
285 &self,
286 cache_key: &AgentCacheKey,
287 agent_id: AgentId,
288 phase_id: PhaseId,
289 status: AgentStatus,
290 output: serde_json::Value,
291 findings: Vec<Finding>,
292 tokens: TokenUsage,
293 ) {
294 let cache = AgentResultCache {
295 agent_id,
296 phase_id,
297 status: status.as_str().to_string(),
298 output,
299 findings,
300 tokens: tokens.total(),
301 completed_at: current_timestamp(),
302 cache_key_hash: Some(cache_key.hash.clone()),
303 description: None,
304 role: None,
305 };
306
307 {
308 let mut index = self.cache_index.write().unwrap();
309 index.insert(cache_key.hash.clone(), cache.clone());
310 index.insert(agent_id.to_string(), cache.clone());
311 }
312
313 if let Err(e) = self.inner.upsert_agent_result(&cache) {
314 tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
315 }
316 }
317
318 pub fn record_session(
322 &self,
323 agent_id: AgentId,
324 session_id: String,
325 status: &str,
326 resumable: bool,
327 ) {
328 let backend_id = crate::contract::current_backend().map(|backend| backend.id);
329 let protocol_session_id = backend_id
330 .as_deref()
331 .and_then(|backend| resolve_session(&session_id, backend))
332 .map(|record| record.protocol_session_id)
333 .or_else(|| Some(session_id.clone()));
334 let session = AgentSessionCheckpoint {
335 agent_id,
336 backend_id,
337 protocol_session_id,
338 session_id,
339 status: status.to_string(),
340 updated_at: current_timestamp(),
341 resumable,
342 };
343 if let Err(e) = self.inner.upsert_agent_session(&session) {
344 tracing::warn!(%agent_id, error = %e, "failed to persist agent session");
345 }
346 }
347
348 pub fn get_session(&self, agent_id: AgentId) -> Option<AgentSessionCheckpoint> {
350 let session = self
351 .inner
352 .get_checkpoint()
353 .and_then(|checkpoint| checkpoint.agent_sessions.get(&agent_id).cloned());
354 if let Some(ref session) = session {
355 if let (Some(backend_id), Some(protocol_id)) =
356 (session.backend_id.as_deref(), session.protocol_session_id.as_deref())
357 {
358 restore_session(&session.session_id, backend_id, protocol_id);
359 }
360 }
361 session
362 }
363
364 pub fn store(&self) -> Arc<RunStore> {
368 self.inner.clone()
369 }
370
371 pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
373 self.inner.append_event(event)?;
374 Ok(())
375 }
376
377 pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
380 let index = self.cache_index.read().unwrap();
381 index.contains_key(&cache_key.hash)
382 }
383
384 pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
387 let index = self.cache_index.read().unwrap();
388 index.get(&cache_key.hash).cloned()
389 }
390
391 pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
394 let index = self.cache_index.read().unwrap();
395 index
396 .keys()
397 .map(|k| AgentCacheKey {
398 hash: k.clone(),
399 prompt_preview: String::new(),
400 phase_id: 0,
401 })
402 .collect()
403 }
404
405 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
407 self.inner.get_checkpoint()
408 }
409
410 pub fn flush(&self) -> Result<(), JournalError> {
412 Ok(())
414 }
415
416 pub fn cancel(&self) -> Result<(), JournalError> {
418 self.inner.cancel()?;
419 Ok(())
420 }
421
422 pub fn reset_status_to_running(&self) -> Result<(), JournalError> {
425 self.inner.reset_status_to_running()?;
426 Ok(())
427 }
428}
429
430pub struct CompositeJournalCallback {
436 callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
437}
438
439impl CompositeJournalCallback {
440 pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
441 Self { callbacks }
442 }
443}
444
445#[async_trait::async_trait]
446impl crate::scheduler::JournalCallback for CompositeJournalCallback {
447 async fn on_agent_done(
448 &self,
449 agent_id: AgentId,
450 phase_id: PhaseId,
451 status: AgentStatus,
452 output: serde_json::Value,
453 tokens: TokenUsage,
454 ) {
455 for cb in &self.callbacks {
456 cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
457 .await;
458 }
459 }
460}
461
462#[async_trait::async_trait]
463impl crate::scheduler::JournalCallback for JournalStore {
464 async fn on_agent_done(
465 &self,
466 agent_id: AgentId,
467 phase_id: PhaseId,
468 status: AgentStatus,
469 output: serde_json::Value,
470 tokens: TokenUsage,
471 ) {
472 let ts = current_timestamp();
473
474 let existing = {
479 let index = self.cache_index.read().unwrap();
480 index.get(&agent_id.to_string()).cloned()
481 };
482
483 let cache = AgentResultCache {
484 agent_id,
485 phase_id: existing.as_ref().map(|c| c.phase_id).unwrap_or(phase_id),
486 status: status.as_str().to_string(),
487 output: existing
488 .as_ref()
489 .filter(|c| !c.output.is_null())
490 .map(|c| c.output.clone())
491 .unwrap_or(output),
492 findings: existing
493 .as_ref()
494 .filter(|c| !c.findings.is_empty())
495 .map(|c| c.findings.clone())
496 .unwrap_or_default(),
497 tokens: tokens.total(),
498 completed_at: ts,
499 cache_key_hash: existing.as_ref().and_then(|c| c.cache_key_hash.clone()),
500 description: existing.as_ref().and_then(|c| c.description.clone()),
501 role: existing.as_ref().and_then(|c| c.role.clone()),
502 };
503
504 {
507 let mut index = self.cache_index.write().unwrap();
508 index.insert(agent_id.to_string(), cache.clone());
509 if let Some(ref hash) = cache.cache_key_hash {
510 index.insert(hash.clone(), cache.clone());
511 }
512 }
513
514 if let Err(e) = self.inner.upsert_agent_result(&cache) {
516 tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
517 }
518 }
519}
520
521#[derive(Debug)]
527pub struct ResumeContext {
528 pub run_id: RunId,
529 pub checkpoint: RunCheckpoint,
530 pub journal: Arc<JournalStore>,
531 pub scheduler_config: SchedulerConfig,
532 pub backend_registry: BackendRegistry,
533}
534
535#[derive(Debug, Clone)]
537pub enum RunCreationMode {
538 New { task: String },
540 Resume { run_id: RunId, run_dir_name: String },
542 Auto { task: String },
544}
545
546impl RunCreationMode {
547 pub fn resolve(
550 self,
551 journal_dir: &Path,
552 ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
553 match self {
554 RunCreationMode::New { task: _ } => {
555 let run_id = uuid::Uuid::now_v7();
556 Ok((run_id, None))
557 }
558 RunCreationMode::Resume {
559 run_id,
560 run_dir_name,
561 } => {
562 let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
563 let checkpoint = store.open(run_id)?;
564 Ok((run_id, Some(checkpoint)))
565 }
566 RunCreationMode::Auto { task: _ } => {
567 let run_dirs = crate::state::list_runs(journal_dir)?;
569 for dir_name in run_dirs.iter().rev() {
570 let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
571 if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
572 if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
573 if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
574 {
575 let run_id = checkpoint.run_id;
576 return Ok((run_id, Some(checkpoint)));
577 }
578 }
579 }
580 }
581 let run_id = uuid::Uuid::now_v7();
583 Ok((run_id, None))
584 }
585 }
586 }
587}
588
589pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
601 let run_dirs = crate::state::list_runs(journal_dir)?;
602 let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
603
604 tracing::debug!("GC: scanning {} runs", run_dirs.len());
605 let mut cleaned = 0;
606 for dir_name in &run_dirs {
607 let run_dir = journal_dir.join(dir_name);
608 let checkpoint_path = run_dir.join("checkpoint.json");
610 if !checkpoint_path.exists() {
611 continue;
612 }
613
614 let content = std::fs::read_to_string(&checkpoint_path)?;
615 let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
616
617 let is_old = checkpoint.updated_at < cutoff;
618 let is_terminal = matches!(
619 checkpoint.status,
620 crate::state::CheckpointStatus::Completed
621 | crate::state::CheckpointStatus::Cancelled
622 | crate::state::CheckpointStatus::Failed
623 );
624
625 if is_old && is_terminal {
626 tracing::info!(dir = %dir_name, "GC: removing old terminal run");
627 std::fs::remove_dir_all(&run_dir)?;
628 cleaned += 1;
629 }
630 }
631
632 Ok(cleaned)
633}
634
635fn current_timestamp() -> u64 {
636 SystemTime::now()
637 .duration_since(UNIX_EPOCH)
638 .map(|d| d.as_secs())
639 .unwrap_or(0)
640}
641
642#[cfg(test)]
647mod tests {
648 use super::*;
649 use tempfile::tempdir;
650
651 #[test]
653 fn test_journal_lifecycle() {
654 let dir = tempdir().unwrap();
655 let run_id = uuid::Uuid::now_v7();
656 let journal = JournalStore::new(dir.path()).unwrap();
657
658 journal.init_run(run_id, "Test task").unwrap();
660 let cp = journal.get_checkpoint().unwrap();
661 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
662 assert_eq!(cp.task, "Test task");
663
664 let agent_id = uuid::Uuid::now_v7();
666 let key = AgentCacheKey::new("test prompt", 1);
667 journal
668 .cache_agent(
669 &key,
670 agent_id,
671 1,
672 AgentStatus::Ok,
673 serde_json::json!({"result": "ok"}),
674 vec![],
675 TokenUsage {
676 input: 100,
677 output: 50,
678 cache_read: 0,
679 cache_write: 0,
680 },
681 )
682 .unwrap();
683
684 assert!(journal.has_completed(&key));
686 let cached = journal.get_cached(&key).unwrap();
687 assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
688 assert_eq!(cached.tokens, 150);
689
690 journal.cancel().unwrap();
692 let cp = journal.get_checkpoint().unwrap();
693 assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
694 }
695
696 #[test]
698 fn test_cache_key_uniqueness() {
699 let k1 = AgentCacheKey::new("prompt A", 1);
700 let k2 = AgentCacheKey::new("prompt B", 1);
701 assert_ne!(k1.hash, k2.hash);
702
703 let k4 = AgentCacheKey::new("prompt A", 2);
705 assert_ne!(k1.hash, k4.hash);
706
707 let k5 = AgentCacheKey::new(" prompt \r\nA ", 1);
709 assert_eq!(k1.hash, k5.hash);
710 }
711
712 #[test]
714 fn test_resume_skip_cached() {
715 let dir = tempdir().unwrap();
716 let run_id = uuid::Uuid::now_v7();
717 let journal = JournalStore::new(dir.path()).unwrap();
718 journal.init_run(run_id, "Three agent test").unwrap();
719
720 let k1 = AgentCacheKey::new("task 1", 1);
722 let k2 = AgentCacheKey::new("task 2", 1);
723 let k3 = AgentCacheKey::new("task 3", 1);
724
725 journal
726 .cache_agent(
727 &k1,
728 uuid::Uuid::now_v7(),
729 1,
730 AgentStatus::Ok,
731 serde_json::json!({"done": 1}),
732 vec![],
733 TokenUsage {
734 input: 10,
735 output: 5,
736 cache_read: 0,
737 cache_write: 0,
738 },
739 )
740 .unwrap();
741 journal
742 .cache_agent(
743 &k2,
744 uuid::Uuid::now_v7(),
745 1,
746 AgentStatus::Ok,
747 serde_json::json!({"done": 2}),
748 vec![],
749 TokenUsage {
750 input: 10,
751 output: 5,
752 cache_read: 0,
753 cache_write: 0,
754 },
755 )
756 .unwrap();
757
758 assert!(journal.has_completed(&k1));
760 assert!(journal.has_completed(&k2));
761 assert!(!journal.has_completed(&k3));
762
763 assert!(journal.get_cached(&k3).is_none());
765 }
766
767 #[test]
769 fn test_journal_crash_recovery() {
770 let dir = tempdir().unwrap();
771 let run_id = uuid::Uuid::now_v7();
772
773 {
775 let j = JournalStore::new(dir.path()).unwrap();
776 j.init_run(run_id, "Crash test").unwrap();
777 let key = AgentCacheKey::new("important work", 0);
778 j.cache_agent(
779 &key,
780 uuid::Uuid::now_v7(),
781 0,
782 AgentStatus::Ok,
783 serde_json::json!({"survived": true}),
784 vec![],
785 TokenUsage {
786 input: 1,
787 output: 1,
788 cache_read: 0,
789 cache_write: 0,
790 },
791 )
792 .unwrap();
793 } {
797 let j2 = JournalStore::new(dir.path()).unwrap();
798 let cp = j2.open(run_id).unwrap();
799 assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
800 assert!(!cp.agent_results.is_empty());
801
802 let key = AgentCacheKey::new("important work", 0);
803 let cached = j2.get_cached(&key).unwrap();
804 assert_eq!(cached.output, serde_json::json!({"survived": true}));
805 }
806 }
807
808 #[test]
810 fn test_gc_older_than() {
811 let dir = tempdir().unwrap();
812 let run_dir = dir.path().join("runs");
813 std::fs::create_dir_all(&run_dir).unwrap();
814
815 let run_id = uuid::Uuid::now_v7();
817 let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
818 journal.init_run(run_id, "GC me").unwrap();
819
820 if let Some(mut cp) = journal.get_checkpoint() {
822 cp.status = crate::state::CheckpointStatus::Completed;
823 cp.updated_at = 1000; let _ = journal.inner.save_checkpoint(&cp);
825 }
826
827 let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
829 assert_eq!(cleaned, 1);
830 }
831
832 fn read_checkpoint_status_for(run_dir: &std::path::Path, agent_id: AgentId) -> Option<String> {
844 let cp_path = run_dir.join("checkpoint.json");
845 let content = std::fs::read_to_string(&cp_path).ok()?;
846 let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
847 let ar = raw.get("agent_results")?.as_object()?;
848 for (_k, v) in ar {
849 if v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string()) {
850 return v.get("status").and_then(|s| s.as_str()).map(String::from);
851 }
852 }
853 None
854 }
855
856 fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
857 TokenUsage {
858 input,
859 output,
860 cache_read: 0,
861 cache_write: 0,
862 }
863 }
864
865 #[test]
866 fn cache_agent_persists_snake_case_status_for_each_variant() {
867 let dir = tempdir().unwrap();
872 let run_id = uuid::Uuid::now_v7();
873 let journal = JournalStore::new(dir.path()).unwrap();
874 journal.init_run(run_id, "cache_agent F5").unwrap();
875
876 let cases: Vec<(AgentStatus, &str)> = vec![
877 (AgentStatus::Ok, "ok"),
878 (AgentStatus::Error, "error"),
879 (AgentStatus::Cancelled, "cancelled"),
880 (AgentStatus::TimedOut, "timed_out"),
881 ];
882 for (status, expected) in &cases {
883 let agent_id = uuid::Uuid::now_v7();
884 let key = AgentCacheKey::new("prompt", 1);
885 journal
886 .cache_agent(
887 &key,
888 agent_id,
889 1,
890 status.clone(),
891 serde_json::json!({"v": 1}),
892 vec![],
893 sample_token_usage(10, 5),
894 )
895 .unwrap();
896
897 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
898 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
899 assert_eq!(
900 persisted, *expected,
901 "cache_agent({status:?}) must persist status={expected:?} (snake_case); \
902 got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
903 for TimedOut and break the on-disk contract."
904 );
905 }
906 }
907
908 #[test]
909 fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
910 let dir = tempdir().unwrap();
914 let run_id = uuid::Uuid::now_v7();
915 let journal = JournalStore::new(dir.path()).unwrap();
916 journal.init_run(run_id, "timed-out guard").unwrap();
917
918 let agent_id = uuid::Uuid::now_v7();
919 let key = AgentCacheKey::new("p", 0);
920 journal
921 .cache_agent(
922 &key,
923 agent_id,
924 0,
925 AgentStatus::TimedOut,
926 serde_json::json!(null),
927 vec![],
928 sample_token_usage(1, 2),
929 )
930 .unwrap();
931
932 let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
933 assert_eq!(
934 persisted, "timed_out",
935 "cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
936 );
937 assert_ne!(
938 persisted, "timedout",
939 "cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
940 );
941 }
942
943 #[test]
944 fn record_result_persists_snake_case_status_for_each_variant() {
945 let dir = tempdir().unwrap();
948 let run_id = uuid::Uuid::now_v7();
949 let journal = JournalStore::new(dir.path()).unwrap();
950 journal.init_run(run_id, "record_result F5").unwrap();
951
952 let cases: Vec<(AgentStatus, &str)> = vec![
953 (AgentStatus::Ok, "ok"),
954 (AgentStatus::Error, "error"),
955 (AgentStatus::Cancelled, "cancelled"),
956 (AgentStatus::TimedOut, "timed_out"),
957 ];
958 for (status, expected) in &cases {
959 let agent_id = uuid::Uuid::now_v7();
960 let key = AgentCacheKey::new("p", 1);
961 journal.record_result(
962 &key,
963 agent_id,
964 1,
965 status.clone(),
966 serde_json::json!({"r": 1}),
967 vec![],
968 sample_token_usage(2, 3),
969 );
970
971 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
972 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
973 assert_eq!(
974 persisted, *expected,
975 "record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
976 );
977 }
978 }
979
980 #[test]
981 fn record_result_timed_out_persists_with_underscore() {
982 let dir = tempdir().unwrap();
984 let run_id = uuid::Uuid::now_v7();
985 let journal = JournalStore::new(dir.path()).unwrap();
986 journal.init_run(run_id, "record_result timed-out").unwrap();
987
988 let agent_id = uuid::Uuid::now_v7();
989 let key = AgentCacheKey::new("p", 0);
990 journal.record_result(
991 &key,
992 agent_id,
993 0,
994 AgentStatus::TimedOut,
995 serde_json::json!(null),
996 vec![],
997 sample_token_usage(0, 0),
998 );
999
1000 let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
1001 assert_eq!(persisted, "timed_out");
1002 assert_ne!(persisted, "timedout");
1003 }
1004
1005 #[tokio::test]
1006 async fn journal_callback_on_agent_done_persists_snake_case_status() {
1007 let dir = tempdir().unwrap();
1011 let run_id = uuid::Uuid::now_v7();
1012 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
1013 journal.init_run(run_id, "callback F5").unwrap();
1014
1015 let cases: Vec<(AgentStatus, &str)> = vec![
1016 (AgentStatus::Ok, "ok"),
1017 (AgentStatus::Error, "error"),
1018 (AgentStatus::Cancelled, "cancelled"),
1019 (AgentStatus::TimedOut, "timed_out"),
1020 ];
1021 for (status, expected) in &cases {
1022 let agent_id = uuid::Uuid::now_v7();
1023 use crate::scheduler::JournalCallback;
1024 journal
1025 .on_agent_done(
1026 agent_id,
1027 1,
1028 status.clone(),
1029 serde_json::json!({}),
1030 sample_token_usage(4, 6),
1031 )
1032 .await;
1033
1034 let persisted = read_checkpoint_status_for(dir.path(), agent_id)
1035 .unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
1036 assert_eq!(
1037 persisted, *expected,
1038 "JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
1039 got {persisted:?}"
1040 );
1041 }
1042 }
1043
1044 #[test]
1045 fn record_result_then_reopen_uses_snake_case_status() {
1046 let dir = tempdir().unwrap();
1049 let run_id = uuid::Uuid::now_v7();
1050 let journal = JournalStore::new(dir.path()).unwrap();
1051 journal.init_run(run_id, "reopen F5").unwrap();
1052
1053 let agent_id = uuid::Uuid::now_v7();
1054 let key = AgentCacheKey::new("reopen prompt", 1);
1055 journal.record_result(
1056 &key,
1057 agent_id,
1058 1,
1059 AgentStatus::Cancelled,
1060 serde_json::json!({"result": "ok"}),
1061 vec![],
1062 sample_token_usage(7, 11),
1063 );
1064 drop(journal);
1065
1066 let j2 = JournalStore::new(dir.path()).unwrap();
1067 let cp = j2.open(run_id).expect("open after drop");
1068 let cached = cp
1069 .agent_results
1070 .get(&agent_id)
1071 .expect("entry survives reopen");
1072 assert_eq!(
1073 cached.status, "cancelled",
1074 "snake_case status must round-trip through close+reopen"
1075 );
1076 assert_eq!(cached.tokens, 18);
1077 }
1078
1079 #[test]
1080 fn cache_agent_persists_snake_case_status_to_event_log() {
1081 let dir = tempdir().unwrap();
1085 let run_id = uuid::Uuid::now_v7();
1086 let journal = JournalStore::new(dir.path()).unwrap();
1087 journal.init_run(run_id, "event log F5").unwrap();
1088
1089 let agent_id = uuid::Uuid::now_v7();
1090 let key = AgentCacheKey::new("p", 1);
1091 journal
1092 .cache_agent(
1093 &key,
1094 agent_id,
1095 1,
1096 AgentStatus::TimedOut,
1097 serde_json::json!(null),
1098 vec![],
1099 sample_token_usage(1, 1),
1100 )
1101 .unwrap();
1102
1103 let log = journal.store().get_event_log().expect("read events.jsonl");
1107 let agent_done = log
1108 .iter()
1109 .find_map(|e| match e {
1110 AgentEvent::AgentDone {
1111 agent_id: id,
1112 status,
1113 ..
1114 } if id == &agent_id => Some(status.clone()),
1115 _ => None,
1116 })
1117 .expect("AgentDone event in log");
1118 assert!(matches!(agent_done, AgentStatus::TimedOut));
1122 }
1123
1124 #[tokio::test]
1129 async fn on_agent_done_preserves_cache_key_hash_from_record_result() {
1130 let dir = tempdir().unwrap();
1134 let run_id = uuid::Uuid::now_v7();
1135 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
1136 journal.init_run(run_id, "hash preservation").unwrap();
1137
1138 let agent_id = uuid::Uuid::now_v7();
1139 let key = AgentCacheKey::new("preserve me", 1);
1140
1141 journal.record_result(
1143 &key,
1144 agent_id,
1145 1,
1146 AgentStatus::Ok,
1147 serde_json::json!({"answer": 42}),
1148 vec![],
1149 sample_token_usage(10, 5),
1150 );
1151
1152 use crate::scheduler::JournalCallback;
1154 journal
1155 .on_agent_done(
1156 agent_id,
1157 1,
1158 AgentStatus::Ok,
1159 serde_json::json!({}),
1160 sample_token_usage(10, 5),
1161 )
1162 .await;
1163
1164 assert!(
1166 journal.has_completed(&key),
1167 "cache_key_hash must survive on_agent_done"
1168 );
1169
1170 drop(journal);
1172 let j2 = JournalStore::new(dir.path()).unwrap();
1173 j2.open(run_id).expect("reopen");
1174 assert!(
1175 j2.has_completed(&key),
1176 "cache_key_hash must survive reopen after on_agent_done"
1177 );
1178 }
1179
1180 #[tokio::test]
1181 async fn on_agent_done_preserves_cache_key_hash_from_cache_agent() {
1182 let dir = tempdir().unwrap();
1184 let run_id = uuid::Uuid::now_v7();
1185 let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
1186 journal.init_run(run_id, "hash preservation 2").unwrap();
1187
1188 let agent_id = uuid::Uuid::now_v7();
1189 let key = AgentCacheKey::new("preserve me 2", 0);
1190
1191 journal
1192 .cache_agent(
1193 &key,
1194 agent_id,
1195 0,
1196 AgentStatus::Ok,
1197 serde_json::json!({"r": 1}),
1198 vec![],
1199 sample_token_usage(1, 1),
1200 )
1201 .unwrap();
1202
1203 use crate::scheduler::JournalCallback;
1204 journal
1205 .on_agent_done(
1206 agent_id,
1207 0,
1208 AgentStatus::Ok,
1209 serde_json::json!({}),
1210 sample_token_usage(1, 1),
1211 )
1212 .await;
1213
1214 assert!(
1215 journal.has_completed(&key),
1216 "cache_key_hash must survive on_agent_done after cache_agent"
1217 );
1218 }
1219}