mod common;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use common::{GateModel, count_lines, run_salvor, text_response, tool_use_response, write_agent};
use salvor_core::{Event, RunId};
use salvor_store::{EventStore, SqliteStore};
use serde_json::json;
use tempfile::tempdir;
const SALVOR_BIN: &str = env!("CARGO_BIN_EXE_salvor");
#[tokio::test]
async fn kill_mid_run_then_resume_completes_with_no_duplicate_write() {
let dir = tempdir().expect("tempdir");
let store_path = dir.path().join("salvor.db");
let count_file = dir.path().join("count.txt");
let released = Arc::new(AtomicBool::new(false));
let model = GateModel::mount_gated(
vec![
(
1,
tool_use_response("tu_write", "record", json!({"line": "otters"}), 100, 20),
),
(3, text_response("published", 150, 30)),
],
Some(3),
released.clone(),
Duration::from_secs(30),
)
.await;
let agent = write_agent(dir.path(), &model.uri(), &count_file, "");
let agent_path = agent.to_str().unwrap().to_owned();
let mut child = Command::new(SALVOR_BIN)
.args(["--store", store_path.to_str().unwrap()])
.args([
"run",
"--agent",
&agent_path,
"--input",
"\"publish otters\"",
])
.env("RUST_LOG", "off")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn salvor run");
let store: Arc<dyn EventStore> =
Arc::new(SqliteStore::open(&store_path).expect("test store opens"));
let run_id: RunId = {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let summaries = store.list_runs().await.expect("list runs");
if let Some(summary) = summaries.into_iter().next() {
break summary.run_id;
}
assert!(Instant::now() < deadline, "no run started in time");
tokio::time::sleep(Duration::from_millis(20)).await;
}
};
{
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let log = store.read_log(run_id).await.expect("read log");
if log
.iter()
.any(|env| matches!(env.event, Event::ToolCallCompleted { .. }))
{
break;
}
assert!(Instant::now() < deadline, "write did not complete in time");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
assert_eq!(
count_lines(&count_file),
1,
"the write executed once before the kill"
);
child.kill().expect("kill the run process");
child.wait().expect("reap the killed process");
released.store(true, Ordering::SeqCst);
let resume = run_salvor(
&store_path,
&[
"resume",
&run_id.as_uuid().to_string(),
"--agent",
&agent_path,
],
)
.await;
let resume_out = String::from_utf8_lossy(&resume.stdout);
assert!(
resume.status.success(),
"resume completes the run: {resume:?}"
);
assert!(
resume_out.contains("published"),
"final output: {resume_out}"
);
assert_eq!(
count_lines(&count_file),
1,
"resume must not re-execute the recorded write"
);
}