Skip to main content

areev_run_core/
error.rs

1//! The `RUN` error domain (governed-agents §6.9). Codes are **append-only**
2//! from day one, same rule as every other domain (`ERROR_CODES.md`): never
3//! renumber, never reuse. The code is the leading token of `Display`, and
4//! `code()` returns it — both pinned by tests below and by the workspace
5//! uniqueness/format tests.
6
7use std::fmt;
8
9/// A runtime failure with a stable `RUN-Ennn` code.
10#[derive(Debug, Clone, PartialEq)]
11pub enum RunError {
12    /// RUN-E001 — the run drained with no terminal node reaching success;
13    /// names the decision node whose conditions all evaluated false.
14    Stalled { node: String },
15    /// RUN-E002 — an unbounded cycle: a strongly-connected component with no
16    /// `max_cycles` edge.
17    UnboundedCycle { nodes: Vec<String> },
18    /// RUN-E003 — node(s) unreachable from the entry.
19    Unreachable { nodes: Vec<String> },
20    /// RUN-E004 — a binding or pinned hash does not resolve (or a journal
21    /// ref is absent without an erasure receipt).
22    UnresolvedRef { what: String },
23    /// RUN-E005 — a condition failed validation at load.
24    InvalidCondition { edge: String, why: String },
25    /// RUN-E006 — an abstract node with no tool-calling LLM configured.
26    NoToolLlm { node: String },
27    /// RUN-E007 — a budget axis is exhausted.
28    BudgetExhausted { axis: BudgetAxis },
29    /// RUN-E008 — a dangling intent on resume with `on_dangling = fail`.
30    DanglingIntent { key: String },
31    /// RUN-E009 — replay divergence: a re-derived decision, counter, or
32    /// checkpoint disagrees with the journal.
33    ReplayDivergence { what: String },
34    /// RUN-E010 — checkpoint does not match the run manifest.
35    ManifestMismatch { why: String },
36    /// RUN-E011 — a response names no pending Client ask.
37    UnknownAsk { tool_call_id: String },
38    /// RUN-E012 — a run operation the session's rights do not cover.
39    Unauthorized { what: String },
40    /// RUN-E013 — the run was canceled.
41    Canceled { by: String, reason: String },
42    /// RUN-E014 — a reducer violated its laws (debug sampling).
43    ReducerLawViolation { key: String },
44    /// RUN-E015 — a checkpoint exceeds the size cap after spill.
45    CheckpointTooLarge { bytes: usize, cap: usize },
46    /// RUN-E016 — the journal is tainted: forked supersession tips, or a
47    /// resume of a copied file without `--fork` / the owner nonce.
48    Tainted { why: String },
49    /// RUN-E017 — a retention floor or legal hold refuses a destruction.
50    RetentionRefused { why: String },
51    /// RUN-E018 — code execution refused; names the failed gate condition.
52    CodeExecRefused { condition: String },
53    /// RUN-E019 — the workflow failed structural validation (V1/V2 shape
54    /// errors that are not one of the specific codes above).
55    InvalidPlan { why: String },
56    /// RUN-E020 — a store operation failed under the runtime (wraps the
57    /// underlying `DOMAIN-Ennn` message, which stays in the detail).
58    Storage { detail: String },
59}
60
61/// The budget axes (§6.7). `Supersteps` is the global backstop too.
62#[derive(
63    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize,
64)]
65pub enum BudgetAxis {
66    Supersteps,
67    Tokens,
68    Usd,
69    WallMs,
70    Storage,
71}
72
73impl BudgetAxis {
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Self::Supersteps => "supersteps",
77            Self::Tokens => "tokens",
78            Self::Usd => "usd",
79            Self::WallMs => "wall_ms",
80            Self::Storage => "storage",
81        }
82    }
83}
84
85impl RunError {
86    /// The stable `RUN-Ennn` code — a permanent debugging handle.
87    pub fn code(&self) -> &'static str {
88        match self {
89            Self::Stalled { .. } => "RUN-E001",
90            Self::UnboundedCycle { .. } => "RUN-E002",
91            Self::Unreachable { .. } => "RUN-E003",
92            Self::UnresolvedRef { .. } => "RUN-E004",
93            Self::InvalidCondition { .. } => "RUN-E005",
94            Self::NoToolLlm { .. } => "RUN-E006",
95            Self::BudgetExhausted { .. } => "RUN-E007",
96            Self::DanglingIntent { .. } => "RUN-E008",
97            Self::ReplayDivergence { .. } => "RUN-E009",
98            Self::ManifestMismatch { .. } => "RUN-E010",
99            Self::UnknownAsk { .. } => "RUN-E011",
100            Self::Unauthorized { .. } => "RUN-E012",
101            Self::Canceled { .. } => "RUN-E013",
102            Self::ReducerLawViolation { .. } => "RUN-E014",
103            Self::CheckpointTooLarge { .. } => "RUN-E015",
104            Self::Tainted { .. } => "RUN-E016",
105            Self::RetentionRefused { .. } => "RUN-E017",
106            Self::CodeExecRefused { .. } => "RUN-E018",
107            Self::InvalidPlan { .. } => "RUN-E019",
108            Self::Storage { .. } => "RUN-E020",
109        }
110    }
111}
112
113impl fmt::Display for RunError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        let code = self.code();
116        match self {
117            Self::Stalled { node } => write!(
118                f,
119                "{code}: run stalled — no terminal node succeeded; every \
120                 condition on decision node '{node}' evaluated false (add an \
121                 unconditional default edge, or check the state it tests)"
122            ),
123            Self::UnboundedCycle { nodes } => write!(
124                f,
125                "{code}: unbounded cycle through [{}] — every cycle must carry \
126                 at least one edge with max_cycles",
127                nodes.join(", ")
128            ),
129            Self::Unreachable { nodes } => write!(
130                f,
131                "{code}: node(s) unreachable from the entry: [{}]",
132                nodes.join(", ")
133            ),
134            Self::UnresolvedRef { what } => {
135                write!(f, "{code}: unresolved reference: {what}")
136            }
137            Self::InvalidCondition { edge, why } => {
138                write!(f, "{code}: condition on edge {edge} is invalid: {why}")
139            }
140            Self::NoToolLlm { node } => write!(
141                f,
142                "{code}: node '{node}' is abstract (no binding, no matching \
143                 tool definition) and no tool-calling LLM is configured"
144            ),
145            Self::BudgetExhausted { axis } => write!(
146                f,
147                "{code}: budget exhausted on axis '{}' — the run is \
148                 checkpointed and resumable after the manifest budget is \
149                 raised",
150                axis.as_str()
151            ),
152            Self::DanglingIntent { key } => write!(
153                f,
154                "{code}: dangling intent {key} (crash between dispatch and \
155                 result) and on_dangling = fail — re-dispatch requires an \
156                 idempotency-honoring executor"
157            ),
158            Self::ReplayDivergence { what } => write!(
159                f,
160                "{code}: replay divergence — {what}. The scheduler, a \
161                 condition evaluator, or a reducer is impure, or the journal \
162                 was modified"
163            ),
164            Self::ManifestMismatch { why } => {
165                write!(f, "{code}: checkpoint does not match the run manifest: {why}")
166            }
167            Self::UnknownAsk { tool_call_id } => write!(
168                f,
169                "{code}: no pending Client ask with tool_call_id \
170                 '{tool_call_id}' (already settled, expired, or never asked)"
171            ),
172            Self::Unauthorized { what } => {
173                write!(f, "{code}: not authorized: {what}")
174            }
175            Self::Canceled { by, reason } => {
176                write!(f, "{code}: run canceled by {by}: {reason}")
177            }
178            Self::ReducerLawViolation { key } => write!(
179                f,
180                "{code}: reducer for key '{key}' violated its laws (must be \
181                 pure and batching-invariant) — caught by debug sampling"
182            ),
183            Self::CheckpointTooLarge { bytes, cap } => write!(
184                f,
185                "{code}: checkpoint is {bytes} bytes after spill (cap {cap})"
186            ),
187            Self::Tainted { why } => write!(f, "{code}: journal tainted: {why}"),
188            Self::RetentionRefused { why } => {
189                write!(f, "{code}: destruction refused: {why}")
190            }
191            Self::CodeExecRefused { condition } => write!(
192                f,
193                "{code}: code execution refused — failed condition: {condition}"
194            ),
195            Self::InvalidPlan { why } => write!(f, "{code}: invalid workflow: {why}"),
196            Self::Storage { detail } => write!(f, "{code}: store failure: {detail}"),
197        }
198    }
199}
200
201impl std::error::Error for RunError {}
202
203pub type Result<T> = std::result::Result<T, RunError>;
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn representatives() -> Vec<RunError> {
210        vec![
211            RunError::Stalled { node: "n".into() },
212            RunError::UnboundedCycle { nodes: vec!["a".into()] },
213            RunError::Unreachable { nodes: vec!["b".into()] },
214            RunError::UnresolvedRef { what: "x".into() },
215            RunError::InvalidCondition { edge: "a->b".into(), why: "w".into() },
216            RunError::NoToolLlm { node: "n".into() },
217            RunError::BudgetExhausted { axis: BudgetAxis::Usd },
218            RunError::DanglingIntent { key: "k".into() },
219            RunError::ReplayDivergence { what: "w".into() },
220            RunError::ManifestMismatch { why: "w".into() },
221            RunError::UnknownAsk { tool_call_id: "t".into() },
222            RunError::Unauthorized { what: "w".into() },
223            RunError::Canceled { by: "u".into(), reason: "r".into() },
224            RunError::ReducerLawViolation { key: "k".into() },
225            RunError::CheckpointTooLarge { bytes: 2, cap: 1 },
226            RunError::Tainted { why: "w".into() },
227            RunError::RetentionRefused { why: "w".into() },
228            RunError::CodeExecRefused { condition: "c".into() },
229            RunError::InvalidPlan { why: "w".into() },
230            RunError::Storage { detail: "d".into() },
231        ]
232    }
233
234    /// The workspace rule: the code is the LEADING token of Display, unique
235    /// per variant, format `RUN-Ennn`.
236    #[test]
237    fn codes_lead_display_and_are_unique() {
238        let mut seen = std::collections::BTreeSet::new();
239        for e in representatives() {
240            let code = e.code();
241            assert!(
242                code.starts_with("RUN-E") && code.len() == 8,
243                "bad code format: {code}"
244            );
245            assert!(
246                e.to_string().starts_with(&format!("{code}: ")),
247                "code must lead Display: {e}"
248            );
249            assert!(seen.insert(code), "duplicate code {code}");
250        }
251        assert_eq!(seen.len(), 20);
252    }
253}