mod common;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use common::{
CountBehavior, ScriptedModel, SseReader, TestServer, agent_factory, app_state, counter,
get_json, open_store, post_json, register_agent, sample_toml, text_response, tool_use_response,
};
use reqwest::StatusCode;
use salvor_core::{Effect, Event, EventEnvelope, RunId};
use salvor_store::EventStore;
use serde_json::json;
use tempfile::tempdir;
async fn wait_for(
store: &Arc<dyn EventStore>,
run_id: RunId,
label: &str,
predicate: impl Fn(&[EventEnvelope]) -> bool,
) {
for _ in 0..600 {
let log = store.read_log(run_id).await.expect("read log");
if predicate(&log) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting for: {label}");
}
fn has_kind(log: &[EventEnvelope], matcher: impl Fn(&Event) -> bool) -> bool {
log.iter().any(|envelope| matcher(&envelope.event))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn killed_run_recovers_on_a_fresh_server_with_no_duplicate_write() {
let dir = tempdir().unwrap();
let path = dir.path().join("salvor.db");
let model = ScriptedModel::mount(vec![
(
1,
tool_use_response("tu_1", "record", json!({"line": "otters"}), 100, 20),
None,
),
(
3,
text_response("all done", 150, 30),
Some(Duration::from_millis(600)),
),
])
.await;
let calls = counter();
let factory = agent_factory(
model.uri(),
"record",
Effect::Write,
CountBehavior::Record,
calls.clone(),
);
let first = TestServer::spawn(app_state(open_store(&path), factory.clone())).await;
let client = reqwest::Client::new();
let agent = register_agent(&client, &first.base, sample_toml(), None).await;
let (_, body) = post_json(
&client,
&format!("{}/v1/runs", first.base),
json!({ "agent": agent, "input": "research otters" }),
None,
)
.await;
let run_id_text = body["run"].as_str().unwrap().to_owned();
let run_id = RunId::from_uuid(uuid::Uuid::parse_str(&run_id_text).unwrap());
wait_for(
&first.state.store(),
run_id,
"the write to complete",
|log| {
has_kind(log, |event| {
matches!(event, Event::ToolCallCompleted { .. })
})
},
)
.await;
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the write ran once before the kill"
);
first.state.abort_all();
drop(first);
let second = TestServer::spawn(app_state(open_store(&path), factory.clone())).await;
let agent_again = register_agent(&client, &second.base, sample_toml(), None).await;
assert_eq!(agent, agent_again, "the re-registered hash is unchanged");
let (status, _) = post_json(
&client,
&format!("{}/v1/runs/{run_id_text}/resume", second.base),
json!({}),
None,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED, "recover accepted");
let mut reader = SseReader::open(&client, &second.base, &run_id_text, None, None, None).await;
let frames = reader.read_to_end().await;
assert_eq!(
frames.last().unwrap().json()["status"]["state"],
"completed",
"the recovered run completes"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"recover replayed the write, it never re-executed"
);
let log = second.state.store().read_log(run_id).await.unwrap();
let completions = log
.iter()
.filter(|envelope| matches!(envelope.event, Event::ToolCallCompleted { .. }))
.count();
assert_eq!(completions, 1, "exactly one recorded tool completion");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dangling_write_is_refused_then_resolved() {
let dir = tempdir().unwrap();
let path = dir.path().join("salvor.db");
let model = ScriptedModel::mount(vec![(
1,
tool_use_response("tu_1", "charge", json!({"amount": 10}), 40, 4),
None,
)])
.await;
let calls = counter();
let factory = agent_factory(
model.uri(),
"charge",
Effect::Write,
CountBehavior::Hang,
calls,
);
let first = TestServer::spawn(app_state(open_store(&path), factory.clone())).await;
let client = reqwest::Client::new();
let agent = register_agent(&client, &first.base, sample_toml(), None).await;
let (_, body) = post_json(
&client,
&format!("{}/v1/runs", first.base),
json!({ "agent": agent, "input": "charge the card" }),
None,
)
.await;
let run_id_text = body["run"].as_str().unwrap().to_owned();
let run_id = RunId::from_uuid(uuid::Uuid::parse_str(&run_id_text).unwrap());
wait_for(&first.state.store(), run_id, "the write intent", |log| {
has_kind(log, |event| {
matches!(
event,
Event::ToolCallRequested {
effect: Effect::Write,
..
}
)
})
})
.await;
first.state.abort_all();
drop(first);
let second = TestServer::spawn(app_state(open_store(&path), factory.clone())).await;
register_agent(&client, &second.base, sample_toml(), None).await;
let (status, refusal) = post_json(
&client,
&format!("{}/v1/runs/{run_id_text}/resume", second.base),
json!({}),
None,
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "resume refuses: {refusal}");
assert_eq!(refusal["error"]["code"], "needs_reconciliation");
let intent = &refusal["error"]["details"]["intent"];
assert_eq!(intent["tool"], "charge");
assert_eq!(intent["effect"], "write");
assert!(intent["seq"].is_number(), "intent names its seq");
assert!(
intent["recorded_at"].is_string(),
"intent carries when it was recorded"
);
let (status, resolved) = post_json(
&client,
&format!("{}/v1/runs/{run_id_text}/resolve", second.base),
json!({ "output": { "charged": true } }),
None,
)
.await;
assert_eq!(
status,
StatusCode::OK,
"resolve records the completion: {resolved}"
);
assert_eq!(resolved["resolved"], true);
assert_eq!(resolved["status"]["state"], "running");
let (status, _) = post_json(
&client,
&format!("{}/v1/runs/{run_id_text}/resolve", second.base),
json!({ "output": {} }),
None,
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "second resolve refused");
let (_, run) = get_json(
&client,
&format!("{}/v1/runs/{run_id_text}", second.base),
None,
)
.await;
assert_ne!(run["status"]["state"], "needs_reconciliation");
}