mod common;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use common::{
ScriptedModel, TestTool, ToolBehavior, agent_builder, event_kinds, fixed_clock, fixed_random,
fixed_run_id, text_response, tool_use_response,
};
use salvor_core::{Effect, Event, EventEnvelope, ReplayError, RunId, RunStatus, derive_state};
use salvor_runtime::{Agent, Budgets, Runtime, RuntimeError};
use salvor_store::{EventStore, SqliteStore};
use salvor_tools::Suspension;
use serde_json::{Value, json};
use wiremock::MockServer;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Turn {
Search,
StoreDoc,
Publish,
Approval,
Final,
}
const TURNS: [Turn; 20] = [
Turn::Search, Turn::StoreDoc, Turn::Publish, Turn::Approval, Turn::Search, Turn::StoreDoc, Turn::Search, Turn::Publish, Turn::Search, Turn::StoreDoc, Turn::Publish, Turn::Search, Turn::StoreDoc, Turn::Search, Turn::Publish, Turn::Search, Turn::StoreDoc, Turn::Search, Turn::Publish, Turn::Final, ];
const MAX_STEPS: u64 = 10;
const BUDGET_TURN: usize = 11;
const CONTROL_WRITE_EXECUTIONS: usize = 5;
fn run_input() -> Value {
json!("survey the archived research notes")
}
fn approval_schema() -> Value {
json!({"type": "object", "required": ["approved"]})
}
fn approval_input() -> Value {
json!({"approved": true})
}
fn extension_input() -> Value {
json!({"extend": {"steps": 100}})
}
async fn scripted_server() -> MockServer {
let mut script = Vec::with_capacity(TURNS.len());
for (index, turn) in TURNS.iter().enumerate() {
let number = index + 1;
let messages = 2 * number - 1;
let id = format!("tu_{number:02}");
let input_tokens = (100 + number) as u64;
let output_tokens = (10 + number) as u64;
let response = match turn {
Turn::Search => tool_use_response(
&id,
"search",
json!({"q": format!("subtopic {number}")}),
input_tokens,
output_tokens,
),
Turn::StoreDoc => tool_use_response(
&id,
"store_doc",
json!({"doc": format!("note {number}")}),
input_tokens,
output_tokens,
),
Turn::Publish => tool_use_response(
&id,
"publish",
json!({"finding": format!("finding {number}")}),
input_tokens,
output_tokens,
),
Turn::Approval => {
tool_use_response(&id, "approval", json!({}), input_tokens, output_tokens)
}
Turn::Final => text_response("research complete", input_tokens, output_tokens),
};
script.push((messages, response));
}
ScriptedModel::mount(script).await
}
struct Counters {
search: Arc<AtomicUsize>,
store_doc: Arc<AtomicUsize>,
publish: Arc<AtomicUsize>,
approval: Arc<AtomicUsize>,
}
impl Counters {
fn snapshot(&self) -> ExecutionCounts {
ExecutionCounts {
search: self.search.load(Ordering::SeqCst),
store_doc: self.store_doc.load(Ordering::SeqCst),
publish: self.publish.load(Ordering::SeqCst),
approval: self.approval.load(Ordering::SeqCst),
}
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ExecutionCounts {
search: usize,
store_doc: usize,
publish: usize,
approval: usize,
}
impl ExecutionCounts {
fn total(&self) -> usize {
self.search + self.store_doc + self.publish + self.approval
}
fn slot(&mut self, tool: &str) -> &mut usize {
match tool {
"search" => &mut self.search,
"store_doc" => &mut self.store_doc,
"publish" => &mut self.publish,
"approval" => &mut self.approval,
other => panic!("unexpected tool in the log: {other}"),
}
}
}
fn reference_agent(server_uri: &str) -> (Agent, Counters) {
let (search, search_calls) = TestTool::new("search", Effect::Read, ToolBehavior::Echo);
let (store_doc, store_calls) =
TestTool::new("store_doc", Effect::Idempotent, ToolBehavior::Echo);
let (publish, publish_calls) = TestTool::new("publish", Effect::Write, ToolBehavior::Echo);
let (approval, approval_calls) = TestTool::new(
"approval",
Effect::Read,
ToolBehavior::Suspend(Suspension {
reason: "awaiting reviewer approval".to_owned(),
input_schema: approval_schema(),
}),
);
let agent = agent_builder(server_uri)
.tool_dyn(Box::new(search))
.tool_dyn(Box::new(store_doc))
.tool_dyn(Box::new(publish))
.tool_dyn(Box::new(approval))
.budgets(Budgets {
max_steps: Some(MAX_STEPS),
..Budgets::default()
})
.build()
.expect("the reference agent builds");
(
agent,
Counters {
search: search_calls,
store_doc: store_calls,
publish: publish_calls,
approval: approval_calls,
},
)
}
fn expected_control_kinds() -> Vec<&'static str> {
let mut kinds = vec!["RunStarted"];
for (index, turn) in TURNS.iter().enumerate() {
let number = index + 1;
kinds.push("NowObserved");
if number == BUDGET_TURN {
kinds.extend(["BudgetExceeded", "Resumed"]);
}
kinds.extend(["ModelCallRequested", "ModelCallCompleted"]);
match turn {
Turn::Search | Turn::Publish => {
kinds.extend(["ToolCallRequested", "ToolCallCompleted"]);
}
Turn::StoreDoc => {
kinds.extend(["RandomObserved", "ToolCallRequested", "ToolCallCompleted"]);
}
Turn::Approval => {
kinds.extend([
"ToolCallRequested",
"ToolCallCompleted", "Suspended",
"Resumed",
]);
}
Turn::Final => kinds.push("RunCompleted"),
}
}
kinds
}
async fn drive_to_completion(
runtime: &Runtime,
agent: &Agent,
run_id: RunId,
store: &Arc<dyn EventStore>,
control: Option<&[EventEnvelope]>,
) {
for _ in 0..16 {
let log = store.read_log(run_id).await.expect("log reads");
let state = derive_state(&log);
match &state.status {
RunStatus::NotStarted => {
runtime
.start_with_id(agent, run_id, run_input())
.await
.expect("the fresh run drives");
}
RunStatus::Completed { .. } => return,
RunStatus::Suspended { .. } => {
let input = approval_input();
if let Some(control) = control {
assert_recorded_resume(control, log.len(), &input);
}
runtime
.resume(agent, run_id, input)
.await
.expect("the suspension resumes");
}
RunStatus::BudgetExceeded { .. } => {
let input = extension_input();
if let Some(control) = control {
assert_recorded_resume(control, log.len(), &input);
}
runtime
.resume(agent, run_id, input)
.await
.expect("the budget crossing resumes with an extension");
}
RunStatus::Failed { error } => panic!("the run failed: {error}"),
RunStatus::NeedsReconciliation => {
panic!("reconciliation must be handled before dispatch")
}
_ => {
runtime
.recover(agent, run_id)
.await
.expect("the crashed run recovers");
}
}
}
panic!("the run did not complete within the action cap");
}
fn assert_recorded_resume(control: &[EventEnvelope], position: usize, input: &Value) {
match &control[position].event {
Event::Resumed { input: recorded } => assert_eq!(
recorded, input,
"the resume input must be the recorded one (control position {position})"
),
other => panic!("expected Resumed at control position {position}, found {other:?}"),
}
}
fn ends_at_dangling_write_intent(prefix: &[EventEnvelope]) -> bool {
matches!(
prefix.last().map(|envelope| &envelope.event),
Some(Event::ToolCallRequested {
effect: Effect::Write,
..
})
)
}
fn expected_executions(control: &[EventEnvelope], cut: usize) -> ExecutionCounts {
let mut counts = ExecutionCounts::default();
for (index, envelope) in control.iter().enumerate() {
let Event::ToolCallRequested { tool, effect, .. } = &envelope.event else {
continue;
};
let runs = if index >= cut {
1
} else if index + 1 == cut && !matches!(effect, Effect::Write) {
1
} else {
0
};
*counts.slot(tool) += runs;
}
counts
}
fn prefix_write_executions(control: &[EventEnvelope], cut: usize) -> usize {
control[..cut]
.iter()
.enumerate()
.filter(|(index, envelope)| {
matches!(envelope.event, Event::ToolCallCompleted { .. })
&& matches!(
control.get(index.wrapping_sub(1)).map(|e| &e.event),
Some(Event::ToolCallRequested {
effect: Effect::Write,
..
})
)
})
.count()
}
struct SweepDir(PathBuf);
impl SweepDir {
fn create() -> Self {
let path = std::env::temp_dir().join(format!("salvor-release-gate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("sweep dir creates");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for SweepDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn record_control(server_uri: &str, dir: &Path) -> Vec<EventEnvelope> {
let store: Arc<dyn EventStore> =
Arc::new(SqliteStore::open(dir.join("control.db")).expect("control store opens"));
let (agent, counters) = reference_agent(server_uri);
let runtime = Runtime::with_hooks(store.clone(), fixed_clock(), fixed_random());
let run_id = fixed_run_id(80);
drive_to_completion(&runtime, &agent, run_id, &store, None).await;
let log = store.read_log(run_id).await.expect("control log reads");
assert_eq!(
event_kinds(&log),
expected_control_kinds(),
"the control run's shape matches the turn plan"
);
assert_eq!(
counters.snapshot(),
ExecutionCounts {
search: 8,
store_doc: 5,
publish: 5,
approval: 1,
},
"the control run executes every tool exactly once per call"
);
log
}
async fn continue_from_boundary(
cut: usize,
control: Arc<Vec<EventEnvelope>>,
server_uri: Arc<String>,
db_path: PathBuf,
) -> bool {
let store: Arc<dyn EventStore> =
Arc::new(SqliteStore::open(&db_path).expect("boundary store opens"));
for envelope in &control[..cut] {
store.append(envelope).await.expect("prefix event appends");
}
let (agent, counters) = reference_agent(&server_uri);
let runtime = Runtime::with_hooks(store.clone(), fixed_clock(), fixed_random());
let run_id = fixed_run_id(80);
if ends_at_dangling_write_intent(&control[..cut]) {
assert_eq!(
derive_state(&control[..cut]).status,
RunStatus::NeedsReconciliation,
"cut {cut}: a dangling write intent derives to reconciliation"
);
let error = runtime
.recover(&agent, run_id)
.await
.expect_err("recovery over a dangling write intent must refuse");
assert!(
matches!(
error,
RuntimeError::Replay(ReplayError::NeedsReconciliation { .. })
),
"cut {cut}: expected the reconciliation error, got {error:?}"
);
assert_eq!(
counters.snapshot().total(),
0,
"cut {cut}: a refused recovery executes nothing"
);
return true;
}
if cut == control.len() {
runtime
.recover(&agent, run_id)
.await
.expect("full-log replay is divergence free");
} else {
drive_to_completion(&runtime, &agent, run_id, &store, Some(&control)).await;
}
let log = store.read_log(run_id).await.expect("final log reads");
assert_eq!(
log, *control,
"cut {cut}: the continued log must equal the control log exactly"
);
let expected = expected_executions(&control, cut);
assert_eq!(
counters.snapshot(),
expected,
"cut {cut}: live executions must match the re-execution table"
);
assert_eq!(
prefix_write_executions(&control, cut) + counters.publish.load(Ordering::SeqCst),
CONTROL_WRITE_EXECUTIONS,
"cut {cut}: write executions across the whole scenario must total the control count"
);
false
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn release_gate_kill_at_every_event_boundary_resumes_identically() {
let started = Instant::now();
let dir = SweepDir::create();
let server = scripted_server().await;
let control = Arc::new(record_control(&server.uri(), dir.path()).await);
assert_eq!(control.len(), 109, "the reference run records 109 events");
let server_uri = Arc::new(server.uri());
let mut boundaries = tokio::task::JoinSet::new();
for cut in 0..=control.len() {
let control = control.clone();
let server_uri = server_uri.clone();
let db_path = dir.path().join(format!("boundary_{cut:03}.db"));
boundaries
.spawn(async move { continue_from_boundary(cut, control, server_uri, db_path).await });
}
let mut refused = 0;
let mut completed = 0;
while let Some(result) = boundaries.join_next().await {
match result {
Ok(was_refusal) => {
refused += usize::from(was_refusal);
completed += 1;
}
Err(error) => std::panic::resume_unwind(error.into_panic()),
}
}
assert_eq!(completed, control.len() + 1, "every boundary was swept");
assert_eq!(
refused, CONTROL_WRITE_EXECUTIONS,
"exactly the five write-intent prefixes refuse; every other boundary completes"
);
eprintln!(
"release gate: {} boundaries swept in {:.2?}",
completed,
started.elapsed()
);
}