Skip to main content

lean_host_mcp/
error.rs

1//! The one error type tool handlers return.
2//!
3//! Most "failures" Lean reports back (parse error, elaboration error, missing
4//! declaration) are *not* `ServerError`; they live in the tool's `result`
5//! payload as structured data. `ServerError` is reserved for things the
6//! caller cannot meaningfully recover from locally: the Lean runtime failed
7//! to init, the project actor is busy or unavailable, or the Lake project
8//! does not exist.
9
10use rmcp::ErrorData as McpError;
11use schemars::JsonSchema;
12use serde::Serialize;
13use serde_json::json;
14
15use crate::envelope::{Freshness, RuntimeFacts, RuntimeFailure};
16use lean_rs_worker_parent::LeanWorkerError;
17
18#[derive(Debug, Clone, Serialize, JsonSchema, thiserror::Error)]
19#[error("worker unavailable: {reason}")]
20pub struct WorkerUnavailable {
21    pub(crate) retryable: bool,
22    pub(crate) worker_restarted: bool,
23    pub(crate) project_root: String,
24    pub(crate) project_hash: String,
25    pub(crate) imports: Vec<String>,
26    pub(crate) session_id: String,
27    pub(crate) lean_toolchain: String,
28    pub(crate) worker_generation: u64,
29    pub(crate) reason: String,
30    pub(crate) restart_cause: Option<String>,
31    pub(crate) rss_kib: Option<u64>,
32    pub(crate) limit_kib: Option<u64>,
33    pub(crate) retry_after_millis: Option<u64>,
34    pub(crate) restarts_in_window: Option<u64>,
35    pub(crate) window_millis: Option<u64>,
36    pub(crate) runtime: RuntimeFacts,
37    /// Project-lifetime toolchain advisories (unknown pin, missing provenance
38    /// sidecar, no smoke record) captured at open. Carried here so a worker
39    /// that dies mid-call still surfaces them on the `runtime_unavailable`
40    /// envelope — the moment a suspect worker is most worth flagging. Internal;
41    /// drained into the envelope's top-level `warnings`, never the error wire
42    /// `data`, so it is skipped from (de)serialization like its
43    /// [`Freshness`] counterpart.
44    #[serde(skip)]
45    #[schemars(skip)]
46    pub(crate) toolchain_advisories: Vec<String>,
47}
48
49impl WorkerUnavailable {
50    pub(crate) fn freshness(&self) -> Freshness {
51        Freshness {
52            project_root: self.project_root.clone(),
53            project_hash: self.project_hash.clone(),
54            imports: self.imports.clone(),
55            session_id: self.session_id.clone(),
56            lean_toolchain: self.lean_toolchain.clone(),
57            toolchain_advisories: self.toolchain_advisories.clone(),
58        }
59    }
60
61    pub(crate) fn failure(&self) -> RuntimeFailure {
62        RuntimeFailure {
63            reason: self.reason.clone(),
64            retryable: self.retryable,
65            project_root: self.project_root.clone(),
66            session_id: self.session_id.clone(),
67            worker_generation: self.worker_generation,
68            worker_restarted: self.worker_restarted,
69            restart_cause: self.restart_cause.clone(),
70            rss_kib: self.rss_kib,
71            limit_kib: self.limit_kib,
72            retry_after_millis: self.retry_after_millis,
73            restarts_in_window: self.restarts_in_window,
74            window_millis: self.window_millis,
75        }
76    }
77}
78
79#[derive(Debug, thiserror::Error)]
80pub enum ServerError {
81    #[error("lean runtime: {0}")]
82    Lean(String),
83
84    #[error("session thread is gone")]
85    SessionGone,
86
87    #[error("{0}")]
88    WorkerUnavailable(Box<WorkerUnavailable>),
89
90    #[error("lake project not usable: {0}")]
91    BadProject(String),
92
93    #[error("io: {0}")]
94    Io(#[from] std::io::Error),
95
96    #[error("internal: {0}")]
97    Internal(String),
98}
99
100pub type Result<T> = std::result::Result<T, ServerError>;
101
102impl ServerError {
103    pub(crate) fn worker_unavailable(info: WorkerUnavailable) -> Self {
104        Self::WorkerUnavailable(Box::new(info))
105    }
106}
107
108impl From<ServerError> for McpError {
109    fn from(err: ServerError) -> Self {
110        // -32603 == internal error in JSON-RPC. The MCP spec leaves wider
111        // codes available but most clients only branch on this band.
112        match err {
113            ServerError::WorkerUnavailable(info) => {
114                let message = info.to_string();
115                let data = json!({
116                    "retryable": info.retryable,
117                    "worker_restarted": info.worker_restarted,
118                    "project_root": &info.project_root,
119                    "project_hash": &info.project_hash,
120                    "imports": &info.imports,
121                    "session_id": &info.session_id,
122                    "lean_toolchain": &info.lean_toolchain,
123                    "worker_generation": info.worker_generation,
124                    "reason": &info.reason,
125                    "restart_cause": &info.restart_cause,
126                    "rss_kib": info.rss_kib,
127                    "limit_kib": info.limit_kib,
128                    "retry_after_millis": info.retry_after_millis,
129                    "restarts_in_window": info.restarts_in_window,
130                    "window_millis": info.window_millis,
131                });
132                Self::internal_error(message, Some(data))
133            }
134            other @ (ServerError::Lean(_)
135            | ServerError::SessionGone
136            | ServerError::BadProject(_)
137            | ServerError::Io(_)
138            | ServerError::Internal(_)) => Self::internal_error(other.to_string(), None),
139        }
140    }
141}
142
143impl From<anyhow::Error> for ServerError {
144    fn from(err: anyhow::Error) -> Self {
145        Self::Internal(err.to_string())
146    }
147}
148
149/// Classify a worker-layer infrastructure error at the runtime boundary.
150///
151/// Bootstrap failures map to `ServerError::BadProject`; worker death and
152/// timeout cases that need retry/restart metadata are handled by the project
153/// actor before this fallback is used.
154#[allow(
155    clippy::needless_pass_by_value,
156    clippy::wildcard_enum_match_arm,
157    reason = "LeanWorkerError is upstream-evolving; everything outside the bootstrap-classification set maps to Lean for the MCP wire"
158)]
159pub(crate) fn map_worker_err(err: LeanWorkerError) -> ServerError {
160    match err {
161        LeanWorkerError::WorkerChildUnresolved { .. }
162        | LeanWorkerError::WorkerChildNotExecutable { .. }
163        | LeanWorkerError::Bootstrap { .. }
164        | LeanWorkerError::CapabilityBuild { .. }
165        | LeanWorkerError::Setup { .. }
166        | LeanWorkerError::Handshake { .. }
167        | LeanWorkerError::CapabilityMetadataMismatch { .. } => ServerError::BadProject(err.to_string()),
168        LeanWorkerError::ChildPanicOrAbort { .. } | LeanWorkerError::ChildExited { .. } => ServerError::Lean(format!(
169            "worker process exited; project worker will restart before the next request: {err}"
170        )),
171        _ => ServerError::Lean(err.to_string()),
172    }
173}