1use std::fmt;
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum RunError {
12 Stalled { node: String },
15 UnboundedCycle { nodes: Vec<String> },
18 Unreachable { nodes: Vec<String> },
20 UnresolvedRef { what: String },
23 InvalidCondition { edge: String, why: String },
25 NoToolLlm { node: String },
27 BudgetExhausted { axis: BudgetAxis },
29 DanglingIntent { key: String },
31 ReplayDivergence { what: String },
34 ManifestMismatch { why: String },
36 UnknownAsk { tool_call_id: String },
38 Unauthorized { what: String },
40 Canceled { by: String, reason: String },
42 ReducerLawViolation { key: String },
44 CheckpointTooLarge { bytes: usize, cap: usize },
46 Tainted { why: String },
49 RetentionRefused { why: String },
51 CodeExecRefused { condition: String },
53 InvalidPlan { why: String },
56 Storage { detail: String },
59}
60
61#[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 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 #[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}