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