@@ -45,6 +45,18 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result<Option<MeshSharingConfig>
4545const RELAY_MESH_RUNTIME_NO_TARGET : & str =
4646 "Buzz shared compute requires a live serving member; start serving the selected model on a member, then try again" ;
4747
48+ /// Whether the Share-compute "stop sharing" path (`mesh_stop_node`) should tear
49+ /// down the runtime currently occupying the single slot.
50+ ///
51+ /// Serve nodes (this machine SHARING compute) are torn down. Client nodes (this
52+ /// machine CONSUMING a peer's compute) share the same slot and MUST be left
53+ /// running — stopping "Share compute" must never kill a consume session the
54+ /// user didn't start from this switch.
55+ #[ cfg( feature = "mesh-llm" ) ]
56+ fn share_stop_should_teardown ( mode : mesh_llm:: MeshNodeMode ) -> bool {
57+ matches ! ( mode, mesh_llm:: MeshNodeMode :: Serve )
58+ }
59+
4860pub type CmdResult < T > = Result < T , String > ;
4961
5062fn advance_mesh_status_cursor (
@@ -197,19 +209,110 @@ pub async fn mesh_start_node(
197209/// Mesh can bind its HTTP ingress and advertise a model shortly before the
198210/// router has installed a usable target. Probe the exact chat path agents use
199211/// so startup cannot race that gap (`single target None unavailable`).
212+ /// Which startup stage a mesh client is stuck at when it never becomes
213+ /// inference-ready. The two live-observed failure modes are physically
214+ /// distinct and want different user copy:
215+ ///
216+ /// * `CatalogNeverSynced` — the local client node came up and connected to
217+ /// the host at the control level (ping/RTT fine), but the served model
218+ /// never appeared in the local `/v1/models` catalog. That catalog is
219+ /// populated by the peer gossip exchange; when the gossip bi-stream can't
220+ /// establish across the network (observed as iroh
221+ /// `MultipathNotNegotiated` / unreachable direct path), the catalog stays
222+ /// empty forever and every request is rejected "model not available".
223+ /// Root cause is the network path between this machine and the host.
224+ /// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but
225+ /// inference requests never completed (routing/transport to the host
226+ /// failing per-request). The host is discoverable and advertised but not
227+ /// actually serving us.
228+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
229+ enum MeshReadinessFailure {
230+ CatalogNeverSynced ,
231+ RoutingNeverCompleted ,
232+ }
233+
234+ /// Pure classifier: given whether the served model was ever observed in the
235+ /// local `/v1/models` catalog during the wait, decide which stage failed.
236+ /// Split out so the diagnosis is unit-testable without a live mesh.
237+ fn classify_mesh_readiness_failure ( model_ever_visible : bool ) -> MeshReadinessFailure {
238+ if model_ever_visible {
239+ MeshReadinessFailure :: RoutingNeverCompleted
240+ } else {
241+ MeshReadinessFailure :: CatalogNeverSynced
242+ }
243+ }
244+
245+ /// Actionable, non-technical copy for a readiness failure. `last_detail` is the
246+ /// last raw transport/HTTP error, appended for support triage.
247+ fn mesh_readiness_failure_message (
248+ failure : MeshReadinessFailure ,
249+ model_id : & str ,
250+ last_detail : & str ,
251+ ) -> String {
252+ match failure {
253+ MeshReadinessFailure :: CatalogNeverSynced => format ! (
254+ "Buzz shared compute connected to the serving member but could not sync \
255+ the model list for \" {model_id}\" — this is a network path problem \
256+ between this machine and the host (the compute node is reachable for \
257+ pings but the model-sync stream did not establish). Try again, or have \
258+ the host and this machine on a more direct network. (last: {last_detail})"
259+ ) ,
260+ MeshReadinessFailure :: RoutingNeverCompleted => format ! (
261+ "Buzz shared compute found \" {model_id}\" on a serving member but inference \
262+ requests did not complete — the host is discoverable but not currently \
263+ reachable for requests. Try again shortly. (last: {last_detail})"
264+ ) ,
265+ }
266+ }
267+
268+ /// Poll the local mesh OpenAI ingress until a real inference for `model_id`
269+ /// succeeds, or a deadline elapses. On failure, returns a stage-specific,
270+ /// actionable message (see [`MeshReadinessFailure`]) rather than a raw
271+ /// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach
272+ /// the host".
200273async fn wait_for_mesh_inference ( model_id : & str ) -> CmdResult < ( ) > {
201274 let client = reqwest:: Client :: builder ( )
202275 . timeout ( std:: time:: Duration :: from_secs ( 30 ) )
203276 . build ( )
204277 . map_err ( |error| format ! ( "failed to build mesh readiness client: {error}" ) ) ?;
205278 let deadline = tokio:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( 120 ) ;
279+ let models_url = format ! ( "{}/models" , crate :: managed_agents:: RELAY_MESH_API_BASE_URL ) ;
280+ let chat_url = format ! (
281+ "{}/chat/completions" ,
282+ crate :: managed_agents:: RELAY_MESH_API_BASE_URL
283+ ) ;
206284 let mut last_error = "mesh inference is not ready" . to_string ( ) ;
285+ // Track whether the served model ever reached the local catalog — the
286+ // signal that splits "catalog never synced" from "routing never completed".
287+ let mut model_ever_visible = false ;
288+
207289 while tokio:: time:: Instant :: now ( ) < deadline {
290+ // Refresh catalog visibility. "auto" delegates model choice to the
291+ // router, so any advertised model counts as the catalog having synced.
292+ if let Ok ( response) = client
293+ . get ( & models_url)
294+ . bearer_auth ( crate :: managed_agents:: RELAY_MESH_API_KEY_PLACEHOLDER )
295+ . send ( )
296+ . await
297+ {
298+ if let Ok ( body) = response. json :: < serde_json:: Value > ( ) . await {
299+ if let Some ( data) = body. get ( "data" ) . and_then ( |d| d. as_array ( ) ) {
300+ let wanted = model_id. trim ( ) . replace ( "@main" , "" ) ;
301+ let visible = !data. is_empty ( )
302+ && ( model_id == crate :: mesh_llm:: AUTO_MODEL_ID
303+ || data. iter ( ) . any ( |m| {
304+ m. get ( "id" )
305+ . and_then ( |id| id. as_str ( ) )
306+ . map ( |id| id. replace ( "@main" , "" ) == wanted)
307+ . unwrap_or ( false )
308+ } ) ) ;
309+ model_ever_visible |= visible;
310+ }
311+ }
312+ }
313+
208314 match client
209- . post ( format ! (
210- "{}/chat/completions" ,
211- crate :: managed_agents:: RELAY_MESH_API_BASE_URL
212- ) )
315+ . post ( & chat_url)
213316 . bearer_auth ( crate :: managed_agents:: RELAY_MESH_API_KEY_PLACEHOLDER )
214317 . json ( & serde_json:: json!( {
215318 "model" : model_id,
@@ -230,8 +333,12 @@ async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> {
230333 }
231334 tokio:: time:: sleep ( std:: time:: Duration :: from_secs ( 2 ) ) . await ;
232335 }
233- Err ( format ! (
234- "Buzz shared compute did not become inference-ready for {model_id}: {last_error}"
336+
337+ let failure = classify_mesh_readiness_failure ( model_ever_visible) ;
338+ Err ( mesh_readiness_failure_message (
339+ failure,
340+ model_id,
341+ & last_error,
235342 ) )
236343}
237344
@@ -411,8 +518,22 @@ pub async fn mesh_stop_node(
411518 app : AppHandle ,
412519 state : State < ' _ , AppState > ,
413520) -> CmdResult < mesh_llm:: MeshNodeStatus > {
414- let runtime = state. mesh_llm_runtime . lock ( ) . await . take ( ) ;
415- if let Some ( runtime) = runtime {
521+ // The single runtime slot is shared by serve (this machine SHARING
522+ // compute) and client (this machine CONSUMING a peer's compute) roles.
523+ // Stopping "Share compute" must NEVER tear down a client node: inspect the
524+ // role under the lock and, when it's a consume session, leave it running
525+ // and return its live status unchanged. The frontend also guards this, but
526+ // status can be stale between polls, so the backend is authoritative.
527+ let taken = {
528+ let mut guard = state. mesh_llm_runtime . lock ( ) . await ;
529+ if let Some ( runtime) = guard. as_ref ( ) {
530+ if !share_stop_should_teardown ( runtime. mode ( ) ) {
531+ return runtime. status ( ) . await . map_err ( |error| error. to_string ( ) ) ;
532+ }
533+ }
534+ guard. take ( )
535+ } ;
536+ if let Some ( runtime) = taken {
416537 runtime. stop ( ) . await . map_err ( |error| error. to_string ( ) ) ?;
417538 }
418539 save_mesh_sharing_config (
@@ -436,6 +557,20 @@ pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult<mesh_llm:
436557 }
437558}
438559
560+ /// Read-only host-side usage: who/what is using the compute this machine is
561+ /// sharing. Returns a zeroed snapshot when no runtime is active. No new trust
562+ /// surface — it reads the serving node's own runtime metrics.
563+ #[ tauri:: command]
564+ pub async fn mesh_serving_usage (
565+ state : State < ' _ , AppState > ,
566+ ) -> CmdResult < mesh_llm:: MeshServingUsage > {
567+ let runtime = state. mesh_llm_runtime . lock ( ) . await ;
568+ match runtime. as_ref ( ) {
569+ Some ( runtime) => runtime. serving_usage ( ) . await . map_err ( |e| e. to_string ( ) ) ,
570+ None => Ok ( mesh_llm:: MeshServingUsage :: default ( ) ) ,
571+ }
572+ }
573+
439574#[ tauri:: command]
440575pub async fn mesh_installed_models (
441576 state : State < ' _ , AppState > ,
@@ -479,6 +614,42 @@ mod tests {
479614 }
480615 }
481616
617+ #[ test]
618+ fn readiness_failure_is_catalog_sync_when_model_never_visible ( ) {
619+ assert_eq ! (
620+ classify_mesh_readiness_failure( false ) ,
621+ MeshReadinessFailure :: CatalogNeverSynced
622+ ) ;
623+ }
624+
625+ #[ test]
626+ fn readiness_failure_is_routing_when_model_was_visible ( ) {
627+ assert_eq ! (
628+ classify_mesh_readiness_failure( true ) ,
629+ MeshReadinessFailure :: RoutingNeverCompleted
630+ ) ;
631+ }
632+
633+ #[ test]
634+ fn readiness_messages_are_distinct_and_actionable ( ) {
635+ let catalog = mesh_readiness_failure_message (
636+ MeshReadinessFailure :: CatalogNeverSynced ,
637+ "auto" ,
638+ "HTTP 429" ,
639+ ) ;
640+ let routing = mesh_readiness_failure_message (
641+ MeshReadinessFailure :: RoutingNeverCompleted ,
642+ "auto" ,
643+ "HTTP 503" ,
644+ ) ;
645+ // Distinct diagnoses, each names the model and carries the raw detail.
646+ assert_ne ! ( catalog, routing) ;
647+ assert ! ( catalog. contains( "network path" ) ) ;
648+ assert ! ( catalog. contains( "HTTP 429" ) ) ;
649+ assert ! ( routing. contains( "did not complete" ) ) ;
650+ assert ! ( routing. contains( "HTTP 503" ) ) ;
651+ }
652+
482653 #[ test]
483654 fn mesh_status_cursor_uses_relay_composite_tiebreak ( ) {
484655 let event = nostr:: EventBuilder :: new ( nostr:: Kind :: TextNote , "status" )
@@ -546,6 +717,52 @@ mod tests {
546717 assert_eq ! ( pick_serve_target_for_model( targets, "model-missing" ) , None ) ;
547718 }
548719
720+ #[ test]
721+ fn share_stop_tears_down_serve_but_not_client ( ) {
722+ // Stopping "Share compute" tears down a serve node (we were sharing)
723+ // but must leave a client node alone (we are consuming a peer). This is
724+ // the backend half of the toggle-on regression: a client node occupies
725+ // the single slot and reports state:"running", and the stop path must
726+ // not kill it.
727+ assert ! (
728+ share_stop_should_teardown( mesh_llm:: MeshNodeMode :: Serve ) ,
729+ "serve node is our sharing runtime; stop must tear it down"
730+ ) ;
731+ assert ! (
732+ !share_stop_should_teardown( mesh_llm:: MeshNodeMode :: Client ) ,
733+ "client node is a consume session; stop must NOT tear it down"
734+ ) ;
735+ }
736+
737+ #[ test]
738+ fn client_status_serializes_with_running_state_and_client_mode ( ) {
739+ // Contract pin for the TS mock (e2eBridge.ts) and the frontend
740+ // predicate: a consuming node serializes as
741+ // {"state":"running","mode":"client"}. If serde renaming drifts, the
742+ // hand-written mock shape and `deriveMeshShareToggle` would silently
743+ // stop matching the real IPC payload.
744+ let status = mesh_llm:: MeshNodeStatus {
745+ state : mesh_llm:: MeshNodeState :: Running ,
746+ mode : Some ( mesh_llm:: MeshNodeMode :: Client ) ,
747+ // `MeshHealth::ok()` is module-private; build via the public fields.
748+ health : mesh_llm:: MeshHealth {
749+ status : mesh_llm:: MeshHealthStatus :: Ok ,
750+ reason : None ,
751+ } ,
752+ api_base_url : Some ( "http://127.0.0.1:9337/v1" . to_string ( ) ) ,
753+ console_url : None ,
754+ model_id : None ,
755+ model_name : None ,
756+ invite_token : None ,
757+ endpoint_id : None ,
758+ device_id : None ,
759+ device_name : None ,
760+ } ;
761+ let value = serde_json:: to_value ( & status) . expect ( "serialize mesh status" ) ;
762+ assert_eq ! ( value[ "state" ] , serde_json:: json!( "running" ) ) ;
763+ assert_eq ! ( value[ "mode" ] , serde_json:: json!( "client" ) ) ;
764+ }
765+
549766 #[ tokio:: test]
550767 async fn cold_client_preflight_requires_explicit_target ( ) {
551768 let state = build_app_state ( ) ;
0 commit comments