Skip to main content

lean_rs_worker_parent/
pool.rs

1//! Local worker-pool orchestration and session leasing.
2//!
3//! The pool sits above `LeanWorkerCapabilityBuilder` and typed commands. It
4//! chooses a compatible local child process for capability work, while callers
5//! only see session requirements and a lease that can run typed commands.
6
7use std::path::PathBuf;
8use std::thread;
9use std::time::{Duration, Instant};
10
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14
15use lean_rs_worker_protocol::types::{
16    LeanWorkerElabOptions, LeanWorkerImportStats, LeanWorkerModuleQueryBatchOutcome, LeanWorkerModuleQuerySelector,
17    LeanWorkerOutputBudgets, LeanWorkerResourceExhaustedFacts, LeanWorkerSessionImportProfile,
18};
19
20use crate::capability::{LeanWorkerCapability, LeanWorkerCapabilityBuilder};
21use crate::session::{
22    LeanWorkerCancellationToken, LeanWorkerDiagnosticSink, LeanWorkerJsonCommand, LeanWorkerProgressSink,
23    LeanWorkerRuntimeMetadata, LeanWorkerSession, LeanWorkerStreamingCommand, LeanWorkerTypedDataSink,
24    LeanWorkerTypedStreamSummary,
25};
26use crate::supervisor::{LeanWorkerError, LeanWorkerReplacementTiming, LeanWorkerRestartReason, LeanWorkerStatus};
27
28/// Coarse restart-policy class used in pool session keys.
29///
30/// The pool key records whether a session was opened under the default policy
31/// or a caller-selected policy class. It deliberately does not expose every
32/// restart-policy knob as key material; memory-aware scheduling and richer
33/// policy admission are not part of the session-key contract.
34#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
35pub enum LeanWorkerRestartPolicyClass {
36    Default,
37    Custom,
38}
39
40/// Worker reuse key for a capability-backed session.
41///
42/// A session key answers only one pool question: can an already-open child
43/// session safely host compatible work? The key includes both the capability
44/// project root and the import workspace root because one open session has one
45/// fixed Lean search path. It is not a downstream cache key, and it does not
46/// encode row schemas, cache validity, ranking, reporting, or source
47/// provenance.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct LeanWorkerSessionKey {
50    project_root: PathBuf,
51    import_workspace_root: PathBuf,
52    built_manifest_path: Option<PathBuf>,
53    package: String,
54    lib_name: String,
55    imports: Vec<String>,
56    import_profile: LeanWorkerSessionImportProfile,
57    metadata_expectation: Option<LeanWorkerMetadataExpectationKey>,
58    toolchain_fingerprint: lean_toolchain::ToolchainFingerprint,
59    restart_policy_class: LeanWorkerRestartPolicyClass,
60}
61
62impl LeanWorkerSessionKey {
63    /// Create a session key from the caller-visible capability requirements.
64    ///
65    /// `project_root` is the capability project root. The import workspace
66    /// root defaults to the same value; capability builders with a distinct
67    /// target workspace override it internally.
68    #[must_use]
69    pub fn new(
70        project_root: impl Into<PathBuf>,
71        package: impl Into<String>,
72        lib_name: impl Into<String>,
73        imports: impl IntoIterator<Item = impl Into<String>>,
74    ) -> Self {
75        let project_root = normalize_import_workspace_root(project_root.into());
76        Self {
77            import_workspace_root: normalize_import_workspace_root(project_root.clone()),
78            project_root,
79            built_manifest_path: None,
80            package: package.into(),
81            lib_name: lib_name.into(),
82            imports: imports.into_iter().map(Into::into).collect(),
83            import_profile: LeanWorkerSessionImportProfile::default(),
84            metadata_expectation: None,
85            toolchain_fingerprint: lean_toolchain::ToolchainFingerprint::current(),
86            restart_policy_class: LeanWorkerRestartPolicyClass::Default,
87        }
88    }
89
90    pub(crate) fn with_import_workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
91        self.import_workspace_root = normalize_import_workspace_root(root.into());
92        self
93    }
94
95    pub(crate) fn with_built_manifest_path(mut self, path: impl Into<PathBuf>) -> Self {
96        self.built_manifest_path = Some(normalize_import_workspace_root(path.into()));
97        self
98    }
99
100    pub(crate) fn with_import_profile(mut self, profile: LeanWorkerSessionImportProfile) -> Self {
101        self.import_profile = profile;
102        self
103    }
104
105    /// Add the metadata expectation used to decide safe session reuse.
106    ///
107    /// `expected` is downstream metadata transported by the generic metadata
108    /// envelope. The pool compares it as opaque facts and does not interpret
109    /// command names or semantic versions.
110    #[must_use]
111    pub fn metadata_expectation(
112        mut self,
113        export: impl Into<String>,
114        request: Value,
115        expected: Option<lean_rs_worker_protocol::types::LeanWorkerCapabilityMetadata>,
116    ) -> Self {
117        self.metadata_expectation = Some(LeanWorkerMetadataExpectationKey {
118            export: export.into(),
119            request,
120            expected,
121        });
122        self
123    }
124
125    /// Set the coarse restart-policy class for this session key.
126    #[must_use]
127    pub fn restart_policy_class(mut self, class: LeanWorkerRestartPolicyClass) -> Self {
128        self.restart_policy_class = class;
129        self
130    }
131
132    /// Return the capability Lake project root for this session key.
133    #[must_use]
134    pub fn project_root(&self) -> &std::path::Path {
135        &self.project_root
136    }
137
138    /// Return the target Lake workspace root whose dependency closure the session imports against.
139    #[must_use]
140    pub fn import_workspace_root(&self) -> &std::path::Path {
141        &self.import_workspace_root
142    }
143
144    /// Return the Lake package name for this session key.
145    #[must_use]
146    pub fn package(&self) -> &str {
147        &self.package
148    }
149
150    /// Return the Lake library target for this session key.
151    #[must_use]
152    pub fn lib_name(&self) -> &str {
153        &self.lib_name
154    }
155
156    /// Return the imports required by this session key.
157    #[must_use]
158    pub fn imports(&self) -> &[String] {
159        &self.imports
160    }
161
162    /// Return the import profile required by this session key.
163    #[must_use]
164    pub fn import_profile(&self) -> LeanWorkerSessionImportProfile {
165        self.import_profile
166    }
167
168    /// Return the build-baked Lean toolchain fingerprint used by this key.
169    #[must_use]
170    pub fn toolchain_fingerprint(&self) -> &lean_toolchain::ToolchainFingerprint {
171        &self.toolchain_fingerprint
172    }
173
174    /// Return the restart-policy class used by this key.
175    #[must_use]
176    pub fn policy_class(&self) -> LeanWorkerRestartPolicyClass {
177        self.restart_policy_class
178    }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182struct LeanWorkerMetadataExpectationKey {
183    export: String,
184    request: Value,
185    expected: Option<lean_rs_worker_protocol::types::LeanWorkerCapabilityMetadata>,
186}
187
188/// Configuration for a local `LeanWorkerPool`.
189///
190/// Long-running hosts should configure the worker count, total child RSS
191/// budget, per-worker RSS ceiling, and each worker's
192/// `LeanWorkerRestartPolicy` together. The pool admits cold distinct session
193/// keys; the worker restart policy bounds retained Lean state inside an
194/// admitted child.
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct LeanWorkerPoolConfig {
197    max_workers: usize,
198    max_total_child_rss_kib: Option<u64>,
199    per_worker_rss_ceiling_kib: Option<u64>,
200    idle_cycle_after: Option<Duration>,
201    queue_wait_timeout: Duration,
202}
203
204impl LeanWorkerPoolConfig {
205    /// Create pool configuration with a fixed local worker limit.
206    ///
207    /// `max_workers` is clamped to at least one. Production callers should
208    /// normally add [`Self::max_total_child_rss_kib`] and
209    /// [`Self::per_worker_rss_ceiling_kib`] rather than relying on the worker
210    /// count alone.
211    #[must_use]
212    pub fn new(max_workers: usize) -> Self {
213        Self {
214            max_workers: max_workers.max(1),
215            max_total_child_rss_kib: None,
216            per_worker_rss_ceiling_kib: None,
217            idle_cycle_after: None,
218            queue_wait_timeout: Duration::ZERO,
219        }
220    }
221
222    /// Return the maximum number of local child workers the pool may own.
223    #[must_use]
224    pub fn max_workers(&self) -> usize {
225        self.max_workers
226    }
227
228    /// Reject new distinct workers when known total child RSS reaches `limit`.
229    ///
230    /// RSS sampling is best effort. On platforms where the pool cannot obtain
231    /// samples, it records unavailable samples and does not make a false
232    /// admission claim. Size this with the worker count; for example, a host
233    /// that admits `n` children under a per-worker cap should normally set this
234    /// to `n * per_worker_rss_ceiling_kib`.
235    #[must_use]
236    pub fn max_total_child_rss_kib(mut self, limit: u64) -> Self {
237        self.max_total_child_rss_kib = Some(limit.max(1));
238        self
239    }
240
241    /// Cycle a worker before assigning work when its sampled RSS reaches `limit`.
242    ///
243    /// This is the pool-side assignment guard. The worker's own
244    /// `LeanWorkerRestartPolicy::memory_bounded` should use a compatible RSS
245    /// cap so fresh import-like requests are checked before entering the child.
246    #[must_use]
247    pub fn per_worker_rss_ceiling_kib(mut self, limit: u64) -> Self {
248        self.per_worker_rss_ceiling_kib = Some(limit.max(1));
249        self
250    }
251
252    /// Cycle an idle worker before assigning more work through an old lease.
253    #[must_use]
254    pub fn idle_cycle_after(mut self, limit: Duration) -> Self {
255        self.idle_cycle_after = Some(limit);
256        self
257    }
258
259    /// Wait this long for local pool admission before returning a typed error.
260    ///
261    /// The current pool is synchronous. This timeout documents and bounds the
262    /// admission point without exposing worker ids or queue internals.
263    #[must_use]
264    pub fn queue_wait_timeout(mut self, timeout: Duration) -> Self {
265        self.queue_wait_timeout = timeout;
266        self
267    }
268
269    /// Return the configured total child RSS budget in KiB.
270    #[must_use]
271    pub fn max_total_child_rss_kib_limit(&self) -> Option<u64> {
272        self.max_total_child_rss_kib
273    }
274
275    /// Return the configured per-worker RSS ceiling in KiB.
276    #[must_use]
277    pub fn per_worker_rss_ceiling_kib_limit(&self) -> Option<u64> {
278        self.per_worker_rss_ceiling_kib
279    }
280
281    /// Return the configured idle-cycle duration.
282    #[must_use]
283    pub fn idle_cycle_after_limit(&self) -> Option<Duration> {
284        self.idle_cycle_after
285    }
286
287    /// Return the configured pool admission wait timeout.
288    #[must_use]
289    pub fn queue_wait_timeout_limit(&self) -> Duration {
290        self.queue_wait_timeout
291    }
292}
293
294impl Default for LeanWorkerPoolConfig {
295    fn default() -> Self {
296        Self::new(1)
297    }
298}
299
300/// Summary of public pool state.
301///
302/// This snapshot exposes admission and reuse facts without revealing worker
303/// ids, child pids, pipe handles, or which warm child will be selected.
304#[derive(Clone, Debug, Eq, PartialEq)]
305pub struct LeanWorkerPoolSnapshot {
306    pub max_workers: usize,
307    pub workers: usize,
308    pub active_workers: usize,
309    pub warm_leases: usize,
310    pub queue_depth: usize,
311    pub total_child_rss_kib: Option<u64>,
312    pub rss_samples_unavailable: u64,
313    pub requests: u64,
314    pub imports: u64,
315    pub worker_restarts: u64,
316    pub max_request_restarts: u64,
317    pub max_import_restarts: u64,
318    pub rss_restarts: u64,
319    pub idle_restarts: u64,
320    pub cancelled_restarts: u64,
321    pub timeout_restarts: u64,
322    pub policy_restarts: u64,
323    pub queue_timeouts: u64,
324    pub memory_budget_rejections: u64,
325    pub cold_open_attempts: u64,
326    pub cold_open_admitted: u64,
327    pub cold_open_refusals: u64,
328    pub key_hits: u64,
329    pub key_misses: u64,
330    pub distinct_keys_seen: u64,
331    pub fresh_cold_opens_avoided: u64,
332    pub miss_empty_pool: u64,
333    pub miss_no_matching_key: u64,
334    pub last_key_miss_reason: Option<String>,
335    pub import_like_requests: u64,
336    pub concurrent_cold_opens_observed: u64,
337    pub rss_before_admission_kib: Option<u64>,
338    pub rss_after_open_kib: Option<u64>,
339    pub refusal_reason: Option<String>,
340    pub replacement_attempts: u64,
341    pub replacement_successes: u64,
342    pub replacement_failures: u64,
343    pub replacement_budget_admitted: u64,
344    pub replacement_budget_skipped: u64,
345    pub last_replacement_timing: Option<LeanWorkerReplacementTiming>,
346    pub last_replacement_skipped_reason: Option<String>,
347    pub last_spawn_handshake_elapsed: Option<Duration>,
348    pub last_capability_load_elapsed: Option<Duration>,
349    pub last_session_open_import_elapsed: Option<Duration>,
350    pub last_first_command_elapsed: Option<Duration>,
351    pub last_warm_command_elapsed: Option<Duration>,
352    pub last_restart_reason: Option<LeanWorkerRestartReason>,
353    pub last_import_stats: Option<LeanWorkerImportStats>,
354    pub stream_requests: u64,
355    pub stream_successes: u64,
356    pub stream_failures: u64,
357    pub data_rows_delivered: u64,
358    pub data_row_payload_bytes: u64,
359    pub stream_elapsed: Duration,
360    pub backpressure_waits: u64,
361    pub backpressure_failures: u64,
362}
363
364/// Local pool for worker-backed capability sessions.
365#[derive(Debug)]
366pub struct LeanWorkerPool {
367    config: LeanWorkerPoolConfig,
368    entries: Vec<PoolEntry>,
369    queue_timeouts: u64,
370    memory_budget_rejections: u64,
371    cold_open_attempts: u64,
372    cold_open_admitted: u64,
373    cold_open_refusals: u64,
374    key_hits: u64,
375    key_misses: u64,
376    seen_keys: Vec<LeanWorkerSessionKey>,
377    fresh_cold_opens_avoided: u64,
378    miss_empty_pool: u64,
379    miss_no_matching_key: u64,
380    last_key_miss_reason: Option<String>,
381    cold_opens_in_progress: u64,
382    concurrent_cold_opens_observed: u64,
383    rss_before_admission_kib: Option<u64>,
384    rss_after_open_kib: Option<u64>,
385    refusal_reason: Option<String>,
386}
387
388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
389enum PoolAdmissionDecision {
390    ReuseWarmEntry { index: usize },
391    OpenColdEntry,
392}
393
394fn grant_lease<'pool>(entry: &'pool mut PoolEntry, config: &LeanWorkerPoolConfig) -> LeanWorkerSessionLease<'pool> {
395    let admitted_generation = entry.capability.lifecycle_snapshot().worker_generation;
396    entry.active_leases = entry.active_leases.saturating_add(1);
397    LeanWorkerSessionLease {
398        entry,
399        config: config.clone(),
400        valid: true,
401        invalidation_reason: None,
402        request_timeout_override: None,
403        admitted_generation,
404        released: false,
405    }
406}
407
408impl LeanWorkerPool {
409    /// Create an empty local worker pool.
410    #[must_use]
411    pub fn new(config: LeanWorkerPoolConfig) -> Self {
412        Self {
413            config,
414            entries: Vec::new(),
415            queue_timeouts: 0,
416            memory_budget_rejections: 0,
417            cold_open_attempts: 0,
418            cold_open_admitted: 0,
419            cold_open_refusals: 0,
420            key_hits: 0,
421            key_misses: 0,
422            seen_keys: Vec::new(),
423            fresh_cold_opens_avoided: 0,
424            miss_empty_pool: 0,
425            miss_no_matching_key: 0,
426            last_key_miss_reason: None,
427            cold_opens_in_progress: 0,
428            concurrent_cold_opens_observed: 0,
429            rss_before_admission_kib: None,
430            rss_after_open_kib: None,
431            refusal_reason: None,
432        }
433    }
434
435    /// Acquire a lease for the capability described by `builder`.
436    ///
437    /// The pool reuses a warm compatible worker session when possible. If a
438    /// matching worker has died, the pool replaces it before returning the
439    /// lease. If admitting a distinct session key would exceed `max_workers`,
440    /// the pool returns `LeanWorkerError::WorkerPoolExhausted`.
441    ///
442    /// # Errors
443    ///
444    /// Returns `LeanWorkerError` when the capability cannot be built, metadata
445    /// validation fails, a dead compatible worker cannot be replaced, or the
446    /// fixed local worker limit is already full of distinct session keys.
447    pub fn acquire_lease(
448        &mut self,
449        builder: LeanWorkerCapabilityBuilder,
450    ) -> Result<LeanWorkerSessionLease<'_>, LeanWorkerError> {
451        let key = builder.session_key();
452        match self.admit_lease(&key)? {
453            PoolAdmissionDecision::ReuseWarmEntry { index } => {
454                self.ensure_entry_running(index)?;
455                self.enforce_entry_policy_before_assignment(index)?;
456                let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
457                    message: "worker pool entry disappeared during lease acquisition".to_owned(),
458                })?;
459                return Ok(grant_lease(entry, &self.config));
460            }
461            PoolAdmissionDecision::OpenColdEntry => {}
462        }
463
464        let capability = match builder.clone().open() {
465            Ok(capability) => capability,
466            Err(err) => {
467                self.cold_opens_in_progress = self.cold_opens_in_progress.saturating_sub(1);
468                return Err(err);
469            }
470        };
471        self.cold_opens_in_progress = self.cold_opens_in_progress.saturating_sub(1);
472        let base_request_timeout = builder.pool_request_timeout();
473        self.entries.push(PoolEntry {
474            key,
475            builder,
476            capability,
477            base_request_timeout,
478            last_rss_kib: None,
479            rss_samples_unavailable: 0,
480            last_activity: Instant::now(),
481            last_restart_reason: None,
482            policy_restarts: 0,
483            active_leases: 0,
484            commands_since_open: 0,
485        });
486        let index = self
487            .entries
488            .len()
489            .checked_sub(1)
490            .ok_or_else(|| LeanWorkerError::Protocol {
491                message: "worker pool failed to retain newly opened entry".to_owned(),
492            })?;
493        let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
494            message: "worker pool failed to retain newly opened entry".to_owned(),
495        })?;
496        let _ = entry.sample_rss();
497        self.rss_after_open_kib = total_known_child_rss_kib(&self.entries);
498        let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
499            message: "worker pool failed to retain newly opened entry".to_owned(),
500        })?;
501        Ok(grant_lease(entry, &self.config))
502    }
503
504    fn admit_lease(&mut self, key: &LeanWorkerSessionKey) -> Result<PoolAdmissionDecision, LeanWorkerError> {
505        self.remember_seen_key(key);
506        if let Some(index) = self.entries.iter().position(|entry| &entry.key == key) {
507            self.record_key_hit();
508            return Ok(PoolAdmissionDecision::ReuseWarmEntry { index });
509        }
510
511        let miss_reason = if self.entries.is_empty() {
512            "empty_pool"
513        } else {
514            "no_matching_key"
515        };
516        self.record_key_miss(miss_reason);
517        self.record_cold_open_attempt();
518        let rss_before_admission = self.refresh_total_child_rss();
519        self.rss_before_admission_kib = rss_before_admission.available_total();
520        if self.entries.len() >= self.config.max_workers {
521            return self.pool_full_error();
522        }
523        self.ensure_spawn_within_total_rss_budget(rss_before_admission)?;
524
525        self.record_cold_open_admitted();
526        Ok(PoolAdmissionDecision::OpenColdEntry)
527    }
528
529    /// Return a public snapshot of pool state.
530    #[must_use]
531    pub fn snapshot(&self) -> LeanWorkerPoolSnapshot {
532        snapshot_from_entries(
533            &self.config,
534            &self.entries,
535            self.queue_timeouts,
536            self.memory_budget_rejections,
537            self.cold_open_attempts,
538            self.cold_open_admitted,
539            self.cold_open_refusals,
540            self.key_hits,
541            self.key_misses,
542            u64::try_from(self.seen_keys.len()).unwrap_or(u64::MAX),
543            self.fresh_cold_opens_avoided,
544            self.miss_empty_pool,
545            self.miss_no_matching_key,
546            self.last_key_miss_reason.clone(),
547            self.concurrent_cold_opens_observed,
548            self.rss_before_admission_kib,
549            self.rss_after_open_kib,
550            self.refusal_reason.clone(),
551        )
552    }
553
554    fn snapshot_from_lease_config(config: &LeanWorkerPoolConfig, entry: &PoolEntry) -> LeanWorkerPoolSnapshot {
555        snapshot_from_entries(
556            config,
557            std::slice::from_ref(entry),
558            0,
559            0,
560            0,
561            0,
562            0,
563            0,
564            0,
565            0,
566            0,
567            0,
568            0,
569            None,
570            0,
571            None,
572            None,
573            None,
574        )
575    }
576}
577
578fn snapshot_from_entries(
579    config: &LeanWorkerPoolConfig,
580    entries: &[PoolEntry],
581    queue_timeouts: u64,
582    memory_budget_rejections: u64,
583    cold_open_attempts: u64,
584    cold_open_admitted: u64,
585    cold_open_refusals: u64,
586    key_hits: u64,
587    key_misses: u64,
588    distinct_keys_seen: u64,
589    fresh_cold_opens_avoided: u64,
590    miss_empty_pool: u64,
591    miss_no_matching_key: u64,
592    last_key_miss_reason: Option<String>,
593    concurrent_cold_opens_observed: u64,
594    rss_before_admission_kib: Option<u64>,
595    rss_after_open_kib: Option<u64>,
596    refusal_reason: Option<String>,
597) -> LeanWorkerPoolSnapshot {
598    LeanWorkerPoolSnapshot {
599        max_workers: config.max_workers,
600        workers: entries.len(),
601        active_workers: entries.iter().filter(|entry| entry.active_leases > 0).count(),
602        warm_leases: entries.iter().filter(|entry| entry.active_leases == 0).count(),
603        queue_depth: 0,
604        total_child_rss_kib: total_known_child_rss_kib(entries),
605        rss_samples_unavailable: entries.iter().map(|entry| entry.rss_samples_unavailable).sum(),
606        requests: entries.iter().map(|entry| entry.capability.stats().requests).sum(),
607        imports: entries.iter().map(|entry| entry.capability.stats().imports).sum(),
608        worker_restarts: entries.iter().map(|entry| entry.capability.stats().restarts).sum(),
609        max_request_restarts: entries
610            .iter()
611            .map(|entry| entry.capability.stats().max_request_restarts)
612            .sum(),
613        max_import_restarts: entries
614            .iter()
615            .map(|entry| entry.capability.stats().max_import_restarts)
616            .sum(),
617        rss_restarts: entries.iter().map(|entry| entry.capability.stats().rss_restarts).sum(),
618        idle_restarts: entries.iter().map(|entry| entry.capability.stats().idle_restarts).sum(),
619        cancelled_restarts: entries
620            .iter()
621            .map(|entry| entry.capability.stats().cancelled_restarts)
622            .sum(),
623        timeout_restarts: entries
624            .iter()
625            .map(|entry| entry.capability.stats().timeout_restarts)
626            .sum(),
627        policy_restarts: entries.iter().map(|entry| entry.policy_restarts).sum(),
628        queue_timeouts,
629        memory_budget_rejections,
630        cold_open_attempts,
631        cold_open_admitted,
632        cold_open_refusals,
633        key_hits,
634        key_misses,
635        distinct_keys_seen,
636        fresh_cold_opens_avoided,
637        miss_empty_pool,
638        miss_no_matching_key,
639        last_key_miss_reason,
640        import_like_requests: entries
641            .iter()
642            .map(|entry| entry.capability.stats().import_like_admission_attempts)
643            .sum(),
644        concurrent_cold_opens_observed,
645        rss_before_admission_kib,
646        rss_after_open_kib,
647        refusal_reason,
648        replacement_attempts: entries
649            .iter()
650            .map(|entry| entry.capability.stats().replacement_attempts)
651            .sum(),
652        replacement_successes: entries
653            .iter()
654            .map(|entry| entry.capability.stats().replacement_successes)
655            .sum(),
656        replacement_failures: entries
657            .iter()
658            .map(|entry| entry.capability.stats().replacement_failures)
659            .sum(),
660        replacement_budget_admitted: entries
661            .iter()
662            .map(|entry| entry.capability.stats().replacement_budget_admitted)
663            .sum(),
664        replacement_budget_skipped: entries
665            .iter()
666            .map(|entry| entry.capability.stats().replacement_budget_skipped)
667            .sum(),
668        last_replacement_timing: entries
669            .iter()
670            .rev()
671            .find_map(|entry| entry.capability.stats().last_replacement_timing),
672        last_replacement_skipped_reason: entries
673            .iter()
674            .rev()
675            .find_map(|entry| entry.capability.stats().last_replacement_skipped_reason),
676        last_spawn_handshake_elapsed: entries
677            .iter()
678            .rev()
679            .find_map(|entry| entry.capability.stats().last_spawn_handshake_elapsed),
680        last_capability_load_elapsed: entries
681            .iter()
682            .rev()
683            .find_map(|entry| entry.capability.stats().last_capability_load_elapsed),
684        last_session_open_import_elapsed: entries
685            .iter()
686            .rev()
687            .find_map(|entry| entry.capability.stats().last_session_open_import_elapsed),
688        last_first_command_elapsed: entries
689            .iter()
690            .rev()
691            .find_map(|entry| entry.capability.stats().last_first_command_elapsed),
692        last_warm_command_elapsed: entries
693            .iter()
694            .rev()
695            .find_map(|entry| entry.capability.stats().last_warm_command_elapsed),
696        last_restart_reason: entries.iter().rev().find_map(|entry| entry.last_restart_reason.clone()),
697        last_import_stats: entries
698            .iter()
699            .rev()
700            .find_map(|entry| entry.capability.stats().last_import_stats),
701        stream_requests: entries
702            .iter()
703            .map(|entry| entry.capability.stats().stream_requests)
704            .sum(),
705        stream_successes: entries
706            .iter()
707            .map(|entry| entry.capability.stats().stream_successes)
708            .sum(),
709        stream_failures: entries
710            .iter()
711            .map(|entry| entry.capability.stats().stream_failures)
712            .sum(),
713        data_rows_delivered: entries
714            .iter()
715            .map(|entry| entry.capability.stats().data_rows_delivered)
716            .sum(),
717        data_row_payload_bytes: entries
718            .iter()
719            .map(|entry| entry.capability.stats().data_row_payload_bytes)
720            .sum(),
721        stream_elapsed: entries.iter().fold(Duration::ZERO, |acc, entry| {
722            acc.saturating_add(entry.capability.stats().stream_elapsed)
723        }),
724        backpressure_waits: entries
725            .iter()
726            .map(|entry| entry.capability.stats().backpressure_waits)
727            .sum(),
728        backpressure_failures: entries
729            .iter()
730            .map(|entry| entry.capability.stats().backpressure_failures)
731            .sum(),
732    }
733}
734
735fn total_known_child_rss_kib(entries: &[PoolEntry]) -> Option<u64> {
736    entries
737        .iter()
738        .map(|entry| entry.last_rss_kib)
739        .try_fold(0_u64, |acc, value| value.map(|rss| acc.saturating_add(rss)))
740}
741
742impl LeanWorkerPool {
743    fn ensure_entry_running(&mut self, index: usize) -> Result<(), LeanWorkerError> {
744        let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
745            message: "worker pool entry disappeared during liveness check".to_owned(),
746        })?;
747        match entry.capability.status()? {
748            LeanWorkerStatus::Running => Ok(()),
749            LeanWorkerStatus::Exited(_exit) => {
750                entry.capability = entry.builder.clone().open()?;
751                entry.last_activity = Instant::now();
752                Ok(())
753            }
754        }
755    }
756
757    fn enforce_entry_policy_before_assignment(&mut self, index: usize) -> Result<(), LeanWorkerError> {
758        let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
759            message: "worker pool entry disappeared during policy check".to_owned(),
760        })?;
761        entry.enforce_policy(&self.config).map(|_| ())
762    }
763
764    fn ensure_spawn_within_total_rss_budget(&mut self, rss: PoolRssTotal) -> Result<(), LeanWorkerError> {
765        let Some(limit_kib) = self.config.max_total_child_rss_kib else {
766            return Ok(());
767        };
768        if rss.unavailable > 0 {
769            return Ok(());
770        }
771        if rss.total_kib >= limit_kib {
772            self.memory_budget_rejections = self.memory_budget_rejections.saturating_add(1);
773            self.record_cold_open_refusal("rss_budget");
774            let last_import_stats = self.latest_import_stats();
775            return Err(LeanWorkerError::WorkerPoolMemoryBudgetExceeded {
776                current_kib: rss.total_kib,
777                limit_kib,
778                last_import_stats: last_import_stats.clone().map(Box::new),
779                resource: Box::new(self.pool_resource_facts(
780                    "worker_pool_total_rss_budget",
781                    Some(rss.total_kib),
782                    Some(limit_kib),
783                    None,
784                    last_import_stats,
785                )),
786            });
787        }
788        Ok(())
789    }
790
791    fn record_cold_open_attempt(&mut self) {
792        self.cold_open_attempts = self.cold_open_attempts.saturating_add(1);
793        self.refusal_reason = None;
794        if self.cold_opens_in_progress > 0 {
795            self.concurrent_cold_opens_observed = self.concurrent_cold_opens_observed.saturating_add(1);
796        }
797    }
798
799    fn record_cold_open_admitted(&mut self) {
800        self.cold_open_admitted = self.cold_open_admitted.saturating_add(1);
801        self.cold_opens_in_progress = self.cold_opens_in_progress.saturating_add(1);
802    }
803
804    fn record_cold_open_refusal(&mut self, reason: impl Into<String>) {
805        self.cold_open_refusals = self.cold_open_refusals.saturating_add(1);
806        self.refusal_reason = Some(reason.into());
807    }
808
809    fn remember_seen_key(&mut self, key: &LeanWorkerSessionKey) {
810        if self.seen_keys.iter().all(|seen| seen != key) {
811            self.seen_keys.push(key.clone());
812        }
813    }
814
815    fn record_key_hit(&mut self) {
816        self.key_hits = self.key_hits.saturating_add(1);
817        self.fresh_cold_opens_avoided = self.fresh_cold_opens_avoided.saturating_add(1);
818        self.last_key_miss_reason = None;
819    }
820
821    fn record_key_miss(&mut self, reason: &'static str) {
822        self.key_misses = self.key_misses.saturating_add(1);
823        match reason {
824            "empty_pool" => self.miss_empty_pool = self.miss_empty_pool.saturating_add(1),
825            "no_matching_key" => self.miss_no_matching_key = self.miss_no_matching_key.saturating_add(1),
826            _ => {}
827        }
828        self.last_key_miss_reason = Some(reason.to_owned());
829    }
830
831    fn latest_import_stats(&self) -> Option<LeanWorkerImportStats> {
832        self.entries
833            .iter()
834            .find_map(|entry| entry.capability.stats().last_import_stats)
835    }
836
837    fn pool_resource_facts(
838        &self,
839        cause: &str,
840        current_rss_kib: Option<u64>,
841        limit_kib: Option<u64>,
842        queue_wait: Option<Duration>,
843        last_import_stats: Option<LeanWorkerImportStats>,
844    ) -> LeanWorkerResourceExhaustedFacts {
845        let restart_reason = self
846            .entries
847            .iter()
848            .find_map(|entry| entry.capability.stats().last_restart_reason)
849            .map(|reason| reason.stable_cause().to_owned());
850        let import_like_requests = self
851            .entries
852            .iter()
853            .map(|entry| entry.capability.stats().import_like_admission_attempts)
854            .fold(0_u64, u64::saturating_add);
855        LeanWorkerResourceExhaustedFacts {
856            cause: cause.to_owned(),
857            work_entered_child: false,
858            operation: Some("worker_pool_acquire_lease".to_owned()),
859            current_rss_kib,
860            limit_kib,
861            import_count: None,
862            worker_generation: None,
863            restart_reason,
864            queue_wait_ms: queue_wait.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)),
865            duration_ms: None,
866            cold_open_attempts: Some(self.cold_open_attempts),
867            cold_open_admitted: Some(self.cold_open_admitted),
868            cold_open_refusals: Some(self.cold_open_refusals),
869            import_like_requests: Some(import_like_requests),
870            import_like_admitted: None,
871            last_import_stats,
872        }
873    }
874
875    fn refresh_total_child_rss(&mut self) -> PoolRssTotal {
876        let mut total_kib = 0_u64;
877        let mut unavailable = 0_u64;
878        for entry in &mut self.entries {
879            match entry.sample_rss() {
880                Some(value) => {
881                    total_kib = total_kib.saturating_add(value);
882                }
883                None => {
884                    unavailable = unavailable.saturating_add(1);
885                }
886            }
887        }
888        PoolRssTotal { total_kib, unavailable }
889    }
890
891    fn pool_full_error<T>(&mut self) -> Result<T, LeanWorkerError> {
892        if self.config.queue_wait_timeout.is_zero() {
893            self.record_cold_open_refusal("max_workers");
894            return Err(LeanWorkerError::WorkerPoolExhausted {
895                max_workers: self.config.max_workers,
896                resource: Box::new(self.pool_resource_facts(
897                    "worker_pool_max_workers",
898                    None,
899                    None,
900                    None,
901                    self.latest_import_stats(),
902                )),
903            });
904        }
905        let started = Instant::now();
906        while started.elapsed() < self.config.queue_wait_timeout {
907            let remaining = self.config.queue_wait_timeout.saturating_sub(started.elapsed());
908            thread::sleep(remaining.min(Duration::from_millis(10)));
909        }
910        self.queue_timeouts = self.queue_timeouts.saturating_add(1);
911        self.record_cold_open_refusal("queue_timeout");
912        Err(LeanWorkerError::WorkerPoolQueueTimeout {
913            waited: self.config.queue_wait_timeout,
914            resource: Box::new(self.pool_resource_facts(
915                "worker_pool_queue_timeout",
916                None,
917                None,
918                Some(self.config.queue_wait_timeout),
919                self.latest_import_stats(),
920            )),
921        })
922    }
923}
924
925impl Default for LeanWorkerPool {
926    fn default() -> Self {
927        Self::new(LeanWorkerPoolConfig::default())
928    }
929}
930
931#[derive(Debug)]
932struct PoolEntry {
933    key: LeanWorkerSessionKey,
934    builder: LeanWorkerCapabilityBuilder,
935    capability: LeanWorkerCapability,
936    base_request_timeout: Duration,
937    last_rss_kib: Option<u64>,
938    rss_samples_unavailable: u64,
939    last_activity: Instant,
940    last_restart_reason: Option<LeanWorkerRestartReason>,
941    policy_restarts: u64,
942    active_leases: u64,
943    commands_since_open: u64,
944}
945
946impl PoolEntry {
947    fn sample_rss(&mut self) -> Option<u64> {
948        match self.capability.rss_kib() {
949            Some(value) => {
950                self.last_rss_kib = Some(value);
951                Some(value)
952            }
953            None => {
954                self.rss_samples_unavailable = self.rss_samples_unavailable.saturating_add(1);
955                None
956            }
957        }
958    }
959
960    fn enforce_policy(&mut self, config: &LeanWorkerPoolConfig) -> Result<Option<String>, LeanWorkerError> {
961        if let Some(limit_kib) = config.per_worker_rss_ceiling_kib {
962            match self.sample_rss() {
963                Some(current_kib) if current_kib >= limit_kib => {
964                    let reason = LeanWorkerRestartReason::RssCeiling {
965                        current_kib,
966                        limit_kib,
967                        last_import_stats: self.capability.stats().last_import_stats,
968                    };
969                    self.cycle_for_policy(reason)?;
970                    return Ok(Some(format!(
971                        "memory policy cycled worker at {current_kib} KiB RSS with limit {limit_kib} KiB"
972                    )));
973                }
974                Some(_) | None => {}
975            }
976        }
977
978        if let Some(limit) = config.idle_cycle_after {
979            let idle_for = self.last_activity.elapsed();
980            if idle_for >= limit {
981                let reason = LeanWorkerRestartReason::Idle { idle_for, limit };
982                self.cycle_for_policy(reason)?;
983                return Ok(Some(format!(
984                    "idle policy cycled worker after {idle_for:?} idle with limit {limit:?}"
985                )));
986            }
987        }
988
989        Ok(None)
990    }
991
992    fn cycle_for_policy(&mut self, reason: LeanWorkerRestartReason) -> Result<(), LeanWorkerError> {
993        self.capability.cycle_with_restart_reason(reason.clone())?;
994        self.last_restart_reason = Some(reason);
995        self.last_activity = Instant::now();
996        self.last_rss_kib = None;
997        self.policy_restarts = self.policy_restarts.saturating_add(1);
998        self.commands_since_open = 0;
999        Ok(())
1000    }
1001}
1002
1003#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1004struct PoolRssTotal {
1005    total_kib: u64,
1006    unavailable: u64,
1007}
1008
1009impl PoolRssTotal {
1010    fn available_total(self) -> Option<u64> {
1011        if self.unavailable == 0 {
1012            Some(self.total_kib)
1013        } else {
1014            None
1015        }
1016    }
1017}
1018
1019/// Borrowed lease for running typed commands on a compatible worker session.
1020///
1021/// The lease does not expose which worker was selected. If a command triggers
1022/// timeout, cancellation, child failure, or explicit cycle, the lease becomes
1023/// invalid and a fresh lease must be acquired from the pool.
1024#[derive(Debug)]
1025pub struct LeanWorkerSessionLease<'pool> {
1026    entry: &'pool mut PoolEntry,
1027    config: LeanWorkerPoolConfig,
1028    valid: bool,
1029    invalidation_reason: Option<String>,
1030    request_timeout_override: Option<Duration>,
1031    admitted_generation: u64,
1032    released: bool,
1033}
1034
1035impl LeanWorkerSessionLease<'_> {
1036    /// Return the session key that justified this lease.
1037    #[must_use]
1038    pub fn session_key(&self) -> &LeanWorkerSessionKey {
1039        &self.entry.key
1040    }
1041
1042    /// Return protocol/runtime facts reported by the leased worker child.
1043    #[must_use]
1044    pub fn runtime_metadata(&self) -> LeanWorkerRuntimeMetadata {
1045        self.entry.capability.runtime_metadata()
1046    }
1047
1048    /// Return whether this lease can still run commands.
1049    #[must_use]
1050    pub fn is_valid(&self) -> bool {
1051        self.valid
1052    }
1053
1054    /// Explicitly release this lease back to the pool.
1055    ///
1056    /// Dropping the lease performs the same release. This method exists for
1057    /// hosts that want the release point to be visible in their control flow.
1058    pub fn release(mut self) {
1059        self.release_once();
1060    }
1061
1062    /// Return an operational snapshot for the worker entry behind this lease.
1063    ///
1064    /// This is the sampling hook to use while a lease is checked out. It keeps
1065    /// child identity, pipe state, and protocol details hidden; the snapshot
1066    /// only reports the same aggregate counters as `LeanWorkerPool::snapshot`
1067    /// for the leased entry.
1068    #[must_use]
1069    pub fn snapshot(&self) -> LeanWorkerPoolSnapshot {
1070        LeanWorkerPool::snapshot_from_lease_config(&self.config, self.entry)
1071    }
1072
1073    /// Explicitly cycle the leased worker and invalidate this lease.
1074    ///
1075    /// Acquire a fresh lease before running more work. The pool keeps the
1076    /// restarted child available for compatible future leases.
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns `LeanWorkerError` if the lease was already invalid or the
1081    /// underlying worker cannot be cycled.
1082    pub fn cycle(&mut self) -> Result<(), LeanWorkerError> {
1083        self.ensure_valid()?;
1084        self.entry.capability.cycle()?;
1085        self.invalidate("explicit worker cycle");
1086        Ok(())
1087    }
1088
1089    /// Set the request timeout for commands run through this lease.
1090    ///
1091    /// The pool and supervisor still own the watchdog, child kill, and restart
1092    /// bookkeeping. This method only selects the deadline for subsequent
1093    /// leased requests.
1094    ///
1095    /// # Errors
1096    ///
1097    /// Returns `LeanWorkerError` if the lease was already invalidated.
1098    pub fn set_request_timeout(&mut self, timeout: Duration) -> Result<(), LeanWorkerError> {
1099        self.ensure_valid()?;
1100        self.request_timeout_override = Some(timeout);
1101        Ok(())
1102    }
1103
1104    /// Run a typed non-streaming downstream JSON command through this lease.
1105    ///
1106    /// # Errors
1107    ///
1108    /// Returns `LeanWorkerError` for invalidated leases, session startup
1109    /// failures, typed command errors, cancellation, timeout, child failure,
1110    /// progress panic, or protocol failure.
1111    pub fn run_json_command<Req, Resp>(
1112        &mut self,
1113        command: &LeanWorkerJsonCommand<Req, Resp>,
1114        request: &Req,
1115        cancellation: Option<&LeanWorkerCancellationToken>,
1116        progress: Option<&dyn LeanWorkerProgressSink>,
1117    ) -> Result<Resp, LeanWorkerError>
1118    where
1119        Req: Serialize,
1120        Resp: DeserializeOwned,
1121    {
1122        self.run_with_current_session(cancellation, progress, |session| {
1123            session.run_json_command(command, request, cancellation, progress)
1124        })
1125    }
1126
1127    /// Run a typed downstream streaming command through this lease.
1128    ///
1129    /// # Errors
1130    ///
1131    /// Returns `LeanWorkerError` for invalidated leases, row or summary decode
1132    /// errors, sink failures, cancellation, timeout, child failure, or protocol
1133    /// failure.
1134    pub fn run_streaming_command<Req, Row, Summary>(
1135        &mut self,
1136        command: &LeanWorkerStreamingCommand<Req, Row, Summary>,
1137        request: &Req,
1138        rows: &dyn LeanWorkerTypedDataSink<Row>,
1139        diagnostics: Option<&dyn LeanWorkerDiagnosticSink>,
1140        cancellation: Option<&LeanWorkerCancellationToken>,
1141        progress: Option<&dyn LeanWorkerProgressSink>,
1142    ) -> Result<LeanWorkerTypedStreamSummary<Summary>, LeanWorkerError>
1143    where
1144        Req: Serialize,
1145        Row: DeserializeOwned,
1146        Summary: DeserializeOwned,
1147    {
1148        self.run_with_current_session(cancellation, progress, |session| {
1149            session.run_streaming_command(command, request, rows, diagnostics, cancellation, progress)
1150        })
1151    }
1152
1153    /// Parse and elaborate a Lean module once, returning bounded selector
1154    /// projections through the already-open worker session behind this lease.
1155    ///
1156    /// This is the warm proof-agent batch path for pool callers. It keeps
1157    /// worker identity, session reopening, policy checks, timeout handling,
1158    /// and `session_missing` retry inside the lease lifecycle wrapper.
1159    /// Batching here reduces request/session churn on a warm child; it does
1160    /// not reclaim Lean import memory or replace worker cycling for full
1161    /// `loadExts := true` sessions.
1162    ///
1163    /// # Errors
1164    ///
1165    /// Returns `LeanWorkerError` for invalidated leases, session startup
1166    /// failures, cancellation, timeout, child failure, progress panic, or
1167    /// protocol failure. Header-parse failures, missing imports, selector
1168    /// unavailability, and budget exhaustion surface in the returned
1169    /// [`LeanWorkerModuleQueryBatchOutcome`].
1170    pub fn process_module_query_batch(
1171        &mut self,
1172        source: &str,
1173        selectors: &[LeanWorkerModuleQuerySelector],
1174        budgets: &LeanWorkerOutputBudgets,
1175        options: &LeanWorkerElabOptions,
1176        cancellation: Option<&LeanWorkerCancellationToken>,
1177        progress: Option<&dyn LeanWorkerProgressSink>,
1178    ) -> Result<LeanWorkerModuleQueryBatchOutcome, LeanWorkerError> {
1179        self.run_with_current_session(cancellation, progress, |session| {
1180            session.process_module_query_batch(source, selectors, budgets, options, cancellation, progress)
1181        })
1182    }
1183
1184    fn run_with_current_session<T>(
1185        &mut self,
1186        cancellation: Option<&LeanWorkerCancellationToken>,
1187        progress: Option<&dyn LeanWorkerProgressSink>,
1188        mut command: impl FnMut(&mut LeanWorkerSession<'_>) -> Result<T, LeanWorkerError>,
1189    ) -> Result<T, LeanWorkerError> {
1190        self.ensure_valid()?;
1191        self.enforce_policy_before_request()?;
1192        let request_timeout = self.request_timeout_override;
1193        let first_command_after_open = self.entry.commands_since_open == 0;
1194        let command_started = Instant::now();
1195        let result = {
1196            let mut session = self.entry.capability.attach_open_session();
1197            if let Some(timeout) = request_timeout {
1198                session.set_request_timeout(timeout);
1199            }
1200            command(&mut session)
1201        };
1202        let result = match result {
1203            Err(err) if worker_session_missing(&err) => self
1204                .entry
1205                .capability
1206                .open_session(cancellation, progress)
1207                .and_then(|mut session| {
1208                    if let Some(timeout) = request_timeout {
1209                        session.set_request_timeout(timeout);
1210                    }
1211                    command(&mut session)
1212                }),
1213            other => other,
1214        };
1215        self.entry
1216            .capability
1217            .record_command_timing(first_command_after_open, command_started.elapsed());
1218        self.entry.commands_since_open = self.entry.commands_since_open.saturating_add(1);
1219        self.map_lifecycle_result(result)
1220    }
1221
1222    fn ensure_valid(&self) -> Result<(), LeanWorkerError> {
1223        if !self.valid {
1224            return Err(LeanWorkerError::LeaseInvalidated {
1225                reason: self.invalid_lease_reason(),
1226            });
1227        }
1228        let current_generation = self.entry.capability.lifecycle_snapshot().worker_generation;
1229        if current_generation != self.admitted_generation {
1230            return Err(LeanWorkerError::LeaseInvalidated {
1231                reason: format!(
1232                    "worker generation changed from {} to {current_generation}",
1233                    self.admitted_generation
1234                ),
1235            });
1236        }
1237        Ok(())
1238    }
1239
1240    fn enforce_policy_before_request(&mut self) -> Result<(), LeanWorkerError> {
1241        if let Some(reason) = self.entry.enforce_policy(&self.config)? {
1242            self.invalidate(reason.clone());
1243            return Err(LeanWorkerError::LeaseInvalidated { reason });
1244        }
1245        Ok(())
1246    }
1247
1248    fn map_lifecycle_result<T>(&mut self, result: Result<T, LeanWorkerError>) -> Result<T, LeanWorkerError> {
1249        if self.request_timeout_override.is_some() {
1250            self.entry
1251                .capability
1252                .set_request_timeout(self.entry.base_request_timeout);
1253        }
1254        match result {
1255            Ok(value) => {
1256                self.entry.last_activity = Instant::now();
1257                Ok(value)
1258            }
1259            Err(err) => {
1260                self.entry.last_activity = Instant::now();
1261                if invalidates_lease(&err) {
1262                    self.invalidate(invalidation_reason(&err));
1263                }
1264                Err(err)
1265            }
1266        }
1267    }
1268
1269    fn invalidate(&mut self, reason: impl Into<String>) {
1270        self.valid = false;
1271        self.invalidation_reason = Some(reason.into());
1272    }
1273
1274    fn invalid_lease_reason(&self) -> String {
1275        self.invalidation_reason
1276            .clone()
1277            .unwrap_or_else(|| "lease was invalidated by a worker lifecycle transition".to_owned())
1278    }
1279
1280    fn release_once(&mut self) {
1281        if !self.released {
1282            self.entry.active_leases = self.entry.active_leases.saturating_sub(1);
1283            self.released = true;
1284        }
1285    }
1286}
1287
1288impl Drop for LeanWorkerSessionLease<'_> {
1289    fn drop(&mut self) {
1290        self.release_once();
1291    }
1292}
1293
1294fn invalidates_lease(err: &LeanWorkerError) -> bool {
1295    matches!(
1296        err,
1297        LeanWorkerError::Cancelled { .. }
1298            | LeanWorkerError::Timeout { .. }
1299            | LeanWorkerError::RestartLimitExceeded { .. }
1300            | LeanWorkerError::ChildExited { .. }
1301            | LeanWorkerError::ChildPanicOrAbort { .. }
1302            | LeanWorkerError::CapabilityMetadataMismatch { .. }
1303    )
1304}
1305
1306fn invalidation_reason(err: &LeanWorkerError) -> String {
1307    if let LeanWorkerError::Cancelled { operation, .. } = err {
1308        format!("cancelled during {operation}")
1309    } else if let LeanWorkerError::Timeout { operation, .. } = err {
1310        format!("timed out during {operation}")
1311    } else if matches!(err, LeanWorkerError::RestartLimitExceeded { .. }) {
1312        "worker restart limit exceeded".to_owned()
1313    } else if matches!(err, LeanWorkerError::ChildExited { .. }) {
1314        "worker child exited".to_owned()
1315    } else if matches!(err, LeanWorkerError::ChildPanicOrAbort { .. }) {
1316        "worker child exited fatally".to_owned()
1317    } else if let LeanWorkerError::CapabilityMetadataMismatch { export, .. } = err {
1318        format!("capability metadata mismatch from {export}")
1319    } else {
1320        "worker lifecycle transition".to_owned()
1321    }
1322}
1323
1324fn worker_session_missing(err: &LeanWorkerError) -> bool {
1325    matches!(err, LeanWorkerError::Worker { code, .. } if code == "lean_rs.worker.session_missing")
1326}
1327
1328fn normalize_import_workspace_root(path: PathBuf) -> PathBuf {
1329    std::fs::canonicalize(&path).unwrap_or(path)
1330}