Skip to main content

lean_ctx_server/tools/
server_lifecycle.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::path::Path;
3use std::sync::Arc;
4use std::sync::atomic::AtomicUsize;
5use std::time::Instant;
6use tokio::sync::RwLock;
7
8use lean_ctx::core::cache::SessionCache;
9use lean_ctx::core::session::SessionState;
10
11use super::server::{InteractionMode, LeanCtxServer, SessionMode};
12use super::startup::detect_startup_context;
13
14const MAX_WARM_SESSIONS: usize = 8;
15
16/// How often a connection re-checks that its bus presence is still listed.
17/// Shorter than the default worker lease, so a presence an older binary
18/// retired is restored before the dashboard polls twice.
19const PRESENCE_VERIFY_INTERVAL: std::time::Duration = std::time::Duration::from_mins(1);
20
21/// Last successful presence verification per presence id. A process holds
22/// one presence per connection, so this stays a handful of entries.
23fn presence_verifications() -> &'static std::sync::Mutex<std::collections::HashMap<String, Instant>>
24{
25    static VERIFIED: std::sync::OnceLock<
26        std::sync::Mutex<std::collections::HashMap<String, Instant>>,
27    > = std::sync::OnceLock::new();
28    VERIFIED.get_or_init(Default::default)
29}
30
31fn presence_verification_due(agent_id: &str) -> bool {
32    presence_verifications()
33        .lock()
34        .unwrap_or_else(std::sync::PoisonError::into_inner)
35        .get(agent_id)
36        .is_none_or(|at| at.elapsed() >= PRESENCE_VERIFY_INTERVAL)
37}
38
39fn mark_presence_verified(agent_id: &str) {
40    presence_verifications()
41        .lock()
42        .unwrap_or_else(std::sync::PoisonError::into_inner)
43        .insert(agent_id.to_string(), Instant::now());
44}
45
46fn forget_presence_verification(agent_id: &str) {
47    presence_verifications()
48        .lock()
49        .unwrap_or_else(std::sync::PoisonError::into_inner)
50        .remove(agent_id);
51}
52
53fn collect_warming_history(
54    project_root: Option<&str>,
55) -> Vec<lean_ctx::core::cache::warming::RecentFile> {
56    let Some(project_root) = project_root else {
57        return Vec::new();
58    };
59
60    let sessions = SessionState::load_recent_for_project_root(project_root, MAX_WARM_SESSIONS);
61    lean_ctx::core::cache::warming::collect_recent_files_in_project(&sessions, project_root)
62}
63
64/// GH #2006: collecting the history resolves every touched file of the recent
65/// sessions inside the project root. On a network drive that is a round trip
66/// per path, and it ran synchronously before the server logged or answered
67/// `initialize` (95-150 s on SMB). It now runs on the warming thread, before
68/// that thread takes the cache lock, so neither the handshake nor the first
69/// tool call waits for it.
70fn warm_cache_in_background(cache: Arc<RwLock<SessionCache>>, project_root: Option<String>) {
71    if project_root.is_none() {
72        return;
73    }
74
75    if let Err(error) = std::thread::Builder::new()
76        .name("lean-ctx-cache-warm".to_string())
77        .spawn(move || {
78            let history = collect_warming_history(project_root.as_deref());
79            if history.is_empty() {
80                return;
81            }
82            let mut cache = cache.blocking_write();
83            lean_ctx::core::cache::warming::warm_cache(&mut cache, &history);
84        })
85    {
86        tracing::debug!("lean-ctx: failed to start cache warming task: {error}");
87    }
88}
89
90impl Default for LeanCtxServer {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl LeanCtxServer {
97    /// Creates a new server with default settings, auto-detecting the project root.
98    pub fn new() -> Self {
99        Self::new_with_project_root(None)
100    }
101
102    /// Creates a new server rooted at the given project directory.
103    pub fn new_with_project_root(project_root: Option<&str>) -> Self {
104        Self::new_with_startup(
105            project_root,
106            std::env::current_dir().ok().as_deref(),
107            SessionMode::Personal,
108            "default",
109            "default",
110        )
111    }
112
113    /// Creates a new server in Context OS shared mode for a specific workspace/channel.
114    pub fn new_shared_with_context(
115        project_root: &str,
116        workspace_id: &str,
117        channel_id: &str,
118    ) -> Self {
119        Self::new_with_startup(
120            Some(project_root),
121            std::env::current_dir().ok().as_deref(),
122            SessionMode::Shared,
123            workspace_id,
124            channel_id,
125        )
126    }
127
128    /// Ensures this MCP connection owns a durable local bus presence before
129    /// executing a tool. Construction registers eagerly; this is the
130    /// fail-closed retry for a transient startup lock or registry failure.
131    pub(crate) async fn ensure_session_presence(&self) -> Result<(), String> {
132        if self.presence_is_current().await {
133            return Ok(());
134        }
135        // Concurrent first calls (16 parallel reads on a fresh engine) each
136        // registered their own agent and timed out on the registry file lock
137        // on Windows. One registers; the rest wait and reuse its id.
138        let _init = self.presence_init.lock().await;
139        if self.presence_is_current().await {
140            return Ok(());
141        }
142
143        let project_root = self.presence_root();
144        // #1766: once `initialize` has resolved the session's role, the retry
145        // must register with it — falling back to the `context-engine`
146        // placeholder rewrote a `reviewer` presence.
147        let requested_role = self.presence_role.read().await.clone();
148        let registration = match requested_role.as_deref() {
149            Some(role) => {
150                let role_source = if lean_ctx::core::agents::declared_session_role().is_some() {
151                    lean_ctx::core::agents::RoleSource::Declared
152                } else {
153                    lean_ctx::core::agents::RoleSource::Assumed
154                };
155                lean_ctx::core::agents::AgentRegistry::register_mcp_process_as(
156                    project_root,
157                    role,
158                    role_source,
159                    None,
160                )
161            }
162            None => lean_ctx::core::agents::AgentRegistry::register_mcp_process(project_root, None),
163        };
164        let retired = self.presence_agent_id.read().await.clone();
165        match registration {
166            Ok(agent_id) => {
167                if let Some(retired) = retired.as_deref()
168                    && retired != agent_id
169                {
170                    tracing::info!("lean-ctx: MCP presence {retired} was retired; now {agent_id}");
171                    forget_presence_verification(retired);
172                }
173                mark_presence_verified(&agent_id);
174                *self.presence_agent_id.write().await = Some(agent_id);
175                Ok(())
176            }
177            // Re-registering a retired presence is best effort: a transient
178            // registry failure must not block a connection that already
179            // passed the fail-closed gate once. Retry after the interval.
180            Err(error) if retired.is_some() => {
181                tracing::warn!("lean-ctx: failed to re-register retired MCP presence: {error}");
182                if let Some(retired) = retired.as_deref() {
183                    mark_presence_verified(retired);
184                }
185                Ok(())
186            }
187            Err(error) => Err(error),
188        }
189    }
190
191    /// True while this connection holds a presence that is still listed.
192    /// The registry is shared with other processes: an older binary finishes
193    /// quiet presences after the worker lease, and finished history is
194    /// pruned. A retired presence reports `false` so the caller re-registers
195    /// instead of heartbeating a dead id forever (`/api/agents` went empty
196    /// while five MCP servers were connected). Verification reads the
197    /// registry at most once per `PRESENCE_VERIFY_INTERVAL` per presence.
198    async fn presence_is_current(&self) -> bool {
199        let Some(agent_id) = self.presence_agent_id.read().await.clone() else {
200            return false;
201        };
202        if !presence_verification_due(&agent_id) {
203            return true;
204        }
205        let id = agent_id.clone();
206        let live = tokio::task::spawn_blocking(move || {
207            lean_ctx::core::agents::AgentRegistry::transport_presence_is_live(&id)
208        })
209        .await
210        // A failed check must not knock out a working presence.
211        .unwrap_or(true);
212        if live {
213            mark_presence_verified(&agent_id);
214        }
215        live
216    }
217
218    fn presence_root(&self) -> &str {
219        self.startup_project_root
220            .as_deref()
221            .or(self.startup_shell_cwd.as_deref())
222            .unwrap_or(".")
223    }
224
225    pub(crate) fn new_with_startup(
226        project_root: Option<&str>,
227        startup_cwd: Option<&Path>,
228        session_mode: SessionMode,
229        workspace_id: &str,
230        channel_id: &str,
231    ) -> Self {
232        crate::server::registry::register_tool_catalog();
233        let ttl = std::env::var("LEAN_CTX_CACHE_TTL")
234            .ok()
235            .and_then(|v| v.parse().ok())
236            .unwrap_or_else(|| {
237                let cfg = lean_ctx::core::config::Config::load();
238                lean_ctx::core::config::MemoryCleanup::effective(&cfg).idle_ttl_secs()
239            });
240
241        // Purge stale graph indices on startup to prevent serving outdated data
242        lean_ctx::core::graph_index::ProjectIndex::purge_stale_indices();
243
244        let mut startup = detect_startup_context(project_root, startup_cwd);
245        // Launchd daemons commonly start in `/`. A broad root must never become
246        // session state: it creates a cross-project mega-session and, before
247        // the bounded project index, also made every later MCP launch scan it.
248        if startup.project_root.as_deref().is_some_and(|root| {
249            lean_ctx::core::pathutil::is_broad_or_unsafe_root(std::path::Path::new(root))
250        }) {
251            tracing::debug!("lean-ctx: ignoring broad startup project root");
252            startup.project_root = None;
253        }
254        let (session, context_os) = match session_mode {
255            SessionMode::Personal => {
256                // A personal MCP server owns one fresh, PID-qualified session.
257                // Reusing a persisted project "latest" session lets concurrent
258                // servers inject each other's state during initialize.
259                let mut session = SessionState::new();
260                if let Some(ref root) = startup.project_root {
261                    session.project_root = Some(root.clone());
262                }
263                if let Some(ref cwd) = startup.shell_cwd {
264                    session.shell_cwd = Some(cwd.clone());
265                }
266                (Arc::new(RwLock::new(session)), None)
267            }
268            SessionMode::Shared => {
269                // Every client of a shared session works in its root, so it
270                // must be an existing absolute directory (AP04-G5).
271                let Some(root) = startup.project_root.as_ref().filter(|root| {
272                    let root = std::path::Path::new(root);
273                    root.is_absolute() && root.is_dir()
274                }) else {
275                    // Shared mode without a project root is not useful; fall back to personal.
276                    return Self::new_with_startup(
277                        project_root,
278                        startup_cwd,
279                        SessionMode::Personal,
280                        workspace_id,
281                        channel_id,
282                    );
283                };
284                let rt = lean_ctx::core::context_os::runtime();
285                let session = rt
286                    .shared_sessions
287                    .get_or_load(root, workspace_id, channel_id);
288                rt.metrics.record_session_loaded();
289                // Ensure shell_cwd is refreshed (best-effort).
290                if let Some(ref cwd) = startup.shell_cwd
291                    && let Ok(mut s) = session.try_write()
292                {
293                    s.shell_cwd = Some(cwd.clone());
294                }
295                (session, Some(rt))
296            }
297        };
298
299        // Indices are NOT built eagerly here. A freshly connected agent that sits
300        // idle — or only uses ctx_read/ctx_shell/ctx_tree — must pay zero indexing
301        // cost. Heavy/search tools warm their indices lazily on first use via
302        // `index_orchestrator::ensure_warm_for_tool`, driven from dispatch (#152).
303        // An eager full graph + BM25 scan on every `new()` pegged a CPU core on
304        // each server start; multiplied across multiple agents and stdio respawns
305        // it was the root cause of the idle-high-CPU report (#453).
306
307        // Rehydrate the persistent stub index (#955) so the first unchanged
308        // re-read after this restart can collapse to the `[unchanged]` stub
309        // instead of re-delivering the whole file — gated by conversation +
310        // mtime/md5 so it can never serve a stale or cross-chat stub.
311        lean_ctx::core::read_stub_index::load();
312
313        let cache = Arc::new(RwLock::new(SessionCache::new()));
314        let bm25_cache: Arc<std::sync::Mutex<Option<lean_ctx::core::bm25_cache::Bm25CacheEntry>>> =
315            Arc::new(std::sync::Mutex::new(None));
316
317        // Register every server-local cache with the single process-wide guardian.
318        // The registry stores weak targets, so closed HTTP/MCP sessions are not retained.
319        let eviction_target = std::sync::Arc::new(
320            lean_ctx::core::eviction_orchestrator::EvictionOrchestrator::new(
321                cache.clone(),
322                bm25_cache.clone(),
323            ),
324        );
325        lean_ctx::core::eviction_orchestrator::register(&eviction_target);
326        lean_ctx::core::memory_guard::start_guard(std::sync::Arc::new(
327            lean_ctx::core::eviction_orchestrator::on_memory_pressure,
328        ));
329        warm_cache_in_background(cache.clone(), startup.project_root.clone());
330
331        let presence_root = startup
332            .project_root
333            .as_deref()
334            .or(startup.shell_cwd.as_deref())
335            .unwrap_or(".");
336        let presence_agent_id = match lean_ctx::core::agents::AgentRegistry::register_mcp_process(
337            presence_root,
338            None,
339        ) {
340            Ok(agent_id) => Some(agent_id),
341            Err(error) => {
342                tracing::warn!("lean-ctx: failed to register MCP agent presence: {error}");
343                None
344            }
345        };
346
347        Self {
348            cache,
349            session: lean_ctx::core::session::SessionSlot::new(session),
350            tool_calls: Arc::new(RwLock::new(Vec::new())),
351            call_count: Arc::new(AtomicUsize::new(0)),
352            background_tick: Arc::new(AtomicUsize::new(0)),
353            cache_ttl_secs: ttl,
354            last_call: Arc::new(RwLock::new(Instant::now())),
355            agent_id: Arc::new(RwLock::new(None)),
356            task_envelope: Arc::new(RwLock::new(None)),
357            native_receipt_authority: None,
358            presence_agent_id: Arc::new(RwLock::new(presence_agent_id)),
359            presence_init: Arc::new(tokio::sync::Mutex::new(())),
360            presence_role: Arc::new(RwLock::new(None)),
361            client_name: Arc::new(RwLock::new(String::new())),
362            autonomy: Arc::new(lean_ctx::core::autonomy::AutonomyState::new()),
363            loop_detector: Arc::new(RwLock::new(
364                lean_ctx::core::loop_detection::LoopDetector::with_config(
365                    &lean_ctx::core::config::Config::load().loop_detection,
366                ),
367            )),
368            // ctx_workflow was removed (focus release): nothing can start or stop
369            // a run, so a run left on disk by an older version must not gate
370            // tools here. The gate code goes with the workflow store.
371            workflow: Arc::new(RwLock::new(None)),
372            ledger: Arc::new(RwLock::new(
373                lean_ctx::core::context_ledger::ContextLedger::load(),
374            )),
375            pipeline_stats: Arc::new(RwLock::new(lean_ctx::core::pipeline::PipelineStats::new())),
376            session_mode,
377            workspace_id: if workspace_id.trim().is_empty() {
378                "default".to_string()
379            } else {
380                workspace_id.trim().to_string()
381            },
382            channel_id: if channel_id.trim().is_empty() {
383                "default".to_string()
384            } else {
385                channel_id.trim().to_string()
386            },
387            context_os,
388            context_ir: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
389                lean_ctx::core::context_ir::ContextIrV1::load(),
390            ))),
391            registry: Some(std::sync::Arc::new(
392                crate::server::registry::build_registry(),
393            )),
394            rules_stale_checked: Arc::new(std::sync::atomic::AtomicBool::new(false)),
395            rules_tip_shown: Arc::new(std::sync::atomic::AtomicBool::new(false)),
396            last_seen_event_id: Arc::new(std::sync::atomic::AtomicI64::new(0)),
397            startup_project_root: startup.project_root,
398            startup_shell_cwd: startup.shell_cwd,
399            peer: Arc::new(tokio::sync::RwLock::new(None)),
400            shell_approvals: Arc::default(),
401            has_client_roots: Arc::new(std::sync::atomic::AtomicBool::new(false)),
402            roots_resolved: Arc::new(std::sync::atomic::AtomicBool::new(false)),
403            roots_list_attempts: Arc::new(std::sync::atomic::AtomicU32::new(0)),
404            bm25_cache,
405            progress_sender: Arc::new(std::sync::Mutex::new(None)),
406            _eviction_target: eviction_target,
407            last_tools_config_hash: Arc::new(std::sync::atomic::AtomicU64::new(
408                crate::server::tools_config_watch::current_hash(),
409            )),
410            interaction_mode: Arc::new(std::sync::atomic::AtomicU8::new(
411                InteractionMode::Agent as u8,
412            )),
413        }
414    }
415
416    /// Clears the cache and saves the session if the TTL idle threshold has been exceeded.
417    pub async fn check_idle_expiry(&self) {
418        if self.cache_ttl_secs == 0 {
419            return;
420        }
421        let last = *self.last_call.read().await;
422        if last.elapsed().as_secs() >= self.cache_ttl_secs {
423            self.clear_idle_cache().await;
424        }
425        *self.last_call.write().await = Instant::now();
426    }
427
428    /// Idle TTL of the read cache (`memory_cleanup` / `LEAN_CTX_CACHE_TTL`); 0 = off.
429    pub fn idle_ttl(&self) -> std::time::Duration {
430        std::time::Duration::from_secs(self.cache_ttl_secs)
431    }
432
433    async fn clear_idle_cache(&self) {
434        tokio::spawn(lean_ctx::core::session::SessionState::save_shared_logged(
435            self.session.instance(),
436        ));
437        let mut cache = self.cache.write().await;
438        let count = cache.clear();
439        // The persisted stub index outlives the warm-cache clear, so a
440        // same-conversation re-read after idle still collapses to the stub
441        // via the cold fallback (#955). Flush it now for durability.
442        lean_ctx::core::read_stub_index::persist();
443        if count > 0 {
444            tracing::info!(
445                "Cache auto-cleared after {}s idle ({count} file(s))",
446                self.cache_ttl_secs
447            );
448        }
449    }
450
451    /// Release what an idle server holds without waiting for the next call:
452    /// the read cache (as `check_idle_expiry` would), the resident indexes and
453    /// the allocator's free pages. Everything reloads on demand. Restarts the
454    /// TTL so the next call does not clear (and count) the cache a second time.
455    pub async fn release_idle_memory(&self) {
456        use lean_ctx::core::memory_guard;
457        let before = (
458            memory_guard::get_rss_bytes(),
459            memory_guard::allocator_stats(),
460        );
461        self.clear_idle_cache().await;
462        *self.last_call.write().await = Instant::now();
463        let _ = tokio::task::spawn_blocking(|| {
464            lean_ctx::core::eviction_orchestrator::on_memory_pressure(
465                memory_guard::PressureLevel::Hard,
466            );
467            memory_guard::jemalloc_purge();
468            memory_guard::release_system_heap();
469        })
470        .await;
471        let after = (
472            memory_guard::get_rss_bytes(),
473            memory_guard::allocator_stats(),
474        );
475        let mb = |bytes: Option<u64>| bytes.map_or_else(|| "?".into(), |b| format!("{}", b >> 20));
476        tracing::info!(
477            "[mcp-idle] released: RSS {} → {} MB, heap {} → {} MB",
478            mb(before.0),
479            mb(after.0),
480            mb(before.1.map(|s| s.0)),
481            mb(after.1.map(|s| s.0)),
482        );
483    }
484
485    /// Aggressive cleanup on connection drop: save session, consolidate knowledge, clear caches.
486    ///
487    /// Order and bounds: the host that closed the connection waits a bounded
488    /// time and then kills the process. What must not be lost comes first —
489    /// the session's last state — and what only enriches later sessions (the
490    /// knowledge consolidation) runs afterwards within `SHUTDOWN_ENRICHMENT`.
491    /// With a semantic detector bound it re-admits the session's text through
492    /// the model; run first and unbounded, it kept the server alive beyond the
493    /// host's wait while the last state was still to be saved. The
494    /// consolidation reads the SAVED session, so it also belongs after the save.
495    pub async fn shutdown(&self) {
496        let stage = Instant::now();
497        let last_state_durable =
498            lean_ctx::core::session::SessionState::save_shared_at_shutdown(self.session.instance())
499                .await;
500        let stage = shutdown_stage("session save", stage);
501        if !last_state_durable {
502            // The save's own warning says why. Its changes stay pending as
503            // after any failed save, and nothing later in this process saves
504            // them. What follows must not make that worse: the consolidation
505            // imports from the SAVED session and advances its watermark, so
506            // on a state that is not the last one it is not run.
507            tracing::error!(
508                "lean-ctx: the session's last state is NOT durable at shutdown; \
509                 knowledge consolidation is skipped"
510            );
511        }
512        lean_ctx::core::savings_tracker::persist_session_summary();
513        if let Some(agent_id) = self.presence_agent_id.read().await.clone() {
514            forget_presence_verification(&agent_id);
515            if let Err(error) = lean_ctx::core::agents::AgentRegistry::finish_persistent(&agent_id)
516            {
517                tracing::warn!("lean-ctx: failed to finish MCP agent presence: {error}");
518            }
519        }
520        let stage = shutdown_stage("summary and presence", stage);
521
522        let enrichment_ends = Instant::now() + SHUTDOWN_ENRICHMENT;
523        {
524            let session = self.session.read().await;
525            let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
526            let root = session.project_root.clone();
527            let hive_target = root.clone().map(|root| (root, session.id.clone()));
528            drop(session);
529
530            if last_state_durable
531                && has_insights
532                && let Some(root) = root
533            {
534                // Incremental, and only this server's own session — never the
535                // project's latest saved one, which may be another agent's
536                // (I07/S03). What is not consolidated now stays newer than the
537                // session's watermark, which advances only after the knowledge
538                // write: when this agent resumes the session explicitly
539                // (`ctx_session` load with `session_id`, or a handoff), its next
540                // consolidation (checkpoint, compaction or shutdown) imports
541                // those items. A new server starts its own fresh session and
542                // never takes this one up by itself; nothing consolidates at
543                // start-up.
544                let own = self.session.instance();
545                within_shutdown("knowledge consolidation", enrichment_ends, move || {
546                    lean_ctx::core::session::caller::scope(Some(own), || {
547                        crate::tools::startup::auto_consolidate_knowledge(&root);
548                    });
549                })
550                .await;
551            }
552            // Queue this session's learnings for Hive (local only, no network).
553            if last_state_durable && let Some((root, session_id)) = hive_target {
554                lean_ctx_hive::knowledge::draft::on_session_event(
555                    &root,
556                    &session_id,
557                    lean_ctx_hive::knowledge::draft::Trigger::Shutdown,
558                );
559            }
560        }
561        // Give back this process's Hive task claims (each bounded; one
562        // Hive cannot be reached for ends at its expiry there).
563        within_shutdown("hive task claims", enrichment_ends, || {
564            lean_ctx_hive::tasks::release_own_claims("the agent session ended")
565        })
566        .await;
567        shutdown_stage("knowledge consolidation", stage);
568        // Persist buffered stats (incl. CEP cache-hit/session counters) before
569        // the process exits. Short bridge sessions — e.g. a phase-isolated
570        // benchmark harness that spawns a fresh server per phase — may never
571        // reach the 30s live-stats flush cadence, which left
572        // `cep.sessions`/`total_cache_hits` at 0 in stats.json despite real
573        // cache hits (#361).
574        lean_ctx::core::stats::flush();
575        // A dropped connection ends its read/edit sequence: book the bounces
576        // still waiting for their edit window instead of losing them.
577        lean_ctx::core::bounce_tracker::flush_pending();
578        // Flush the persistent stub index (#955) so an unchanged re-read survives
579        // this restart as a cheap stub instead of a full re-delivery.
580        lean_ctx::core::read_stub_index::persist();
581        {
582            let mut cache = self.cache.write().await;
583            let count = cache.clear();
584            if count > 0 {
585                tracing::info!("[shutdown] cleared {count} cached file(s)");
586            }
587        }
588        lean_ctx::core::memory_guard::force_purge();
589    }
590}
591
592/// Time the shutdown gives to what only enriches later sessions, AFTER the
593/// session's last state is durable. An engineering bound, not a contract of
594/// any host: hosts wait differently long before they kill a server.
595const SHUTDOWN_ENRICHMENT: std::time::Duration = std::time::Duration::from_secs(8);
596
597/// Run blocking shutdown work off the async executor until `until` at the
598/// latest. `None`: it was not started (no time left) or did not finish in
599/// time; the log names the stage.
600///
601/// The bound ends the WAIT, not the work: a blocking task cannot be stopped.
602/// Work that did not finish keeps running until the process ends (or to its
603/// end, when the server object lives on). The knowledge consolidation writes
604/// its store in one atomic replace at the end
605/// (`knowledge::persist::write_json_atomic`), so a process that ends first
606/// leaves the store as it was — the consolidation's items stay ahead of the
607/// session's watermark for the next consolidation of the continued session.
608async fn within_shutdown<T: Send + 'static>(
609    stage: &'static str,
610    until: Instant,
611    work: impl FnOnce() -> T + Send + 'static,
612) -> Option<T> {
613    let left = until.saturating_duration_since(Instant::now());
614    if left.is_zero() {
615        tracing::info!("lean-ctx: shutdown stage `{stage}` skipped: no time left for it");
616        return None;
617    }
618    match tokio::time::timeout(left, tokio::task::spawn_blocking(work)).await {
619        Ok(Ok(value)) => Some(value),
620        Ok(Err(error)) => {
621            tracing::warn!("lean-ctx: shutdown stage `{stage}` failed: {error}");
622            None
623        }
624        Err(_) => {
625            tracing::info!(
626                "lean-ctx: shutdown stage `{stage}` not finished within its time and left behind"
627            );
628            None
629        }
630    }
631}
632
633/// Name a shutdown stage that took a second or more, and return the start of
634/// the next one. A host waits a bounded time for the server to end after it
635/// closed the connection and kills it afterwards; without these lines a
636/// shutdown that outlives that wait leaves no trace of where the time went.
637pub fn shutdown_stage(stage: &'static str, started: Instant) -> Instant {
638    let milliseconds = started.elapsed().as_millis();
639    if milliseconds >= 1000 {
640        tracing::info!("lean-ctx: shutdown stage `{stage}` took {milliseconds} ms");
641    } else {
642        tracing::debug!("lean-ctx: shutdown stage `{stage}` took {milliseconds} ms");
643    }
644    Instant::now()
645}
646
647#[cfg(test)]
648mod tests {
649    use super::LeanCtxServer;
650
651    #[tokio::test]
652    async fn missing_presence_is_registered_before_first_tool() {
653        let _isolated_data_dir = lean_ctx::core::data_dir::isolated_data_dir();
654        let server = LeanCtxServer::new_shared_with_context("/workspace/lean-ctx", "ws", "ch");
655        *server.presence_agent_id.write().await = None;
656
657        server
658            .ensure_session_presence()
659            .await
660            .expect("registration retry succeeds");
661        let agent_id = server
662            .presence_agent_id
663            .read()
664            .await
665            .clone()
666            .expect("presence id is retained");
667        let registry = lean_ctx::core::agents::AgentRegistry::load().expect("registry persisted");
668        assert!(
669            registry
670                .agents
671                .iter()
672                .any(|agent| agent.agent_id == agent_id)
673        );
674    }
675
676    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
677    async fn concurrent_presence_retries_register_once() {
678        let _isolated_data_dir = lean_ctx::core::data_dir::isolated_data_dir();
679        let server = LeanCtxServer::new_shared_with_context("/workspace/lean-ctx", "ws", "ch");
680        *server.presence_agent_id.write().await = None;
681        let before = lean_ctx::core::agents::AgentRegistry::load()
682            .map(|registry| registry.agents.len())
683            .unwrap_or(0);
684
685        let calls: Vec<_> = (0..16)
686            .map(|_| {
687                let server = server.clone();
688                tokio::spawn(async move { server.ensure_session_presence().await })
689            })
690            .collect();
691        for call in calls {
692            call.await
693                .expect("task joined")
694                .expect("presence retry succeeds");
695        }
696
697        let agent_id = server
698            .presence_agent_id
699            .read()
700            .await
701            .clone()
702            .expect("presence id is retained");
703        let registry = lean_ctx::core::agents::AgentRegistry::load().expect("registry persisted");
704        assert!(
705            registry.agents.len() <= before + 1,
706            "no duplicate registrations"
707        );
708        assert!(
709            registry
710                .agents
711                .iter()
712                .any(|agent| agent.agent_id == agent_id)
713        );
714    }
715
716    /// Audit bug 1: another process (an older binary's lease expiry) finished
717    /// this live connection's presence; the server kept heartbeating the dead
718    /// id and the dashboard listed no agents. The next tool call re-registers.
719    #[tokio::test]
720    async fn retired_presence_is_replaced_on_next_tool_call() {
721        let _isolated_data_dir = lean_ctx::core::data_dir::isolated_data_dir();
722        let server = LeanCtxServer::new_shared_with_context("/workspace/lean-ctx", "ws", "ch");
723        server
724            .ensure_session_presence()
725            .await
726            .expect("initial presence");
727        let retired = server
728            .presence_agent_id
729            .read()
730            .await
731            .clone()
732            .expect("presence id");
733        lean_ctx::core::agents::AgentRegistry::mutate_locked(|registry| {
734            registry.set_status(
735                &retired,
736                lean_ctx::core::agents::AgentStatus::Finished,
737                Some("worker lease expired; register again before continuing"),
738            )
739        })
740        .expect("registry writable")
741        .1
742        .expect("presence exists");
743        super::forget_presence_verification(&retired);
744
745        server
746            .ensure_session_presence()
747            .await
748            .expect("re-registration succeeds");
749
750        let current = server
751            .presence_agent_id
752            .read()
753            .await
754            .clone()
755            .expect("presence id");
756        assert_ne!(current, retired, "a retired presence id must be replaced");
757        assert!(lean_ctx::core::agents::AgentRegistry::transport_presence_is_live(&current));
758        let registry = lean_ctx::core::agents::AgentRegistry::load().expect("registry persisted");
759        let entry = registry
760            .agents
761            .iter()
762            .find(|agent| agent.agent_id == current)
763            .expect("new presence persisted");
764        assert!(entry.transport.is_some(), "re-registered as a transport");
765    }
766
767    /// Verification is throttled: a live presence is not re-read per call.
768    #[test]
769    fn presence_verification_is_throttled_per_presence() {
770        let id = "throttle-test-presence";
771        super::forget_presence_verification(id);
772        assert!(super::presence_verification_due(id));
773        super::mark_presence_verified(id);
774        assert!(!super::presence_verification_due(id));
775        super::forget_presence_verification(id);
776        assert!(super::presence_verification_due(id));
777    }
778
779    #[tokio::test]
780    async fn session_presence_failure_is_observable_to_tool_gate() {
781        let _isolated_data_dir = lean_ctx::core::data_dir::isolated_data_dir();
782        let data_dir = lean_ctx::core::data_dir::lean_ctx_data_dir().expect("data dir");
783        std::fs::create_dir_all(&data_dir).expect("data dir exists");
784        std::fs::write(data_dir.join("agents"), "not a directory").expect("poison agents path");
785
786        let server = LeanCtxServer::new_shared_with_context("/workspace/lean-ctx", "ws", "ch");
787        assert!(server.presence_agent_id.read().await.is_none());
788        assert!(server.ensure_session_presence().await.is_err());
789    }
790}