mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): use collective mesh routing for Auto (#2825)
Uses MeshLLM built-in `mesh` collective intelligence / Mixture of Agents when Buzz Auto sees two or more distinct physical models. With zero or one model, Auto remains ordinary `auto`. This is an alternative way to improve tool responses, accuracy, and resistance to hallucination when a high-latency distributed mesh contains diverse models. Models and members may come and go: collective routing enables only after stable capacity, drops on confirmed contraction, and can recover later. Mesh-specific failures retry once through ordinary Auto. This update also pins MeshLLM to a v0.73.1-compatible backport of [MeshLLM #1074](https://github.com/Mesh-LLM/mesh-llm/pull/1074), so client-only Buzz nodes cannot enter model election or download a remote provider model. Buzz preserves the selected local sharing model and switches an existing client to sharing across a controlled app restart, retaining one runtime and one `:9337` / `:3131` pair per machine. Validation: - Full local `just ci` passes on the cleaned branch. - MeshLLM host-runtime suite: 1,568 passed, 0 failed; strict Clippy passes. - Buzz desktop Tauri suite with `mesh-llm`: 1,721 passed, 0 failed; strict feature Clippy passes. - Playwright covers client-to-share using the saved local model and no destructive stop. - Packaged two-machine testing proved single-model routing, dual-model collective routing, tool-markup fallback, and runtime reuse. - Packaged client-only recheck routed a real Mini Buzz turn through M5 while Mini stayed `is_client=true`, `is_host=false`, hosted no models, and created no Gemma cache. Builds on the recovery work merged in #2823; this PR does not duplicate it. --------- Signed-off-by: Michael Neale <michael.neale@gmail.com> 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>
This commit is contained in:
@@ -726,6 +726,12 @@ pub struct Config {
|
||||
pub anthropic_api_version: String,
|
||||
/// OpenAI endpoint selection. See [`OpenAiApi`].
|
||||
pub openai_api: OpenAiApi,
|
||||
/// Prefer mesh-llm's virtual `mesh` model when the configured/effective
|
||||
/// OpenAI model is `auto` and the live model catalog advertises it.
|
||||
/// Set by Buzz's relay-mesh provider via
|
||||
/// `BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1`; other providers keep their
|
||||
/// existing `auto` semantics.
|
||||
pub prefer_mesh_for_auto: bool,
|
||||
pub hints_enabled: bool,
|
||||
/// Thinking/reasoning effort level. `None` = use provider default (no
|
||||
/// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`.
|
||||
@@ -798,6 +804,7 @@ impl Config {
|
||||
base_url,
|
||||
anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"),
|
||||
openai_api,
|
||||
prefer_mesh_for_auto: parse_env("BUZZ_AGENT_PREFER_MESH_FOR_AUTO", 0u8)? != 0,
|
||||
max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0)?,
|
||||
max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 32_768)?,
|
||||
llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?),
|
||||
@@ -844,6 +851,7 @@ impl Config {
|
||||
system_prompt: String::new(),
|
||||
anthropic_api_version: "2023-06-01".into(),
|
||||
openai_api: OpenAiApi::Chat,
|
||||
prefer_mesh_for_auto: false,
|
||||
max_rounds: 0,
|
||||
max_output_tokens: 1,
|
||||
llm_timeout: Duration::from_secs(30),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
78
desktop/src-tauri/Cargo.lock
generated
78
desktop/src-tauri/Cargo.lock
generated
@@ -4935,7 +4935,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-api-client"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"mesh-llm-client",
|
||||
@@ -4945,7 +4945,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-api-server"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mesh-llm-api-client",
|
||||
@@ -4956,12 +4956,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-build-info"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
|
||||
[[package]]
|
||||
name = "mesh-llm-client"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4993,7 +4993,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-config"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -5009,7 +5009,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-embedded-runtime"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mesh-llm-host-runtime",
|
||||
@@ -5019,7 +5019,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-events"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5031,7 +5031,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-gpu-bench"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
@@ -5044,7 +5044,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-guardrails"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5053,7 +5053,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-hardware-profile"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"mesh-llm-native-runtime",
|
||||
]
|
||||
@@ -5061,7 +5061,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-host-runtime"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -5154,7 +5154,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-identity"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
@@ -5176,7 +5176,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-native-runtime"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -5187,7 +5187,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-node"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mesh-llm-types",
|
||||
@@ -5201,7 +5201,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-plugin"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5218,7 +5218,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-plugin-manager"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -5236,7 +5236,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-protocol"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hex",
|
||||
@@ -5249,7 +5249,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-routing"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"iroh",
|
||||
]
|
||||
@@ -5257,7 +5257,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-runtime-install"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -5280,7 +5280,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-sdk"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mesh-llm-api-client",
|
||||
@@ -5295,7 +5295,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-skills"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -5306,7 +5306,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-system"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5329,7 +5329,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-types"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
@@ -5340,12 +5340,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mesh-llm-ui"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
|
||||
[[package]]
|
||||
name = "mesh-mixture-of-agents"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"mesh-llm-guardrails",
|
||||
@@ -5418,7 +5418,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "model-artifact"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5429,7 +5429,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "model-hf"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5447,7 +5447,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "model-package"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5467,7 +5467,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "model-ref"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -5475,7 +5475,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "model-resolver"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"model-artifact",
|
||||
@@ -6504,7 +6504,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "openai-frontend"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -9037,7 +9037,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
[[package]]
|
||||
name = "skippy-cache"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
@@ -9047,7 +9047,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "skippy-coordinator"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -9055,7 +9055,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "skippy-ffi"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
@@ -9063,12 +9063,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "skippy-metrics"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
|
||||
[[package]]
|
||||
name = "skippy-protocol"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"prost",
|
||||
"prost-build",
|
||||
@@ -9079,7 +9079,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "skippy-runtime"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -9093,7 +9093,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "skippy-server"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -9121,7 +9121,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "skippy-topology"
|
||||
version = "0.73.1"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb"
|
||||
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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", 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 }
|
||||
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", 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 = "f455d493a2ae82baf2a326e2d0fda351433b4b30", 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", 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 }
|
||||
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-client", optional = true }
|
||||
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-node", optional = true }
|
||||
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-system", optional = true }
|
||||
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-events", optional = true }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.11"
|
||||
tar = "0.4"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::{app_state::AppState, mesh_llm, relay};
|
||||
@@ -57,8 +58,85 @@ fn share_stop_should_teardown(mode: mesh_llm::MeshNodeMode) -> bool {
|
||||
matches!(mode, mesh_llm::MeshNodeMode::Serve)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MeshStartPlan {
|
||||
Start,
|
||||
RestartToReplaceClient,
|
||||
RejectOccupied,
|
||||
}
|
||||
|
||||
fn mesh_start_plan(
|
||||
requested_mode: mesh_llm::MeshNodeMode,
|
||||
existing_mode: Option<mesh_llm::MeshNodeMode>,
|
||||
) -> MeshStartPlan {
|
||||
match (requested_mode, existing_mode) {
|
||||
(_, None) => MeshStartPlan::Start,
|
||||
(mesh_llm::MeshNodeMode::Serve, Some(mesh_llm::MeshNodeMode::Client)) => {
|
||||
MeshStartPlan::RestartToReplaceClient
|
||||
}
|
||||
_ => MeshStartPlan::RejectOccupied,
|
||||
}
|
||||
}
|
||||
|
||||
fn sharing_config_from_request(
|
||||
request: &mesh_llm::StartMeshNodeRequest,
|
||||
) -> CmdResult<MeshSharingConfig> {
|
||||
let model_id = request
|
||||
.model_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|model_id| !model_id.is_empty())
|
||||
.ok_or_else(|| "modelId is required for serve mode".to_string())?;
|
||||
Ok(MeshSharingConfig {
|
||||
enabled: true,
|
||||
model_id: model_id.to_string(),
|
||||
max_vram_gb: request.max_vram_gb,
|
||||
})
|
||||
}
|
||||
|
||||
fn restarting_share_status(config: &MeshSharingConfig) -> mesh_llm::MeshNodeStatus {
|
||||
mesh_llm::MeshNodeStatus {
|
||||
state: mesh_llm::MeshNodeState::Starting,
|
||||
mode: Some(mesh_llm::MeshNodeMode::Serve),
|
||||
health: mesh_llm::MeshHealth {
|
||||
status: mesh_llm::MeshHealthStatus::Degraded,
|
||||
reason: Some("Buzz is restarting to switch this machine to sharing".to_string()),
|
||||
},
|
||||
api_base_url: None,
|
||||
console_url: None,
|
||||
model_id: Some(config.model_id.clone()),
|
||||
model_name: Some(config.model_id.clone()),
|
||||
invite_token: None,
|
||||
endpoint_id: None,
|
||||
device_id: None,
|
||||
device_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_to_share(
|
||||
app: &AppHandle,
|
||||
config: &MeshSharingConfig,
|
||||
) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
save_mesh_sharing_config(app, config)?;
|
||||
let status = restarting_share_status(config);
|
||||
app.request_restart();
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub type CmdResult<T> = Result<T, String>;
|
||||
|
||||
fn buzz_mesh_name_for_relay(relay_url: &str) -> String {
|
||||
let normalized = url::Url::parse(relay_url.trim())
|
||||
.map(|url| url.origin().ascii_serialization())
|
||||
.unwrap_or_else(|_| relay_url.trim().trim_end_matches('/').to_ascii_lowercase());
|
||||
let digest = hex::encode(Sha256::digest(normalized.as_bytes()));
|
||||
format!("buzz-community-{}", &digest[..32])
|
||||
}
|
||||
|
||||
fn buzz_mesh_name(state: &AppState) -> String {
|
||||
buzz_mesh_name_for_relay(&relay::relay_ws_url_with_override(state))
|
||||
}
|
||||
|
||||
fn advance_mesh_status_cursor(
|
||||
filter: &mut serde_json::Value,
|
||||
page: &[nostr::Event],
|
||||
@@ -137,6 +215,81 @@ pub(crate) async fn resolve_trusted_owner_ids_or_self_only(state: &AppState) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose validated live endpoints from other runtimes in this Buzz community.
|
||||
/// The stable relay-derived mesh name gives every runtime the same MeshLLM mesh
|
||||
/// identity; these endpoints supply transport bootstrap only.
|
||||
fn buzz_mesh_join_targets(
|
||||
mut targets: Vec<mesh_llm::MeshServeTarget>,
|
||||
self_owner_id: &str,
|
||||
) -> Vec<mesh_llm::MeshServeTarget> {
|
||||
targets.retain(|target| {
|
||||
target.reporter_pubkey.is_some()
|
||||
&& target
|
||||
.owner_id
|
||||
.as_deref()
|
||||
.is_some_and(|owner| !owner.eq_ignore_ascii_case(self_owner_id.trim()))
|
||||
});
|
||||
targets.sort_by(|left, right| {
|
||||
left.reporter_pubkey
|
||||
.cmp(&right.reporter_pubkey)
|
||||
.then_with(|| left.owner_id.cmp(&right.owner_id))
|
||||
.then_with(|| left.endpoint_id.cmp(&right.endpoint_id))
|
||||
.then_with(|| left.endpoint_addr.cmp(&right.endpoint_addr))
|
||||
.then_with(|| left.model_id.cmp(&right.model_id))
|
||||
});
|
||||
targets.dedup_by(|left, right| left.endpoint_addr == right.endpoint_addr);
|
||||
targets
|
||||
}
|
||||
|
||||
/// Resolve the validated member endpoint this runtime should join to enter the
|
||||
/// existing Buzz community mesh. `Ok(None)` means this machine is the first
|
||||
/// live serving member (or is itself the shared bootstrap contact).
|
||||
pub(crate) async fn resolve_buzz_mesh_join_targets(
|
||||
state: &AppState,
|
||||
) -> Result<Vec<mesh_llm::MeshServeTarget>, String> {
|
||||
let events = query_mesh_discovery_events(state).await?;
|
||||
let self_owner_id = mesh_llm::ensure_owner_identity()
|
||||
.map_err(|error| format!("failed to load mesh owner identity: {error}"))?
|
||||
.owner_id;
|
||||
Ok(buzz_mesh_join_targets(
|
||||
mesh_llm::availability_from_events(events).serve_targets,
|
||||
&self_owner_id,
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve the initial admission roster and bootstrap endpoint from one relay
|
||||
/// snapshot. A node start used to repeat the full membership + status query
|
||||
/// for each value, making Share Compute startup both slower and more exposed
|
||||
/// to inconsistent snapshots.
|
||||
async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec<String>, Option<String>) {
|
||||
match query_mesh_discovery_events(state).await {
|
||||
Ok(events) => {
|
||||
let trusted_owner_ids = mesh_llm::owner_ids_from_events(&events);
|
||||
let join_token = mesh_llm::ensure_owner_identity()
|
||||
.ok()
|
||||
.and_then(|identity| {
|
||||
buzz_mesh_join_targets(
|
||||
mesh_llm::availability_from_events(events).serve_targets,
|
||||
&identity.owner_id,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
})
|
||||
.map(|target| target.endpoint_addr);
|
||||
(trusted_owner_ids, join_token)
|
||||
}
|
||||
Err(error) => {
|
||||
// Initial startup fails closed to this runtime's own owner. Share
|
||||
// Compute must still start for the first member and through a
|
||||
// transient relay outage; the coordinator retries convergence.
|
||||
eprintln!(
|
||||
"buzz-mesh: startup discovery failed; allowing only this node and starting isolated for now: {error}"
|
||||
);
|
||||
(Vec::new(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> CmdResult<()> {
|
||||
let Some(config) = load_mesh_sharing_config(app)? else {
|
||||
return Ok(());
|
||||
@@ -144,6 +297,10 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C
|
||||
if !config.enabled || config.model_id.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if state.mesh_llm_runtime.lock().await.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(state).await;
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
if runtime.is_some() {
|
||||
return Ok(());
|
||||
@@ -152,8 +309,9 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C
|
||||
mode: mesh_llm::MeshNodeMode::Serve,
|
||||
model_id: Some(config.model_id),
|
||||
max_vram_gb: config.max_vram_gb,
|
||||
join_token: None,
|
||||
trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
|
||||
join_token,
|
||||
mesh_name: Some(buzz_mesh_name(state)),
|
||||
trusted_owner_ids: Some(trusted_owner_ids),
|
||||
};
|
||||
let started = mesh_llm::DesktopMeshRuntime::start(request)
|
||||
.await
|
||||
@@ -170,37 +328,91 @@ pub async fn mesh_start_node(
|
||||
state: State<'_, AppState>,
|
||||
mut request: mesh_llm::StartMeshNodeRequest,
|
||||
) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
// Frontend requests never carry a roster; resolve it here so every
|
||||
// UI-started node enforces the member allowlist.
|
||||
if request.trusted_owner_ids.is_none() {
|
||||
request.trusted_owner_ids = Some(resolve_trusted_owner_ids_or_self_only(&state).await);
|
||||
let sharing_config = if request.mode == mesh_llm::MeshNodeMode::Serve {
|
||||
Some(sharing_config_from_request(&request)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Never replace a client runtime in-process. Even a Ready SDK handle can
|
||||
// finish `stop()` while its native listeners are still releasing :9337
|
||||
// and :3131; a pending client has no shutdown handle at all. Persist the
|
||||
// requested serving configuration and switch roles across a controlled
|
||||
// process restart, the only boundary that proves both ports are clean.
|
||||
{
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
if let Some(existing) = runtime.as_ref() {
|
||||
let plan = mesh_start_plan(request.mode, Some(existing.mode()));
|
||||
match plan {
|
||||
MeshStartPlan::RestartToReplaceClient => {
|
||||
let config = sharing_config
|
||||
.as_ref()
|
||||
.ok_or_else(|| "serving configuration is unavailable".to_string())?;
|
||||
drop(runtime);
|
||||
return restart_to_share(&app, config);
|
||||
}
|
||||
MeshStartPlan::RejectOccupied => {
|
||||
return Err("mesh node is already running".to_string());
|
||||
}
|
||||
MeshStartPlan::Start => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frontend requests never carry a roster. Resolve it and the bootstrap
|
||||
// endpoint from one snapshot so UI startup does not repeat relay probes.
|
||||
if request.trusted_owner_ids.is_none() || request.join_token.is_none() {
|
||||
let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(&state).await;
|
||||
request.trusted_owner_ids.get_or_insert(trusted_owner_ids);
|
||||
if request.join_token.is_none() {
|
||||
request.join_token = join_token;
|
||||
}
|
||||
}
|
||||
request.mesh_name = Some(buzz_mesh_name(&state));
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
if runtime.is_some() {
|
||||
|
||||
let plan = match runtime.as_ref() {
|
||||
Some(existing) => mesh_start_plan(request.mode, Some(existing.mode())),
|
||||
None => mesh_start_plan(request.mode, None),
|
||||
};
|
||||
if plan == MeshStartPlan::RestartToReplaceClient {
|
||||
let config = sharing_config
|
||||
.as_ref()
|
||||
.ok_or_else(|| "serving configuration is unavailable".to_string())?;
|
||||
drop(runtime);
|
||||
return restart_to_share(&app, config);
|
||||
}
|
||||
if plan == MeshStartPlan::RejectOccupied {
|
||||
return Err("mesh node is already running".to_string());
|
||||
}
|
||||
|
||||
let saved_request = request.clone();
|
||||
let started = mesh_llm::DesktopMeshRuntime::start(request)
|
||||
.await
|
||||
.map_err(|error| format!("{error:#}"))?;
|
||||
let status = started
|
||||
.status()
|
||||
.await
|
||||
.map_err(|error| format!("mesh node started but status probe failed: {error:#}"))?;
|
||||
let status = match started.status().await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
let cleanup = started.stop().await;
|
||||
if let Err(cleanup_error) = &cleanup {
|
||||
eprintln!(
|
||||
"buzz-mesh: started node status failed and cleanup was incomplete: {cleanup_error:#}"
|
||||
);
|
||||
}
|
||||
// The handle was never installed into AppState, so shutdown cannot
|
||||
// see it again. Restart even when stop reported success: the
|
||||
// process boundary guarantees native :9337/:3131 listeners cannot
|
||||
// linger behind an untracked runtime.
|
||||
drop(runtime);
|
||||
app.request_restart();
|
||||
return Err(format!(
|
||||
"mesh node started but status probe failed: {error:#}; Buzz is restarting to guarantee cleanup"
|
||||
));
|
||||
}
|
||||
};
|
||||
*runtime = Some(started);
|
||||
drop(runtime);
|
||||
if saved_request.mode == mesh_llm::MeshNodeMode::Serve {
|
||||
if let Some(model_id) = saved_request.model_id.as_deref() {
|
||||
save_mesh_sharing_config(
|
||||
&app,
|
||||
&MeshSharingConfig {
|
||||
enabled: true,
|
||||
model_id: model_id.to_string(),
|
||||
max_vram_gb: saved_request.max_vram_gb,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if let Some(config) = sharing_config.as_ref() {
|
||||
save_mesh_sharing_config(&app, config)?;
|
||||
}
|
||||
mesh_llm::publish_current_status_once(&app, "start").await;
|
||||
Ok(status)
|
||||
@@ -398,12 +610,21 @@ pub(crate) async fn ensure_client_node_for_model(
|
||||
mode: mesh_llm::MeshNodeMode::Client,
|
||||
model_id: None,
|
||||
max_vram_gb: None,
|
||||
join_token: Some(join_token),
|
||||
join_token: Some(join_token.clone()),
|
||||
mesh_name: Some(buzz_mesh_name(state)),
|
||||
trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await),
|
||||
};
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
if runtime.is_some() {
|
||||
return Err("mesh node changed while starting Buzz shared compute client".to_string());
|
||||
if let Some(existing) = runtime.as_ref() {
|
||||
// Another GUI agent may have won the startup race while this caller
|
||||
// was resolving membership. The runtime is machine-scoped, not
|
||||
// agent-scoped: join its selected endpoint into the existing node and
|
||||
// let every caller reuse the same local ingress.
|
||||
existing
|
||||
.dial_endpoint_addr(join_token)
|
||||
.await
|
||||
.map_err(|error| format!("mesh dial failed: {error}"))?;
|
||||
return existing.status().await.map_err(|error| error.to_string());
|
||||
}
|
||||
let started = mesh_llm::DesktopMeshRuntime::start(start)
|
||||
.await
|
||||
@@ -644,266 +865,5 @@ pub async fn mesh_model_catalog() -> CmdResult<mesh_llm::MeshModelCatalog> {
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "mesh-llm"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_state::build_app_state;
|
||||
|
||||
fn target(model_id: &str, endpoint_addr: &str) -> mesh_llm::MeshServeTarget {
|
||||
mesh_llm::MeshServeTarget {
|
||||
model_id: model_id.to_string(),
|
||||
model_name: None,
|
||||
endpoint_addr: endpoint_addr.to_string(),
|
||||
node_name: None,
|
||||
capacity: None,
|
||||
endpoint_id: None,
|
||||
device_id: None,
|
||||
device_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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")
|
||||
.custom_created_at(nostr::Timestamp::from(1_234))
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.expect("sign test status");
|
||||
let mut filter = mesh_llm::mesh_status_filter();
|
||||
|
||||
let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event))
|
||||
.expect("advance status cursor");
|
||||
|
||||
assert_eq!(cursor, (1_234, event.id.to_hex()));
|
||||
assert_eq!(filter["until"], serde_json::json!(1_234));
|
||||
assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex()));
|
||||
assert_eq!(
|
||||
filter["limit"],
|
||||
serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_returns_first_match_for_model() {
|
||||
let targets = vec![
|
||||
target("model-a", "addr-a"),
|
||||
target("model-b", "addr-b1"),
|
||||
target("model-b", "addr-b2"),
|
||||
];
|
||||
// Matches by model id and returns the first such target.
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, "model-b").map(|t| t.endpoint_addr),
|
||||
Some("addr-b1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_normalizes_main_revision() {
|
||||
let targets = vec![target("org/model@main:q4", "addr")];
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, "org/model:q4").map(|target| target.endpoint_addr),
|
||||
Some("addr".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_auto_takes_any_live_target() {
|
||||
let targets = vec![target("model-a", "addr-a"), target("model-b", "addr-b")];
|
||||
// "auto" delegates model choice to the mesh router; any live target
|
||||
// is a valid bootstrap peer (first one wins).
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, crate::mesh_llm::AUTO_MODEL_ID)
|
||||
.map(|t| t.endpoint_addr),
|
||||
Some("addr-a".to_string())
|
||||
);
|
||||
// But auto with zero live targets still falls closed.
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(Vec::new(), crate::mesh_llm::AUTO_MODEL_ID),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_none_when_model_not_hosted() {
|
||||
let targets = vec![target("model-a", "addr-a")];
|
||||
// No live target serves this model -> caller falls closed.
|
||||
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();
|
||||
let error = ensure_client_node_for_model(&state, "demo/model", None)
|
||||
.await
|
||||
.expect_err("cold relay-mesh preflight must not auto-pick a target");
|
||||
assert_eq!(error, RELAY_MESH_RUNTIME_NO_TARGET);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Buzz shared compute as a client"). That forbade exactly what 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 and no explicit target still reuses the
|
||||
/// existing runtime. Cold starts without a target are rejected before mesh-llm
|
||||
/// startup; running runtimes are already joined to whatever target the
|
||||
/// frontend selected earlier.
|
||||
///
|
||||
/// Hardware-gated (`#[ignore]`): loads a real model. Run with:
|
||||
/// cargo test -p buzz-desktop --features mesh-llm \
|
||||
/// ensure_serve_runtime_serves_other_model -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore = "loads a real model; run manually with --ignored"]
|
||||
fn ensure_serve_runtime_serves_other_model() {
|
||||
std::thread::Builder::new()
|
||||
.name("mesh-hardware-acceptance".to_string())
|
||||
.stack_size(mesh_llm::MESH_WORKER_STACK_SIZE)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_stack_size(mesh_llm::MESH_WORKER_STACK_SIZE)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build mesh acceptance runtime");
|
||||
runtime.block_on(async {
|
||||
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,
|
||||
trusted_owner_ids: 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;
|
||||
}
|
||||
});
|
||||
})
|
||||
.expect("spawn mesh acceptance thread")
|
||||
.join()
|
||||
.expect("mesh acceptance thread panicked");
|
||||
}
|
||||
}
|
||||
#[path = "mesh_llm_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
499
desktop/src-tauri/src/commands/mesh_llm_tests.rs
Normal file
499
desktop/src-tauri/src/commands/mesh_llm_tests.rs
Normal file
@@ -0,0 +1,499 @@
|
||||
use super::*;
|
||||
use crate::app_state::build_app_state;
|
||||
|
||||
fn target(model_id: &str, endpoint_addr: &str) -> mesh_llm::MeshServeTarget {
|
||||
mesh_llm::MeshServeTarget {
|
||||
model_id: model_id.to_string(),
|
||||
model_name: None,
|
||||
endpoint_addr: endpoint_addr.to_string(),
|
||||
reporter_pubkey: None,
|
||||
owner_id: None,
|
||||
node_name: None,
|
||||
capacity: None,
|
||||
endpoint_id: None,
|
||||
device_id: None,
|
||||
device_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_target(
|
||||
reporter_pubkey: &str,
|
||||
model_id: &str,
|
||||
endpoint_addr: &str,
|
||||
) -> mesh_llm::MeshServeTarget {
|
||||
let mut target = target(model_id, endpoint_addr);
|
||||
target.reporter_pubkey = Some(reporter_pubkey.to_string());
|
||||
target.owner_id = Some(reporter_pubkey.to_string());
|
||||
target
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_mesh_join_uses_the_same_live_member_from_every_other_node() {
|
||||
let targets = vec![
|
||||
reported_target("member-c", "model-c", "addr-c"),
|
||||
reported_target("member-a", "model-a", "addr-a"),
|
||||
reported_target("member-b", "model-b", "addr-b"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
buzz_mesh_join_targets(targets.clone(), "member-b")
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|target| target.endpoint_addr),
|
||||
Some("addr-a".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
buzz_mesh_join_targets(targets, "member-c")
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|target| target.endpoint_addr),
|
||||
Some("addr-a".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_mesh_bootstrap_member_does_not_dial_itself() {
|
||||
let targets = vec![
|
||||
reported_target("member-b", "model-b", "addr-b"),
|
||||
reported_target("member-a", "model-a", "addr-a"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
buzz_mesh_join_targets(targets, "MEMBER-A")
|
||||
.into_iter()
|
||||
.next(),
|
||||
Some(reported_target("member-b", "model-b", "addr-b"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_mesh_join_ignores_targets_without_a_validated_reporter() {
|
||||
let targets = vec![
|
||||
target("unbound-model", "unbound-addr"),
|
||||
reported_target("member-b", "model-b", "addr-b"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
buzz_mesh_join_targets(targets, "member-c")
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|target| target.endpoint_addr),
|
||||
Some("addr-b".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_mesh_join_keeps_other_device_with_the_same_member_key() {
|
||||
let mut self_target = reported_target("same-member", "model-a", "self-addr");
|
||||
self_target.owner_id = Some("owner-self".to_string());
|
||||
let mut other_device = reported_target("same-member", "model-b", "other-addr");
|
||||
other_device.owner_id = Some("owner-other".to_string());
|
||||
|
||||
assert_eq!(
|
||||
buzz_mesh_join_targets(vec![self_target, other_device], "owner-self")
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|target| target.endpoint_addr),
|
||||
Some("other-addr".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_mesh_name_is_stable_and_does_not_expose_the_relay() {
|
||||
let first = buzz_mesh_name_for_relay("WSS://EXAMPLE.COM/");
|
||||
let second = buzz_mesh_name_for_relay("wss://example.com:443/some/path?ignored=yes");
|
||||
let other_relay = buzz_mesh_name_for_relay("wss://other.example.com");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(first, other_relay);
|
||||
assert!(first.starts_with("buzz-community-"));
|
||||
assert!(!first.contains("example"));
|
||||
}
|
||||
|
||||
#[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")
|
||||
.custom_created_at(nostr::Timestamp::from(1_234))
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.expect("sign test status");
|
||||
let mut filter = mesh_llm::mesh_status_filter();
|
||||
|
||||
let cursor = advance_mesh_status_cursor(&mut filter, std::slice::from_ref(&event))
|
||||
.expect("advance status cursor");
|
||||
|
||||
assert_eq!(cursor, (1_234, event.id.to_hex()));
|
||||
assert_eq!(filter["until"], serde_json::json!(1_234));
|
||||
assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex()));
|
||||
assert_eq!(
|
||||
filter["limit"],
|
||||
serde_json::json!(mesh_llm::MESH_STATUS_PAGE_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_returns_first_match_for_model() {
|
||||
let targets = vec![
|
||||
target("model-a", "addr-a"),
|
||||
target("model-b", "addr-b1"),
|
||||
target("model-b", "addr-b2"),
|
||||
];
|
||||
// Matches by model id and returns the first such target.
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, "model-b").map(|t| t.endpoint_addr),
|
||||
Some("addr-b1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_normalizes_main_revision() {
|
||||
let targets = vec![target("org/model@main:q4", "addr")];
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, "org/model:q4").map(|target| target.endpoint_addr),
|
||||
Some("addr".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_auto_takes_any_live_target() {
|
||||
let targets = vec![target("model-a", "addr-a"), target("model-b", "addr-b")];
|
||||
// "auto" delegates model choice to the mesh router; any live target
|
||||
// is a valid bootstrap peer (first one wins).
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(targets, crate::mesh_llm::AUTO_MODEL_ID)
|
||||
.map(|t| t.endpoint_addr),
|
||||
Some("addr-a".to_string())
|
||||
);
|
||||
// But auto with zero live targets still falls closed.
|
||||
assert_eq!(
|
||||
pick_serve_target_for_model(Vec::new(), crate::mesh_llm::AUTO_MODEL_ID),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_serve_target_none_when_model_not_hosted() {
|
||||
let targets = vec![target("model-a", "addr-a")];
|
||||
// No live target serves this model -> caller falls closed.
|
||||
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 share_start_restarts_to_replace_only_client_runtimes() {
|
||||
assert_eq!(
|
||||
mesh_start_plan(mesh_llm::MeshNodeMode::Serve, None),
|
||||
MeshStartPlan::Start
|
||||
);
|
||||
assert_eq!(
|
||||
mesh_start_plan(
|
||||
mesh_llm::MeshNodeMode::Serve,
|
||||
Some(mesh_llm::MeshNodeMode::Client),
|
||||
),
|
||||
MeshStartPlan::RestartToReplaceClient
|
||||
);
|
||||
assert_eq!(
|
||||
mesh_start_plan(
|
||||
mesh_llm::MeshNodeMode::Serve,
|
||||
Some(mesh_llm::MeshNodeMode::Serve),
|
||||
),
|
||||
MeshStartPlan::RejectOccupied
|
||||
);
|
||||
assert_eq!(
|
||||
mesh_start_plan(
|
||||
mesh_llm::MeshNodeMode::Client,
|
||||
Some(mesh_llm::MeshNodeMode::Client),
|
||||
),
|
||||
MeshStartPlan::RejectOccupied
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
let error = ensure_client_node_for_model(&state, "demo/model", None)
|
||||
.await
|
||||
.expect_err("cold relay-mesh preflight must not auto-pick a target");
|
||||
assert_eq!(error, RELAY_MESH_RUNTIME_NO_TARGET);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Buzz shared compute as a client"). That forbade exactly what 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 and no explicit target still reuses the
|
||||
/// existing runtime. Cold starts without a target are rejected before mesh-llm
|
||||
/// startup; running runtimes are already joined to whatever target the
|
||||
/// frontend selected earlier.
|
||||
///
|
||||
/// Hardware-gated (`#[ignore]`): loads a real model. Run with:
|
||||
/// cargo test -p buzz-desktop --features mesh-llm \
|
||||
/// ensure_serve_runtime_serves_other_model -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore = "loads a real model; run manually with --ignored"]
|
||||
fn ensure_serve_runtime_serves_other_model() {
|
||||
std::thread::Builder::new()
|
||||
.name("mesh-hardware-acceptance".to_string())
|
||||
.stack_size(mesh_llm::MESH_WORKER_STACK_SIZE)
|
||||
.spawn(|| {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_stack_size(mesh_llm::MESH_WORKER_STACK_SIZE)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build mesh acceptance runtime");
|
||||
runtime.block_on(async {
|
||||
const DEFAULT_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 hosted_model = std::env::var("BUZZ_MESH_TEST_MODEL")
|
||||
.unwrap_or_else(|_| DEFAULT_HOSTED_MODEL.to_string());
|
||||
|
||||
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.clone()),
|
||||
max_vram_gb: None,
|
||||
join_token: None,
|
||||
mesh_name: None,
|
||||
trusted_owner_ids: 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()
|
||||
.expect("serve runtime must expose its local API base");
|
||||
assert_eq!(serve_status.mode, Some(mesh_llm::MeshNodeMode::Serve));
|
||||
|
||||
{
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
*runtime = Some(serve);
|
||||
}
|
||||
|
||||
// Concurrent GUI agent starts all reuse this one machine-scoped
|
||||
// runtime. None may create a per-agent client/serve node.
|
||||
let preflights = tokio::join!(
|
||||
ensure_client_node_for_model(&state, OTHER_MODEL, None),
|
||||
ensure_client_node_for_model(&state, OTHER_MODEL, None),
|
||||
ensure_client_node_for_model(&state, OTHER_MODEL, None),
|
||||
ensure_client_node_for_model(&state, OTHER_MODEL, None),
|
||||
);
|
||||
let statuses = [
|
||||
preflights.0,
|
||||
preflights.1,
|
||||
preflights.2,
|
||||
preflights.3,
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("all agent preflights must reuse the serve runtime");
|
||||
|
||||
// It returns the SAME running node — agents keep using A's 9337, and
|
||||
// the router decides routability for OTHER_MODEL per request.
|
||||
for status in statuses {
|
||||
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.as_deref(),
|
||||
Some(serve_base.as_str()),
|
||||
"every agent must be pointed at the machine's existing ingress"
|
||||
);
|
||||
}
|
||||
|
||||
// A standalone Share Compute runtime must advertise exactly
|
||||
// one physical model and serve inference through `auto`.
|
||||
let http = reqwest::Client::new();
|
||||
let catalog_deadline =
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let catalog = loop {
|
||||
let body = http
|
||||
.get(format!("{serve_base}/models"))
|
||||
.send()
|
||||
.await
|
||||
.expect("query single-node catalog")
|
||||
.error_for_status()
|
||||
.expect("single-node catalog status")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("parse single-node catalog");
|
||||
let physical_count = body["data"]
|
||||
.as_array()
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|model| model["id"].as_str())
|
||||
.filter(|model| *model != "mesh")
|
||||
.count()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if physical_count > 0 {
|
||||
break body;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < catalog_deadline,
|
||||
"single Share Compute model never became ready: {body}"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
};
|
||||
let physical_models = catalog["data"]
|
||||
.as_array()
|
||||
.expect("catalog data")
|
||||
.iter()
|
||||
.filter_map(|model| model["id"].as_str())
|
||||
.filter(|model| *model != "mesh")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
physical_models.len(),
|
||||
1,
|
||||
"single Share Compute node must advertise one physical model: {catalog}"
|
||||
);
|
||||
assert!(
|
||||
physical_models[0].contains(hosted_model.split(':').next().unwrap_or("")),
|
||||
"catalog must contain the hosted model: {catalog}"
|
||||
);
|
||||
|
||||
let inference_deadline =
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let inference = loop {
|
||||
let response = http
|
||||
.post(format!("{serve_base}/chat/completions"))
|
||||
.json(&serde_json::json!({
|
||||
"model": "auto",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Reply with exactly BUZZ_SINGLE_SHARE_OK and nothing else."
|
||||
}],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("single-node auto inference");
|
||||
if response.status().is_success() {
|
||||
break response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("parse single-node inference");
|
||||
}
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
assert!(
|
||||
tokio::time::Instant::now() < inference_deadline,
|
||||
"single-node auto inference never became ready: HTTP {status}: {body}"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
};
|
||||
let answer = inference["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!answer.trim().is_empty(),
|
||||
"single-node auto must produce visible output: {inference}"
|
||||
);
|
||||
|
||||
// Clean up the runtime.
|
||||
let taken = state.mesh_llm_runtime.lock().await.take();
|
||||
if let Some(runtime) = taken {
|
||||
let _ = runtime.stop().await;
|
||||
}
|
||||
});
|
||||
})
|
||||
.expect("spawn mesh acceptance thread")
|
||||
.join()
|
||||
.expect("mesh acceptance thread panicked");
|
||||
}
|
||||
@@ -2,6 +2,8 @@ pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1";
|
||||
pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local";
|
||||
pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh";
|
||||
pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto";
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
pub const RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV: &str = "BUZZ_AGENT_PREFER_MESH_FOR_AUTO";
|
||||
|
||||
/// Translate the native Buzz shared compute provider into the OpenAI-compatible
|
||||
/// transport understood by buzz-agent. These are derived runtime details, not
|
||||
@@ -32,6 +34,14 @@ pub fn apply_relay_mesh_env(
|
||||
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
|
||||
);
|
||||
env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string());
|
||||
// Buzz owns the meaning of relay-mesh `auto`: buzz-agent dynamically uses
|
||||
// mesh-llm's virtual Mixture-of-Agents model whenever the live catalog says
|
||||
// at least two distinct models are available, and otherwise keeps the
|
||||
// router's normal single-model `auto` behavior.
|
||||
env.insert(
|
||||
RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
// Keep the requested response inside smaller local-model context windows,
|
||||
// and spend that budget on an answer/tool call instead of hidden reasoning.
|
||||
// Without both settings Qwen3 either fails the router's fit check at the
|
||||
@@ -66,5 +76,10 @@ mod tests {
|
||||
env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str),
|
||||
Some("none")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV)
|
||||
.map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,17 @@ const STATUS_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// not hammer discovery every tick, but a recovered peer is noticed quickly.
|
||||
const INGRESS_WATCHDOG_BASE: Duration = Duration::from_secs(15);
|
||||
const INGRESS_WATCHDOG_MAX: Duration = Duration::from_secs(120);
|
||||
/// A Share Compute node may start before another member's signed status reaches
|
||||
/// the relay. Recheck promptly so simultaneous starts converge into one Buzz
|
||||
/// mesh instead of remaining independent islands.
|
||||
const MESH_JOIN_POLL_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const MESH_JOIN_RETRY_MAX: Duration = Duration::from_secs(120);
|
||||
|
||||
pub struct MeshCoordinator {
|
||||
_status_publisher: tokio::task::JoinHandle<()>,
|
||||
_roster_watcher: tokio::task::JoinHandle<()>,
|
||||
_ingress_watchdog: tokio::task::JoinHandle<()>,
|
||||
_mesh_join_watcher: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Start the runtime-owned status publisher and admission-roster watcher.
|
||||
@@ -62,12 +68,25 @@ pub async fn start_coordinator(app: AppHandle) {
|
||||
let mut pending_shrink: Option<Vec<String>> = None;
|
||||
loop {
|
||||
tokio::time::sleep(ROSTER_POLL_INTERVAL).await;
|
||||
let state = roster_app.state::<AppState>();
|
||||
if let Err(error) = reconcile_roster(&state, &mut pending_shrink).await {
|
||||
if let Err(error) = reconcile_roster(&roster_app, &mut pending_shrink).await {
|
||||
eprintln!("buzz-mesh: roster reconcile failed: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
let join_app = app.clone();
|
||||
let mesh_join_watcher = tokio::spawn(async move {
|
||||
let mut sleep_for = MESH_JOIN_POLL_INTERVAL;
|
||||
loop {
|
||||
tokio::time::sleep(sleep_for).await;
|
||||
match reconcile_buzz_mesh_join(&join_app).await {
|
||||
Ok(()) => sleep_for = MESH_JOIN_POLL_INTERVAL,
|
||||
Err(error) => {
|
||||
eprintln!("buzz-mesh: community mesh join reconcile failed: {error}");
|
||||
sleep_for = (sleep_for * 2).min(MESH_JOIN_RETRY_MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Brad #2304 / #2062: ensure_relay_mesh_for_record only runs on explicit
|
||||
// start + launch restore. After launch, local buzz-agent processes talk
|
||||
@@ -98,14 +117,85 @@ pub async fn start_coordinator(app: AppHandle) {
|
||||
_status_publisher: status_publisher,
|
||||
_roster_watcher: roster_watcher,
|
||||
_ingress_watchdog: ingress_watchdog,
|
||||
_mesh_join_watcher: mesh_join_watcher,
|
||||
});
|
||||
} else {
|
||||
status_publisher.abort();
|
||||
roster_watcher.abort();
|
||||
ingress_watchdog.abort();
|
||||
mesh_join_watcher.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Join an isolated runtime to the existing Buzz community mesh. The relay is
|
||||
/// discovery only: the selected endpoint is member-signed and validated, then
|
||||
/// MeshLLM establishes the encrypted peer transport itself.
|
||||
async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
let peer_ids = {
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
let Some(runtime) = runtime.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let payload = runtime
|
||||
.status_report_payload()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
visible_peer_ids(&payload)
|
||||
};
|
||||
|
||||
let targets = crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state).await?;
|
||||
let Some(target) = targets
|
||||
.into_iter()
|
||||
.find(|target| !target_is_visible(target, &peer_ids))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
let Some(runtime) = runtime.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let payload = runtime
|
||||
.status_report_payload()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if target_is_visible(&target, &visible_peer_ids(&payload)) {
|
||||
return Ok(());
|
||||
}
|
||||
runtime
|
||||
.dial_endpoint_addr(target.endpoint_addr)
|
||||
.await
|
||||
.map_err(|error| format!("mesh join failed: {error:#}"))
|
||||
}
|
||||
|
||||
fn visible_peer_ids(payload: &serde_json::Value) -> Vec<String> {
|
||||
payload
|
||||
.get("peers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|peer| peer.get("id").and_then(serde_json::Value::as_str))
|
||||
.map(|id| id.trim().to_ascii_lowercase())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn target_is_visible(target: &crate::mesh_llm::MeshServeTarget, peer_ids: &[String]) -> bool {
|
||||
let Some(endpoint_id) = target.endpoint_id.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let endpoint_id = endpoint_id.trim().to_ascii_lowercase();
|
||||
if endpoint_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
peer_ids.iter().any(|peer_id| {
|
||||
let peer_id = peer_id.trim().to_ascii_lowercase();
|
||||
!peer_id.is_empty()
|
||||
&& (endpoint_id.starts_with(&peer_id) || peer_id.starts_with(&endpoint_id))
|
||||
})
|
||||
}
|
||||
|
||||
/// Outcome of a roster reconcile decision.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RosterReconcileAction {
|
||||
@@ -170,9 +260,10 @@ fn roster_reconcile_action(
|
||||
}
|
||||
|
||||
async fn reconcile_roster(
|
||||
state: &AppState,
|
||||
app: &AppHandle,
|
||||
pending_shrink: &mut Option<Vec<String>>,
|
||||
) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
let current_request = {
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
match runtime.as_ref() {
|
||||
@@ -192,7 +283,7 @@ async fn reconcile_roster(
|
||||
// other member on a transient relay blip (the flapping restart loop). Keep
|
||||
// the current allowlist and try again on the next poll. A shrink is held
|
||||
// for one extra poll (hysteresis) so a single short-read never tears down.
|
||||
let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(state).await;
|
||||
let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(&state).await;
|
||||
let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) {
|
||||
RosterReconcileAction::Keep => {
|
||||
*pending_shrink = None;
|
||||
@@ -209,8 +300,27 @@ async fn reconcile_roster(
|
||||
}
|
||||
};
|
||||
|
||||
let mut request = current_request;
|
||||
let mut request = current_request.clone();
|
||||
request.trusted_owner_ids = Some(fresh);
|
||||
// Bootstrap endpoints are live device state, not configuration. The
|
||||
// endpoint used at the previous start may belong to the member that just
|
||||
// left or to a device whose iroh identity rotated while offline. Resolve a
|
||||
// fresh validated peer for this restart; starting isolated is safe because
|
||||
// the join watcher will converge it when a member next publishes.
|
||||
request.join_token = match crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state)
|
||||
.await
|
||||
{
|
||||
Ok(targets) => targets
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|target| target.endpoint_addr),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"buzz-mesh: could not refresh bootstrap endpoint for roster restart; starting isolated: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut guard = state.mesh_llm_runtime.lock().await;
|
||||
let startup_pending = match guard.as_ref() {
|
||||
Some(runtime) => runtime.is_starting().await,
|
||||
@@ -222,12 +332,29 @@ async fn reconcile_roster(
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if guard
|
||||
.as_ref()
|
||||
.is_some_and(|runtime| runtime.start_request() != ¤t_request)
|
||||
{
|
||||
// The runtime changed while relay discovery was in flight. Its own
|
||||
// request is now authoritative; the next poll will reconcile that
|
||||
// runtime instead of tearing down a fresh replacement from this stale
|
||||
// snapshot.
|
||||
return Ok(());
|
||||
}
|
||||
let Some(running) = guard.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
eprintln!("buzz-mesh: membership roster changed; restarting mesh node with fresh allowlist");
|
||||
if let Err(error) = running.stop().await {
|
||||
eprintln!("buzz-mesh: stopping mesh node for roster restart failed: {error}");
|
||||
drop(guard);
|
||||
eprintln!(
|
||||
"buzz-mesh: stopping mesh node for roster restart failed; restarting Buzz instead of racing the occupied ingress: {error}"
|
||||
);
|
||||
app.request_restart();
|
||||
return Err(format!(
|
||||
"mesh node shutdown failed during roster change: {error}"
|
||||
));
|
||||
}
|
||||
let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request)
|
||||
.await
|
||||
@@ -357,6 +484,51 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn join_target(endpoint_id: Option<&str>) -> crate::mesh_llm::MeshServeTarget {
|
||||
crate::mesh_llm::MeshServeTarget {
|
||||
model_id: "model".to_string(),
|
||||
model_name: None,
|
||||
endpoint_addr: "iroh://example".to_string(),
|
||||
reporter_pubkey: Some("member".to_string()),
|
||||
owner_id: Some("owner".to_string()),
|
||||
node_name: None,
|
||||
capacity: None,
|
||||
endpoint_id: endpoint_id.map(str::to_string),
|
||||
device_id: None,
|
||||
device_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_peer_ids_reads_the_sdk_status_shape() {
|
||||
assert_eq!(
|
||||
visible_peer_ids(&serde_json::json!({
|
||||
"peers": [{"id": " ABC123 "}, {"id": "def456"}, {"name": "ignored"}]
|
||||
})),
|
||||
vec!["abc123".to_string(), "def456".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_visibility_accepts_sdk_short_endpoint_ids() {
|
||||
let target = join_target(Some("ABC1234567890"));
|
||||
|
||||
assert!(target_is_visible(&target, &["abc123".to_string()]));
|
||||
assert!(!target_is_visible(&target, &["def456".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_without_an_endpoint_id_is_never_treated_as_connected() {
|
||||
assert!(!target_is_visible(
|
||||
&join_target(None),
|
||||
&["abc123".to_string()]
|
||||
));
|
||||
assert!(!target_is_visible(
|
||||
&join_target(Some(" ")),
|
||||
&["abc123".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
// Regression: a transient roster-query failure must never restart the node
|
||||
// down to self-only. Before the fix, `resolve_trusted_owner_ids` returned
|
||||
// an empty Vec on error, which `reconcile_roster` read as "roster changed
|
||||
|
||||
@@ -5,9 +5,10 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, MESH_STATUS_KIND};
|
||||
|
||||
/// Running-node status notes are refreshed every 45 seconds. Ignore notes older
|
||||
/// than two minutes so crashed/offline devices stop contributing compute or
|
||||
/// admission identities without requiring a relay-side cleanup job.
|
||||
/// Running-node status notes are refreshed every 45 seconds. Routing ignores
|
||||
/// notes older than two minutes so crashed/offline devices stop contributing
|
||||
/// compute without a relay-side cleanup job. Admission intentionally does not:
|
||||
/// Buzz membership, rather than device liveness, is the trust boundary.
|
||||
pub(super) const STATUS_FRESHNESS_SECS: u64 = 120;
|
||||
pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100;
|
||||
|
||||
@@ -199,13 +200,14 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
|
||||
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
|
||||
continue;
|
||||
};
|
||||
if owner_id_from_status_event(&event).is_none() {
|
||||
let Some(owner_id) = owner_id_from_status_event(&event) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !endpoint_binding_is_valid(&event, &content) {
|
||||
continue;
|
||||
}
|
||||
saw_valid_status = true;
|
||||
let reporter_pubkey = event.pubkey.to_hex().to_ascii_lowercase();
|
||||
let mut serve_targets = content
|
||||
.get("serveTargets")
|
||||
.or_else(|| content.get("serve_targets"))
|
||||
@@ -218,6 +220,8 @@ pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
|
||||
super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr)
|
||||
.ok()?;
|
||||
target.endpoint_addr = validated.join_token;
|
||||
target.reporter_pubkey = Some(reporter_pubkey.clone());
|
||||
target.owner_id = Some(owner_id.clone());
|
||||
if target.endpoint_id.is_none() {
|
||||
target.endpoint_id = Some(validated.endpoint_id);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ pub(crate) use recovery::{
|
||||
MeshRuntimeRecovery,
|
||||
};
|
||||
|
||||
mod usage;
|
||||
pub use usage::{serving_usage_from_payload, MeshServingUsage};
|
||||
|
||||
mod transport_policy;
|
||||
#[cfg(test)]
|
||||
use transport_policy::iroh_relay_mode_from;
|
||||
@@ -84,6 +87,15 @@ pub struct MeshServeTarget {
|
||||
pub model_id: String,
|
||||
pub model_name: Option<String>,
|
||||
pub endpoint_addr: String,
|
||||
/// Buzz member that signed the discovery note containing this target.
|
||||
/// Populated after signature/membership validation; never trusted from the
|
||||
/// note payload itself.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reporter_pubkey: Option<String>,
|
||||
/// Per-runtime MeshLLM owner identity verified by the signed Buzz status.
|
||||
/// Distinguishes two devices logged into the same Buzz member account.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
pub node_name: Option<String>,
|
||||
pub capacity: Option<MeshTargetCapacity>,
|
||||
#[serde(default)]
|
||||
@@ -184,6 +196,10 @@ pub struct StartMeshNodeRequest {
|
||||
pub max_vram_gb: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub join_token: Option<String>,
|
||||
/// Stable, relay-scoped mesh name injected by the Buzz backend. It is not
|
||||
/// accepted from the frontend and contains no relay address.
|
||||
#[serde(default, skip_deserializing)]
|
||||
pub mesh_name: Option<String>,
|
||||
/// Mesh owner ids admitted to this node (the member roster from
|
||||
/// member-signed discovery notes). `None` = caller did not resolve a roster
|
||||
/// (tests, direct invocations): the node runs without allowlist
|
||||
@@ -214,81 +230,6 @@ pub struct MeshNodeStatus {
|
||||
pub device_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Host-side "who is using the compute I'm sharing" snapshot.
|
||||
///
|
||||
/// Read-only projection of the serving node's own runtime metrics (the same
|
||||
/// `routing_metrics` / `inflight_requests` the SDK already exposes on the local
|
||||
/// console). No new trust surface: it reads the node's own status payload.
|
||||
///
|
||||
/// The local/remote/endpoint attempt split is what distinguishes *my own*
|
||||
/// agent (local) from *another member consuming my compute* (remote/endpoint).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshServingUsage {
|
||||
/// Requests being served right now.
|
||||
pub inflight: u64,
|
||||
/// Highest concurrent in-flight seen this session.
|
||||
pub peak_inflight: u64,
|
||||
/// Total requests routed through this node.
|
||||
pub requests_served: u64,
|
||||
/// Completion tokens produced.
|
||||
pub tokens_served: u64,
|
||||
/// Recent decode throughput.
|
||||
pub tokens_per_second: f64,
|
||||
/// Requests served for this machine's own agents.
|
||||
pub local_attempts: u64,
|
||||
/// Requests served for a remote peer (someone else consuming my compute).
|
||||
pub remote_attempts: u64,
|
||||
/// Requests served via an advertised endpoint (also a remote consumer).
|
||||
pub endpoint_attempts: u64,
|
||||
/// Other nodes currently visible as peers.
|
||||
pub peers: u64,
|
||||
}
|
||||
|
||||
impl MeshServingUsage {
|
||||
/// True when at least one request has been served for a non-local consumer.
|
||||
#[cfg(test)]
|
||||
pub fn has_remote_consumers(&self) -> bool {
|
||||
self.remote_attempts > 0 || self.endpoint_attempts > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure extractor: project a raw SDK status payload into [`MeshServingUsage`].
|
||||
///
|
||||
/// Every field is read defensively (missing → 0) so an SDK shape change
|
||||
/// degrades to "no usage shown" rather than an error. Kept pure so it can be
|
||||
/// unit-tested against a captured payload without a live runtime.
|
||||
pub fn serving_usage_from_payload(payload: &serde_json::Value) -> MeshServingUsage {
|
||||
let u64_at = |v: &serde_json::Value| v.as_u64().unwrap_or(0);
|
||||
let rm = payload.get("routing_metrics");
|
||||
let local = rm.and_then(|m| m.get("local_node"));
|
||||
let get_u64 = |obj: Option<&serde_json::Value>, key: &str| {
|
||||
obj.and_then(|o| o.get(key)).map(u64_at).unwrap_or(0)
|
||||
};
|
||||
MeshServingUsage {
|
||||
inflight: local
|
||||
.and_then(|l| l.get("current_inflight_requests"))
|
||||
.map(u64_at)
|
||||
.or_else(|| payload.get("inflight_requests").map(u64_at))
|
||||
.unwrap_or(0),
|
||||
peak_inflight: get_u64(local, "peak_inflight_requests"),
|
||||
requests_served: get_u64(rm, "request_count"),
|
||||
tokens_served: get_u64(rm, "completion_tokens_observed"),
|
||||
tokens_per_second: rm
|
||||
.and_then(|m| m.get("avg_tokens_per_second"))
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(0.0),
|
||||
local_attempts: get_u64(local, "local_attempt_count"),
|
||||
remote_attempts: get_u64(local, "remote_attempt_count"),
|
||||
endpoint_attempts: get_u64(local, "endpoint_attempt_count"),
|
||||
peers: payload
|
||||
.get("peers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|a| a.len() as u64)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopped_status() -> MeshNodeStatus {
|
||||
MeshNodeStatus {
|
||||
state: MeshNodeState::Off,
|
||||
@@ -423,6 +364,9 @@ impl DesktopMeshRuntime {
|
||||
.discovery_mode(MeshDiscoveryMode::Nostr)
|
||||
.startup_timeout(MESH_STARTUP_TIMEOUT)
|
||||
.console_ui(true);
|
||||
if let Some(mesh_name) = request.mesh_name.as_deref() {
|
||||
builder = builder.mesh_name(mesh_name);
|
||||
}
|
||||
builder = match iroh_relay_mode()? {
|
||||
IrohRelayMode::Disabled => builder.disable_iroh_relays(true),
|
||||
IrohRelayMode::Default => builder.disable_iroh_relays(false),
|
||||
@@ -460,6 +404,9 @@ impl DesktopMeshRuntime {
|
||||
.discovery_mode(MeshDiscoveryMode::Nostr)
|
||||
.startup_timeout(MESH_CLIENT_MANAGEMENT_TIMEOUT)
|
||||
.console_ui(true);
|
||||
if let Some(mesh_name) = request.mesh_name.as_deref() {
|
||||
builder = builder.mesh_name(mesh_name);
|
||||
}
|
||||
builder = match iroh_relay_mode()? {
|
||||
IrohRelayMode::Disabled => builder.disable_iroh_relays(true),
|
||||
IrohRelayMode::Default => builder.disable_iroh_relays(false),
|
||||
@@ -646,6 +593,8 @@ impl DesktopMeshRuntime {
|
||||
model_id: model.id,
|
||||
model_name: model.name,
|
||||
endpoint_addr: endpoint_addr.clone(),
|
||||
reporter_pubkey: None,
|
||||
owner_id: None,
|
||||
node_name: payload
|
||||
.get("node_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
|
||||
@@ -11,6 +11,7 @@ fn pending_client_runtime(
|
||||
model_id: None,
|
||||
max_vram_gb: None,
|
||||
join_token: Some("initial-token".to_string()),
|
||||
mesh_name: None,
|
||||
trusted_owner_ids: None,
|
||||
};
|
||||
super::DesktopMeshRuntime {
|
||||
@@ -692,10 +693,7 @@ fn serving_usage_extracts_local_and_remote_attempts() {
|
||||
assert_eq!(usage.local_attempts, 4);
|
||||
assert_eq!(usage.remote_attempts, 0);
|
||||
assert_eq!(usage.endpoint_attempts, 0);
|
||||
assert!(
|
||||
!usage.has_remote_consumers(),
|
||||
"all-local traffic is not a remote consumer"
|
||||
);
|
||||
assert_eq!(usage.remote_attempts + usage.endpoint_attempts, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -715,10 +713,7 @@ fn serving_usage_flags_remote_consumer() {
|
||||
assert_eq!(usage.remote_attempts, 6);
|
||||
assert_eq!(usage.endpoint_attempts, 1);
|
||||
assert_eq!(usage.peers, 2);
|
||||
assert!(
|
||||
usage.has_remote_consumers(),
|
||||
"remote/endpoint attempts mean someone else is using my compute"
|
||||
);
|
||||
assert!(usage.remote_attempts + usage.endpoint_attempts > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -726,5 +721,5 @@ fn serving_usage_defaults_to_zero_on_missing_fields() {
|
||||
// SDK shape drift must degrade to "no usage" not panic.
|
||||
let usage = super::serving_usage_from_payload(&json!({}));
|
||||
assert_eq!(usage, super::MeshServingUsage::default());
|
||||
assert!(!usage.has_remote_consumers());
|
||||
assert_eq!(usage.remote_attempts + usage.endpoint_attempts, 0);
|
||||
}
|
||||
|
||||
@@ -243,6 +243,11 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
let _rearm_guard = state.mesh_recovery.rearm_lock.lock().await;
|
||||
let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await;
|
||||
let active_pubkeys = active_managed_agent_pubkeys(&state);
|
||||
// Mesh participation is resolved through the same definition-authoritative
|
||||
// path as spawn/restore (#1968): definition → global fallback. A linked
|
||||
// instance's own bytes never contribute.
|
||||
let personas = crate::managed_agents::load_personas(app).unwrap_or_default();
|
||||
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
||||
|
||||
match recovery {
|
||||
MeshRuntimeRecovery::Live
|
||||
@@ -250,10 +255,9 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
| MeshRuntimeRecovery::Replaced => return Ok(()),
|
||||
MeshRuntimeRecovery::RestartRequired => {
|
||||
let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default();
|
||||
if !records
|
||||
.iter()
|
||||
.any(|record| is_running_relay_mesh_agent(record, &active_pubkeys))
|
||||
{
|
||||
if !records.iter().any(|record| {
|
||||
running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some()
|
||||
}) {
|
||||
// A foreground save may still be bringing up its first ingress.
|
||||
// Only an already-running consumer justifies an automatic app
|
||||
// relaunch from the background watchdog.
|
||||
@@ -272,10 +276,9 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
}
|
||||
MeshRuntimeRecovery::Absent => {
|
||||
let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default();
|
||||
if !records
|
||||
.iter()
|
||||
.any(|record| is_running_relay_mesh_agent(record, &active_pubkeys))
|
||||
{
|
||||
if !records.iter().any(|record| {
|
||||
running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some()
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -285,11 +288,20 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
|
||||
let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default();
|
||||
let mesh_records: Vec<_> = records
|
||||
.into_iter()
|
||||
.filter(|record| is_running_relay_mesh_agent(record, &active_pubkeys))
|
||||
.filter_map(|record| {
|
||||
running_relay_mesh_model_id(&record, &active_pubkeys, &personas, &global)
|
||||
.map(|mesh_model_id| (record, mesh_model_id))
|
||||
})
|
||||
.collect();
|
||||
let mut first_error = None;
|
||||
for record in &mesh_records {
|
||||
match crate::commands::mesh_llm::ensure_relay_mesh_for_record(app, record, false).await {
|
||||
for (record, mesh_model_id) in &mesh_records {
|
||||
match crate::commands::mesh_llm::ensure_relay_mesh_for_record(
|
||||
app,
|
||||
Some(mesh_model_id.as_str()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Err(error) = clear_mesh_last_error_if_set(app, &record.pubkey) {
|
||||
eprintln!("buzz-mesh: failed to clear recovery error: {error}");
|
||||
@@ -322,16 +334,28 @@ fn active_managed_agent_pubkeys(state: &AppState) -> HashSet<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_running_relay_mesh_agent(
|
||||
/// Effective mesh model for a record that is actively running, or `None`
|
||||
/// when the record is not a running relay-mesh consumer. Resolution goes
|
||||
/// through `resolve_effective_relay_mesh_model_id` (definition → global
|
||||
/// fallback, #1968) so the watchdog agrees with spawn/restore about which
|
||||
/// agents are mesh-backed.
|
||||
fn running_relay_mesh_model_id(
|
||||
record: &crate::managed_agents::ManagedAgentRecord,
|
||||
active_pubkeys: &HashSet<String>,
|
||||
) -> bool {
|
||||
record.backend == crate::managed_agents::BackendKind::Local
|
||||
&& crate::managed_agents::relay_mesh_model_id(record).is_some()
|
||||
personas: &[crate::managed_agents::AgentDefinition],
|
||||
global: &crate::managed_agents::GlobalAgentConfig,
|
||||
) -> Option<String> {
|
||||
let running = record.backend == crate::managed_agents::BackendKind::Local
|
||||
&& active_pubkeys.contains(&record.pubkey.to_ascii_lowercase())
|
||||
&& record
|
||||
.runtime_pid
|
||||
.is_none_or(crate::managed_agents::process_is_running)
|
||||
.is_none_or(crate::managed_agents::process_is_running);
|
||||
if !running {
|
||||
return None;
|
||||
}
|
||||
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
|
||||
record, personas, global,
|
||||
)
|
||||
}
|
||||
|
||||
fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> {
|
||||
@@ -458,29 +482,45 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_running_relay_mesh_agents_trigger_rearm() {
|
||||
let personas: Vec<crate::managed_agents::AgentDefinition> = Vec::new();
|
||||
let global = crate::managed_agents::GlobalAgentConfig::default();
|
||||
|
||||
let empty = active_set(&[]);
|
||||
assert!(!is_running_relay_mesh_agent(
|
||||
assert!(running_relay_mesh_model_id(
|
||||
&mesh_record("stopped", Some(std::process::id())),
|
||||
&empty
|
||||
));
|
||||
&empty,
|
||||
&personas,
|
||||
&global,
|
||||
)
|
||||
.is_none());
|
||||
|
||||
let active = active_set(&["live"]);
|
||||
assert!(is_running_relay_mesh_agent(
|
||||
&mesh_record("live", Some(std::process::id())),
|
||||
&active
|
||||
));
|
||||
assert!(is_running_relay_mesh_agent(
|
||||
&mesh_record("live", None),
|
||||
&active
|
||||
));
|
||||
assert_eq!(
|
||||
running_relay_mesh_model_id(
|
||||
&mesh_record("live", Some(std::process::id())),
|
||||
&active,
|
||||
&personas,
|
||||
&global,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("Qwen3")
|
||||
);
|
||||
assert_eq!(
|
||||
running_relay_mesh_model_id(&mesh_record("live", None), &active, &personas, &global)
|
||||
.as_deref(),
|
||||
Some("Qwen3")
|
||||
);
|
||||
|
||||
let mut non_mesh = mesh_record("plain", Some(std::process::id()));
|
||||
non_mesh.env_vars.clear();
|
||||
non_mesh.relay_mesh = None;
|
||||
assert!(!is_running_relay_mesh_agent(
|
||||
assert!(running_relay_mesh_model_id(
|
||||
&non_mesh,
|
||||
&active_set(&["plain"])
|
||||
));
|
||||
&active_set(&["plain"]),
|
||||
&personas,
|
||||
&global,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
68
desktop/src-tauri/src/mesh_llm/usage.rs
Normal file
68
desktop/src-tauri/src/mesh_llm/usage.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Host-side "who is using the compute I'm sharing" snapshot.
|
||||
///
|
||||
/// Read-only projection of the serving node's own runtime metrics (the same
|
||||
/// `routing_metrics` / `inflight_requests` the SDK already exposes on the local
|
||||
/// console). No new trust surface: it reads the node's own status payload.
|
||||
///
|
||||
/// The local/remote/endpoint attempt split is what distinguishes *my own*
|
||||
/// agent (local) from *another member consuming my compute* (remote/endpoint).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshServingUsage {
|
||||
/// Requests being served right now.
|
||||
pub inflight: u64,
|
||||
/// Highest concurrent in-flight seen this session.
|
||||
pub peak_inflight: u64,
|
||||
/// Total requests routed through this node.
|
||||
pub requests_served: u64,
|
||||
/// Completion tokens produced.
|
||||
pub tokens_served: u64,
|
||||
/// Recent decode throughput.
|
||||
pub tokens_per_second: f64,
|
||||
/// Requests served for this machine's own agents.
|
||||
pub local_attempts: u64,
|
||||
/// Requests served for a remote peer (someone else consuming my compute).
|
||||
pub remote_attempts: u64,
|
||||
/// Requests served via an advertised endpoint (also a remote consumer).
|
||||
pub endpoint_attempts: u64,
|
||||
/// Other nodes currently visible as peers.
|
||||
pub peers: u64,
|
||||
}
|
||||
|
||||
/// Pure extractor: project a raw SDK status payload into [`MeshServingUsage`].
|
||||
///
|
||||
/// Every field is read defensively (missing → 0) so an SDK shape change
|
||||
/// degrades to "no usage shown" rather than an error. Kept pure so it can be
|
||||
/// unit-tested against a captured payload without a live runtime.
|
||||
pub fn serving_usage_from_payload(payload: &serde_json::Value) -> MeshServingUsage {
|
||||
let u64_at = |v: &serde_json::Value| v.as_u64().unwrap_or(0);
|
||||
let rm = payload.get("routing_metrics");
|
||||
let local = rm.and_then(|m| m.get("local_node"));
|
||||
let get_u64 = |obj: Option<&serde_json::Value>, key: &str| {
|
||||
obj.and_then(|o| o.get(key)).map(u64_at).unwrap_or(0)
|
||||
};
|
||||
MeshServingUsage {
|
||||
inflight: local
|
||||
.and_then(|l| l.get("current_inflight_requests"))
|
||||
.map(u64_at)
|
||||
.or_else(|| payload.get("inflight_requests").map(u64_at))
|
||||
.unwrap_or(0),
|
||||
peak_inflight: get_u64(local, "peak_inflight_requests"),
|
||||
requests_served: get_u64(rm, "request_count"),
|
||||
tokens_served: get_u64(rm, "completion_tokens_observed"),
|
||||
tokens_per_second: rm
|
||||
.and_then(|m| m.get("avg_tokens_per_second"))
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(0.0),
|
||||
local_attempts: get_u64(local, "local_attempt_count"),
|
||||
remote_attempts: get_u64(local, "remote_attempt_count"),
|
||||
endpoint_attempts: get_u64(local, "endpoint_attempt_count"),
|
||||
peers: payload
|
||||
.get("peers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|a| a.len() as u64)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,8 @@ export function PersonaModelField({
|
||||
) : null}
|
||||
{showSharedComputeAutoHint ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Buzz will choose an available shared model when the agent starts.
|
||||
Auto uses Mesh collective intelligence when two or more models stay
|
||||
available, otherwise it chooses one available model.
|
||||
</p>
|
||||
) : null}
|
||||
{modelDiscoveryStatus ? (
|
||||
|
||||
@@ -44,6 +44,16 @@ test("an explicit inherited default label wins over harness discovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("shared compute describes Auto's collective behavior", () => {
|
||||
assert.equal(
|
||||
resolveDefaultModelLabel({
|
||||
discoveredModelOptions: null,
|
||||
isSharedCompute: true,
|
||||
}),
|
||||
"Auto (collective when available)",
|
||||
);
|
||||
});
|
||||
|
||||
// ── appendNoModelsSentinel ─────────────────────────────────────────────────────
|
||||
|
||||
test("appendNoModelsSentinel_emptyOptionsDiscoveryFinished_addsDisabledRow", () => {
|
||||
|
||||
@@ -326,7 +326,9 @@ export function resolveDefaultModelLabel({
|
||||
return (
|
||||
defaultModelLabel ??
|
||||
discoveredModelOptions?.find((option) => option.id.trim() === "")?.label ??
|
||||
(isSharedCompute ? "Default (auto)" : getDefaultLlmModelLabel(globalModel))
|
||||
(isSharedCompute
|
||||
? "Auto (collective when available)"
|
||||
: getDefaultLlmModelLabel(globalModel))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AUTO_MODEL_DROPDOWN_VALUE } from "./agentConfigOptions.tsx";
|
||||
|
||||
const fallback = [{ id: "", label: "Default model" }];
|
||||
const live = [
|
||||
{ id: "", label: "Default (auto)" },
|
||||
{ id: "", label: "Auto (collective when available)" },
|
||||
{ id: "mesh/model", label: "mesh/model" },
|
||||
];
|
||||
|
||||
@@ -35,7 +35,9 @@ test("Buzz shared compute fallback is Default auto while normal providers remain
|
||||
model: "",
|
||||
provider: "relay-mesh",
|
||||
});
|
||||
assert.deepEqual(mesh.options, [{ id: "", label: "Default (auto)" }]);
|
||||
assert.deepEqual(mesh.options, [
|
||||
{ id: "", label: "Auto (collective when available)" },
|
||||
]);
|
||||
|
||||
const openai = relayMeshModelPickerState({
|
||||
discoveredOptions: null,
|
||||
@@ -57,7 +59,9 @@ test("Buzz shared compute keeps Default auto when discovery is empty", () => {
|
||||
provider: "relay-mesh",
|
||||
});
|
||||
|
||||
assert.deepEqual(state.options, [{ id: "", label: "Default (auto)" }]);
|
||||
assert.deepEqual(state.options, [
|
||||
{ id: "", label: "Auto (collective when available)" },
|
||||
]);
|
||||
assert.equal(state.selectValue, AUTO_MODEL_DROPDOWN_VALUE);
|
||||
assert.equal(state.showCustomInput, false);
|
||||
});
|
||||
@@ -71,6 +75,6 @@ test("Buzz shared compute dropdown contains Default plus live models and no cust
|
||||
});
|
||||
assert.deepEqual(
|
||||
options.map((option) => option.label),
|
||||
["Default (auto)", "mesh/model"],
|
||||
["Auto (collective when available)", "mesh/model"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ function withSharedComputeAutoOption(
|
||||
options: readonly PersonaModelOption[],
|
||||
): readonly PersonaModelOption[] {
|
||||
const modelOptions = options.filter((option) => option.id.trim() !== "");
|
||||
return [{ id: "", label: "Default (auto)" }, ...modelOptions];
|
||||
return [
|
||||
{ id: "", label: "Auto (collective when available)" },
|
||||
...modelOptions,
|
||||
];
|
||||
}
|
||||
|
||||
export function relayMeshModelPickerState({
|
||||
|
||||
@@ -21,13 +21,14 @@ export type MeshShareToggleModel = {
|
||||
isSharing: boolean;
|
||||
/**
|
||||
* A client-mode runtime occupies the single slot (this machine is consuming
|
||||
* a peer's compute). The Share switch must read off + disabled while true.
|
||||
* a peer's compute). The Share switch reads off while true, but the member
|
||||
* may replace this client with a serve runtime by turning sharing on.
|
||||
*/
|
||||
isConsuming: boolean;
|
||||
/**
|
||||
* ANY runtime occupies the single slot (serve or client, healthy or failed).
|
||||
* A fresh `mesh_start_node` fails with "already running" while true, so the
|
||||
* switch must not offer a start — only a stop of an existing serve node.
|
||||
* This remains useful for disabling edits around an existing serve runtime;
|
||||
* the backend supports the intentional client-to-serve replacement.
|
||||
*/
|
||||
slotOccupied: boolean;
|
||||
};
|
||||
|
||||
@@ -124,22 +124,21 @@ export function MeshComputeSettingsCard() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mirror the running node's modelId back into the field so the card shows
|
||||
// what's ACTUALLY being served — not just on a fresh load (empty field) but
|
||||
// whenever the served model differs from the field (e.g. the member switched
|
||||
// models and the node restarted on the new one). This is safe from clobbering
|
||||
// a mid-edit because the field is disabled whenever the slot is occupied
|
||||
// (`controlsDisabled`), so the user can't be typing while a node is running.
|
||||
// Mirror only a SERVE runtime's model into the field. A client reports the
|
||||
// remote model it is consuming; copying that value here both loses the
|
||||
// member's local sharing choice and can cause this machine to download the
|
||||
// remote member's much larger model when sharing is enabled.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
status?.state === "running" &&
|
||||
status.mode === "serve" &&
|
||||
status.modelId &&
|
||||
status.modelId !== modelInput
|
||||
) {
|
||||
setModelInput(status.modelId);
|
||||
writeDraft(MODEL_DRAFT_STORAGE_KEY, status.modelId);
|
||||
}
|
||||
}, [status?.state, status?.modelId, modelInput]);
|
||||
}, [status?.state, status?.mode, status?.modelId, modelInput]);
|
||||
|
||||
// The Share toggle reflects ONLY serve-mode occupancy. A client-mode runtime
|
||||
// (this machine consuming a peer's compute) shares the single runtime slot
|
||||
@@ -151,14 +150,12 @@ export function MeshComputeSettingsCard() {
|
||||
// actively sharing (serve mode). Read-only; reads the node's own metrics.
|
||||
const servingUsage = useMeshServingUsage(isSharing);
|
||||
const servingIndicator = deriveServingIndicator(servingUsage, isSharing);
|
||||
// Any occupying runtime (serve or client, healthy or failed) locks the model
|
||||
// inputs and blocks a fresh start — stop it before reconfiguring.
|
||||
const controlsDisabled = slotOccupied || actionInFlight;
|
||||
// A consuming client may be intentionally replaced by a serve runtime, so
|
||||
// keep the local model controls available in client mode. Serve and unknown
|
||||
// occupants remain locked until stopped/recovered.
|
||||
const controlsDisabled = actionInFlight || (slotOccupied && !isConsuming);
|
||||
const refClass = classifyModelRef(modelInput);
|
||||
const canStart =
|
||||
refClass.kind !== "unknown" &&
|
||||
!actionInFlight &&
|
||||
status?.state !== "starting";
|
||||
const canStart = refClass.kind !== "unknown" && !actionInFlight;
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
// Never let the Share switch tear down a consume session. The switch is
|
||||
@@ -260,13 +257,15 @@ export function MeshComputeSettingsCard() {
|
||||
checked={isSharing}
|
||||
data-testid="mesh-share-compute-toggle"
|
||||
disabled={
|
||||
// When the slot is occupied, the switch is only actionable if
|
||||
// WE are sharing (so it can stop a serve node — even a failed
|
||||
// one). Any other occupant (consuming, or an unexpected
|
||||
// modeless-running node) would make a fresh start throw "already
|
||||
// running", so keep it disabled. When the slot is empty, gate on
|
||||
// a valid model ref.
|
||||
actionInFlight || (slotOccupied ? !isSharing : !canStart)
|
||||
// A serve node can always be stopped. An off node or consuming
|
||||
// client can start sharing once a valid local model is selected.
|
||||
// Unknown occupants remain protected from replacement.
|
||||
actionInFlight ||
|
||||
(isSharing
|
||||
? false
|
||||
: slotOccupied && !isConsuming
|
||||
? true
|
||||
: !canStart)
|
||||
}
|
||||
id="mesh-share-compute-toggle"
|
||||
onCheckedChange={handleToggle}
|
||||
@@ -563,14 +562,13 @@ function StatusLine({
|
||||
return <p className="text-sm text-muted-foreground">Stopping…</p>;
|
||||
}
|
||||
// A client-mode runtime owns the single slot: this machine is consuming a
|
||||
// peer's compute, not sharing. Explain why Share is off + disabled instead
|
||||
// of showing the misleading "Sharing … with relay members" serve copy. The
|
||||
// client node is app-session-lived infra with no user stop control, so the
|
||||
// copy states the fact rather than promising an action that doesn't exist.
|
||||
// peer's compute, not sharing. The switch stays off, but remains available
|
||||
// so the member can replace the client with a serving runtime.
|
||||
if (isConsuming) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This machine is currently using another member's shared compute.
|
||||
This machine is currently using another member's shared compute. Turn on
|
||||
sharing to switch to the selected local model; Buzz may briefly restart.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@ import { openSettings } from "../helpers/settings";
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__BUZZ_E2E_COMMANDS__?: string[];
|
||||
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
|
||||
command: string;
|
||||
payload: { request?: { mode?: string; modelId?: string } } | null;
|
||||
}>;
|
||||
__BUZZ_E2E_SET_MESH__?: (mesh: {
|
||||
nodeState?: "off" | "running";
|
||||
nodeMode?: "serve" | "client" | null;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("Share compute has a clear empty state and starts and stops sharing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await openSettings(page, "compute");
|
||||
|
||||
@@ -56,14 +57,20 @@ test("Share compute has a clear empty state and starts and stops sharing", async
|
||||
.toContain("mesh_stop_node");
|
||||
});
|
||||
|
||||
test("consuming a peer's compute does NOT light the Share toggle", async ({
|
||||
test("a consuming client can switch to sharing its saved local model", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Regression: consuming someone else's shared compute starts a client-mode
|
||||
// node in the single runtime slot, which reports state:"running". The Share
|
||||
// toggle keyed off state alone and lit up — and clicking it would have torn
|
||||
// down the unrelated consume session. It must stay off + disabled, explain
|
||||
// why, and issue no stop command.
|
||||
// toggle keyed off state alone and lit up. A later guard overcorrected by
|
||||
// disabling the switch and copying the remote model over the local sharing
|
||||
// choice. Keep the switch off, preserve the local model, then replace the
|
||||
// client with one serve start (never a stop command).
|
||||
const localModel = "hf://demo/local-small-model:Q4_K_M";
|
||||
await page.addInitScript((model) => {
|
||||
window.localStorage.setItem("buzz.mesh-compute.share.model.v1", model);
|
||||
}, localModel);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
// The mesh seed hook is installed when the mock bridge boots; calling it
|
||||
// before then silently no-ops (optional chaining) and the seed is lost.
|
||||
@@ -80,19 +87,26 @@ test("consuming a peer's compute does NOT light the Share toggle", async ({
|
||||
|
||||
const card = page.getByTestId("settings-mesh-share-compute");
|
||||
const toggle = page.getByTestId("mesh-share-compute-toggle");
|
||||
const model = page.getByTestId("mesh-share-compute-model");
|
||||
|
||||
await expect(card).toContainText(
|
||||
"This machine is currently using another member's shared compute",
|
||||
);
|
||||
await expect(card).toContainText("Buzz may briefly restart");
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await expect(toggle).toBeDisabled();
|
||||
await expect(model).toBeEnabled();
|
||||
await expect(model).toHaveValue(localModel);
|
||||
await expect(toggle).toBeEnabled();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
|
||||
// The switch is disabled, so a click can't fire onCheckedChange — but assert
|
||||
// the destructive command never went out regardless.
|
||||
const stopIssued = await page.evaluate(() =>
|
||||
((window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []).includes(
|
||||
"mesh_stop_node",
|
||||
),
|
||||
);
|
||||
expect(stopIssued).toBe(false);
|
||||
const commands = await page.evaluate(() => ({
|
||||
names: (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? [],
|
||||
payloads: (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
|
||||
}));
|
||||
expect(commands.names).not.toContain("mesh_stop_node");
|
||||
expect(commands.payloads).toContainEqual({
|
||||
command: "mesh_start_node",
|
||||
payload: { request: { mode: "serve", modelId: localModel } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ test("Buzz shared compute explains automatic model selection", async ({
|
||||
await expect(page.locator("#persona-model")).toContainText("Automatic");
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Buzz will choose an available shared model when the agent starts.",
|
||||
"Auto uses Mesh collective intelligence when two or more models stay available, otherwise it chooses one available model.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#persona-custom-model")).toHaveCount(0);
|
||||
|
||||
Reference in New Issue
Block a user