Skip to main content

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///
26/// `Replay`, `Store`, and `Model` each spell out their inner error's `Display`
27/// directly in their own message (`"replay: {0}"` and so on) rather than
28/// leaning on `thiserror`'s `#[source]`/`#[from]` chaining for that text. A
29/// field that is both interpolated into the message AND wired as the
30/// `Error::source()` gets printed twice by anything that walks the source
31/// chain on top of `Display` (`anyhow`'s `{:#}`, `{:?}`, and the like): once
32/// embedded in this variant's own message, once again as the chain's next
33/// link. Plain `From` impls below give `?` the same conversion `#[from]`
34/// would without also handing these three a chained source, so the detail
35/// appears exactly once no matter how the caller prints the error.
36#[derive(Debug, Error)]
37pub enum RuntimeError {
38    /// The replay layer refused to continue: divergence, a malformed log, or
39    /// a dangling write intent that needs human reconciliation.
40    #[error("replay: {0}")]
41    Replay(ReplayError),
42
43    /// The event store failed to persist or read an event.
44    #[error("store: {0}")]
45    Store(StoreError),
46
47    /// A live model call failed after the client's own retries. The run's
48    /// log is intact (the intent, if any, is recorded), so the run can be
49    /// recovered later; the model intent will be re-issued safely.
50    #[error("model call: {0}")]
51    Model(salvor_llm::Error),
52
53    /// A model request could not be serialized to JSON for hashing.
54    #[error("model request did not serialize: {0}")]
55    RequestEncode(serde_json::Error),
56
57    /// A recorded model response could not be decoded back into a typed
58    /// response. This means the log holds something this build cannot read,
59    /// which is a storage or versioning fault, not orchestration divergence.
60    #[error("recorded model response did not decode: {0}")]
61    RecordedResponseDecode(serde_json::Error),
62
63    /// `start` was called for a run id that already has recorded history.
64    #[error("run {run_id:?} already has recorded history; use recover or resume")]
65    RunAlreadyStarted {
66        /// The run that already exists.
67        run_id: RunId,
68    },
69
70    /// The named run has no recorded history at all.
71    #[error("run {run_id:?} has no recorded history")]
72    UnknownRun {
73        /// The run that was not found.
74        run_id: RunId,
75    },
76
77    /// `resume` was called on a run whose log does not end at a suspension
78    /// or budget crossing.
79    #[error("run {run_id:?} is not parked (status: {status}); resume needs a parked run")]
80    NotParked {
81        /// The run that was not parked.
82        run_id: RunId,
83        /// A short description of the status the run was actually in.
84        status: String,
85    },
86
87    /// The resume input did not satisfy the recorded suspension schema (or,
88    /// for a budget crossing, the budget-extension shape).
89    #[error("resume input rejected: {0}")]
90    ResumeInputRejected(String),
91
92    /// The labels a run is about to be created with violate the sanity
93    /// bounds (too many, or a key/value over its length cap). See
94    /// [`crate::validate_labels`]. Surfaces only on a genuinely fresh
95    /// `begin`; a replayed run never re-checks the labels it already
96    /// recorded.
97    #[error("invalid labels: {0}")]
98    InvalidLabels(String),
99
100    /// `resolve` was called on a run that is not awaiting reconciliation. The
101    /// hand-recorded completion is only ever appended to a run whose log ends
102    /// at a dangling write intent; every other state is a caller mistake.
103    #[error(
104        "run {run_id:?} does not need reconciliation (status: {status}); resolve records the completion of a dangling write intent, and this run has none"
105    )]
106    NotReconcilable {
107        /// The run that was not awaiting reconciliation.
108        run_id: RunId,
109        /// A short description of the status the run was actually in.
110        status: String,
111    },
112
113    /// A keyed call could not proceed because another run holds the same
114    /// `(tool, idempotency key)` identity and has not finished with it.
115    ///
116    /// The holder is either running right now or died mid-call. Either way the
117    /// effect may or may not have happened, and this run has no way to find out
118    /// and no right to try: proceeding would be exactly the second execution
119    /// the key exists to prevent. So the call refuses, and it refuses *before*
120    /// recording anything, which leaves this run's log untouched and the run
121    /// re-runnable once the holder is finished or reconciled.
122    ///
123    /// The resolution lives in the holding run, never here. Finish it, or
124    /// reconcile its dangling write with `salvor resolve`, and then run this
125    /// one again.
126    #[error(
127        "tool `{tool}` under idempotency key `{idempotency_key}` is held by run {holder:?} at seq {holder_seq}, which has not recorded a completion; nothing was executed and nothing was recorded. Finish or reconcile that run before running this one again"
128    )]
129    CallInFlight {
130        /// The tool whose identity is held.
131        tool: String,
132        /// The idempotency key naming the effect.
133        idempotency_key: String,
134        /// The run holding the identity.
135        holder: RunId,
136        /// The position of the holder's intent for this call.
137        holder_seq: u64,
138    },
139
140    /// Two different calls presented the same `(tool, idempotency key)`
141    /// identity with different inputs.
142    ///
143    /// The key is a promise that two calls are the same call. Different inputs
144    /// under one key break that promise, and there is no safe reading of it:
145    /// deduplicating would hand this call an output computed from somebody
146    /// else's arguments, and executing would perform an effect the key says has
147    /// already been performed. So neither happens and the key's author is told.
148    ///
149    /// The fix is in the key, not here. A key must be specific enough to name
150    /// one effect: `"pay_claim:wreck-9931"`, not `"pay_claim"`.
151    #[error(
152        "tool `{tool}` was called under idempotency key `{idempotency_key}` with an input that differs from the call run {origin:?} already committed at seq {origin_seq}; the key names two different calls, so neither deduplicating nor executing is safe"
153    )]
154    IdempotencyKeyCollision {
155        /// The tool whose key collided.
156        tool: String,
157        /// The key that named two different calls.
158        idempotency_key: String,
159        /// The run holding the committed call.
160        origin: RunId,
161        /// The position of the committed call's intent.
162        origin_seq: u64,
163    },
164
165    /// A commitment pointed at a completion that its run's log does not hold.
166    ///
167    /// The store said an identity was settled at a position, and reading that
168    /// run's log (chain verification included) did not produce the completion
169    /// there. That is a damaged store, not a race: a settlement and its
170    /// completion are written as one unit, so one cannot exist without the
171    /// other. Reported rather than worked around, because the alternative would
172    /// be executing an effect the store believes already happened.
173    #[error(
174        "run {origin:?} was committed to tool `{tool}` under idempotency key `{idempotency_key}` at seq {origin_seq}, but its log holds no such completion; the store disagrees with itself and nothing was executed"
175    )]
176    CommitmentUnreadable {
177        /// The tool named by the commitment.
178        tool: String,
179        /// The key named by the commitment.
180        idempotency_key: String,
181        /// The run the commitment pointed at.
182        origin: RunId,
183        /// The position the commitment pointed at.
184        origin_seq: u64,
185    },
186
187    /// `abandon` was called on a run that already reached a terminal event
188    /// (completed, failed, or previously abandoned). A terminal run is already
189    /// at rest; there is nothing left to retire, so the operator action is
190    /// refused rather than appending a second terminal.
191    #[error(
192        "run {run_id:?} is already terminal (status: {status}); there is nothing left to abandon"
193    )]
194    AlreadyTerminal {
195        /// The run that had already finished.
196        run_id: RunId,
197        /// A short description of the terminal status the run was in.
198        status: String,
199    },
200}
201
202// Plain `From` impls, not `#[from]`: see the doc comment on `RuntimeError`
203// for why these three stay unchained.
204impl From<ReplayError> for RuntimeError {
205    fn from(error: ReplayError) -> Self {
206        RuntimeError::Replay(error)
207    }
208}
209
210impl From<StoreError> for RuntimeError {
211    fn from(error: StoreError) -> Self {
212        RuntimeError::Store(error)
213    }
214}
215
216impl From<salvor_llm::Error> for RuntimeError {
217    fn from(error: salvor_llm::Error) -> Self {
218        RuntimeError::Model(error)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    /// Joins an error's `Display` with every `source()` below it, exactly the
227    /// walk `anyhow`'s `{:#}`/`{:?}` and `salvor_runtime::wire::error_chain`
228    /// both do. A variant whose message already embeds its own `#[source]`
229    /// field's text, while ALSO exposing that field as the chained source,
230    /// would print the same text twice through a walk like this one; that is
231    /// the bug a tester hit for `RuntimeError::Model`.
232    fn chain(error: &dyn std::error::Error) -> String {
233        let mut message = error.to_string();
234        let mut source = error.source();
235        while let Some(inner) = source {
236            message.push_str(": ");
237            message.push_str(&inner.to_string());
238            source = inner.source();
239        }
240        message
241    }
242
243    /// Pins the fix: a model call's `500` (the demo model's own
244    /// no-conversation-matched error, reproduced by hand here) reads exactly
245    /// once whether a caller walks the source chain (`chain`, matching
246    /// `anyhow`'s alternate `Display`) or just calls `to_string()` on the bare
247    /// `RuntimeError` (matching `ApiError::message()`'s plain `Display` on the
248    /// HTTP path). Before the fix, `chain` doubled it: `Model`'s own message
249    /// interpolated the inner error's text AND `#[from]` chained that same
250    /// field as `source()`.
251    #[test]
252    fn model_error_prints_once_through_the_source_chain_and_plain_display() {
253        let inner = salvor_llm::Error::Api(salvor_llm::ApiError {
254            status: 500,
255            kind: "demo_script_no_conversation".to_owned(),
256            message: "no conversation name matched the system prompt".to_owned(),
257            request_id: None,
258            retry_after: None,
259        });
260        let error: RuntimeError = inner.into();
261
262        let needle = "no conversation name matched the system prompt";
263        let chained = chain(&error);
264        assert_eq!(chained.matches(needle).count(), 1, "{chained}");
265        assert_eq!(error.to_string().matches(needle).count(), 1, "{error}");
266
267        // No source to walk past `RuntimeError` itself: that absence is what
268        // keeps `chain` from doubling the text back up.
269        assert!(std::error::Error::source(&error).is_none());
270    }
271}