Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 50 additions & 50 deletions desktop/src-tauri/Cargo.lock

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
iroh = { version = "1.0.2", optional = true }
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 }
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 }
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 }
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 }
# Model catalog + hardware survey for the Share-compute model picker (same
# diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client.
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-client", optional = true }
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-node", optional = true }
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-system", optional = true }
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-events", optional = true }
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-client", optional = true }
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-node", optional = true }
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-system", optional = true }
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-events", optional = true }
base64 = "0.22"
sha2 = "0.11"
tar = "0.4"
Expand Down
233 changes: 225 additions & 8 deletions desktop/src-tauri/src/commands/mesh_llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result<Option<MeshSharingConfig>
const RELAY_MESH_RUNTIME_NO_TARGET: &str =
"Buzz shared compute requires a live serving member; start serving the selected model on a member, then try again";

/// Whether the Share-compute "stop sharing" path (`mesh_stop_node`) should tear
/// down the runtime currently occupying the single slot.
///
/// Serve nodes (this machine SHARING compute) are torn down. Client nodes (this
/// machine CONSUMING a peer's compute) share the same slot and MUST be left
/// running — stopping "Share compute" must never kill a consume session the
/// user didn't start from this switch.
#[cfg(feature = "mesh-llm")]
fn share_stop_should_teardown(mode: mesh_llm::MeshNodeMode) -> bool {
matches!(mode, mesh_llm::MeshNodeMode::Serve)
}

pub type CmdResult<T> = Result<T, String>;

fn advance_mesh_status_cursor(
Expand Down Expand Up @@ -197,19 +209,110 @@ pub async fn mesh_start_node(
/// Mesh can bind its HTTP ingress and advertise a model shortly before the
/// router has installed a usable target. Probe the exact chat path agents use
/// so startup cannot race that gap (`single target None unavailable`).
/// Which startup stage a mesh client is stuck at when it never becomes
/// inference-ready. The two live-observed failure modes are physically
/// distinct and want different user copy:
///
/// * `CatalogNeverSynced` — the local client node came up and connected to
/// the host at the control level (ping/RTT fine), but the served model
/// never appeared in the local `/v1/models` catalog. That catalog is
/// populated by the peer gossip exchange; when the gossip bi-stream can't
/// establish across the network (observed as iroh
/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays
/// empty forever and every request is rejected "model not available".
/// Root cause is the network path between this machine and the host.
/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but
/// inference requests never completed (routing/transport to the host
/// failing per-request). The host is discoverable and advertised but not
/// actually serving us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MeshReadinessFailure {
CatalogNeverSynced,
RoutingNeverCompleted,
}

/// Pure classifier: given whether the served model was ever observed in the
/// local `/v1/models` catalog during the wait, decide which stage failed.
/// Split out so the diagnosis is unit-testable without a live mesh.
fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure {
if model_ever_visible {
MeshReadinessFailure::RoutingNeverCompleted
} else {
MeshReadinessFailure::CatalogNeverSynced
}
}

/// Actionable, non-technical copy for a readiness failure. `last_detail` is the
/// last raw transport/HTTP error, appended for support triage.
fn mesh_readiness_failure_message(
failure: MeshReadinessFailure,
model_id: &str,
last_detail: &str,
) -> String {
match failure {
MeshReadinessFailure::CatalogNeverSynced => format!(
"Buzz shared compute connected to the serving member but could not sync \
the model list for \"{model_id}\" — this is a network path problem \
between this machine and the host (the compute node is reachable for \
pings but the model-sync stream did not establish). Try again, or have \
the host and this machine on a more direct network. (last: {last_detail})"
),
MeshReadinessFailure::RoutingNeverCompleted => format!(
"Buzz shared compute found \"{model_id}\" on a serving member but inference \
requests did not complete — the host is discoverable but not currently \
reachable for requests. Try again shortly. (last: {last_detail})"
),
}
}

/// Poll the local mesh OpenAI ingress until a real inference for `model_id`
/// succeeds, or a deadline elapses. On failure, returns a stage-specific,
/// actionable message (see [`MeshReadinessFailure`]) rather than a raw
/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach
/// the host".
async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|error| format!("failed to build mesh readiness client: {error}"))?;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120);
let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL);
let chat_url = format!(
"{}/chat/completions",
crate::managed_agents::RELAY_MESH_API_BASE_URL
);
let mut last_error = "mesh inference is not ready".to_string();
// Track whether the served model ever reached the local catalog — the
// signal that splits "catalog never synced" from "routing never completed".
let mut model_ever_visible = false;

while tokio::time::Instant::now() < deadline {
// Refresh catalog visibility. "auto" delegates model choice to the
// router, so any advertised model counts as the catalog having synced.
if let Ok(response) = client
.get(&models_url)
.bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER)
.send()
.await
{
if let Ok(body) = response.json::<serde_json::Value>().await {
if let Some(data) = body.get("data").and_then(|d| d.as_array()) {
let wanted = model_id.trim().replace("@main", "");
let visible = !data.is_empty()
&& (model_id == crate::mesh_llm::AUTO_MODEL_ID
|| data.iter().any(|m| {
m.get("id")
.and_then(|id| id.as_str())
.map(|id| id.replace("@main", "") == wanted)
.unwrap_or(false)
}));
model_ever_visible |= visible;
}
}
}

match client
.post(format!(
"{}/chat/completions",
crate::managed_agents::RELAY_MESH_API_BASE_URL
))
.post(&chat_url)
.bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER)
.json(&serde_json::json!({
"model": model_id,
Expand All @@ -230,8 +333,12 @@ async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> {
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
Err(format!(
"Buzz shared compute did not become inference-ready for {model_id}: {last_error}"

let failure = classify_mesh_readiness_failure(model_ever_visible);
Err(mesh_readiness_failure_message(
failure,
model_id,
&last_error,
))
}

Expand Down Expand Up @@ -411,8 +518,22 @@ pub async fn mesh_stop_node(
app: AppHandle,
state: State<'_, AppState>,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
let runtime = state.mesh_llm_runtime.lock().await.take();
if let Some(runtime) = runtime {
// The single runtime slot is shared by serve (this machine SHARING
// compute) and client (this machine CONSUMING a peer's compute) roles.
// Stopping "Share compute" must NEVER tear down a client node: inspect the
// role under the lock and, when it's a consume session, leave it running
// and return its live status unchanged. The frontend also guards this, but
// status can be stale between polls, so the backend is authoritative.
let taken = {
let mut guard = state.mesh_llm_runtime.lock().await;
if let Some(runtime) = guard.as_ref() {
if !share_stop_should_teardown(runtime.mode()) {
return runtime.status().await.map_err(|error| error.to_string());
}
}
guard.take()
};
if let Some(runtime) = taken {
runtime.stop().await.map_err(|error| error.to_string())?;
}
save_mesh_sharing_config(
Expand All @@ -436,6 +557,20 @@ pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult<mesh_llm:
}
}

/// Read-only host-side usage: who/what is using the compute this machine is
/// sharing. Returns a zeroed snapshot when no runtime is active. No new trust
/// surface — it reads the serving node's own runtime metrics.
#[tauri::command]
pub async fn mesh_serving_usage(
state: State<'_, AppState>,
) -> CmdResult<mesh_llm::MeshServingUsage> {
let runtime = state.mesh_llm_runtime.lock().await;
match runtime.as_ref() {
Some(runtime) => runtime.serving_usage().await.map_err(|e| e.to_string()),
None => Ok(mesh_llm::MeshServingUsage::default()),
}
}

#[tauri::command]
pub async fn mesh_installed_models(
state: State<'_, AppState>,
Expand Down Expand Up @@ -479,6 +614,42 @@ mod tests {
}
}

#[test]
fn readiness_failure_is_catalog_sync_when_model_never_visible() {
assert_eq!(
classify_mesh_readiness_failure(false),
MeshReadinessFailure::CatalogNeverSynced
);
}

#[test]
fn readiness_failure_is_routing_when_model_was_visible() {
assert_eq!(
classify_mesh_readiness_failure(true),
MeshReadinessFailure::RoutingNeverCompleted
);
}

#[test]
fn readiness_messages_are_distinct_and_actionable() {
let catalog = mesh_readiness_failure_message(
MeshReadinessFailure::CatalogNeverSynced,
"auto",
"HTTP 429",
);
let routing = mesh_readiness_failure_message(
MeshReadinessFailure::RoutingNeverCompleted,
"auto",
"HTTP 503",
);
// Distinct diagnoses, each names the model and carries the raw detail.
assert_ne!(catalog, routing);
assert!(catalog.contains("network path"));
assert!(catalog.contains("HTTP 429"));
assert!(routing.contains("did not complete"));
assert!(routing.contains("HTTP 503"));
}

#[test]
fn mesh_status_cursor_uses_relay_composite_tiebreak() {
let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status")
Expand Down Expand Up @@ -546,6 +717,52 @@ mod tests {
assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None);
}

#[test]
fn share_stop_tears_down_serve_but_not_client() {
// Stopping "Share compute" tears down a serve node (we were sharing)
// but must leave a client node alone (we are consuming a peer). This is
// the backend half of the toggle-on regression: a client node occupies
// the single slot and reports state:"running", and the stop path must
// not kill it.
assert!(
share_stop_should_teardown(mesh_llm::MeshNodeMode::Serve),
"serve node is our sharing runtime; stop must tear it down"
);
assert!(
!share_stop_should_teardown(mesh_llm::MeshNodeMode::Client),
"client node is a consume session; stop must NOT tear it down"
);
}

#[test]
fn client_status_serializes_with_running_state_and_client_mode() {
// Contract pin for the TS mock (e2eBridge.ts) and the frontend
// predicate: a consuming node serializes as
// {"state":"running","mode":"client"}. If serde renaming drifts, the
// hand-written mock shape and `deriveMeshShareToggle` would silently
// stop matching the real IPC payload.
let status = mesh_llm::MeshNodeStatus {
state: mesh_llm::MeshNodeState::Running,
mode: Some(mesh_llm::MeshNodeMode::Client),
// `MeshHealth::ok()` is module-private; build via the public fields.
health: mesh_llm::MeshHealth {
status: mesh_llm::MeshHealthStatus::Ok,
reason: None,
},
api_base_url: Some("http://127.0.0.1:9337/v1".to_string()),
console_url: None,
model_id: None,
model_name: None,
invite_token: None,
endpoint_id: None,
device_id: None,
device_name: None,
};
let value = serde_json::to_value(&status).expect("serialize mesh status");
assert_eq!(value["state"], serde_json::json!("running"));
assert_eq!(value["mode"], serde_json::json!("client"));
}

#[tokio::test]
async fn cold_client_preflight_requires_explicit_target() {
let state = build_app_state();
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ pub fn run() {
mesh_start_node,
mesh_stop_node,
mesh_node_status,
mesh_serving_usage,
mesh_installed_models,
mesh_model_catalog,
update_managed_agent,
Expand Down
Loading
Loading