lean-host-mcp 0.5.0

MCP server hosting Lean 4 via a supervised lean-rs-worker child
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Resolution-health rendering: the parent's one set of honest, actionable
//! messages for the ways a verdict is untrustworthy — the project environment
//! is incomplete (`needs_build`), the name is genuinely ambiguous (multiple
//! competing declarations), or the verdict was computed while the worker was
//! recycled/crashed mid-call (`worker_recycled`, [`execution_taint`]).
//!
//! Ousterhout ch.10 (exception aggregation). The incomplete-build condition
//! used to reach the parent in several disguises — a worker `MissingImports`
//! outcome, a missing-`.olean` [`ServerError`], a `verification_status` of
//! "ambiguous", and a free-text "ambiguous in the module" message — each
//! presented differently (a hard error here, a misleading "ambiguous" there).
//! This module collapses them into one renderer per verdict so every tool
//! emits the *same* wording. Per ch.10.10 the build-state is deliberately
//! *exposed* (the agent must learn to run `lake build`), never masked.
//!
//! As of `lean-rs-worker-protocol` 0.1.18 the worker classifies resolution at
//! its own boundary: `proof_state` and `verify_declaration` return typed
//! `NeedsBuild` / `Ambiguous { candidates }` verdicts, so the parent no longer
//! string-matches those. The one remaining text heuristic,
//! [`missing_olean_failure`], covers the env-based query path
//! (`find_references`, `search_for_proof`), where an unbuilt transitive
//! dependency still surfaces as an infrastructure [`ServerError`].

use schemars::JsonSchema;
use serde::Serialize;

use crate::envelope::{Response, RuntimeFacts, RuntimeRestartEvent};
use crate::error::{Result, ServerError};
use crate::trust::ArtifactTrust;

/// Why a query ran against an environment that was not fully built. Each
/// variant carries only what the renderer needs to name the blocking work.
pub(crate) enum IncompleteCause {
    /// Modules the open environment did not have (worker `MissingImports`).
    MissingImports(Vec<String>),
    /// A query hit a missing `.olean` (an infrastructure [`ServerError`] whose
    /// text [`missing_olean_failure`] recognised). Carries the error text.
    MissingOlean(String),
}

/// One competing declaration for a genuinely ambiguous name. `namespace` is the
/// disambiguator the worker provides (`namespace_name`); source-snapshot
/// candidates have no loaded module, so there is nothing finer to show.
pub(crate) struct CompetingDecl {
    pub name: String,
    pub namespace: Option<String>,
}

/// Recognise an infrastructure failure that is really a missing/stale build
/// artifact rather than a Lean-domain error. Text-coupled heuristic; the only
/// one left, for the env-based query path the worker does not type. See module
/// docs.
pub(crate) fn missing_olean_failure(err: &ServerError) -> bool {
    let text = err.to_string().to_lowercase();
    (text.contains(".olean")
        && (text.contains("does not exist") || text.contains("no such file") || text.contains("object file")))
        || text.contains("unknown module")
        || text.contains("unknown import")
        || text.contains("module not found")
}

/// The wire status token for a degraded, incomplete-build verdict. One producer
/// so every tool agrees on the spelling.
pub(crate) const NEEDS_BUILD_STATUS: &str = "needs_build";

/// A worker call either produced its value, or it hit an unbuilt dependency
/// while assembling the environment. The second case is recoverable and
/// agent-actionable (run `lake build`), not an infrastructure failure, so the
/// tools degrade it into a `needs_build` verdict rather than letting it
/// propagate as an MCP transport error.
pub(crate) enum CallOutcome<T> {
    Ready(T),
    NeedsBuild(ServerError),
}

/// Split a worker-call result into the normal value and the
/// "ran against an unbuilt dependency" case. The single chokepoint where
/// `verify_declaration`, `proof_state`, and `try_proof_step` recognise the
/// condition, so all three degrade on exactly the predicate `find_references`
/// already uses ([`missing_olean_failure`]). `find_references` does not call
/// this — its loop skips the file and continues rather than returning early —
/// but it shares the same predicate, so the policy lives in one place.
///
/// # Errors
///
/// Propagates any [`ServerError`] that is *not* a missing-`.olean` failure
/// unchanged; only the recoverable build-state case is captured as
/// [`CallOutcome::NeedsBuild`].
pub(crate) fn classify_missing_olean<T>(outcome: Result<T>) -> Result<CallOutcome<T>> {
    match outcome {
        Ok(value) => Ok(CallOutcome::Ready(value)),
        Err(err) if missing_olean_failure(&err) => Ok(CallOutcome::NeedsBuild(err)),
        Err(err) => Err(err),
    }
}

/// The canonical `(warning, next_action)` pair for an incomplete build. Pure so
/// it can be unit-tested without a [`Response`]. `project_root` comes from the
/// response freshness.
pub(crate) fn needs_build_text(project_root: &str, cause: &IncompleteCause) -> (String, String) {
    let detail = match cause {
        IncompleteCause::MissingImports(missing) if !missing.is_empty() => {
            format!(" (missing imports: {})", missing.join(", "))
        }
        IncompleteCause::MissingImports(_) => String::new(),
        IncompleteCause::MissingOlean(text) => format!(" (blocked: {})", first_line(text)),
    };
    let warning = format!(
        "the project may not be fully built, so this query ran against an incomplete environment. \
         Run `lake build` in {project_root} and resolve errors, then retry.{detail}"
    );
    let next_action = match cause {
        IncompleteCause::MissingImports(missing) if !missing.is_empty() => {
            format!("lake build {} # then retry", missing.join(" "))
        }
        IncompleteCause::MissingImports(_) | IncompleteCause::MissingOlean(_) => {
            "lake build # complete the project environment, then retry".to_owned()
        }
    };
    (warning, next_action)
}

/// Attach the canonical incomplete-build warning + `lake build` next action.
/// The single rendering point for the condition across all tools.
#[must_use]
pub(crate) fn warn_needs_build<T>(resp: Response<T>, cause: &IncompleteCause) -> Response<T>
where
    T: Serialize + JsonSchema,
{
    let (warning, next_action) = needs_build_text(&resp.freshness.project_root, cause);
    resp.with_trust_artifacts(needs_build_trust_artifacts(cause))
        .warn(warning)
        .hint(next_action)
}

fn needs_build_trust_artifacts(cause: &IncompleteCause) -> Vec<ArtifactTrust> {
    match cause {
        IncompleteCause::MissingImports(missing) if missing.is_empty() => {
            vec![ArtifactTrust::olean_project_missing_build(
                "project environment is missing built .olean artifacts",
            )]
        }
        IncompleteCause::MissingImports(missing) => missing
            .iter()
            .cloned()
            .map(ArtifactTrust::olean_module_missing_build)
            .collect(),
        IncompleteCause::MissingOlean(text) => {
            vec![ArtifactTrust::olean_project_missing_build(first_line(text))]
        }
    }
}

/// The canonical `(warning, next_action)` pair for a genuinely ambiguous name.
/// Pure for testing. `candidates` must be non-empty.
pub(crate) fn ambiguous_text(candidates: &[CompetingDecl]) -> (String, String) {
    let listed = candidates
        .iter()
        .map(|c| match &c.namespace {
            Some(ns) if !ns.is_empty() => format!("{} (namespace {ns})", c.name),
            _ => c.name.clone(),
        })
        .collect::<Vec<_>>()
        .join(", ");
    (
        format!("name is genuinely ambiguous; competing declarations: {listed}"),
        "fully-qualify the name to one of the competing declarations".to_owned(),
    )
}

/// Attach the genuine-ambiguity warning naming the competitors + a disambiguate
/// next action. No-op when `candidates` is empty (nothing actionable to say).
#[must_use]
pub(crate) fn warn_ambiguous<T>(resp: Response<T>, candidates: &[CompetingDecl]) -> Response<T>
where
    T: Serialize + JsonSchema,
{
    if candidates.is_empty() {
        return resp;
    }
    let (warning, next_action) = ambiguous_text(candidates);
    resp.warn(warning).hint(next_action)
}

/// The wire status token for a verdict computed while the worker was recycled
/// or crashed mid-call. One producer so every tool agrees on the spelling.
pub(crate) const WORKER_RECYCLED_STATUS: &str = "worker_recycled";

/// Recognise that this call's verdict was computed under infrastructure duress:
/// the worker was recycled or restarted *during* the call by a job-disrupting
/// cause, so a non-positive Lean verdict (a rejection, `not_found`, timeout) is
/// not reliable evidence about the declaration — it may be a casualty of the
/// recycle, not a real result.
///
/// Returns the `call_restart` event for a job-disrupting cause; `None`
/// otherwise. The benign causes are deliberately excluded:
/// - `rss_import_switch` / `import_profile_switch` cycle the worker *before* the
///   job runs, so the job then executes on a fresh worker — its verdict is
///   sound.
/// - `max_requests` / `max_imports` / `idle` / `explicit` are planned hygiene
///   cycles, not duress.
///
/// `rss_post_job` *is* job-disrupting even though it fires after the job
/// returned `Ok`: crossing the post-job RSS budget means the job elaborated
/// under heavy memory pressure, and report 62 §A documented that exact
/// condition degrading a verdict — a *valid* lemma returned `not_found` while
/// the worker sat 2.4 GiB over its 5 GiB cap. The worker returns a degraded
/// value rather than crashing, so "the job returned `Ok`" is not evidence the
/// verdict is sound. A `verified` verdict is never relabeled regardless, so a
/// marginal-overage false positive only costs a retry hint.
///
/// The cause strings mirror `crate::project::RestartCause::as_str`; the unit
/// test below pins the disrupting set so a new cause there is a conscious
/// decision here.
pub(crate) fn execution_taint(runtime: &RuntimeFacts) -> Option<&RuntimeRestartEvent> {
    let event = runtime.call_restart.as_ref()?;
    matches!(
        event.cause.as_str(),
        "rss_post_job"
            | "rss_hard_limit_exceeded"
            | "child_abort"
            | "child_exit"
            | "session_missing"
            | "worker_internal"
            | "timeout"
            | "cancelled"
    )
    .then_some(event)
}

/// The canonical `(warning, next_action)` pair for an execution-tainted verdict.
/// Pure so it can be unit-tested without a [`Response`]. Names the recycle cause
/// and, when known, the RSS numbers that explain it.
pub(crate) fn execution_taint_text(event: &RuntimeRestartEvent) -> (String, String) {
    let rss = match (event.rss_kib, event.limit_kib) {
        (Some(rss), Some(limit)) => format!(" (rss {rss} KiB vs limit {limit} KiB)"),
        (Some(rss), None) => format!(" (rss {rss} KiB)"),
        _ => String::new(),
    };
    let warning = format!(
        "the worker was recycled or restarted during this call ({cause}){rss}; any non-positive outcome here — a \
         rejection, `not_found`, a failed tactic, or empty goals — may be a casualty of the recycle rather than a real \
         result, and is not trustworthy. Retry; if it persists, the module is too heavy for the worker's memory budget \
         (raise LEAN_HOST_MCP_WORKER_RSS_POST_JOB_RESTART_KIB or verify with `lake build` / `lake env lean`).",
        cause = event.cause,
    );
    let next_action = "retry; if it persists, verify with `lake build <module>` or `lake env lean <file>`".to_owned();
    (warning, next_action)
}

/// Attach the canonical execution-taint warning + retry next action. The single
/// rendering point for the condition across `verify_declaration`,
/// `try_proof_step`, and `proof_state`.
#[must_use]
pub(crate) fn warn_execution_taint<T>(resp: Response<T>, event: &RuntimeRestartEvent) -> Response<T>
where
    T: Serialize + JsonSchema,
{
    let (warning, next_action) = execution_taint_text(event);
    resp.warn(warning).hint(next_action)
}

fn first_line(text: &str) -> &str {
    text.lines().next().unwrap_or(text).trim()
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn missing_olean_failure_recognizes_build_artifacts_not_domain_errors() {
        assert!(missing_olean_failure(&ServerError::Lean(
            "object file '/tmp/Missing/Import.olean' does not exist".to_owned()
        )));
        assert!(missing_olean_failure(&ServerError::Lean(
            "unknown module 'Definitely.Missing'".to_owned()
        )));
        assert!(!missing_olean_failure(&ServerError::Lean(
            "unknown declaration Foo.bar".to_owned()
        )));
    }

    #[test]
    fn needs_build_text_names_lake_build_and_avoids_the_word_ambiguous() {
        let (warning, next_action) = needs_build_text("/work/proj", &IncompleteCause::MissingImports(Vec::new()));
        assert!(warning.contains("lake build"));
        assert!(warning.contains("/work/proj"));
        assert!(!warning.to_lowercase().contains("ambiguous"));
        assert!(next_action.contains("lake build"));
    }

    #[test]
    fn needs_build_text_lists_missing_imports_in_warning_and_action() {
        let cause = IncompleteCause::MissingImports(vec!["Foo.Bar".to_owned(), "Baz".to_owned()]);
        let (warning, next_action) = needs_build_text("/work/proj", &cause);
        assert!(warning.contains("Foo.Bar"));
        assert!(warning.contains("missing imports"));
        assert!(next_action.contains("lake build Foo.Bar Baz"));
    }

    #[test]
    fn needs_build_text_includes_the_blocking_olean_for_missing_olean() {
        let cause = IncompleteCause::MissingOlean("object file '/p/Foo.olean' does not exist\nsecond line".to_owned());
        let (warning, _) = needs_build_text("/work/proj", &cause);
        assert!(warning.contains("Foo.olean"));
        assert!(!warning.contains("second line"));
    }

    #[test]
    fn classify_missing_olean_routes_only_build_artifacts_to_needs_build() {
        // A missing-`.olean` infrastructure failure becomes the recoverable
        // needs_build case.
        let missing: Result<()> = Err(ServerError::Lean(
            "object file '/p/Dep.olean' of module Dep does not exist".to_owned(),
        ));
        assert!(matches!(
            classify_missing_olean(missing),
            Ok(CallOutcome::NeedsBuild(_))
        ));

        // An unrelated infrastructure error still propagates.
        let other: Result<()> = Err(ServerError::Lean("worker thread gone".to_owned()));
        assert!(classify_missing_olean(other).is_err());

        // A success passes through untouched.
        assert!(matches!(classify_missing_olean(Ok(7)), Ok(CallOutcome::Ready(7))));
    }

    fn facts_with_restart(cause: &str) -> RuntimeFacts {
        RuntimeFacts {
            call_restart: Some(RuntimeRestartEvent {
                cause: cause.to_owned(),
                reason: format!("{cause} current_kib=7000000 limit_kib=5000000"),
                worker_generation: 9,
                planned: false,
                rss_kib: Some(7_000_000),
                limit_kib: Some(5_000_000),
            }),
            ..RuntimeFacts::default()
        }
    }

    #[test]
    fn execution_taint_flags_job_disrupting_causes_only() {
        // Job-disrupting causes taint the verdict.
        for cause in [
            "rss_post_job",
            "rss_hard_limit_exceeded",
            "child_abort",
            "child_exit",
            "session_missing",
            "worker_internal",
            "timeout",
            "cancelled",
        ] {
            assert!(
                execution_taint(&facts_with_restart(cause)).is_some(),
                "{cause} should taint the verdict"
            );
        }
        // A pre-job clean cycle and planned hygiene cycles do not: the job ran
        // on a fresh worker, or the cycle was routine.
        for cause in [
            "rss_import_switch",
            "import_profile_switch",
            "max_requests",
            "max_imports",
            "idle",
            "explicit",
        ] {
            assert!(
                execution_taint(&facts_with_restart(cause)).is_none(),
                "{cause} should not taint the verdict"
            );
        }
        // No restart at all: nothing to flag.
        assert!(execution_taint(&RuntimeFacts::default()).is_none());
    }

    #[test]
    fn execution_taint_text_names_cause_and_rss_and_offers_a_retry() {
        let (warning, next_action) = execution_taint_text(&RuntimeRestartEvent {
            cause: "rss_post_job".to_owned(),
            reason: String::new(),
            worker_generation: 1,
            planned: false,
            rss_kib: Some(7_600_000),
            limit_kib: Some(5_242_880),
        });
        assert!(warning.contains("rss_post_job"));
        assert!(warning.contains("7600000"));
        assert!(warning.contains("5242880"));
        assert!(warning.contains("not trustworthy"));
        assert!(next_action.contains("retry"));
    }

    #[test]
    fn ambiguous_text_names_each_competitor_with_its_namespace() {
        let candidates = vec![
            CompetingDecl {
                name: "boundary.isPushout".to_owned(),
                namespace: Some("SSet".to_owned()),
            },
            CompetingDecl {
                name: "boundary.isPushout".to_owned(),
                namespace: None,
            },
        ];
        let (warning, next_action) = ambiguous_text(&candidates);
        assert!(warning.contains("ambiguous"));
        assert!(warning.contains("namespace SSet"));
        assert!(next_action.contains("fully-qualify"));
    }
}