use std::sync::Arc;
use std::time::Duration;
use super::config::RestartPolicy;
use super::fakes::*;
use super::residency::{GB, ResidencyLedger};
use super::roster::{RosterOverlay, flavor_for_backend, import_roster};
use super::spec::{InferFlavor, LatencyClass};
use crate::chain::ChainManager;
use crate::model_manifest::ModelFormat;
use crate::workload_governance::{NodeTrustTier, WorkloadGate};
use crate::workload_runtime::host::{RUNTIME_CHAIN_SOURCE, WorkloadHost};
use crate::workload_runtime::types::{RuntimeError, WorkloadRuntime};
const YAML: &str = include_str!("fixtures/model-lab-roster.yaml");
fn overlay() -> RosterOverlay {
serde_yaml::from_str(
r#"
excludes:
planner: ["role:coder-daily"]
latency_class:
coder-small: interactive
sticky:
swarm-micro: false
"#,
)
.unwrap()
}
#[test]
fn imports_memory_ports_flavors_and_the_overlay() {
let r = import_roster(YAML, &overlay()).unwrap();
let ids: Vec<_> = r.specs.iter().map(|s| s.spec.role.as_str()).collect();
assert_eq!(
ids,
["coder-daily", "coder-compact", "coder-small", "planner", "swarm-worker", "swarm-micro", "voice-llm"]
);
let daily = r.get("coder-daily").unwrap();
assert_eq!(daily.spec.runtime, InferFlavor::MlxLm);
assert_eq!(daily.spec.serve.port, Some(8081));
assert_eq!(daily.spec.model.as_deref(), Some("Qwen3-Coder-Next-80B-A3B"));
assert_eq!(daily.spec.memory.weights_bytes, 55 * GB);
assert!(daily.spec.sticky, "inference is sticky by default (ADR-101 section 6)");
assert_eq!(daily.spec.latency_class, LatencyClass::Batch);
assert_eq!(daily.status, "live");
assert_eq!(r.get("coder-small").unwrap().spec.memory.weights_bytes, 9_700_000_000);
assert_eq!(r.get("coder-compact").unwrap().spec.runtime, InferFlavor::Ollama);
assert_eq!(r.get("planner").unwrap().spec.excludes, ["role:coder-daily"]);
assert_eq!(r.get("coder-small").unwrap().spec.latency_class, LatencyClass::Interactive);
assert!(!r.get("swarm-micro").unwrap().spec.sticky);
assert_eq!(r.get("voice-llm").unwrap().spec.latency_class, LatencyClass::Interactive);
assert_eq!(r.get("swarm-worker").unwrap().spec.runtime, InferFlavor::Ollama);
assert_eq!(r.get("swarm-micro").unwrap().spec.runtime, InferFlavor::MlxLm);
assert!(r.specs.iter().all(|s| s.spec.validate().is_ok()));
}
#[test]
fn what_is_not_imported_is_reported_with_a_reason() {
let r = import_roster(YAML, &RosterOverlay::default()).unwrap();
let why = |id: &str| r.skipped.iter().find(|(i, _)| i == id).map(|(_, w)| w.clone()).unwrap_or_default();
assert!(why("coder-next").contains("alias of coder-daily"), "{}", why("coder-next"));
assert!(why("vlm").contains("no inference adapter"));
assert!(why("tts").contains("no inference adapter"));
assert!(why("broken-mem").contains("ram_gb"));
assert!(why("bad id!").contains("plain token"));
assert_eq!(r.specs.len() + r.skipped.len(), 12, "nothing vanishes");
}
#[test]
fn bad_input_is_refused_at_the_boundary() {
assert!(import_roster("roster: [", &RosterOverlay::default()).is_err());
assert!(import_roster(&"x".repeat(2 * 1024 * 1024), &RosterOverlay::default()).is_err());
let many: String = std::iter::once("roster:\n".to_string())
.chain((0..300).map(|i| format!("- id: r{i}\n backend: mlx_lm\n port: 9000\n ram_gb: 1\n repo: a{i}\n")))
.collect();
assert!(import_roster(&many, &RosterOverlay::default()).is_err());
assert!(serde_yaml::from_str::<RosterOverlay>("bogus: 1").is_err());
assert_eq!(import_roster("meta: {}", &RosterOverlay::default()).unwrap().specs.len(), 0);
assert_eq!(flavor_for_backend("llama.cpp"), Some(InferFlavor::LlamaCpp));
assert_eq!(flavor_for_backend(""), None);
}
fn fast() -> RestartPolicy {
RestartPolicy { max_restarts: 1, base: Duration::from_millis(300), cap: Duration::from_secs(1), ..RestartPolicy::default() }
}
fn managed_with_ledger(ledger: &Arc<ResidencyLedger>) -> Managed {
let l = ledger.clone();
let m = managed_with(InferFlavor::LlamaCpp, fast(), move |c| c.ledger = Some(l));
for n in ["Model-A", "Model-B"] {
adopt_model(&m.reg, m.tmp.path(), n, ModelFormat::Gguf);
}
m
}
fn spec_of(role: &str, model: &str, gb: u64, excludes: &[&str]) -> super::InferenceSpec {
let mut s = spec(role, InferFlavor::LlamaCpp, free_port());
s.model = Some(model.into());
s.memory.weights_bytes = gb * GB;
s.excludes = excludes.iter().map(|x| x.to_string()).collect();
s
}
#[tokio::test]
async fn the_adapter_refuses_a_start_over_budget_and_frees_it_on_stop() {
let ledger = Arc::new(ResidencyLedger::new(Some(10 * GB)));
let (m1, m2) = (managed_with_ledger(&ledger), managed_with_ledger(&ledger));
let a = load(&m1.rt, spec_of("coder", "Model-A", 6, &[])).await;
let b = load(&m2.rt, spec_of("planner", "Model-B", 6, &[])).await;
m1.rt.start(&a).await.unwrap();
let e = m2.rt.start(&b).await.unwrap_err();
assert!(matches!(e, RuntimeError::AdmissionRefused(_)), "{e:?}");
let text = e.to_string();
assert!(text.contains("unplaceable") && text.contains("'planner' needs 6.0 GB") && text.contains("coder 6.0 GB"), "{text}");
assert!(!m2.script_dir.join("pid.txt").exists());
assert_eq!(ledger.snapshot(), [("coder".to_string(), 6 * GB)]);
m1.rt.stop(&a, Duration::from_secs(3)).await.unwrap();
assert_eq!(ledger.used(), 0);
m2.rt.start(&b).await.unwrap();
assert!(m2.rt.start(&b).await.is_err(), "already running");
assert_eq!(ledger.used(), 6 * GB, "the failed second start kept the first reservation");
m2.rt.unload(b).await.unwrap();
assert_eq!(ledger.used(), 0);
m1.rt.unload(a).await.unwrap();
}
#[tokio::test]
async fn co_residency_exclusions_hold_across_adapters() {
let ledger = Arc::new(ResidencyLedger::new(None));
let (m1, m2) = (managed_with_ledger(&ledger), managed_with_ledger(&ledger));
let a = load(&m1.rt, spec_of("coder-daily", "Model-A", 1, &[])).await;
let b = load(&m2.rt, spec_of("planner", "Model-B", 1, &["role:coder-daily"])).await;
m1.rt.start(&a).await.unwrap();
let e = m2.rt.start(&b).await.unwrap_err().to_string();
assert!(e.contains("cannot be resident beside 'coder-daily'") && e.contains("'planner' excludes it"), "{e}");
m1.rt.unload(a).await.unwrap();
m2.rt.start(&b).await.unwrap();
m2.rt.unload(b).await.unwrap();
}
#[tokio::test]
async fn through_the_host_an_unplaceable_start_is_chained_with_its_reason() {
let ledger = Arc::new(ResidencyLedger::new(Some(5 * GB)));
let m = managed_with_ledger(&ledger);
let chain = Arc::new(ChainManager::new(0, 1000));
let gate = WorkloadGate::exempt(0.8, false, "test")
.with_chain(chain.clone())
.with_permit(infer_permit())
.unwrap();
let host = WorkloadHost::new(m.rt.clone(), Arc::new(gate), "operator", NodeTrustTier::Paired)
.with_chain(chain.clone());
let h = host.load(&workload(spec_of("big", "Model-A", 8, &[])), &wl_cfg()).await.unwrap();
let e = host.start(&h).await.unwrap_err();
assert!(e.to_string().contains("unplaceable"), "{e}");
let refused: Vec<_> = chain
.tail(0)
.into_iter()
.filter(|e| e.source == RUNTIME_CHAIN_SOURCE && e.kind == "workload.refuse")
.collect();
assert_eq!(refused.len(), 1);
let p = refused[0].payload.clone().unwrap().to_string();
assert!(p.contains("unplaceable") && p.contains("8.0 GB") && p.contains("5.0 GB"), "{p}");
host.unload(h).await.unwrap();
}