use crate::contract::backend::AgentStatus;
use crate::contract::event::{AgentEvent, EventSender};
use crate::contract::finding::Finding;
use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
use crate::scheduler::{BackendRegistry, SchedulerConfig};
use crate::state::{AgentResultCache, RunCheckpoint, RunStore};
use blake3::Hasher;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum JournalError {
#[error("run not found: {0}")]
RunNotFound(RunId),
#[error("run is not resumable (status: {status:?})")]
NotResumable { status: String },
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("journal corrupted: {0}")]
Corrupted(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AgentCacheKey {
pub hash: String,
pub prompt_preview: String,
pub model: Option<String>,
pub phase_id: PhaseId,
}
impl AgentCacheKey {
pub fn new(prompt: &str, model: Option<&str>, phase_id: PhaseId) -> Self {
let normalized = normalize_prompt(prompt);
let preview = if normalized.chars().count() > 80 {
format!("{}...", normalized.chars().take(80).collect::<String>())
} else {
normalized.clone()
};
let mut h = Hasher::new();
h.update(normalized.as_bytes());
h.update(b"\0");
if let Some(m) = model {
h.update(m.as_bytes());
}
h.update(b"\0");
h.update(&phase_id.to_le_bytes());
Self {
hash: h.finalize().to_hex().to_string(),
prompt_preview: preview,
model: model.map(|s| s.to_string()),
phase_id,
}
}
}
fn normalize_prompt(prompt: &str) -> String {
prompt
.replace("\r\n", "\n")
.replace('\r', "\n")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub struct JournalStore {
inner: Arc<RunStore>,
cache_index: RwLock<HashMap<String, AgentResultCache>>,
event_tx: Option<EventSender>,
}
impl std::fmt::Debug for JournalStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JournalStore")
.field("inner", &self.inner)
.field("cache_index_size", &self.cache_index.read().unwrap().len())
.field("has_event_tx", &self.event_tx.is_some())
.finish()
}
}
impl JournalStore {
pub fn new(run_dir: &Path) -> Result<Self, JournalError> {
tracing::debug!(path = %run_dir.display(), "creating journal store");
let inner = RunStore::new(run_dir)?;
Ok(Self {
inner,
cache_index: RwLock::new(HashMap::new()),
event_tx: None,
})
}
pub fn with_event_sender(mut self, tx: EventSender) -> Self {
self.event_tx = Some(tx);
self
}
pub fn init_run(&self, run_id: RunId, task: &str) -> Result<(), JournalError> {
tracing::info!(%run_id, %task, "initializing run in journal");
self.inner.init_run(run_id, task)?;
Ok(())
}
pub fn init_run_with_meta(
&self,
run_id: RunId,
task: &str,
workflow_meta: serde_json::Value,
) -> Result<(), JournalError> {
tracing::info!(
%run_id, %task,
"initializing run in journal with meta"
);
self.inner.init_run_with_meta(run_id, task, workflow_meta)?;
Ok(())
}
pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
tracing::info!(%run_id, "opening journal for resume");
let checkpoint = self
.inner
.open_run(run_id)?
.ok_or(JournalError::RunNotFound(run_id))?;
if matches!(
checkpoint.status,
crate::state::CheckpointStatus::Completed | crate::state::CheckpointStatus::Cancelled
) {
return Err(JournalError::NotResumable {
status: format!("{:?}", checkpoint.status),
});
}
let mut index = HashMap::new();
for (agent_id, cache) in &checkpoint.agent_results {
index.insert(agent_id.to_string(), cache.clone());
if let Some(ref hash) = cache.cache_key_hash {
index.insert(hash.clone(), cache.clone());
}
}
*self.cache_index.write().unwrap() = index;
Ok(checkpoint)
}
#[allow(clippy::too_many_arguments)]
pub fn cache_agent(
&self,
cache_key: &AgentCacheKey,
agent_id: AgentId,
phase_id: PhaseId,
status: AgentStatus,
output: serde_json::Value,
findings: Vec<Finding>,
tokens: TokenUsage,
) -> Result<AgentCacheKey, JournalError> {
let ts = current_timestamp();
let cache = AgentResultCache {
agent_id,
phase_id,
status: status.as_str().to_string(),
output,
findings,
tokens: tokens.total(),
completed_at: ts,
cache_key_hash: Some(cache_key.hash.clone()),
description: None,
role: None,
};
{
let mut index = self.cache_index.write().unwrap();
index.insert(cache_key.hash.clone(), cache.clone());
index.insert(agent_id.to_string(), cache.clone());
}
if let Err(e) = self.inner.upsert_agent_result(&cache) {
tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
}
let event = AgentEvent::AgentDone {
run_id: self
.inner
.get_checkpoint()
.map(|c| c.run_id)
.unwrap_or_else(uuid::Uuid::nil),
agent_id,
status,
tokens,
elapsed_ms: 0,
name: None,
agent_seq: 0,
output: serde_json::Value::Null,
findings: Vec::new(),
prompt: String::new(),
retry_count: 0,
};
self.inner.append_event(&event)?;
if let Some(ref tx) = self.event_tx {
let _ = tx.send(event);
}
Ok(cache_key.clone())
}
#[allow(clippy::too_many_arguments)]
pub fn record_result(
&self,
cache_key: &AgentCacheKey,
agent_id: AgentId,
phase_id: PhaseId,
status: AgentStatus,
output: serde_json::Value,
findings: Vec<Finding>,
tokens: TokenUsage,
) {
let cache = AgentResultCache {
agent_id,
phase_id,
status: status.as_str().to_string(),
output,
findings,
tokens: tokens.total(),
completed_at: current_timestamp(),
cache_key_hash: Some(cache_key.hash.clone()),
description: None,
role: None,
};
{
let mut index = self.cache_index.write().unwrap();
index.insert(cache_key.hash.clone(), cache.clone());
index.insert(agent_id.to_string(), cache.clone());
}
if let Err(e) = self.inner.upsert_agent_result(&cache) {
tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
}
}
pub fn store(&self) -> Arc<RunStore> {
self.inner.clone()
}
pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
self.inner.append_event(event)?;
Ok(())
}
pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
let index = self.cache_index.read().unwrap();
index.contains_key(&cache_key.hash)
}
pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
let index = self.cache_index.read().unwrap();
index.get(&cache_key.hash).cloned()
}
pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
let index = self.cache_index.read().unwrap();
index
.keys()
.map(|k| AgentCacheKey {
hash: k.clone(),
prompt_preview: String::new(),
model: None,
phase_id: 0,
})
.collect()
}
pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
self.inner.get_checkpoint()
}
pub fn flush(&self) -> Result<(), JournalError> {
Ok(())
}
pub fn cancel(&self) -> Result<(), JournalError> {
self.inner.cancel()?;
Ok(())
}
}
pub struct CompositeJournalCallback {
callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
}
impl CompositeJournalCallback {
pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
Self { callbacks }
}
}
#[async_trait::async_trait]
impl crate::scheduler::JournalCallback for CompositeJournalCallback {
async fn on_agent_done(
&self,
agent_id: AgentId,
phase_id: PhaseId,
status: AgentStatus,
output: serde_json::Value,
tokens: TokenUsage,
) {
for cb in &self.callbacks {
cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
.await;
}
}
}
#[async_trait::async_trait]
impl crate::scheduler::JournalCallback for JournalStore {
async fn on_agent_done(
&self,
agent_id: AgentId,
phase_id: PhaseId,
status: AgentStatus,
output: serde_json::Value,
tokens: TokenUsage,
) {
let ts = current_timestamp();
let cache = AgentResultCache {
agent_id,
phase_id,
status: status.as_str().to_string(),
output,
findings: vec![], tokens: tokens.total(),
completed_at: ts,
cache_key_hash: None, description: None,
role: None,
};
if let Err(e) = self.inner.upsert_agent_result(&cache) {
tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
}
}
}
#[derive(Debug)]
pub struct ResumeContext {
pub run_id: RunId,
pub checkpoint: RunCheckpoint,
pub journal: Arc<JournalStore>,
pub scheduler_config: SchedulerConfig,
pub backend_registry: BackendRegistry,
}
#[derive(Debug, Clone)]
pub enum RunCreationMode {
New { task: String },
Resume { run_id: RunId, run_dir_name: String },
Auto { task: String },
}
impl RunCreationMode {
pub fn resolve(
self,
journal_dir: &Path,
) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
match self {
RunCreationMode::New { task: _ } => {
let run_id = uuid::Uuid::now_v7();
Ok((run_id, None))
}
RunCreationMode::Resume {
run_id,
run_dir_name,
} => {
let store = JournalStore::new(&journal_dir.join(&run_dir_name))?;
let checkpoint = store.open(run_id)?;
Ok((run_id, Some(checkpoint)))
}
RunCreationMode::Auto { task: _ } => {
let run_dirs = crate::state::list_runs(journal_dir)?;
for dir_name in run_dirs.iter().rev() {
let checkpoint_path = journal_dir.join(dir_name).join("checkpoint.json");
if let Ok(content) = std::fs::read_to_string(&checkpoint_path) {
if let Ok(checkpoint) = serde_json::from_str::<RunCheckpoint>(&content) {
if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
{
let run_id = checkpoint.run_id;
return Ok((run_id, Some(checkpoint)));
}
}
}
}
let run_id = uuid::Uuid::now_v7();
Ok((run_id, None))
}
}
}
}
pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
let run_dirs = crate::state::list_runs(journal_dir)?;
let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
tracing::debug!("GC: scanning {} runs", run_dirs.len());
let mut cleaned = 0;
for dir_name in &run_dirs {
let run_dir = journal_dir.join(dir_name);
let checkpoint_path = run_dir.join("checkpoint.json");
if !checkpoint_path.exists() {
continue;
}
let content = std::fs::read_to_string(&checkpoint_path)?;
let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
let is_old = checkpoint.updated_at < cutoff;
let is_terminal = matches!(
checkpoint.status,
crate::state::CheckpointStatus::Completed
| crate::state::CheckpointStatus::Cancelled
| crate::state::CheckpointStatus::Failed
);
if is_old && is_terminal {
tracing::info!(dir = %dir_name, "GC: removing old terminal run");
std::fs::remove_dir_all(&run_dir)?;
cleaned += 1;
}
}
Ok(cleaned)
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_journal_lifecycle() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "Test task").unwrap();
let cp = journal.get_checkpoint().unwrap();
assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
assert_eq!(cp.task, "Test task");
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("test prompt", Some("gpt-4"), 1);
journal
.cache_agent(
&key,
agent_id,
1,
AgentStatus::Ok,
serde_json::json!({"result": "ok"}),
vec![],
TokenUsage {
input: 100,
output: 50,
cache_read: 0,
cache_write: 0,
},
)
.unwrap();
assert!(journal.has_completed(&key));
let cached = journal.get_cached(&key).unwrap();
assert_eq!(cached.output, serde_json::json!({"result": "ok"}));
assert_eq!(cached.tokens, 150);
journal.cancel().unwrap();
let cp = journal.get_checkpoint().unwrap();
assert_eq!(cp.status, crate::state::CheckpointStatus::Cancelled);
}
#[test]
fn test_cache_key_uniqueness() {
let k1 = AgentCacheKey::new("prompt A", Some("gpt-4"), 1);
let k2 = AgentCacheKey::new("prompt B", Some("gpt-4"), 1);
assert_ne!(k1.hash, k2.hash);
let k3 = AgentCacheKey::new("prompt A", Some("claude"), 1);
assert_ne!(k1.hash, k3.hash);
let k4 = AgentCacheKey::new("prompt A", Some("gpt-4"), 2);
assert_ne!(k1.hash, k4.hash);
let k5 = AgentCacheKey::new(" prompt \r\nA ", Some("gpt-4"), 1);
assert_eq!(k1.hash, k5.hash);
}
#[test]
fn test_resume_skip_cached() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "Three agent test").unwrap();
let k1 = AgentCacheKey::new("task 1", None, 1);
let k2 = AgentCacheKey::new("task 2", None, 1);
let k3 = AgentCacheKey::new("task 3", None, 1);
journal
.cache_agent(
&k1,
uuid::Uuid::now_v7(),
1,
AgentStatus::Ok,
serde_json::json!({"done": 1}),
vec![],
TokenUsage {
input: 10,
output: 5,
cache_read: 0,
cache_write: 0,
},
)
.unwrap();
journal
.cache_agent(
&k2,
uuid::Uuid::now_v7(),
1,
AgentStatus::Ok,
serde_json::json!({"done": 2}),
vec![],
TokenUsage {
input: 10,
output: 5,
cache_read: 0,
cache_write: 0,
},
)
.unwrap();
assert!(journal.has_completed(&k1));
assert!(journal.has_completed(&k2));
assert!(!journal.has_completed(&k3));
assert!(journal.get_cached(&k3).is_none());
}
#[test]
fn test_journal_crash_recovery() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
{
let j = JournalStore::new(dir.path()).unwrap();
j.init_run(run_id, "Crash test").unwrap();
let key = AgentCacheKey::new("important work", None, 0);
j.cache_agent(
&key,
uuid::Uuid::now_v7(),
0,
AgentStatus::Ok,
serde_json::json!({"survived": true}),
vec![],
TokenUsage {
input: 1,
output: 1,
cache_read: 0,
cache_write: 0,
},
)
.unwrap();
}
{
let j2 = JournalStore::new(dir.path()).unwrap();
let cp = j2.open(run_id).unwrap();
assert_eq!(cp.status, crate::state::CheckpointStatus::Running);
assert!(!cp.agent_results.is_empty());
let key = AgentCacheKey::new("important work", None, 0);
let cached = j2.get_cached(&key).unwrap();
assert_eq!(cached.output, serde_json::json!({"survived": true}));
}
}
#[test]
fn test_gc_older_than() {
let dir = tempdir().unwrap();
let run_dir = dir.path().join("runs");
std::fs::create_dir_all(&run_dir).unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(&run_dir.join(run_id.to_string())).unwrap();
journal.init_run(run_id, "GC me").unwrap();
if let Some(mut cp) = journal.get_checkpoint() {
cp.status = crate::state::CheckpointStatus::Completed;
cp.updated_at = 1000; let _ = journal.inner.save_checkpoint(&cp);
}
let cleaned = gc_runs(&run_dir, Duration::from_secs(3600)).unwrap();
assert_eq!(cleaned, 1);
}
fn read_checkpoint_status_for(run_dir: &std::path::Path, agent_id: AgentId) -> Option<String> {
let cp_path = run_dir.join("checkpoint.json");
let content = std::fs::read_to_string(&cp_path).ok()?;
let raw: serde_json::Value = serde_json::from_str(&content).ok()?;
let ar = raw.get("agent_results")?.as_object()?;
for (_k, v) in ar {
if v.get("agent_id").and_then(|id| id.as_str()) == Some(&agent_id.to_string()) {
return v.get("status").and_then(|s| s.as_str()).map(String::from);
}
}
None
}
fn sample_token_usage(input: u64, output: u64) -> TokenUsage {
TokenUsage {
input,
output,
cache_read: 0,
cache_write: 0,
}
}
#[test]
fn cache_agent_persists_snake_case_status_for_each_variant() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "cache_agent F5").unwrap();
let cases: Vec<(AgentStatus, &str)> = vec![
(AgentStatus::Ok, "ok"),
(AgentStatus::Error, "error"),
(AgentStatus::Cancelled, "cancelled"),
(AgentStatus::TimedOut, "timed_out"),
];
for (status, expected) in &cases {
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("prompt", Some("gpt-4"), 1);
journal
.cache_agent(
&key,
agent_id,
1,
status.clone(),
serde_json::json!({"v": 1}),
vec![],
sample_token_usage(10, 5),
)
.unwrap();
let persisted = read_checkpoint_status_for(dir.path(), agent_id)
.unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
assert_eq!(
persisted, *expected,
"cache_agent({status:?}) must persist status={expected:?} (snake_case); \
got {persisted:?}. Reverting to Debug formatting would yield \"timedout\" \
for TimedOut and break the on-disk contract."
);
}
}
#[test]
fn cache_agent_timed_out_persists_with_underscore_not_collapsed() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "timed-out guard").unwrap();
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("p", None, 0);
journal
.cache_agent(
&key,
agent_id,
0,
AgentStatus::TimedOut,
serde_json::json!(null),
vec![],
sample_token_usage(1, 2),
)
.unwrap();
let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
assert_eq!(
persisted, "timed_out",
"cache_agent(TimedOut) must persist \"timed_out\"; got {persisted:?}"
);
assert_ne!(
persisted, "timedout",
"cache_agent(TimedOut) must NOT collapse to Debug-lowercased \"timedout\""
);
}
#[test]
fn record_result_persists_snake_case_status_for_each_variant() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "record_result F5").unwrap();
let cases: Vec<(AgentStatus, &str)> = vec![
(AgentStatus::Ok, "ok"),
(AgentStatus::Error, "error"),
(AgentStatus::Cancelled, "cancelled"),
(AgentStatus::TimedOut, "timed_out"),
];
for (status, expected) in &cases {
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("p", None, 1);
journal.record_result(
&key,
agent_id,
1,
status.clone(),
serde_json::json!({"r": 1}),
vec![],
sample_token_usage(2, 3),
);
let persisted = read_checkpoint_status_for(dir.path(), agent_id)
.unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
assert_eq!(
persisted, *expected,
"record_result({status:?}) must persist status={expected:?}; got {persisted:?}"
);
}
}
#[test]
fn record_result_timed_out_persists_with_underscore() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "record_result timed-out").unwrap();
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("p", None, 0);
journal.record_result(
&key,
agent_id,
0,
AgentStatus::TimedOut,
serde_json::json!(null),
vec![],
sample_token_usage(0, 0),
);
let persisted = read_checkpoint_status_for(dir.path(), agent_id).expect("status on disk");
assert_eq!(persisted, "timed_out");
assert_ne!(persisted, "timedout");
}
#[tokio::test]
async fn journal_callback_on_agent_done_persists_snake_case_status() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = std::sync::Arc::new(JournalStore::new(dir.path()).unwrap());
journal.init_run(run_id, "callback F5").unwrap();
let cases: Vec<(AgentStatus, &str)> = vec![
(AgentStatus::Ok, "ok"),
(AgentStatus::Error, "error"),
(AgentStatus::Cancelled, "cancelled"),
(AgentStatus::TimedOut, "timed_out"),
];
for (status, expected) in &cases {
let agent_id = uuid::Uuid::now_v7();
use crate::scheduler::JournalCallback;
journal
.on_agent_done(
agent_id,
1,
status.clone(),
serde_json::json!({}),
sample_token_usage(4, 6),
)
.await;
let persisted = read_checkpoint_status_for(dir.path(), agent_id)
.unwrap_or_else(|| panic!("status missing on disk for {status:?}"));
assert_eq!(
persisted, *expected,
"JournalCallback::on_agent_done({status:?}) must persist status={expected:?}; \
got {persisted:?}"
);
}
}
#[test]
fn record_result_then_reopen_uses_snake_case_status() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "reopen F5").unwrap();
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("reopen prompt", Some("gpt-4"), 1);
journal.record_result(
&key,
agent_id,
1,
AgentStatus::Cancelled,
serde_json::json!({"result": "ok"}),
vec![],
sample_token_usage(7, 11),
);
drop(journal);
let j2 = JournalStore::new(dir.path()).unwrap();
let cp = j2.open(run_id).expect("open after drop");
let cached = cp
.agent_results
.get(&agent_id)
.expect("entry survives reopen");
assert_eq!(
cached.status, "cancelled",
"snake_case status must round-trip through close+reopen"
);
assert_eq!(cached.tokens, 18);
}
#[test]
fn cache_agent_persists_snake_case_status_to_event_log() {
let dir = tempdir().unwrap();
let run_id = uuid::Uuid::now_v7();
let journal = JournalStore::new(dir.path()).unwrap();
journal.init_run(run_id, "event log F5").unwrap();
let agent_id = uuid::Uuid::now_v7();
let key = AgentCacheKey::new("p", None, 1);
journal
.cache_agent(
&key,
agent_id,
1,
AgentStatus::TimedOut,
serde_json::json!(null),
vec![],
sample_token_usage(1, 1),
)
.unwrap();
let log = journal.store().get_event_log().expect("read events.jsonl");
let agent_done = log
.iter()
.find_map(|e| match e {
AgentEvent::AgentDone {
agent_id: id,
status,
..
} if id == &agent_id => Some(status.clone()),
_ => None,
})
.expect("AgentDone event in log");
assert!(matches!(agent_done, AgentStatus::TimedOut));
}
}