From 8b6227751938d64a92d4ab749ee5978154509fbc Mon Sep 17 00:00:00 2001
From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
 <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 3 Jun 2026 13:44:55 -0400
Subject: [PATCH 1/2] desktop(mesh-llm): let a serving node route a different
 model
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ensure_client_node_for_model rejected any running Serve runtime with
"stop sharing before using relay mesh as a client". That made hosting
one model and using another mutually exclusive — but mesh-llm's router
already resolves a request to a local, remote, or split target at request
time (route_missing_local_model -> hosts_for_model). The mode split was a
Sprout policy, not a mesh constraint.

Treat a running runtime (any mode) as the local 9337 OpenAI ingress and
hand it back. If the caller selected a target, still dial it first so the
node joins the chosen peer's mesh — skipping that join was a latent bug:
a serve runtime not yet on the target's mesh would fail its first
inference while the frontend had already signalled the peer to expect us.
No /v1/models probe: it races model gossip and would wrongly reject
freshly-discovered remote/split models.

justfile: pass --features mesh-llm directly to tauri dev/build. The
TAURI_CLI_EXTRA_CARGO_ARGS env var is not a recognized Tauri v2 setting,
so the feature was silently dropped and desktop dev/staging/build ran
WITHOUT mesh-llm.

Adds an #[ignore] hardware regression: a real Serve runtime + a
different-model preflight returns Ok with the same 9337 ingress.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
---
 desktop/src-tauri/src/commands/mesh_llm.rs | 114 +++++++++++++++++++--
 justfile                                   |   6 +-
 2 files changed, 108 insertions(+), 12 deletions(-)

diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs
index c9f9686590..c73c298a5d 100644
--- a/desktop/src-tauri/src/commands/mesh_llm.rs
+++ b/desktop/src-tauri/src/commands/mesh_llm.rs
@@ -57,15 +57,34 @@ pub(crate) async fn ensure_client_node_for_model(
     {
         let runtime = state.mesh_llm_runtime.lock().await;
         if let Some(runtime) = runtime.as_ref() {
-            let status = runtime.status().await.map_err(|error| error.to_string())?;
-            return match status.mode {
-                Some(mesh_llm::MeshNodeMode::Client) => Ok(status),
-                Some(mesh_llm::MeshNodeMode::Serve) => Err(
-                    "this desktop is currently sharing compute; stop sharing before using relay mesh as a client"
-                        .to_string(),
-                ),
-                None => Ok(status),
-            };
+            // A running runtime — in any mode — is the mesh's local OpenAI
+            // ingress on `9337`. mesh-llm's router already resolves the
+            // requested model to a local, remote, or split target at request
+            // time (see `route_missing_local_model` -> `hosts_for_model`), so
+            // "serving" and "using the mesh as a client" are not mutually
+            // exclusive: a serve node can host model A and route model B to a
+            // peer through the same ingress. Hand the agent the existing
+            // runtime; the router decides routability per request rather than
+            // this preflight second-guessing it (a `/v1/models` check here
+            // would race model gossip and wrongly reject freshly-discovered
+            // remote/split models).
+            //
+            // If the caller selected a specific target, still dial it: that is
+            // how the runtime joins the chosen peer's mesh. Skipping it would
+            // let a serve runtime not yet connected to that target fail its
+            // first inference while the frontend has already signalled the
+            // peer to expect us.
+            if let Some(endpoint_addr) = endpoint_addr
+                .as_deref()
+                .map(str::trim)
+                .filter(|value| !value.is_empty())
+            {
+                runtime
+                    .dial_endpoint_addr(endpoint_addr)
+                    .await
+                    .map_err(|error| format!("mesh dial failed: {error}"))?;
+            }
+            return runtime.status().await.map_err(|error| error.to_string());
         }
     }
 
@@ -197,3 +216,80 @@ pub fn mesh_agent_preset(
 ) -> CmdResult<mesh_llm::MeshAgentPreset> {
     mesh_llm::agent_preset(request)
 }
+
+#[cfg(all(test, feature = "mesh-llm"))]
+mod tests {
+    use super::*;
+    use crate::app_state::build_app_state;
+
+    /// Acceptance-critical regression for dropping the serve-vs-client guard.
+    ///
+    /// Before this change, `ensure_client_node_for_model` hard-errored whenever
+    /// the running runtime was in `Serve` mode ("stop sharing before using
+    /// relay mesh as a client"). That forbade the exact thing a user should be
+    /// able to do: host model A while pointing an agent at a different model B
+    /// through the same `9337` ingress.
+    ///
+    /// This test starts a real serve runtime and asserts that a follow-up
+    /// preflight for a *different* model:
+    ///   1. does NOT reject on mode, and
+    ///   2. returns the existing runtime's status (same `9337` ingress), so the
+    ///      agent keeps talking to the running node and mesh-llm's router
+    ///      resolves the model per request.
+    ///
+    /// Hardware-gated (`#[ignore]`): loads a real model. Run with:
+    ///   cargo test -p sprout-desktop --features mesh-llm \
+    ///     ensure_serve_runtime_serves_other_model -- --ignored --nocapture
+    #[tokio::test]
+    #[ignore = "loads a real model; run manually with --ignored"]
+    async fn ensure_serve_runtime_serves_other_model() {
+        const HOSTED_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M";
+        const OTHER_MODEL: &str = "some/other-model-not-hosted-locally:Q4_K_M";
+
+        let state = build_app_state();
+
+        // Start a serve runtime hosting HOSTED_MODEL — this is the "Share
+        // compute" path.
+        let serve = mesh_llm::DesktopMeshRuntime::start(mesh_llm::StartMeshNodeRequest {
+            mode: mesh_llm::MeshNodeMode::Serve,
+            model_id: Some(HOSTED_MODEL.to_string()),
+            max_vram_gb: None,
+            join_token: None,
+        })
+        .await
+        .expect("serve runtime should start");
+
+        let serve_status = serve.status().await.expect("serve status");
+        let serve_base = serve_status.api_base_url.clone();
+        assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve));
+
+        {
+            let mut runtime = state.mesh_llm_runtime.lock().await;
+            *runtime = Some(serve);
+        }
+
+        // Preflight for a DIFFERENT model with no explicit target. Old code:
+        // Err(...sharing compute...). New code: reuse the running ingress.
+        let status = ensure_client_node_for_model(&state, OTHER_MODEL, None)
+            .await
+            .expect("serve runtime must not reject a different-model preflight");
+
+        // It returns the SAME running node — agents keep using A's 9337, and
+        // the router decides routability for OTHER_MODEL per request.
+        assert_eq!(
+            status.mode,
+            Some(mesh_llm::MeshNodeMode::Serve),
+            "preflight should reuse the existing serve runtime, not spin up a client"
+        );
+        assert_eq!(
+            status.api_base_url, serve_base,
+            "agent must be pointed at the existing serve node's ingress"
+        );
+
+        // Clean up the runtime.
+        let taken = state.mesh_llm_runtime.lock().await.take();
+        if let Some(runtime) = taken {
+            let _ = runtime.stop().await;
+        }
+    }
+}
diff --git a/justfile b/justfile
index e6b97880b0..c56d51fae3 100644
--- a/justfile
+++ b/justfile
@@ -181,7 +181,7 @@ desktop-release-build target="aarch64-apple-darwin":
     touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
     touch "desktop/src-tauri/binaries/sprout-$TARGET"
     pnpm install
-    cd {{desktop_dir}} && TAURI_CLI_EXTRA_CARGO_ARGS="--features=mesh-llm" pnpm tauri build --target {{target}}
+    cd {{desktop_dir}} && pnpm tauri build --features mesh-llm --target {{target}}
 
 # Run desktop checks suitable for CI / pre-push
 desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
@@ -263,7 +263,7 @@ dev *ARGS: _ensure-sidecar-stubs
     [[ -d node_modules ]] || pnpm install
     source ../scripts/instance-env.sh
     echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
-    TAURI_CLI_EXTRA_CARGO_ARGS="--features=mesh-llm" pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
+    pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
 
 # Run the desktop app against the internal staging relay (installs deps + builds agent tools automatically)
 staging *ARGS: _ensure-sidecar-stubs
@@ -279,7 +279,7 @@ staging *ARGS: _ensure-sidecar-stubs
     source ../scripts/instance-env.sh
     export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
     echo "Starting staging on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
-    TAURI_CLI_EXTRA_CARGO_ARGS="--features=mesh-llm" pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
+    pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
 
 # Run the desktop frontend dev server (port derived from worktree)
 desktop-dev:

From 178db7d4e8e9682b5cf0cf0fa04eee1d68ca5471 Mon Sep 17 00:00:00 2001
From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
 <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Date: Wed, 3 Jun 2026 14:09:58 -0400
Subject: [PATCH 2/2] desktop(mesh): persist share compute model draft

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
---
 .../ui/MeshComputeSettingsCard.tsx            | 49 +++++++++++++++++--
 desktop/tests/e2e/mesh-compute.spec.ts        | 24 +++++++++
 2 files changed, 68 insertions(+), 5 deletions(-)

diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
index bccbb85449..4e2b7c8819 100644
--- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
+++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
@@ -14,6 +14,29 @@ import type { MeshModelOption, MeshNodeStatus } from "@/shared/api/tauriMesh";
 import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef";
 import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
 
+const MODEL_DRAFT_STORAGE_KEY = "sprout.mesh-compute.share.model.v1";
+const MAX_VRAM_DRAFT_STORAGE_KEY = "sprout.mesh-compute.share.max-vram-gb.v1";
+
+function readDraft(key: string): string {
+  try {
+    return window.localStorage.getItem(key) ?? "";
+  } catch {
+    return "";
+  }
+}
+
+function writeDraft(key: string, value: string): void {
+  try {
+    if (value === "") {
+      window.localStorage.removeItem(key);
+    } else {
+      window.localStorage.setItem(key, value);
+    }
+  } catch {
+    // Ignore unavailable/full storage; the input still works for this session.
+  }
+}
+
 /**
  * Settings → Compute → Share compute.
  *
@@ -31,8 +54,12 @@ export function MeshComputeSettingsCard() {
   const [installedModels, setInstalledModels] = React.useState<
     MeshModelOption[]
   >([]);
-  const [modelInput, setModelInput] = React.useState("");
-  const [maxVramGb, setMaxVramGb] = React.useState<string>("");
+  const [modelInput, setModelInput] = React.useState(() =>
+    readDraft(MODEL_DRAFT_STORAGE_KEY),
+  );
+  const [maxVramGb, setMaxVramGb] = React.useState<string>(() =>
+    readDraft(MAX_VRAM_DRAFT_STORAGE_KEY),
+  );
   const [advancedOpen, setAdvancedOpen] = React.useState(false);
   const [actionInFlight, setActionInFlight] = React.useState(false);
   const [actionError, setActionError] = React.useState<string | null>(null);
@@ -63,6 +90,7 @@ export function MeshComputeSettingsCard() {
   React.useEffect(() => {
     if (status?.state === "running" && status.modelId && modelInput === "") {
       setModelInput(status.modelId);
+      writeDraft(MODEL_DRAFT_STORAGE_KEY, status.modelId);
     }
   }, [status?.state, status?.modelId, modelInput]);
 
@@ -153,7 +181,11 @@ export function MeshComputeSettingsCard() {
           </legend>
           <Input
             data-testid="mesh-share-compute-model"
-            onChange={(e) => setModelInput(e.target.value)}
+            onChange={(e) => {
+              const next = e.target.value;
+              setModelInput(next);
+              writeDraft(MODEL_DRAFT_STORAGE_KEY, next);
+            }}
             placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main"
             value={modelInput}
           />
@@ -177,7 +209,10 @@ export function MeshComputeSettingsCard() {
                   <li key={m.id}>
                     <button
                       className="rounded border border-border/60 bg-muted/20 px-2 py-0.5 text-xs hover:bg-muted/40"
-                      onClick={() => setModelInput(m.id)}
+                      onClick={() => {
+                        setModelInput(m.id);
+                        writeDraft(MODEL_DRAFT_STORAGE_KEY, m.id);
+                      }}
                       type="button"
                     >
                       {m.name ?? m.id}
@@ -217,7 +252,11 @@ export function MeshComputeSettingsCard() {
               data-testid="mesh-share-compute-vram"
               id="mesh-vram"
               inputMode="decimal"
-              onChange={(e) => setMaxVramGb(e.target.value)}
+              onChange={(e) => {
+                const next = e.target.value;
+                setMaxVramGb(next);
+                writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next);
+              }}
               placeholder="No limit"
               value={maxVramGb}
             />
diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts
index 4cd0d6415e..0170afcd1b 100644
--- a/desktop/tests/e2e/mesh-compute.spec.ts
+++ b/desktop/tests/e2e/mesh-compute.spec.ts
@@ -89,6 +89,30 @@ test("Share compute starts and stops a serve node", async ({ page }) => {
     .toContain("mesh_stop_node");
 });
 
+test("Share compute model draft persists across reload", async ({ page }) => {
+  await gotoApp(page);
+  await openSettings(page, "compute");
+
+  const model = page.getByTestId("mesh-share-compute-model");
+  await expect(model).toBeVisible({ timeout: 10_000 });
+  await model.fill("unsloth/Qwen3.6-35B-A3B-GGUF@main:UD-Q4_K_S");
+
+  await page.getByText("Advanced").click();
+  await page.getByTestId("mesh-share-compute-vram").fill("42");
+
+  await page.reload({ waitUntil: "domcontentloaded" });
+  await expect(page.getByTestId("open-agents-view")).toBeVisible({
+    timeout: 10_000,
+  });
+  await openSettings(page, "compute");
+
+  await expect(page.getByTestId("mesh-share-compute-model")).toHaveValue(
+    "unsloth/Qwen3.6-35B-A3B-GGUF@main:UD-Q4_K_S",
+  );
+  await page.getByText("Advanced").click();
+  await expect(page.getByTestId("mesh-share-compute-vram")).toHaveValue("42");
+});
+
 test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", async ({
   page,
 }) => {
