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