diff --git a/src/workers/continuum-core/src/modules/ai_provider.rs b/src/workers/continuum-core/src/modules/ai_provider.rs index 58d9e6ae5..3dec457a6 100644 --- a/src/workers/continuum-core/src/modules/ai_provider.rs +++ b/src/workers/continuum-core/src/modules/ai_provider.rs @@ -182,8 +182,91 @@ impl AIProviderModule { /// - "asked for local but DMR is down" (Docker Desktop needs to be running) /// - "asked for a specific provider/model that isn't here" (existing message) /// -/// Hoisted out of both `ai/generate` and the convenience `generate_text` so -/// the two paths report the same diagnosis. +/// The set of provider ids that count as "offline / network- +/// independent" for the boot-status `adapter:` line. A host with +/// any of these registered has an inference path that survives a +/// WAN outage and is reported as `Ok` regardless of which cloud +/// providers also registered. +/// +/// Sourced from the substrate's actual provider-id constants — NOT +/// a hand-rolled prefix match — so a rename in `LLAMACPP_PROVIDER_ID` +/// or a future `LOCAL_*` constant lands the boot-status check +/// automatically. PR #1553 round 1 reviewer caught two failure +/// modes the prefix shape produced: +/// +/// 1. `"docker-model-runner"` (DMR) didn't start with `llamacpp-`, +/// so a host running DMR-only got `Degraded: "cloud only ..."` — +/// factually false. DMR runs against localhost:12434, network- +/// independent. +/// 2. The `register_adapters` flow uses the single +/// `LLAMACPP_PROVIDER_ID = "llamacpp-local"` constant — there is +/// no per-model `"llamacpp-qwen3-coder"` registered name. The +/// prefix matched anyway by coincidence; the prior tests pinned +/// a string shape that production never produces. +/// +/// Identifier strings, not a trait check on `dyn AIProviderAdapter`, +/// because the registry's `available()` returns `Vec<&str>` of +/// provider ids — the trait isn't reachable from the helper without +/// holding the registry lock. The trait-method refactor +/// (`AIProviderAdapter::is_offline_path()`) is a follow-up. +const OFFLINE_PROVIDER_IDS: &[&str] = &[ + crate::inference::LLAMACPP_PROVIDER_ID, + // DMR registers under "docker-model-runner". Localhost via + // Docker Desktop's model runner socket — survives a WAN + // outage as long as Docker Desktop is running. + "docker-model-runner", +]; + +/// Pure projection of the AdapterRegistry's `available()` set → a +/// boot-status `(kind, detail)` pair. Card e9f50a36 slice A4. +/// +/// Mapping: +/// - **Empty** → `Failed`. The substrate has zero inference +/// capability; persona cognition on `FullCitizen` / `FailFast` +/// will refuse to boot downstream. +/// - **No offline provider registered** → `Degraded`, `"cloud only: +/// (no local fallback if cloud unreachable)"`. The +/// substrate runs but has no offline path — a single network +/// outage takes down inference. Operator wants to know. +/// - **At least one offline provider** (`llamacpp-local`, +/// `docker-model-runner`, etc — see [`OFFLINE_PROVIDER_IDS`]) → +/// `Ok`, `" (N providers)"`. Mixed cloud + offline OR +/// offline-only both land here; substrate has at least one path +/// that survives WAN loss. +/// +/// The kind ordering matches the rest of the slice A boot lines: +/// `Ok < Degraded < Failed` per `BootStatusKind`'s derived `Ord`, +/// so a sentinel computing "worst kind across boot" with `.max()` +/// reports the right verdict. +fn render_adapter_boot_status( + available: &[&str], +) -> (crate::runtime::boot_status::BootStatusKind, String) { + use crate::runtime::boot_status::BootStatusKind; + if available.is_empty() { + return ( + BootStatusKind::Failed, + "no inference adapter registered — add API keys to \ + ~/.continuum/config.env or pull a local GGUF" + .to_string(), + ); + } + let has_offline = available + .iter() + .any(|name| OFFLINE_PROVIDER_IDS.contains(name)); + let names_joined = available.join(", "); + if has_offline { + ( + BootStatusKind::Ok, + format!("{names_joined} ({} providers)", available.len()), + ) + } else { + ( + BootStatusKind::Degraded, + format!("cloud only: {names_joined} (no local fallback if cloud unreachable)"), + ) + } +} + fn select_failure_message( registry: &AdapterRegistry, requested_provider: Option<&str>, @@ -585,6 +668,15 @@ impl AIProviderModule { .warn("No providers available! Add API keys to ~/.continuum/config.env"); } + // Boot status report (card e9f50a36 slice A4): a single line + // naming the registered adapters so the operator sees what + // inference capabilities the substrate actually has, not + // what *should* be available. Empty = Failed (no inference + // possible). All cloud + no local = Degraded (no offline + // path). Mixed or local-only = Ok. + let (kind, detail) = render_adapter_boot_status(&available); + crate::runtime::boot_status::boot_status("adapter", kind, &detail); + Ok(()) } @@ -1120,3 +1212,110 @@ pub async fn generate_text( Ok(response) } + +#[cfg(test)] +mod boot_status_tests { + use super::*; + use crate::runtime::boot_status::BootStatusKind; + + /// Empty registry = no inference at all. The substrate has + /// nothing to route to. Operator action lives in the detail. + #[test] + fn empty_registry_reports_failed_with_remediation() { + let (kind, detail) = render_adapter_boot_status(&[]); + assert_eq!(kind, BootStatusKind::Failed); + assert!( + detail.contains("config.env") && detail.contains("GGUF"), + "detail must name both remediation paths: {detail:?}" + ); + } + + /// Cloud-only (no offline provider registered) = `Degraded`. + /// The substrate is "working" but a single network blip kills + /// inference for every persona. Operator wants to know. + #[test] + fn cloud_only_reports_degraded_with_no_local_fallback_note() { + let providers = ["anthropic", "openai", "groq"]; + let (kind, detail) = render_adapter_boot_status(&providers); + assert_eq!(kind, BootStatusKind::Degraded); + assert!( + detail.contains("cloud only"), + "detail must say cloud-only: {detail:?}" + ); + assert!( + detail.contains("no local fallback"), + "detail must call out the missing fallback: {detail:?}" + ); + } + + /// `LLAMACPP_PROVIDER_ID` registered = `Ok`. PR #1553 round 1 + /// reviewer caught the prior test using a fictitious + /// `"llamacpp-qwen3-coder"` id that production never emits — + /// `register_adapters` uses the single `LLAMACPP_PROVIDER_ID` + /// constant ("llamacpp-local") and multiplexes models within + /// the adapter. Pinning the actual constant here protects + /// against a future rename silently changing the boot-status + /// classification. + #[test] + fn llamacpp_local_reports_ok() { + let providers = [ + "anthropic", + crate::inference::LLAMACPP_PROVIDER_ID, + "openai", + ]; + let (kind, detail) = render_adapter_boot_status(&providers); + assert_eq!(kind, BootStatusKind::Ok); + assert!( + detail.contains(crate::inference::LLAMACPP_PROVIDER_ID), + "detail must name the local provider so the operator \ + can spot wrong-model-loaded at a glance: {detail:?}" + ); + assert!( + detail.contains("3 providers"), + "detail must include count for quick visual scan: {detail:?}" + ); + } + + /// PR #1553 round 1 reviewer's primary BLOCK: DMR is + /// network-independent (runs against `localhost:12434` via + /// Docker Desktop's model runner socket). The prior prefix + /// match shape misclassified a DMR-only host as `Degraded: + /// "cloud only ..."` — a load-bearing observability lie. With + /// the `OFFLINE_PROVIDER_IDS` set this case correctly reports + /// `Ok`. + /// + /// Pins the exact production registration id from + /// `model_registry/catalog.rs:471` so a future provider-id + /// drift fails this test instead of silently misclassifying. + #[test] + fn dmr_only_reports_ok_not_cloud_only() { + let providers = ["docker-model-runner"]; + let (kind, detail) = render_adapter_boot_status(&providers); + assert_eq!( + kind, + BootStatusKind::Ok, + "DMR is localhost-only (network-independent) and must \ + classify as an offline provider, not as a cloud lie. \ + Got {kind:?} for detail {detail:?}" + ); + assert!( + !detail.contains("cloud"), + "DMR-only must not surface a cloud-fallback warning: {detail:?}" + ); + } + + /// Local-only also reports Ok — a host with no API keys but + /// a local GGUF is the "M1 32GB offline" doctrine case Joel's + /// canonical workflow targets. + #[test] + fn local_only_reports_ok_without_cloud_warning() { + let providers = [crate::inference::LLAMACPP_PROVIDER_ID]; + let (kind, detail) = render_adapter_boot_status(&providers); + assert_eq!(kind, BootStatusKind::Ok); + assert!( + !detail.contains("cloud"), + "local-only must not mention cloud in the detail (no \ + warning to surface): {detail:?}" + ); + } +}