diff --git a/desktop/src-tauri/src/commands/host_move_tests.rs b/desktop/src-tauri/src/commands/host_move_tests.rs index 93038c4641..42fe37dce9 100644 --- a/desktop/src-tauri/src/commands/host_move_tests.rs +++ b/desktop/src-tauri/src/commands/host_move_tests.rs @@ -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(); +} diff --git a/desktop/src-tauri/src/host_move_tracer.rs b/desktop/src-tauri/src/host_move_tracer.rs index fbada8200a..017b1bd8b7 100644 --- a/desktop/src-tauri/src/host_move_tracer.rs +++ b/desktop/src-tauri/src/host_move_tracer.rs @@ -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::(); + 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, diff --git a/desktop/src-tauri/src/host_start_tracer.rs b/desktop/src-tauri/src/host_start_tracer.rs index ee69da67d4..df63d9d93d 100644 --- a/desktop/src-tauri/src/host_start_tracer.rs +++ b/desktop/src-tauri/src/host_start_tracer.rs @@ -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 { diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 838d908df0..12d1ced43f 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -117,8 +117,13 @@ export async function stopManagedAgentRuntime( export async function restartManagedAgentRuntime( pubkey: string, relayUrl: string, + selectedRunId?: string | null, ): Promise { - return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl }); + return invokeTauri("restart_managed_agent_runtime", { + pubkey, + relayUrl, + selectedRunId, + }); } export async function putManagedAgentRuntimeLifecycle( diff --git a/docs/host-execution.md b/docs/host-execution.md index 2fb34014ae..b83ccb9eb7 100644 --- a/docs/host-execution.md +++ b/docs/host-execution.md @@ -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