mod common;
use agentd::config::v2 as cfg;
use agentd::mcp::client::McpClient;
use agentd::state::{Durable, InboxEvent, Kind, Policy, TimerRecord};
use agentd::store::{self, SharedStore, StoreError};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
fn connect(mock: &common::MockMcp) -> Arc<McpClient> {
let mut c =
McpClient::connect("ckpt", &mock.uri(), vec![], Duration::from_secs(5)).expect("connect");
c.initialize().expect("initialize");
Arc::new(c)
}
fn open_store(client: Arc<McpClient>) -> SharedStore {
let settings: cfg::Store = serde_json::from_value(json!({
"kind": "mcp",
"mcp": {"server": "ckpt"},
"timeout": "5s",
}))
.unwrap();
let advertised: Vec<String> = client
.list_tools()
.unwrap()
.into_iter()
.map(|t| t.name)
.collect();
assert!(
advertised.iter().any(|t| t == "state.delete"),
"mock advertises the full profile: {advertised:?}"
);
let c2 = client.clone();
let s = store::open(&settings, &move |name: &str| {
(name == "ckpt").then(|| c2.clone() as Arc<dyn store::mcp::McpCall>)
})
.expect("open")
.expect("a store");
assert_eq!(s.kind(), "mcp");
s
}
fn control(client: &McpClient, tool: &str, args: Value) -> Value {
let res = client
.call_tool_with_meta_within(tool, Some(args), json!({}), Duration::from_secs(5))
.expect("control call");
serde_json::from_str(&res.text()).unwrap_or(Value::Null)
}
fn durable(store: SharedStore, on_error: cfg::StoreOnError) -> Durable {
Durable::new(
store,
"agentd",
"e2e-inst",
Policy {
debounce: Duration::from_millis(0),
on_error,
retries: 3,
},
None,
)
}
#[test]
fn durable_state_round_trips_and_restores_over_http_mcp() {
let mock = common::spawn_mock_mcp("mock://noop", false);
let client = connect(&mock);
let (e_pending_id, e_done_id) = {
let d = durable(open_store(client.clone()), cfg::StoreOnError::Halt);
let r = d.restore().expect("fresh restore");
assert!(r.manifest.is_none(), "fresh instance");
assert_eq!(d.manifest().generation, 1);
assert_eq!(
d.put(
Kind::Run,
"r1",
json!({"status": "running", "step": 1}),
Some("h1".into())
)
.unwrap(),
1
);
assert_eq!(
d.put(
Kind::Run,
"r1",
json!({"status": "running", "step": 2}),
Some("h1".into())
)
.unwrap(),
2
);
assert_eq!(
d.put(Kind::Context, "root", json!({"messages": ["hi"]}), None)
.unwrap(),
1
);
assert_eq!(
d.put(Kind::Task, "task-1", json!({"state": "working"}), None)
.unwrap(),
1
);
let pending = InboxEvent::new(
"a2a_message",
Some("user:alice".into()),
json!({"text": "hello"}),
);
let done = InboxEvent::new(
"start_fired",
None,
json!({"workflow": "w", "node": "tick"}),
);
d.inbox_put(&pending).unwrap();
d.inbox_put(&done).unwrap();
d.inbox_done(&done.id).unwrap();
d.timer_arm(&TimerRecord {
id: "t-1".into(),
deadline_ms: 4_102_444_800_000,
owner: json!({"run": "r1", "node": "sleep"}),
payload: json!({"reason": "sleep"}),
})
.unwrap();
d.manifest_update(|m| {
m.starts
.insert("w.tick".into(), json!({"last_fired": 17, "iteration": 3}));
m.budget = json!({"tokens": {"day": 1234}});
});
assert!(d.flush(true).unwrap());
assert_eq!(
d.put(Kind::Artifact, "art-1", json!({"mime": "text/plain"}), None)
.unwrap(),
1
);
let env = d.get(Kind::Run, "r1").unwrap().unwrap();
assert_eq!(env.seq, 2);
assert_eq!(env.state["step"], json!(2));
assert_eq!(env.instance, "e2e-inst");
assert_eq!(env.hash.as_deref(), Some("h1"));
(pending.id, done.id)
};
let ops = control(&client, "mock.ops", json!({}));
let ops: Vec<String> = ops["ops"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(ops.iter().any(|o| o == "state.put"));
assert!(ops.iter().any(|o| o == "state.get"));
assert!(
ops.iter().any(|o| o == "state.delete"),
"inbox_done deletes over the wire: {ops:?}"
);
let d2 = durable(open_store(client.clone()), cfg::StoreOnError::Halt);
let r = d2.restore().expect("restore");
let m = r.manifest.as_ref().expect("manifest");
assert_eq!(m.generation, 2);
assert_eq!(m.starts["w.tick"]["iteration"], json!(3));
assert_eq!(m.budget["tokens"]["day"], json!(1234));
assert!(r.lost.is_empty(), "nothing lost: {:?}", r.lost);
let pending = r.inbox_pending();
assert_eq!(pending.len(), 1, "only the un-acked event replays");
assert_eq!(pending[0].id, e_pending_id);
assert_eq!(pending[0].principal.as_deref(), Some("user:alice"));
assert!(
!r.of(Kind::Inbox).iter().any(|e| e.id == e_done_id),
"the done event is gone"
);
assert_eq!(r.timers().len(), 1);
assert_eq!(r.timers()[0].owner["node"], json!("sleep"));
assert_eq!(r.of(Kind::Run).len(), 1);
assert_eq!(r.of(Kind::Run)[0].seq, 2);
assert_eq!(r.of(Kind::Context).len(), 1);
assert_eq!(r.of(Kind::Task).len(), 1);
assert!(
r.unindexed
.iter()
.any(|u| u.kind == "artifact" && u.id == "art-1"),
"list found the unflushed artifact: {:?}",
r.unindexed
);
assert_eq!(r.of(Kind::Artifact).len(), 1);
assert_eq!(
d2.put(
Kind::Run,
"r1",
json!({"status": "done"}),
Some("h1".into())
)
.unwrap(),
3
);
assert_eq!(
d2.put(
Kind::Artifact,
"art-1",
json!({"mime": "text/plain", "size": 3}),
None
)
.unwrap(),
2
);
d2.inbox_done(&e_pending_id).unwrap();
d2.flush(true).unwrap();
let d_stale = durable(open_store(client.clone()), cfg::StoreOnError::Halt);
assert_eq!(
d_stale
.put(Kind::Run, "r1", json!({"status": "stale?"}), None)
.unwrap(),
4
);
assert!(matches!(
d2.put(Kind::Run, "r1", json!({"status": "x"}), None),
Err(StoreError::Conflict(_))
));
let d3 = durable(open_store(client.clone()), cfg::StoreOnError::Halt);
let r3 = d3.restore().unwrap();
assert_eq!(r3.manifest.as_ref().unwrap().generation, 3);
assert!(r3.inbox_pending().is_empty());
}
#[test]
fn transient_store_faults_are_retried_and_persistent_ones_follow_policy() {
let mock = common::spawn_mock_mcp("mock://noop", false);
let client = connect(&mock);
let d = durable(open_store(client.clone()), cfg::StoreOnError::Halt);
d.restore().unwrap();
control(&client, "mock.fault", json!({"count": 2}));
assert_eq!(d.put(Kind::Run, "r", json!({"n": 1}), None).unwrap(), 1);
assert!(!d.is_degraded());
control(&client, "mock.fault", json!({"count": 10}));
assert!(matches!(
d.put(Kind::Run, "r", json!({"n": 2}), None),
Err(StoreError::Io(_))
));
control(&client, "mock.fault", json!({"count": 0}));
let dd = durable(open_store(client.clone()), cfg::StoreOnError::Degrade);
dd.restore().unwrap();
control(&client, "mock.fault", json!({"count": 10}));
assert_eq!(dd.put(Kind::Run, "q", json!({"n": 1}), None).unwrap(), 1);
assert!(dd.is_degraded());
control(&client, "mock.fault", json!({"count": 0}));
assert_eq!(dd.put(Kind::Run, "q", json!({"n": 2}), None).unwrap(), 2);
assert!(!dd.is_degraded(), "a successful write clears the flag");
let env = dd.get(Kind::Run, "q").unwrap().unwrap();
assert_eq!(env.seq, 2);
assert_eq!(env.state["n"], json!(2));
}
#[test]
fn a_kill_between_inbox_put_and_ack_replays_the_event_after_restart() {
const KILL_AT: &str = "inbox.after_put";
if std::env::var("AGENTD_TEST_KILL_AT").as_deref() == Ok(KILL_AT) {
let uri = std::env::var("AGENTD_TEST_MOCK_URI").expect("mock uri");
let mut c = McpClient::connect("ckpt", &uri, vec![], Duration::from_secs(5)).unwrap();
c.initialize().unwrap();
let d = durable(open_store(Arc::new(c)), cfg::StoreOnError::Halt);
d.restore().unwrap();
let ev = InboxEvent::new(
"a2a_message",
Some("user:bob".into()),
json!({"text": "do the thing"}),
);
d.inbox_put(&ev).unwrap(); d.inbox_done(&ev.id).unwrap(); panic!("the kill point did not fire");
}
let mock = common::spawn_mock_mcp("mock://noop", false);
let exe = std::env::current_exe().unwrap();
let status = std::process::Command::new(exe)
.args([
"--exact",
"a_kill_between_inbox_put_and_ack_replays_the_event_after_restart",
"--nocapture",
])
.env("AGENTD_TEST_KILL_AT", KILL_AT)
.env("AGENTD_TEST_MOCK_URI", mock.uri())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("spawn child life");
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(
status.signal(),
Some(libc::SIGKILL),
"the child must die at the kill point: {status:?}"
);
}
let client = connect(&mock);
let d2 = durable(open_store(client.clone()), cfg::StoreOnError::Halt);
let r = d2.restore().unwrap();
assert_eq!(r.manifest.as_ref().unwrap().generation, 2);
let pending = r.inbox_pending();
assert_eq!(
pending.len(),
1,
"accepted ⇒ durable, even though the process died before acking"
);
assert_eq!(pending[0].payload["text"], json!("do the thing"));
assert_eq!(pending[0].principal.as_deref(), Some("user:bob"));
d2.inbox_done(&pending[0].id).unwrap();
let d3 = durable(open_store(client), cfg::StoreOnError::Halt);
assert!(d3.restore().unwrap().inbox_pending().is_empty());
}