use serde::Serialize;
use thiserror::Error;
use crate::context::ContextState;
use crate::gates::StopReason;
use crate::invariant::InvariantClass;
#[derive(Debug, Error, Serialize)]
pub enum ConvergeError {
#[error("budget exhausted: {kind}")]
BudgetExhausted { kind: String },
#[error("{class:?} invariant '{name}' violated: {reason}")]
InvariantViolation {
name: String,
class: InvariantClass,
reason: String,
context: Box<ContextState>,
},
#[error("agent failed: {agent_id}")]
AgentFailed { agent_id: String },
#[error("invalid gate resume: {reason}")]
InvalidResume {
reason: String,
},
#[error("invalid admission: {reason}")]
InvalidAdmission {
reason: String,
},
#[error("invalid context snapshot: {reason}")]
InvalidSnapshot {
reason: String,
},
#[error(
"conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
)]
Conflict {
id: String,
existing: String,
new: String,
context: Box<ContextState>,
},
}
impl ConvergeError {
#[must_use]
pub fn context(&self) -> Option<&ContextState> {
match self {
Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
Some(context)
}
Self::BudgetExhausted { .. }
| Self::AgentFailed { .. }
| Self::InvalidResume { .. }
| Self::InvalidAdmission { .. }
| Self::InvalidSnapshot { .. } => None,
}
}
#[must_use]
pub fn stop_reason(&self) -> StopReason {
match self {
Self::BudgetExhausted { kind } => StopReason::Error {
message: format!("budget exhausted: {kind}"),
category: crate::gates::ErrorCategory::Resource,
},
Self::InvariantViolation {
name,
class,
reason,
..
} => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
Self::AgentFailed { agent_id } => StopReason::AgentRefused {
agent_id: agent_id.clone(),
reason: "agent execution failed".to_string(),
},
Self::InvalidResume { reason } => StopReason::Error {
message: format!("invalid gate resume: {reason}"),
category: crate::gates::ErrorCategory::Internal,
},
Self::InvalidAdmission { reason } => StopReason::Error {
message: format!("invalid admission: {reason}"),
category: crate::gates::ErrorCategory::Configuration,
},
Self::InvalidSnapshot { reason } => StopReason::Error {
message: format!("invalid context snapshot: {reason}"),
category: crate::gates::ErrorCategory::Configuration,
},
Self::Conflict {
id, existing, new, ..
} => StopReason::Error {
message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
category: crate::gates::ErrorCategory::Internal,
},
}
}
}
impl From<crate::AdmissionError> for ConvergeError {
fn from(value: crate::AdmissionError) -> Self {
Self::InvalidAdmission {
reason: value.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_context() -> ContextState {
ContextState::default()
}
#[test]
fn budget_exhausted_display() {
let err = ConvergeError::BudgetExhausted {
kind: "cycles".into(),
};
assert_eq!(err.to_string(), "budget exhausted: cycles");
}
#[test]
fn budget_exhausted_has_no_context() {
let err = ConvergeError::BudgetExhausted {
kind: "tokens".into(),
};
assert!(err.context().is_none());
}
#[test]
fn agent_failed_display() {
let err = ConvergeError::AgentFailed {
agent_id: "agent-x".into(),
};
assert_eq!(err.to_string(), "agent failed: agent-x");
}
#[test]
fn agent_failed_has_no_context() {
let err = ConvergeError::AgentFailed {
agent_id: "a".into(),
};
assert!(err.context().is_none());
}
#[test]
fn invariant_violation_has_context() {
let err = ConvergeError::InvariantViolation {
name: "no_empty".into(),
class: InvariantClass::Structural,
reason: "empty found".into(),
context: Box::new(empty_context()),
};
assert!(err.context().is_some());
}
#[test]
fn invariant_violation_display() {
let err = ConvergeError::InvariantViolation {
name: "no_empty".into(),
class: InvariantClass::Semantic,
reason: "bad".into(),
context: Box::new(empty_context()),
};
assert_eq!(
err.to_string(),
"Semantic invariant 'no_empty' violated: bad"
);
}
#[test]
fn conflict_has_context() {
let err = ConvergeError::Conflict {
id: "fact-1".into(),
existing: "old".into(),
new: "new".into(),
context: Box::new(empty_context()),
};
assert!(err.context().is_some());
}
#[test]
fn conflict_display() {
let err = ConvergeError::Conflict {
id: "f".into(),
existing: "a".into(),
new: "b".into(),
context: Box::new(empty_context()),
};
assert!(err.to_string().contains("conflict detected for fact 'f'"));
}
#[test]
fn stop_reason_budget_exhausted() {
let err = ConvergeError::BudgetExhausted {
kind: "time".into(),
};
let reason = err.stop_reason();
assert!(matches!(reason, StopReason::Error { .. }));
}
#[test]
fn stop_reason_invariant_violated() {
let err = ConvergeError::InvariantViolation {
name: "inv".into(),
class: InvariantClass::Acceptance,
reason: "fail".into(),
context: Box::new(empty_context()),
};
let reason = err.stop_reason();
assert!(matches!(reason, StopReason::InvariantViolated { .. }));
}
#[test]
fn stop_reason_agent_refused() {
let err = ConvergeError::AgentFailed {
agent_id: "bot".into(),
};
let reason = err.stop_reason();
assert!(matches!(reason, StopReason::AgentRefused { .. }));
}
#[test]
fn invalid_resume_display() {
let err = ConvergeError::InvalidResume {
reason: "gate_id mismatch".into(),
};
assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
}
#[test]
fn invalid_resume_has_no_context() {
let err = ConvergeError::InvalidResume {
reason: "test".into(),
};
assert!(err.context().is_none());
}
#[test]
fn stop_reason_invalid_resume() {
let err = ConvergeError::InvalidResume {
reason: "wrong gate".into(),
};
let reason = err.stop_reason();
assert!(matches!(reason, StopReason::Error { .. }));
}
#[test]
fn stop_reason_conflict() {
let err = ConvergeError::Conflict {
id: "x".into(),
existing: "old".into(),
new: "new".into(),
context: Box::new(empty_context()),
};
let reason = err.stop_reason();
assert!(matches!(reason, StopReason::Error { .. }));
}
}