use std::sync::Arc;
use tracing::{debug, info, warn};
use crate::{
config::ReviewConfig,
llm::LlmProvider,
models::{Effort, Finding},
pipeline::verify::{emit_verification_model_error, error_class},
pipeline::verify_prompt::build_verify_request,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LivenessDecision {
Ok,
Refuse {
reason: String,
error_class: String,
},
}
pub async fn probe_verifier_liveness(
verifier: &Arc<dyn LlmProvider>,
verifier_model: &str,
) -> LivenessDecision {
let probe_finding = stub_probe_finding();
let req = build_verify_request(verifier_model, "", &probe_finding, None, Some(16));
match verifier.complete(req).await {
Ok(_) => {
debug!(model = %verifier_model, "verifier liveness probe: model responded — OK");
LivenessDecision::Ok
}
Err(e) if e.is_alarm() => {
let error_class = error_class(&e);
emit_verification_model_error(verifier_model, &error_class, &e);
LivenessDecision::Refuse {
reason: format!(
"verifier model '{verifier_model}' is unavailable ({error_class}: {e}); \
refusing to start in live mode. Fix the verifier model id / lifecycle \
state, or disable the gate with TRUSTY_REVIEW_VERIFIER_LIVENESS_CHECK=false \
(only if you accept running without verification)."
),
error_class,
}
}
Err(e) => {
warn!(
model = %verifier_model,
"verifier liveness probe hit a transient error (allowing start): {e}"
);
LivenessDecision::Ok
}
}
}
fn stub_probe_finding() -> Finding {
Finding::new(
"__liveness_probe__",
"liveness",
"startup verifier liveness probe — not a real finding",
"",
0.5,
Effort::Low,
)
}
pub async fn enforce_verifier_liveness(
config: &ReviewConfig,
verifier: Option<&Arc<dyn LlmProvider>>,
) -> Result<(), String> {
if !config.verification.enabled || !config.verification.liveness_check {
return Ok(());
}
if config.dry_run {
info!("dry-run mode — verifier liveness gate is informational only");
}
let Some(verifier) = verifier else {
if config.dry_run {
warn!("no verifier provider available — dry-run continues without verification");
return Ok(());
}
return Err(
"verification is enabled but no verifier provider could be built; \
refusing to start in live mode"
.to_string(),
);
};
let model = &config.role_models.verifier.model;
match probe_verifier_liveness(verifier, model).await {
LivenessDecision::Ok => Ok(()),
LivenessDecision::Refuse { reason, .. } => {
if config.dry_run {
warn!("verifier liveness probe failed in dry-run (continuing): {reason}");
Ok(())
} else {
Err(reason)
}
}
}
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;
use super::*;
use crate::llm::{LlmError, LlmRequest, LlmResponse};
struct StubVerifier {
err: Option<fn() -> LlmError>,
}
#[async_trait]
impl LlmProvider for StubVerifier {
fn name(&self) -> &str {
"stub"
}
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
if let Some(make) = self.err {
return Err(make());
}
Ok(LlmResponse {
text: r#"{"judgment":"REFUTED"}"#.to_string(),
model: req.model,
input_tokens: 1,
output_tokens: 1,
latency_ms: 1,
cost_usd: 0.0,
})
}
}
fn alive() -> Arc<dyn LlmProvider> {
Arc::new(StubVerifier { err: None })
}
fn dead() -> Arc<dyn LlmProvider> {
Arc::new(StubVerifier {
err: Some(|| LlmError::ModelNotFound("stale".into())),
})
}
fn cfg(enabled: bool, liveness: bool, dry_run: bool) -> ReviewConfig {
let mut c = ReviewConfig::load(None);
c.verification.enabled = enabled;
c.verification.liveness_check = liveness;
c.dry_run = dry_run;
c
}
#[tokio::test]
async fn enforce_disabled_is_ok() {
let c = cfg(false, true, false);
assert!(enforce_verifier_liveness(&c, Some(&dead())).await.is_ok());
}
#[tokio::test]
async fn enforce_liveness_check_off_is_ok() {
let c = cfg(true, false, false);
assert!(enforce_verifier_liveness(&c, Some(&dead())).await.is_ok());
}
#[tokio::test]
async fn enforce_live_missing_verifier_refuses() {
let c = cfg(true, true, false);
assert!(enforce_verifier_liveness(&c, None).await.is_err());
}
#[tokio::test]
async fn enforce_live_model_unavailable_refuses() {
let c = cfg(true, true, false);
let res = enforce_verifier_liveness(&c, Some(&dead())).await;
assert!(res.is_err(), "live mode must refuse a dead verifier");
assert!(
res.unwrap_err().contains("refusing to start"),
"error must state the refusal"
);
}
#[tokio::test]
async fn enforce_live_alive_ok() {
let c = cfg(true, true, false);
assert!(
enforce_verifier_liveness(&c, Some(&alive())).await.is_ok(),
"a live verifier must allow start"
);
}
#[tokio::test]
async fn enforce_dry_run_model_unavailable_allows() {
let c = cfg(true, true, true);
assert!(
enforce_verifier_liveness(&c, Some(&dead())).await.is_ok(),
"dry-run must not block startup on a dead verifier"
);
}
#[tokio::test]
async fn enforce_dry_run_missing_verifier_allows() {
let c = cfg(true, true, true);
assert!(enforce_verifier_liveness(&c, None).await.is_ok());
}
#[tokio::test]
async fn liveness_alive_allows_start() {
let decision = probe_verifier_liveness(&alive(), "us.anthropic.claude-haiku-4-5").await;
assert_eq!(
decision,
LivenessDecision::Ok,
"a responding model allows start"
);
}
#[tokio::test]
async fn liveness_model_unavailable_refuses() {
let dead_model: Arc<dyn LlmProvider> = Arc::new(StubVerifier {
err: Some(|| LlmError::ModelNotFound("no-such-profile".to_string())),
});
let decision = probe_verifier_liveness(&dead_model, "no-such-profile").await;
match decision {
LivenessDecision::Refuse {
error_class,
reason,
} => {
assert_eq!(error_class, "ModelNotFound");
assert!(reason.contains("no-such-profile"), "reason names the model");
assert!(
reason.contains("refusing to start"),
"reason must state the refusal"
);
}
LivenessDecision::Ok => panic!("an unavailable verifier model must refuse start"),
}
}
#[tokio::test]
async fn liveness_access_denied_refuses() {
let access_denied: Arc<dyn LlmProvider> = Arc::new(StubVerifier {
err: Some(|| LlmError::AccessDenied("bad iam".to_string())),
});
let decision = probe_verifier_liveness(&access_denied, "m").await;
assert!(
matches!(decision, LivenessDecision::Refuse { .. }),
"AccessDenied is alarm-class and must refuse start"
);
}
#[tokio::test]
async fn liveness_transient_allows_start() {
let transient: Arc<dyn LlmProvider> = Arc::new(StubVerifier {
err: Some(|| LlmError::Transport("connection reset".to_string())),
});
let decision = probe_verifier_liveness(&transient, "m").await;
assert_eq!(
decision,
LivenessDecision::Ok,
"a transient probe error must not block startup"
);
}
#[tokio::test]
async fn liveness_rate_limited_allows_start() {
let rate_limited: Arc<dyn LlmProvider> = Arc::new(StubVerifier {
err: Some(|| LlmError::RateLimited),
});
let decision = probe_verifier_liveness(&rate_limited, "m").await;
assert_eq!(
decision,
LivenessDecision::Ok,
"rate-limit during probe is transient"
);
}
}