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}