From ae480852a9ee00e9965391331c6b9837e9a1e342 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 15:59:26 -0400 Subject: [PATCH] feat(desktop): fence automatic launches after remote Stop Bind explicit Start permission before preflight and release it only after child registration. Reuse ordinary platform-specific pair Stop and refuse success for an unscoped live legacy child. Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/agents.rs | 62 +++++++-- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/remote_stop.rs | 131 ++++++++++++++++++ .../src-tauri/src/managed_agents/restore.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 10 +- .../src/managed_agents/runtime/stop.rs | 2 +- .../src/managed_agents/runtime_commands.rs | 130 +++++++++++------ desktop/src/features/agents/AGENTS.md | 8 ++ desktop/src/features/agents/hooks.ts | 2 + .../lib/managedAgentControlActions.test.mjs | 10 +- .../agents/lib/managedAgentControlActions.ts | 7 +- .../agents/managedAgentRuntimeHooks.ts | 4 +- .../agents/ui/useManagedAgentActions.ts | 7 +- .../channels/ui/useMembersSidebarActions.ts | 1 + .../profile/ui/useAgentLifecycleActions.ts | 4 +- desktop/src/shared/api/tauriManagedAgents.ts | 10 +- 16 files changed, 326 insertions(+), 64 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/remote_stop.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0ad7fd321c5..a3bc56882e2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -160,15 +160,35 @@ pub(super) async fn start_local_agent_pairs_with_preflight( summarize_from_disk(app, record, &runtimes) } -pub(super) async fn start_local_agent_with_preflight( +enum LocalStartIntent { + Create, + Explicit, + Automatic, +} + +async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, - allow_fresh_create_start: bool, + intent: LocalStartIntent, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, ) -> Result { + let launch_owner = workspace_owner_hex(state)?; + let launch_key = crate::managed_agents::ManagedAgentRuntimeKey::new( + pubkey, + &relay_ws_url_with_override(state), + )?; + let resume = if matches!(intent, LocalStartIntent::Explicit) { + Some(crate::managed_agents::remote_stop::capture_resume( + app, + &launch_key, + &launch_owner, + )?) + } else { + None + }; let record_snapshot = { let _store_guard = state .managed_agents_store_lock @@ -201,7 +221,12 @@ pub(super) async fn start_local_agent_with_preflight( &personas, &global, ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + ensure_relay_mesh_for_record( + app, + mesh_model_id.as_deref(), + matches!(intent, LocalStartIntent::Create), + ) + .await?; // The mesh preflight above is the suspension window Projects callbacks // capture their scope against: a community switch during that await @@ -212,15 +237,21 @@ pub(super) async fn start_local_agent_with_preflight( // point can no longer retarget the spawn (it only changes state this // call no longer consults). let workspace_relay_url = crate::relay::bind_expected_relay_scope( - expected_relay_url, + expected_relay_url.or(Some(launch_key.relay_url.as_str())), crate::relay::relay_ws_url_with_override(state), )?; // Bind the active owner after the same final await as the relay. A // same-relay identity replacement during mesh preflight must not release // the stale preflight owner to spawn. - let workspace_owner = - crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; + let workspace_owner = crate::relay::bind_expected_signer( + expected_signer_pubkey.or(Some(launch_owner.as_str())), + workspace_owner_hex(state)?, + )?; + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; let _store_guard = state .managed_agents_store_lock .lock() @@ -262,6 +293,7 @@ pub(super) async fn start_local_agent_with_preflight( Some(workspace_owner.as_str()), &workspace_relay_url, replay_floor_unix, + resume.as_ref(), )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { @@ -713,7 +745,16 @@ pub async fn create_managed_agent( // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await + match start_local_agent_with_preflight( + &app, + &state, + &pubkey, + LocalStartIntent::Create, + None, + None, + None, + ) + .await { Ok(agent) => agent, Err(error) => { @@ -824,6 +865,7 @@ pub async fn start_managed_agent( expected_relay_url: Option, expected_signer_pubkey: Option, replay_floor_unix: Option, + explicit_start: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -920,7 +962,11 @@ pub async fn start_managed_agent( &app, &state, &pubkey, - false, + if explicit_start.unwrap_or(false) { + LocalStartIntent::Explicit + } else { + LocalStartIntent::Automatic + }, expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), replay_floor_unix, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a66f9c75ba2..e859344e4c7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -32,6 +32,7 @@ mod process_lifecycle; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; +pub(crate) mod remote_stop; mod repos; mod restore; pub mod retention; diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs new file mode 100644 index 00000000000..818c7072064 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -0,0 +1,131 @@ +//! Durable no-auto-start fence shared by ordinary Desktop launch paths. +use super::retention::{open_retention_db, scoped_retention_db_path}; +use super::ManagedAgentRuntimeKey; +use rusqlite::{Connection, OptionalExtension}; +use tauri::{AppHandle, Manager}; + +fn schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence ( + agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL);") + .map_err(|e| e.to_string()) +} + +/// Explicit local Start captures the Stop fence before its asynchronous preflight. +/// Automatic starts and Restart continuations never receive this permission. +pub(crate) struct ResumeTicket { + previous: Option, +} + +pub(crate) fn capture_resume( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: &str, +) -> Result { + let conn = connection(app, key, owner)?; + schema(&conn)?; + let previous = conn + .query_row( + "SELECT event_id FROM desktop_stop_fence WHERE agent=?1", + [&key.pubkey], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(ResumeTicket { previous }) +} + +fn connection( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: &str, +) -> Result { + let path = + scoped_retention_db_path(&super::managed_agents_base_dir(app)?, &key.relay_url, owner); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + open_retention_db(&path) +} + +/// Every ordinary spawn passes here, including restore/config/reconcile. +/// Caller holds the existing transition lock through child registration. +pub(crate) fn check_launch( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: Option<&str>, + resume: Option<&ResumeTicket>, +) -> Result<(), String> { + let state = app.state::(); + let current_owner = state.signing_keys()?.public_key().to_hex(); + if owner != Some(current_owner.as_str()) { + return Err("Desktop launch owner changed".into()); + } + let conn = connection(app, key, ¤t_owner)?; + schema(&conn)?; + let row: Option<(String, bool)> = conn + .query_row( + "SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1", + [&key.pubkey], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + allow_launch(row.as_ref(), resume) +} + +fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) -> Result<(), String> { + if let Some(ticket) = resume { + if ticket.previous.as_ref() != row.map(|(id, _)| id) { + return Err("A newer Stop interrupted this Start".into()); + } + } else if row.is_some_and(|(_, blocked)| *blocked) { + return Err( + "Stopped from another Desktop. Use Start agent to start it again explicitly.".into(), + ); + } + Ok(()) +} + +/// A failed spawn must not unblock config/restore. Commit only after the child +/// has its ordinary receipt and tracked handle, still under the transition lock. +pub(crate) fn finish_resume( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + owner: Option<&str>, + ticket: Option<&ResumeTicket>, +) -> Result<(), String> { + if ticket.is_none() { + return Ok(()); + } + let owner = owner.ok_or("Desktop launch owner unavailable")?; + check_launch(app, key, Some(owner), ticket)?; + connection(app, key, owner)? + .execute( + "UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1", + [&key.pubkey], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() { + let stopped = ("stop-a".to_owned(), true); + let resumed = ("stop-a".to_owned(), false); + let new_stop = ("stop-b".to_owned(), true); + assert!(allow_launch(None, None).is_ok()); + assert!(allow_launch(Some(&stopped), None).is_err()); + assert!(allow_launch(Some(&resumed), None).is_ok()); + let ticket = ResumeTicket { + previous: Some("stop-a".to_owned()), + }; + assert!(allow_launch(Some(&stopped), Some(&ticket)).is_ok()); + assert!(allow_launch(Some(&new_stop), Some(&ticket)).is_err()); + let before_any_stop = ResumeTicket { previous: None }; + assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err()); + assert!(allow_launch(None, Some(&before_any_stop)).is_ok()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 5b79ccac27f..66aa95cba74 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -345,6 +345,7 @@ pub async fn restore_managed_agents_on_launch( true, owner_hex_ref, None, + None, ) }) { Ok(process) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b8d586b32af..1eec6eee979 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -31,7 +31,7 @@ mod setup_payload; use setup_payload::apply_setup_payload_env; mod stop; -pub(crate) use stop::managed_agent_runtime_keys; +pub(crate) use stop::{managed_agent_runtime_keys, stop_managed_agent_pair}; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; @@ -451,7 +451,10 @@ pub fn spawn_agent_child( lazy: bool, owner_hex: Option<&str>, replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, ) -> Result { + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + super::remote_stop::check_launch(app, &key, owner_hex, resume)?; if let Some(error) = spawn_key_refusal(record) { return Err(error); } @@ -881,6 +884,7 @@ pub fn start_managed_agent_process( owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; if let Some(runtime) = runtimes.get_mut(&key) { @@ -907,6 +911,7 @@ pub fn start_managed_agent_process( false, owner_hex, replay_floor_unix, + resume, )?; let now = now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { @@ -928,7 +933,8 @@ pub fn start_managed_agent_process( record.last_error = None; record.last_error_code = None; - runtimes.insert(key, ManagedAgentPairRuntime::starting(process)); + runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + super::remote_stop::finish_resume(app, &key, owner_hex, resume)?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 7b8ded7926d..0c13937ff27 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -37,7 +37,7 @@ pub(crate) fn managed_agent_runtime_relay_urls( /// runtime is reinserted so the pair stays visible and stoppable instead of /// becoming an invisible orphan. Touches no other pair for the agent and /// does no record-level stop bookkeeping — callers own that. -fn stop_managed_agent_pair( +pub(crate) fn stop_managed_agent_pair( app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7a..eacb7ba6bf6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -3,13 +3,12 @@ use std::sync::atomic::Ordering; use tauri::{AppHandle, Emitter, Manager}; use super::{ - agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut, - load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path, - process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents, - spawn_agent_child, terminate_process, terminate_untracked_pair_runtime, - write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime, - ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, - ManagedAgentRuntimeStatus, + agent_readiness, current_instance_id, find_managed_agent_mut, load_global_agent_config, + load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running, + record_agent_command, resolve_effective_agent_env, save_managed_agents, spawn_agent_child, + terminate_process, terminate_untracked_pair_runtime, write_agent_runtime_receipt, + AgentReadiness, BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey, + ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, }; use crate::app_state::AppState; @@ -229,16 +228,24 @@ pub(crate) fn start_managed_agent_runtime_pair_lazy( relay_url: String, app: AppHandle, ) -> Result { - start_pair(pubkey, relay_url, true, None, app) + start_pair(pubkey, relay_url, true, None, false, app) } #[tauri::command] pub fn start_managed_agent_runtime( pubkey: String, relay_url: String, + explicit_start: Option, app: AppHandle, ) -> Result { - start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app) + start_pair( + pubkey, + relay_url, + true, + None, + explicit_start.unwrap_or(false), + app, + ) } fn start_pair( @@ -246,6 +253,7 @@ fn start_pair( relay_url: String, lazy: bool, expected_updated_at: Option<&str>, + explicit_start: bool, app: AppHandle, ) -> Result { let state = app.state::(); @@ -288,8 +296,24 @@ fn start_pair( .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = - spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; + let resume = if explicit_start { + Some(super::remote_stop::capture_resume( + &app, + &key, + owner.as_deref().ok_or("Desktop owner unavailable")?, + )?) + } else { + None + }; + let mut process = spawn_agent_child( + &app, + record, + &key.relay_url, + lazy, + owner.as_deref(), + None, + resume.as_ref(), + )?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(), @@ -308,6 +332,7 @@ fn start_pair( record.last_stopped_at = None; record.last_error = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + super::remote_stop::finish_resume(&app, &key, owner.as_deref(), resume.as_ref())?; let status = status_for(&app, record, &key, runtimes.get(&key), None); drop(runtimes); save_managed_agents(&app, &records)?; @@ -326,6 +351,16 @@ pub fn stop_managed_agent_runtime( .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; + stop_pair_locked(pubkey, relay_url, app.clone()) +} + +// Caller owns managed_agent_runtime_transition for the whole admission/effect. +pub(crate) fn stop_pair_locked( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + let state = app.state::(); let _store = state .managed_agents_store_lock .lock() @@ -337,42 +372,27 @@ pub fn stop_managed_agent_runtime( .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if let Some(mut runtime) = runtimes.remove(&key) { - let stop_result = if process_is_running(runtime.child.id()) { - terminate_process(runtime.child.id()) - } else { - Ok(()) - } - .and_then(|()| runtime.child.wait().map_err(|e| e.to_string())); - match stop_result { - Ok(status) => { - record.last_exit_code = status.code(); - let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); - } - Err(error) => { - // Keep failed teardown visible/manageable instead of - // orphaning it: the child stays tracked and the receipt - // stays on disk until a stop actually succeeds. - runtimes.insert(key, runtime); - return Err(error); - } - } + if runtimes.contains_key(&key) { + // Use ordinary Desktop Stop, including its platform-specific child/job + // ownership. Remote control must not grow a second teardown contract. + super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; } else { - // No runtime is tracked at this key, but a valid prior-session - // receipt may still point at a live child (e.g. the crash-recovery - // window for a non-auto-start agent). Terminate that orphan before - // erasing its receipt — otherwise this "stop" leaves the harness - // running yet deletes the one artifact sweeps and - // terminate_untracked_pair_runtime use to find it, and a follow-up - // start would spawn a duplicate harness for the same pair. On - // failure the receipt stays on disk (terminate_untracked_pair_runtime - // only removes it after the child exits), mirroring the tracked - // path's keep-until-success invariant. terminate_untracked_pair_runtime(&app, &key)?; } + // Old scalar records have no community-bound receipt. Do not erase a live + // child or claim success for it when this request cannot establish scope. + reject_unscoped_live_child( + record.runtime_pid.filter(|pid| process_is_running(*pid)), + runtimes.values().map(|runtime| runtime.child.id()), + )?; super::remove_agent_runtime_receipt(&app, &key); state.clear_agent_session_cache(&key); - record.runtime_pid = None; + if record + .runtime_pid + .is_some_and(|pid| !process_is_running(pid)) + { + record.runtime_pid = None; + } record.updated_at = crate::util::now_iso(); record.last_stopped_at = Some(record.updated_at.clone()); let status = status_for(&app, record, &key, None, None); @@ -382,6 +402,16 @@ pub fn stop_managed_agent_runtime( Ok(status) } +fn reject_unscoped_live_child( + live_pid: Option, + tracked: impl Iterator, +) -> Result<(), String> { + if live_pid.is_some_and(|pid| !tracked.into_iter().any(|other| other == pid)) { + return Err("Legacy runtime is not bound to this community; use local Desktop Stop".into()); + } + Ok(()) +} + #[tauri::command] pub fn restart_managed_agent_runtime( pubkey: String, @@ -389,7 +419,7 @@ pub fn restart_managed_agent_runtime( app: AppHandle, ) -> Result { stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, None, app) + start_pair(pubkey, relay_url, true, None, false, app) } /// Probe whether this agent can operate on `requested_relay_url`. @@ -513,6 +543,7 @@ pub async fn reconcile_managed_agent_runtimes( key.relay_url.clone(), true, Some(&record.updated_at), + false, app.clone(), ) { Ok(mut status) => { @@ -732,3 +763,16 @@ mod tests { assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); } } + +#[cfg(test)] +mod stop_scope_tests { + use super::reject_unscoped_live_child; + + #[test] + fn live_legacy_child_cannot_be_erased_or_reported_stopped() { + assert!(reject_unscoped_live_child(Some(12), [].into_iter()).is_err()); + assert!(reject_unscoped_live_child(Some(12), [13].into_iter()).is_err()); + assert!(reject_unscoped_live_child(Some(12), [12].into_iter()).is_ok()); + assert!(reject_unscoped_live_child(None, [13].into_iter()).is_ok()); + } +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index ff8df71cd30..cf0cb2c6524 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -304,6 +304,14 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +## Desktop Stop launch fence + +All local spawn paths consume the durable Stop fence at the shared native +spawn boundary. Only a deliberate **Start agent** action can supersede that +fence; config/restore/reconcile and Restart continuations cannot. Explicit Start +captures its fence before preflight and fails if a newer Stop arrives. Fence +release happens only after the new child has its receipt and tracked handle. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ec1ccd262e8..8f2fe3ca1c0 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -595,6 +595,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl?: string; expectedSignerPubkey?: string; replayFloorUnix?: number; + explicitStart?: boolean; }, ) => typeof input === "string" @@ -603,6 +604,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, replayFloorUnix: input.replayFloorUnix, + explicitStart: input.explicitStart, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..e2efef08c33 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -56,7 +56,10 @@ test("relay-mesh agents delegate start to the backend preflight", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, meshAgent.pubkey); + assert.deepEqual(calledWith, { + pubkey: meshAgent.pubkey, + explicitStart: true, + }); // Backend preflight failures (e.g. no live serve target) propagate as-is. await assert.rejects( @@ -78,7 +81,10 @@ test("ordinary local agents still start normally", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, "deadbeef".repeat(8)); + assert.deepEqual(calledWith, { + pubkey: "deadbeef".repeat(8), + explicitStart: true, + }); }); // --- respawnManagedAgentWithRules: stop→clear→start boundary tests ----------- diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index aaf10075e0d..d0fea69a062 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -8,7 +8,10 @@ type DeleteManagedAgentInput = { forceRemoteDelete?: boolean; }; -type StartManagedAgent = (pubkey: string) => Promise; +export type ManagedAgentStartInput = + | string + | { pubkey: string; explicitStart: true }; +type StartManagedAgent = (input: ManagedAgentStartInput) => Promise; type StopManagedAgent = (pubkey: string) => Promise; type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise; @@ -82,7 +85,7 @@ export async function startManagedAgentWithRules({ // Relay-mesh agents are no longer blocked here: the backend start preflight // (ensure_relay_mesh_for_record) re-resolves a live serve target and dials // it, failing with an actionable error when no peer serves the model. - await startManagedAgent(agent.pubkey); + await startManagedAgent({ pubkey: agent.pubkey, explicitStart: true }); } export async function respawnManagedAgentWithRules({ diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 96a3abc78d4..21dfe9a94fe 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -185,10 +185,12 @@ export function useManagedAgentRuntimeAction() { action, pubkey, relayUrl, + explicitStart = false, }: { action: "start" | "stop" | "restart"; pubkey: string; relayUrl: string; + explicitStart?: boolean; }) => { if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); if (action === "restart") { @@ -200,7 +202,7 @@ export function useManagedAgentRuntimeAction() { startManagedAgentRuntime, ); } - return startManagedAgentRuntime(pubkey, relayUrl); + return startManagedAgentRuntime(pubkey, relayUrl, explicitStart); }, onSuccess: (runtime, { action }) => { // For stop-only: clear stale working badges immediately. The restart diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 9c06044c5a9..5f5cb277457 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -433,10 +433,11 @@ export function useManagedAgentActions() { stopMutation.isPending || startOnLaunchMutation.isPending || deleteMutation.isPending; - const startingAgentPubkey = - startMutation.isPending && typeof startMutation.variables === "string" + const startingAgentPubkey = startMutation.isPending + ? typeof startMutation.variables === "string" ? startMutation.variables - : null; + : (startMutation.variables?.pubkey ?? null) + : null; return { relayAgentsQuery, diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index ced4836d6b4..63a64899b61 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -176,6 +176,7 @@ export function useMembersSidebarActions({ action, pubkey: agent.pubkey, relayUrl, + explicitStart: action === "start", }); setActionNoticeMessage( action === "stop" diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 745df71768d..0b2e79ab6e5 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -28,7 +28,9 @@ export function useAgentLifecycleActions({ channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; relayAgents: readonly RelayAgent[] | undefined; - startManagedAgent: (pubkey: string) => Promise; + startManagedAgent: ( + input: import("@/features/agents/lib/managedAgentControlActions").ManagedAgentStartInput, + ) => Promise; stopManagedAgent: (pubkey: string) => Promise; }) { const handleAgentPrimaryAction = React.useCallback(async () => { diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index ed7e053f259..3d33e925c13 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -24,6 +24,8 @@ export async function startManagedAgent( * long the spawn takes. Local spawns receive it as process env; provider * deploys carry it in the payload's launch.policy_env. */ replayFloorUnix?: number; + /** Only a deliberate Start button may supersede a remote Stop. */ + explicitStart?: boolean; }, ): Promise { const response = await invokeTauri("start_managed_agent", { @@ -31,6 +33,7 @@ export async function startManagedAgent( expectedRelayUrl: options?.expectedRelayUrl ?? null, expectedSignerPubkey: options?.expectedSignerPubkey ?? null, replayFloorUnix: options?.replayFloorUnix ?? null, + explicitStart: options?.explicitStart ?? false, }); return fromRawManagedAgent(response); } @@ -81,8 +84,13 @@ export async function listManagedAgentRuntimes(): Promise< export async function startManagedAgentRuntime( pubkey: string, relayUrl: string, + explicitStart = false, ): Promise { - return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl }); + return invokeTauri("start_managed_agent_runtime", { + pubkey, + relayUrl, + explicitStart, + }); } export async function stopManagedAgentRuntime(