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}