Skip to main content

salvor_engine/
error.rs

1//! [`EngineError`]: everything that can stop a graph drive.
2//!
3//! Two families sit here. The first is the engine's own refusals, each naming
4//! the offending node: a `map` node whose `over` reference does not resolve to a
5//! list ([`EngineError::MapOverNotAList`]) or whose body form is not
6//! executable ([`EngineError::UnsupportedMapBody`]), a `fold` node whose body
7//! form is not executable ([`EngineError::UnsupportedFoldBody`]) or whose
8//! `best_by` join finds no comparable candidate
9//! ([`EngineError::FoldNoComparableCandidate`]) or that reached its iteration
10//! bound while declaring `on_bound: fail`
11//! ([`EngineError::FoldBoundExceeded`]), an agent or tool the resolver
12//! could not supply, a graph whose topology is not a well-formed DAG, a branch
13//! that no case matched or whose model decision named no case, a tool that
14//! failed, or a gate resumed with an approval that does not satisfy its
15//! `approval_schema` ([`EngineError::ApprovalSchemaViolation`]). Most are
16//! returned **before** recording anything for the node they
17//! name, so the log never carries events past the refusal; the two branch-decision
18//! errors that require running a model first are the documented exception (their
19//! `NodeEntered` and the model's events are already recorded when the mapping
20//! fails), as are the fold's two post-loop refusals, which can only be reached
21//! once the passes they judge have run. There is no longer a whole-kind refusal:
22//! every node kind the document defines is executed, so the variant that named
23//! one is gone. The second family is [`EngineError::Runtime`], the plain
24//! pass-through of a [`RuntimeError`] from the `RunCtx` operations the engine
25//! drives.
26//!
27//! # Permanent and transient, and why the split is here
28//!
29//! Every variant answers [`EngineError::is_permanent`]. A **permanent** refusal
30//! is a pure function of the frozen graph document and the recorded log: drive
31//! the same run again and it re-fails identically, forever, with no live call
32//! able to change the answer. A **transient** one depends on something outside
33//! that pair (how the process was invoked, what was registered, what a provider
34//! or the store did on this attempt), so a retry, or the same retry with
35//! different flags, can succeed.
36//!
37//! The split exists so a graph driver can tell a run that is *stuck* from a run
38//! that is *dead*. A dead run must stop reading as `running`: the drivers record
39//! a terminal `RunFailed` for a permanent refusal (see
40//! [`crate::record_permanent_refusal`]) and leave a transient one exactly as it
41//! was, recoverable. Getting that backwards in the safe direction costs an
42//! operator a re-drive; getting it backwards in the unsafe direction kills a run
43//! that would have recovered. So the rule when a variant is genuinely arguable
44//! is **transient**. [`EngineError::is_permanent`]'s doc comment defends every
45//! variant's call, one line each, in one place so the whole table can be read
46//! (and argued with) at once.
47
48use crate::approval::ApprovalViolation;
49use salvor_runtime::RuntimeError;
50use thiserror::Error;
51
52/// Why a graph drive could not continue.
53#[derive(Debug, Error)]
54pub enum EngineError {
55    /// A `map` node's `over` reference did not resolve to a JSON array against the
56    /// routed value (it was missing, or resolved to a non-array value). A map can
57    /// only fan out over a list, so the engine refuses deterministically rather
58    /// than guessing. Returned **before** the map's `NodeEntered` is recorded, so
59    /// nothing lands in the log past the refusal, and it reproduces on replay: the
60    /// same recorded routed value re-resolves to the same non-list.
61    #[error("map node `{node}`: the `over` reference `{over}` did not resolve to a list")]
62    MapOverNotAList {
63        /// The id of the map node.
64        node: String,
65        /// The `over` reference that failed to resolve to a list.
66        over: String,
67    },
68
69    /// A `map` node's body is a form that is not executable: an embedded
70    /// `subgraph` (per-item sub-walks need their own
71    /// log per iteration to keep node ids unambiguous, which is not implemented
72    /// yet), or a `node` body that
73    /// names a node whose kind cannot be a per-item worker (only `agent` and
74    /// `tool` bodies run). Returned **before** the map's `NodeEntered` is recorded,
75    /// so nothing lands in the log past the refusal. The document layer still
76    /// validates these as legal graphs; only the engine declines to run them.
77    #[error("map node `{node}`: {detail}")]
78    UnsupportedMapBody {
79        /// The id of the map node.
80        node: String,
81        /// What about the body is not supported.
82        detail: String,
83    },
84
85    /// An expression `branch` reached with no case whose condition evaluated
86    /// true. The author declared the cases exhaustively or the graph cannot
87    /// proceed; the engine refuses deterministically rather than guessing a
88    /// route. Returned before the branch's `NodeEntered` is recorded, so nothing
89    /// lands in the log past the refusal, and the refusal reproduces on replay
90    /// (the same routed value re-evaluates to the same no-match).
91    #[error("branch node `{node}`: no case condition matched the routed value")]
92    NoBranchCaseMatched {
93        /// The id of the branch node.
94        node: String,
95    },
96
97    /// A model-decision `branch`'s agent produced a reply that is not one of the
98    /// branch's case names. Unlike the other refusals this arrives **after** the
99    /// branch's `NodeEntered` and the decision agent's own events are recorded
100    /// (the model had to run to produce the reply); it still reproduces on
101    /// replay, because the reply is decoded from the recorded model completion.
102    #[error(
103        "branch node `{node}`: the decision agent replied `{reply}`, which is not one of the cases [{}]",
104        .cases.join(", ")
105    )]
106    BranchDecisionUnmatched {
107        /// The id of the branch node.
108        node: String,
109        /// The agent's reply, trimmed, that named no case.
110        reply: String,
111        /// The branch's case names, in author order.
112        cases: Vec<String>,
113    },
114
115    /// A `fold` node's body is a form that is not executable: an embedded
116    /// `subgraph` (per-pass sub-walks need their own log per pass to keep node
117    /// ids unambiguous, which is not implemented yet, exactly as for a map), or
118    /// a `node` body that names a node whose kind cannot be a per-pass worker
119    /// (only `agent` and `tool` bodies run). Returned **before** the fold's
120    /// `NodeEntered` is recorded, so nothing lands in the log past the refusal.
121    /// The document layer still validates these as legal graphs; only the engine
122    /// declines to run them.
123    #[error("fold node `{node}`: {detail}")]
124    UnsupportedFoldBody {
125        /// The id of the fold node.
126        node: String,
127        /// What about the body is not supported.
128        detail: String,
129    },
130
131    /// A `fold` node's `best_by` join found no pass it could choose between:
132    /// the reference resolved on no pass, or resolved only to values the
133    /// expression language does not order (anything but a number or a string).
134    /// An argmax with no candidate has no answer, so the engine refuses rather
135    /// than falling back to a pass no rule chose. Unlike the body refusal this
136    /// arrives **after** the fold's `NodeEntered` and its passes are recorded
137    /// (the passes had to run to be chosen among), but **before**
138    /// `FoldConverged`: no winner and no reason land in the log for a
139    /// convergence that did not happen. It reproduces on replay, because the
140    /// argmax reads the recorded pass outputs.
141    #[error(
142        "fold node `{node}`: the `best_by` join reference `{reference}` named no comparable value in any pass"
143    )]
144    FoldNoComparableCandidate {
145        /// The id of the fold node.
146        node: String,
147        /// The `best_by` reference that named nothing comparable.
148        reference: String,
149    },
150
151    /// A `fold` node that declares `on_bound: fail` ran every pass its
152    /// `max_iterations` bound allows and `stop_when` never held. For such a
153    /// fold the predicate is a REQUIREMENT rather than an early exit: the loop
154    /// converged on nothing, so the node produces no value and the join is
155    /// never consulted.
156    ///
157    /// Recorded state at the refusal: the passes and their joins are all in the
158    /// log, because they really happened and a replay must reproduce them. What
159    /// does not land is the convergence: this is returned exactly where
160    /// `FoldConverged` would have been recorded, so no `FoldConverged` and no
161    /// `NodeExited` are written, mirroring
162    /// [`EngineError::FoldNoComparableCandidate`]. It reproduces on replay,
163    /// because the pass count and the predicate's verdict are both pure
164    /// functions of the recorded pass outputs.
165    #[error(
166        "fold node `{node}`: reached the max_iterations bound of {bound} without `stop_when` holding, and this fold declares `on_bound: fail`"
167    )]
168    FoldBoundExceeded {
169        /// The id of the fold node.
170        node: String,
171        /// The `max_iterations` bound the loop reached.
172        bound: u32,
173    },
174
175    /// An `agent` node referenced an agent hash the resolver could not supply.
176    #[error("agent node `{node}`: no agent registered for hash `{agent_hash}`")]
177    UnknownAgent {
178        /// The id of the agent node.
179        node: String,
180        /// The unresolved agent definition hash.
181        agent_hash: String,
182    },
183
184    /// A `tool` node named a tool the resolver could not supply.
185    #[error("tool node `{node}`: no tool registered under the name `{tool}`")]
186    UnknownTool {
187        /// The id of the tool node.
188        node: String,
189        /// The unresolved tool name.
190        tool: String,
191    },
192
193    /// The graph's edges do not form a well-formed DAG (a cycle, or an edge
194    /// referencing a node that is not in the document). The document validator
195    /// rejects both at submit; the engine re-checks defensively so a walk is
196    /// never attempted over a malformed topology.
197    #[error("the graph is not a well-formed acyclic document: {detail}")]
198    MalformedGraph {
199        /// What was wrong with the topology.
200        detail: String,
201    },
202
203    /// A `tool` node's call failed after exhausting its retry policy. The full
204    /// failure is already recorded in the log's `ToolCallCompleted`; this
205    /// carries the message so the caller sees why the graph stopped.
206    #[error("tool node `{node}` failed: {message}")]
207    ToolFailed {
208        /// The id of the tool node that failed.
209        node: String,
210        /// The recorded failure message.
211        message: String,
212    },
213
214    /// A `gate` node was resumed with an input that does not satisfy the gate's
215    /// declared `approval_schema`. Returned from the **accept edge**: after the
216    /// gate's `Suspended` has been replayed and BEFORE `await_resume` can
217    /// append a `Resumed`, so the refusal appends nothing and leaves the run
218    /// parked exactly where it was, ready for a conforming approval. It is
219    /// therefore not reachable on replay at all: a recorded `Resumed` is
220    /// history and is fed to the gate untouched. See [`crate::approval`].
221    #[error(
222        "gate node `{node}`: the approval input does not satisfy the gate's approval_schema ({})",
223        .violations.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
224    )]
225    ApprovalSchemaViolation {
226        /// The id of the gate node the run is parked at.
227        node: String,
228        /// Every way the input failed the schema, in a stable order.
229        violations: Vec<ApprovalViolation>,
230    },
231
232    /// The graph document could not be serialized to compute its hash. A graph
233    /// is plain data, so this does not arise in practice; it exists to keep the
234    /// hashing edge honest rather than panicking on a `serde_json` error.
235    #[error("could not serialize the graph document to hash it: {0}")]
236    GraphEncode(#[source] serde_json::Error),
237
238    /// A `RunCtx` operation surfaced a runtime error (replay divergence, a
239    /// dangling write needing reconciliation, a live provider failure, a store
240    /// failure). Passed through unchanged.
241    #[error(transparent)]
242    Runtime(#[from] RuntimeError),
243}
244
245impl EngineError {
246    /// Whether this refusal is PERMANENT: a pure function of the frozen graph
247    /// document and the recorded log, so the same drive re-fails identically
248    /// forever and no retry, registration, or live call can change the answer.
249    /// `false` means TRANSIENT: the refusal depends on the environment, on how
250    /// the drive was invoked, or on a live call, so a retry (possibly with
251    /// different flags) can succeed.
252    ///
253    /// A graph driver records a terminal `RunFailed` for a permanent refusal so
254    /// a dead run stops reading as `running`, and leaves a transient one
255    /// recoverable exactly as it was. Because a wrong `true` kills a run that
256    /// would have come back and a wrong `false` costs only an operator's
257    /// re-drive, an arguable variant is classified TRANSIENT.
258    ///
259    /// The match is exhaustive with no wildcard arm on purpose: a new variant
260    /// does not compile until someone decides which side it falls on.
261    ///
262    /// # The table
263    ///
264    /// PERMANENT:
265    ///
266    /// - [`MapOverNotAList`](Self::MapOverNotAList): the `over` reference and
267    ///   the routed value are both recorded, so the resolve re-runs to the same
268    ///   non-list every time.
269    /// - [`UnsupportedMapBody`](Self::UnsupportedMapBody) /
270    ///   [`UnsupportedFoldBody`](Self::UnsupportedFoldBody): the body form is a
271    ///   field of the frozen document. No retry makes a `subgraph` body run;
272    ///   only a NEW document (a new run) does.
273    /// - [`NoBranchCaseMatched`](Self::NoBranchCaseMatched): the cases are the
274    ///   document's and the routed value is recorded, so the same no-match
275    ///   reproduces exactly.
276    /// - [`BranchDecisionUnmatched`](Self::BranchDecisionUnmatched): the reply
277    ///   is decoded from a RECORDED model completion, never re-requested, so
278    ///   the mapping re-fails on replay. The model is not asked again, which is
279    ///   what separates this from a live provider failure.
280    /// - [`FoldNoComparableCandidate`](Self::FoldNoComparableCandidate): the
281    ///   argmax reads the recorded pass outputs; nothing in the log can become
282    ///   comparable later.
283    /// - [`FoldBoundExceeded`](Self::FoldBoundExceeded): the bound and
284    ///   `on_bound` are the document's, and the passes that failed the
285    ///   predicate are recorded. The loop cannot be given more passes without
286    ///   changing the document.
287    /// - [`MalformedGraph`](Self::MalformedGraph): a property of the document
288    ///   alone (a cycle, a dangling body reference, a bound below one). The
289    ///   supplied document is pinned to the run by the recorded `graph_hash`,
290    ///   so "supply a fixed one" is not a retry of THIS run; it is a new run.
291    ///
292    /// TRANSIENT:
293    ///
294    /// - [`UnknownAgent`](Self::UnknownAgent) /
295    ///   [`UnknownTool`](Self::UnknownTool): the document names a hash or a
296    ///   name; whether it RESOLVES is a fact about this invocation's resolvers,
297    ///   which is registration, not meaning. Registering the agent on the
298    ///   server, or passing the missing `--agent` file, makes the same log
299    ///   drive on. Killing the run for a forgotten flag would be the exact
300    ///   mistake this split exists to avoid.
301    /// - [`ToolFailed`](Self::ToolFailed): a live call failed after its retry
302    ///   policy. The tool is the outside world; a resume can reach a world that
303    ///   answers. (A recorded failure does replay, but re-driving is the
304    ///   operator's decision to make, not the engine's to foreclose.)
305    /// - [`ApprovalSchemaViolation`](Self::ApprovalSchemaViolation): NOT
306    ///   permanent, and the clearest case of it. The refusal is about an input
307    ///   that has not been recorded and never will be; the run is still parked
308    ///   at its gate, and a conforming approval can arrive at any moment. This
309    ///   variant is not even reachable on replay.
310    /// - [`GraphEncode`](Self::GraphEncode): arguable, so transient. It is a
311    ///   serializer edge rather than a statement about the document's meaning,
312    ///   and it is raised before `begin_graph` writes the run head, so there is
313    ///   no run for a terminal to belong to. Classifying it permanent would
314    ///   invite appending `RunFailed` onto a log with no `GraphRunStarted`.
315    /// - [`Runtime`](Self::Runtime): everything the `RunCtx` surfaces (a store
316    ///   failure, a provider failure, a replay divergence, a dangling write
317    ///   needing reconciliation). Store and provider failures are plainly
318    ///   retryable; a divergence or a reconciliation refusal is the operator's
319    ///   to resolve, and `resolve` exists precisely so such a run continues.
320    ///   None of it is the engine's to declare dead.
321    #[must_use]
322    pub fn is_permanent(&self) -> bool {
323        match self {
324            Self::MapOverNotAList { .. }
325            | Self::UnsupportedMapBody { .. }
326            | Self::NoBranchCaseMatched { .. }
327            | Self::BranchDecisionUnmatched { .. }
328            | Self::UnsupportedFoldBody { .. }
329            | Self::FoldNoComparableCandidate { .. }
330            | Self::FoldBoundExceeded { .. }
331            | Self::MalformedGraph { .. } => true,
332            Self::UnknownAgent { .. }
333            | Self::UnknownTool { .. }
334            | Self::ToolFailed { .. }
335            | Self::ApprovalSchemaViolation { .. }
336            | Self::GraphEncode(_)
337            | Self::Runtime(_) => false,
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::collections::BTreeSet;
346
347    /// A short name per variant, written as an EXHAUSTIVE match with no
348    /// wildcard arm. This is the forcing function the classification test rests
349    /// on: a variant added to [`EngineError`] does not compile until it is
350    /// named here, and naming it here is not enough until [`samples`] carries
351    /// one and states which side of the split it falls on.
352    fn variant_name(error: &EngineError) -> &'static str {
353        match error {
354            EngineError::MapOverNotAList { .. } => "MapOverNotAList",
355            EngineError::UnsupportedMapBody { .. } => "UnsupportedMapBody",
356            EngineError::NoBranchCaseMatched { .. } => "NoBranchCaseMatched",
357            EngineError::BranchDecisionUnmatched { .. } => "BranchDecisionUnmatched",
358            EngineError::UnsupportedFoldBody { .. } => "UnsupportedFoldBody",
359            EngineError::FoldNoComparableCandidate { .. } => "FoldNoComparableCandidate",
360            EngineError::FoldBoundExceeded { .. } => "FoldBoundExceeded",
361            EngineError::UnknownAgent { .. } => "UnknownAgent",
362            EngineError::UnknownTool { .. } => "UnknownTool",
363            EngineError::MalformedGraph { .. } => "MalformedGraph",
364            EngineError::ToolFailed { .. } => "ToolFailed",
365            EngineError::ApprovalSchemaViolation { .. } => "ApprovalSchemaViolation",
366            EngineError::GraphEncode(_) => "GraphEncode",
367            EngineError::Runtime(_) => "Runtime",
368        }
369    }
370
371    /// One of every variant, paired with the answer
372    /// [`EngineError::is_permanent`] must give it. This is the table the
373    /// method's doc comment argues, written out independently so a change to
374    /// the method that is not also a change to the argument fails here.
375    fn samples() -> Vec<(EngineError, bool)> {
376        vec![
377            (
378                EngineError::MapOverNotAList {
379                    node: "fanout".to_owned(),
380                    over: "roster".to_owned(),
381                },
382                true,
383            ),
384            (
385                EngineError::UnsupportedMapBody {
386                    node: "fanout".to_owned(),
387                    detail: "a `subgraph` body is not executed yet".to_owned(),
388                },
389                true,
390            ),
391            (
392                EngineError::NoBranchCaseMatched {
393                    node: "route".to_owned(),
394                },
395                true,
396            ),
397            (
398                EngineError::BranchDecisionUnmatched {
399                    node: "route".to_owned(),
400                    reply: "maybe".to_owned(),
401                    cases: vec!["yes".to_owned(), "no".to_owned()],
402                },
403                true,
404            ),
405            (
406                EngineError::UnsupportedFoldBody {
407                    node: "refine".to_owned(),
408                    detail: "a `gate` body node cannot be a per-pass worker".to_owned(),
409                },
410                true,
411            ),
412            (
413                EngineError::FoldNoComparableCandidate {
414                    node: "refine".to_owned(),
415                    reference: "score".to_owned(),
416                },
417                true,
418            ),
419            (
420                EngineError::FoldBoundExceeded {
421                    node: "refine".to_owned(),
422                    bound: 3,
423                },
424                true,
425            ),
426            (
427                EngineError::MalformedGraph {
428                    detail: "the edges form a cycle".to_owned(),
429                },
430                true,
431            ),
432            (
433                EngineError::UnknownAgent {
434                    node: "research".to_owned(),
435                    agent_hash: "sha256:0".to_owned(),
436                },
437                false,
438            ),
439            (
440                EngineError::UnknownTool {
441                    node: "publish".to_owned(),
442                    tool: "publish_post".to_owned(),
443                },
444                false,
445            ),
446            (
447                EngineError::ToolFailed {
448                    node: "publish".to_owned(),
449                    message: "publish endpoint unreachable".to_owned(),
450                },
451                false,
452            ),
453            (
454                EngineError::ApprovalSchemaViolation {
455                    node: "approve".to_owned(),
456                    violations: vec![ApprovalViolation {
457                        path: "$.approved".to_owned(),
458                        message: "is a required property".to_owned(),
459                    }],
460                },
461                false,
462            ),
463            (
464                EngineError::GraphEncode(
465                    serde_json::from_str::<serde_json::Value>("{").expect_err("malformed JSON"),
466                ),
467                false,
468            ),
469            (
470                EngineError::Runtime(RuntimeError::ResumeInputRejected(
471                    "the store is unavailable".to_owned(),
472                )),
473                false,
474            ),
475        ]
476    }
477
478    /// Every variant answers `is_permanent` with the value its doc comment
479    /// defends, and the sample table covers every variant there is: a new
480    /// variant fails `variant_name`'s exhaustive match at compile time, and a
481    /// variant named there but left out of the table fails here.
482    #[test]
483    fn every_engine_error_variant_is_classified_permanent_or_transient() {
484        for (error, permanent) in samples() {
485            assert_eq!(
486                error.is_permanent(),
487                permanent,
488                "{}: classified against its documented side ({error})",
489                variant_name(&error)
490            );
491        }
492
493        let covered: BTreeSet<&'static str> = samples()
494            .iter()
495            .map(|(error, _)| variant_name(error))
496            .collect();
497        let expected: BTreeSet<&'static str> = [
498            "MapOverNotAList",
499            "UnsupportedMapBody",
500            "NoBranchCaseMatched",
501            "BranchDecisionUnmatched",
502            "UnsupportedFoldBody",
503            "FoldNoComparableCandidate",
504            "FoldBoundExceeded",
505            "UnknownAgent",
506            "UnknownTool",
507            "MalformedGraph",
508            "ToolFailed",
509            "ApprovalSchemaViolation",
510            "GraphEncode",
511            "Runtime",
512        ]
513        .into_iter()
514        .collect();
515        assert_eq!(
516            covered, expected,
517            "every EngineError variant carries a sample and a decided classification"
518        );
519    }
520
521    /// The split itself, stated once as a fact rather than variant by variant:
522    /// exactly the eight refusals that read only the frozen document and the
523    /// recorded log are permanent, and every refusal that depends on
524    /// registration, a live call, an input that has not arrived, or the store
525    /// is not.
526    #[test]
527    fn the_permanent_side_is_exactly_the_document_and_log_refusals() {
528        let permanent: BTreeSet<&'static str> = samples()
529            .iter()
530            .filter(|(error, _)| error.is_permanent())
531            .map(|(error, _)| variant_name(error))
532            .collect();
533        let expected: BTreeSet<&'static str> = [
534            "BranchDecisionUnmatched",
535            "FoldBoundExceeded",
536            "FoldNoComparableCandidate",
537            "MalformedGraph",
538            "MapOverNotAList",
539            "NoBranchCaseMatched",
540            "UnsupportedFoldBody",
541            "UnsupportedMapBody",
542        ]
543        .into_iter()
544        .collect();
545        assert_eq!(permanent, expected);
546    }
547}