test(hosts): verify ordinary restart and immutable Stop receipt recovery

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-31 16:32:25 -04:00
parent 146c84cfb7
commit 1839294971
5 changed files with 101 additions and 9 deletions

View File

@@ -238,3 +238,34 @@ fn destination_rejection_reports_source_stopped_never_moved_or_restarted() {
assert_eq!(f.store.journal.sent.len(), 2);
super::super::host_start::validate_journal(&f.store, &f.owner, RELAY).unwrap();
}
#[test]
fn renewed_stop_receipt_preserves_observation_and_releases_move_once() {
let mut f = Fixture::new();
f.receipt(Outcome::Stopped);
let intent = f.store.journal.moves[&f.id].clone();
let stop = f.store.journal.sent.get_mut(&intent.stop).unwrap();
let command = stop.command.clone();
let original = stop.receipt.clone().unwrap();
super::super::host_start_store::prepare_receipt(stop, &f.owner, &f.source, RELAY, &[], 1200)
.unwrap();
let renewed = stop.receipt.as_ref().unwrap();
assert_ne!(renewed.id, original.id);
assert_eq!(renewed.content, original.content);
assert_eq!(renewed.tags, original.tags);
assert_eq!(stop.command, command);
let req = request(&f.owner, stop, RELAY).unwrap();
let observation =
host_execution::decrypt_receipt(&f.owner, &stop.registration, renewed, &command, &req)
.unwrap();
assert_eq!(observation.observed_at, 110);
assert_eq!(observation.outcome, Outcome::Stopped);
f.store.save().unwrap();
drop(f.store);
f.store = Store::open_dir(f.dir.path(), &f.owner.public_key().to_hex(), RELAY).unwrap();
release_to_outbox(&mut f.store, &f.owner, RELAY, &f.id, &intent, 1201).unwrap();
let released = f.store.journal.moves[&f.id].clone();
assert!(release_to_outbox(&mut f.store, &f.owner, RELAY, &f.id, &released, 1202).is_err());
assert_eq!(f.store.journal.sent.len(), 2);
super::super::host_start::validate_journal(&f.store, &f.owner, RELAY).unwrap();
}

View File

@@ -347,7 +347,7 @@ pub(super) async fn trace(
"ordinary-stop-success.json",
&serde_json::to_value(peer_stop).map_err(|_| "Stop status")?,
)?;
exercise_ordinary_restart(app, root, &peer, relay_url, &peer_run)?;
exercise_ordinary_restart(app, root, &peer, relay_url, &peer_run).await?;
save(root, "move-finish", &json!({"done":true}))?;
cleanup(app)?;
return Ok(());
@@ -361,7 +361,7 @@ pub(super) async fn trace(
// Exercise the existing pair Start/Restart entry points, not a fixture-only
// ledger shortcut. A stale Stop retry must not label a live successor stopped.
fn exercise_ordinary_restart(
async fn exercise_ordinary_restart(
app: &tauri::AppHandle,
root: &Path,
peer: &str,
@@ -387,6 +387,12 @@ fn exercise_ordinary_restart(
{
return Err("old successful Stop retry misreported successor as stopped".into());
}
save(
root,
"ordinary-start-success.json",
&serde_json::to_value(&started).map_err(|_| "Start status")?,
)?;
wait_live_run(app, peer, relay_url, &started_run).await?;
let restarted = managed_agents::restart_managed_agent_runtime(
peer.into(),
relay_url.into(),
@@ -400,6 +406,7 @@ fn exercise_ordinary_restart(
if restarted_run == started_run || restarted.pid.is_none() {
return Err("ordinary Restart did not create a fresh runtime".into());
}
wait_live_run(app, peer, relay_url, &restarted_run).await?;
let stopped = managed_agents::stop_managed_agent_runtime(
peer.into(),
relay_url.into(),
@@ -416,6 +423,36 @@ fn exercise_ordinary_restart(
)
}
// Spawn is explicitly not Ready. Exercise the happy path only after observing
// this exact new runtime on the relay; an immediate startup Stop may fail closed.
async fn wait_live_run(
app: &tauri::AppHandle,
agent: &str,
relay_url: &str,
run: &str,
) -> Result<(), String> {
let state = app.state::<AppState>();
let owner = state.signing_keys()?.public_key().to_hex();
for _ in 0..60 {
let runs = commands::get_presence_runs(
state.clone(),
owner.clone(),
relay_url.into(),
vec![agent.into()],
)
.await?;
let now = nostr::Timestamp::now().as_secs();
if runs.get(agent).is_some_and(|runs| {
runs.iter()
.any(|r| r.run == run && r.status != "offline" && r.expires_at > now)
}) {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err("ordinary restart generation not observed live".into())
}
async fn pulse(
app: &tauri::AppHandle,
owner: &str,

View File

@@ -91,6 +91,10 @@ pub fn run() -> Result<(), String> {
.map_err(|_| "native app build failed")?;
let handle = app.handle().clone();
let relay = args[3].clone();
// Use run_return: Wry's non-returning run can discard AppHandle::exit's
// requested code. Success also requires that the trace actually completed.
let completed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let task_completed = completed.clone();
tauri::async_runtime::spawn(async move {
let result = if role.starts_with("move-") {
move_trace::trace(&handle, &role, &root, &relay).await
@@ -98,15 +102,22 @@ pub fn run() -> Result<(), String> {
trace(&handle, &role, &root, &relay).await
};
match result {
Ok(()) => handle.exit(0),
Ok(()) => {
task_completed.store(true, std::sync::atomic::Ordering::Release);
handle.exit(0);
}
Err(error) => {
eprintln!("Start tracer failed: {error}");
handle.exit(1);
}
}
});
app.run(|_, _| {});
Ok(())
let exit_code = app.run_return(|_, _| {});
if exit_code == 0 && completed.load(std::sync::atomic::Ordering::Acquire) {
Ok(())
} else {
Err("tracer did not complete successfully; inspect result files and logs".into())
}
}
fn keys(root: &Path, name: &str) -> Result<Keys, String> {

View File

@@ -117,8 +117,13 @@ export async function stopManagedAgentRuntime(
export async function restartManagedAgentRuntime(
pubkey: string,
relayUrl: string,
selectedRunId?: string | null,
): Promise<ManagedAgentRuntimeStatus> {
return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl });
return invokeTauri("restart_managed_agent_runtime", {
pubkey,
relayUrl,
selectedRunId,
});
}
export async function putManagedAgentRuntimeLifecycle(

View File

@@ -124,7 +124,10 @@ cannot be reported as stopped by replaying that earlier success.
An explicit ordinary Start after confirmed exact Stop, and the existing pair
Restart action, enter the same ledger for a fresh generation. Automatic reconcile
remains fenced. Provider `!shutdown` behavior is unchanged. Config-edit, delete
remains fenced. Spawn is not Ready: an immediate Stop during ACP pool initialization
can return RootExited, even when partial children were drained. It cannot certify
replacement; the actual tracer preserves this negative case rather than forcing
Stopped. Provider `!shutdown` behavior is unchanged. Config-edit, delete
and application cleanup retain their separate best-effort behavior and are not
certified Stop claims. No new run authority or provider control channel is added.
@@ -260,8 +263,13 @@ Cleanup reaped only the fixture's tracked root, not a certified Stop.
The 2026-08-31 Move tracer additionally completed confirmed selected-source Stop
followed by destination spawn with the same agent and matching fresh run/host.
The unrelated source peer survived Move and then completed its own ordinary Stop.
An old source token was rejected at the destination. Both executors exited 0;
fixture cleanup was not used as certification evidence.
An old source token was rejected at the destination. The expanded tracer also
exercised ordinary Start after confirmed Stop, waited for that exact new run's
signed live presence, then used ordinary Restart and finally its own confirmed
Stop. Retrying the earlier successful Stop could not report the successor stopped.
Both executors completed with the required result files; fixture cleanup was not
used as certification evidence. A debug tracer failure is checked from its result
and log as well as exit code (the tracer uses the returning native event loop).
Remaining gates include real-provider and second-physical-host validation,
actual Hosts native UI capture, authenticated asynchronous lifecycle receipt