Skip to content

Commit 12ac55b

Browse files
micspiralmichaelnealenpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67dTyler Longwell
authored
fix(desktop): shared-compute usability — share toggle, usage indicator, model resync (#2448)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
1 parent 2f67225 commit 12ac55b

16 files changed

Lines changed: 1036 additions & 71 deletions

File tree

desktop/src-tauri/Cargo.lock

Lines changed: 50 additions & 50 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/src-tauri/Cargo.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona
9090
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
9191
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
9292
iroh = { version = "1.0.2", optional = true }
93-
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
94-
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
93+
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
94+
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
9595
# Model catalog + hardware survey for the Share-compute model picker (same
9696
# diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client.
97-
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-client", optional = true }
98-
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-node", optional = true }
99-
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-system", optional = true }
100-
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-events", optional = true }
97+
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-client", optional = true }
98+
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-node", optional = true }
99+
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-system", optional = true }
100+
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-events", optional = true }
101101
base64 = "0.22"
102102
sha2 = "0.11"
103103
tar = "0.4"

desktop/src-tauri/src/commands/mesh_llm.rs

Lines changed: 225 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result<Option<MeshSharingConfig>
4545
const 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+
4860
pub type CmdResult<T> = Result<T, String>;
4961

5062
fn 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".
200273
async 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]
440575
pub 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();

desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,7 @@ pub fn run() {
799799
mesh_start_node,
800800
mesh_stop_node,
801801
mesh_node_status,
802+
mesh_serving_usage,
802803
mesh_installed_models,
803804
mesh_model_catalog,
804805
update_managed_agent,

0 commit comments

Comments
 (0)