salvor_runtime/error.rs
1//! [`RuntimeError`]: the one error type every `salvor-runtime` operation
2//! returns.
3//!
4//! The variants fall into three groups:
5//!
6//! - **Forwarded layers.** [`Replay`](RuntimeError::Replay),
7//! [`Store`](RuntimeError::Store), and [`Model`](RuntimeError::Model) wrap
8//! the typed errors of the crates underneath, unflattened, so a caller can
9//! still match the inner variant. The one that matters most is
10//! `Replay(ReplayError::NeedsReconciliation)`: resuming a run whose log
11//! ends in a write intent with no completion surfaces here, and the runtime
12//! refuses to continue until a human resolves it.
13//! - **Serialization edges.** [`RequestEncode`](RuntimeError::RequestEncode)
14//! and [`RecordedResponseDecode`](RuntimeError::RecordedResponseDecode)
15//! mark the two places JSON conversion can fail around a model call.
16//! - **Runtime protocol.** Starting a run that already has history, resuming
17//! a run that is not parked, resuming with input the recorded schema
18//! rejects, or naming a run the store does not know.
19
20use salvor_core::{ReplayError, RunId};
21use salvor_store::StoreError;
22use thiserror::Error;
23
24/// What can go wrong while driving a run.
25#[derive(Debug, Error)]
26pub enum RuntimeError {
27 /// The replay layer refused to continue: divergence, a malformed log, or
28 /// a dangling write intent that needs human reconciliation.
29 #[error("replay: {0}")]
30 Replay(#[from] ReplayError),
31
32 /// The event store failed to persist or read an event.
33 #[error("store: {0}")]
34 Store(#[from] StoreError),
35
36 /// A live model call failed after the client's own retries. The run's
37 /// log is intact (the intent, if any, is recorded), so the run can be
38 /// recovered later; the model intent will be re-issued safely.
39 #[error("model call: {0}")]
40 Model(#[from] salvor_llm::Error),
41
42 /// A model request could not be serialized to JSON for hashing.
43 #[error("model request did not serialize: {0}")]
44 RequestEncode(serde_json::Error),
45
46 /// A recorded model response could not be decoded back into a typed
47 /// response. This means the log holds something this build cannot read,
48 /// which is a storage or versioning fault, not orchestration divergence.
49 #[error("recorded model response did not decode: {0}")]
50 RecordedResponseDecode(serde_json::Error),
51
52 /// `start` was called for a run id that already has recorded history.
53 #[error("run {run_id:?} already has recorded history; use recover or resume")]
54 RunAlreadyStarted {
55 /// The run that already exists.
56 run_id: RunId,
57 },
58
59 /// The named run has no recorded history at all.
60 #[error("run {run_id:?} has no recorded history")]
61 UnknownRun {
62 /// The run that was not found.
63 run_id: RunId,
64 },
65
66 /// `resume` was called on a run whose log does not end at a suspension
67 /// or budget crossing.
68 #[error("run {run_id:?} is not parked (status: {status}); resume needs a parked run")]
69 NotParked {
70 /// The run that was not parked.
71 run_id: RunId,
72 /// A short description of the status the run was actually in.
73 status: String,
74 },
75
76 /// The resume input did not satisfy the recorded suspension schema (or,
77 /// for a budget crossing, the budget-extension shape).
78 #[error("resume input rejected: {0}")]
79 ResumeInputRejected(String),
80
81 /// The labels a run is about to be created with violate the sanity
82 /// bounds (too many, or a key/value over its length cap). See
83 /// [`crate::validate_labels`]. Surfaces only on a genuinely fresh
84 /// `begin`; a replayed run never re-checks the labels it already
85 /// recorded.
86 #[error("invalid labels: {0}")]
87 InvalidLabels(String),
88
89 /// `resolve` was called on a run that is not awaiting reconciliation. The
90 /// hand-recorded completion is only ever appended to a run whose log ends
91 /// at a dangling write intent; every other state is a caller mistake.
92 #[error(
93 "run {run_id:?} does not need reconciliation (status: {status}); resolve records the completion of a dangling write intent, and this run has none"
94 )]
95 NotReconcilable {
96 /// The run that was not awaiting reconciliation.
97 run_id: RunId,
98 /// A short description of the status the run was actually in.
99 status: String,
100 },
101
102 /// `abandon` was called on a run that already reached a terminal event
103 /// (completed, failed, or previously abandoned). A terminal run is already
104 /// at rest; there is nothing left to retire, so the operator action is
105 /// refused rather than appending a second terminal.
106 #[error(
107 "run {run_id:?} is already terminal (status: {status}); there is nothing left to abandon"
108 )]
109 AlreadyTerminal {
110 /// The run that had already finished.
111 run_id: RunId,
112 /// A short description of the terminal status the run was in.
113 status: String,
114 },
115}