use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum RunError {
Stalled { node: String },
UnboundedCycle { nodes: Vec<String> },
Unreachable { nodes: Vec<String> },
UnresolvedRef { what: String },
InvalidCondition { edge: String, why: String },
NoToolLlm { node: String },
BudgetExhausted { axis: BudgetAxis },
DanglingIntent { key: String },
ReplayDivergence { what: String },
ManifestMismatch { why: String },
UnknownAsk { tool_call_id: String },
Unauthorized { what: String },
Canceled { by: String, reason: String },
ReducerLawViolation { key: String },
CheckpointTooLarge { bytes: usize, cap: usize },
Tainted { why: String },
RetentionRefused { why: String },
CodeExecRefused { condition: String },
InvalidPlan { why: String },
Storage { detail: String },
LeaseLost { run_id: String },
EgressRefused { destination: String },
AnonReplayUnsafe { ns: String, scope: String },
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize,
)]
pub enum BudgetAxis {
Supersteps,
Tokens,
Usd,
WallMs,
Storage,
}
impl BudgetAxis {
pub fn as_str(&self) -> &'static str {
match self {
Self::Supersteps => "supersteps",
Self::Tokens => "tokens",
Self::Usd => "usd",
Self::WallMs => "wall_ms",
Self::Storage => "storage",
}
}
}
impl RunError {
pub fn code(&self) -> &'static str {
match self {
Self::Stalled { .. } => "RUN-E001",
Self::UnboundedCycle { .. } => "RUN-E002",
Self::Unreachable { .. } => "RUN-E003",
Self::UnresolvedRef { .. } => "RUN-E004",
Self::InvalidCondition { .. } => "RUN-E005",
Self::NoToolLlm { .. } => "RUN-E006",
Self::BudgetExhausted { .. } => "RUN-E007",
Self::DanglingIntent { .. } => "RUN-E008",
Self::ReplayDivergence { .. } => "RUN-E009",
Self::ManifestMismatch { .. } => "RUN-E010",
Self::UnknownAsk { .. } => "RUN-E011",
Self::Unauthorized { .. } => "RUN-E012",
Self::Canceled { .. } => "RUN-E013",
Self::ReducerLawViolation { .. } => "RUN-E014",
Self::CheckpointTooLarge { .. } => "RUN-E015",
Self::Tainted { .. } => "RUN-E016",
Self::RetentionRefused { .. } => "RUN-E017",
Self::CodeExecRefused { .. } => "RUN-E018",
Self::InvalidPlan { .. } => "RUN-E019",
Self::Storage { .. } => "RUN-E020",
Self::LeaseLost { .. } => "RUN-E021",
Self::EgressRefused { .. } => "RUN-E022",
Self::AnonReplayUnsafe { .. } => "RUN-E023",
}
}
}
impl fmt::Display for RunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let code = self.code();
match self {
Self::Stalled { node } => write!(
f,
"{code}: run stalled — no terminal node succeeded; every \
condition on decision node '{node}' evaluated false (add an \
unconditional default edge, or check the state it tests)"
),
Self::UnboundedCycle { nodes } => write!(
f,
"{code}: unbounded cycle through [{}] — every cycle must carry \
at least one edge with max_cycles",
nodes.join(", ")
),
Self::Unreachable { nodes } => write!(
f,
"{code}: node(s) unreachable from the entry: [{}]",
nodes.join(", ")
),
Self::UnresolvedRef { what } => {
write!(f, "{code}: unresolved reference: {what}")
}
Self::InvalidCondition { edge, why } => {
write!(f, "{code}: condition on edge {edge} is invalid: {why}")
}
Self::NoToolLlm { node } => write!(
f,
"{code}: node '{node}' is abstract (no binding, no matching \
tool definition) and no tool-calling LLM is configured"
),
Self::BudgetExhausted { axis } => write!(
f,
"{code}: budget exhausted on axis '{}' — the run is \
checkpointed and resumable after the manifest budget is \
raised",
axis.as_str()
),
Self::DanglingIntent { key } => write!(
f,
"{code}: dangling intent {key} (crash between dispatch and \
result) and on_dangling = fail — re-dispatch requires an \
idempotency-honoring executor"
),
Self::ReplayDivergence { what } => write!(
f,
"{code}: replay divergence — {what}. The scheduler, a \
condition evaluator, or a reducer is impure, or the journal \
was modified"
),
Self::ManifestMismatch { why } => {
write!(f, "{code}: checkpoint does not match the run manifest: {why}")
}
Self::UnknownAsk { tool_call_id } => write!(
f,
"{code}: no pending Client ask with tool_call_id \
'{tool_call_id}' (already settled, expired, or never asked)"
),
Self::Unauthorized { what } => {
write!(f, "{code}: not authorized: {what}")
}
Self::Canceled { by, reason } => {
write!(f, "{code}: run canceled by {by}: {reason}")
}
Self::ReducerLawViolation { key } => write!(
f,
"{code}: reducer for key '{key}' violated its laws (must be \
pure and batching-invariant) — caught by debug sampling"
),
Self::CheckpointTooLarge { bytes, cap } => write!(
f,
"{code}: checkpoint is {bytes} bytes after spill (cap {cap})"
),
Self::Tainted { why } => write!(f, "{code}: journal tainted: {why}"),
Self::RetentionRefused { why } => {
write!(f, "{code}: destruction refused: {why}")
}
Self::CodeExecRefused { condition } => write!(
f,
"{code}: code execution refused — failed condition: {condition}"
),
Self::InvalidPlan { why } => write!(f, "{code}: invalid workflow: {why}"),
Self::Storage { detail } => write!(f, "{code}: store failure: {detail}"),
Self::EgressRefused { destination } => write!(
f,
"{code}: outbound call to '{destination}' was refused by this run's egress policy"
),
Self::AnonReplayUnsafe { ns, scope } => write!(
f,
"{code}: namespace '{ns}' has an anonymization policy with scope \
'{scope}', which cannot be replayed — an abstract node would \
pseudonymize differently on every run and verify would diverge. \
Use scope \"memory\" (value-derived tokens, which needs an \
encrypted memory), or run this plan without abstract nodes"
),
Self::LeaseLost { run_id } => write!(
f,
"{code}: lease on run '{run_id}' was lost — another driver took it over \
while this one was advancing it; this driver's writes are refused"
),
}
}
}
impl std::error::Error for RunError {}
pub type Result<T> = std::result::Result<T, RunError>;
#[cfg(test)]
mod tests {
use super::*;
fn representatives() -> Vec<RunError> {
vec![
RunError::Stalled { node: "n".into() },
RunError::UnboundedCycle { nodes: vec!["a".into()] },
RunError::Unreachable { nodes: vec!["b".into()] },
RunError::UnresolvedRef { what: "x".into() },
RunError::InvalidCondition { edge: "a->b".into(), why: "w".into() },
RunError::NoToolLlm { node: "n".into() },
RunError::BudgetExhausted { axis: BudgetAxis::Usd },
RunError::DanglingIntent { key: "k".into() },
RunError::ReplayDivergence { what: "w".into() },
RunError::ManifestMismatch { why: "w".into() },
RunError::UnknownAsk { tool_call_id: "t".into() },
RunError::Unauthorized { what: "w".into() },
RunError::Canceled { by: "u".into(), reason: "r".into() },
RunError::ReducerLawViolation { key: "k".into() },
RunError::CheckpointTooLarge { bytes: 2, cap: 1 },
RunError::Tainted { why: "w".into() },
RunError::RetentionRefused { why: "w".into() },
RunError::CodeExecRefused { condition: "c".into() },
RunError::InvalidPlan { why: "w".into() },
RunError::Storage { detail: "d".into() },
RunError::LeaseLost { run_id: "r".into() },
RunError::EgressRefused { destination: "https://x/".into() },
RunError::AnonReplayUnsafe { ns: "n".into(), scope: "session".into() },
]
}
#[test]
fn codes_lead_display_and_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for e in representatives() {
let code = e.code();
assert!(
code.starts_with("RUN-E") && code.len() == 8,
"bad code format: {code}"
);
assert!(
e.to_string().starts_with(&format!("{code}: ")),
"code must lead Display: {e}"
);
assert!(seen.insert(code), "duplicate code {code}");
}
assert_eq!(seen.len(), 23);
}
}