mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use async_trait::async_trait;
use common::{
ScriptedModel, TestTool, ToolBehavior, agent_builder, fixed_clock, fixed_random, fixed_run_id,
text_response, tool_use_response,
};
use salvor_core::{Effect, EventEnvelope, ReplayError, RunId};
use salvor_runtime::{Agent, RunOutcome, Runtime, RuntimeError};
use salvor_store::{EventStore, RunSummary, SqliteStore, StoreError};
use serde_json::json;
use wiremock::MockServer;
struct KillStore {
inner: Arc<dyn EventStore>,
remaining: AtomicI64,
}
impl KillStore {
fn new(inner: Arc<dyn EventStore>, allow: i64) -> Self {
Self {
inner,
remaining: AtomicI64::new(allow),
}
}
}
#[async_trait]
impl EventStore for KillStore {
async fn append(&self, envelope: &EventEnvelope) -> Result<(), StoreError> {
if self.remaining.fetch_sub(1, Ordering::SeqCst) <= 0 {
return Err(StoreError::Backend("simulated crash".to_owned()));
}
self.inner.append(envelope).await
}
async fn read_log(&self, run_id: RunId) -> Result<Vec<EventEnvelope>, StoreError> {
self.inner.read_log(run_id).await
}
async fn list_runs(&self) -> Result<Vec<RunSummary>, StoreError> {
self.inner.list_runs().await
}
}
async fn scripted_server() -> MockServer {
ScriptedModel::mount(vec![
(
1,
tool_use_response("tu_read", "lookup", json!({"q": "otters"}), 100, 10),
),
(
3,
tool_use_response("tu_write", "publish", json!({"doc": "otters"}), 120, 12),
),
(5, text_response("published", 140, 14)),
])
.await
}
fn reference_agent(server_uri: &str) -> (Agent, Arc<AtomicUsize>, Arc<AtomicUsize>) {
let (read_tool, read_calls) = TestTool::new("lookup", Effect::Read, ToolBehavior::Echo);
let (write_tool, write_calls) = TestTool::new("publish", Effect::Write, ToolBehavior::Echo);
let agent = agent_builder(server_uri)
.tool_dyn(Box::new(read_tool))
.tool_dyn(Box::new(write_tool))
.build()
.expect("agent builds");
(agent, read_calls, write_calls)
}
async fn control_log(run_tag: u8) -> Vec<EventEnvelope> {
let server = scripted_server().await;
let (agent, read_calls, write_calls) = reference_agent(&server.uri());
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let runtime = Runtime::with_hooks(store.clone(), fixed_clock(), fixed_random());
let run_id = fixed_run_id(run_tag);
let outcome = runtime
.start_with_id(&agent, run_id, json!("publish otters"))
.await
.expect("control run completes");
assert!(matches!(
outcome,
RunOutcome::Completed { ref output, .. } if *output == json!("published")
));
assert_eq!(read_calls.load(Ordering::SeqCst), 1);
assert_eq!(write_calls.load(Ordering::SeqCst), 1);
let log = store.read_log(run_id).await.expect("log reads");
assert_eq!(log.len(), 15, "the reference run records 15 events");
log
}
async fn kill_then_recover(
run_tag: u8,
allow: i64,
) -> (Vec<EventEnvelope>, Arc<AtomicUsize>, Arc<AtomicUsize>) {
let server = scripted_server().await;
let (agent, read_calls, write_calls) = reference_agent(&server.uri());
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let run_id = fixed_run_id(run_tag);
let killed = Runtime::with_hooks(
Arc::new(KillStore::new(store.clone(), allow)),
fixed_clock(),
fixed_random(),
);
let error = killed
.start_with_id(&agent, run_id, json!("publish otters"))
.await
.expect_err("the kill store aborts the drive");
assert!(matches!(error, RuntimeError::Store(_)), "{error}");
let interrupted = store.read_log(run_id).await.expect("log reads");
assert_eq!(
interrupted.len(),
usize::try_from(allow).unwrap(),
"exactly the budgeted number of events persisted"
);
let recovering = Runtime::with_hooks(store.clone(), fixed_clock(), fixed_random());
let outcome = recovering
.recover(&agent, run_id)
.await
.expect("recovery completes the run");
assert!(matches!(
outcome,
RunOutcome::Completed { ref output, .. } if *output == json!("published")
));
let log = store.read_log(run_id).await.expect("log reads");
(log, read_calls, write_calls)
}
#[tokio::test]
async fn kill_after_model_intent_recovers_identically() {
let control = control_log(10).await;
let (log, read_calls, write_calls) = kill_then_recover(10, 3).await;
assert_eq!(log, control, "the recovered log equals the control log");
assert_eq!(read_calls.load(Ordering::SeqCst), 1);
assert_eq!(write_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn kill_after_read_intent_recovers_identically() {
let control = control_log(11).await;
let (log, read_calls, write_calls) = kill_then_recover(11, 5).await;
assert_eq!(log, control, "the recovered log equals the control log");
assert_eq!(read_calls.load(Ordering::SeqCst), 2);
assert_eq!(write_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn kill_after_write_completion_recovers_identically() {
let control = control_log(12).await;
let (log, read_calls, write_calls) = kill_then_recover(12, 11).await;
assert_eq!(log, control, "the recovered log equals the control log");
assert_eq!(read_calls.load(Ordering::SeqCst), 1);
assert_eq!(write_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn kill_after_write_intent_refuses_recovery() {
let server = scripted_server().await;
let (agent, _read_calls, write_calls) = reference_agent(&server.uri());
let store = Arc::new(SqliteStore::in_memory().expect("store opens"));
let run_id = fixed_run_id(13);
let killed = Runtime::with_hooks(
Arc::new(KillStore::new(store.clone(), 10)),
fixed_clock(),
fixed_random(),
);
killed
.start_with_id(&agent, run_id, json!("publish otters"))
.await
.expect_err("the kill store aborts the drive");
assert_eq!(write_calls.load(Ordering::SeqCst), 1, "the write ran once");
let recovering = Runtime::with_hooks(store.clone(), fixed_clock(), fixed_random());
let error = recovering
.recover(&agent, run_id)
.await
.expect_err("a dangling write intent refuses recovery");
match error {
RuntimeError::Replay(ReplayError::NeedsReconciliation { tool, input, .. }) => {
assert_eq!(tool, "publish");
assert_eq!(input, json!({"doc": "otters"}));
}
other => panic!("expected NeedsReconciliation, got {other:?}"),
}
assert_eq!(
write_calls.load(Ordering::SeqCst),
1,
"recovery executed nothing"
);
}