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}