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    /// RUN-E021 — the run lease was lost mid-flight: another driver took this
60    /// run over while we were advancing it.
61    ///
62    /// Before this existed, two drivers advancing one run last-write-wins in
63    /// the journal — silently. The doc comment on `Tainted` claimed forked tips
64    /// were detected; they were not. Refusing here fails safe *by prevention*
65    /// rather than by after-the-fact detection.
66    ///
67    /// Note the code: the design proposal called this RUN-E017, but that is
68    /// `RetentionRefused` and codes are append-only.
69    LeaseLost { run_id: String },
70    /// RUN-E022 — a host command's outbound call was refused: a destination
71    /// outside the run's allowlist, a method its grant does not permit, a
72    /// credential it may not spend, a request header it may not set —
73    /// undeclared, or one the broker owns (#105) — or a CAS blob read without
74    /// the `blob` capability (#106). One code for "the broker said no",
75    /// whichever of its doors was knocked on.
76    ///
77    /// The trigger evaluator reports the same condition as `TRG-E009`, the way
78    /// a storage failure is `TRG-E010` there and `RUN-E020` here. One
79    /// condition, one code per subsystem that reports it.
80    EgressRefused { destination: String },
81    /// RUN-E023 — an anonymization policy covers this run's namespace but
82    /// cannot replay, so the model boundary would make `verify` diverge.
83    ///
84    /// Session scope numbers tokens by order of appearance, so the same input
85    /// yields different placeholders on a replay. Only value-derived (HMAC)
86    /// tokens are stable across handles and processes — that is D8 — and the
87    /// key for them is HKDF-derived from the page key, which is why this also
88    /// means an encrypted memory.
89    AnonReplayUnsafe { ns: String, scope: String },
90    /// RUN-E024 — an abstract node's transcript is over `llm_context_tokens`
91    /// and there is nothing left to fold: the node's input and the kept tail
92    /// alone exceed the ceiling, or a fold already ran and did not help.
93    ///
94    /// Deliberately NOT a retry and not a second fold. A fold that cannot find
95    /// a foldable middle will never find one by trying again, and looping on it
96    /// would spend the effect budget summarizing summaries. The honest answer
97    /// is a failed node naming the ceiling it could not fit under — raise it,
98    /// bound the tool results (`llm_tool_result_chars`), or split the node.
99    /// `ceiling` is `None` when no ceiling was configured and the PROVIDER
100    /// refused the transcript — the limit was learned from the rejection
101    /// rather than predicted.
102    ContextExceeded { node: String, tokens: u64, ceiling: Option<u64> },
103    /// RUN-E025 — the model configuration this run STARTED under is not the
104    /// one on offer now (#287).
105    ///
106    /// Raised before the lease is taken and before any grain is written: a
107    /// resume that would have finished a parked run on a different model,
108    /// provider or region simply does not begin. `areev run fork` is the
109    /// sanctioned way through — a fork writes a new manifest carrying the new
110    /// pin and records what it forked from, so the change is a recorded
111    /// decision rather than an undocumented drift.
112    ModelMismatch { pinned: String, offered: String },
113    /// RUN-E026 — this run was written by a scheduler generation whose
114    /// decisions differ from this build's (#288).
115    ///
116    /// A patch upgrade must not strand parked approval runs, so the VERSION
117    /// string is not what is compared — only the scheduler epoch, which moves
118    /// exactly when a change makes an existing journal replay differently.
119    EngineMismatch { written_by: String, epoch: u32, this_epoch: u32 },
120    /// RUN-E027 — starting this run would exceed a concurrency cap (#296).
121    ///
122    /// RETRYABLE by nature: the cap is a backstop beneath the host's own
123    /// dispatcher, not a verdict on the run. Nothing is written under the run
124    /// id, so the same id starts once a slot frees, and a trigger firing
125    /// refused here leaves its item unconsumed (the #129 rule for RUN-E018).
126    ConcurrencyLimit { scope: String, limit: u32 },
127    /// RUN-E028 — a Tool declares a brokered-transfer ceiling
128    /// (`runtime_limits.max_response_bytes` / `max_request_bytes`) that is
129    /// malformed, zero, or above the 32 MiB hard maximum (#339).
130    ///
131    /// Refused at run start — before any upstream I/O — and never clamped: a
132    /// tool that declared more than the host will carry would otherwise fail
133    /// on the first document between the two sizes, with nothing pointing at
134    /// its own declaration.
135    TransferLimitInvalid { node: String, detail: String },
136    /// RUN-E029 — a pause was asked of a run that cannot be paused (#344): it
137    /// already finished (completed, failed, stalled, canceled, or out of
138    /// budget), or a cancel is pending against it — cancel wins over pause.
139    ///
140    /// Pausing an already-paused (or already-pause-requested) run is NOT this
141    /// error; it is idempotent and answers with the standing request.
142    NotPausable { run_id: String, why: String },
143    /// RUN-E030 — a plan binds a decision node (a Tool Definition whose
144    /// `executor_uri` is `areev://decide`) and this host has no decision
145    /// backend installed.
146    ///
147    /// Refused at run start (the V7 freeze) and on resume — before the lease
148    /// and before any grain is written — naming the node, so a run never
149    /// starts down a path whose branch point cannot be answered here. The
150    /// scheduler's OPTIONAL asks (the decision-guided fold, the tool-offer
151    /// narrowing) never raise this: without a backend they are not asked.
152    NoDecider { node: String },
153    /// RUN-E031 — a declared `op: query` read (#370) cannot be served: at
154    /// run start the saved query is not defined or its parameters do not fit
155    /// the declaration; at dispatch the body pinned at run start is missing
156    /// from the manifest or no longer matches the hash recorded beside it.
157    ///
158    /// The run reads the body PINNED at start, never the live `qry:` row, so
159    /// a body superseded or dropped mid-run does not change a running run;
160    /// this code is what a pin that cannot be honoured fails with instead of
161    /// quietly reading something else.
162    SavedQueryUnavailable { node: String, name: String, why: String },
163}
164
165/// The budget axes (§6.7). `Supersteps` is the global backstop too.
166#[derive(
167    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize,
168)]
169pub enum BudgetAxis {
170    Supersteps,
171    Tokens,
172    Usd,
173    WallMs,
174    Storage,
175    /// Settled effects across the WHOLE run (#295). `--max-effects` bounds
176    /// one node ATTEMPT; a run's total was bounded only by
177    /// nodes × retries × cycles × fan-out × that number.
178    Effects,
179    /// Host tool calls across the whole run (#295) — plan-bound or
180    /// model-issued. Client asks and memory reads do not count: an ask is a
181    /// person, and a memory read is not an outbound call.
182    ToolCalls,
183}
184
185impl BudgetAxis {
186    pub fn as_str(&self) -> &'static str {
187        match self {
188            Self::Supersteps => "supersteps",
189            Self::Tokens => "tokens",
190            Self::Usd => "usd",
191            Self::WallMs => "wall_ms",
192            Self::Storage => "storage",
193            Self::Effects => "effects",
194            Self::ToolCalls => "tool_calls",
195        }
196    }
197}
198
199impl RunError {
200    /// The stable `RUN-Ennn` code — a permanent debugging handle.
201    pub fn code(&self) -> &'static str {
202        match self {
203            Self::Stalled { .. } => "RUN-E001",
204            Self::UnboundedCycle { .. } => "RUN-E002",
205            Self::Unreachable { .. } => "RUN-E003",
206            Self::UnresolvedRef { .. } => "RUN-E004",
207            Self::InvalidCondition { .. } => "RUN-E005",
208            Self::NoToolLlm { .. } => "RUN-E006",
209            Self::BudgetExhausted { .. } => "RUN-E007",
210            Self::DanglingIntent { .. } => "RUN-E008",
211            Self::ReplayDivergence { .. } => "RUN-E009",
212            Self::ManifestMismatch { .. } => "RUN-E010",
213            Self::UnknownAsk { .. } => "RUN-E011",
214            Self::Unauthorized { .. } => "RUN-E012",
215            Self::Canceled { .. } => "RUN-E013",
216            Self::ReducerLawViolation { .. } => "RUN-E014",
217            Self::CheckpointTooLarge { .. } => "RUN-E015",
218            Self::Tainted { .. } => "RUN-E016",
219            Self::RetentionRefused { .. } => "RUN-E017",
220            Self::CodeExecRefused { .. } => "RUN-E018",
221            Self::InvalidPlan { .. } => "RUN-E019",
222            Self::Storage { .. } => "RUN-E020",
223            Self::LeaseLost { .. } => "RUN-E021",
224            Self::EgressRefused { .. } => "RUN-E022",
225            Self::AnonReplayUnsafe { .. } => "RUN-E023",
226            Self::ContextExceeded { .. } => "RUN-E024",
227            Self::ModelMismatch { .. } => "RUN-E025",
228            Self::EngineMismatch { .. } => "RUN-E026",
229            Self::ConcurrencyLimit { .. } => "RUN-E027",
230            Self::TransferLimitInvalid { .. } => "RUN-E028",
231            Self::NotPausable { .. } => "RUN-E029",
232            Self::NoDecider { .. } => "RUN-E030",
233            Self::SavedQueryUnavailable { .. } => "RUN-E031",
234        }
235    }
236}
237
238impl fmt::Display for RunError {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        let code = self.code();
241        match self {
242            Self::Stalled { node } => write!(
243                f,
244                "{code}: run stalled — no terminal node succeeded; every \
245                 condition on decision node '{node}' evaluated false (add an \
246                 unconditional default edge, or check the state it tests)"
247            ),
248            Self::UnboundedCycle { nodes } => write!(
249                f,
250                "{code}: unbounded cycle through [{}] — every cycle must carry \
251                 at least one edge with max_cycles",
252                nodes.join(", ")
253            ),
254            Self::Unreachable { nodes } => write!(
255                f,
256                "{code}: node(s) unreachable from the entry: [{}]",
257                nodes.join(", ")
258            ),
259            Self::UnresolvedRef { what } => {
260                write!(f, "{code}: unresolved reference: {what}")
261            }
262            Self::InvalidCondition { edge, why } => {
263                write!(f, "{code}: condition on edge {edge} is invalid: {why}")
264            }
265            Self::NoToolLlm { node } => write!(
266                f,
267                "{code}: node '{node}' is abstract (no binding, no matching \
268                 tool definition) and no tool-calling LLM is configured"
269            ),
270            Self::BudgetExhausted { axis } => write!(
271                f,
272                "{code}: budget exhausted on axis '{}' — the run is \
273                 checkpointed and resumable after the manifest budget is \
274                 raised",
275                axis.as_str()
276            ),
277            Self::DanglingIntent { key } => write!(
278                f,
279                "{code}: dangling intent {key} (crash between dispatch and \
280                 result) and on_dangling = fail — re-dispatch requires an \
281                 idempotency-honoring executor"
282            ),
283            Self::ReplayDivergence { what } => write!(
284                f,
285                "{code}: replay divergence — {what}. The scheduler, a \
286                 condition evaluator, or a reducer is impure, or the journal \
287                 was modified"
288            ),
289            Self::ManifestMismatch { why } => {
290                write!(f, "{code}: checkpoint does not match the run manifest: {why}")
291            }
292            Self::UnknownAsk { tool_call_id } => write!(
293                f,
294                "{code}: no pending Client ask with tool_call_id \
295                 '{tool_call_id}' (already settled, expired, or never asked)"
296            ),
297            Self::Unauthorized { what } => {
298                write!(f, "{code}: not authorized: {what}")
299            }
300            Self::Canceled { by, reason } => {
301                write!(f, "{code}: run canceled by {by}: {reason}")
302            }
303            Self::ReducerLawViolation { key } => write!(
304                f,
305                "{code}: reducer for key '{key}' violated its laws (must be \
306                 pure and batching-invariant) — caught by debug sampling"
307            ),
308            Self::CheckpointTooLarge { bytes, cap } => write!(
309                f,
310                "{code}: checkpoint is {bytes} bytes after spill (cap {cap})"
311            ),
312            Self::Tainted { why } => write!(f, "{code}: journal tainted: {why}"),
313            Self::RetentionRefused { why } => {
314                write!(f, "{code}: destruction refused: {why}")
315            }
316            Self::CodeExecRefused { condition } => write!(
317                f,
318                "{code}: code execution refused — failed condition: {condition}"
319            ),
320            Self::InvalidPlan { why } => write!(f, "{code}: invalid workflow: {why}"),
321            Self::Storage { detail } => write!(f, "{code}: store failure: {detail}"),
322            Self::EgressRefused { destination } => write!(
323                f,
324                "{code}: outbound call to '{destination}' was refused by this run's egress policy"
325            ),
326            Self::AnonReplayUnsafe { ns, scope } => write!(
327                f,
328                "{code}: namespace '{ns}' has an anonymization policy with scope \
329                 '{scope}', which cannot be replayed — an abstract node would \
330                 pseudonymize differently on every run and verify would diverge. \
331                 Use scope \"memory\" (value-derived tokens, which needs an \
332                 encrypted memory), or run this plan without abstract nodes"
333            ),
334            Self::LeaseLost { run_id } => write!(
335                f,
336                "{code}: lease on run '{run_id}' was lost — another driver took it over \
337                 while this one was advancing it; this driver's writes are refused"
338            ),
339            Self::ContextExceeded { node, tokens, ceiling } => write!(
340                f,
341                "{code}: node '{node}' has a transcript the model reported at \
342                 {tokens} prompt tokens against {}, and nothing is left to \
343                 fold — the node's input and the kept tail alone exceed it. \
344                 Raise --llm-context-tokens, bound oversized tool results \
345                 (--llm-tool-result-chars), or split the node",
346                match ceiling {
347                    Some(c) => format!("a ceiling of {c}"),
348                    None => "the provider's own limit, which it refused the \
349                             request against"
350                        .to_string(),
351                }
352            ),
353            Self::ModelMismatch { pinned, offered } => write!(
354                f,
355                "{code}: this run started under {pinned} and is being resumed \
356                 under {offered} — a parked run must finish on the \
357                 configuration it started on. Resume under the pinned model, \
358                 or `areev run fork` to continue under the new one (the fork \
359                 records both)"
360            ),
361            Self::EngineMismatch { written_by, epoch, this_epoch } => write!(
362                f,
363                "{code}: this run was written by engine {written_by} at \
364                 scheduler epoch {epoch}, and this build is at epoch \
365                 {this_epoch} — the two schedulers do not make the same \
366                 decisions, so resuming would produce a journal neither can \
367                 verify whole. Run it on an engine at epoch {epoch}, or \
368                 `areev run fork` to continue under this one"
369            ),
370            Self::ConcurrencyLimit { scope, limit } => write!(
371                f,
372                "{code}: {limit} concurrent runs already executing for \
373                 {scope} — nothing was written, so this run id is still free. \
374                 Retry once a slot frees, or raise the cap"
375            ),
376            Self::TransferLimitInvalid { node, detail } => write!(
377                f,
378                "{code}: node '{node}' declares an invalid transfer ceiling: \
379                 {detail}. Declare 1..=33554432 bytes (32 MiB), or omit it for \
380                 the 1048576-byte (1 MiB) default"
381            ),
382            Self::NotPausable { run_id, why } => write!(
383                f,
384                "{code}: run '{run_id}' cannot be paused: {why}. A pause only \
385                 applies to a run that can still advance"
386            ),
387            Self::NoDecider { node } => write!(
388                f,
389                "{code}: node '{node}' is a decision node (executor_uri \
390                 \"areev://decide\") and this host has no decision backend — \
391                 configure one (`--decide <chain>` / `--decide-cmd`, \
392                 $AREEV_DECIDE, or `Runner::with_decider`), or bind the node to \
393                 an ordinary tool"
394            ),
395            Self::SavedQueryUnavailable { node, name, why } => write!(
396                f,
397                "{code}: read '{node}' names saved query \"{name}\", which cannot be \
398                 served: {why}"
399            ),
400        }
401    }
402}
403
404impl std::error::Error for RunError {}
405
406pub type Result<T> = std::result::Result<T, RunError>;
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn representatives() -> Vec<RunError> {
413        vec![
414            RunError::Stalled { node: "n".into() },
415            RunError::UnboundedCycle { nodes: vec!["a".into()] },
416            RunError::Unreachable { nodes: vec!["b".into()] },
417            RunError::UnresolvedRef { what: "x".into() },
418            RunError::InvalidCondition { edge: "a->b".into(), why: "w".into() },
419            RunError::NoToolLlm { node: "n".into() },
420            RunError::BudgetExhausted { axis: BudgetAxis::Usd },
421            RunError::DanglingIntent { key: "k".into() },
422            RunError::ReplayDivergence { what: "w".into() },
423            RunError::ManifestMismatch { why: "w".into() },
424            RunError::UnknownAsk { tool_call_id: "t".into() },
425            RunError::Unauthorized { what: "w".into() },
426            RunError::Canceled { by: "u".into(), reason: "r".into() },
427            RunError::ReducerLawViolation { key: "k".into() },
428            RunError::CheckpointTooLarge { bytes: 2, cap: 1 },
429            RunError::Tainted { why: "w".into() },
430            RunError::RetentionRefused { why: "w".into() },
431            RunError::CodeExecRefused { condition: "c".into() },
432            RunError::InvalidPlan { why: "w".into() },
433            RunError::Storage { detail: "d".into() },
434            RunError::LeaseLost { run_id: "r".into() },
435            RunError::EgressRefused { destination: "https://x/".into() },
436            RunError::AnonReplayUnsafe { ns: "n".into(), scope: "session".into() },
437            RunError::ContextExceeded { node: "n".into(), tokens: 9, ceiling: Some(8) },
438            RunError::TransferLimitInvalid { node: "n".into(), detail: "d".into() },
439            RunError::NotPausable { run_id: "r".into(), why: "w".into() },
440            RunError::NoDecider { node: "n".into() },
441            RunError::SavedQueryUnavailable {
442                node: "n".into(),
443                name: "q".into(),
444                why: "w".into(),
445            },
446        ]
447    }
448
449    /// The workspace rule: the code is the LEADING token of Display, unique
450    /// per variant, format `RUN-Ennn`.
451    #[test]
452    fn codes_lead_display_and_are_unique() {
453        let mut seen = std::collections::BTreeSet::new();
454        for e in representatives() {
455            let code = e.code();
456            assert!(
457                code.starts_with("RUN-E") && code.len() == 8,
458                "bad code format: {code}"
459            );
460            assert!(
461                e.to_string().starts_with(&format!("{code}: ")),
462                "code must lead Display: {e}"
463            );
464            assert!(seen.insert(code), "duplicate code {code}");
465        }
466        assert_eq!(seen.len(), 28);
467    }
468}