mirror of
https://github.com/Mesh-LLM/mesh-llm.git
synced 2026-08-08 22:23:19 -04:00
Add plugin startup resilience diagnostics
Validation
* Validation tier: Tier 3 - shared plugin startup/config/runtime behavior plus doctor diagnostic capture, refreshed onto current main for PR #759; no plugin protocol/schema or release metadata change.
* git fetch --no-tags origin main:refs/remotes/origin/main codex/plugin-startup-resilience-doctor:refs/remotes/origin/codex/plugin-startup-resilience-doctor: PASS, origin/main at 4f02a65c.
* git rebase origin/main: PASS, no conflicts.
* git diff --check origin/main...HEAD: PASS, no output.
* git diff --check: PASS, no output.
* git diff --cached --check: PASS, no output.
* cargo fmt --all -- --check: PASS.
* cargo test -p mesh-llm-config plugin_startup --lib: PASS, 2 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo test -p mesh-llm-host-runtime plugin::tests --lib -- --test-threads=1: PASS, 25 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo test -p mesh-llm-host-runtime split_doctor_captures_plugin_startup_surfaces --lib -- --test-threads=1: PASS, 1 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo test -p mesh-llm-host-runtime runtime_data --lib -- --test-threads=1: PASS, 27 passed.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal cargo check -p mesh-llm: PASS.
* LLAMA_STAGE_BUILD_DIR=/Users/Funtland/Downloads/mesh-llm/.deps/llama-build/build-stage-abi-metal /opt/homebrew/bin/cargo-clippy clippy -p mesh-llm-config -p mesh-llm-host-runtime --all-targets -- -D warnings: PASS.
* Ledger: not applicable - not required for selected validation tier/change family.
* Version: not applicable - no release/version sync required for this non-release plugin runtime diagnostic change.
* Not run: just build - not required for selected validation tier; no UI assets or release bundle changed.
* Not run: live legacy CPU/intelSDE plugin startup smoke - no local legacy/emulated plugin host was available; targeted config, runtime, API, and doctor tests cover the changed branches.
Rollback
* git revert HEAD
This commit is contained in:
parent
4f02a65cb5
commit
06f9411ef1
20 changed files with 502 additions and 32 deletions
|
|
@ -142,7 +142,11 @@ store.update(|config| {
|
|||
config.enable_builtin_plugin("telemetry")?;
|
||||
config.upsert_plugin("endpoint-plugin")?
|
||||
.enabled(true)
|
||||
.url("http://localhost:8000/v1");
|
||||
.url("http://localhost:8000/v1")
|
||||
.connect_timeout_secs(75)
|
||||
.init_timeout_secs(90)
|
||||
.optional(true)
|
||||
.lazy_start(true);
|
||||
config.upsert_external_plugin("custom-tool", "mesh-tool", ["--serve"])?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ impl ConfigEditor {
|
|||
command: None,
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
});
|
||||
self.config.plugins.len() - 1
|
||||
}
|
||||
|
|
@ -382,6 +383,26 @@ impl PluginConfigEditor<'_> {
|
|||
self.plugin.url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn connect_timeout_secs(&mut self, seconds: u64) -> &mut Self {
|
||||
self.plugin.startup.connect_timeout_secs = Some(seconds);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn init_timeout_secs(&mut self, seconds: u64) -> &mut Self {
|
||||
self.plugin.startup.init_timeout_secs = Some(seconds);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn optional(&mut self, optional: bool) -> &mut Self {
|
||||
self.plugin.startup.optional = optional;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn lazy_start(&mut self, lazy_start: bool) -> &mut Self {
|
||||
self.plugin.startup.lazy_start = lazy_start;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_non_empty(value: &str, label: &str) -> Result<String> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod authoring;
|
||||
mod model;
|
||||
mod plugin_validation;
|
||||
mod store;
|
||||
mod validate;
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ pub use validate::validate_config;
|
|||
mod tests {
|
||||
use super::{
|
||||
ConfigStore, GpuAssignment, LocalServingNodeConfig, MeshConfig, ModelRuntimeKind,
|
||||
parse_config_toml,
|
||||
parse_config_toml, validate_config,
|
||||
};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
|
@ -30,6 +31,58 @@ mod tests {
|
|||
assert!(config.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_startup_config_round_trips_from_toml() {
|
||||
let config: MeshConfig = toml::from_str(
|
||||
r#"
|
||||
version = 1
|
||||
|
||||
[[plugin]]
|
||||
name = "metrics"
|
||||
command = "mesh-llm-plugin-metrics"
|
||||
|
||||
[plugin.startup]
|
||||
connect_timeout_secs = 75
|
||||
init_timeout_secs = 90
|
||||
optional = true
|
||||
lazy_start = true
|
||||
"#,
|
||||
)
|
||||
.expect("plugin startup config should parse");
|
||||
|
||||
let startup = &config.plugins[0].startup;
|
||||
assert_eq!(startup.connect_timeout_secs, Some(75));
|
||||
assert_eq!(startup.init_timeout_secs, Some(90));
|
||||
assert!(startup.optional);
|
||||
assert!(startup.lazy_start);
|
||||
validate_config(&config).expect("positive startup timeouts should validate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_startup_config_rejects_zero_timeouts() {
|
||||
let config: MeshConfig = toml::from_str(
|
||||
r#"
|
||||
version = 1
|
||||
|
||||
[[plugin]]
|
||||
name = "metrics"
|
||||
command = "mesh-llm-plugin-metrics"
|
||||
|
||||
[plugin.startup]
|
||||
connect_timeout_secs = 0
|
||||
"#,
|
||||
)
|
||||
.expect("plugin startup config should parse before validation");
|
||||
|
||||
let err = validate_config(&config).expect_err("zero connect timeout must be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("plugin[0].startup.connect_timeout_secs must be at least 1"),
|
||||
"unexpected validation error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_add_model_preserves_existing_fields() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -978,4 +978,24 @@ pub struct PluginConfigEntry {
|
|||
/// Optional URL passed to the plugin as `MESH_LLM_PLUGIN_URL`.
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "PluginStartupConfig::is_default")]
|
||||
pub startup: PluginStartupConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct PluginStartupConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub init_timeout_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
#[serde(default)]
|
||||
pub lazy_start: bool,
|
||||
}
|
||||
|
||||
impl PluginStartupConfig {
|
||||
pub fn is_default(&self) -> bool {
|
||||
self == &Self::default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
crates/mesh-llm-config/src/plugin_validation.rs
Normal file
20
crates/mesh-llm-config/src/plugin_validation.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::PluginConfigEntry;
|
||||
|
||||
pub(crate) fn validate_plugin_entries(entries: &[PluginConfigEntry]) -> Result<()> {
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
validate_plugin_startup(entry, index)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_plugin_startup(entry: &PluginConfigEntry, index: usize) -> Result<()> {
|
||||
if matches!(entry.startup.connect_timeout_secs, Some(0)) {
|
||||
bail!("plugin[{index}].startup.connect_timeout_secs must be at least 1 when set");
|
||||
}
|
||||
if matches!(entry.startup.init_timeout_secs, Some(0)) {
|
||||
bail!("plugin[{index}].startup.init_timeout_secs must be at least 1 when set");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::model::{merge_hardware, merge_model_fit, merge_multimodal, merge_throughput};
|
||||
use crate::plugin_validation::validate_plugin_entries;
|
||||
use crate::*;
|
||||
use anyhow::{Result, bail};
|
||||
use semver::{BuildMetadata, Version};
|
||||
|
|
@ -30,6 +31,7 @@ pub fn validate_config(config: &MeshConfig) -> Result<()> {
|
|||
}
|
||||
validate_mesh_requirements_config(&config.mesh_requirements)?;
|
||||
validate_telemetry_config(&config.telemetry)?;
|
||||
validate_plugin_entries(&config.plugins)?;
|
||||
let defaults_hardware = config
|
||||
.defaults
|
||||
.as_ref()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,23 @@ const SKIPPY_DIAGNOSTIC_ENDPOINTS: &[(&str, &str, &str)] = &[
|
|||
"/api/runtime/stages",
|
||||
"runtime-stages.json",
|
||||
),
|
||||
(
|
||||
"runtime_endpoints",
|
||||
"/api/runtime/endpoints",
|
||||
"runtime-endpoints.json",
|
||||
),
|
||||
("runtime_llama", "/api/runtime/llama", "runtime-llama.json"),
|
||||
("plugins", "/api/plugins", "plugins.json"),
|
||||
(
|
||||
"plugin_endpoints",
|
||||
"/api/plugins/endpoints",
|
||||
"plugin-endpoints.json",
|
||||
),
|
||||
(
|
||||
"plugin_providers",
|
||||
"/api/plugins/providers",
|
||||
"plugin-providers.json",
|
||||
),
|
||||
];
|
||||
|
||||
pub(crate) async fn dispatch_doctor_command(command: &DoctorCommand) -> Result<()> {
|
||||
|
|
@ -309,8 +325,8 @@ fn split_readiness_lines(report: &Value) -> Vec<String> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
capture_skippy_native_log, select_runtime_instance, split_readiness_lines,
|
||||
write_split_readiness_report,
|
||||
SKIPPY_DIAGNOSTIC_ENDPOINTS, capture_skippy_native_log, select_runtime_instance,
|
||||
split_readiness_lines, write_split_readiness_report,
|
||||
};
|
||||
use crate::runtime::instance::LocalInstanceSnapshot;
|
||||
use serde_json::json;
|
||||
|
|
@ -358,6 +374,18 @@ mod tests {
|
|||
assert!(written.contains("\"verdict\": \"ready\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_doctor_captures_plugin_startup_surfaces() {
|
||||
let paths = SKIPPY_DIAGNOSTIC_ENDPOINTS
|
||||
.iter()
|
||||
.map(|(_, path, _)| *path)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(paths.contains(&"/api/runtime/endpoints"));
|
||||
assert!(paths.contains(&"/api/plugins"));
|
||||
assert!(paths.contains(&"/api/plugins/providers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_runtime_instance_prefers_matching_console_port() {
|
||||
let first = instance_snapshot(100, Some(3131), "/tmp/mesh-100");
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ fn resolve_installed_cli_plugin(command: &str) -> Result<Option<plugin::External
|
|||
args: Vec::new(),
|
||||
url: None,
|
||||
env: Default::default(),
|
||||
startup: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use super::installed::{append_installed_plugins, configured_external_plugin_spec};
|
||||
use super::{BLOBSTORE_PLUGIN_ID, PluginSummary};
|
||||
use super::installed::{
|
||||
ConfiguredExternalPlugin, append_installed_plugins, configured_external_plugin_spec,
|
||||
};
|
||||
use super::{BLOBSTORE_PLUGIN_ID, PluginStartupOptions, PluginSummary};
|
||||
use crate::{
|
||||
MeshRequirementRejectReason, MeshRequirements, NodeVersionBounds, ProtocolGenerationBounds,
|
||||
ReleaseAttestationRequirement,
|
||||
|
|
@ -11,11 +13,11 @@ pub use mesh_llm_config::{
|
|||
FlashAttentionType, GpuAssignment, GpuConfig, HardwareConfig, IntegerOrString,
|
||||
LocalServingNodeConfig, MeshConfig, MeshRequirementsConfig, ModelConfigDefaults,
|
||||
ModelConfigEditor, ModelConfigEntry, ModelDefaultsEditor, ModelFitConfig, ModelRuntimeKind,
|
||||
MultimodalConfig, OwnerControlConfig, PluginConfigEditor, PluginConfigEntry, PrefixCacheConfig,
|
||||
ReasoningBudget, ReasoningEnabled, RequestDefaultsConfig, ReservedObjectConfig, SkippyConfig,
|
||||
SpeculativeConfig, StringOrStringList, TelemetryConfig, TelemetryMetricsConfig,
|
||||
TensorSplitConfig, ThroughputConfig, config_path, config_to_toml, load_config,
|
||||
parse_config_toml, validate_config,
|
||||
MultimodalConfig, OwnerControlConfig, PluginConfigEditor, PluginConfigEntry,
|
||||
PluginStartupConfig, PrefixCacheConfig, ReasoningBudget, ReasoningEnabled,
|
||||
RequestDefaultsConfig, ReservedObjectConfig, SkippyConfig, SpeculativeConfig,
|
||||
StringOrStringList, TelemetryConfig, TelemetryMetricsConfig, TensorSplitConfig,
|
||||
ThroughputConfig, config_path, config_to_toml, load_config, parse_config_toml, validate_config,
|
||||
};
|
||||
use mesh_llm_plugin::MeshVisibility;
|
||||
use std::collections::BTreeMap;
|
||||
|
|
@ -206,6 +208,7 @@ pub struct ExternalPluginSpec {
|
|||
pub url: Option<String>,
|
||||
/// Extra environment passed only to the plugin process.
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub startup: PluginStartupOptions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
@ -224,7 +227,11 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
|
|||
}
|
||||
let enabled = entry.enabled.unwrap_or(true);
|
||||
if entry.name == BLOBSTORE_PLUGIN_ID {
|
||||
if entry.command.is_some() || !entry.args.is_empty() || entry.url.is_some() {
|
||||
if entry.command.is_some()
|
||||
|| !entry.args.is_empty()
|
||||
|| entry.url.is_some()
|
||||
|| !entry.startup.is_default()
|
||||
{
|
||||
bail!(
|
||||
"Plugin '{}' is served by mesh-llm itself; only `enabled` may be set",
|
||||
BLOBSTORE_PLUGIN_ID
|
||||
|
|
@ -236,7 +243,10 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
|
|||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
externals.push(configured_external_plugin_spec(entry)?);
|
||||
match configured_external_plugin_spec(entry)? {
|
||||
ConfiguredExternalPlugin::Active(spec) => externals.push(spec),
|
||||
ConfiguredExternalPlugin::Inactive(summary) => inactive.push(summary),
|
||||
}
|
||||
}
|
||||
|
||||
append_installed_plugins(&mut externals, &mut inactive, &mut names);
|
||||
|
|
@ -267,6 +277,7 @@ pub fn blobstore_plugin_spec() -> Result<ExternalPluginSpec> {
|
|||
],
|
||||
url: None,
|
||||
env: BTreeMap::new(),
|
||||
startup: PluginStartupOptions::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
use super::PluginSummary;
|
||||
use super::config::{ExternalPluginSpec, PluginConfigEntry};
|
||||
use super::startup::PluginStartupOptions;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use mesh_llm_plugin_manager::{InstalledPluginMetadata, PluginStore, default_store_root};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) enum ConfiguredExternalPlugin {
|
||||
Active(ExternalPluginSpec),
|
||||
Inactive(PluginSummary),
|
||||
}
|
||||
|
||||
pub(crate) fn configured_external_plugin_spec(
|
||||
entry: &PluginConfigEntry,
|
||||
) -> Result<ExternalPluginSpec> {
|
||||
) -> Result<ConfiguredExternalPlugin> {
|
||||
let startup = PluginStartupOptions::from_config(&entry.startup);
|
||||
let command = entry
|
||||
.command
|
||||
.as_deref()
|
||||
|
|
@ -17,16 +24,25 @@ pub(crate) fn configured_external_plugin_spec(
|
|||
|
||||
let command = match command {
|
||||
Some(command) => command,
|
||||
None => installed_plugin_command_for_name(&entry.name)?,
|
||||
None => match installed_plugin_command_for_name(&entry.name) {
|
||||
Ok(command) => command,
|
||||
Err(error) if startup.optional => {
|
||||
return Ok(ConfiguredExternalPlugin::Inactive(
|
||||
optional_configured_plugin_summary(entry, &startup, error),
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(ExternalPluginSpec {
|
||||
Ok(ConfiguredExternalPlugin::Active(ExternalPluginSpec {
|
||||
name: entry.name.clone(),
|
||||
command,
|
||||
args: entry.args.clone(),
|
||||
url: entry.url.clone(),
|
||||
env: BTreeMap::new(),
|
||||
})
|
||||
startup,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn append_installed_plugins(
|
||||
|
|
@ -97,6 +113,29 @@ fn installed_plugin_spec(metadata: &InstalledPluginMetadata) -> ExternalPluginSp
|
|||
args: Vec::new(),
|
||||
url: None,
|
||||
env: BTreeMap::new(),
|
||||
startup: PluginStartupOptions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_configured_plugin_summary(
|
||||
entry: &PluginConfigEntry,
|
||||
startup: &PluginStartupOptions,
|
||||
error: anyhow::Error,
|
||||
) -> PluginSummary {
|
||||
PluginSummary {
|
||||
name: entry.name.clone(),
|
||||
kind: "external".to_string(),
|
||||
enabled: true,
|
||||
status: "missing".to_string(),
|
||||
pid: None,
|
||||
version: None,
|
||||
capabilities: Vec::new(),
|
||||
command: entry.command.clone(),
|
||||
args: entry.args.clone(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: Some(startup.summary()),
|
||||
error: Some(format!("optional plugin not loaded: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +174,7 @@ fn installed_store_error_summary(error: anyhow::Error) -> PluginSummary {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +196,7 @@ fn installed_plugin_summary(
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod installed;
|
|||
pub(crate) mod mcp;
|
||||
mod runtime;
|
||||
pub(crate) mod stapler;
|
||||
mod startup;
|
||||
mod support;
|
||||
mod transport;
|
||||
|
||||
|
|
@ -43,8 +44,9 @@ pub use self::config::{
|
|||
ConfigEditor, ConfigStore, GpuAssignment, GpuConfig, LocalServingNodeConfig, MeshConfig,
|
||||
MeshRequirementsConfig, ModelConfigEditor, ModelConfigEntry, ModelDefaultsEditor,
|
||||
ModelRuntimeKind, OwnerControlConfig, PluginConfigEditor, PluginConfigEntry, PluginHostMode,
|
||||
ResolvedPlugins, TelemetryConfig, TelemetryMetricsConfig, bundled_cli_plugin_spec, config_path,
|
||||
config_to_toml, load_config, parse_config_toml, resolve_plugins,
|
||||
PluginStartupConfig, ResolvedPlugins, TelemetryConfig, TelemetryMetricsConfig,
|
||||
bundled_cli_plugin_spec, config_path, config_to_toml, load_config, parse_config_toml,
|
||||
resolve_plugins,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::config::{
|
||||
|
|
@ -57,6 +59,7 @@ pub(crate) use self::config::{
|
|||
mesh_requirements_validation_error,
|
||||
};
|
||||
use self::runtime::ExternalPlugin;
|
||||
pub use self::startup::{PluginStartupOptions, PluginStartupSummary};
|
||||
pub(crate) use self::support::parse_optional_json;
|
||||
use self::support::{format_args_for_log, format_slice_for_log, format_tool_names_for_log};
|
||||
#[cfg(all(test, unix))]
|
||||
|
|
@ -72,7 +75,6 @@ use tokio::sync::oneshot;
|
|||
|
||||
pub const BLOBSTORE_PLUGIN_ID: &str = "blobstore";
|
||||
pub(crate) const PROTOCOL_VERSION: u32 = mesh_llm_plugin::PROTOCOL_VERSION;
|
||||
const CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
const HEALTH_CHECK_INTERVAL_SECS: u64 = 15;
|
||||
const ENDPOINT_STARTUP_GRACE_SECS: u64 = 30;
|
||||
|
|
@ -156,6 +158,8 @@ pub struct PluginSummary {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manifest: Option<PluginManifestOverview>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub startup: Option<PluginStartupSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -454,6 +458,7 @@ impl PluginManager {
|
|||
args: spec.args.clone(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: Some(spec.startup.summary()),
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -562,6 +567,7 @@ impl PluginManager {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: Some(plugin_manifest_overview(&manifest)),
|
||||
startup: None,
|
||||
error: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
|
@ -650,6 +656,7 @@ impl PluginManager {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: Some(plugin_manifest_overview(&manifest)),
|
||||
startup: None,
|
||||
error: None,
|
||||
};
|
||||
self.publish_plugin_summary(&summary);
|
||||
|
|
@ -1334,7 +1341,11 @@ impl PluginManager {
|
|||
};
|
||||
self.publish_plugin_summary(&summary);
|
||||
|
||||
let manifest = self.manifest(plugin_name).await.ok().flatten();
|
||||
let manifest = if let Some(plugin) = self.inner.plugins.get(plugin_name) {
|
||||
plugin.manifest_snapshot().await
|
||||
} else {
|
||||
self.manifest(plugin_name).await.ok().flatten()
|
||||
};
|
||||
let Some(manifest) = manifest else {
|
||||
self.clear_plugin_endpoint_health(plugin_name).await;
|
||||
self.publish_plugin_summary(&summary);
|
||||
|
|
@ -1946,6 +1957,7 @@ mod tests {
|
|||
command: Some("mesh-llm-plugin-demo".into()),
|
||||
args: vec!["--stdio".into()],
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
|
|
@ -1959,6 +1971,86 @@ mod tests {
|
|||
assert!(resolved.inactive.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_plugin_startup_policy_is_resolved() {
|
||||
let config = MeshConfig {
|
||||
plugins: vec![PluginConfigEntry {
|
||||
name: "metrics".into(),
|
||||
enabled: Some(true),
|
||||
command: Some("mesh-llm-plugin-metrics".into()),
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
startup: PluginStartupConfig {
|
||||
connect_timeout_secs: Some(75),
|
||||
init_timeout_secs: Some(90),
|
||||
optional: true,
|
||||
lazy_start: true,
|
||||
},
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
};
|
||||
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
let spec = resolved
|
||||
.externals
|
||||
.iter()
|
||||
.find(|spec| spec.name == "metrics")
|
||||
.expect("configured plugin should resolve");
|
||||
|
||||
assert_eq!(spec.startup.connect_timeout().as_secs(), 75);
|
||||
assert_eq!(spec.startup.init_timeout().as_secs(), 90);
|
||||
assert!(spec.startup.optional);
|
||||
assert!(spec.startup.lazy_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_missing_installed_plugin_becomes_inactive_summary() {
|
||||
let config = MeshConfig {
|
||||
plugins: vec![PluginConfigEntry {
|
||||
name: "missing-optional".into(),
|
||||
enabled: Some(true),
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
startup: PluginStartupConfig {
|
||||
optional: true,
|
||||
..PluginStartupConfig::default()
|
||||
},
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
};
|
||||
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved
|
||||
.inactive
|
||||
.iter()
|
||||
.filter(|summary| summary.name == "missing-optional")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let summary = resolved
|
||||
.inactive
|
||||
.iter()
|
||||
.find(|summary| summary.name == "missing-optional")
|
||||
.unwrap();
|
||||
assert_eq!(summary.status, "missing");
|
||||
assert_eq!(
|
||||
summary.startup.as_ref().map(|startup| startup.optional),
|
||||
Some(true)
|
||||
);
|
||||
assert!(
|
||||
summary
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("optional")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobstore_can_be_disabled() {
|
||||
let config = MeshConfig {
|
||||
|
|
@ -1968,6 +2060,7 @@ mod tests {
|
|||
command: None,
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
|
|
@ -1986,6 +2079,7 @@ mod tests {
|
|||
command: Some("endpoint-plugin".into()),
|
||||
args: Vec::new(),
|
||||
url: Some("http://gpu-box:8000/v1".into()),
|
||||
startup: Default::default(),
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
|
|
@ -2009,6 +2103,7 @@ mod tests {
|
|||
command: Some("/opt/plugins/endpoint-plugin".into()),
|
||||
args: vec!["--verbose".into()],
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
|
|
@ -2031,6 +2126,7 @@ mod tests {
|
|||
command: None,
|
||||
args: Vec::new(),
|
||||
url: Some("http://gpu-box:8000/v1".into()),
|
||||
startup: Default::default(),
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
|
|
@ -2063,6 +2159,7 @@ mod tests {
|
|||
command: Some("/tmp/demo".into()),
|
||||
args: vec!["--flag".into()],
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
}],
|
||||
defaults: None,
|
||||
..MeshConfig::default()
|
||||
|
|
@ -2083,6 +2180,7 @@ mod tests {
|
|||
args: vec!["--stdio".into()],
|
||||
url: None,
|
||||
env: BTreeMap::new(),
|
||||
startup: PluginStartupOptions::default(),
|
||||
}],
|
||||
inactive: Vec::new(),
|
||||
};
|
||||
|
|
@ -2100,6 +2198,51 @@ mod tests {
|
|||
assert!(!summaries[0].error.as_deref().unwrap_or_default().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lazy_start_plugin_does_not_block_manager_startup() {
|
||||
let specs = ResolvedPlugins {
|
||||
externals: vec![ExternalPluginSpec {
|
||||
name: "lazy".into(),
|
||||
command: "mesh-llm-definitely-missing-plugin-binary".into(),
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
env: BTreeMap::new(),
|
||||
startup: PluginStartupOptions {
|
||||
optional: true,
|
||||
lazy_start: true,
|
||||
..PluginStartupOptions::default()
|
||||
},
|
||||
}],
|
||||
inactive: Vec::new(),
|
||||
};
|
||||
let (mesh_tx, _mesh_rx) = mpsc::channel(1);
|
||||
|
||||
let manager = PluginManager::start(&specs, private_host_mode(), mesh_tx)
|
||||
.await
|
||||
.expect("lazy plugin should not start during manager startup");
|
||||
let summaries = manager.list().await;
|
||||
manager.shutdown().await;
|
||||
|
||||
assert_eq!(summaries.len(), 1);
|
||||
assert_eq!(summaries[0].name, "lazy");
|
||||
assert_eq!(summaries[0].status, "deferred");
|
||||
assert_eq!(
|
||||
summaries[0]
|
||||
.startup
|
||||
.as_ref()
|
||||
.map(|startup| startup.lazy_start),
|
||||
Some(true)
|
||||
);
|
||||
assert!(summaries[0].pid.is_none());
|
||||
assert!(
|
||||
summaries[0]
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("lazy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_ids_include_pid_and_random_suffix() {
|
||||
let instance_id = make_instance_id();
|
||||
|
|
@ -2145,6 +2288,7 @@ mod tests {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use super::plugin_manifest_overview;
|
|||
use super::support::{plugin_error, serialize_params, summarize_capabilities};
|
||||
use super::transport::{LocalListener, LocalStream, bind_local_listener, connection_loop};
|
||||
use super::{
|
||||
CONNECT_TIMEOUT_SECS, PROTOCOL_VERSION, PluginMeshEvent, PluginRpcBridge, PluginSummary,
|
||||
REQUEST_TIMEOUT_SECS, ToolCallResult, ToolSummary, proto,
|
||||
PROTOCOL_VERSION, PluginMeshEvent, PluginRpcBridge, PluginSummary, REQUEST_TIMEOUT_SECS,
|
||||
ToolCallResult, ToolSummary, proto,
|
||||
};
|
||||
use crate::runtime_data::RuntimeDataProducer;
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
|
@ -67,6 +67,7 @@ impl ExternalPlugin {
|
|||
args: spec.args.clone(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: Some(spec.startup.summary()),
|
||||
error: None,
|
||||
})),
|
||||
server_info: Arc::new(Mutex::new(None)),
|
||||
|
|
@ -79,6 +80,10 @@ impl ExternalPlugin {
|
|||
next_request_id: AtomicU64::new(1),
|
||||
next_generation: AtomicU64::new(1),
|
||||
};
|
||||
if spec.startup.lazy_start {
|
||||
plugin.mark_deferred().await;
|
||||
return Ok(plugin);
|
||||
}
|
||||
if let Err(err) = plugin.ensure_running().await {
|
||||
if plugin.is_disabled().await {
|
||||
return Ok(plugin);
|
||||
|
|
@ -119,6 +124,17 @@ impl ExternalPlugin {
|
|||
self.publish_summary().await;
|
||||
}
|
||||
|
||||
async fn mark_deferred(&self) {
|
||||
{
|
||||
let mut summary = self.summary.lock().await;
|
||||
summary.status = "deferred".into();
|
||||
summary.pid = None;
|
||||
summary.error =
|
||||
Some("lazy start enabled; plugin will start on first direct use".to_string());
|
||||
}
|
||||
self.publish_summary().await;
|
||||
}
|
||||
|
||||
fn log_waiting_for_connection(&self, listener: &LocalListener) {
|
||||
let endpoint = listener.endpoint();
|
||||
let transport = listener.transport_name();
|
||||
|
|
@ -161,12 +177,9 @@ impl ExternalPlugin {
|
|||
}
|
||||
|
||||
async fn await_plugin_connection(&self, listener: LocalListener) -> Result<LocalStream> {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
|
||||
listener.accept(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Timed out waiting for plugin '{}'", self.spec.name))?
|
||||
tokio::time::timeout(self.spec.startup.connect_timeout(), listener.accept())
|
||||
.await
|
||||
.with_context(|| format!("Timed out waiting for plugin '{}'", self.spec.name))?
|
||||
}
|
||||
|
||||
async fn install_runtime(
|
||||
|
|
@ -218,7 +231,7 @@ impl ExternalPlugin {
|
|||
host_info_json,
|
||||
mesh_visibility: proto_mesh_visibility(self.host_mode.mesh_visibility),
|
||||
}),
|
||||
Some(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)),
|
||||
Some(self.spec.startup.init_timeout()),
|
||||
)
|
||||
.await?;
|
||||
self.parse_initialize_response(generation, response).await
|
||||
|
|
@ -301,6 +314,9 @@ impl ExternalPlugin {
|
|||
if self.is_disabled().await {
|
||||
return Ok(());
|
||||
}
|
||||
if self.is_deferred().await {
|
||||
return Ok(());
|
||||
}
|
||||
if self.is_stopping().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -414,6 +430,10 @@ impl ExternalPlugin {
|
|||
Ok(self.manifest.lock().await.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn manifest_snapshot(&self) -> Option<proto::PluginManifest> {
|
||||
self.manifest.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn open_stream(
|
||||
&self,
|
||||
request: proto::OpenStreamRequest,
|
||||
|
|
@ -777,6 +797,13 @@ impl ExternalPlugin {
|
|||
self.disabled_reason().await.is_some()
|
||||
}
|
||||
|
||||
async fn is_deferred(&self) -> bool {
|
||||
if !self.spec.startup.lazy_start || self.runtime.lock().await.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.summary.lock().await.status == "deferred"
|
||||
}
|
||||
|
||||
async fn is_stopping(&self) -> bool {
|
||||
let summary = self.summary.lock().await;
|
||||
matches!(summary.status.as_str(), "shutting down" | "stopped")
|
||||
|
|
|
|||
69
crates/mesh-llm-host-runtime/src/plugin/startup.rs
Normal file
69
crates/mesh-llm-host-runtime/src/plugin/startup.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use mesh_llm_config::PluginStartupConfig;
|
||||
use serde::Serialize;
|
||||
|
||||
pub(crate) const DEFAULT_PLUGIN_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
pub(crate) const DEFAULT_PLUGIN_INIT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PluginStartupOptions {
|
||||
pub connect_timeout: Duration,
|
||||
pub init_timeout: Duration,
|
||||
pub optional: bool,
|
||||
pub lazy_start: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct PluginStartupSummary {
|
||||
pub connect_timeout_secs: u64,
|
||||
pub init_timeout_secs: u64,
|
||||
pub optional: bool,
|
||||
pub lazy_start: bool,
|
||||
}
|
||||
|
||||
impl Default for PluginStartupOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(DEFAULT_PLUGIN_CONNECT_TIMEOUT_SECS),
|
||||
init_timeout: Duration::from_secs(DEFAULT_PLUGIN_INIT_TIMEOUT_SECS),
|
||||
optional: false,
|
||||
lazy_start: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginStartupOptions {
|
||||
pub fn from_config(config: &PluginStartupConfig) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
connect_timeout: config
|
||||
.connect_timeout_secs
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(defaults.connect_timeout),
|
||||
init_timeout: config
|
||||
.init_timeout_secs
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(defaults.init_timeout),
|
||||
optional: config.optional,
|
||||
lazy_start: config.lazy_start,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_timeout(&self) -> Duration {
|
||||
self.connect_timeout
|
||||
}
|
||||
|
||||
pub fn init_timeout(&self) -> Duration {
|
||||
self.init_timeout
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> PluginStartupSummary {
|
||||
PluginStartupSummary {
|
||||
connect_timeout_secs: self.connect_timeout.as_secs(),
|
||||
init_timeout_secs: self.init_timeout.as_secs(),
|
||||
optional: self.optional,
|
||||
lazy_start: self.lazy_start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1057,6 +1057,7 @@ fn legacy_proto_config_to_mesh(
|
|||
command: p.command.clone(),
|
||||
args: p.args.clone(),
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let mesh_requirements = snapshot
|
||||
|
|
|
|||
|
|
@ -2025,6 +2025,7 @@ alias = "model-alias"
|
|||
command: Some("mesh-llm".to_string()),
|
||||
args: vec!["--plugin".to_string()],
|
||||
url: None,
|
||||
startup: Default::default(),
|
||||
}],
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9074,6 +9074,7 @@ mod tests {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1187,6 +1187,7 @@ pub(crate) mod tests {
|
|||
mesh_event_subscriptions: 0,
|
||||
capabilities: vec!["chat".into()],
|
||||
}),
|
||||
startup: None,
|
||||
error: None,
|
||||
});
|
||||
alpha.publish_plugin_manifest(PluginManifestOverview {
|
||||
|
|
@ -1240,6 +1241,7 @@ pub(crate) mod tests {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error: Some("disabled".into()),
|
||||
});
|
||||
beta.publish_plugin_payload("metrics", json!({"requests": 5}));
|
||||
|
|
@ -1366,6 +1368,7 @@ pub(crate) mod tests {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error: None,
|
||||
});
|
||||
alpha.publish_plugin_payload("metrics", json!({"requests": 1}));
|
||||
|
|
@ -1398,6 +1401,7 @@ pub(crate) mod tests {
|
|||
args: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
manifest: None,
|
||||
startup: None,
|
||||
error: None,
|
||||
});
|
||||
beta.publish_plugin_payload("metrics", json!({"requests": 7}));
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ participant.
|
|||
|
||||
For maintainer debugging, add `--output-dir <dir>`. The doctor bundle includes
|
||||
`split-readiness.json`, management API snapshots for runtime/stage/llama status,
|
||||
`skippy-diagnostics.json`, and the active instance's `skippy-native.log` when
|
||||
the local runtime directory can be matched to the console port.
|
||||
plugin startup/provider/endpoint snapshots, `skippy-diagnostics.json`, and the
|
||||
active instance's `skippy-native.log` when the local runtime directory can be
|
||||
matched to the console port.
|
||||
|
||||
On Windows, collect a shareable diagnostic bundle from already-running nodes:
|
||||
|
||||
|
|
|
|||
|
|
@ -609,6 +609,12 @@ command = "mesh-llm-plugin-blackboard"
|
|||
# [[plugin]]
|
||||
# name = "openai-endpoint"
|
||||
# url = "http://localhost:8000/api/v1"
|
||||
#
|
||||
# [plugin.startup]
|
||||
# connect_timeout_secs = 75
|
||||
# init_timeout_secs = 90
|
||||
# optional = true
|
||||
# lazy_start = true
|
||||
```
|
||||
|
||||
Use the default config:
|
||||
|
|
@ -650,6 +656,11 @@ Config precedence:
|
|||
written back into TOML.
|
||||
- Changing this file affects future starts or reloads, not active sessions.
|
||||
- Plugin entries stay in the same file.
|
||||
- `[plugin.startup]` controls how long mesh-llm waits for an external plugin to
|
||||
connect and initialize. `optional = true` records a missing installed plugin
|
||||
as inactive instead of rejecting the config, and `lazy_start = true` defers
|
||||
process launch until direct plugin use. This is useful for very slow legacy
|
||||
hosts or emulator-assisted startup paths.
|
||||
|
||||
## Lemonade integration
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,18 @@ mesh-llm plugins install metrics
|
|||
```toml
|
||||
[[plugin]]
|
||||
name = "metrics"
|
||||
|
||||
[plugin.startup]
|
||||
connect_timeout_secs = 75
|
||||
init_timeout_secs = 90
|
||||
optional = true
|
||||
lazy_start = true
|
||||
```
|
||||
|
||||
The startup block is optional. It is useful on slow legacy machines where the
|
||||
plugin process may take longer than the default startup budget, or where metrics
|
||||
should be advertised only after the plugin is actually used.
|
||||
|
||||
Endpoint precedence is:
|
||||
|
||||
1. `telemetry.metrics.endpoint`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue