1use 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
16const PRESENCE_VERIFY_INTERVAL: std::time::Duration = std::time::Duration::from_mins(1);
20
21fn 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
64fn 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 pub fn new() -> Self {
99 Self::new_with_project_root(None)
100 }
101
102 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 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 pub(crate) async fn ensure_session_presence(&self) -> Result<(), String> {
132 if self.presence_is_current().await {
133 return Ok(());
134 }
135 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 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 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 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 .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 lean_ctx::core::graph_index::ProjectIndex::purge_stale_indices();
243
244 let mut startup = detect_startup_context(project_root, startup_cwd);
245 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 lean_ctx::core::stats::flush();
575 lean_ctx::core::bounce_tracker::flush_pending();
578 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
592const SHUTDOWN_ENRICHMENT: std::time::Duration = std::time::Duration::from_secs(8);
596
597async 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
633pub 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 #[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(¤t));
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 #[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}