diff --git a/crates/mesh-llm-host-runtime/src/mesh/host_role_claims.rs b/crates/mesh-llm-host-runtime/src/mesh/host_role_claims.rs new file mode 100644 index 0000000000..b3b50e36fa --- /dev/null +++ b/crates/mesh-llm-host-runtime/src/mesh/host_role_claims.rs @@ -0,0 +1,98 @@ +use super::node::Node; +use super::peer_state::NodeRole; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) enum HostRoleClaim { + LocalModel, + PluginInference, +} + +#[derive(Default)] +pub(crate) struct HostRoleClaims(BTreeMap); + +impl HostRoleClaims { + fn claim(&mut self, claim: HostRoleClaim) { + *self.0.entry(claim).or_default() += 1; + } + + fn release(&mut self, claim: HostRoleClaim) -> bool { + let Some(count) = self.0.get_mut(&claim) else { + return false; + }; + if *count > 1 { + *count -= 1; + } else { + self.0.remove(&claim); + } + true + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Node { + pub async fn claim_host_role(&self, claim: HostRoleClaim, http_port: u16) { + let transitioned = { + let mut claims = self.host_role_claims.lock().await; + claims.claim(claim); + let mut role = self.role.lock().await; + if matches!(*role, NodeRole::Worker) { + *role = NodeRole::Host { http_port }; + true + } else { + false + } + }; + if transitioned { + self.regossip().await; + } + } + + pub async fn release_host_role(&self, claim: HostRoleClaim) { + let transitioned = { + let mut claims = self.host_role_claims.lock().await; + if !claims.release(claim) || !claims.is_empty() { + false + } else { + let mut role = self.role.lock().await; + if matches!(*role, NodeRole::Host { .. }) { + *role = NodeRole::Worker; + true + } else { + false + } + } + }; + if transitioned { + self.regossip().await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn host_role_claims_are_reference_counted_across_sources() { + let node = Node::new_for_tests(NodeRole::Worker).await.unwrap(); + + node.claim_host_role(HostRoleClaim::LocalModel, 9337).await; + node.claim_host_role(HostRoleClaim::PluginInference, 9337) + .await; + assert_eq!(node.role().await, NodeRole::Host { http_port: 9337 }); + + node.release_host_role(HostRoleClaim::PluginInference).await; + assert_eq!(node.role().await, NodeRole::Host { http_port: 9337 }); + + node.claim_host_role(HostRoleClaim::LocalModel, 9337).await; + node.release_host_role(HostRoleClaim::LocalModel).await; + assert_eq!(node.role().await, NodeRole::Host { http_port: 9337 }); + + node.release_host_role(HostRoleClaim::LocalModel).await; + assert_eq!(node.role().await, NodeRole::Worker); + } +} diff --git a/crates/mesh-llm-host-runtime/src/mesh/mod.rs b/crates/mesh-llm-host-runtime/src/mesh/mod.rs index eeebae810b..ffef719b86 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/mod.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/mod.rs @@ -85,6 +85,7 @@ mod connections; mod direct_path; mod gossip; mod heartbeat; +mod host_role_claims; mod identity_persistence; mod lan_bootstrap; mod model_identity; @@ -108,6 +109,7 @@ mod stun; use connection_reservation::*; use connections::*; +pub(crate) use host_role_claims::{HostRoleClaim, HostRoleClaims}; use model_identity::*; use node_identity::*; use operational_logging::{MeshOperationalEvent, record_mesh_operational_event}; diff --git a/crates/mesh-llm-host-runtime/src/mesh/node.rs b/crates/mesh-llm-host-runtime/src/mesh/node.rs index f871aeae85..00b17bd414 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/node.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/node.rs @@ -40,6 +40,7 @@ pub struct Node { pub(crate) local_mesh_requirements: crate::MeshRequirements, pub(crate) state: Arc>, pub(crate) role: Arc>, + pub(crate) host_role_claims: Arc>, pub(crate) models: Arc>>, pub(crate) model_source: Arc>>, pub(crate) serving_models: Arc>>, @@ -775,6 +776,7 @@ impl Node { recent_mesh_rejections: VecDeque::new(), })), role: Arc::new(Mutex::new(role)), + host_role_claims: Arc::new(Mutex::new(HostRoleClaims::default())), models: Arc::new(Mutex::new(Vec::new())), model_source: Arc::new(Mutex::new(None)), serving_models: Arc::new(Mutex::new(Vec::new())), @@ -950,6 +952,7 @@ impl Node { recent_mesh_rejections: VecDeque::new(), })), role: Arc::new(Mutex::new(role)), + host_role_claims: Arc::new(Mutex::new(HostRoleClaims::default())), models: Arc::new(Mutex::new(Vec::new())), model_source: Arc::new(Mutex::new(None)), serving_models: Arc::new(Mutex::new(Vec::new())), @@ -1154,6 +1157,7 @@ impl Node { self.role.lock().await.clone() } + #[cfg(test)] pub async fn set_role(&self, role: NodeRole) { *self.role.lock().await = role; } diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs index b202dc87df..111e361b5c 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs @@ -418,6 +418,7 @@ async fn make_test_node_with_requirements( recent_mesh_rejections: VecDeque::new(), })), role: Arc::new(Mutex::new(role)), + host_role_claims: Arc::new(Mutex::new(HostRoleClaims::default())), models: Arc::new(Mutex::new(Vec::new())), model_source: Arc::new(Mutex::new(None)), serving_models: Arc::new(Mutex::new(Vec::new())), @@ -505,6 +506,65 @@ async fn make_test_node_with_requirements( Ok(node) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn host_role_claim_transitions_regossip_to_connected_peer() -> Result<()> { + let host = make_test_node(super::NodeRole::Worker).await?; + let peer = make_test_node(super::NodeRole::Worker).await?; + host.set_mesh_id("host-role-claim-regossip-test".to_string()) + .await; + peer.set_mesh_id("host-role-claim-regossip-test".to_string()) + .await; + host.start_accepting(); + peer.start_accepting(); + + let host_id = host.id(); + peer.join(&host.invite_token().await).await?; + wait_for_peer(&peer, host_id).await; + wait_for_peer(&host, peer.id()).await; + + host.claim_host_role(super::HostRoleClaim::PluginInference, 9337) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if peer + .peers() + .await + .into_iter() + .find(|candidate| candidate.id == host_id) + .is_some_and(|candidate| { + candidate.role == super::NodeRole::Host { http_port: 9337 } + }) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + }) + .await + .expect("connected peer should receive host promotion gossip"); + + host.release_host_role(super::HostRoleClaim::PluginInference) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if peer + .peers() + .await + .into_iter() + .find(|candidate| candidate.id == host_id) + .is_some_and(|candidate| candidate.role == super::NodeRole::Worker) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + }) + .await + .expect("connected peer should receive host demotion gossip"); + + Ok(()) +} + #[tokio::test] async fn set_serving_models_preserves_existing_known_descriptor_capabilities_when_adding_model() -> Result<()> { diff --git a/crates/mesh-llm-host-runtime/src/plugin/transport.rs b/crates/mesh-llm-host-runtime/src/plugin/transport.rs index 9301fb3a48..9a92fdfa20 100644 --- a/crates/mesh-llm-host-runtime/src/plugin/transport.rs +++ b/crates/mesh-llm-host-runtime/src/plugin/transport.rs @@ -27,6 +27,8 @@ use tokio::sync::{Mutex, mpsc, oneshot}; const PLUGIN_ENVELOPE_PREFIX_READ_TIMEOUT: Duration = Duration::from_secs((super::health::HEALTH_CHECK_INTERVAL_SECS * 4) + 30); const PLUGIN_ENVELOPE_BODY_READ_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(test)] +const PLUGIN_ENVELOPE_TEST_READ_TIMEOUT: Duration = Duration::from_secs(1); const PLUGIN_MESH_STREAM_RESPONSE_TIMEOUT: Duration = Duration::from_secs(super::REQUEST_TIMEOUT_SECS); @@ -419,31 +421,41 @@ pub(crate) async fn read_envelope(stream: &mut LocalStream) -> Result(stream: &mut R) -> Result +where + R: tokio::io::AsyncRead + Unpin, +{ + read_envelope_from_reader_with_timeouts( + stream, + PLUGIN_ENVELOPE_TEST_READ_TIMEOUT, + PLUGIN_ENVELOPE_TEST_READ_TIMEOUT, + ) + .await +} + +#[cfg(test)] +async fn read_envelope_from_reader_with_timeouts( + stream: &mut R, + prefix_timeout: Duration, + body_timeout: Duration, +) -> Result where R: tokio::io::AsyncRead + Unpin, { let mut len_buf = [0u8; 4]; tokio::time::timeout( - PLUGIN_ENVELOPE_PREFIX_READ_TIMEOUT, + prefix_timeout, AsyncReadExt::read_exact(stream, &mut len_buf), ) .await - .map_err(|_| { - anyhow!("timeout reading plugin frame prefix after {PLUGIN_ENVELOPE_PREFIX_READ_TIMEOUT:?}") - })??; + .map_err(|_| anyhow!("timeout reading plugin frame prefix after {prefix_timeout:?}"))??; let len = u32::from_le_bytes(len_buf) as usize; if len > 16 * 1024 * 1024 { bail!("Plugin frame too large"); } let mut body = vec![0u8; len]; - tokio::time::timeout( - PLUGIN_ENVELOPE_BODY_READ_TIMEOUT, - AsyncReadExt::read_exact(stream, &mut body), - ) - .await - .map_err(|_| { - anyhow!("timeout reading plugin frame body after {PLUGIN_ENVELOPE_BODY_READ_TIMEOUT:?}") - })??; + tokio::time::timeout(body_timeout, AsyncReadExt::read_exact(stream, &mut body)) + .await + .map_err(|_| anyhow!("timeout reading plugin frame body after {body_timeout:?}"))??; Ok(prost::Message::decode(body.as_slice())?) } diff --git a/crates/mesh-llm-host-runtime/src/runtime/mod.rs b/crates/mesh-llm-host-runtime/src/runtime/mod.rs index 2117f7e17d..7d9ffa3879 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/mod.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/mod.rs @@ -18,6 +18,7 @@ mod model_lifecycle; pub(crate) mod model_reconciliation; mod operational_logging; mod options; +mod plugin_host_role; mod proxy; mod publication; mod release_attestation; diff --git a/crates/mesh-llm-host-runtime/src/runtime/plugin_host_role.rs b/crates/mesh-llm-host-runtime/src/runtime/plugin_host_role.rs new file mode 100644 index 0000000000..8dd0f38bb8 --- /dev/null +++ b/crates/mesh-llm-host-runtime/src/runtime/plugin_host_role.rs @@ -0,0 +1,35 @@ +use crate::{mesh, plugin}; +use std::time::Duration; + +const WATCH_INTERVAL: Duration = Duration::from_secs(5); + +/// Promotes this node to `NodeRole::Host` whenever a loaded plugin is +/// advertising at least one inference model, and releases the plugin's host +/// claim when that stops being true. +pub(super) fn spawn(node: mesh::Node, plugin_manager: plugin::PluginManager, http_port: u16) { + tokio::spawn(async move { + let mut plugin_claimed = false; + loop { + tokio::time::sleep(WATCH_INTERVAL).await; + let has_plugin_models = match plugin_manager.inference_models().await { + Ok(models) => !models.is_empty(), + Err(error) => { + tracing::warn!( + %error, + "plugin host-role watcher: failed to read plugin inference models, skipping this tick" + ); + continue; + } + }; + if has_plugin_models && !plugin_claimed { + node.claim_host_role(mesh::HostRoleClaim::PluginInference, http_port) + .await; + plugin_claimed = true; + } else if !has_plugin_models && plugin_claimed { + node.release_host_role(mesh::HostRoleClaim::PluginInference) + .await; + plugin_claimed = false; + } + } + }); +} diff --git a/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs b/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs index 82c7928efc..1982a498e1 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs @@ -1,4 +1,5 @@ use super::daemon_startup::{check_mode_conflicts, resolve_effective_mode}; +use super::plugin_host_role; use super::startup_identity::{emit_private_mesh_name_warning, handle_public_identity_transition}; use super::status::mesh_guardrail_mode_to_openai; use super::{ @@ -1351,6 +1352,33 @@ pub(super) async fn run_auto(ctx: RunAutoContext) -> Result<()> { let tunnel_mgr = tunnel::Manager::start(node.clone(), channels.rpc, channels.http, channels.stage).await?; + // Both halves of inbound reachability are established here for any node + // that can serve, rather than only as a side effect of a local model + // finishing load. + // + // `set_http_port` is what lets a plugin-only node (no local model ever + // loads) accept inbound requests at all: the api proxy it points at is + // already bound and already answers correctly with no models loaded, so a + // tunneled request arriving before any model is ready gets a normal "not + // available" response instead of being silently dropped (the previous + // behavior whenever this was still 0 — see `network/tunnel.rs`'s + // `port == 0` early-return). The three call sites in `startup_handles.rs` + // remain and are now redundant-but-harmless — same node, same `api_port`, + // for the lifetime of the process. + // + // `plugin_host_role::spawn` is the other half: whether peers actually + // route here. + // + // Both are gated on `!is_client`. A client node has no compute to offer + // and never advertises `Host`, so nothing selects it as a route target; + // leaving its inbound HTTP tunnel terminated at the `port == 0` check + // keeps it exactly as reachable as it was before this change — not at + // all — instead of turning it into a mesh-internal request relay for any + // admitted peer that dials it. + if !is_client { + tunnel_mgr.set_http_port(api_port); + plugin_host_role::spawn(node.clone(), plugin_manager.clone(), api_port); + } // Election publishes per-model targets let (target_tx, target_rx) = tokio::sync::watch::channel(election::ModelTargets::default()); diff --git a/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs b/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs index d8eb993e60..5a152eb6c8 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs @@ -19,7 +19,7 @@ use super::{ }; use crate::api; use crate::inference::{election, skippy}; -use crate::mesh::{self, NodeRole}; +use crate::mesh; use crate::network::tunnel; use crate::plugin; use crate::runtime::interactive; @@ -665,6 +665,11 @@ pub(super) async fn startup_handle_fallback_failure( event.reason, event.generation, unavailable_stage_nodes )), }); + // The failed fallback exits without the shared shutdown path, so release + // the local-model host claim explicitly before returning to the runtime. + ctx.node + .release_host_role(mesh::HostRoleClaim::LocalModel) + .await; startup_remove_runtime_instance_artifacts(ctx, model_name).await; StartupLoopControl::Return } @@ -952,6 +957,9 @@ pub(super) async fn startup_shutdown_local_model_loop( task.abort(); let _ = task.await; } + ctx.node + .release_host_role(mesh::HostRoleClaim::LocalModel) + .await; if !state.survey_exited_unexpectedly { ctx.survey_telemetry .record_unload(&state.survey_loaded_model); @@ -1527,9 +1535,7 @@ pub(super) async fn startup_publish_loaded_runtime( ) { let payload = startup_register_loaded_runtime(ctx, loaded_name, handle).await; ctx.node - .set_role(NodeRole::Host { - http_port: ctx.api_port, - }) + .claim_host_role(mesh::HostRoleClaim::LocalModel, ctx.api_port) .await; refresh_dashboard_context_usage(ctx.dashboard_context_usage, loaded_name, handle).await; publish_runtime_llama_slots( diff --git a/docs/plugins/plugin-hosted-mesh-serving-design.md b/docs/plugins/plugin-hosted-mesh-serving-design.md new file mode 100644 index 0000000000..108cf32dc6 --- /dev/null +++ b/docs/plugins/plugin-hosted-mesh-serving-design.md @@ -0,0 +1,180 @@ +# Design: mesh-wide serving for plugin-hosted models + +## Status + +Implemented in this branch. A live end-to-end test covered a single node +running an external-relay plugin and serving an LM Studio model through that +node's own local proxy; it did not exercise an inbound mesh tunnel from +another peer or mesh-wide discovery and routing (see "Deliberately deferred" +below). That test surfaced a gap in how +a plugin-only node — one with zero local (downloaded) models, whose only +inference capacity comes from a plugin endpoint (e.g. +`inference: [openai_http(...)]`) — is treated by the rest of the mesh. + +## What already worked + +Two things were already correct by the time this investigation started, +and are not touched by this change: + +- **This node's own local ingress.** The unified API proxy every node now + binds unconditionally at startup (`run_auto()` → `api_proxy` → + `network/openai/ingress.rs`) already merges `plugin_manager + .inference_models()` into `/v1/models` and already has full + external-endpoint forwarding for plugin-hosted models. This has been + true since before this investigation — an earlier draft of this + document (and an earlier commit on this branch, since dropped) mistook + a *different*, no-longer-primary code path + (`network/openai/transport.rs::handle_mesh_request`, reached only via + the now-`#[expect(dead_code)]` "passive listener" lane — + `run_auto_model_path_or_shutdown` — that a large "daemon model + lifecycle reconciliation" refactor superseded between this fork's base + and current `main`) for the node's primary listener. It isn't; that + patch was reverted as a no-op once the actual current architecture was + understood. See "History" below. +- **Gossip advertisement.** `mesh/gossip.rs`'s + `plugin_inference_models()` already correctly merges plugin-provided + models into what this node tells the mesh it serves. + +## The actual gap + +Even with both of those correct, no *other* peer could ever route a +request to a plugin-only node, for two structural reasons — both +independent of the local-ingress/gossip machinery above, and both still +present on current `main`: + +### 1. Host-role eligibility + +Other peers only consider a node as a candidate host for model X if +`PeerInfo::routes_http_model` returns true, which requires +`accepts_http_inference()` — `matches!(self.role, NodeRole::Host { .. })` +(`mesh/peer_state.rs`). + +`NodeRole::Host { http_port: u16 }` is set in exactly one place in the +whole crate: `startup_handles.rs`, as a side effect of a local model +finishing load. A plugin-only node's role never leaves the default +`Worker`, so **every other peer's `hosts_for_model()` filters this node +out completely**, independent of what its gossip payload advertises. + +### 2. Inbound tunnel routing + +A request another peer *does* decide to route here arrives as a raw byte +relay: an inbound QUIC stream gets forwarded to a local TCP connection at +`tunnel::Manager`'s configured `http_port`. That port defaults to `0` and +is — like the role above — only ever set as a side effect of a local +model finishing load (`startup_handles.rs`, three call sites). If it's +still `0`, an inbound tunnel is dropped with a warning +(`network/tunnel.rs`). + +## Fix + +Both gaps have the same shape: a value that should be a property of "the +node's API surface is up," generalized everywhere else in `run_auto()` +since the daemon-lifecycle-reconciliation refactor, but that these two +specific call sites still only set as a side effect of local-model load +completing. + +- **Tunnel port**: since `run_auto()` already unconditionally constructs + `tunnel::Manager` and binds the real API proxy before any model + resolution happens, set `tunnel_mgr.set_http_port(api_port)` + immediately after construction, for any node that can serve. The three + call sites in `startup_handles.rs` remain and become + redundant-but-harmless (same node, same `api_port`, for the process's + lifetime) rather than conflicting. +- **Host role**: add `plugin_host_role::spawn`, a small background + task started alongside the tunnel manager that polls + `plugin_manager.inference_models()` on a short + interval and claims `NodeRole::Host { http_port: api_port }` when the + successful result is non-empty, releasing that claim when it becomes + empty again. Errors are logged and leave the previous role state intact + until a successful sample arrives. `mesh::Node` reference-counts local + model and plugin claims, demotes only after the final claimant releases, + and re-gossips actual role transitions. The local-model `Exit` fallback + path also releases its claim explicitly because it bypasses the shared + shutdown teardown. `inference_models()` reads the plugin manager's own + already-debounced health state (see `plugin::health`'s startup-grace/ + failure-threshold logic) rather than probing anything itself, so the + short poll interval doesn't add flapping risk beyond that debouncing. + +The tunnel-port fix is a small addition in `run_auto()`'s existing startup +path. The host-role watcher is new (if small) background machinery. Neither +threads through the +passive-listener/model-selection context that an earlier version of this +fix (written against an older fork base, before the +daemon-lifecycle-reconciliation refactor landed upstream) needed and that +would have been dead weight added to an already-dead code path. + +## Client nodes + +Both changes are gated behind `!is_client`, together, at the same call site: + +```rust +if !is_client { + tunnel_mgr.set_http_port(api_port); + plugin_host_role::spawn(node.clone(), plugin_manager.clone(), api_port); +} +``` + +A `--client` node has no compute to offer regardless of plugin state, so the +host-role watcher is pointless there. The tunnel port is gated for a separate +and more important reason: setting it is what makes a node's inbound QUIC HTTP +tunnel terminate at the local API proxy instead of at `tunnel.rs`'s +`port == 0` early-return. + +If a client set it, an inbound tunnel from any admitted peer would relay into +the local proxy, find no local model, and route back out to another peer +through `hosts_for_model()` — making the client a mesh-internal request relay. +Nothing would *select* a client that way (clients never advertise `Host`), but +any peer that deliberately dialed one could use it, which on a public mesh +means any member. The same ingress also carries the `/mesh/load` and +`/mesh/drop` control paths, which are dispatched ahead of the inference +admission check; `/mesh/load` is refused for clients by the runtime control +loop, and `/mesh/drop` is inert only because a client has nothing loaded. + +Gating keeps a client exactly as reachable as it was before this change — not +at all — and avoids widening a surface that issue #1190 exists to narrow. +Plugin-only nodes, the case this document is about, are never `--client`, so +the gate does not affect them. + +## Deliberately deferred + +- **Additional host-role claim sources.** Local model load and plugin health + now have explicit claims, but future host capabilities may need their own + claim type and lifecycle. Any new claimant must use the same ownership and + re-gossip path so it cannot demote a node still serving for another source. +- **Capacity/VRAM-based host ordering.** `order_remote_hosts_by_context` + and related host-ranking logic (used when multiple peers can serve the + same model) is built around real local VRAM/context-window numbers. A + plugin-relayed model has no meaningful local VRAM figure — needs a + policy decision (advertise an unbounded/synthetic capacity, or exempt + plugin-sourced models from VRAM-based ranking and use a simpler + round-robin/health-only ordering among plugin hosts). +- **Model descriptors.** `all_served_model_descriptors` / + `all_model_runtime_descriptors` assume a real local runtime descriptor + (loaded quant, context length, etc.). Plugin-sourced models need either + synthetic descriptors or these call sites need to become Option-aware. +- **Streaming verification through the tunnel path specifically.** Local + streaming completions through the plugin's proxy are verified working; + the same request routed to this node over an inbound QUIC tunnel from a + peer has not been separately verified. +- **Live multi-peer verification.** This fix compiles cleanly and doesn't + regress the existing single-node acceptance test, but the actual + mesh-wide behavior it targets — another peer discovering and + successfully routing a request to a plugin-only node — has not yet been + exercised by a live multi-peer test. + +## History + +An earlier version of this branch, written against this fork's original +base (before a large "daemon model lifecycle reconciliation" feature and +related refactors landed on upstream `main`), additionally patched +`network/openai/transport.rs::handle_mesh_request` to merge plugin models +into its own `/v1/models` response and to route matching requests +directly to a plugin's loopback endpoint. That function is reached via +`run_passive`, itself reached only through +`run_auto_model_path_or_shutdown` — a function upstream has since marked +`#[expect(dead_code, reason = "bridges the retained advertised-model and +passive runtime compatibility lanes")]`. The refactor made every node's +own local ingress go through the already-plugin-aware +`network/openai/ingress.rs` path unconditionally instead, which made that +patch a no-op on current `main`. It was reverted once this was confirmed, +rather than landing a fix for an unreachable code path.