Skip to main content

lean_ctx/tools/
server_lifecycle.rs

1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::AtomicUsize;
4use std::time::Instant;
5use tokio::sync::RwLock;
6
7use crate::core::cache::SessionCache;
8use crate::core::session::SessionState;
9
10use super::autonomy;
11use super::server::{LeanCtxServer, SessionMode};
12use super::startup::detect_startup_context;
13
14impl Default for LeanCtxServer {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl LeanCtxServer {
21    /// Creates a new server with default settings, auto-detecting the project root.
22    pub fn new() -> Self {
23        Self::new_with_project_root(None)
24    }
25
26    /// Creates a new server rooted at the given project directory.
27    pub fn new_with_project_root(project_root: Option<&str>) -> Self {
28        Self::new_with_startup(
29            project_root,
30            std::env::current_dir().ok().as_deref(),
31            SessionMode::Personal,
32            "default",
33            "default",
34        )
35    }
36
37    /// Creates a new server in Context OS shared mode for a specific workspace/channel.
38    pub fn new_shared_with_context(
39        project_root: &str,
40        workspace_id: &str,
41        channel_id: &str,
42    ) -> Self {
43        Self::new_with_startup(
44            Some(project_root),
45            std::env::current_dir().ok().as_deref(),
46            SessionMode::Shared,
47            workspace_id,
48            channel_id,
49        )
50    }
51
52    pub(crate) fn new_with_startup(
53        project_root: Option<&str>,
54        startup_cwd: Option<&Path>,
55        session_mode: SessionMode,
56        workspace_id: &str,
57        channel_id: &str,
58    ) -> Self {
59        let ttl = std::env::var("LEAN_CTX_CACHE_TTL")
60            .ok()
61            .and_then(|v| v.parse().ok())
62            .unwrap_or_else(|| {
63                let cfg = crate::core::config::Config::load();
64                crate::core::config::MemoryCleanup::effective(&cfg).idle_ttl_secs()
65            });
66
67        // Purge stale graph indices on startup to prevent serving outdated data
68        crate::core::graph_index::ProjectIndex::purge_stale_indices();
69
70        let startup = detect_startup_context(project_root, startup_cwd);
71        let (session, context_os) = match session_mode {
72            SessionMode::Personal => {
73                let mut session = if let Some(ref root) = startup.project_root {
74                    SessionState::load_latest_for_project_root(root).unwrap_or_default()
75                } else {
76                    SessionState::load_latest().unwrap_or_default()
77                };
78                if let Some(ref root) = startup.project_root {
79                    session.project_root = Some(root.clone());
80                }
81                if let Some(ref cwd) = startup.shell_cwd {
82                    session.shell_cwd = Some(cwd.clone());
83                }
84                (Arc::new(RwLock::new(session)), None)
85            }
86            SessionMode::Shared => {
87                let Some(ref root) = startup.project_root else {
88                    // Shared mode without a project root is not useful; fall back to personal.
89                    return Self::new_with_startup(
90                        project_root,
91                        startup_cwd,
92                        SessionMode::Personal,
93                        workspace_id,
94                        channel_id,
95                    );
96                };
97                let rt = crate::core::context_os::runtime();
98                let session = rt
99                    .shared_sessions
100                    .get_or_load(root, workspace_id, channel_id);
101                rt.metrics.record_session_loaded();
102                // Ensure shell_cwd is refreshed (best-effort).
103                if let Some(ref cwd) = startup.shell_cwd
104                    && let Ok(mut s) = session.try_write()
105                {
106                    s.shell_cwd = Some(cwd.clone());
107                }
108                (session, Some(rt))
109            }
110        };
111
112        // Indices are NOT built eagerly here. A freshly connected agent that sits
113        // idle — or only uses ctx_read/ctx_shell/ctx_tree — must pay zero indexing
114        // cost. Heavy/search tools warm their indices lazily on first use via
115        // `index_orchestrator::ensure_warm_for_tool`, driven from dispatch (#152).
116        // An eager full graph + BM25 scan on every `new()` pegged a CPU core on
117        // each server start; multiplied across multiple agents and stdio respawns
118        // it was the root cause of the idle-high-CPU report (#453).
119
120        // Rehydrate the persistent stub index (#955) so the first unchanged
121        // re-read after this restart can collapse to the `[unchanged]` stub
122        // instead of re-delivering the whole file — gated by conversation +
123        // mtime/md5 so it can never serve a stale or cross-chat stub.
124        crate::core::read_stub_index::load();
125
126        let cache = Arc::new(RwLock::new(SessionCache::new()));
127        let bm25_cache: Arc<std::sync::Mutex<Option<crate::core::bm25_cache::Bm25CacheEntry>>> =
128            Arc::new(std::sync::Mutex::new(None));
129
130        // Register every server-local cache with the single process-wide guardian.
131        // The registry stores weak targets, so closed HTTP/MCP sessions are not retained.
132        let eviction_target = std::sync::Arc::new(
133            crate::core::eviction_orchestrator::EvictionOrchestrator::new(
134                cache.clone(),
135                bm25_cache.clone(),
136            ),
137        );
138        crate::core::eviction_orchestrator::register(&eviction_target);
139        crate::core::memory_guard::start_guard(std::sync::Arc::new(
140            crate::core::eviction_orchestrator::on_memory_pressure,
141        ));
142
143        let presence_root = startup
144            .project_root
145            .as_deref()
146            .or(startup.shell_cwd.as_deref())
147            .unwrap_or(".");
148        let presence_agent_id =
149            match crate::core::agents::AgentRegistry::register_mcp_process(presence_root) {
150                Ok(agent_id) => Some(agent_id),
151                Err(error) => {
152                    tracing::warn!("lean-ctx: failed to register MCP agent presence: {error}");
153                    None
154                }
155            };
156
157        Self {
158            cache,
159            session,
160            tool_calls: Arc::new(RwLock::new(Vec::new())),
161            call_count: Arc::new(AtomicUsize::new(0)),
162            cache_ttl_secs: ttl,
163            last_call: Arc::new(RwLock::new(Instant::now())),
164            agent_id: Arc::new(RwLock::new(None)),
165            presence_agent_id: Arc::new(RwLock::new(presence_agent_id)),
166            client_name: Arc::new(RwLock::new(String::new())),
167            autonomy: Arc::new(autonomy::AutonomyState::new()),
168            loop_detector: Arc::new(RwLock::new(
169                crate::core::loop_detection::LoopDetector::with_config(
170                    &crate::core::config::Config::load().loop_detection,
171                ),
172            )),
173            workflow: Arc::new(RwLock::new(
174                crate::core::workflow::load_active().ok().flatten(),
175            )),
176            ledger: Arc::new(RwLock::new(
177                crate::core::context_ledger::ContextLedger::load(),
178            )),
179            pipeline_stats: Arc::new(RwLock::new(crate::core::pipeline::PipelineStats::new())),
180            session_mode,
181            workspace_id: if workspace_id.trim().is_empty() {
182                "default".to_string()
183            } else {
184                workspace_id.trim().to_string()
185            },
186            channel_id: if channel_id.trim().is_empty() {
187                "default".to_string()
188            } else {
189                channel_id.trim().to_string()
190            },
191            context_os,
192            context_ir: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
193                crate::core::context_ir::ContextIrV1::load(),
194            ))),
195            registry: Some(std::sync::Arc::new(
196                crate::server::registry::build_registry(),
197            )),
198            rules_stale_checked: Arc::new(std::sync::atomic::AtomicBool::new(false)),
199            rules_tip_shown: Arc::new(std::sync::atomic::AtomicBool::new(false)),
200            last_seen_event_id: Arc::new(std::sync::atomic::AtomicI64::new(0)),
201            startup_project_root: startup.project_root,
202            startup_shell_cwd: startup.shell_cwd,
203            peer: Arc::new(tokio::sync::RwLock::new(None)),
204            has_client_roots: Arc::new(std::sync::atomic::AtomicBool::new(false)),
205            roots_resolved: Arc::new(std::sync::atomic::AtomicBool::new(false)),
206            roots_list_attempts: Arc::new(std::sync::atomic::AtomicU32::new(0)),
207            bm25_cache,
208            progress_sender: Arc::new(std::sync::Mutex::new(None)),
209            _eviction_target: eviction_target,
210            last_tools_config_hash: Arc::new(std::sync::atomic::AtomicU64::new(
211                crate::server::tools_config_watch::current_hash(),
212            )),
213        }
214    }
215
216    /// Clears the cache and saves the session if the TTL idle threshold has been exceeded.
217    pub async fn check_idle_expiry(&self) {
218        if self.cache_ttl_secs == 0 {
219            return;
220        }
221        let last = *self.last_call.read().await;
222        if last.elapsed().as_secs() >= self.cache_ttl_secs {
223            {
224                let mut session = self.session.write().await;
225                let _ = session.save();
226            }
227            let mut cache = self.cache.write().await;
228            let redelivered = cache.count_full_delivered();
229            let count = cache.clear();
230            crate::core::cache_telemetry::record_idle(redelivered as u64);
231            // The persisted stub index outlives the warm-cache clear, so a
232            // same-conversation re-read after idle still collapses to the stub
233            // via the cold fallback (#955). Flush it now for durability.
234            crate::core::read_stub_index::persist();
235            if count > 0 {
236                tracing::info!(
237                    "Cache auto-cleared after {}s idle ({count} file(s), {redelivered} forced re-delivery)",
238                    self.cache_ttl_secs
239                );
240            }
241        }
242        *self.last_call.write().await = Instant::now();
243    }
244
245    /// Aggressive cleanup on connection drop: save session, consolidate knowledge, clear caches.
246    pub async fn shutdown(&self) {
247        if let Some(agent_id) = self.presence_agent_id.read().await.clone()
248            && let Err(error) = crate::core::agents::AgentRegistry::finish_persistent(&agent_id)
249        {
250            tracing::warn!("lean-ctx: failed to finish MCP agent presence: {error}");
251        }
252        {
253            let session = self.session.read().await;
254            let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
255            let root = session.project_root.clone();
256            drop(session);
257
258            if has_insights && let Some(ref root) = root {
259                crate::tools::startup::auto_consolidate_knowledge(root);
260            }
261        }
262        {
263            let mut session = self.session.write().await;
264            let _ = session.save();
265        }
266        // Persist buffered stats (incl. CEP cache-hit/session counters) before
267        // the process exits. Short bridge sessions — e.g. a phase-isolated
268        // benchmark harness that spawns a fresh server per phase — may never
269        // reach the 30s live-stats flush cadence, which left
270        // `cep.sessions`/`total_cache_hits` at 0 in stats.json despite real
271        // cache hits (#361).
272        crate::core::stats::flush();
273        // Flush the persistent stub index (#955) so an unchanged re-read survives
274        // this restart as a cheap stub instead of a full re-delivery.
275        crate::core::read_stub_index::persist();
276        {
277            let mut cache = self.cache.write().await;
278            let count = cache.clear();
279            if count > 0 {
280                tracing::info!("[shutdown] cleared {count} cached file(s)");
281            }
282        }
283        crate::core::memory_guard::force_purge();
284    }
285}