diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 6464edc23..19109ac3c 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -390,14 +390,13 @@ pub async fn import_identity( None }; - // ── Layer 1 async: fail closed if a client-mode Mesh runtime is active ── - // Identity import clears the active scope entirely, so any live client-mode - // Mesh runtime would become dangling after the import. Option A ruling: - // require the user to stop it first. The journaled Mesh recipe is a tracked - // follow-up in the PR body. + // Fail closed if a client-mode Mesh runtime is active when there is an + // active scope — a live client-mode runtime would become dangling after the + // import (Option A ruling). Called via `run_mesh_transition_preflight` so the + // shared preflight logic is not duplicated inline; the guard above is held. + #[cfg(feature = "mesh-llm")] if has_active_scope { - #[cfg(feature = "mesh-llm")] - crate::commands::mesh_llm::scope_impl::fail_if_client_mesh_active(&app_handle).await?; + crate::commands::mesh_llm::scope_impl::run_mesh_transition_preflight(&app_handle).await?; } let result = tokio::task::spawn_blocking(move || { diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index f86c6e4ff..ce4773c87 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -896,9 +896,27 @@ pub(crate) async fn ensure_relay_mesh_for_record( }; // No serving configuration exists — genuine consumer-only start. - // Acquire workspace_transition to serialize client install against apply_workspace. - let _workspace_guard = state.workspace_transition.lock().await; - ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; + // Capture scope BEFORE discovery so a concurrent workspace switch can be + // detected under the install lock. Route through + // `install_client_under_workspace_transition` which acquires + // `workspace_transition`, validates full scope identity + // (scope_id, relay, owner, generation), then calls the install closure — + // serialized against apply_workspace and live identity import. + let captured_scope = state + .capture_active_scope() + .ok_or("mesh client install: no active workspace scope")?; + let model_id_owned = model_id.to_string(); + let endpoint = target.endpoint_addr; + scope_impl::install_client_under_workspace_transition(app, &captured_scope, move || { + let state_ref = state.clone(); + let model_id_ref = model_id_owned.clone(); + async move { + ensure_client_node_for_model(&state_ref, &model_id_ref, Some(endpoint)) + .await + .map(|_| ()) + } + }) + .await?; wait_for_mesh_inference(model_id).await } diff --git a/desktop/src-tauri/src/commands/mesh_llm_scope.rs b/desktop/src-tauri/src/commands/mesh_llm_scope.rs index e27673cb0..aa4a5216c 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_scope.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_scope.rs @@ -101,13 +101,21 @@ pub(crate) async fn fail_if_client_mesh_active( /// `fail_if_client_mesh_active` preflight, then invoke `transition_body` while /// the guard remains held. /// -/// Used by `apply_workspace` and live identity import so neither duplicates the -/// acquire + preflight sequence inline. Tests call this helper directly to prove -/// the serialization contract against `install_client_under_workspace_transition`. +/// Used by `apply_workspace`, live identity import, and tests to prove the +/// serialization contract against `install_client_under_workspace_transition`. /// /// The transition body runs synchronously (or dispatches to `spawn_blocking`) /// while the async guard is alive on the current task. If the preflight fails, /// `transition_body` is never called. +/// +/// Production callers that must `.await` after dispatch use this helper with +/// a body that dispatches `tokio::task::spawn_blocking` and immediately returns +/// the join handle's `.await` — the guard is held across the dispatch and the +/// `.await` is done at the call site after this helper returns. +/// +/// Alternatively, callers that cannot fit their body into a sync `FnOnce` call +/// `run_mesh_transition_preflight` (which they invoke after acquiring the lock +/// themselves) to share the preflight logic without lifetime constraints. pub(crate) async fn with_workspace_transition_preflight( app: &AppHandle, transition_body: F, @@ -126,6 +134,25 @@ where transition_body() } +/// Run only the Mesh-preflight portion of the workspace transition check. +/// +/// Called by production commands (`apply_workspace`, `import_identity`) that +/// already hold `workspace_transition` and need to avoid duplicating the +/// `fail_if_client_mesh_active` call inline. The guard must already be acquired +/// and remain alive for the duration of the transition. +/// +/// No-op when the `mesh-llm` feature is disabled. +pub(crate) async fn run_mesh_transition_preflight(app: &AppHandle) -> Result<(), String> +where + R: tauri::Runtime, +{ + #[cfg(feature = "mesh-llm")] + fail_if_client_mesh_active(app).await?; + #[cfg(not(feature = "mesh-llm"))] + let _ = app; + Ok(()) +} + /// Acquire the `workspace_transition` lock, validate the full captured scope /// identity under the guard — `(scope_id, normalized relay, owner_pubkey, /// generation)` — then call the injected `install` closure. diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index f77e5528e..eba4980bb 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -114,7 +114,7 @@ pub async fn apply_workspace( ) -> Result { use crate::managed_agents::scope::WorkspaceApplyResult; - // ── Layer 1: async serialization lock ──────────────────────────────────── + // ── Layer 1: async serialization lock + Mesh preflight ────────────────── // workspace_transition serializes apply_workspace and live identity import // so scope transitions are never concurrent. We acquire via a clone so the // borrow does not prevent moving `app` into spawn_blocking below. @@ -122,14 +122,12 @@ pub async fn apply_workspace( let lock_state = lock_app.state::(); let _transition_guard = lock_state.workspace_transition.lock().await; - // ── Layer 1 async: fail closed if a client-mode Mesh runtime is active ── - // Serve-mode runtimes are machine-level and stay pinned across workspace - // switches — they never block a switch. Client-mode runtimes require an - // active scope to be meaningful and cannot be safely moved to a new scope - // atomically. Option A ruling: require the user to stop the client session - // first; the journaled Mesh recipe is a tracked follow-up in the PR body. + // Fail closed if a client-mode Mesh runtime is active — no client-mode + // runtime may be active when a workspace switch begins (Option A ruling). + // Called via `run_mesh_transition_preflight` so the shared preflight logic + // is not duplicated inline; the guard above remains held throughout. #[cfg(feature = "mesh-llm")] - crate::commands::mesh_llm::scope_impl::fail_if_client_mesh_active(&app).await?; + crate::commands::mesh_llm::scope_impl::run_mesh_transition_preflight(&app).await?; let restore_app = app.clone(); let blocking_result: Result =