Skip to main content

lash/
error.rs

1use crate::support::*;
2
3#[derive(Debug, thiserror::Error)]
4pub enum EmbedError {
5    #[error(
6        "protocol plugin is required; call .protocol_plugin(...) or use LashCore::standard_builder()/LashCore::rlm_builder(...)"
7    )]
8    MissingProtocolPlugin,
9    #[error("model spec is required; hosts must supply explicit model metadata")]
10    MissingModelSpec,
11    #[error("effect host is required; provide an explicit effect host with .effect_host(...)")]
12    MissingEffectHost,
13    #[error(
14        "attachment store is required; provide an explicit attachment store with .attachment_store(...)"
15    )]
16    MissingAttachmentStore,
17    #[error(
18        "process execution environment store is required; provide an explicit process env store with .process_env_store(...)"
19    )]
20    MissingProcessEnvStore,
21    #[error("failed to create store for session `{session_id}`: {message}")]
22    StoreFactory { session_id: String, message: String },
23    #[error("store is bound to session `{loaded}` but builder requested `{requested}`")]
24    StoreSessionMismatch { loaded: String, requested: String },
25    #[error("durable process worker requires a LashCore store factory")]
26    MissingProcessWorkerStoreFactory,
27    #[error(
28        "durable session store requires a durable {facet}; an ephemeral {facet} cannot back a durable session store"
29    )]
30    DurableStorePeerRequired { facet: &'static str },
31    #[error(
32        "durable process registry requires a durable session store factory; call .store_factory(...) with a durable store"
33    )]
34    DurableProcessRegistryRequiresStoreFactory,
35    #[error(
36        "a process registry is configured for the default inline process work runner but no session store factory is wired; the runner rebuilds a session runtime per process and cannot do so without one. Wire .store_factory(...) - InMemorySessionStoreFactory::new() for ephemeral process execution, or a durable factory - or use .process_work_driver(...) for an externally driven durable runner."
37    )]
38    ProcessRegistryRequiresStoreFactory,
39    #[error("durable process worker config requires a LashCore process registry")]
40    MissingProcessRegistry,
41    #[error("invalid process execution configuration: {0}")]
42    ProcessExecutionConcurrency(#[from] lash_core::ProcessExecutionConcurrencyError),
43    #[error("session deletion requires a LashCore store factory")]
44    MissingSessionStoreFactory,
45    #[error("failed to delete process state for session `{session_id}`: {message}")]
46    SessionDeleteProcess { session_id: String, message: String },
47    #[error("missing required turn input for plugin `{plugin_id}`")]
48    MissingPluginTurnInput { plugin_id: &'static str },
49    #[error(
50        "session is still in use: park()/close() consume the session and require exclusive ownership; drop any cloned handles and finish or cancel in-flight turns first"
51    )]
52    SessionStillInUse,
53    #[error("failed to flush trace sink: {0}")]
54    TraceFlush(#[from] lash_trace::TraceSinkError),
55    #[error(
56        "configured effect host for {operation} is durable and requires a handler context; use .effects(&controller) and provide .turn_id(...) for replayable foreground requests"
57    )]
58    DurableEffectHostRequiresHandlerContext { operation: &'static str },
59    #[error(
60        "pull-style turn streams require an effect host that can create a static scoped controller; use stream_to(...) inside the handler context"
61    )]
62    StaticTurnStreamRequiresStaticEffectHost,
63    #[error("runtime session error: {0}")]
64    Session(#[from] SessionError),
65    #[error("runtime turn error: {0}")]
66    Runtime(#[from] lash_core::RuntimeError),
67    #[error("runtime plugin/control error: {0}")]
68    Plugin(#[from] lash_core::PluginError),
69    #[error("remote protocol error: {0}")]
70    RemoteProtocol(#[from] lash_remote_protocol::RemoteProtocolError),
71    #[error("failed to encode protocol turn options: {0}")]
72    ProtocolTurnOptions(#[from] serde_json::Error),
73    #[error("failed to decode protocol turn options: {0}")]
74    DecodeProtocolTurnOptions(#[from] lash_core::ProtocolTurnOptionsError),
75    #[error("runtime control unavailable: {0}")]
76    Control(#[from] lash_core::PluginOperationInvokeError),
77}
78
79impl EmbedError {
80    /// True only when a typed signal says the failed operation is safe to
81    /// retry as-is; `false` means "no typed retryable signal", not "known
82    /// permanent" (see [`is_terminal`](Self::is_terminal) for that).
83    ///
84    /// The retryable set is enumerated deliberately from
85    /// [`RuntimeErrorCode`](lash_core::RuntimeErrorCode):
86    ///
87    /// - [`SessionExecutionBusy`](lash_core::RuntimeErrorCode::SessionExecutionBusy):
88    ///   another executor currently holds the session-execution lease; the
89    ///   turn was rejected before any state changed, so retrying after a
90    ///   backoff is safe.
91    /// - [`SessionExecutionLeaseLost`](lash_core::RuntimeErrorCode::SessionExecutionLeaseLost):
92    ///   the lease was fenced away mid-turn. The final commit is fenced on
93    ///   the same lease, so the failed attempt committed nothing and its
94    ///   queued-work/turn-input claims were released; a fresh attempt can
95    ///   re-claim safely.
96    ///
97    /// Everything else is `false`. Notably
98    /// [`StoreCommitFailed`](lash_core::RuntimeErrorCode::StoreCommitFailed)
99    /// stays `false`: the code does not distinguish transient store I/O from
100    /// conflicts, so there is no typed signal that a retry is safe.
101    ///
102    /// Provider failures never surface as `EmbedError` — a failed LLM call
103    /// finishes the turn with `TurnOutcome::Stopped(ProviderError)` — so
104    /// their typed retryability is carried on
105    /// [`TurnIssue::retryable`](crate::turn::TurnIssue) instead.
106    pub fn is_retryable(&self) -> bool {
107        use lash_core::RuntimeErrorCode;
108        match self {
109            Self::Runtime(err) => matches!(
110                err.code,
111                RuntimeErrorCode::SessionExecutionBusy
112                    | RuntimeErrorCode::SessionExecutionLeaseLost
113            ),
114            _ => false,
115        }
116    }
117
118    /// True only when a typed signal says retrying can never succeed without
119    /// host-side changes (wiring, configuration, or invariant violations that
120    /// a retry cannot repair). Errors that are neither
121    /// [`is_retryable`](Self::is_retryable) nor terminal are simply unknown.
122    ///
123    /// The terminal set:
124    ///
125    /// - builder/wiring variants of this enum (missing protocol plugin,
126    ///   model spec, effect host, stores, registries, handler context, and
127    ///   store/session mismatches) — the same call fails identically until
128    ///   the host changes its wiring;
129    /// - [`RuntimeErrorCode`](lash_core::RuntimeErrorCode) wiring codes:
130    ///   `MissingExecutionScopeId`, `ExecutionScopeTurnIdMismatch`,
131    ///   `MissingProcessExecutionId`, `DurableStoreRequired`,
132    ///   `DurableEffectLiveProtocolExtension`,
133    ///   `DurableEffectLivePluginInput`;
134    /// - session provider-configuration errors (`ProviderMismatch`,
135    ///   `ProviderUnconfigured`, `ProviderUnavailable`,
136    ///   `CodeExecutionUnavailable`).
137    pub fn is_terminal(&self) -> bool {
138        use lash_core::RuntimeErrorCode;
139        match self {
140            Self::MissingProtocolPlugin
141            | Self::MissingModelSpec
142            | Self::MissingEffectHost
143            | Self::MissingAttachmentStore
144            | Self::MissingProcessEnvStore
145            | Self::StoreSessionMismatch { .. }
146            | Self::MissingProcessWorkerStoreFactory
147            | Self::DurableStorePeerRequired { .. }
148            | Self::DurableProcessRegistryRequiresStoreFactory
149            | Self::ProcessRegistryRequiresStoreFactory
150            | Self::MissingProcessRegistry
151            | Self::ProcessExecutionConcurrency(_)
152            | Self::MissingSessionStoreFactory
153            | Self::MissingPluginTurnInput { .. }
154            | Self::DurableEffectHostRequiresHandlerContext { .. }
155            | Self::StaticTurnStreamRequiresStaticEffectHost => true,
156            Self::Runtime(err) => matches!(
157                err.code,
158                RuntimeErrorCode::MissingExecutionScopeId
159                    | RuntimeErrorCode::ExecutionScopeTurnIdMismatch
160                    | RuntimeErrorCode::MissingProcessExecutionId
161                    | RuntimeErrorCode::DurableStoreRequired { .. }
162                    | RuntimeErrorCode::DurableEffectLiveProtocolExtension
163                    | RuntimeErrorCode::DurableEffectLivePluginInput
164            ),
165            Self::Session(err) => matches!(
166                err,
167                SessionError::ProviderMismatch { .. }
168                    | SessionError::ProviderUnconfigured { .. }
169                    | SessionError::ProviderUnavailable { .. }
170                    | SessionError::CodeExecutionUnavailable
171            ),
172            _ => false,
173        }
174    }
175}
176
177pub type Result<T> = std::result::Result<T, EmbedError>;
178
179#[cfg(test)]
180mod tests {
181    use super::EmbedError;
182    use lash_core::{RuntimeError, RuntimeErrorCode};
183
184    fn runtime_error(code: RuntimeErrorCode) -> EmbedError {
185        EmbedError::Runtime(RuntimeError::new(code, "test"))
186    }
187
188    #[test]
189    fn lease_contention_codes_are_retryable_and_not_terminal() {
190        for code in [
191            RuntimeErrorCode::SessionExecutionBusy,
192            RuntimeErrorCode::SessionExecutionLeaseLost,
193        ] {
194            let err = runtime_error(code);
195            assert!(err.is_retryable(), "{err}");
196            assert!(!err.is_terminal(), "{err}");
197        }
198    }
199
200    #[test]
201    fn untyped_failures_are_neither_retryable_nor_terminal() {
202        for err in [
203            runtime_error(RuntimeErrorCode::StoreCommitFailed),
204            runtime_error(RuntimeErrorCode::Other("plugin_defined_abort".into())),
205        ] {
206            assert!(!err.is_retryable(), "{err}");
207            assert!(!err.is_terminal(), "{err}");
208        }
209    }
210
211    #[test]
212    fn wiring_errors_are_terminal_and_not_retryable() {
213        for err in [
214            EmbedError::MissingProtocolPlugin,
215            EmbedError::MissingEffectHost,
216            runtime_error(RuntimeErrorCode::DurableStoreRequired {
217                facet: lash_core::DurableStoreFacet::SessionStore,
218            }),
219            runtime_error(RuntimeErrorCode::MissingExecutionScopeId),
220        ] {
221            assert!(err.is_terminal(), "{err}");
222            assert!(!err.is_retryable(), "{err}");
223        }
224    }
225}