Skip to main content

aft/
context.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock, TryLockError, Weak};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11use serde::Serialize;
12
13use crate::alert_state::{
14    AcceptedObservationBatch, AcceptedObservationResult, AlertDeltaState, ObservationError,
15};
16use crate::artifact_owner::{
17    ArtifactOwnerLease, ArtifactOwnerLeaseRegistration, ArtifactOwnerMode, ArtifactOwnerStatus,
18};
19use crate::backup::hash_session;
20use crate::backup::BackupStore;
21use crate::bash_background::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
22use crate::callgraph_store::{CallGraphStore, CallGraphStoreError, ReadonlyCallGraphStore};
23use crate::checkpoint::CheckpointStore;
24use crate::config::Config;
25use crate::harness::Harness;
26use crate::inspect::{
27    InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
28};
29use crate::language::LanguageProvider;
30use crate::lsp::manager::{LspManager, StaleDiagnosticsMark};
31use crate::lsp::registry::is_config_file_path_with_custom;
32use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
33use crate::protocol::{
34    ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
35};
36use crate::watcher_filter::WatcherJoinOutcome;
37use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
38
39pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
40pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
41pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
42const STATUS_DEBOUNCE_MS: u64 = 1_000;
43
44/// Canonicalize a path that may no longer exist (pending callgraph paths
45/// legitimately include deleted files): canonicalize the nearest existing
46/// ancestor of the ORIGINAL spelling and re-append the missing tail, so alias
47/// spellings (macOS /var vs /private/var) normalize even for dead paths.
48///
49/// Symlink semantics match the callgraph store's `normalize_file_path`
50/// (filesystem-first): `root/link/../x` where `link` targets a foreign
51/// directory canonicalizes to the FOREIGN parent, not a lexical `root/x`.
52/// Lexical `.`/`..` resolution applies only past the deepest existing
53/// component (a nonexistent component cannot be a symlink) and to the tail
54/// appended onto an already-canonical, symlink-free base.
55/// Component-wise lenient canonicalization with filesystem-first semantics
56/// (matching the callgraph store's `normalize_file_path`): each existing
57/// component — including symlinks — resolves through the filesystem; genuinely
58/// absent components accumulate on a missing stack and resolve lexically. `..`
59/// pops the missing stack first, and only when the stack is empty does it take
60/// the parent of the canonical base (symlink-free, so a lexical parent is
61/// sound there). Handles re-entry: in `dead/../link/../x`, `dead/..` drains
62/// back to the existing base and `link` (a symlink) resolves through the
63/// filesystem instead of being erased lexically.
64///
65/// Returns `None` — and containment fails closed — where realpath would not
66/// resolve either: a dangling symlink or other filesystem error on an existing
67/// component (the store falls back to the raw spelling for those, which
68/// `relative_path` keeps as an absolute out-of-root key), and `..` traversal
69/// through a non-directory (realpath ENOTDIR).
70fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
71    use std::path::Component;
72    if let Ok(canonical) = std::fs::canonicalize(path) {
73        return Some(canonical);
74    }
75    let mut resolved = PathBuf::new();
76    let mut missing: Vec<std::ffi::OsString> = Vec::new();
77    for component in path.components() {
78        match component {
79            Component::Prefix(_) | Component::RootDir => {
80                resolved.push(component.as_os_str());
81                // Canonicalize the anchor so a missing child directly under a
82                // drive/UNC root compares in the same (verbatim) spelling as a
83                // canonicalized root on Windows; "/" is a no-op on Unix.
84                if let Ok(canonical_anchor) = std::fs::canonicalize(&resolved) {
85                    resolved = canonical_anchor;
86                }
87            }
88            Component::CurDir => {}
89            Component::ParentDir => {
90                if missing.pop().is_none() {
91                    if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
92                        // `file/..` — realpath rejects with ENOTDIR.
93                        return None;
94                    }
95                    resolved.pop();
96                }
97            }
98            Component::Normal(name) => {
99                if missing.is_empty() {
100                    let candidate = resolved.join(name);
101                    match std::fs::canonicalize(&candidate) {
102                        Ok(canonical) => resolved = canonical,
103                        Err(_) => match std::fs::symlink_metadata(&candidate) {
104                            // Genuinely absent: lexical from here until `..`
105                            // drains back.
106                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
107                                missing.push(name.to_owned())
108                            }
109                            // Exists but does not canonicalize (dangling
110                            // symlink) or the probe itself failed: fail closed.
111                            _ => return None,
112                        },
113                    }
114                } else {
115                    missing.push(name.to_owned());
116                }
117            }
118        }
119    }
120    for name in missing {
121        resolved.push(name);
122    }
123    Some(resolved)
124}
125
126/// Root-containment check for pending callgraph replay paths.
127///
128/// Relative paths are project-root-relative by the callgraph store's own
129/// contract (`normalize_file_path`), so they are resolved against each root
130/// rather than the process CWD. Both sides are lenient-canonicalized
131/// (component-wise, filesystem-first) before the prefix comparison: raw-spelling
132/// acceptance would let `root/../foreign` or a symlinked escape pass, and a
133/// bare textual check false-drops alias spellings (macOS /var vs
134/// /private/var) and deleted files.
135fn pending_path_in_roots(path: &Path, roots: &[PathBuf]) -> bool {
136    if path.is_relative() {
137        // Project-root-relative by contract, and only for prefix-free
138        // spellings: Windows drive-relative (`C:foo`) and root-relative
139        // (`\foo`) forms are "relative" to std but `join` replaces the root
140        // for them, resolving through the drive CWD instead of the project.
141        let has_prefix_or_root = path.components().next().is_some_and(|component| {
142            matches!(
143                component,
144                std::path::Component::Prefix(_) | std::path::Component::RootDir
145            )
146        });
147        if has_prefix_or_root {
148            return false;
149        }
150        // A lexical escape via `..` still fails the canonical prefix check
151        // after joining. Unresolvable spellings fail closed.
152        return roots.iter().any(|root| {
153            let joined = root.join(path);
154            match (canonicalize_lenient(&joined), canonicalize_lenient(root)) {
155                (Some(path), Some(root)) => path.starts_with(&root),
156                _ => false,
157            }
158        });
159    }
160    let Some(canonical_path) = canonicalize_lenient(path) else {
161        return false;
162    };
163    roots.iter().any(|root| {
164        canonicalize_lenient(root)
165            .is_some_and(|canonical_root| canonical_path.starts_with(&canonical_root))
166    })
167}
168
169/// Serializes the daemon's bound/unbound transition with admission of deferred
170/// root work. The lock covers only the bounded decision and worker-start commit;
171/// call sites must not wait for worker completion or run a scan while holding it.
172#[derive(Clone, Default)]
173pub(crate) struct SubcLifecycleAdmission {
174    unbound: Arc<parking_lot::Mutex<bool>>,
175}
176
177impl SubcLifecycleAdmission {
178    fn mark_bound(&self) {
179        *self.unbound.lock() = false;
180    }
181
182    fn mark_unbound(&self, configure_generation: &AtomicU64) {
183        let mut unbound = self.unbound.lock();
184        if !*unbound {
185            *unbound = true;
186            configure_generation.fetch_add(1, Ordering::SeqCst);
187        }
188    }
189
190    pub(crate) fn is_current(&self, generation: &AtomicU64, expected: u64) -> bool {
191        let unbound = self.unbound.lock();
192        !*unbound && generation.load(Ordering::SeqCst) == expected
193    }
194
195    fn advance_generation(&self, generation: &AtomicU64) -> u64 {
196        let _unbound = self.unbound.lock();
197        generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
198    }
199
200    pub(crate) fn run_if_current<R>(
201        &self,
202        generation: &AtomicU64,
203        expected: u64,
204        action: impl FnOnce() -> R,
205    ) -> Option<R> {
206        let unbound = self.unbound.lock();
207        if *unbound || generation.load(Ordering::SeqCst) != expected {
208            return None;
209        }
210        Some(action())
211    }
212
213    pub(crate) fn is_bound(&self) -> bool {
214        !*self.unbound.lock()
215    }
216
217    fn try_is_bound(&self) -> Option<bool> {
218        self.unbound.try_lock().map(|unbound| !*unbound)
219    }
220
221    fn is_unbound(&self) -> bool {
222        !self.is_bound()
223    }
224
225    fn run_if_unbound<R>(&self, action: impl FnOnce() -> R) -> Option<R> {
226        let unbound = self.unbound.lock();
227        if !*unbound {
228            return None;
229        }
230        Some(action())
231    }
232}
233
234const GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT: Duration = Duration::from_secs(5);
235const GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL: Duration = Duration::from_millis(10);
236
237/// Numeric projection for consumers that still require the legacy status-bar
238/// shape. It is derived from [`StatusBarCountValues`], which preserves whether
239/// each category is present instead of converting missing values to zero.
240#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct StatusBarCounts {
242    pub errors: usize,
243    pub warnings: usize,
244    pub dead_code: usize,
245    pub unused_exports: usize,
246    pub duplicates: usize,
247    pub todos: usize,
248    pub tier2_stale: bool,
249}
250
251/// Proven status values. A missing category has not produced a trustworthy
252/// value and remains absent instead of being converted to a clean zero.
253#[derive(Debug, Clone, Default, PartialEq, Eq)]
254pub struct StatusBarCountValues {
255    pub errors: Option<usize>,
256    pub warnings: Option<usize>,
257    pub dead_code: Option<usize>,
258    pub unused_exports: Option<usize>,
259    pub duplicates: Option<usize>,
260    pub todos: Option<usize>,
261    pub tier2_stale: bool,
262}
263
264impl StatusBarCountValues {
265    fn legacy_projection(&self) -> Option<StatusBarCounts> {
266        let [Some(dead_code), Some(unused_exports), Some(duplicates)] =
267            [self.dead_code, self.unused_exports, self.duplicates]
268        else {
269            return None;
270        };
271
272        Some(StatusBarCounts {
273            errors: self.errors.unwrap_or_default(),
274            warnings: self.warnings.unwrap_or_default(),
275            dead_code,
276            unused_exports,
277            duplicates,
278            todos: self.todos.unwrap_or_default(),
279            tier2_stale: self.tier2_stale,
280        })
281    }
282}
283
284/// Last-known Tier-2 + todos counts, refreshed off the hot path. `errors` and
285/// `warnings` are intentionally NOT cached here — they're read live per attach.
286///
287/// Each Tier-2 category is `Option`: `None` means "no scan has ever produced a
288/// count for this category", so we never fabricate a `0`. The bar is only
289/// surfaced once all three Tier-2 categories hold a real value — a partially
290/// completed cold scan (e.g. dead_code done, unused_exports/duplicates still
291/// running) must not render `D<real> U0 C0` and lie about project health (#1).
292#[derive(Debug, Clone, Default)]
293struct StatusBarTier2 {
294    dead_code: Option<usize>,
295    unused_exports: Option<usize>,
296    duplicates: Option<usize>,
297    todos: Option<usize>,
298    stale: bool,
299    generation: u64,
300    /// True when the latest dead_code aggregate reported `callgraph_available:
301    /// false` (the callgraph store was not ready when dead_code scanned). Health
302    /// uses this to tell "tier2 still building" apart from "tier2 complete except
303    /// dead_code, which is blocked on the callgraph store" — the latter must not
304    /// report "building" forever, because nothing recomputes dead_code until the
305    /// callgraph store becomes ready.
306    dead_code_blocked_on_callgraph: bool,
307}
308
309#[derive(Debug, Clone, Default)]
310struct StatusBarCache {
311    valid: bool,
312    diagnostics_generation: u64,
313    tier2_generation: u64,
314    tsconfig_generation: u64,
315    counts: Option<StatusBarCountValues>,
316}
317
318/// Deduplicates emissions of the legacy numeric status-bar projection. It only
319/// sees the projection derived from truthful values, so missing categories are
320/// not converted to zero in the underlying state.
321#[derive(Debug, Default)]
322struct LegacyStatusBarEmission(RwLock<Option<StatusBarCounts>>);
323
324impl LegacyStatusBarEmission {
325    fn should_emit(&self, counts: &StatusBarCounts) -> bool {
326        let mut last = self
327            .0
328            .write()
329            .unwrap_or_else(std::sync::PoisonError::into_inner);
330        if last.as_ref() == Some(counts) {
331            return false;
332        }
333        *last = Some(counts.clone());
334        true
335    }
336
337    fn clear(&self) {
338        *self
339            .0
340            .write()
341            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
342    }
343}
344
345#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
346#[serde(rename_all = "snake_case")]
347pub enum RootHealthState {
348    Ready,
349    Busy,
350}
351
352#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
353pub struct HealthComponentSnapshot {
354    pub status: &'static str,
355}
356
357/// Live counters for an in-progress semantic embedding build. The worker updates
358/// only atomics at batch boundaries, so progress reporting never contends with
359/// embedding requests.
360#[derive(Debug, Clone, Default)]
361pub struct SemanticBuildProgress {
362    embedded_chunks: Arc<AtomicUsize>,
363    total_chunks: Arc<AtomicUsize>,
364    current_batch: Arc<AtomicUsize>,
365    total_batches: Arc<AtomicUsize>,
366}
367
368#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
369pub struct SemanticBuildProgressSnapshot {
370    pub embedded_chunks: usize,
371    pub total_chunks: usize,
372    pub current_batch: usize,
373    pub total_batches: usize,
374}
375
376impl SemanticBuildProgress {
377    pub fn report(&self, embedded_chunks: usize, total_chunks: usize, batch_size: usize) {
378        let batch_size = batch_size.max(1);
379        let total_batches = total_chunks.div_ceil(batch_size);
380        self.total_chunks.store(total_chunks, Ordering::Relaxed);
381        self.embedded_chunks
382            .store(embedded_chunks.min(total_chunks), Ordering::Relaxed);
383        self.current_batch.store(
384            embedded_chunks.min(total_chunks).div_ceil(batch_size),
385            Ordering::Relaxed,
386        );
387        self.total_batches.store(total_batches, Ordering::Relaxed);
388    }
389
390    pub fn snapshot(&self) -> SemanticBuildProgressSnapshot {
391        SemanticBuildProgressSnapshot {
392            embedded_chunks: self.embedded_chunks.load(Ordering::Relaxed),
393            total_chunks: self.total_chunks.load(Ordering::Relaxed),
394            current_batch: self.current_batch.load(Ordering::Relaxed),
395            total_batches: self.total_batches.load(Ordering::Relaxed),
396        }
397    }
398}
399
400#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
401pub struct SemanticHealthComponentSnapshot {
402    pub status: &'static str,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub stage: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub embedded_chunks: Option<usize>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub total_chunks: Option<usize>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub current_batch: Option<usize>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub total_batches: Option<usize>,
413}
414
415#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
416pub struct Tier2HealthSnapshot {
417    pub status: &'static str,
418}
419
420#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
421pub struct SuspendedDomainHealthSnapshot {
422    pub domain: String,
423    pub reason: String,
424    pub death_count: u64,
425    pub age_s: u64,
426}
427
428#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
429pub struct RootHealthSnapshot {
430    pub project_root: String,
431    pub actor_count: usize,
432    pub state: RootHealthState,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub search_index: Option<HealthComponentSnapshot>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub semantic_index: Option<SemanticHealthComponentSnapshot>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub callgraph_store: Option<HealthComponentSnapshot>,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub callgraph_repair_entries_60s: Option<u64>,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub callgraph_commits_60s: Option<u64>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub callgraph_pages_or_bytes_written_60s: Option<u64>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub tier2: Option<Tier2HealthSnapshot>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub bash: Option<BgTaskHealthCounts>,
449    #[serde(skip_serializing_if = "Vec::is_empty")]
450    pub suspended_domains: Vec<SuspendedDomainHealthSnapshot>,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub(crate) struct RootHealthSummary {
455    state: RootHealthState,
456    search_index_status: Option<&'static str>,
457    semantic_index: Option<SemanticHealthComponentSnapshot>,
458    callgraph_store_status: Option<&'static str>,
459    tier2_status: Option<&'static str>,
460    bash: Option<BgTaskHealthCounts>,
461    suspended_domains: Vec<SuspendedDomainHealthSnapshot>,
462}
463
464impl RootHealthSummary {
465    fn busy() -> Self {
466        Self {
467            state: RootHealthState::Busy,
468            search_index_status: None,
469            semantic_index: None,
470            callgraph_store_status: None,
471            tier2_status: None,
472            bash: None,
473            suspended_domains: Vec::new(),
474        }
475    }
476
477    pub(crate) fn is_busy(&self) -> bool {
478        matches!(self.state, RootHealthState::Busy)
479    }
480
481    pub(crate) fn is_fully_ready(&self) -> bool {
482        let component_is_satisfied = |status: &str| matches!(status, "ready" | "disabled");
483        matches!(self.state, RootHealthState::Ready)
484            && self.search_index_status.is_some_and(component_is_satisfied)
485            && self
486                .semantic_index
487                .as_ref()
488                .is_some_and(|semantic| component_is_satisfied(semantic.status))
489            && self
490                .callgraph_store_status
491                .is_some_and(component_is_satisfied)
492            && self.tier2_status.is_some_and(component_is_satisfied)
493    }
494
495    pub(crate) fn into_snapshot(self, project_root: &Path) -> RootHealthSnapshot {
496        if self.is_busy() {
497            return RootHealthSnapshot::busy(project_root);
498        }
499        // Health snapshots may be assembled by the maintenance refresh, but the
500        // public snapshot helper also has latency-sensitive callers. Never derive
501        // a cache key here: derivation can spawn git and read repository state.
502        let callgraph_write_metrics =
503            crate::search_index::artifact_cache_key_memoized_only(project_root)
504                .map(|key| crate::callgraph_store::callgraph_write_metrics_for_project(&key));
505        let (callgraph_commits_60s, callgraph_pages_or_bytes_written_60s) =
506            match callgraph_write_metrics {
507                Some(metrics)
508                    if metrics.commits_60s > 0 || metrics.pages_or_bytes_written_60s > 0 =>
509                {
510                    (
511                        Some(metrics.commits_60s),
512                        Some(metrics.pages_or_bytes_written_60s),
513                    )
514                }
515                _ => (None, None),
516            };
517        RootHealthSnapshot {
518            project_root: project_root.display().to_string(),
519            actor_count: 1,
520            state: self.state,
521            search_index: self
522                .search_index_status
523                .map(|status| HealthComponentSnapshot { status }),
524            semantic_index: self.semantic_index,
525            callgraph_store: self
526                .callgraph_store_status
527                .map(|status| HealthComponentSnapshot { status }),
528            callgraph_repair_entries_60s: None,
529            callgraph_commits_60s,
530            callgraph_pages_or_bytes_written_60s,
531            tier2: self
532                .tier2_status
533                .map(|status| Tier2HealthSnapshot { status }),
534            bash: self.bash,
535            suspended_domains: self.suspended_domains,
536        }
537    }
538}
539
540impl RootHealthSnapshot {
541    fn busy(project_root: &Path) -> Self {
542        Self {
543            project_root: project_root.display().to_string(),
544            actor_count: 1,
545            state: RootHealthState::Busy,
546            search_index: None,
547            semantic_index: None,
548            callgraph_store: None,
549            callgraph_repair_entries_60s: None,
550            callgraph_commits_60s: None,
551            callgraph_pages_or_bytes_written_60s: None,
552            tier2: None,
553            bash: None,
554            suspended_domains: Vec::new(),
555        }
556    }
557
558    pub fn is_fully_ready(&self) -> bool {
559        let component_is_satisfied =
560            |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
561        let tier2_is_satisfied =
562            |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
563
564        matches!(self.state, RootHealthState::Ready)
565            && self
566                .search_index
567                .as_ref()
568                .is_some_and(component_is_satisfied)
569            && self
570                .semantic_index
571                .as_ref()
572                .is_some_and(|semantic| matches!(semantic.status, "ready" | "disabled"))
573            && self
574                .callgraph_store
575                .as_ref()
576                .is_some_and(component_is_satisfied)
577            && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
578    }
579}
580
581pub struct StatusEmitter {
582    latest: Arc<Mutex<Option<StatusPayload>>>,
583    notify: mpsc::Sender<()>,
584}
585
586#[derive(Clone, Debug, Default)]
587struct ConfigureWarmState {
588    generation: u64,
589    key: Option<String>,
590}
591
592#[derive(Debug)]
593struct ConfigurePhaseTiming {
594    phase: &'static str,
595    started_at: Instant,
596    completed: Vec<(&'static str, Duration)>,
597}
598
599impl Default for ConfigurePhaseTiming {
600    fn default() -> Self {
601        Self {
602            phase: "idle",
603            started_at: Instant::now(),
604            completed: Vec::new(),
605        }
606    }
607}
608
609#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
610pub(crate) enum WatcherDrainApplyPhase {
611    #[default]
612    PendingTier2,
613    PendingIndexes,
614    SymbolCache,
615    Callgraph,
616    SearchIndex,
617    SemanticIndex,
618    LspDiagnostics,
619    Complete,
620}
621
622#[derive(Debug, Default)]
623pub(crate) enum WatcherDrainPhase {
624    #[default]
625    Collect,
626    Apply {
627        stage: WatcherDrainApplyPhase,
628        paths: VecDeque<PathBuf>,
629        remaining: usize,
630        oversized_inline_batch: bool,
631    },
632}
633
634#[derive(Debug)]
635pub(crate) struct WatcherDrainSliceState {
636    pub(crate) configure_generation: u64,
637    /// Content identity of the configuration this continuation was built
638    /// under. A lifecycle-only generation change (route unbind/rebind with an
639    /// equivalent config) preserves the continuation by REBASING it onto the
640    /// new generation; a content change discards it (the new configuration
641    /// rebuilds artifacts wholesale).
642    pub(crate) configure_content_generation: u64,
643    pub(crate) phase: WatcherDrainPhase,
644    pub(crate) pending_paths: VecDeque<PathBuf>,
645    pub(crate) ignore_changed: bool,
646    pub(crate) rescan_required: bool,
647    pub(crate) status_changed: bool,
648    pub(crate) scheduler_changed_path_count: usize,
649    pub(crate) semantic_refresh_paths: Vec<PathBuf>,
650    pub(crate) path_slice_count: usize,
651}
652
653/// Pending watcher-derived reconciliation state taken out of the context for
654/// a transactional TTL teardown: committed (dropped) once eviction succeeds,
655/// restored when a secondary blocker aborts the eviction.
656pub(crate) struct PendingReconciliationState {
657    search: BTreeSet<PathBuf>,
658    callgraph: BTreeSet<PathBuf>,
659    tier2: BTreeSet<PathBuf>,
660    semantic: BTreeSet<PathBuf>,
661    corpus_refresh: bool,
662}
663
664impl WatcherDrainSliceState {
665    pub(crate) fn new(configure_generation: u64, configure_content_generation: u64) -> Self {
666        Self {
667            configure_generation,
668            configure_content_generation,
669            phase: WatcherDrainPhase::Collect,
670            pending_paths: VecDeque::new(),
671            ignore_changed: false,
672            rescan_required: false,
673            status_changed: false,
674            scheduler_changed_path_count: 0,
675            semantic_refresh_paths: Vec::new(),
676            path_slice_count: 0,
677        }
678    }
679
680    pub(crate) fn has_pending_work(&self) -> bool {
681        !matches!(self.phase, WatcherDrainPhase::Collect)
682            || !self.pending_paths.is_empty()
683            || self.ignore_changed
684            || self.rescan_required
685    }
686}
687
688#[doc(hidden)]
689pub enum CallGraphStoreBuildEvent {
690    Ready {
691        store: CallGraphStore,
692        fulfilled_force_token: Option<u64>,
693        publication_epoch: u64,
694    },
695    Denied {
696        reason: String,
697    },
698    Suspended {
699        suspension: crate::build_breaker::BuildSuspension,
700    },
701    Settled,
702}
703
704struct CallGraphStoreBuildSettlement {
705    tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
706    sent: bool,
707    force_token: Option<u64>,
708    publication_epoch: u64,
709}
710
711impl CallGraphStoreBuildSettlement {
712    fn new(
713        tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
714        force_token: Option<u64>,
715        publication_epoch: u64,
716    ) -> Self {
717        Self {
718            tx,
719            sent: false,
720            force_token,
721            publication_epoch,
722        }
723    }
724
725    fn ready(&mut self, store: CallGraphStore) {
726        let _ = self.tx.send(CallGraphStoreBuildEvent::Ready {
727            store,
728            fulfilled_force_token: self.force_token,
729            publication_epoch: self.publication_epoch,
730        });
731        self.sent = true;
732    }
733
734    fn denied(&mut self, reason: String) {
735        let _ = self.tx.send(CallGraphStoreBuildEvent::Denied { reason });
736        self.sent = true;
737    }
738
739    fn suspended(&mut self, suspension: crate::build_breaker::BuildSuspension) {
740        let _ = self
741            .tx
742            .send(CallGraphStoreBuildEvent::Suspended { suspension });
743        self.sent = true;
744    }
745}
746
747impl Drop for CallGraphStoreBuildSettlement {
748    fn drop(&mut self) {
749        if !self.sent {
750            let _ = self.tx.send(CallGraphStoreBuildEvent::Settled);
751        }
752    }
753}
754
755#[derive(Clone, Debug)]
756pub(crate) struct ConfigureMaintenanceJob {
757    pub(crate) generation: u64,
758    pub(crate) root_path: PathBuf,
759    pub(crate) canonical_cache_root: PathBuf,
760    pub(crate) harness: Harness,
761    pub(crate) storage_root: PathBuf,
762    pub(crate) harness_dir: PathBuf,
763    pub(crate) session_id: String,
764    pub(crate) home_match: bool,
765    pub(crate) format_tool_cache_clear_needed: bool,
766    pub(crate) run_bash_replay: bool,
767    pub(crate) refresh_project_runtime: bool,
768    pub(crate) sync_bash_compress_flag: bool,
769    pub(crate) reset_filter_registry: bool,
770    pub(crate) clear_failed_spawns: bool,
771    pub(crate) warm_callgraph_store: bool,
772    /// Advance search disk-publication epochs only after the configure
773    /// acknowledgement is sent. Updating an epoch may wait for a writer already
774    /// committing, so the initial route bind must not perform this work.
775    pub(crate) supersede_search_artifact_persistence: bool,
776    /// Advance the callgraph publication epoch only when its root/corpus inputs
777    /// changed. Unrelated reconfiguration adopts the live callgraph worker.
778    pub(crate) supersede_callgraph_artifact_persistence: bool,
779    /// Keep the adopted semantic worker's artifact-publication epoch valid so it
780    /// can still publish its result while unrelated configure work replaces the
781    /// other artifact lanes.
782    pub(crate) supersede_semantic_artifact_persistence: bool,
783    /// One-shot gates for artifact workers created during configure. The
784    /// configure tail opens them only after the bind response has been produced.
785    pub(crate) artifact_load_starts: Vec<crossbeam_channel::Sender<()>>,
786}
787
788impl StatusEmitter {
789    fn new(progress_sender: SharedProgressSender) -> Self {
790        let (notify, rx) = mpsc::channel();
791        let latest = Arc::new(Mutex::new(None));
792        let latest_for_thread = Arc::clone(&latest);
793        std::thread::spawn(move || {
794            status_debounce_loop(rx, latest_for_thread, progress_sender);
795        });
796        Self { latest, notify }
797    }
798
799    pub fn signal(&self, snapshot: StatusPayload) {
800        if let Ok(mut latest) = self.latest.lock() {
801            *latest = Some(snapshot);
802        }
803        let _ = self.notify.send(());
804    }
805}
806
807fn status_debounce_loop(
808    rx: mpsc::Receiver<()>,
809    latest: Arc<Mutex<Option<StatusPayload>>>,
810    progress_sender: SharedProgressSender,
811) {
812    while rx.recv().is_ok() {
813        let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
814        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
815            match rx.recv_timeout(remaining) {
816                Ok(()) => continue,
817                Err(mpsc::RecvTimeoutError::Timeout) => break,
818                Err(mpsc::RecvTimeoutError::Disconnected) => return,
819            }
820        }
821
822        let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
823        let Some(snapshot) = snapshot else { continue };
824        let sender = progress_sender
825            .lock()
826            .ok()
827            .and_then(|sender| sender.clone());
828        if let Some(sender) = sender {
829            sender(PushFrame::StatusChanged(StatusChangedFrame::new(
830                None, snapshot,
831            )));
832        }
833    }
834}
835use crate::cache_freshness::FileFreshness;
836use crate::search_index::SearchIndex;
837use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
838
839// `SemanticIndexStatus::Ready` exposes a unique `refreshing` path list. Keep
840// per-path queue accounting separately so repeated edits to the same file do not
841// let an older refresh completion remove the path while newer work is pending.
842#[derive(Debug, Default, Clone)]
843#[doc(hidden)]
844pub struct SemanticRefreshAccounting {
845    #[doc(hidden)]
846    pub pending: usize,
847    #[doc(hidden)]
848    pub in_flight: usize,
849}
850
851#[derive(Debug, Default)]
852struct SemanticRefreshCircuit {
853    consecutive_transient_failures: AtomicUsize,
854    open: AtomicBool,
855    probe_in_flight: AtomicBool,
856    probe_ready: AtomicBool,
857    probe_token: AtomicU64,
858}
859
860#[derive(Clone, Copy, Debug, Default)]
861pub(crate) struct SemanticColdSeedResume {
862    request_tier2: bool,
863    warm_callgraph: bool,
864}
865
866fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
867    if !refreshing.iter().any(|existing| existing == &path) {
868        refreshing.push(path);
869        refreshing.sort();
870    }
871}
872
873fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
874    refreshing.retain(|existing| existing != path);
875}
876
877#[derive(Debug, Clone)]
878pub enum SemanticIndexStatus {
879    Disabled,
880    Building {
881        /// Cold-build only — index is not queryable.
882        stage: String,
883        files: Option<usize>,
884        entries_done: Option<usize>,
885        entries_total: Option<usize>,
886    },
887    Ready {
888        /// Files currently being re-embedded after recent edits. The index is
889        /// still queryable; results for these files may be temporarily missing.
890        refreshing: Vec<PathBuf>,
891        /// Per-root queue accounting for repeated refreshes of the same path.
892        /// Kept on the status value so two AppContexts in one process cannot
893        /// share refresh-completion state.
894        #[doc(hidden)]
895        accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
896    },
897    Failed(String),
898}
899
900impl SemanticIndexStatus {
901    pub fn ready() -> Self {
902        Self::Ready {
903            refreshing: Vec::new(),
904            accounting: BTreeMap::new(),
905        }
906    }
907
908    pub fn add_refreshing_file(&mut self, path: PathBuf) {
909        if let Self::Ready {
910            refreshing,
911            accounting,
912        } = self
913        {
914            let state = accounting.entry(path.clone()).or_default();
915            state.pending = state.pending.saturating_add(1);
916            ensure_refreshing_path(refreshing, path);
917        }
918    }
919
920    pub fn start_refreshing_file(&mut self, path: PathBuf) {
921        if let Self::Ready {
922            refreshing,
923            accounting,
924        } = self
925        {
926            let state = accounting.entry(path.clone()).or_default();
927            if state.pending == 0 {
928                state.pending = 1;
929            }
930            if state.in_flight == 0 {
931                state.in_flight = state.pending;
932            }
933            ensure_refreshing_path(refreshing, path);
934        }
935    }
936
937    pub fn cancel_refreshing_file(&mut self, path: &Path) {
938        self.finish_refreshing_file(path, false);
939    }
940
941    /// Take every file currently tracked as refreshing, clearing the
942    /// accounting. Used when the refresh worker is cancelled outright: the
943    /// caller re-queues the paths for a replacement worker.
944    pub fn take_refreshing_files(&mut self) -> Vec<PathBuf> {
945        if let Self::Ready {
946            refreshing,
947            accounting,
948        } = self
949        {
950            accounting.clear();
951            std::mem::take(refreshing)
952        } else {
953            Vec::new()
954        }
955    }
956
957    /// True while a corpus-wide (not per-file) refresh is running.
958    pub fn corpus_refresh_in_flight(&self) -> bool {
959        matches!(self, Self::Building { stage, .. } if stage == "refreshing_corpus")
960    }
961
962    pub fn complete_refreshing_file(&mut self, path: &Path) {
963        self.finish_refreshing_file(path, true);
964    }
965
966    pub fn remove_refreshing_file(&mut self, path: &Path) {
967        self.complete_refreshing_file(path);
968    }
969
970    fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
971        if let Self::Ready {
972            refreshing,
973            accounting,
974        } = self
975        {
976            let mut keep_refreshing = false;
977            if let Some(state) = accounting.get_mut(path) {
978                let finished = if complete_in_flight {
979                    state.in_flight.max(1)
980                } else {
981                    1
982                };
983                state.pending = state.pending.saturating_sub(finished);
984                if complete_in_flight {
985                    state.in_flight = 0;
986                } else {
987                    state.in_flight = state.in_flight.min(state.pending);
988                }
989                keep_refreshing = state.pending > 0;
990                if !keep_refreshing {
991                    accounting.remove(path);
992                }
993            }
994
995            if !keep_refreshing {
996                remove_refreshing_path(refreshing, path);
997            }
998        }
999    }
1000
1001    pub fn refreshing_count(&self) -> usize {
1002        match self {
1003            Self::Ready { refreshing, .. } => refreshing.len(),
1004            _ => 0,
1005        }
1006    }
1007}
1008
1009pub enum SemanticIndexEvent {
1010    Progress {
1011        stage: String,
1012        files: Option<usize>,
1013        entries_done: Option<usize>,
1014        entries_total: Option<usize>,
1015    },
1016    /// Emitted when the semantic worker avoids or pauses full project corpus
1017    /// collection before reaching terminal Ready/Failed, such as after loading a
1018    /// cached index or while waiting to retry an embedding backend with no vectors
1019    /// retained. Work that was waiting for the full index can proceed.
1020    ColdSeedGateCleared,
1021    Ready(SemanticIndex),
1022    Failed(String),
1023}
1024
1025#[derive(Debug, Clone)]
1026pub enum SemanticRefreshRequest {
1027    Files {
1028        paths: Vec<PathBuf>,
1029    },
1030    /// Refresh the whole semantic corpus on the refresh worker. The worker owns
1031    /// the project walk so watcher/configure drains never do corpus-scale work
1032    /// on the single dispatch thread before scheduling embedding.
1033    Corpus,
1034}
1035
1036#[derive(Debug)]
1037pub enum SemanticRefreshEvent {
1038    Started {
1039        paths: Vec<PathBuf>,
1040    },
1041    CorpusStarted {
1042        files: usize,
1043    },
1044    Completed {
1045        added_entries: Vec<EmbeddingEntry>,
1046        updated_metadata: Vec<(PathBuf, FileFreshness)>,
1047        completed_paths: Vec<PathBuf>,
1048    },
1049    CorpusCompleted {
1050        index: SemanticIndex,
1051        changed: usize,
1052        added: usize,
1053        deleted: usize,
1054        total_processed: usize,
1055    },
1056    Failed {
1057        paths: Vec<PathBuf>,
1058        error: String,
1059    },
1060    CorpusFailed {
1061        error: String,
1062    },
1063}
1064
1065pub(crate) struct ReceiverTerminalGuard {
1066    terminal_epoch: Arc<AtomicU64>,
1067    epoch: u64,
1068}
1069
1070impl ReceiverTerminalGuard {
1071    fn new(terminal_epoch: Arc<AtomicU64>, epoch: u64) -> Self {
1072        Self {
1073            terminal_epoch,
1074            epoch,
1075        }
1076    }
1077}
1078
1079impl Drop for ReceiverTerminalGuard {
1080    fn drop(&mut self) {
1081        self.terminal_epoch.fetch_max(self.epoch, Ordering::SeqCst);
1082    }
1083}
1084
1085pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
1086
1087struct PathRestrictionContext {
1088    raw_root: PathBuf,
1089    resolved_root: PathBuf,
1090    path_for_resolution: PathBuf,
1091}
1092
1093/// Per-context memo for the configured project root used by containment checks.
1094///
1095/// `resolved_root` is the bare output from `fs::canonicalize`; it must not be
1096/// lexically normalized because it remains in the filesystem identity domain.
1097struct PathRestrictionRootMemo {
1098    configured_root: PathBuf,
1099    resolved_root: PathBuf,
1100}
1101
1102/// Normalize a path by resolving `.` and `..` components lexically,
1103/// without touching the filesystem. This prevents path traversal
1104/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
1105fn normalize_path(path: &Path) -> PathBuf {
1106    let mut result = PathBuf::new();
1107    for component in path.components() {
1108        match component {
1109            Component::ParentDir => {
1110                // Pop the last component unless we're at root or have no components
1111                if !result.pop() {
1112                    result.push(component);
1113                }
1114            }
1115            Component::CurDir => {} // Skip `.`
1116            _ => result.push(component),
1117        }
1118    }
1119    result
1120}
1121
1122fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
1123    let mut existing = path.to_path_buf();
1124    let mut tail_segments = Vec::new();
1125
1126    while !existing.exists() {
1127        if let Some(name) = existing.file_name() {
1128            tail_segments.push(name.to_owned());
1129        } else {
1130            break;
1131        }
1132
1133        existing = match existing.parent() {
1134            Some(parent) => parent.to_path_buf(),
1135            None => break,
1136        };
1137    }
1138
1139    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1140    for segment in tail_segments.into_iter().rev() {
1141        resolved.push(segment);
1142    }
1143
1144    resolved
1145}
1146
1147fn path_error_response(
1148    req_id: &str,
1149    path: &Path,
1150    resolved_root: &Path,
1151) -> crate::protocol::Response {
1152    crate::protocol::Response::error(
1153        req_id,
1154        "path_outside_root",
1155        format!(
1156            "path '{}' is outside the project root '{}'",
1157            path.display(),
1158            resolved_root.display()
1159        ),
1160    )
1161}
1162
1163/// Walk `candidate` component-by-component. For any component that is a
1164/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
1165/// reject if any hop's resolved target lies outside `resolved_root`.
1166///
1167/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
1168/// Linux with broken symlink chains pointing to non-existent destinations).
1169/// On macOS `canonicalize` also fails for broken symlinks but the returned
1170/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
1171/// form, so we must accept either form when deciding which symlinks to check.
1172fn reject_escaping_symlink(
1173    req_id: &str,
1174    original_path: &Path,
1175    candidate: &Path,
1176    resolved_root: &Path,
1177    raw_root: &Path,
1178) -> Result<(), crate::protocol::Response> {
1179    let mut current = PathBuf::new();
1180
1181    for component in candidate.components() {
1182        current.push(component);
1183
1184        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
1185            continue;
1186        };
1187
1188        if !metadata.file_type().is_symlink() {
1189            continue;
1190        }
1191
1192        // Only check symlinks that live inside the project root. This skips
1193        // OS-level prefix symlinks (macOS /var → /private/var) that are not
1194        // inside our project directory and whose "escaping" is harmless.
1195        //
1196        // We compare against BOTH the canonicalized root (resolved_root, e.g.
1197        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
1198        // because tempdir() returns raw paths while fs::canonicalize returns
1199        // the resolved form — and our `current` may be in either form.
1200        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
1201        if !inside_root {
1202            continue;
1203        }
1204
1205        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
1206    }
1207
1208    Ok(())
1209}
1210
1211/// Iteratively follow a symlink chain from `link` and reject if any hop's
1212/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
1213fn iterative_follow_chain(
1214    req_id: &str,
1215    original_path: &Path,
1216    start: &Path,
1217    resolved_root: &Path,
1218) -> Result<(), crate::protocol::Response> {
1219    let mut link = start.to_path_buf();
1220    let mut depth = 0usize;
1221
1222    loop {
1223        if depth > 40 {
1224            return Err(path_error_response(req_id, original_path, resolved_root));
1225        }
1226
1227        let target = match std::fs::read_link(&link) {
1228            Ok(t) => t,
1229            Err(_) => {
1230                // Can't read the link — treat as escaping to be safe.
1231                return Err(path_error_response(req_id, original_path, resolved_root));
1232            }
1233        };
1234
1235        let resolved_target = if target.is_absolute() {
1236            normalize_path(&target)
1237        } else {
1238            let parent = link.parent().unwrap_or_else(|| Path::new(""));
1239            normalize_path(&parent.join(&target))
1240        };
1241
1242        // Check boundary: use canonicalized target when available (handles
1243        // macOS /var → /private/var aliasing), fall back to the normalized
1244        // path when canonicalize fails (e.g. broken symlink on Linux).
1245        let canonical_target =
1246            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
1247
1248        if !canonical_target.starts_with(resolved_root)
1249            && !resolved_target.starts_with(resolved_root)
1250        {
1251            return Err(path_error_response(req_id, original_path, resolved_root));
1252        }
1253
1254        // If the target is itself a symlink, follow the next hop.
1255        match std::fs::symlink_metadata(&resolved_target) {
1256            Ok(meta) if meta.file_type().is_symlink() => {
1257                link = resolved_target;
1258                depth += 1;
1259            }
1260            _ => break, // Non-symlink or non-existent target — chain ends here.
1261        }
1262    }
1263
1264    Ok(())
1265}
1266
1267pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
1268
1269pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
1270    Box::new(TreeSitterProvider::new())
1271}
1272
1273fn database_path_key(path: &Path) -> PathBuf {
1274    if let Ok(canonical) = std::fs::canonicalize(path) {
1275        return canonical;
1276    }
1277    let Some(parent) = path.parent() else {
1278        return path.to_path_buf();
1279    };
1280    let canonical_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
1281    path.file_name()
1282        .map(|name| canonical_parent.join(name))
1283        .unwrap_or_else(|| canonical_parent.join(path))
1284}
1285
1286/// Process-global services shared by all project actors in this AFT process.
1287///
1288/// `App` owns only true process services. Per-root caches and the live
1289/// language provider instance stay in [`AppContext`].
1290pub struct App {
1291    /// One process-wide handle for the current AFT database. Every project
1292    /// actor points at this handle so roots do not open duplicate SQLite/WAL
1293    /// descriptors for the same database.
1294    db: parking_lot::Mutex<Option<(PathBuf, Arc<Mutex<Connection>>)>>,
1295    active_watchers: AtomicUsize,
1296    active_actor_roots: AtomicUsize,
1297    open_routes: AtomicUsize,
1298    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
1299    stdout_writer: SharedStdoutWriter,
1300    provider_factory: LanguageProviderFactory,
1301    /// Weak actor references let status attribute process RSS across roots
1302    /// without making the process-global App own per-root caches.
1303    memory_contexts: parking_lot::Mutex<BTreeMap<PathBuf, Weak<AppContext>>>,
1304}
1305
1306impl App {
1307    pub fn new(provider_factory: LanguageProviderFactory) -> Self {
1308        Self {
1309            db: parking_lot::Mutex::new(None),
1310            active_watchers: AtomicUsize::new(0),
1311            active_actor_roots: AtomicUsize::new(0),
1312            open_routes: AtomicUsize::new(0),
1313            lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
1314            stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
1315            provider_factory,
1316            memory_contexts: parking_lot::Mutex::new(BTreeMap::new()),
1317        }
1318    }
1319
1320    /// Create the shared process `App` handle required by the actor split.
1321    pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
1322        Arc::new(Self::new(provider_factory))
1323    }
1324
1325    pub fn default_shared() -> Arc<Self> {
1326        Self::shared(default_language_provider_factory)
1327    }
1328
1329    pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
1330        (self.provider_factory)()
1331    }
1332
1333    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1334        self.lsp_child_registry.clone()
1335    }
1336
1337    pub fn stdout_writer(&self) -> SharedStdoutWriter {
1338        Arc::clone(&self.stdout_writer)
1339    }
1340
1341    pub(crate) fn register_memory_context(&self, root: PathBuf, ctx: &Arc<AppContext>) {
1342        let mut contexts = self.memory_contexts.lock();
1343        contexts.retain(|_, context| context.strong_count() > 0);
1344        contexts.insert(root, Arc::downgrade(ctx));
1345    }
1346
1347    pub(crate) fn unregister_memory_context(&self, root: &Path, ctx: &Arc<AppContext>) {
1348        let mut contexts = self.memory_contexts.lock();
1349        let removes_current = contexts
1350            .get(root)
1351            .and_then(Weak::upgrade)
1352            .is_some_and(|registered| Arc::ptr_eq(&registered, ctx));
1353        if removes_current {
1354            contexts.remove(root);
1355        }
1356    }
1357
1358    /// Snapshot process roots without waiting behind actor registration. A busy
1359    /// registry is surfaced as a named status gap by the memory snapshot.
1360    pub(crate) fn try_memory_contexts(&self) -> Option<Vec<(PathBuf, Arc<AppContext>)>> {
1361        let contexts = self.memory_contexts.try_lock()?;
1362        Some(
1363            contexts
1364                .iter()
1365                .filter_map(|(root, context)| {
1366                    context.upgrade().map(|context| (root.clone(), context))
1367                })
1368                .collect(),
1369        )
1370    }
1371
1372    pub(crate) fn adopt_resident_semantic_index(
1373        &self,
1374        artifact_cache_key: &str,
1375        borrower_root: &Path,
1376        semantic_config: &crate::config::SemanticBackendConfig,
1377    ) -> Option<SemanticIndex> {
1378        let contexts = {
1379            let mut contexts = self.memory_contexts.lock();
1380            contexts.retain(|_, context| context.strong_count() > 0);
1381            contexts
1382                .iter()
1383                .filter_map(|(root, context)| {
1384                    context.upgrade().map(|context| (root.clone(), context))
1385                })
1386                .collect::<Vec<_>>()
1387        };
1388
1389        // Normalize comparison-local copies only. Context caches stay keyed by
1390        // their original root spelling, so successful candidates are looked up
1391        // with the context's stored cache root rather than a normalized re-key.
1392        let normalized_borrower = crate::inspect::job::canonicalize_normalized(borrower_root);
1393        contexts
1394            .into_iter()
1395            .filter_map(|(registered_root, context)| {
1396                let normalized_registered =
1397                    crate::inspect::job::canonicalize_normalized(&registered_root);
1398                if normalized_registered == normalized_borrower {
1399                    return None;
1400                }
1401                let cache_root = context.canonical_cache_root_opt()?;
1402                if normalized_registered
1403                    != crate::inspect::job::canonicalize_normalized(&cache_root)
1404                {
1405                    return None;
1406                }
1407                Some((cache_root, context))
1408            })
1409            .find_map(|(cache_root, context)| {
1410                if context.cached_artifact_cache_key(&cache_root).as_deref()
1411                    != Some(artifact_cache_key)
1412                    || !matches!(
1413                        &*context
1414                            .semantic_index_status()
1415                            .read()
1416                            .unwrap_or_else(std::sync::PoisonError::into_inner),
1417                        SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
1418                    )
1419                {
1420                    return None;
1421                }
1422                context
1423                    .semantic_index()
1424                    .write()
1425                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1426                    .as_mut()?
1427                    .adopt_frozen_base_for_root(borrower_root, semantic_config)
1428            })
1429    }
1430
1431    /// Return the process-shared database handle, opening it only when the
1432    /// requested path is not already resident. The connection mutex serializes
1433    /// transactions from all roots; callers never hold the App lock while using
1434    /// the returned connection.
1435    pub fn open_db(&self, path: &Path) -> Result<Arc<Mutex<Connection>>, crate::db::OpenError> {
1436        let key = database_path_key(path);
1437        let mut slot = self.db.lock();
1438        if let Some((existing_path, conn)) = slot.as_ref() {
1439            if existing_path == &key {
1440                return Ok(Arc::clone(conn));
1441            }
1442        }
1443
1444        let conn = Arc::new(Mutex::new(crate::db::open(path)?));
1445        *slot = Some((key, Arc::clone(&conn)));
1446        Ok(conn)
1447    }
1448
1449    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1450        *self.db.lock() = Some((PathBuf::new(), conn));
1451    }
1452
1453    pub fn clear_db(&self) {
1454        *self.db.lock() = None;
1455    }
1456
1457    /// Clear the shared handle only when it still refers to `path`. A failed
1458    /// reconfigure for one root must not tear down a database used by another
1459    /// root.
1460    pub fn clear_db_for_path(&self, path: &Path) {
1461        let key = database_path_key(path);
1462        let mut slot = self.db.lock();
1463        if slot.as_ref().is_some_and(|(existing_path, _)| {
1464            existing_path.as_os_str().is_empty() || existing_path == &key
1465        }) {
1466            *slot = None;
1467        }
1468    }
1469
1470    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1471        self.db.lock().as_ref().map(|(_, conn)| Arc::clone(conn))
1472    }
1473
1474    pub(crate) fn watcher_started(&self) {
1475        self.active_watchers.fetch_add(1, Ordering::SeqCst);
1476    }
1477
1478    pub(crate) fn watcher_stopped(&self) {
1479        self.active_watchers
1480            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1481                Some(count.saturating_sub(1))
1482            })
1483            .ok();
1484    }
1485
1486    /// Number of live watcher filter runtimes registered by this process.
1487    /// A runtime remains counted until its OS watcher thread has actually exited.
1488    pub fn watcher_count(&self) -> usize {
1489        self.active_watchers.load(Ordering::SeqCst)
1490    }
1491
1492    pub(crate) fn actor_root_registered(&self) {
1493        self.active_actor_roots.fetch_add(1, Ordering::SeqCst);
1494    }
1495
1496    pub(crate) fn actor_root_unregistered(&self) {
1497        self.active_actor_roots
1498            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1499                Some(count.saturating_sub(1))
1500            })
1501            .ok();
1502    }
1503
1504    pub fn actor_root_count(&self) -> usize {
1505        self.active_actor_roots.load(Ordering::SeqCst)
1506    }
1507
1508    pub(crate) fn set_open_route_count(&self, count: usize) {
1509        self.open_routes.store(count, Ordering::SeqCst);
1510    }
1511
1512    pub fn open_route_count(&self) -> usize {
1513        self.open_routes.load(Ordering::SeqCst)
1514    }
1515}
1516
1517impl Default for App {
1518    fn default() -> Self {
1519        Self::new(default_language_provider_factory)
1520    }
1521}
1522
1523const _: fn() = || {
1524    fn assert_send_sync<T: Send + Sync>() {}
1525    fn assert_send<T: Send>() {}
1526
1527    assert_send_sync::<App>();
1528    assert_send_sync::<AppContext>();
1529    assert_send::<crate::lsp::manager::LspManager>();
1530    assert_send::<crate::semantic_index::EmbeddingModel>();
1531};
1532
1533#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1534enum GitEntryKind {
1535    Missing,
1536    File,
1537    Directory,
1538    Other,
1539}
1540
1541#[derive(Clone, Debug, PartialEq, Eq)]
1542struct GitEntrySignature {
1543    kind: GitEntryKind,
1544    modified: Option<SystemTime>,
1545}
1546
1547#[derive(Clone, Debug)]
1548struct WorktreeBridgeCacheEntry {
1549    git_entry: GitEntrySignature,
1550    is_worktree_bridge: bool,
1551    git_common_dir: Option<PathBuf>,
1552}
1553
1554pub(crate) const BORROWED_INDEX_CACHE_CAPACITY: usize = 4;
1555
1556#[derive(Clone, Debug, PartialEq, Eq)]
1557struct BorrowedIndexCacheKey {
1558    canonical_root: PathBuf,
1559    artifact: crate::readonly_artifacts::BorrowedArtifactGeneration,
1560}
1561
1562#[derive(Clone, Debug)]
1563enum BorrowedIndexCacheValue {
1564    Search(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>),
1565    Semantic(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>),
1566}
1567
1568#[derive(Debug, Default)]
1569struct BorrowedIndexCache {
1570    entries: VecDeque<(BorrowedIndexCacheKey, BorrowedIndexCacheValue)>,
1571    resolved_roots: VecDeque<(PathBuf, GitEntrySignature)>,
1572}
1573
1574impl BorrowedIndexCache {
1575    fn search(
1576        &mut self,
1577        key: &BorrowedIndexCacheKey,
1578    ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>> {
1579        let position = self.entries.iter().position(|(candidate, value)| {
1580            candidate == key && matches!(value, BorrowedIndexCacheValue::Search(_))
1581        })?;
1582        let entry = self.entries.remove(position)?;
1583        let BorrowedIndexCacheValue::Search(index) = &entry.1 else {
1584            return None;
1585        };
1586        let index = (*index).clone();
1587        self.entries.push_back(entry);
1588        Some(index)
1589    }
1590
1591    fn semantic(
1592        &mut self,
1593        key: &BorrowedIndexCacheKey,
1594    ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>> {
1595        let position = self.entries.iter().position(|(candidate, value)| {
1596            candidate == key && matches!(value, BorrowedIndexCacheValue::Semantic(_))
1597        })?;
1598        let entry = self.entries.remove(position)?;
1599        let BorrowedIndexCacheValue::Semantic(index) = &entry.1 else {
1600            return None;
1601        };
1602        let index = (*index).clone();
1603        self.entries.push_back(entry);
1604        Some(index)
1605    }
1606
1607    fn insert(&mut self, key: BorrowedIndexCacheKey, value: BorrowedIndexCacheValue) {
1608        self.entries.retain(|(candidate, _)| {
1609            candidate.canonical_root != key.canonical_root
1610                || candidate.artifact.path != key.artifact.path
1611        });
1612        self.entries.push_back((key, value));
1613        while self.entries.len() > BORROWED_INDEX_CACHE_CAPACITY {
1614            self.entries.pop_front();
1615        }
1616    }
1617
1618    fn resolved_root(&mut self, requested_root: &Path) -> Option<PathBuf> {
1619        let position = self
1620            .resolved_roots
1621            .iter()
1622            .position(|(candidate, _)| candidate == requested_root)?;
1623        let entry = self.resolved_roots.remove(position)?;
1624        if entry.1 != git_entry_signature(requested_root) {
1625            return None;
1626        }
1627        let root = entry.0.clone();
1628        self.resolved_roots.push_back(entry);
1629        Some(root)
1630    }
1631
1632    fn remember_resolved_root(&mut self, root: PathBuf) {
1633        self.resolved_roots
1634            .retain(|(candidate, _)| candidate != &root);
1635        let signature = git_entry_signature(&root);
1636        self.resolved_roots.push_back((root, signature));
1637        while self.resolved_roots.len() > BORROWED_INDEX_CACHE_CAPACITY {
1638            self.resolved_roots.pop_front();
1639        }
1640    }
1641
1642    fn clear(&mut self) {
1643        self.entries.clear();
1644        self.resolved_roots.clear();
1645    }
1646}
1647
1648fn git_entry_signature(project_root: &Path) -> GitEntrySignature {
1649    match std::fs::symlink_metadata(project_root.join(".git")) {
1650        Ok(metadata) => GitEntrySignature {
1651            kind: if metadata.file_type().is_file() {
1652                GitEntryKind::File
1653            } else if metadata.file_type().is_dir() {
1654                GitEntryKind::Directory
1655            } else {
1656                GitEntryKind::Other
1657            },
1658            modified: metadata.modified().ok(),
1659        },
1660        Err(error) if error.kind() == io::ErrorKind::NotFound => GitEntrySignature {
1661            kind: GitEntryKind::Missing,
1662            modified: None,
1663        },
1664        Err(_) => GitEntrySignature {
1665            kind: GitEntryKind::Other,
1666            modified: None,
1667        },
1668    }
1669}
1670
1671/// Shared application context threaded through all command handlers.
1672///
1673/// Holds the language provider, backup/checkpoint stores, and configuration.
1674/// Constructed once at startup and passed by
1675/// reference to `dispatch`.
1676///
1677/// Write-rarely stores use `parking_lot::Mutex` for interior mutability so this
1678/// context can become thread-safe while preserving the current single-request
1679/// dispatch behavior. `config` is a thread-safe owned snapshot so future
1680/// read-only dispatch can hold configuration across other work without holding
1681/// a lock guard.
1682pub struct AppContext {
1683    app: Arc<App>,
1684    provider: Box<dyn LanguageProvider>,
1685    backup: parking_lot::Mutex<BackupStore>,
1686    checkpoint: parking_lot::Mutex<CheckpointStore>,
1687    config: RwLock<Arc<Config>>,
1688    /// Per-root-actor memo for containment checks. The key is the configured
1689    /// root's exact `PathBuf` spelling, so reconfiguration never reuses a
1690    /// canonical root selected for another configured value.
1691    path_restriction_root_memo: parking_lot::Mutex<Option<PathRestrictionRootMemo>>,
1692    #[cfg(test)]
1693    path_restriction_root_canonicalizations: AtomicUsize,
1694    force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
1695    pub harness: parking_lot::Mutex<Option<Harness>>,
1696    canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
1697    is_worktree_bridge: parking_lot::Mutex<bool>,
1698    git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
1699    shared_artifacts_read_only: AtomicBool,
1700    /// Standalone NDJSON requests may borrow a finite CLI snapshot after the
1701    /// writer has exited; daemon-bound routes keep their live freshness owner.
1702    daemonless_query_mode: AtomicBool,
1703    callgraph_writer: AtomicBool,
1704    inspect_writer: AtomicBool,
1705    artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
1706    artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
1707    /// Reasons (if any) why heavy AFT subsystems were auto-disabled for the
1708    /// current project root. Populated by `handle_configure` based on the
1709    /// canonical project root. Each reason is a stable machine-readable string
1710    /// (e.g. `"home_root"`, `"watcher_unavailable"`) so the plugin can render
1711    /// distinct degraded-mode UI states without re-deriving the reason locally.
1712    /// Empty when the project is healthy / full-featured.
1713    degraded_reasons: parking_lot::Mutex<Vec<String>>,
1714    /// Configure-time gate for project-wide scans, builds, and watcher-driven
1715    /// refreshes that would otherwise walk the whole root. `handle_configure`
1716    /// closes it for degraded home roots and every heavy-work entry point reads
1717    /// the same atomic so the decision cannot drift after configure returns.
1718    heavy_root_work_allowed: Arc<AtomicBool>,
1719    /// Standing roots retain artifacts across idle session reaping, but they
1720    /// remain subject to every verification and publication fence.
1721    standing_artifact_exempt: AtomicBool,
1722    cold_build_limiter: RwLock<Arc<crate::cold_build_limiter::ColdBuildLimiter>>,
1723    callgraph_store: Arc<RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1724    callgraph_store_force_requested: AtomicU64,
1725    callgraph_store_force_fulfilled: AtomicU64,
1726    callgraph_store_rx:
1727        parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>>,
1728    callgraph_store_rx_generation: AtomicU64,
1729    callgraph_store_rx_epoch: AtomicU64,
1730    callgraph_store_build_denied: parking_lot::Mutex<Option<(u64, String)>>,
1731    callgraph_store_build_suspension:
1732        parking_lot::Mutex<Option<(u64, crate::build_breaker::BuildSuspension)>>,
1733    /// Health probes run the durable query outside reply handling and copy its
1734    /// results into this small snapshot. Reply handling uses only `try_read` on
1735    /// this lock, avoiding SQLite and other blocking work.
1736    health_build_suspensions: RwLock<Vec<SuspendedDomainHealthSnapshot>>,
1737    callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1738    callgraph_legacy_migration_summary_logged: Arc<AtomicBool>,
1739    pending_callgraph_store_paths: crate::callgraph_store::PendingCallGraphStorePaths,
1740    search_index: RwLock<Option<SearchIndex>>,
1741    search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
1742    search_index_rx_generation: AtomicU64,
1743    search_index_rx_epoch: AtomicU64,
1744    search_index_rx_terminal_epoch: Arc<AtomicU64>,
1745    /// `(configure_generation, automatic_replacement_attempts)`. Caps the
1746    /// drain-path replacement of a search-index load whose worker disconnected
1747    /// without delivering an index, so a persistently failing worker cannot be
1748    /// relaunched in a loop on the drain thread. Resets when the configure
1749    /// generation advances.
1750    search_index_disconnect_reschedule: parking_lot::Mutex<(u64, u32)>,
1751    search_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1752    pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1753    symbol_cache: SharedSymbolCache,
1754    inspect_manager: Arc<InspectManager>,
1755    tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
1756    pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1757    semantic_index: RwLock<Option<SemanticIndex>>,
1758    semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
1759    semantic_index_rx_generation: AtomicU64,
1760    semantic_index_rx_epoch: AtomicU64,
1761    semantic_index_rx_terminal_epoch: Arc<AtomicU64>,
1762    semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1763    semantic_persist_lock: Arc<parking_lot::Mutex<()>>,
1764    semantic_index_status: RwLock<SemanticIndexStatus>,
1765    /// Present only while a cold semantic build is running. Its counters are
1766    /// read by status and health without taking the worker's batch-loop locks.
1767    semantic_build_progress: RwLock<Option<SemanticBuildProgress>>,
1768    /// Advances when the inputs that determine a semantic corpus build change.
1769    /// Unrelated configure changes adopt the existing worker instead.
1770    semantic_build_epoch: Arc<AtomicU64>,
1771    /// Serializes missing-artifact checks with receiver installation so
1772    /// concurrent fallback queries cannot start duplicate reload workers.
1773    artifact_reload_lock: parking_lot::Mutex<()>,
1774    /// True while this context has a cold semantic seed scheduled or actively
1775    /// collecting/embedding/persisting the full project corpus. The semantic
1776    /// worker clears it as soon as it proves the cached/incremental path is in use.
1777    semantic_cold_seed_active: Arc<AtomicBool>,
1778    /// Monotonic generation that prevents a superseded semantic worker from
1779    /// reopening the cold-seed gate after a later configure has reset it.
1780    semantic_cold_seed_generation: Arc<AtomicU64>,
1781    semantic_fingerprint_generation: Arc<AtomicU64>,
1782    semantic_callgraph_warm_deferred: AtomicBool,
1783    pending_semantic_index_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
1784    pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
1785    semantic_refresh_tx:
1786        Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
1787    semantic_refresh_event_rx:
1788        parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
1789    semantic_refresh_generation: AtomicU64,
1790    semantic_refresh_epoch: AtomicU64,
1791    semantic_refresh_build_epoch: AtomicU64,
1792    semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
1793    semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
1794    semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
1795    semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
1796    watcher_runtime_lock: parking_lot::Mutex<()>,
1797    watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
1798    watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
1799    watcher_drain_slice: parking_lot::Mutex<Option<WatcherDrainSliceState>>,
1800    watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
1801    lsp_manager: parking_lot::Mutex<LspManager>,
1802    configure_generation: Arc<AtomicU64>,
1803    /// Advances only when the warm configuration changes, not on route
1804    /// teardown. Already-admitted workers use it to decide whether their disk
1805    /// artifact is still configuration-compatible after becoming unbound.
1806    configure_content_generation: Arc<AtomicU64>,
1807    /// Set only by the daemon route lifecycle. Standalone contexts remain bound.
1808    /// Deferred maintenance uses the same gate for the state check and admission.
1809    subc_lifecycle: SubcLifecycleAdmission,
1810    configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
1811    /// Identity of the inputs that govern callgraph disk publication. It is
1812    /// narrower than the all-artifact warm key so unrelated lanes can rebind
1813    /// without superseding a valid callgraph build.
1814    callgraph_build_key: parking_lot::Mutex<Option<String>>,
1815    configure_phase_timing: parking_lot::Mutex<ConfigurePhaseTiming>,
1816    configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
1817    hashline_bindings: crate::hashline::integration::BindingRegistry,
1818    configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
1819    artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
1820    artifact_cache_key_derivations: AtomicU64,
1821    borrowed_index_cache: parking_lot::Mutex<BorrowedIndexCache>,
1822    /// Successful git worktree probes, keyed by canonical root and guarded by
1823    /// the root's `.git` entry shape and modification time.
1824    worktree_bridge_cache: parking_lot::Mutex<BTreeMap<PathBuf, WorktreeBridgeCacheEntry>>,
1825    #[cfg(test)]
1826    worktree_bridge_probe_spawns: AtomicU64,
1827    #[cfg(test)]
1828    force_worktree_bridge_reprobe: AtomicBool,
1829    /// Last-seen value of `InspectManager::reuse_completion_count()`, so the
1830    /// per-request inspect drain can detect watcher-driven Tier-2 scans that
1831    /// finished since the previous tick and refresh the status bar (#3).
1832    last_seen_reuse_completions: AtomicU64,
1833    configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
1834    configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
1835    /// Per-context push sender slot. Status and background-bash emitters share
1836    /// this Arc so a sender installed after construction is observed at emit time.
1837    progress_sender: SharedProgressSender,
1838    status_emitter: StatusEmitter,
1839    /// Present only for daemon-bound actors. Standalone NDJSON contexts never
1840    /// acquire a status-holder transport and therefore keep the solo bar path.
1841    fleet_status_client: RwLock<Option<crate::fleet_status::FleetStatusClient>>,
1842    /// Temporary state used to avoid repeatedly emitting the legacy status-bar
1843    /// response fields. It is no longer needed once those fields are removed.
1844    status_bar_last_emitted: LegacyStatusBarEmission,
1845    /// The omission-preserving source of truth. Legacy projections never enter
1846    /// this cache, so a cache hit cannot recreate an absent category as zero.
1847    status_bar_cached: RwLock<StatusBarCache>,
1848    /// Authoritative diagnostics observations are retained per session and
1849    /// producer. Only explicit observation sources may mutate this state.
1850    alert_state: parking_lot::Mutex<AlertDeltaState>,
1851    compression_aggregates: Arc<crate::db::compression_events::CompressionAggregateCache>,
1852    bash_background: BgTaskRegistry,
1853    #[cfg(unix)]
1854    escalation_grants: parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore>,
1855    /// Thread-safe registry of TOML output filters. Lazy-built on first
1856    /// access; populated atomically via `RwLock`. Shared between command
1857    /// handlers (which use it through `filter_registry()` -> read guard) and
1858    /// the `BgTaskRegistry` watchdog thread (which uses it through
1859    /// `compress::compress_with_registry`). Reloaded when configure changes
1860    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
1861    filter_registry: crate::compress::SharedFilterRegistry,
1862    filter_registry_rebuild_count: AtomicU64,
1863    /// Set to true once the filter_registry has been populated. Avoids
1864    /// double-loading on hot paths without holding a write lock.
1865    filter_registry_loaded: std::sync::atomic::AtomicBool,
1866    /// Live `experimental.bash.compress` flag, kept in sync with `config`
1867    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
1868    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
1869    /// holding the config refcell.
1870    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
1871    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
1872    /// whenever `project_root` changes or a watcher event reports a
1873    /// `.gitignore` write. Used by the watcher event filter to decide which
1874    /// path-changes are interesting to AFT's caches. `None` when no project
1875    /// root is configured or when the project has no gitignore files; in that
1876    /// case the watcher falls back to a small hardcoded infra-directory skip.
1877    gitignore: SharedGitignore,
1878    gitignore_generation: Arc<AtomicU64>,
1879    /// Last-known Tier-2 + todos counts for the agent status bar, refreshed off
1880    /// the hot path (on `aft_inspect` reads and background Tier-2 completions).
1881    /// Errors/warnings are read live and not stored here.
1882    status_bar_tier2: RwLock<StatusBarTier2>,
1883    /// Persistent TypeScript-project membership cache for the status-bar E/W
1884    /// count. The bar reads E/W live on every tool result, so resolving the
1885    /// nearest tsconfig (read + parse + glob-compile) per drain is too costly;
1886    /// this memoizes per tsconfig dir. Invalidated wholesale on any
1887    /// tsconfig-like watcher event and on `configure`. Owned here (not in
1888    /// `DiagnosticsStore`, which stays raw policy-free) per the v0.35 council.
1889    tsconfig_membership:
1890        parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
1891}
1892
1893/// RAII guard for a server-owned request-scoped path-restriction override.
1894///
1895/// Guards are refcounted by request id so duplicated ids over-restrict until the
1896/// last worker exits, rather than letting one completion disable another
1897/// in-flight request's containment.
1898pub struct ForceRestrictGuard<'a> {
1899    ctx: &'a AppContext,
1900    req_id: String,
1901}
1902
1903impl Drop for ForceRestrictGuard<'_> {
1904    fn drop(&mut self) {
1905        self.ctx.release_force_restrict(&self.req_id);
1906    }
1907}
1908
1909impl Drop for AppContext {
1910    fn drop(&mut self) {
1911        self.artifact_owner_lease.get_mut().take();
1912        if let Some(runtime) = self.watcher_thread.get_mut().take() {
1913            let root = self
1914                .canonical_cache_root
1915                .get_mut()
1916                .clone()
1917                .or_else(|| {
1918                    self.config
1919                        .get_mut()
1920                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1921                        .project_root
1922                        .clone()
1923                })
1924                .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
1925            Self::spawn_watcher_shutdown(Arc::clone(&self.app), root, runtime);
1926        }
1927    }
1928}
1929
1930/// Result of requesting the persisted callgraph store for a store-backed op.
1931///
1932/// The five edge-query ops never block the request thread on a cold build:
1933/// a genuine cold build is kicked off in the background and `Building` is
1934/// returned so the agent retries, mirroring how semantic search reports a
1935/// build in progress. Warm restarts open the on-disk DB synchronously, so
1936/// `Building` is only ever seen during a true first cold build.
1937pub enum CallgraphStoreAccess {
1938    /// Store is resident and queryable.
1939    Ready(Arc<ReadonlyCallGraphStore>),
1940    /// A cold build is in flight (or was just started); retry shortly.
1941    Building,
1942    /// The durable build-death breaker refuses this domain until an explicit reset
1943    /// or a time-to-live check confirms the suspension has expired.
1944    Suspended(crate::build_breaker::BuildSuspension),
1945    /// Not configured, or a read-only worktree whose store was never built.
1946    Unavailable,
1947    /// A store open/build check failed with a real error (DB/IO).
1948    Error(CallGraphStoreError),
1949}
1950
1951#[derive(Clone, Copy)]
1952enum CallgraphBackgroundWork {
1953    Ensure,
1954    ForceRebuild(u64),
1955    LegacyMigration,
1956}
1957
1958#[cfg(test)]
1959struct CallgraphBuildStartGate {
1960    root: PathBuf,
1961    reached: crossbeam_channel::Sender<()>,
1962    release: crossbeam_channel::Receiver<()>,
1963}
1964
1965#[cfg(test)]
1966static CALLGRAPH_BUILD_START_GATE: std::sync::OnceLock<
1967    parking_lot::Mutex<Option<CallgraphBuildStartGate>>,
1968> = std::sync::OnceLock::new();
1969
1970#[cfg(test)]
1971fn install_callgraph_build_start_gate(
1972    root: PathBuf,
1973) -> (
1974    crossbeam_channel::Receiver<()>,
1975    crossbeam_channel::Sender<()>,
1976) {
1977    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1978    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1979    *CALLGRAPH_BUILD_START_GATE
1980        .get_or_init(|| parking_lot::Mutex::new(None))
1981        .lock() = Some(CallgraphBuildStartGate {
1982        root,
1983        reached: reached_tx,
1984        release: release_rx,
1985    });
1986    (reached_rx, release_tx)
1987}
1988
1989#[cfg(test)]
1990pub(crate) fn install_callgraph_build_start_gate_for_test(
1991    root: PathBuf,
1992) -> (
1993    crossbeam_channel::Receiver<()>,
1994    crossbeam_channel::Sender<()>,
1995) {
1996    install_callgraph_build_start_gate(root)
1997}
1998
1999#[cfg(test)]
2000static CALLGRAPH_BUILD_WAIT_MS_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
2001    std::sync::OnceLock::new();
2002
2003#[cfg(test)]
2004pub(crate) struct CallgraphBuildWaitMsGuard {
2005    _guard: std::sync::MutexGuard<'static, ()>,
2006    previous: Option<std::ffi::OsString>,
2007}
2008
2009#[cfg(test)]
2010impl Drop for CallgraphBuildWaitMsGuard {
2011    fn drop(&mut self) {
2012        // SAFETY: serialized by CALLGRAPH_BUILD_WAIT_MS_LOCK for this guard's
2013        // lifetime, and restored before the lock is released.
2014        unsafe {
2015            match &self.previous {
2016                Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2017                None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
2018            }
2019        }
2020    }
2021}
2022
2023/// Serialize test overrides of the query-op inline wait. Configure-tail tests
2024/// share this with query-op tests so they cannot clobber each other's env.
2025#[cfg(test)]
2026pub(crate) fn override_callgraph_build_wait_ms_for_test(ms: u64) -> CallgraphBuildWaitMsGuard {
2027    let guard = crate::test_env::lock_test_mutex(
2028        CALLGRAPH_BUILD_WAIT_MS_LOCK.get_or_init(|| std::sync::Mutex::new(())),
2029    );
2030    let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
2031    // SAFETY: serialized by CALLGRAPH_BUILD_WAIT_MS_LOCK and restored on drop.
2032    unsafe {
2033        std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
2034    }
2035    CallgraphBuildWaitMsGuard {
2036        _guard: guard,
2037        previous,
2038    }
2039}
2040
2041#[cfg(test)]
2042fn wait_on_callgraph_build_start_gate(root: &Path) {
2043    let mut slot = CALLGRAPH_BUILD_START_GATE
2044        .get_or_init(|| parking_lot::Mutex::new(None))
2045        .lock();
2046    if !slot.as_ref().is_some_and(|gate| gate.root == root) {
2047        return;
2048    }
2049    let gate = slot.take();
2050    drop(slot);
2051    if let Some(gate) = gate {
2052        let _ = gate.reached.send(());
2053        let _ = gate.release.recv();
2054    }
2055}
2056
2057#[cfg(not(test))]
2058fn wait_on_callgraph_build_start_gate(_root: &Path) {}
2059
2060#[cfg(test)]
2061static CALLGRAPH_POINTER_REMOVAL_ARMS: std::sync::OnceLock<parking_lot::Mutex<BTreeSet<PathBuf>>> =
2062    std::sync::OnceLock::new();
2063
2064#[cfg(test)]
2065struct RemoveCallgraphPointerBeforeInlineReopenGuard {
2066    pointer: PathBuf,
2067}
2068
2069#[cfg(test)]
2070impl Drop for RemoveCallgraphPointerBeforeInlineReopenGuard {
2071    fn drop(&mut self) {
2072        CALLGRAPH_POINTER_REMOVAL_ARMS
2073            .get_or_init(|| parking_lot::Mutex::new(BTreeSet::new()))
2074            .lock()
2075            .remove(&self.pointer);
2076    }
2077}
2078
2079#[cfg(test)]
2080fn install_callgraph_pointer_removal_arm(
2081    pointer: PathBuf,
2082) -> RemoveCallgraphPointerBeforeInlineReopenGuard {
2083    let inserted = CALLGRAPH_POINTER_REMOVAL_ARMS
2084        .get_or_init(|| parking_lot::Mutex::new(BTreeSet::new()))
2085        .lock()
2086        .insert(pointer.clone());
2087    assert!(inserted, "callgraph pointer removal arm already installed");
2088    RemoveCallgraphPointerBeforeInlineReopenGuard { pointer }
2089}
2090
2091#[cfg(test)]
2092fn remove_armed_callgraph_pointer_for_test(pointer: &Path) {
2093    let armed = CALLGRAPH_POINTER_REMOVAL_ARMS
2094        .get_or_init(|| parking_lot::Mutex::new(BTreeSet::new()))
2095        .lock()
2096        .remove(pointer);
2097    if armed {
2098        std::fs::remove_file(pointer).expect("remove callgraph pointer before inline reopen");
2099    }
2100}
2101
2102#[cfg(test)]
2103fn remove_callgraph_pointer_before_inline_reopen_for_test(
2104    callgraph_dir: &Path,
2105    store: &CallGraphStore,
2106) {
2107    let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
2108    remove_armed_callgraph_pointer_for_test(&pointer);
2109}
2110
2111#[cfg(not(test))]
2112fn remove_callgraph_pointer_before_inline_reopen_for_test(
2113    _callgraph_dir: &Path,
2114    _store: &CallGraphStore,
2115) {
2116}
2117
2118/// Inline wait window for a callgraph-store cold build before returning
2119/// `Building`. Default `0` (pure-async: never block the request thread).
2120/// Tests set `AFT_CALLGRAPH_BUILD_WAIT_MS` large so small fixture builds
2121/// resolve to `Ready` synchronously and exercise query correctness directly.
2122fn callgraph_build_wait_window() -> Duration {
2123    std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
2124        .ok()
2125        .and_then(|raw| raw.parse::<u64>().ok())
2126        .map(Duration::from_millis)
2127        .unwrap_or(Duration::ZERO)
2128}
2129
2130static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
2131
2132#[doc(hidden)]
2133pub fn reset_callgraph_cold_build_spawn_count_for_test() {
2134    CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2135}
2136
2137#[doc(hidden)]
2138pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
2139    CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
2140}
2141
2142impl AppContext {
2143    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
2144        Self::with_app_and_provider(App::default_shared(), provider, config)
2145    }
2146
2147    pub fn from_app(app: Arc<App>, config: Config) -> Self {
2148        let provider = app.create_provider();
2149        Self::with_app_and_provider(app, provider, config)
2150    }
2151
2152    pub fn with_app_and_provider(
2153        app: Arc<App>,
2154        provider: Box<dyn LanguageProvider>,
2155        config: Config,
2156    ) -> Self {
2157        let bash_compress_enabled = config.experimental_bash_compress;
2158        let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
2159        let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
2160        let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
2161        let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
2162        let semantic_cold_seed_active = Arc::new(AtomicBool::new(false));
2163        let symbol_cache = provider
2164            .as_any()
2165            .downcast_ref::<TreeSitterProvider>()
2166            .map(|provider| provider.symbol_cache())
2167            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
2168        let mut lsp_manager = LspManager::new();
2169        lsp_manager.set_child_registry(app.lsp_child_registry());
2170        // Apply the configured diagnostic LRU cap (default 5000, 0 = unbounded)
2171        // so the documented `lsp.diagnostic_cache_size` knob takes effect.
2172        lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
2173        let bash_background = BgTaskRegistry::new(Arc::clone(&progress_sender));
2174        let compression_aggregates = bash_background.compression_aggregate_cache();
2175        let context = AppContext {
2176            app: Arc::clone(&app),
2177            provider,
2178            backup: parking_lot::Mutex::new(BackupStore::new()),
2179            checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
2180            config: RwLock::new(Arc::new(config)),
2181            path_restriction_root_memo: parking_lot::Mutex::new(None),
2182            #[cfg(test)]
2183            path_restriction_root_canonicalizations: AtomicUsize::new(0),
2184            force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
2185            harness: parking_lot::Mutex::new(None),
2186            canonical_cache_root: parking_lot::Mutex::new(None),
2187            is_worktree_bridge: parking_lot::Mutex::new(false),
2188            git_common_dir: parking_lot::Mutex::new(None),
2189            shared_artifacts_read_only: AtomicBool::new(false),
2190            daemonless_query_mode: AtomicBool::new(false),
2191            callgraph_writer: AtomicBool::new(true),
2192            inspect_writer: AtomicBool::new(true),
2193            artifact_owner_status: parking_lot::Mutex::new(None),
2194            artifact_owner_lease: parking_lot::Mutex::new(None),
2195            degraded_reasons: parking_lot::Mutex::new(Vec::new()),
2196            heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
2197            standing_artifact_exempt: AtomicBool::new(false),
2198            cold_build_limiter: RwLock::new(crate::cold_build_limiter::global_limiter()),
2199            callgraph_store: Arc::new(RwLock::new(None)),
2200            callgraph_store_force_requested: AtomicU64::new(0),
2201            callgraph_store_force_fulfilled: AtomicU64::new(0),
2202            callgraph_store_rx: parking_lot::Mutex::new(None),
2203            callgraph_store_rx_generation: AtomicU64::new(0),
2204            callgraph_store_rx_epoch: AtomicU64::new(0),
2205            callgraph_store_build_denied: parking_lot::Mutex::new(None),
2206            callgraph_store_build_suspension: parking_lot::Mutex::new(None),
2207            health_build_suspensions: RwLock::new(Vec::new()),
2208            callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2209            callgraph_legacy_migration_summary_logged: Arc::new(AtomicBool::new(false)),
2210            pending_callgraph_store_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2211            search_index: RwLock::new(None),
2212            search_index_rx: RwLock::new(None),
2213            search_index_rx_generation: AtomicU64::new(0),
2214            search_index_rx_epoch: AtomicU64::new(0),
2215            search_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2216            search_index_disconnect_reschedule: parking_lot::Mutex::new((0, 0)),
2217            search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2218            pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
2219            symbol_cache,
2220            inspect_manager: Arc::new(InspectManager::with_root_work_gates(
2221                Arc::clone(&heavy_root_work_allowed),
2222                Arc::clone(&semantic_cold_seed_active),
2223            )),
2224            tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
2225            pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
2226            semantic_index: RwLock::new(None),
2227            semantic_index_rx: parking_lot::Mutex::new(None),
2228            semantic_index_rx_generation: AtomicU64::new(0),
2229            semantic_index_rx_epoch: AtomicU64::new(0),
2230            semantic_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2231            semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2232            semantic_persist_lock: Arc::new(parking_lot::Mutex::new(())),
2233            semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
2234            semantic_build_progress: RwLock::new(None),
2235            semantic_build_epoch: Arc::new(AtomicU64::new(0)),
2236            artifact_reload_lock: parking_lot::Mutex::new(()),
2237            semantic_cold_seed_active,
2238            semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
2239            semantic_fingerprint_generation: Arc::new(AtomicU64::new(0)),
2240            semantic_callgraph_warm_deferred: AtomicBool::new(false),
2241            pending_semantic_index_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2242            pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
2243            semantic_refresh_tx: Arc::new(parking_lot::Mutex::new(None)),
2244            semantic_refresh_event_rx: parking_lot::Mutex::new(None),
2245            semantic_refresh_generation: AtomicU64::new(0),
2246            semantic_refresh_epoch: AtomicU64::new(0),
2247            semantic_refresh_build_epoch: AtomicU64::new(0),
2248            semantic_refresh_worker: parking_lot::Mutex::new(None),
2249            semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
2250            semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
2251            semantic_embedding_model: parking_lot::Mutex::new(None),
2252            watcher_runtime_lock: parking_lot::Mutex::new(()),
2253            watcher: parking_lot::Mutex::new(None),
2254            watcher_rx: parking_lot::Mutex::new(None),
2255            watcher_drain_slice: parking_lot::Mutex::new(None),
2256            watcher_thread: parking_lot::Mutex::new(None),
2257            lsp_manager: parking_lot::Mutex::new(lsp_manager),
2258            configure_generation: Arc::new(AtomicU64::new(0)),
2259            configure_content_generation: Arc::new(AtomicU64::new(0)),
2260            subc_lifecycle: SubcLifecycleAdmission::default(),
2261            configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
2262            callgraph_build_key: parking_lot::Mutex::new(None),
2263            configure_phase_timing: parking_lot::Mutex::new(ConfigurePhaseTiming::default()),
2264            configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
2265            hashline_bindings: crate::hashline::integration::BindingRegistry::new(),
2266            configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
2267            artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
2268            artifact_cache_key_derivations: AtomicU64::new(0),
2269            borrowed_index_cache: parking_lot::Mutex::new(BorrowedIndexCache::default()),
2270            worktree_bridge_cache: parking_lot::Mutex::new(BTreeMap::new()),
2271            #[cfg(test)]
2272            worktree_bridge_probe_spawns: AtomicU64::new(0),
2273            #[cfg(test)]
2274            force_worktree_bridge_reprobe: AtomicBool::new(false),
2275            last_seen_reuse_completions: AtomicU64::new(0),
2276            configure_warnings_tx,
2277            configure_warnings_rx,
2278            progress_sender: Arc::clone(&progress_sender),
2279            status_emitter,
2280            fleet_status_client: RwLock::new(None),
2281            status_bar_last_emitted: LegacyStatusBarEmission::default(),
2282            status_bar_cached: RwLock::new(StatusBarCache::default()),
2283            alert_state: parking_lot::Mutex::new(AlertDeltaState::default()),
2284            compression_aggregates,
2285            bash_background,
2286            #[cfg(unix)]
2287            escalation_grants: parking_lot::Mutex::new(
2288                crate::sandbox_spawn::EscalationGrantStore::default(),
2289            ),
2290            filter_registry: Arc::new(std::sync::RwLock::new(
2291                crate::compress::toml_filter::FilterRegistry::default(),
2292            )),
2293            filter_registry_rebuild_count: AtomicU64::new(0),
2294            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
2295            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
2296            gitignore: Arc::new(std::sync::RwLock::new(None)),
2297            gitignore_generation: Arc::new(AtomicU64::new(0)),
2298            status_bar_tier2: RwLock::new(StatusBarTier2::default()),
2299            tsconfig_membership: parking_lot::Mutex::new(
2300                crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
2301            ),
2302        };
2303        crate::logging::sync_storage_root(context.storage_dir());
2304        context
2305    }
2306
2307    /// Current omission-preserving status values. Generation identities are
2308    /// checked before project scoping or tsconfig-membership work, so a cache hit
2309    /// faithfully reuses each category's presence or absence.
2310    pub fn status_bar_count_values(&self) -> StatusBarCountValues {
2311        let tier2 = self
2312            .status_bar_tier2
2313            .read()
2314            .unwrap_or_else(std::sync::PoisonError::into_inner)
2315            .clone();
2316        let tsconfig_generation = self.tsconfig_membership.lock().generation();
2317        let lsp = self.lsp_manager.lock();
2318        let diagnostics_generation = lsp.diagnostics_generation();
2319
2320        {
2321            let cached = self
2322                .status_bar_cached
2323                .read()
2324                .unwrap_or_else(std::sync::PoisonError::into_inner);
2325            if cached.valid
2326                && cached.diagnostics_generation == diagnostics_generation
2327                && cached.tier2_generation == tier2.generation
2328                && cached.tsconfig_generation == tsconfig_generation
2329            {
2330                return cached
2331                    .counts
2332                    .clone()
2333                    .expect("a valid status-count cache carries truthful values");
2334            }
2335        }
2336
2337        let previous_authoritative = self
2338            .status_bar_cached
2339            .read()
2340            .unwrap_or_else(std::sync::PoisonError::into_inner)
2341            .counts
2342            .as_ref()
2343            .map(|counts| (counts.errors, counts.warnings));
2344        let ((current_errors, current_warnings), provisional) =
2345            match self.canonical_cache_root_opt() {
2346                Some(root) => {
2347                    // The cache root is identity-domain (bare-canonical, verbatim on
2348                    // Windows) while diagnostics store keys are normalized; normalize a
2349                    // comparison-local copy or the starts_with filter drops every diagnostic.
2350                    let root = crate::inspect::job::normalize_path(&root);
2351                    let mut membership = self.tsconfig_membership.lock();
2352                    lsp.filtered_error_warning_counts_with_provisional(|file| {
2353                        file.starts_with(&root) && !membership.should_skip_diagnostics(file)
2354                    })
2355                }
2356                None => lsp.warm_error_warning_counts_with_provisional(),
2357            };
2358        let (errors, warnings) = if provisional {
2359            // A warming report cannot prove current E/W values. Preserve a prior
2360            // authoritative pair when available; otherwise omit both categories.
2361            previous_authoritative.unwrap_or((None, None))
2362        } else if lsp.has_any_diagnostic_reports() {
2363            (Some(current_errors), Some(current_warnings))
2364        } else {
2365            (None, None)
2366        };
2367        let counts = StatusBarCountValues {
2368            errors,
2369            warnings,
2370            dead_code: tier2.dead_code,
2371            unused_exports: tier2.unused_exports,
2372            duplicates: tier2.duplicates,
2373            todos: tier2.todos,
2374            tier2_stale: tier2.stale,
2375        };
2376
2377        *self
2378            .status_bar_cached
2379            .write()
2380            .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
2381            valid: true,
2382            diagnostics_generation,
2383            tier2_generation: tier2.generation,
2384            tsconfig_generation,
2385            counts: Some(counts.clone()),
2386        };
2387        counts
2388    }
2389
2390    /// Provides legacy numeric status-bar fields to callers that still require
2391    /// them. The truthful accessor above remains the source of category presence.
2392    pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
2393        self.status_bar_count_values().legacy_projection()
2394    }
2395
2396    pub(crate) fn try_health_summary(&self) -> RootHealthSummary {
2397        // Read lifecycle state before taking artifact locks. Worker admission takes
2398        // the lifecycle lock first and then installs artifact receivers, so the
2399        // reverse order here would deadlock a health poll against worker startup.
2400        let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
2401            Some(allowed) => allowed,
2402            None => return RootHealthSummary::busy(),
2403        };
2404        let config = match self.config.try_read() {
2405            Ok(guard) => Arc::clone(&*guard),
2406            Err(_) => return RootHealthSummary::busy(),
2407        };
2408        let search_index = match self.search_index.try_read() {
2409            Ok(guard) => guard,
2410            Err(_) => return RootHealthSummary::busy(),
2411        };
2412        let search_index_rx = match self.search_index_rx.try_read() {
2413            Ok(guard) => guard,
2414            Err(_) => return RootHealthSummary::busy(),
2415        };
2416        let semantic_status = match self.semantic_index_status.try_read() {
2417            Ok(guard) => guard,
2418            Err(_) => return RootHealthSummary::busy(),
2419        };
2420        let semantic_build_progress = match self.semantic_build_progress.try_read() {
2421            Ok(guard) => guard.clone(),
2422            Err(_) => return RootHealthSummary::busy(),
2423        };
2424        let callgraph_store = match self.callgraph_store.try_read() {
2425            Ok(guard) => guard,
2426            Err(_) => return RootHealthSummary::busy(),
2427        };
2428        let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
2429            Some(guard) => guard,
2430            None => return RootHealthSummary::busy(),
2431        };
2432        let tier2 = match self.status_bar_tier2.try_read() {
2433            Ok(guard) => guard,
2434            Err(_) => return RootHealthSummary::busy(),
2435        };
2436        // Read the inspect builder registry (the same map used to refuse inspect
2437        // work while a rebuild is registered). Published status-bar counts are
2438        // not a substitute: a complete snapshot with a rebuild still registered
2439        // is still building.
2440        let tier2_builder_busy = match self.inspect_manager.try_tier2_builder_busy() {
2441            Some(busy) => busy,
2442            None => return RootHealthSummary::busy(),
2443        };
2444        let bash = match self.bash_background.try_health_counts() {
2445            Some(counts) => counts,
2446            None => return RootHealthSummary::busy(),
2447        };
2448        let suspended_domains = match self.health_build_suspensions.try_read() {
2449            Ok(snapshot) => snapshot.clone(),
2450            Err(_) => return RootHealthSummary::busy(),
2451        };
2452
2453        // Borrow-only roots (mason worktrees, read-only siblings) never
2454        // materialize an in-RAM index or spawn a build: queries go through the
2455        // read-only disk openers against the shared artifact. Reporting them
2456        // as "building" would never resolve.
2457        let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
2458        let search_index_status = if search_index
2459            .as_ref()
2460            .is_some_and(|index| index.ready || index.build_denied)
2461            || (borrows_shared_artifacts && config.search_index)
2462        {
2463            "ready"
2464        } else if config.search_index
2465            || search_index.as_ref().is_some()
2466            || search_index_rx.as_ref().is_some()
2467        {
2468            "building"
2469        } else {
2470            "disabled"
2471        };
2472        let semantic_index = match &*semantic_status {
2473            SemanticIndexStatus::Ready { .. } => SemanticHealthComponentSnapshot {
2474                status: "ready",
2475                stage: None,
2476                embedded_chunks: None,
2477                total_chunks: None,
2478                current_batch: None,
2479                total_batches: None,
2480            },
2481            SemanticIndexStatus::Building { stage, .. } => {
2482                let progress = semantic_build_progress
2483                    .as_ref()
2484                    .map(SemanticBuildProgress::snapshot);
2485                SemanticHealthComponentSnapshot {
2486                    status: "building",
2487                    stage: Some(stage.clone()),
2488                    embedded_chunks: progress.as_ref().map(|progress| progress.embedded_chunks),
2489                    total_chunks: progress.as_ref().map(|progress| progress.total_chunks),
2490                    current_batch: progress.as_ref().map(|progress| progress.current_batch),
2491                    total_batches: progress.as_ref().map(|progress| progress.total_batches),
2492                }
2493            }
2494            SemanticIndexStatus::Disabled => SemanticHealthComponentSnapshot {
2495                status: "disabled",
2496                stage: None,
2497                embedded_chunks: None,
2498                total_chunks: None,
2499                current_batch: None,
2500                total_batches: None,
2501            },
2502            SemanticIndexStatus::Failed(_) => SemanticHealthComponentSnapshot {
2503                status: "degraded",
2504                stage: None,
2505                embedded_chunks: None,
2506                total_chunks: None,
2507                current_batch: None,
2508                total_batches: None,
2509            },
2510        };
2511        let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
2512        let callgraph_store_status = if !heavy_root_work_allowed {
2513            "disabled"
2514        } else if callgraph_store.as_ref().is_some() {
2515            "ready"
2516        } else if !callgraph_writer && config.callgraph_store {
2517            // Read-only roots never cold-build; they query the shared store
2518            // via ReadonlyCallGraphStore on demand.
2519            "ready"
2520        } else if callgraph_store_rx.is_some() || config.callgraph_store {
2521            "building"
2522        } else {
2523            "disabled"
2524        };
2525        // dead_code is suppressed while the callgraph store is unavailable.
2526        // Let the callgraph component report that dependency instead of leaving
2527        // tier2 permanently "building" with no refresh able to complete it.
2528        let dead_code_blocked_on_callgraph = tier2.dead_code_blocked_on_callgraph;
2529        let tier2_complete = (tier2.dead_code.is_some() || dead_code_blocked_on_callgraph)
2530            && tier2.unused_exports.is_some()
2531            && tier2.duplicates.is_some()
2532            && !tier2.stale;
2533        let tier2_has_aggregates = tier2.dead_code.is_some()
2534            || tier2.unused_exports.is_some()
2535            || tier2.duplicates.is_some();
2536        let tier2_refresh_gated = borrows_shared_artifacts
2537            || !heavy_root_work_allowed
2538            || !self.inspect_writer.load(Ordering::SeqCst)
2539            || !self.inspect_manager.automatic_tier2_refresh_enabled();
2540        let tier2_status = if tier2_builder_busy {
2541            // A registered rebuild is still in flight, including a first scan
2542            // that has no aggregate yet. Keep the root warming until the
2543            // registry entry is cleared.
2544            "building"
2545        } else if tier2_complete {
2546            "ready"
2547        } else if !config.inspect.enabled || !tier2_has_aggregates || tier2_refresh_gated {
2548            // A partial snapshot can be "building" only when this root is
2549            // allowed to run the refresh that would complete it.
2550            "disabled"
2551        } else {
2552            "building"
2553        };
2554
2555        RootHealthSummary {
2556            state: RootHealthState::Ready,
2557            search_index_status: Some(search_index_status),
2558            semantic_index: Some(semantic_index),
2559            callgraph_store_status: Some(callgraph_store_status),
2560            tier2_status: Some(tier2_status),
2561            bash: Some(bash),
2562            suspended_domains,
2563        }
2564    }
2565
2566    pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
2567        self.try_health_summary().into_snapshot(project_root)
2568    }
2569
2570    /// Deduplicates emissions of the legacy status-bar response section. This
2571    /// compatibility method is no longer needed when responses omit that section.
2572    pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
2573        self.status_bar_last_emitted.should_emit(counts)
2574    }
2575
2576    /// Record an atomic batch of complete per-producer diagnostics observations.
2577    /// Callers must construct the batch from a source that proved the document
2578    /// version; passive count reads have no access to this mutation boundary.
2579    pub fn accept_alert_observation_batch(
2580        &self,
2581        batch: &AcceptedObservationBatch,
2582    ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
2583        self.alert_state.lock().accept_batch(batch)
2584    }
2585
2586    /// Invalidate the status-bar tsconfig-membership cache. Called from the
2587    /// watcher seam when a tsconfig-like file changes and from `configure`
2588    /// when the project root changes, so the next bar count re-reads from disk.
2589    pub fn clear_tsconfig_membership_cache(&self) {
2590        self.tsconfig_membership.lock().clear();
2591    }
2592
2593    #[cfg(test)]
2594    pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
2595        self.tsconfig_membership.lock().generation()
2596    }
2597
2598    /// Mark the status-bar Tier-2 counts stale (rendered with `~`) without
2599    /// changing the numbers — called when the watcher sees a source-file change,
2600    /// so the bar honestly signals the counts predate the latest edit until the
2601    /// next background scan completes. Returns true only when the visible stale
2602    /// bit flips. No-op before the first populate.
2603    pub fn mark_status_bar_tier2_stale(&self) -> bool {
2604        let mut tier2 = self
2605            .status_bar_tier2
2606            .write()
2607            .unwrap_or_else(std::sync::PoisonError::into_inner);
2608        // No-op before the first proven count (nothing real to mark stale).
2609        if tier2.dead_code.is_some()
2610            || tier2.unused_exports.is_some()
2611            || tier2.duplicates.is_some()
2612            || tier2.todos.is_some()
2613        {
2614            let changed = !tier2.stale;
2615            tier2.stale = true;
2616            if changed {
2617                tier2.generation = tier2.generation.wrapping_add(1);
2618            }
2619            return changed;
2620        }
2621        false
2622    }
2623
2624    /// Refresh the cached Tier-2 + todos counts for the status bar. Each count
2625    /// is `Option`: `None` preserves the last-known value (the category wasn't
2626    /// recomputed or has no real aggregate yet) so we never overwrite a real
2627    /// count with a fabricated `0`. `stale` marks the Tier-2 numbers as
2628    /// not-yet-reconciled with the latest edits.
2629    pub fn update_status_bar_tier2(
2630        &self,
2631        dead_code: Option<usize>,
2632        unused_exports: Option<usize>,
2633        duplicates: Option<usize>,
2634        todos: Option<usize>,
2635        stale: bool,
2636    ) {
2637        let mut tier2 = self
2638            .status_bar_tier2
2639            .write()
2640            .unwrap_or_else(std::sync::PoisonError::into_inner);
2641        let previous = (
2642            tier2.dead_code,
2643            tier2.unused_exports,
2644            tier2.duplicates,
2645            tier2.todos,
2646            tier2.stale,
2647        );
2648        if let Some(dead_code) = dead_code {
2649            tier2.dead_code = Some(dead_code);
2650        }
2651        if let Some(unused_exports) = unused_exports {
2652            tier2.unused_exports = Some(unused_exports);
2653        }
2654        if let Some(duplicates) = duplicates {
2655            tier2.duplicates = Some(duplicates);
2656        }
2657        if let Some(todos) = todos {
2658            tier2.todos = Some(todos);
2659        }
2660        tier2.stale = stale;
2661        let current = (
2662            tier2.dead_code,
2663            tier2.unused_exports,
2664            tier2.duplicates,
2665            tier2.todos,
2666            tier2.stale,
2667        );
2668        if current != previous {
2669            tier2.generation = tier2.generation.wrapping_add(1);
2670        }
2671    }
2672
2673    /// Record whether the latest dead_code aggregate was suppressed because the
2674    /// callgraph store was not ready (`callgraph_available:false`). Kept separate
2675    /// from [`update_status_bar_tier2`] because the flag is health metadata, not a
2676    /// status-bar count: it never renders in the bar and need not bump the
2677    /// count-generation used for status-bar cache invalidation.
2678    pub(crate) fn set_status_bar_tier2_dead_code_blocked_on_callgraph(&self, blocked: bool) {
2679        let mut tier2 = self
2680            .status_bar_tier2
2681            .write()
2682            .unwrap_or_else(std::sync::PoisonError::into_inner);
2683        tier2.dead_code_blocked_on_callgraph = blocked;
2684    }
2685
2686    /// Borrow the cached project gitignore matcher. Returns `None` when no
2687    /// project_root is configured or when the project has no gitignore files.
2688    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
2689        self.gitignore
2690            .read()
2691            .unwrap_or_else(|poisoned| poisoned.into_inner())
2692            .clone()
2693    }
2694
2695    /// Shared gitignore matcher handle for the watcher filter thread.
2696    pub fn shared_gitignore(&self) -> SharedGitignore {
2697        Arc::clone(&self.gitignore)
2698    }
2699
2700    /// Monotonic generation bumped after every matcher rebuild/clear. The
2701    /// watcher filter thread uses it to wait until the main thread has rebuilt
2702    /// ignore rules after it reports an ignore-file change.
2703    pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
2704        Arc::clone(&self.gitignore_generation)
2705    }
2706
2707    fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
2708        *self
2709            .gitignore
2710            .write()
2711            .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
2712        self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
2713    }
2714
2715    /// Rebuild the gitignore matcher from the current `project_root` and
2716    /// cache it. Called by the configure handler whenever the project root
2717    /// changes, and by the watcher event drain when a `.gitignore` file
2718    /// itself is modified.
2719    ///
2720    /// The builder honors:
2721    /// - `<project_root>/.gitignore`
2722    /// - Git's global excludes file (the same source used by `ignore::WalkBuilder`)
2723    /// - the repository's real `info/exclude` file, resolved through Git's
2724    ///   common dir for linked worktrees
2725    /// - nested `.gitignore` files (each `.gitignore` discovered during
2726    ///   the recursive walk)
2727    ///
2728    /// Stores `None` if there's no project_root or no matchable gitignore
2729    /// files. Logs build errors but never fails configure.
2730    /// Clear any cached gitignore matcher without rebuilding.
2731    ///
2732    /// Used by `handle_configure` in degraded mode (e.g. `project_root == $HOME`)
2733    /// where running the gitignore-discovery walk would exceed the configure
2734    /// budget. The watcher event filter falls back to the hardcoded infra-dir
2735    /// skip list when no matcher is present.
2736    pub fn clear_gitignore(&self) {
2737        self.set_gitignore(None);
2738    }
2739
2740    pub fn rebuild_gitignore(&self) {
2741        use ignore::gitignore::GitignoreBuilder;
2742        use std::path::Path;
2743        let root_raw = match self.config().project_root.clone() {
2744            Some(r) => r,
2745            None => {
2746                self.set_gitignore(None);
2747                return;
2748            }
2749        };
2750        // Canonicalize the root so symlink-prefix mismatches don't cause
2751        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
2752        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
2753        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
2754        // crate's matcher panics when a query path isn't lexically under the
2755        // matcher's root. Canonicalizing both ends (here for root, naturally
2756        // for watcher events on macOS) keeps them in the same prefix space.
2757        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
2758        let mut builder = GitignoreBuilder::new(&root);
2759        // Git's global excludes file — keep the live watcher matcher aligned
2760        // with the project walkers (`WalkBuilder::git_global(true)`). The
2761        // ignore crate exposes the same path discovery it uses internally, so
2762        // this handles the default XDG location and configured excludesFile.
2763        if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
2764            if global_ignore.is_file() {
2765                if let Some(err) = builder.add(&global_ignore) {
2766                    crate::slog_warn!(
2767                        "global gitignore parse error in {}: {}",
2768                        global_ignore.display(),
2769                        err
2770                    );
2771                }
2772            }
2773        }
2774        // Add root .gitignore (the most common case)
2775        let root_ignore = Path::new(&root).join(".gitignore");
2776        if root_ignore.exists() {
2777            if let Some(err) = builder.add(&root_ignore) {
2778                crate::slog_warn!(
2779                    "gitignore parse error in {}: {}",
2780                    root_ignore.display(),
2781                    err
2782                );
2783            }
2784        }
2785        // Root .aftignore — AFT-specific ignores layered on top of .gitignore.
2786        // Lets users exclude paths git can't (e.g. submodules) from AFT's
2787        // walks/indexes. Honored by the watcher matcher too, so edits under an
2788        // aftignored path don't trigger reindexing.
2789        let root_aftignore = Path::new(&root).join(".aftignore");
2790        if root_aftignore.exists() {
2791            if let Some(err) = builder.add(&root_aftignore) {
2792                crate::slog_warn!(
2793                    "aftignore parse error in {}: {}",
2794                    root_aftignore.display(),
2795                    err
2796                );
2797            }
2798        }
2799        // .git/info/exclude — manually added because GitignoreBuilder::new()
2800        // does not auto-discover it (verified against ignore-0.4.25 source).
2801        // In linked worktrees this lives under the repository common dir, not
2802        // under `<worktree>/.git/info/exclude` (where `.git` is only a file).
2803        let info_exclude = self
2804            .git_common_dir
2805            .lock()
2806            .clone()
2807            .unwrap_or_else(|| Path::new(&root).join(".git"))
2808            .join("info")
2809            .join("exclude");
2810        if info_exclude.exists() {
2811            if let Some(err) = builder.add(&info_exclude) {
2812                crate::slog_warn!(
2813                    "gitignore parse error in {}: {}",
2814                    info_exclude.display(),
2815                    err
2816                );
2817            }
2818        }
2819        // Walk the project to pick up nested .gitignore/.aftignore files at
2820        // arbitrary depth. The main project walkers honor deeply nested ignore
2821        // files, so the watcher matcher must do the same or live invalidation
2822        // can disagree with startup indexing. Skip obvious infra dirs so we
2823        // don't accidentally load a vendored repo's ignore file as ours.
2824        // Prevent a disappearing child mount from making ReadDir::drop abort on ENXIO.
2825        let walker = ignore::WalkBuilder::new(&root)
2826            .same_file_system(true)
2827            .standard_filters(true)
2828            // Hidden files are filtered by default, but `.gitignore` starts with
2829            // `.` so we need to traverse "hidden" entries to find nested ones.
2830            // No `max_depth`: nested `.gitignore`/`.aftignore` files are honored
2831            // at arbitrary depth (see configure_watcher_honors_deep_nested_aftignore).
2832            // The walk is pruned by standard gitignore filters plus the infra
2833            // skip below; configure never runs this against `$HOME` (guarded by
2834            // `home_match`), and tests use bounded roots rather than `/`.
2835            .hidden(false)
2836            .filter_entry(|entry| {
2837                let name = entry.file_name().to_string_lossy();
2838                !matches!(
2839                    name.as_ref(),
2840                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
2841                )
2842            })
2843            .build();
2844        for entry in walker.flatten() {
2845            let file_name = entry.file_name();
2846            let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
2847            let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
2848            if is_nested_gitignore || is_nested_aftignore {
2849                if let Some(err) = builder.add(entry.path()) {
2850                    crate::slog_warn!(
2851                        "nested ignore parse error in {}: {}",
2852                        entry.path().display(),
2853                        err
2854                    );
2855                }
2856            }
2857        }
2858        match builder.build() {
2859            Ok(gi) => {
2860                let count = gi.num_ignores();
2861                if count > 0 {
2862                    crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
2863                    self.set_gitignore(Some(Arc::new(gi)));
2864                } else {
2865                    self.set_gitignore(None);
2866                }
2867            }
2868            Err(err) => {
2869                crate::slog_warn!("gitignore matcher build failed: {}", err);
2870                self.set_gitignore(None);
2871            }
2872        }
2873    }
2874
2875    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
2876    /// configure handler. Read by the BgTaskRegistry compressor closure.
2877    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
2878        Arc::clone(&self.bash_compress_flag)
2879    }
2880
2881    /// Update the shared `bash_compress_flag` mirror. Call this from the
2882    /// configure handler whenever `experimental.bash.compress` changes so the
2883    /// BgTaskRegistry watchdog sees the new value on the next completion.
2884    pub fn sync_bash_compress_flag(&self) {
2885        let value = self.config().experimental_bash_compress;
2886        self.bash_compress_flag
2887            .store(value, std::sync::atomic::Ordering::Relaxed);
2888    }
2889
2890    pub fn set_bash_compress_enabled(&self, enabled: bool) {
2891        self.update_config(|config| {
2892            config.experimental_bash_compress = enabled;
2893        });
2894        self.bash_compress_flag
2895            .store(enabled, std::sync::atomic::Ordering::Relaxed);
2896    }
2897
2898    /// Read-only access to the TOML filter registry, building it lazily on
2899    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
2900    /// against directly.
2901    pub fn filter_registry(
2902        &self,
2903    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
2904        self.ensure_filter_registry_loaded();
2905        match self.filter_registry.read() {
2906            Ok(g) => g,
2907            Err(poisoned) => poisoned.into_inner(),
2908        }
2909    }
2910
2911    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
2912    /// outside `AppContext` (notably the bash watchdog) can read it without
2913    /// touching the rest of the context.
2914    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
2915        self.ensure_filter_registry_loaded();
2916        Arc::clone(&self.filter_registry)
2917    }
2918
2919    /// Force a fresh load of the TOML filter registry. Called when configure
2920    /// changes the project root, storage_dir, or trust state so subsequent
2921    /// `compress::compress` calls pick up new filters.
2922    pub fn reset_filter_registry(&self) {
2923        let new_registry = crate::compress::build_registry_for_context(self);
2924        self.filter_registry_rebuild_count
2925            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2926        match self.filter_registry.write() {
2927            Ok(mut slot) => *slot = new_registry,
2928            Err(poisoned) => *poisoned.into_inner() = new_registry,
2929        }
2930        self.filter_registry_loaded
2931            .store(true, std::sync::atomic::Ordering::Release);
2932    }
2933
2934    fn ensure_filter_registry_loaded(&self) {
2935        use std::sync::atomic::Ordering;
2936        if self.filter_registry_loaded.load(Ordering::Acquire) {
2937            return;
2938        }
2939        // Build outside the lock to avoid blocking other readers during a
2940        // multi-file TOML parse.
2941        let new_registry = crate::compress::build_registry_for_context(self);
2942        self.filter_registry_rebuild_count
2943            .fetch_add(1, Ordering::SeqCst);
2944        if let Ok(mut slot) = self.filter_registry.write() {
2945            *slot = new_registry;
2946            self.filter_registry_loaded.store(true, Ordering::Release);
2947        }
2948    }
2949
2950    #[cfg(test)]
2951    pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
2952        self.filter_registry_rebuild_count.load(Ordering::SeqCst)
2953    }
2954
2955    pub fn app(&self) -> Arc<App> {
2956        Arc::clone(&self.app)
2957    }
2958
2959    /// Clone the LSP child registry handle. Used by main.rs to give the
2960    /// signal handler thread a way to SIGKILL LSP children on shutdown.
2961    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
2962        self.app.lsp_child_registry()
2963    }
2964
2965    pub fn stdout_writer(&self) -> SharedStdoutWriter {
2966        self.app.stdout_writer()
2967    }
2968
2969    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
2970        if let Ok(mut progress_sender) = self.progress_sender.lock() {
2971            *progress_sender = sender;
2972        }
2973    }
2974
2975    pub fn emit_progress(&self, frame: ProgressFrame) {
2976        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
2977            return;
2978        };
2979        if let Some(sender) = progress_sender.as_ref() {
2980            sender(PushFrame::Progress(frame));
2981        }
2982    }
2983
2984    pub fn status_emitter(&self) -> &StatusEmitter {
2985        &self.status_emitter
2986    }
2987
2988    pub(crate) fn install_fleet_status_client(
2989        &self,
2990        client: Option<crate::fleet_status::FleetStatusClient>,
2991    ) {
2992        *self
2993            .fleet_status_client
2994            .write()
2995            .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
2996    }
2997
2998    pub(crate) fn fleet_status_client(&self) -> Option<crate::fleet_status::FleetStatusClient> {
2999        self.fleet_status_client
3000            .read()
3001            .unwrap_or_else(std::sync::PoisonError::into_inner)
3002            .clone()
3003    }
3004
3005    /// Get a clone of the current progress sender for use from background
3006    /// threads. Returns `None` when the main loop hasn't installed one (tests,
3007    /// CLI without push frames).
3008    ///
3009    /// Used by `configure`'s deferred file-walk thread to push warnings after
3010    /// configure has already returned, so configure latency stays sub-100 ms
3011    /// even on huge directories.
3012    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
3013        self.progress_sender
3014            .lock()
3015            .ok()
3016            .and_then(|sender| sender.clone())
3017    }
3018
3019    pub fn advance_configure_generation(&self) -> u64 {
3020        self.subc_lifecycle
3021            .advance_generation(self.configure_generation.as_ref())
3022    }
3023
3024    pub(crate) fn mark_subc_bound(&self) {
3025        self.subc_lifecycle.mark_bound();
3026    }
3027
3028    pub(crate) fn mark_subc_unbound(&self) {
3029        self.subc_lifecycle
3030            .mark_unbound(self.configure_generation.as_ref());
3031    }
3032
3033    #[doc(hidden)]
3034    pub fn subc_unbound_quiesced(&self) -> bool {
3035        self.subc_lifecycle.is_unbound()
3036    }
3037
3038    pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
3039        self.subc_lifecycle.clone()
3040    }
3041
3042    pub(crate) fn run_if_subc_bound_generation<R>(
3043        &self,
3044        expected_generation: u64,
3045        action: impl FnOnce() -> R,
3046    ) -> Option<R> {
3047        self.subc_lifecycle.run_if_current(
3048            self.configure_generation.as_ref(),
3049            expected_generation,
3050            action,
3051        )
3052    }
3053
3054    /// Record the warm-maintenance key for a successful configure and return
3055    /// the generation this configure operates under.
3056    ///
3057    /// An unchanged key ADOPTS the running generation without advancing it:
3058    /// in-flight build workers gate their publish on the generation flag being
3059    /// unchanged, so advancing on an equivalent rebind would silently discard
3060    /// every adopted build's result at completion (the receiver never
3061    /// resolves, and long builds can never finish under rebind traffic). Only
3062    /// a genuinely different warm config advances the generation, which is
3063    /// what cancels superseded in-flight builds.
3064    pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
3065        let mut state = self.configure_warm_state.lock();
3066        let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
3067        let generation = if equivalent {
3068            self.configure_generation()
3069        } else {
3070            self.configure_content_generation
3071                .fetch_add(1, Ordering::SeqCst);
3072            self.advance_configure_generation()
3073        };
3074        state.generation = generation;
3075        state.key = Some(key);
3076        (generation, equivalent)
3077    }
3078
3079    pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
3080        self.configure_warm_state
3081            .lock()
3082            .key
3083            .as_deref()
3084            .is_some_and(|current| current == key)
3085    }
3086
3087    /// Record the callgraph-specific corpus/publication identity and report
3088    /// whether an existing worker remains valid under the new configure.
3089    pub(crate) fn note_callgraph_build_key(&self, key: String) -> bool {
3090        let mut current = self.callgraph_build_key.lock();
3091        let equivalent = current.as_deref() == Some(key.as_str());
3092        *current = Some(key);
3093        equivalent
3094    }
3095
3096    pub(crate) fn invalidate_configure_warm_state(&self) {
3097        self.configure_warm_state.lock().key = None;
3098    }
3099
3100    pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
3101        self.configured_session_roots
3102            .lock()
3103            .insert((root, session_id))
3104    }
3105
3106    pub(crate) fn has_configure_session_binding(&self, root: &Path, session_id: &str) -> bool {
3107        self.configured_session_roots
3108            .lock()
3109            .contains(&(root.to_path_buf(), session_id.to_string()))
3110    }
3111
3112    /// Undo [`Self::note_configure_session_binding`] when the maintenance job
3113    /// carrying the session's bash replay was dropped as stale: the session has
3114    /// not actually been replayed, so its next bind must count as first again.
3115    pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
3116        self.configured_session_roots
3117            .lock()
3118            .remove(&(root.to_path_buf(), session_id.to_string()));
3119    }
3120
3121    /// Cheap emptiness probes for the maintenance scheduler: a drain kind with
3122    /// no pending work is not enqueued at all, so idle roots stop paying a
3123    /// dispatch cycle per kind per tick. Every probe is lock-free or try-lock
3124    /// (a contended source reports "maybe work" and the kind is enqueued —
3125    /// fail-open keeps the skip an optimization, never a correctness gate).
3126    pub fn watcher_drain_has_work(&self) -> bool {
3127        let receiver_pending = self
3128            .watcher_rx
3129            .lock()
3130            .as_ref()
3131            .is_some_and(|rx| !rx.is_empty());
3132        receiver_pending
3133            || self
3134                .watcher_drain_slice
3135                .lock()
3136                .as_ref()
3137                .is_some_and(WatcherDrainSliceState::has_pending_work)
3138    }
3139
3140    pub fn lsp_drain_has_work(&self) -> bool {
3141        match self.lsp_manager.try_lock() {
3142            Some(lsp) => lsp.has_pending_events(),
3143            // Contended: the manager is busy, so events may be queuing.
3144            None => true,
3145        }
3146    }
3147
3148    pub fn completion_drains_have_work(&self) -> bool {
3149        let search_pending = self
3150            .search_index_rx
3151            .try_read()
3152            .map(|slot| {
3153                slot.as_ref().is_some_and(|receiver| {
3154                    !receiver.is_empty()
3155                        || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
3156                            == self.search_index_rx_epoch()
3157                })
3158            })
3159            .unwrap_or(true);
3160        if search_pending {
3161            return true;
3162        }
3163        if self
3164            .callgraph_store_rx
3165            .lock()
3166            .as_ref()
3167            .is_some_and(|rx| !rx.is_empty())
3168        {
3169            return true;
3170        }
3171        if self
3172            .semantic_index_rx
3173            .lock()
3174            .as_ref()
3175            .is_some_and(|receiver| {
3176                !receiver.is_empty()
3177                    || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
3178                        == self.semantic_index_rx_epoch()
3179            })
3180        {
3181            return true;
3182        }
3183        if self
3184            .semantic_refresh_event_rx
3185            .lock()
3186            .as_ref()
3187            .is_some_and(|rx| !rx.is_empty())
3188        {
3189            return true;
3190        }
3191        if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
3192            return true;
3193        }
3194        if self
3195            .semantic_refresh_worker
3196            .lock()
3197            .as_ref()
3198            .is_some_and(|worker_slot| match worker_slot.try_lock() {
3199                Ok(handle) => handle
3200                    .as_ref()
3201                    .is_some_and(std::thread::JoinHandle::is_finished),
3202                Err(std::sync::TryLockError::WouldBlock) => true,
3203                Err(std::sync::TryLockError::Poisoned(_)) => true,
3204            })
3205        {
3206            return true;
3207        }
3208        self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
3209    }
3210
3211    pub fn configure_tail_has_work(&self) -> bool {
3212        !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
3213    }
3214
3215    pub(crate) fn configure_maintenance_has_capacity(&self) -> bool {
3216        self.configure_maintenance_jobs.lock().len() < crate::executor::MAINTENANCE_QUEUE_CAP
3217    }
3218
3219    pub(crate) fn enqueue_configure_maintenance(
3220        &self,
3221        job: ConfigureMaintenanceJob,
3222    ) -> Result<(), ConfigureMaintenanceJob> {
3223        let mut jobs = self.configure_maintenance_jobs.lock();
3224        if jobs.len() >= crate::executor::MAINTENANCE_QUEUE_CAP {
3225            return Err(job);
3226        }
3227        jobs.push_back(job);
3228        Ok(())
3229    }
3230
3231    pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
3232        self.configure_maintenance_jobs.lock().drain(..).collect()
3233    }
3234
3235    #[cfg(test)]
3236    pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
3237        self.configure_maintenance_jobs.lock().len()
3238    }
3239
3240    /// Peek the memoized artifact key without deriving it. Passive readers
3241    /// (status snapshots) use this so reporting never spawns a git probe.
3242    pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
3243        self.artifact_cache_keys.lock().get(canonical_root).cloned()
3244    }
3245
3246    /// Return a worktree probe result only while the root's `.git` marker still
3247    /// matches the marker present when the successful probe was cached.
3248    pub(crate) fn cached_worktree_bridge(
3249        &self,
3250        canonical_root: &Path,
3251    ) -> Option<(bool, Option<PathBuf>)> {
3252        #[cfg(test)]
3253        if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
3254            return None;
3255        }
3256
3257        let signature = git_entry_signature(canonical_root);
3258        self.worktree_bridge_cache
3259            .lock()
3260            .get(canonical_root)
3261            .filter(|entry| entry.git_entry == signature)
3262            .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
3263    }
3264
3265    /// Cache only successful git worktree probes. Failed probes remain retryable
3266    /// because a transient process or filesystem error must not become sticky.
3267    pub(crate) fn cache_worktree_bridge(
3268        &self,
3269        canonical_root: &Path,
3270        is_worktree_bridge: bool,
3271        git_common_dir: PathBuf,
3272    ) {
3273        self.worktree_bridge_cache.lock().insert(
3274            canonical_root.to_path_buf(),
3275            WorktreeBridgeCacheEntry {
3276                git_entry: git_entry_signature(canonical_root),
3277                is_worktree_bridge,
3278                git_common_dir: Some(git_common_dir),
3279            },
3280        );
3281    }
3282
3283    #[cfg(test)]
3284    pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
3285        self.worktree_bridge_probe_spawns
3286            .fetch_add(1, Ordering::SeqCst);
3287    }
3288
3289    #[cfg(test)]
3290    pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
3291        self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
3292    }
3293
3294    #[cfg(test)]
3295    pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
3296        self.force_worktree_bridge_reprobe
3297            .store(enabled, Ordering::SeqCst);
3298    }
3299
3300    pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
3301        let mut keys = self.artifact_cache_keys.lock();
3302        if let Some(key) = keys.get(canonical_root).cloned() {
3303            return key;
3304        }
3305        let key = crate::search_index::artifact_cache_key(canonical_root);
3306        self.artifact_cache_key_derivations
3307            .fetch_add(1, Ordering::SeqCst);
3308        keys.insert(canonical_root.to_path_buf(), key.clone());
3309        key
3310    }
3311
3312    pub fn memoized_artifact_cache_key_for_configure(
3313        &self,
3314        raw_root: &Path,
3315        canonical_root: &Path,
3316        storage_root: &Path,
3317        git_common_dir: Option<&Path>,
3318    ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
3319        {
3320            let keys = self.artifact_cache_keys.lock();
3321            if let Some(key) = keys
3322                .get(canonical_root)
3323                .or_else(|| keys.get(raw_root))
3324                .cloned()
3325            {
3326                return Ok(key);
3327            }
3328        }
3329
3330        let key = crate::search_index::artifact_cache_key_with_memo(
3331            canonical_root,
3332            raw_root,
3333            storage_root,
3334            git_common_dir,
3335        )?;
3336        self.artifact_cache_key_derivations
3337            .fetch_add(1, Ordering::SeqCst);
3338        let mut keys = self.artifact_cache_keys.lock();
3339        keys.insert(canonical_root.to_path_buf(), key.clone());
3340        keys.insert(raw_root.to_path_buf(), key.clone());
3341        Ok(key)
3342    }
3343
3344    #[cfg(test)]
3345    pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
3346        self.artifact_cache_key_derivations.load(Ordering::SeqCst)
3347    }
3348
3349    pub(crate) fn resolve_external_git_root(
3350        &self,
3351        project_root: &Path,
3352        requested_path: &str,
3353    ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
3354        let raw_path = Path::new(requested_path);
3355        let canonical_requested = if raw_path.is_absolute() {
3356            std::fs::canonicalize(raw_path).ok()
3357        } else {
3358            None
3359        };
3360        if let Some(root) = canonical_requested
3361            .as_deref()
3362            .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
3363        {
3364            return Ok(root);
3365        }
3366
3367        let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
3368            project_root,
3369            requested_path,
3370        )?;
3371        if canonical_requested.as_deref() == Some(root.as_path()) {
3372            self.borrowed_index_cache
3373                .lock()
3374                .remember_resolved_root(root.clone());
3375        }
3376        Ok(root)
3377    }
3378
3379    pub(crate) fn open_borrowed_search_index(
3380        &self,
3381        external_root: &Path,
3382        storage_dir: Option<&Path>,
3383    ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
3384        let canonical_root =
3385            std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3386        let project_key = self.memoized_artifact_cache_key(&canonical_root);
3387        let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
3388            &project_key,
3389            storage_dir,
3390        ) else {
3391            return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3392        };
3393        let key = BorrowedIndexCacheKey {
3394            canonical_root: canonical_root.clone(),
3395            artifact,
3396        };
3397        {
3398            let mut cache = self.borrowed_index_cache.lock();
3399            if let Some(index) = cache.search(&key) {
3400                return index;
3401            }
3402        }
3403
3404        // Artifact parsing can touch many records. Keep this process-local cache
3405        // mutex free so another read-only request is not blocked behind the load.
3406        let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
3407            &canonical_root,
3408            storage_dir,
3409            &project_key,
3410        )
3411        .map(Arc::new);
3412        if !matches!(
3413            opened,
3414            crate::readonly_artifacts::ReadOnlyArtifact::Absent
3415                | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3416        ) {
3417            self.borrowed_index_cache
3418                .lock()
3419                .insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
3420        }
3421        opened
3422    }
3423
3424    pub(crate) fn open_borrowed_semantic_index(
3425        &self,
3426        external_root: &Path,
3427        storage_dir: Option<&Path>,
3428    ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
3429        let canonical_root =
3430            std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3431        let project_key = self.memoized_artifact_cache_key(&canonical_root);
3432        let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
3433            &project_key,
3434            storage_dir,
3435        ) else {
3436            return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3437        };
3438        let key = BorrowedIndexCacheKey {
3439            canonical_root: canonical_root.clone(),
3440            artifact,
3441        };
3442        {
3443            let mut cache = self.borrowed_index_cache.lock();
3444            if let Some(index) = cache.semantic(&key) {
3445                return index;
3446            }
3447        }
3448
3449        // Semantic snapshot parsing follows the same rule: bounded work runs
3450        // without holding the cache's process-wide coordination mutex.
3451        let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
3452            &canonical_root,
3453            storage_dir,
3454            &project_key,
3455        )
3456        .map(Arc::new);
3457        if !matches!(
3458            opened,
3459            crate::readonly_artifacts::ReadOnlyArtifact::Absent
3460                | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3461        ) {
3462            self.borrowed_index_cache
3463                .lock()
3464                .insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
3465        }
3466        opened
3467    }
3468
3469    #[cfg(test)]
3470    pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
3471        self.borrowed_index_cache.lock().entries.len()
3472    }
3473
3474    pub fn configure_generation(&self) -> u64 {
3475        self.configure_generation.load(Ordering::SeqCst)
3476    }
3477
3478    pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
3479        Arc::clone(&self.configure_generation)
3480    }
3481
3482    pub(crate) fn configure_content_generation(&self) -> u64 {
3483        self.configure_content_generation.load(Ordering::SeqCst)
3484    }
3485
3486    pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
3487        Arc::clone(&self.configure_content_generation)
3488    }
3489
3490    pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
3491        let now = Instant::now();
3492        let mut timing = self.configure_phase_timing.lock();
3493        if phase == "canonicalize" {
3494            timing.completed.clear();
3495        } else if timing.phase != "idle" && timing.phase != "ack_ready" {
3496            let previous = timing.phase;
3497            let elapsed = now.saturating_duration_since(timing.started_at);
3498            timing.completed.push((previous, elapsed));
3499        }
3500        timing.phase = phase;
3501        timing.started_at = now;
3502    }
3503
3504    pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
3505        let timing = self.configure_phase_timing.lock();
3506        let mut parts = timing
3507            .completed
3508            .iter()
3509            .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
3510            .collect::<Vec<_>>();
3511        parts.push(format!(
3512            "{}={}ms",
3513            timing.phase,
3514            timing.started_at.elapsed().as_millis()
3515        ));
3516        parts.join(",")
3517    }
3518
3519    pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
3520        self.semantic_fingerprint_generation
3521            .fetch_add(1, Ordering::SeqCst)
3522            .wrapping_add(1)
3523    }
3524
3525    pub fn semantic_fingerprint_generation(&self) -> u64 {
3526        self.semantic_fingerprint_generation.load(Ordering::SeqCst)
3527    }
3528
3529    pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
3530        Arc::clone(&self.semantic_fingerprint_generation)
3531    }
3532
3533    /// Invalidate an in-flight semantic builder when its corpus inputs change.
3534    /// This is intentionally independent from the broad configure generation so
3535    /// unrelated configuration changes can adopt a costly live embedding build.
3536    pub(crate) fn advance_semantic_build_epoch(&self) -> u64 {
3537        self.semantic_build_epoch
3538            .fetch_add(1, Ordering::SeqCst)
3539            .wrapping_add(1)
3540    }
3541
3542    pub(crate) fn semantic_build_epoch(&self) -> u64 {
3543        self.semantic_build_epoch.load(Ordering::SeqCst)
3544    }
3545
3546    pub(crate) fn semantic_build_epoch_flag(&self) -> Arc<AtomicU64> {
3547        Arc::clone(&self.semantic_build_epoch)
3548    }
3549
3550    pub fn configure_warnings_sender(
3551        &self,
3552    ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
3553        self.configure_warnings_tx.clone()
3554    }
3555
3556    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
3557        let mut warnings = Vec::new();
3558        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
3559            warnings.push(warning);
3560        }
3561        warnings
3562    }
3563
3564    pub fn bash_background(&self) -> &BgTaskRegistry {
3565        &self.bash_background
3566    }
3567
3568    #[cfg(unix)]
3569    pub(crate) fn escalation_grants(
3570        &self,
3571    ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
3572        &self.escalation_grants
3573    }
3574
3575    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
3576        self.bash_background.drain_completions()
3577    }
3578
3579    /// Access the language provider.
3580    pub fn provider(&self) -> &dyn LanguageProvider {
3581        self.provider.as_ref()
3582    }
3583
3584    /// Access the backup store.
3585    pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
3586        &self.backup
3587    }
3588
3589    /// Session-scoped hashline bindings installed by successful configure calls.
3590    pub fn hashline_bindings(&self) -> &crate::hashline::integration::BindingRegistry {
3591        &self.hashline_bindings
3592    }
3593
3594    /// Access the checkpoint store.
3595    pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
3596        &self.checkpoint
3597    }
3598
3599    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
3600        self.app.set_db(conn);
3601        self.compression_aggregates.clear();
3602    }
3603
3604    pub fn clear_db(&self) {
3605        self.app.clear_db();
3606        self.compression_aggregates.clear();
3607    }
3608
3609    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
3610        self.app.db()
3611    }
3612
3613    pub(crate) fn compression_aggregate_cache(
3614        &self,
3615    ) -> &crate::db::compression_events::CompressionAggregateCache {
3616        self.compression_aggregates.as_ref()
3617    }
3618
3619    /// Access an owned configuration snapshot.
3620    pub fn config(&self) -> Arc<Config> {
3621        let guard = match self.config.read() {
3622            Ok(guard) => guard,
3623            Err(poisoned) => poisoned.into_inner(),
3624        };
3625        Arc::clone(&*guard)
3626    }
3627
3628    /// Atomically publish a fully-built configuration snapshot.
3629    pub fn set_config(&self, config: Config) {
3630        let next = Arc::new(config);
3631        let project_root_changed = {
3632            let mut guard = self
3633                .config
3634                .write()
3635                .unwrap_or_else(std::sync::PoisonError::into_inner);
3636            // Compare the configured spelling, not a normalized equivalent:
3637            // that spelling is the memo key for containment-root resolution.
3638            let changed = guard.project_root.as_ref().map(|root| root.as_os_str())
3639                != next.project_root.as_ref().map(|root| root.as_os_str());
3640            *guard = next;
3641            changed
3642        };
3643        if project_root_changed {
3644            self.path_restriction_root_memo.lock().take();
3645        }
3646    }
3647
3648    #[cfg(test)]
3649    pub(crate) fn path_restriction_root_memo_is_empty_for_test(&self) -> bool {
3650        self.path_restriction_root_memo.lock().is_none()
3651    }
3652
3653    #[cfg(test)]
3654    pub(crate) fn path_restriction_root_canonicalizations_for_test(&self) -> usize {
3655        self.path_restriction_root_canonicalizations
3656            .load(Ordering::SeqCst)
3657    }
3658
3659    /// Clone-mutate-publish the current configuration without returning a guard.
3660    pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
3661        let mut next = self.config().as_ref().clone();
3662        update(&mut next);
3663        self.set_config(next);
3664    }
3665
3666    pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
3667        let mut requests = self.force_restrict_requests.lock();
3668        *requests.entry(req_id.to_string()).or_insert(0) += 1;
3669        ForceRestrictGuard {
3670            ctx: self,
3671            req_id: req_id.to_string(),
3672        }
3673    }
3674
3675    pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
3676        let _guard = self.force_restrict_guard(req_id);
3677        f()
3678    }
3679
3680    pub fn request_force_restrict(&self, req_id: &str) -> bool {
3681        self.force_restrict_requests.lock().contains_key(req_id)
3682    }
3683
3684    fn release_force_restrict(&self, req_id: &str) {
3685        let mut requests = self.force_restrict_requests.lock();
3686        match requests.get_mut(req_id) {
3687            Some(count) if *count > 1 => *count -= 1,
3688            Some(_) => {
3689                requests.remove(req_id);
3690            }
3691            None => {}
3692        }
3693    }
3694
3695    pub fn set_harness(&self, harness: Harness) {
3696        self.bash_background.set_harness(harness.clone());
3697        *self.harness.lock() = Some(harness);
3698    }
3699
3700    pub fn harness_opt(&self) -> Option<Harness> {
3701        self.harness.lock().clone()
3702    }
3703
3704    pub fn harness(&self) -> Harness {
3705        self.harness_opt()
3706            .expect("harness set by configure before any tool call")
3707    }
3708
3709    pub fn storage_dir(&self) -> PathBuf {
3710        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
3711    }
3712
3713    pub fn harness_dir(&self) -> PathBuf {
3714        self.storage_dir().join(self.harness().storage_segment())
3715    }
3716
3717    /// Refresh the in-memory list of durable build suspensions during health
3718    /// maintenance so reply handling can return the cached snapshot instead of
3719    /// querying storage.
3720    pub(crate) fn refresh_build_suspensions_for_health(
3721        &self,
3722        project_root: &Path,
3723        project_key: Option<&str>,
3724    ) {
3725        let now_ms = SystemTime::now()
3726            .duration_since(UNIX_EPOCH)
3727            .unwrap_or_default()
3728            .as_millis()
3729            .min(u128::from(u64::MAX)) as u64;
3730        self.refresh_build_suspensions_for_health_at(project_root, project_key, now_ms);
3731    }
3732
3733    pub(crate) fn refresh_build_suspensions_for_health_at(
3734        &self,
3735        project_root: &Path,
3736        project_key: Option<&str>,
3737        now_ms: u64,
3738    ) {
3739        let suspended_domains = project_key
3740            .and_then(|key| {
3741                let path = self
3742                    .storage_dir()
3743                    .join("callgraph")
3744                    .join(key)
3745                    .join("build-breaker.sqlite");
3746                path.is_file().then_some(path)
3747            })
3748            .and_then(|path| crate::build_breaker::BuildDeathBreaker::open(path).ok())
3749            .and_then(|breaker| {
3750                breaker
3751                    .active_suspensions_for_root_at(&project_root.display().to_string(), now_ms)
3752                    .ok()
3753            })
3754            .unwrap_or_default()
3755            .into_iter()
3756            .map(|suspension| {
3757                let age_s = suspension.age_seconds_at(now_ms);
3758                SuspendedDomainHealthSnapshot {
3759                    domain: suspension.domain.as_str().to_string(),
3760                    reason: suspension.reason,
3761                    death_count: suspension.death_count,
3762                    age_s,
3763                }
3764            })
3765            .collect();
3766        if let Ok(mut snapshot) = self.health_build_suspensions.write() {
3767            *snapshot = suspended_domains;
3768        }
3769    }
3770
3771    pub fn inspect_dir(&self) -> PathBuf {
3772        if let Some(root) = self
3773            .canonical_cache_root_opt()
3774            .or_else(|| self.config().project_root.clone())
3775        {
3776            self.storage_dir()
3777                .join("inspect")
3778                .join(crate::path_identity::project_scope_key(&root))
3779        } else {
3780            self.storage_dir().join("inspect").join("unconfigured")
3781        }
3782    }
3783
3784    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3785        self.harness_dir()
3786            .join("bash-tasks")
3787            .join(hash_session(session_id))
3788    }
3789
3790    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3791        self.harness_dir()
3792            .join("backups")
3793            .join(hash_session(session_id))
3794            .join(path_hash)
3795    }
3796
3797    pub fn filters_dir(&self) -> PathBuf {
3798        self.harness_dir().join("filters")
3799    }
3800
3801    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
3802    pub fn trust_file(&self) -> PathBuf {
3803        self.storage_dir().join("trusted-filter-projects.json")
3804    }
3805
3806    pub fn set_canonical_cache_root(&self, root: PathBuf) {
3807        debug_assert!(root.is_absolute());
3808        let root_changed = {
3809            let mut current = self.canonical_cache_root.lock();
3810            let changed = current.as_deref() != Some(root.as_path());
3811            *current = Some(root);
3812            changed
3813        };
3814        if root_changed {
3815            let mut tier2 = self
3816                .status_bar_tier2
3817                .write()
3818                .unwrap_or_else(std::sync::PoisonError::into_inner);
3819            let generation = tier2.generation.wrapping_add(1);
3820            *tier2 = StatusBarTier2 {
3821                generation,
3822                ..StatusBarTier2::default()
3823            };
3824            self.status_bar_last_emitted.clear();
3825        }
3826    }
3827
3828    pub fn canonical_cache_root(&self) -> PathBuf {
3829        self.canonical_cache_root
3830            .lock()
3831            .clone()
3832            .expect("canonical_cache_root accessed before handle_configure")
3833    }
3834
3835    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3836        self.canonical_cache_root.lock().clone()
3837    }
3838
3839    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3840        *self.is_worktree_bridge.lock() = is_worktree_bridge;
3841        *self.git_common_dir.lock() = git_common_dir;
3842        // The configure-time worktree probe already applies the test seam, so
3843        // automatic Tier-2 scheduling follows the same effective root role as
3844        // callgraph cold-build gating while explicit inspect demand stays enabled.
3845        self.inspect_manager
3846            .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3847        let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3848        self.callgraph_writer
3849            .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3850    }
3851
3852    pub fn set_artifact_owner(
3853        &self,
3854        status: Option<ArtifactOwnerStatus>,
3855        lease: Option<ArtifactOwnerLease>,
3856    ) {
3857        let read_only = status
3858            .as_ref()
3859            .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3860        self.shared_artifacts_read_only
3861            .store(read_only, Ordering::SeqCst);
3862        self.callgraph_writer
3863            .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3864        self.inspect_writer.store(true, Ordering::SeqCst);
3865        *self.artifact_owner_status.lock() = status;
3866        *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3867    }
3868
3869    pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3870        self.callgraph_writer
3871            .store(callgraph_writer, Ordering::SeqCst);
3872        self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3873    }
3874
3875    pub fn callgraph_writer(&self) -> bool {
3876        self.callgraph_writer.load(Ordering::SeqCst)
3877    }
3878
3879    pub fn inspect_writer(&self) -> bool {
3880        self.inspect_writer.load(Ordering::SeqCst)
3881    }
3882
3883    pub fn shared_artifacts_read_only(&self) -> bool {
3884        !self.callgraph_writer()
3885    }
3886
3887    /// Mark whether this context serves standalone NDJSON requests rather than
3888    /// a live subc route. Only standalone queries may disclose a stale CLI
3889    /// snapshot instead of following the daemon's normal freshness path.
3890    #[doc(hidden)]
3891    pub fn set_daemonless_query_mode(&self, enabled: bool) {
3892        self.daemonless_query_mode.store(enabled, Ordering::SeqCst);
3893    }
3894
3895    pub(crate) fn daemonless_query_mode(&self) -> bool {
3896        self.daemonless_query_mode.load(Ordering::SeqCst)
3897    }
3898
3899    /// True when this root is borrow-only and `worktree.ram_overlay` is on.
3900    ///
3901    /// Search and symbol-cache watcher arms may then apply local edits to the
3902    /// in-RAM trigram delta. Persist stays fail-closed: a borrow-only root
3903    /// never writes the shared `cache.bin`, overlay or not.
3904    pub fn ram_overlay_active(&self) -> bool {
3905        self.shared_artifacts_read_only() && self.config().worktree.ram_overlay
3906    }
3907
3908    pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3909        self.artifact_owner_status.lock().clone()
3910    }
3911
3912    pub fn is_worktree_bridge(&self) -> bool {
3913        *self.is_worktree_bridge.lock()
3914    }
3915
3916    pub fn git_common_dir(&self) -> Option<PathBuf> {
3917        self.git_common_dir.lock().clone()
3918    }
3919
3920    /// Replace the current degraded-mode reasons. Empty vec = full-featured
3921    /// mode (no degradation). Called by `handle_configure` after deciding
3922    /// which subsystems to disable for this project root.
3923    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3924        *self.degraded_reasons.lock() = reasons;
3925    }
3926
3927    pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3928        self.heavy_root_work_allowed
3929            .store(allowed, Ordering::SeqCst);
3930    }
3931
3932    pub fn heavy_root_work_allowed(&self) -> bool {
3933        self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3934    }
3935
3936    fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3937        if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3938            return Some(false);
3939        }
3940        self.subc_lifecycle.try_is_bound()
3941    }
3942
3943    pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3944        let reason = reason.into();
3945        let mut reasons = self.degraded_reasons.lock();
3946        if reasons.iter().any(|existing| existing == &reason) {
3947            return false;
3948        }
3949        reasons.push(reason);
3950        true
3951    }
3952
3953    /// Snapshot of current degraded-mode reasons. Order is stable
3954    /// (insertion order from `set_degraded_reasons`) so UI rendering and
3955    /// snapshot diffs are deterministic.
3956    pub fn degraded_reasons(&self) -> Vec<String> {
3957        self.degraded_reasons.lock().clone()
3958    }
3959
3960    /// True iff at least one degraded reason is recorded.
3961    pub fn is_degraded(&self) -> bool {
3962        !self.degraded_reasons.lock().is_empty()
3963    }
3964
3965    /// True when configure identified the current root as exactly `$HOME`.
3966    /// Home is a user container, never a project root, so callgraph queries
3967    /// must report the intentional disabled state instead of a retryable miss.
3968    pub fn is_home_root(&self) -> bool {
3969        self.degraded_reasons
3970            .lock()
3971            .iter()
3972            .any(|reason| reason == "home_root")
3973    }
3974
3975    pub fn cache_role(&self) -> &'static str {
3976        if self.canonical_cache_root.lock().is_none() {
3977            "not_initialized"
3978        } else if self.is_worktree_bridge() {
3979            "worktree"
3980        } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3981            "read_only"
3982        } else {
3983            "main"
3984        }
3985    }
3986
3987    /// Access the persisted call graph store.
3988    pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
3989        self.callgraph_store.as_ref()
3990    }
3991
3992    pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
3993        self.callgraph_store_force_requested
3994            .fetch_add(1, Ordering::SeqCst)
3995            .wrapping_add(1)
3996    }
3997
3998    pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
3999        let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
4000        let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
4001        (requested > fulfilled).then_some(requested)
4002    }
4003
4004    pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
4005        self.callgraph_store_force_fulfilled
4006            .fetch_max(token, Ordering::SeqCst);
4007    }
4008
4009    #[doc(hidden)]
4010    pub fn record_callgraph_store_build_denied(&self, generation: u64, reason: String) {
4011        *self.callgraph_store_build_denied.lock() = Some((generation, reason));
4012    }
4013
4014    #[doc(hidden)]
4015    pub fn record_callgraph_store_build_suspension(
4016        &self,
4017        generation: u64,
4018        suspension: crate::build_breaker::BuildSuspension,
4019    ) {
4020        *self.callgraph_store_build_suspension.lock() = Some((generation, suspension));
4021    }
4022
4023    #[doc(hidden)]
4024    pub fn clear_callgraph_store_build_denied(&self) {
4025        *self.callgraph_store_build_denied.lock() = None;
4026        *self.callgraph_store_build_suspension.lock() = None;
4027    }
4028
4029    fn callgraph_store_build_suspension(&self) -> Option<crate::build_breaker::BuildSuspension> {
4030        let generation = self.configure_generation();
4031        let mut suspended = self.callgraph_store_build_suspension.lock();
4032        match suspended.as_ref() {
4033            Some((suspended_generation, value)) if *suspended_generation == generation => {
4034                Some(value.clone())
4035            }
4036            Some(_) => {
4037                *suspended = None;
4038                None
4039            }
4040            None => None,
4041        }
4042    }
4043
4044    fn callgraph_store_build_denial(&self) -> Option<String> {
4045        let generation = self.configure_generation();
4046        let mut denied = self.callgraph_store_build_denied.lock();
4047        match denied.as_ref() {
4048            Some((denied_generation, reason)) if *denied_generation == generation => {
4049                Some(reason.clone())
4050            }
4051            Some(_) => {
4052                *denied = None;
4053                None
4054            }
4055            None => None,
4056        }
4057    }
4058
4059    pub fn callgraph_store_dir(&self) -> PathBuf {
4060        if let Some(root) = self.callgraph_project_root() {
4061            self.storage_dir()
4062                .join("callgraph")
4063                .join(self.memoized_artifact_cache_key(&root))
4064        } else {
4065            self.storage_dir().join("callgraph").join("unconfigured")
4066        }
4067    }
4068
4069    pub fn ensure_callgraph_store(
4070        &self,
4071    ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
4072        self.ensure_callgraph_store_with_flag(true)
4073    }
4074
4075    fn ensure_callgraph_store_with_flag(
4076        &self,
4077        respect_config_flag: bool,
4078    ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
4079        if respect_config_flag && !self.config().callgraph_store {
4080            return Ok(None);
4081        }
4082        if !self.heavy_root_work_allowed() {
4083            return Ok(None);
4084        }
4085        self.revalidate_callgraph_store_generation();
4086        let force_token = self.pending_callgraph_store_force_token();
4087        if force_token.is_none() {
4088            if let Some(store) = {
4089                let guard = self
4090                    .callgraph_store
4091                    .read()
4092                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4093                guard.as_ref().map(Arc::clone)
4094            } {
4095                self.schedule_legacy_callgraph_migration_if_needed(
4096                    store.as_ref(),
4097                    store.project_root().to_path_buf(),
4098                    self.callgraph_store_dir(),
4099                );
4100                return Ok(Some(store));
4101            }
4102        }
4103
4104        let Some(project_root) = self.callgraph_project_root() else {
4105            return Ok(None);
4106        };
4107        let callgraph_dir = self.callgraph_store_dir();
4108
4109        // Preserve a readable legacy fallback while writer-capable processes
4110        // migrate it on the cold-build lane. Opening before the writer path is
4111        // also the cheap fast path for an already-published root generation.
4112        if force_token.is_none() {
4113            if let Some(store) =
4114                CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
4115            {
4116                let store = Arc::new(store);
4117                {
4118                    let mut guard = self
4119                        .callgraph_store
4120                        .write()
4121                        .unwrap_or_else(std::sync::PoisonError::into_inner);
4122                    *guard = Some(Arc::clone(&store));
4123                }
4124                self.schedule_legacy_callgraph_migration_if_needed(
4125                    store.as_ref(),
4126                    project_root,
4127                    callgraph_dir,
4128                );
4129                return Ok(Some(store));
4130            }
4131        }
4132
4133        if !self.callgraph_writer() {
4134            return Ok(None);
4135        }
4136        let build_generation = self.configure_generation();
4137        let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4138        let Some(persist_epoch) = self
4139            .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
4140        else {
4141            return Ok(None);
4142        };
4143        // Let the store walk directly into its staging table. Keeping discovery
4144        // inside the builder prevents a second corpus-sized path inventory here.
4145        let (store, _stats) = crate::callgraph_store::with_publish_epoch(
4146            persist_epoch_flag.clone(),
4147            persist_epoch,
4148            || {
4149                if force_token.is_some() {
4150                    CallGraphStore::force_cold_build_with_lease_chunked(
4151                        callgraph_dir.clone(),
4152                        project_root.clone(),
4153                        &[],
4154                        self.config().callgraph_chunk_size,
4155                    )
4156                    .map(|(store, _stats)| (store, ()))
4157                } else {
4158                    CallGraphStore::ensure_built_with_lease_chunked(
4159                        callgraph_dir.clone(),
4160                        project_root.clone(),
4161                        &[],
4162                        self.config().callgraph_chunk_size,
4163                    )
4164                    .map(|(store, _stats)| (store, ()))
4165                }
4166            },
4167        )?;
4168        drop(store);
4169
4170        let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
4171            return Ok(None);
4172        };
4173        let store = Arc::new(store);
4174        self.run_if_subc_bound_generation(build_generation, || {
4175            if persist_epoch_flag.current() != persist_epoch {
4176                return None;
4177            }
4178            let mut guard = self
4179                .callgraph_store
4180                .write()
4181                .unwrap_or_else(std::sync::PoisonError::into_inner);
4182            *guard = Some(Arc::clone(&store));
4183            if let Some(force_token) = force_token {
4184                self.fulfill_callgraph_store_force_token(force_token);
4185            }
4186            Some(Arc::clone(&store))
4187        })
4188        .flatten()
4189        .map_or(Ok(None), |store| Ok(Some(store)))
4190    }
4191
4192    /// Resolve the project root used for the callgraph store: prefer the
4193    /// canonical cache root, falling back to the configured project root.
4194    pub fn callgraph_project_root(&self) -> Option<PathBuf> {
4195        self.canonical_cache_root_opt().or_else(|| {
4196            self.config()
4197                .project_root
4198                .clone()
4199                .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
4200        })
4201    }
4202
4203    /// Drop a cached reader when another process published a newer generation.
4204    /// The next access reopens through the pointer and converges to that
4205    /// generation instead of serving a stale long-lived connection.
4206    pub fn revalidate_callgraph_store_generation(&self) {
4207        let (superseded, legacy_fallback) = {
4208            let guard = self
4209                .callgraph_store
4210                .read()
4211                .unwrap_or_else(std::sync::PoisonError::into_inner);
4212            guard
4213                .as_ref()
4214                .map(|store| (!store.is_current(), store.is_legacy_fallback()))
4215                .unwrap_or((false, false))
4216        };
4217        if !superseded {
4218            return;
4219        }
4220        // A local migration publishes its pointer just before sending the new
4221        // store to the main-loop drain. Keep queries on the fallback during that
4222        // narrow handoff instead of reporting a transient Building state.
4223        if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
4224            return;
4225        }
4226        let mut guard = self
4227            .callgraph_store
4228            .write()
4229            .unwrap_or_else(std::sync::PoisonError::into_inner);
4230        *guard = None;
4231    }
4232
4233    pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
4234        self.callgraph_store_for_ops_with_wait(callgraph_build_wait_window())
4235    }
4236
4237    /// Warm the callgraph store from the transport loop without the query-op wait.
4238    ///
4239    /// Query operations can wait up to `AFT_CALLGRAPH_BUILD_WAIT_MS` for a cold
4240    /// build to become ready. Configure maintenance and work resumed after semantic
4241    /// index initialization run on the loop that reads stdin; waiting there would
4242    /// delay EOF handling until the build or wait window finishes, leaving the
4243    /// process alive after the client closes the pipe.
4244    pub(crate) fn schedule_callgraph_store_warm(&self) -> CallgraphStoreAccess {
4245        self.callgraph_store_for_ops_with_wait(Duration::ZERO)
4246    }
4247
4248    fn callgraph_store_for_ops_with_wait(&self, wait: Duration) -> CallgraphStoreAccess {
4249        if !self.heavy_root_work_allowed() {
4250            return CallgraphStoreAccess::Unavailable;
4251        }
4252        let operation_generation = self.configure_generation();
4253
4254        // Converge to a newer generation another process (or a local cold
4255        // rebuild) may have published: if our resident store is superseded, drop
4256        // it so the open path below reopens via the pointer. Cheap pointer read.
4257        self.revalidate_callgraph_store_generation();
4258        let force_token = self.pending_callgraph_store_force_token();
4259        if force_token.is_none() {
4260            if let Some(store) = {
4261                let guard = self
4262                    .callgraph_store
4263                    .read()
4264                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4265                guard.as_ref().map(Arc::clone)
4266            } {
4267                self.clear_callgraph_store_build_denied();
4268                self.schedule_legacy_callgraph_migration_if_needed(
4269                    store.as_ref(),
4270                    store.project_root().to_path_buf(),
4271                    self.callgraph_store_dir(),
4272                );
4273                return CallgraphStoreAccess::Ready(store);
4274            }
4275        }
4276
4277        if let Some(suspension) = self.callgraph_store_build_suspension() {
4278            return CallgraphStoreAccess::Suspended(suspension);
4279        }
4280        if let Some(reason) = self.callgraph_store_build_denial() {
4281            return CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason));
4282        }
4283
4284        // Query ops share an existing build instead of starting a second one.
4285        // Their bounded wait below must cover work scheduled by maintenance as
4286        // well as work started by the query itself.
4287        let build_in_flight = self.callgraph_store_rx.lock().is_some();
4288
4289        let Some(project_root) = self.callgraph_project_root() else {
4290            return CallgraphStoreAccess::Unavailable;
4291        };
4292        let callgraph_dir = self.callgraph_store_dir();
4293
4294        if !build_in_flight {
4295            match CallGraphStore::cold_build_suspension(&callgraph_dir, &project_root) {
4296                Ok(Some(suspension)) => return CallgraphStoreAccess::Suspended(suspension),
4297                Ok(None) => {}
4298                Err(error) => return CallgraphStoreAccess::Error(error),
4299            }
4300        }
4301
4302        if !build_in_flight {
4303            if force_token.is_none() {
4304                match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
4305                    Ok(Some(store)) => {
4306                        let store = Arc::new(store);
4307                        let installed =
4308                            self.run_if_subc_bound_generation(operation_generation, || {
4309                                let mut guard = self
4310                                    .callgraph_store
4311                                    .write()
4312                                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4313                                *guard = Some(Arc::clone(&store));
4314                                Arc::clone(&store)
4315                            });
4316                        let Some(store) = installed else {
4317                            return CallgraphStoreAccess::Unavailable;
4318                        };
4319                        self.clear_callgraph_store_build_denied();
4320                        self.schedule_legacy_callgraph_migration_if_needed(
4321                            store.as_ref(),
4322                            project_root.clone(),
4323                            callgraph_dir.clone(),
4324                        );
4325                        return CallgraphStoreAccess::Ready(store);
4326                    }
4327                    Ok(None) => {
4328                        if !self.callgraph_writer() {
4329                            return CallgraphStoreAccess::Unavailable;
4330                        }
4331                    }
4332                    Err(error) => {
4333                        if !self.callgraph_writer() {
4334                            return CallgraphStoreAccess::Unavailable;
4335                        }
4336                        crate::slog_warn!(
4337                            "callgraph read-only open failed before writer promotion: {}",
4338                            error
4339                        );
4340                    }
4341                }
4342            } else if !self.callgraph_writer() {
4343                return CallgraphStoreAccess::Unavailable;
4344            }
4345
4346            if self.semantic_cold_seed_active() {
4347                self.defer_callgraph_store_warm_for_semantic_cold_seed();
4348                return CallgraphStoreAccess::Building;
4349            }
4350
4351            // Cold build required: run it off the request thread and return
4352            // `Building` so the agent retries (the watcher keeps the store fresh
4353            // once it lands). By default this never blocks the request thread.
4354            //
4355            // `wait` is the query-op inline window (`AFT_CALLGRAPH_BUILD_WAIT_MS`,
4356            // default 0). Transport-loop warmers pass zero so stdin EOF stays
4357            // observable while the cold build runs in the background.
4358            let work = if let Some(force_token) = force_token {
4359                crate::slog_info!(
4360                    "callgraph cold-build decision: reason=corpus drift; action=force rebuild"
4361                );
4362                CallgraphBackgroundWork::ForceRebuild(force_token)
4363            } else {
4364                crate::slog_info!(
4365                    "callgraph cold-build decision: reason=no current generation; action=ensure build"
4366                );
4367                CallgraphBackgroundWork::Ensure
4368            };
4369            // A concurrent caller may have installed a receiver after the
4370            // snapshot above. The spawn path deduplicates that race, and the
4371            // common wait path below joins whichever build won.
4372            let _ = self.spawn_callgraph_store_cold_build(
4373                project_root.clone(),
4374                callgraph_dir.clone(),
4375                work,
4376            );
4377        }
4378
4379        if !wait.is_zero() {
4380            let (received, receiver_generation, receiver_epoch) = {
4381                let rx_ref = self.callgraph_store_rx.lock();
4382                let Some(rx) = rx_ref.as_ref() else {
4383                    return CallgraphStoreAccess::Building;
4384                };
4385                (
4386                    rx.recv_timeout(wait),
4387                    self.callgraph_store_rx_generation(),
4388                    self.callgraph_store_rx_epoch(),
4389                )
4390            };
4391            match received {
4392                Ok(CallGraphStoreBuildEvent::Ready {
4393                    store,
4394                    fulfilled_force_token,
4395                    publication_epoch,
4396                }) => {
4397                    if self.callgraph_persist_epoch_flag().current() != publication_epoch {
4398                        // Superseded publication: a newer configure owns the
4399                        // pointer. Clear the receiver and report Building so the
4400                        // replacement build's event installs instead.
4401                        drop(store);
4402                        let _ = self.with_current_callgraph_store_rx(
4403                            receiver_generation,
4404                            receiver_epoch,
4405                            |receiver| {
4406                                *receiver = None;
4407                            },
4408                        );
4409                        return CallgraphStoreAccess::Building;
4410                    }
4411                    // The completed build owns the writer lease until dropped;
4412                    // release it before reopening the published generation.
4413                    remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
4414                    drop(store);
4415                    let reopened =
4416                        CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
4417                    let mut pending = Vec::new();
4418                    let outcome = self.with_current_callgraph_store_rx(
4419                        receiver_generation,
4420                        receiver_epoch,
4421                        |receiver| {
4422                            *receiver = None;
4423                            match reopened {
4424                                Ok(Some(store)) => {
4425                                    let ready = Arc::new(store);
4426                                    self.clear_callgraph_store_build_denied();
4427                                    *self
4428                                        .callgraph_store
4429                                        .write()
4430                                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
4431                                        Some(Arc::clone(&ready));
4432                                    // This take and the refresh worker's post-defer re-check form a
4433                                    // check-then-act handoff: the store is installed before the
4434                                    // take, so one site sees parked paths with a ready store and
4435                                    // neither site needs to poll alone.
4436                                    pending = self.take_pending_callgraph_store_paths();
4437                                    if let Some(force_token) = fulfilled_force_token {
4438                                        self.fulfill_callgraph_store_force_token(force_token);
4439                                    }
4440                                    CallgraphStoreAccess::Ready(ready)
4441                                }
4442                                Ok(None) => CallgraphStoreAccess::Building,
4443                                Err(error) => CallgraphStoreAccess::Error(error),
4444                            }
4445                        },
4446                    );
4447                    let Some(outcome) = outcome else {
4448                        return if self.subc_unbound_quiesced()
4449                            || self.configure_generation() != receiver_generation
4450                        {
4451                            CallgraphStoreAccess::Unavailable
4452                        } else {
4453                            CallgraphStoreAccess::Building
4454                        };
4455                    };
4456                    if !pending.is_empty() {
4457                        let _ = self.enqueue_callgraph_store_refresh(pending);
4458                    }
4459                    if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
4460                        let _ = self.request_tier2_refresh_pull();
4461                    }
4462                    return outcome;
4463                }
4464                Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
4465                    let suspended = self.with_current_callgraph_store_rx(
4466                        receiver_generation,
4467                        receiver_epoch,
4468                        |receiver| {
4469                            *receiver = None;
4470                            self.record_callgraph_store_build_suspension(
4471                                receiver_generation,
4472                                suspension.clone(),
4473                            );
4474                            CallgraphStoreAccess::Suspended(suspension)
4475                        },
4476                    );
4477                    return suspended.unwrap_or(CallgraphStoreAccess::Unavailable);
4478                }
4479                Ok(CallGraphStoreBuildEvent::Denied { reason }) => {
4480                    let denied = self.with_current_callgraph_store_rx(
4481                        receiver_generation,
4482                        receiver_epoch,
4483                        |receiver| {
4484                            *receiver = None;
4485                            self.record_callgraph_store_build_denied(
4486                                receiver_generation,
4487                                reason.clone(),
4488                            );
4489                            CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
4490                        },
4491                    );
4492                    return denied.unwrap_or(CallgraphStoreAccess::Unavailable);
4493                }
4494                Ok(CallGraphStoreBuildEvent::Settled) => {
4495                    let _ = self.with_current_callgraph_store_rx(
4496                        receiver_generation,
4497                        receiver_epoch,
4498                        |receiver| *receiver = None,
4499                    );
4500                    return CallgraphStoreAccess::Building;
4501                }
4502                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
4503                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
4504                    let _ = self.with_current_callgraph_store_rx(
4505                        receiver_generation,
4506                        receiver_epoch,
4507                        |receiver| *receiver = None,
4508                    );
4509                }
4510            }
4511        }
4512        CallgraphStoreAccess::Building
4513    }
4514
4515    fn schedule_legacy_callgraph_migration_if_needed(
4516        &self,
4517        store: &ReadonlyCallGraphStore,
4518        project_root: PathBuf,
4519        callgraph_dir: PathBuf,
4520    ) {
4521        if !store.is_legacy_fallback()
4522            || !self.callgraph_writer()
4523            || !self.heavy_root_work_allowed()
4524        {
4525            return;
4526        }
4527        if self.semantic_cold_seed_active() {
4528            self.defer_callgraph_store_warm_for_semantic_cold_seed();
4529            return;
4530        }
4531        let _ = self.spawn_callgraph_store_cold_build(
4532            project_root,
4533            callgraph_dir,
4534            CallgraphBackgroundWork::LegacyMigration,
4535        );
4536    }
4537
4538    fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
4539        let mut roots = self
4540            .configured_session_roots
4541            .lock()
4542            .iter()
4543            .map(|(root, _session)| root.clone())
4544            .collect::<BTreeSet<_>>();
4545        roots.insert(current_root.to_path_buf());
4546        roots
4547            .iter()
4548            // Configure already derived these keys. Re-running the git
4549            // root-commit probe here would block the transport loop on spawn
4550            // retries when git is missing from PATH.
4551            .map(|root| self.memoized_artifact_cache_key(root))
4552            .collect()
4553    }
4554
4555    /// Atomically mark root-keyed callgraph maintenance in flight and spawn it
4556    /// on the cold-build lane. The same receiver/install path handles cold
4557    /// builds and legacy migrations, so watcher edits are queued and replayed
4558    /// against whichever root-keyed generation publishes.
4559    fn spawn_callgraph_store_cold_build(
4560        &self,
4561        project_root: PathBuf,
4562        callgraph_dir: PathBuf,
4563        work: CallgraphBackgroundWork,
4564    ) -> bool {
4565        if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
4566            return false;
4567        }
4568        let generation = self.configure_generation();
4569        self.run_if_subc_bound_generation(generation, || {
4570            self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
4571        })
4572        .unwrap_or(false)
4573    }
4574
4575    /// Start a callgraph worker after lifecycle admission has been acquired.
4576    fn spawn_callgraph_store_cold_build_admitted(
4577        &self,
4578        project_root: PathBuf,
4579        callgraph_dir: PathBuf,
4580        work: CallgraphBackgroundWork,
4581    ) -> bool {
4582        let session_id = crate::log_ctx::current_session();
4583        let chunk_size = self.config().callgraph_chunk_size;
4584        let build_generation = self.configure_generation();
4585        let configured_keys = self.configured_callgraph_keys(&project_root);
4586        let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
4587
4588        let mut rx_guard = self.callgraph_store_rx.lock();
4589        if rx_guard.is_some() {
4590            return false;
4591        }
4592
4593        let limiter = self.cold_build_limiter();
4594        let request = crate::cold_build_limiter::ColdBuildAdmissionRequest::new(
4595            "callgraph-background",
4596            crate::cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
4597        );
4598        let Some(permit) =
4599            crate::cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
4600        else {
4601            crate::slog_info!(
4602                "callgraph store background work deferred by cold build limit ({})",
4603                limiter.limit()
4604            );
4605            return false;
4606        };
4607
4608        let force_token = match work {
4609            CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
4610            CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
4611        };
4612        let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
4613        self.note_callgraph_store_rx_generation(build_generation);
4614        self.next_callgraph_store_rx_epoch();
4615        *rx_guard = Some(rx);
4616        let persist_epoch = self.next_callgraph_persist_epoch();
4617        let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4618
4619        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
4620
4621        std::thread::spawn(move || {
4622            let _permit = permit;
4623            let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
4624            crate::log_ctx::with_session(session_id, || {
4625                wait_on_callgraph_build_start_gate(&project_root);
4626                if persist_epoch_flag.current() != persist_epoch {
4627                    crate::slog_info!(
4628                        "callgraph store background work skipped for superseded epoch {}",
4629                        persist_epoch
4630                    );
4631                    return;
4632                }
4633                let built = crate::callgraph_store::with_publish_epoch(
4634                    persist_epoch_flag.clone(),
4635                    persist_epoch,
4636                    || match work {
4637                        CallgraphBackgroundWork::LegacyMigration => {
4638                            CallGraphStore::migrate_legacy_with_lease(
4639                                callgraph_dir.clone(),
4640                                project_root.clone(),
4641                            )
4642                        }
4643                        CallgraphBackgroundWork::ForceRebuild(_) => {
4644                            let files = crate::callgraph::walk_project_files(&project_root)
4645                                .collect::<Vec<_>>();
4646                            CallGraphStore::force_cold_build_with_lease_chunked(
4647                                callgraph_dir.clone(),
4648                                project_root.clone(),
4649                                &files,
4650                                chunk_size,
4651                            )
4652                            .map(|(store, _)| Some(store))
4653                        }
4654                        CallgraphBackgroundWork::Ensure => {
4655                            let files = crate::callgraph::walk_project_files(&project_root)
4656                                .collect::<Vec<_>>();
4657                            CallGraphStore::ensure_built_with_lease_chunked(
4658                                callgraph_dir.clone(),
4659                                project_root.clone(),
4660                                &files,
4661                                chunk_size,
4662                            )
4663                            .map(|(store, _)| Some(store))
4664                        }
4665                    },
4666                );
4667                match built {
4668                    Ok(Some(store)) => {
4669                        if store.is_legacy_migration() {
4670                            match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
4671                                &callgraph_dir,
4672                                &configured_keys,
4673                            ) {
4674                                Ok(true)
4675                                    if summary_logged
4676                                        .compare_exchange(
4677                                            false,
4678                                            true,
4679                                            Ordering::SeqCst,
4680                                            Ordering::SeqCst,
4681                                        )
4682                                        .is_ok() =>
4683                                {
4684                                    crate::slog_info!(
4685                                        "all legacy callgraph partitions migrated for configured roots"
4686                                    );
4687                                }
4688                                Ok(_) => {}
4689                                Err(error) => crate::slog_warn!(
4690                                    "failed to inspect legacy callgraph migration completion: {}",
4691                                    error
4692                                ),
4693                            }
4694                        }
4695                        if persist_epoch_flag.is_current(persist_epoch) {
4696                            settlement.ready(store);
4697                        } else {
4698                            crate::slog_info!(
4699                                "callgraph store warm build result discarded for superseded publication epoch {}",
4700                                persist_epoch
4701                            );
4702                        }
4703                    }
4704                    Ok(None) => {}
4705                    Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
4706                        crate::slog_info!(
4707                            "callgraph store disk publication skipped for superseded epoch {}",
4708                            persist_epoch
4709                        );
4710                    }
4711                    Err(crate::callgraph_store::CallGraphStoreError::Suspended(suspension)) => {
4712                        crate::slog_warn!(
4713                            "callgraph store background work suspended: {}",
4714                            suspension.reason
4715                        );
4716                        settlement.suspended(suspension);
4717                    }
4718                    Err(crate::callgraph_store::CallGraphStoreError::Unavailable(reason))
4719                        if reason.ends_with("could not acquire writer capability") =>
4720                    {
4721                        crate::slog_warn!(
4722                            "callgraph store background work denied writer capability: {}",
4723                            reason
4724                        );
4725                        settlement.denied(reason);
4726                    }
4727                    Err(error) => {
4728                        crate::slog_warn!("callgraph store background work failed: {}", error);
4729                    }
4730                }
4731            });
4732        });
4733        true
4734    }
4735
4736    /// Access the callgraph-store background-build receiver (drained by the
4737    /// main loop once the cold build completes).
4738    pub fn callgraph_store_rx(
4739        &self,
4740    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
4741        &self.callgraph_store_rx
4742    }
4743
4744    /// Commit a dequeued result only while its lifecycle and receiver identity
4745    /// remain current. Lifecycle admission is intentionally acquired first,
4746    /// matching worker-start paths and preventing a lock-order cycle.
4747    #[doc(hidden)]
4748    pub fn with_current_callgraph_store_rx<R>(
4749        &self,
4750        generation: u64,
4751        epoch: u64,
4752        action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
4753    ) -> Option<R> {
4754        self.run_if_subc_bound_generation(generation, || {
4755            let mut receiver = self.callgraph_store_rx.lock();
4756            if receiver.is_none()
4757                || self.callgraph_store_rx_generation() != generation
4758                || self.callgraph_store_rx_epoch() != epoch
4759            {
4760                return None;
4761            }
4762            Some(action(&mut receiver))
4763        })
4764        .flatten()
4765    }
4766
4767    pub(crate) fn retire_callgraph_store_rx(&self) {
4768        let mut receiver = self.callgraph_store_rx.lock();
4769        *receiver = None;
4770        self.next_callgraph_store_rx_epoch();
4771    }
4772
4773    /// Rebind a live callgraph build receiver while its dedicated publication
4774    /// epoch remains valid for the same root and corpus inputs.
4775    pub(crate) fn adopt_callgraph_store_rx_generation(&self, generation: u64) -> bool {
4776        let receiver = self.callgraph_store_rx.lock();
4777        if receiver.is_none() {
4778            return false;
4779        }
4780        self.note_callgraph_store_rx_generation(generation);
4781        true
4782    }
4783
4784    pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
4785        self.callgraph_store_rx_generation
4786            .store(generation, Ordering::SeqCst);
4787    }
4788
4789    #[doc(hidden)]
4790    pub fn callgraph_store_rx_generation(&self) -> u64 {
4791        self.callgraph_store_rx_generation.load(Ordering::SeqCst)
4792    }
4793
4794    pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
4795        self.callgraph_store_rx_epoch
4796            .fetch_add(1, Ordering::SeqCst)
4797            .wrapping_add(1)
4798    }
4799
4800    #[doc(hidden)]
4801    pub fn callgraph_store_rx_epoch(&self) -> u64 {
4802        self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
4803    }
4804
4805    pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
4806        self.callgraph_persist_epoch.next()
4807    }
4808
4809    #[doc(hidden)]
4810    pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4811        self.callgraph_persist_epoch.clone()
4812    }
4813
4814    /// Record source-file paths that could not be applied to the writable store
4815    /// so the next ready-store replay can refresh them.
4816    pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
4817    where
4818        I: IntoIterator<Item = PathBuf>,
4819    {
4820        self.pending_callgraph_store_paths.lock().extend(paths);
4821    }
4822
4823    pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
4824    where
4825        I: IntoIterator<Item = PathBuf>,
4826    {
4827        let generation = self.configure_generation();
4828        self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
4829    }
4830
4831    pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
4832        &self,
4833        paths: I,
4834        generation: u64,
4835    ) -> bool
4836    where
4837        I: IntoIterator<Item = PathBuf>,
4838    {
4839        let paths = paths.into_iter().collect::<Vec<_>>();
4840        if paths.is_empty() {
4841            return true;
4842        }
4843        // A disabled or degraded root must not create a refresh worker merely
4844        // to discover later that it cannot write the callgraph store.
4845        if !self.config().callgraph_store || !self.heavy_root_work_allowed() {
4846            return true;
4847        }
4848        self.run_if_subc_bound_generation(generation, || {
4849            if !self.callgraph_writer() {
4850                self.add_pending_callgraph_store_paths(paths);
4851                return false;
4852            }
4853            let Some(project_root) = self.callgraph_project_root() else {
4854                self.add_pending_callgraph_store_paths(paths);
4855                return false;
4856            };
4857
4858            // The ticket fences the batch against lifecycle transitions and
4859            // cold-build publications: a superseded batch defers its paths to
4860            // the pending sink instead of committing into a store generation
4861            // that a newer configure no longer owns.
4862            let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
4863                self.subc_lifecycle_admission(),
4864                self.configure_generation_flag(),
4865                generation,
4866                self.callgraph_persist_epoch_flag(),
4867                self.callgraph_persist_epoch_flag().current(),
4868            );
4869            crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
4870                self.callgraph_store_dir(),
4871                project_root,
4872                paths,
4873                Arc::clone(&self.pending_callgraph_store_paths),
4874                crate::callgraph_store::CallgraphRefreshState::new(
4875                    Arc::clone(&self.callgraph_store),
4876                    Arc::clone(&self.heavy_root_work_allowed),
4877                ),
4878                ticket,
4879            )
4880        })
4881        .unwrap_or(false)
4882    }
4883
4884    /// Take and clear paths waiting for a ready writable store.
4885    ///
4886    /// Paths outside the current project root are dropped: the pending sink is
4887    /// shared with detached refresh batches, so a batch superseded by a root
4888    /// change can defer paths from the PREVIOUS root after configure cleared
4889    /// the sink. Replaying those would index foreign files into the new root's
4890    /// store (refresh accepts absolute out-of-root paths).
4891    pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
4892        let roots: Vec<PathBuf> = [
4893            self.canonical_cache_root_opt(),
4894            self.config().project_root.clone(),
4895        ]
4896        .into_iter()
4897        .flatten()
4898        .collect();
4899        std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
4900            .into_iter()
4901            .filter(|path| {
4902                let in_root = pending_path_in_roots(path, &roots);
4903                if !in_root {
4904                    crate::slog_debug!(
4905                        "dropping pending callgraph path outside current root: {}",
4906                        path.display()
4907                    );
4908                }
4909                in_root
4910            })
4911            .collect()
4912    }
4913
4914    /// Access the search index.
4915    pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
4916        &self.search_index
4917    }
4918
4919    /// Access the search-index build receiver.
4920    pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
4921        &self.search_index_rx
4922    }
4923
4924    pub(crate) fn install_search_index_rx(
4925        &self,
4926        receiver: crossbeam_channel::Receiver<SearchIndex>,
4927        generation: u64,
4928    ) -> u64 {
4929        let mut slot = self
4930            .search_index_rx
4931            .write()
4932            .unwrap_or_else(std::sync::PoisonError::into_inner);
4933        self.note_search_index_rx_generation(generation);
4934        let epoch = self.next_search_index_rx_epoch();
4935        *slot = Some(receiver);
4936        epoch
4937    }
4938
4939    pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4940        ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
4941    }
4942
4943    /// Keep generation/epoch validation and receiver mutation under the same
4944    /// lock used by receiver installation.
4945    pub(crate) fn with_current_search_index_rx<R>(
4946        &self,
4947        generation: u64,
4948        epoch: u64,
4949        action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
4950    ) -> Option<R> {
4951        self.run_if_subc_bound_generation(generation, || {
4952            let mut receiver = self
4953                .search_index_rx
4954                .write()
4955                .unwrap_or_else(std::sync::PoisonError::into_inner);
4956            if receiver.is_none()
4957                || self.search_index_rx_generation() != generation
4958                || self.search_index_rx_epoch() != epoch
4959            {
4960                return None;
4961            }
4962            Some(action(&mut receiver))
4963        })
4964        .flatten()
4965    }
4966
4967    pub(crate) fn retire_search_index_rx(&self) {
4968        let mut receiver = self
4969            .search_index_rx
4970            .write()
4971            .unwrap_or_else(std::sync::PoisonError::into_inner);
4972        *receiver = None;
4973        self.next_search_index_rx_epoch();
4974    }
4975
4976    pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4977        self.search_index_rx_generation
4978            .store(generation, Ordering::SeqCst);
4979    }
4980
4981    pub(crate) fn search_index_rx_generation(&self) -> u64 {
4982        self.search_index_rx_generation.load(Ordering::SeqCst)
4983    }
4984
4985    pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
4986        self.search_index_rx_epoch
4987            .fetch_add(1, Ordering::SeqCst)
4988            .wrapping_add(1)
4989    }
4990
4991    pub(crate) fn search_index_rx_epoch(&self) -> u64 {
4992        self.search_index_rx_epoch.load(Ordering::SeqCst)
4993    }
4994
4995    /// Allow one automatic search-index replacement load per configure
4996    /// generation. The drain disconnect path calls this before rescheduling a
4997    /// load whose worker exited without delivering an index; capping it at one
4998    /// prevents a persistently failing worker from being relaunched in a loop on
4999    /// the drain thread. After the cap is hit, the query-triggered reload
5000    /// (`trigger_search_index_reload_if_evicted`) remains the recovery path.
5001    pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
5002        const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
5003        let generation = self.configure_generation();
5004        let mut state = self.search_index_disconnect_reschedule.lock();
5005        if state.0 != generation {
5006            *state = (generation, 0);
5007        }
5008        if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
5009            return false;
5010        }
5011        state.1 += 1;
5012        true
5013    }
5014
5015    pub(crate) fn next_search_persist_epoch(&self) -> u64 {
5016        self.search_persist_epoch.next()
5017    }
5018
5019    pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5020        self.search_persist_epoch.clone()
5021    }
5022
5023    pub fn add_pending_search_index_paths<I>(&self, paths: I)
5024    where
5025        I: IntoIterator<Item = PathBuf>,
5026    {
5027        let paths = paths.into_iter().collect::<Vec<_>>();
5028        if !paths.is_empty() {
5029            self.invalidate_warm_verify_memo();
5030            self.pending_search_index_paths.lock().extend(paths);
5031        }
5032    }
5033
5034    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
5035        std::mem::take(&mut *self.pending_search_index_paths.lock())
5036            .into_iter()
5037            .collect()
5038    }
5039
5040    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
5041    where
5042        I: IntoIterator<Item = PathBuf>,
5043    {
5044        let paths = paths.into_iter().collect::<Vec<_>>();
5045        if !paths.is_empty() {
5046            self.invalidate_warm_verify_memo();
5047            self.pending_semantic_index_paths.lock().extend(paths);
5048        }
5049    }
5050
5051    pub(crate) fn invalidate_warm_verify_memo(&self) {
5052        if let Some(root) = self.canonical_cache_root_opt() {
5053            crate::cache_freshness::invalidate_verify_memo(&root);
5054        }
5055    }
5056
5057    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
5058        std::mem::take(&mut *self.pending_semantic_index_paths.lock())
5059            .into_iter()
5060            .collect()
5061    }
5062
5063    pub fn mark_pending_semantic_corpus_refresh(&self) {
5064        *self.pending_semantic_corpus_refresh.lock() = true;
5065    }
5066
5067    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
5068        std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
5069    }
5070
5071    pub fn clear_pending_index_updates(&self) {
5072        self.clear_pending_index_updates_with_callgraph(true);
5073    }
5074
5075    pub(crate) fn clear_pending_index_updates_preserving_callgraph(&self) {
5076        self.clear_pending_index_updates_with_callgraph(false);
5077    }
5078
5079    fn clear_pending_index_updates_with_callgraph(&self, clear_callgraph: bool) {
5080        self.pending_search_index_paths.lock().clear();
5081        if clear_callgraph {
5082            self.pending_callgraph_store_paths.lock().clear();
5083        }
5084        self.pending_tier2_paths.lock().clear();
5085        self.pending_semantic_index_paths.lock().clear();
5086        *self.pending_semantic_corpus_refresh.lock() = false;
5087    }
5088
5089    /// Take the retained pending reconciliation state for a transactional
5090    /// teardown. The caller commits the disposal by dropping the returned
5091    /// state after eviction succeeds, or restores it with
5092    /// [`Self::restore_pending_reconciliation_state`] when eviction is blocked
5093    /// by a secondary blocker (running bash, in-flight builds): the paths are
5094    /// the only repair record for consumed watcher events, and the root may
5095    /// rebind before the next reap attempt.
5096    pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
5097        PendingReconciliationState {
5098            search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
5099            callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
5100            tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
5101            semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
5102            corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
5103        }
5104    }
5105
5106    pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
5107        self.pending_search_index_paths.lock().extend(state.search);
5108        self.pending_callgraph_store_paths
5109            .lock()
5110            .extend(state.callgraph);
5111        self.pending_tier2_paths.lock().extend(state.tier2);
5112        self.pending_semantic_index_paths
5113            .lock()
5114            .extend(state.semantic);
5115        if state.corpus_refresh {
5116            *self.pending_semantic_corpus_refresh.lock() = true;
5117        }
5118    }
5119
5120    /// Cancel artifact work that no longer has a bound daemon route to consume it.
5121    /// `mark_subc_unbound` advances the generation under the lifecycle admission
5122    /// gate before this cleanup runs. Clearing receivers lets a later rebind
5123    /// schedule fresh work instead of adopting a disconnected worker forever.
5124    ///
5125    /// Pending watcher-derived path sets are RETAINED: a pre-unbind artifact
5126    /// worker may legitimately finish generation-safe disk persistence during
5127    /// the unbound window (content generation and persist epochs deliberately
5128    /// do not advance on route teardown), and those paths are the only record
5129    /// that its artifact is content-stale. Rebind replays them. Disposal of
5130    /// pending state belongs to non-equivalent configure and TTL eviction
5131    /// (transactional take in the TTL reaper), whose strict invalidation
5132    /// subsumes their purpose.
5133    pub(crate) fn cancel_unbound_artifact_work(&self) {
5134        // A cancelled non-ready search corpus refresh left the resident index
5135        // marked not-ready; retiring its receiver alone would strand it
5136        // (equivalent rebind only reloads a MISSING index). Drop the resident
5137        // index too so the rebind's artifact setup reloads from disk and the
5138        // retained pending paths repair it on install.
5139        let search_refresh_cancelled = self
5140            .search_index_rx
5141            .read()
5142            .unwrap_or_else(std::sync::PoisonError::into_inner)
5143            .is_some();
5144        self.retire_search_index_rx();
5145        if search_refresh_cancelled {
5146            let mut resident = self
5147                .search_index
5148                .write()
5149                .unwrap_or_else(std::sync::PoisonError::into_inner);
5150            if resident.as_ref().is_some_and(|index| !index.ready) {
5151                *resident = None;
5152            }
5153        }
5154        self.retire_callgraph_store_rx();
5155        let semantic_cancelled = self.semantic_index_rx.lock().is_some();
5156        self.retire_semantic_index_rx();
5157        let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
5158        self.clear_semantic_refresh_worker();
5159        self.reset_semantic_cold_seed_gate_for_configure();
5160        let _ = self.inspect_manager.discard_completions();
5161        let _ = self.take_new_reuse_completions();
5162        if semantic_cancelled || semantic_refresh_cancelled {
5163            let has_index = self
5164                .semantic_index
5165                .read()
5166                .unwrap_or_else(std::sync::PoisonError::into_inner)
5167                .is_some();
5168            // In-flight refreshing files were consumed from the watcher; the
5169            // cancelled worker will never re-embed them. Transfer them to the
5170            // retained pending set so the rebind's replacement worker does.
5171            {
5172                let mut status = self
5173                    .semantic_index_status
5174                    .write()
5175                    .unwrap_or_else(std::sync::PoisonError::into_inner);
5176                let refreshing = status.take_refreshing_files();
5177                if !refreshing.is_empty() {
5178                    self.pending_semantic_index_paths.lock().extend(refreshing);
5179                }
5180                if status.corpus_refresh_in_flight() {
5181                    *self.pending_semantic_corpus_refresh.lock() = true;
5182                }
5183                *status = if has_index {
5184                    SemanticIndexStatus::ready()
5185                } else {
5186                    SemanticIndexStatus::Disabled
5187                };
5188            }
5189            self.set_semantic_build_progress(None);
5190        }
5191    }
5192
5193    /// Gate every watcher-maintained artifact after the last route detaches. Files
5194    /// may change before the watcher is restored, so a later bind must reconcile
5195    /// from disk instead of serving retained snapshots that missed those edits.
5196    pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
5197        self.next_search_persist_epoch();
5198        self.next_semantic_persist_epoch();
5199        self.next_callgraph_persist_epoch();
5200
5201        self.search_index
5202            .write()
5203            .unwrap_or_else(std::sync::PoisonError::into_inner)
5204            .take();
5205        self.semantic_index
5206            .write()
5207            .unwrap_or_else(std::sync::PoisonError::into_inner)
5208            .take();
5209        self.callgraph_store
5210            .write()
5211            .unwrap_or_else(std::sync::PoisonError::into_inner)
5212            .take();
5213        // Keep semantic status reloadable when the feature is enabled: the
5214        // query path's self-healing reload only fires from Ready (or Failed on
5215        // read-only roots), so Disabled would strand an already-bound root
5216        // with no way back short of a reconfigure. The advanced persist epoch
5217        // and strict verify memo force the reload to re-verify from disk.
5218        *self
5219            .semantic_index_status
5220            .write()
5221            .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
5222            SemanticIndexStatus::ready()
5223        } else {
5224            SemanticIndexStatus::Disabled
5225        };
5226        // A force token is only fulfillable by a local writer build; read-only
5227        // roots follow the owner's published pointer and would be stuck
5228        // permanently unavailable behind an unfulfillable token.
5229        if self.callgraph_writer() {
5230            self.mark_callgraph_store_force_rebuild();
5231        }
5232
5233        if let Some(root) = self
5234            .canonical_cache_root_opt()
5235            .or_else(|| self.config().project_root.clone())
5236        {
5237            crate::cache_freshness::invalidate_verify_memo_strict(&root);
5238        }
5239        self.borrowed_index_cache.lock().clear();
5240        self.inspect_manager.evict_idle_caches();
5241        self.reset_symbol_cache();
5242        self.clear_tsconfig_membership_cache();
5243    }
5244
5245    fn drain_search_index_events_for_graceful_shutdown(&self) {
5246        crate::runtime_drain::drain_watcher_events(self);
5247        crate::runtime_drain::drain_search_index_events(self);
5248    }
5249
5250    fn search_index_build_in_progress(&self) -> bool {
5251        self.search_index_rx()
5252            .read()
5253            .unwrap_or_else(std::sync::PoisonError::into_inner)
5254            .is_some()
5255    }
5256
5257    /// Graceful EOF teardown can afford a bounded wait for an already running
5258    /// search rebuild to publish. Poll the observable receiver state
5259    /// directly instead of relying on fixed sleeps in callers or tests.
5260    fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
5261        crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
5262        let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
5263        while self.search_index_build_in_progress() && Instant::now() < deadline {
5264            let remaining = deadline.saturating_duration_since(Instant::now());
5265            std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
5266            self.drain_search_index_events_for_graceful_shutdown();
5267        }
5268    }
5269
5270    /// Flush the owner-side trigram delta during an orderly transport shutdown.
5271    /// EOF/Goodbye teardown uses this best-effort path; signal and panic exits
5272    /// intentionally skip it so abrupt shutdown never waits on slow recovery work.
5273    ///
5274    /// Borrow-only roots (including ram-overlay worktrees) return immediately
5275    /// and never write the shared artifact.
5276    #[doc(hidden)]
5277    pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
5278        if self.shared_artifacts_read_only() {
5279            return false;
5280        }
5281
5282        self.drain_search_index_events_for_graceful_shutdown();
5283        if self.search_index_build_in_progress() {
5284            self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
5285            self.drain_search_index_events_for_graceful_shutdown();
5286        }
5287
5288        if self.search_index_build_in_progress() {
5289            return false;
5290        }
5291
5292        let Some(canonical_root) = self.canonical_cache_root_opt() else {
5293            return false;
5294        };
5295        let config = self.config();
5296        let project_key = self.memoized_artifact_cache_key(&canonical_root);
5297        let cache_dir = crate::search_index::resolve_cache_dir_with_key(
5298            &project_key,
5299            config.storage_dir.as_deref(),
5300        );
5301
5302        {
5303            let search_index = self
5304                .search_index()
5305                .read()
5306                .unwrap_or_else(std::sync::PoisonError::into_inner);
5307            let Some(index) = search_index.as_ref() else {
5308                return false;
5309            };
5310            if !index.ready || !index.has_pending_disk_changes() {
5311                return false;
5312            }
5313        }
5314
5315        let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
5316            &cache_dir,
5317            &canonical_root,
5318        ) {
5319            Ok(lock) => lock,
5320            Err(error) => {
5321                crate::slog_warn!(
5322                    "search index: skipped shutdown flush because cache lock was unavailable: {}",
5323                    error
5324                );
5325                return false;
5326            }
5327        };
5328
5329        let mut search_index = self
5330            .search_index()
5331            .write()
5332            .unwrap_or_else(std::sync::PoisonError::into_inner);
5333        let Some(index) = search_index.as_mut() else {
5334            return false;
5335        };
5336        if !index.ready || !index.has_pending_disk_changes() {
5337            return false;
5338        }
5339
5340        let git_head = index.stored_git_head().map(str::to_owned);
5341        index.write_to_disk(&cache_dir, git_head.as_deref())
5342    }
5343
5344    pub fn inspect_manager(&self) -> Arc<InspectManager> {
5345        Arc::clone(&self.inspect_manager)
5346    }
5347
5348    /// Standing ownership exempts a root from idle artifact eviction only. It
5349    /// does not bypass strict verification, budgets, breaker checks, or leases.
5350    pub(crate) fn set_standing_artifact_exempt(&self, exempt: bool) {
5351        self.standing_artifact_exempt
5352            .store(exempt, Ordering::Release);
5353    }
5354
5355    pub(crate) fn cold_build_limiter(&self) -> Arc<crate::cold_build_limiter::ColdBuildLimiter> {
5356        Arc::clone(
5357            &self
5358                .cold_build_limiter
5359                .read()
5360                .unwrap_or_else(std::sync::PoisonError::into_inner),
5361        )
5362    }
5363
5364    /// Give one integration-test context its own maintenance-build capacity.
5365    /// Production contexts continue to share the process-wide limiter.
5366    #[doc(hidden)]
5367    pub fn isolate_cold_build_limiter_for_test(&self, limit: usize) {
5368        let limiter = crate::cold_build_limiter::isolated_limiter(limit);
5369        self.inspect_manager
5370            .set_cold_build_limiter(Arc::clone(&limiter));
5371        *self
5372            .cold_build_limiter
5373            .write()
5374            .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
5375    }
5376
5377    pub fn add_pending_tier2_paths<I>(&self, paths: I)
5378    where
5379        I: IntoIterator<Item = PathBuf>,
5380    {
5381        self.pending_tier2_paths.lock().extend(paths);
5382    }
5383
5384    pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
5385        self.pending_tier2_paths.lock().iter().cloned().collect()
5386    }
5387
5388    pub fn remove_pending_tier2_paths<I>(&self, paths: I)
5389    where
5390        I: IntoIterator<Item = PathBuf>,
5391    {
5392        let mut pending = self.pending_tier2_paths.lock();
5393        for path in paths {
5394            pending.remove(&path);
5395        }
5396    }
5397
5398    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
5399    /// have completed since the last call, advancing the last-seen marker. The
5400    /// per-request inspect drain uses this to refresh the status bar after a
5401    /// background scan — those completions bypass `drain_completions`.
5402    /// Peek variant of `take_new_reuse_completions`: reports whether new reuse
5403    /// completions exist WITHOUT consuming the observation, so the maintenance
5404    /// scheduler's skip probe cannot swallow a status-bar refresh.
5405    pub fn has_new_reuse_completions(&self) -> bool {
5406        self.inspect_manager.reuse_completion_count()
5407            != self.last_seen_reuse_completions.load(Ordering::SeqCst)
5408    }
5409
5410    pub fn take_new_reuse_completions(&self) -> bool {
5411        let current = self.inspect_manager.reuse_completion_count();
5412        let previous = self
5413            .last_seen_reuse_completions
5414            .swap(current, Ordering::SeqCst);
5415        current != previous
5416    }
5417
5418    pub fn reset_tier2_refresh_scheduler(&self) {
5419        self.reset_tier2_refresh_scheduler_at(Instant::now());
5420    }
5421
5422    #[doc(hidden)]
5423    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
5424        self.tier2_refresh_scheduler
5425            .lock()
5426            .reset_after_configure(now);
5427    }
5428
5429    pub fn request_tier2_refresh_pull(&self) -> bool {
5430        let can_schedule = self.inspect_writer()
5431            && self.heavy_root_work_allowed()
5432            && self.inspect_manager.automatic_tier2_refresh_allowed();
5433        self.tier2_refresh_scheduler
5434            .lock()
5435            .request_pull(can_schedule)
5436    }
5437
5438    pub fn tick_tier2_refresh_scheduler(
5439        &self,
5440        changed_path_count: usize,
5441    ) -> Option<Tier2TriggerReason> {
5442        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
5443    }
5444
5445    #[doc(hidden)]
5446    pub fn tick_tier2_refresh_scheduler_at(
5447        &self,
5448        now: Instant,
5449        changed_path_count: usize,
5450    ) -> Option<Tier2TriggerReason> {
5451        let manager = self.inspect_manager();
5452        let can_write = self.inspect_writer()
5453            && self.heavy_root_work_allowed()
5454            && manager.automatic_tier2_refresh_allowed();
5455        let in_flight = manager.tier2_any_in_flight();
5456        let semantic_cold_seed_active = self.semantic_cold_seed_active();
5457        let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
5458            now,
5459            changed_path_count,
5460            can_write,
5461            in_flight,
5462            semantic_cold_seed_active,
5463        );
5464
5465        if let Some(reason) = decision {
5466            self.start_tier2_refresh(reason, manager);
5467        }
5468
5469        decision
5470    }
5471
5472    pub fn note_tier2_refresh_started(&self) {
5473        self.note_tier2_refresh_started_at(Instant::now());
5474    }
5475
5476    #[doc(hidden)]
5477    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
5478        self.tier2_refresh_scheduler
5479            .lock()
5480            .note_external_scan_started(now);
5481    }
5482
5483    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
5484        self.tier2_refresh_scheduler
5485            .lock()
5486            .last_trigger_reason()
5487            .map(Tier2TriggerReason::as_str)
5488    }
5489
5490    #[doc(hidden)]
5491    pub fn tier2_pull_demand_pending(&self) -> bool {
5492        self.tier2_refresh_scheduler.lock().pull_demand_pending()
5493    }
5494
5495    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
5496        let generation = self.configure_generation();
5497        if !self.inspect_writer()
5498            || !self.heavy_root_work_allowed()
5499            || !manager.automatic_tier2_refresh_allowed()
5500            || !self.config().inspect.enabled
5501        {
5502            return;
5503        }
5504        let _ = self.run_if_subc_bound_generation(generation, || {
5505            self.start_tier2_refresh_admitted(reason, manager);
5506        });
5507    }
5508
5509    fn start_tier2_refresh_admitted(
5510        &self,
5511        reason: Tier2TriggerReason,
5512        manager: Arc<InspectManager>,
5513    ) {
5514        let Some(snapshot) = self.tier2_refresh_snapshot() else {
5515            return;
5516        };
5517        let categories = Self::automatic_tier2_refresh_categories(&snapshot);
5518        let submission =
5519            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
5520        if !submission.deferred_categories.is_empty() {
5521            self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
5522            crate::slog_info!(
5523                "tier2 refresh deferred by cold build limit: categories={:?}",
5524                submission
5525                    .deferred_categories
5526                    .iter()
5527                    .map(|category| category.as_str())
5528                    .collect::<Vec<_>>()
5529            );
5530        }
5531        if submission.has_new_work() {
5532            crate::slog_info!(
5533                "tier2 refresh scheduled: reason={}, categories={:?}",
5534                reason.as_str(),
5535                submission
5536                    .newly_queued_categories
5537                    .iter()
5538                    .map(|category| category.as_str())
5539                    .collect::<Vec<_>>()
5540            );
5541        }
5542        for error in submission.errors {
5543            crate::slog_warn!(
5544                "tier2 refresh schedule failed for {}: {}",
5545                error.category,
5546                error.message
5547            );
5548        }
5549    }
5550
5551    fn automatic_tier2_refresh_categories(snapshot: &InspectSnapshot) -> Vec<InspectCategory> {
5552        let callgraph_store_enabled = snapshot.config.callgraph_store;
5553        InspectCategory::active()
5554            .iter()
5555            .copied()
5556            .filter(|category| category.is_tier2())
5557            .filter(|category| {
5558                if *category == InspectCategory::DeadCode && !callgraph_store_enabled {
5559                    // With callgraph_store=false, the scan produces zero reusable
5560                    // contributions: zero contributions → reuse rejection → full rescan →
5561                    // discard, so automatic dead_code work is pure waste.
5562                    return false;
5563                }
5564                true
5565            })
5566            .collect()
5567    }
5568
5569    #[doc(hidden)]
5570    pub fn automatic_tier2_refresh_categories_for_test(&self) -> Vec<InspectCategory> {
5571        self.tier2_refresh_snapshot()
5572            .map(|snapshot| Self::automatic_tier2_refresh_categories(&snapshot))
5573            .unwrap_or_default()
5574    }
5575
5576    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
5577        self.harness_opt()?;
5578        let config = self.config();
5579        let project_root = config
5580            .project_root
5581            .clone()
5582            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
5583        // Normalized, not bare-canonical: scoped diagnostics compare
5584        // LSP-reported paths (normalized form) against this root with
5585        // starts_with, and a verbatim root on Windows matches nothing.
5586        let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
5587        Some(InspectSnapshot::new_with_capabilities(
5588            project_root,
5589            self.inspect_dir(),
5590            config,
5591            self.symbol_cache(),
5592            self.inspect_writer(),
5593            self.callgraph_writer(),
5594        ))
5595    }
5596
5597    /// Access the shared symbol cache.
5598    pub fn symbol_cache(&self) -> SharedSymbolCache {
5599        Arc::clone(&self.symbol_cache)
5600    }
5601
5602    /// Clear the shared symbol cache and return the new active generation.
5603    pub fn reset_symbol_cache(&self) -> u64 {
5604        self.symbol_cache
5605            .write()
5606            .map(|mut cache| cache.reset())
5607            .unwrap_or(0)
5608    }
5609
5610    /// Access the semantic search index.
5611    pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
5612        &self.semantic_index
5613    }
5614
5615    /// Access the semantic-index build receiver.
5616    pub fn semantic_index_rx(
5617        &self,
5618    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
5619        &self.semantic_index_rx
5620    }
5621
5622    pub(crate) fn install_semantic_index_rx(
5623        &self,
5624        receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
5625        generation: u64,
5626    ) -> u64 {
5627        let mut slot = self.semantic_index_rx.lock();
5628        self.note_semantic_index_rx_generation(generation);
5629        let epoch = self.next_semantic_index_rx_epoch();
5630        *slot = Some(receiver);
5631        epoch
5632    }
5633
5634    pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
5635        ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
5636    }
5637
5638    /// Keep generation/epoch validation and receiver mutation under the same
5639    /// lock used by receiver installation.
5640    pub(crate) fn with_current_semantic_index_rx<R>(
5641        &self,
5642        generation: u64,
5643        epoch: u64,
5644        action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
5645    ) -> Option<R> {
5646        self.run_if_subc_bound_generation(generation, || {
5647            let mut receiver = self.semantic_index_rx.lock();
5648            if receiver.is_none()
5649                || self.semantic_index_rx_generation() != generation
5650                || self.semantic_index_rx_epoch() != epoch
5651            {
5652                return None;
5653            }
5654            Some(action(&mut receiver))
5655        })
5656        .flatten()
5657    }
5658
5659    pub(crate) fn retire_semantic_index_rx(&self) {
5660        let mut receiver = self.semantic_index_rx.lock();
5661        *receiver = None;
5662        self.next_semantic_index_rx_epoch();
5663    }
5664
5665    /// Rebind a live semantic build to a newer configure generation when the
5666    /// semantic corpus inputs are unchanged. Its receiver keeps the completed
5667    /// result while the worker's dedicated build epoch remains valid.
5668    pub(crate) fn adopt_semantic_index_rx_generation(&self, generation: u64) -> bool {
5669        let receiver = self.semantic_index_rx.lock();
5670        if receiver.is_none() {
5671            return false;
5672        }
5673        self.note_semantic_index_rx_generation(generation);
5674        true
5675    }
5676
5677    /// Retire a build receiver only if no replacement changed its epoch after
5678    /// the caller inspected it. `None` means a newer receiver won the race;
5679    /// `Some(false)` means the inspected epoch is still current but empty.
5680    pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
5681        let mut receiver = self.semantic_index_rx.lock();
5682        if self.semantic_index_rx_epoch() != expected_epoch {
5683            return None;
5684        }
5685        let retired = receiver.take().is_some();
5686        if retired {
5687            self.next_semantic_index_rx_epoch();
5688        }
5689        Some(retired)
5690    }
5691
5692    pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
5693        self.semantic_index_rx_generation
5694            .store(generation, Ordering::SeqCst);
5695    }
5696
5697    pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
5698        self.semantic_index_rx_generation.load(Ordering::SeqCst)
5699    }
5700
5701    pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
5702        self.semantic_index_rx_epoch
5703            .fetch_add(1, Ordering::SeqCst)
5704            .wrapping_add(1)
5705    }
5706
5707    pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
5708        self.semantic_index_rx_epoch.load(Ordering::SeqCst)
5709    }
5710
5711    pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
5712        self.semantic_persist_epoch.next()
5713    }
5714
5715    pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5716        self.semantic_persist_epoch.clone()
5717    }
5718
5719    pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
5720        Arc::clone(&self.semantic_persist_lock)
5721    }
5722
5723    pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
5724        &self.semantic_index_status
5725    }
5726
5727    pub(crate) fn set_semantic_build_progress(&self, progress: Option<SemanticBuildProgress>) {
5728        *self
5729            .semantic_build_progress
5730            .write()
5731            .unwrap_or_else(std::sync::PoisonError::into_inner) = progress;
5732    }
5733
5734    pub(crate) fn semantic_build_progress(&self) -> Option<SemanticBuildProgress> {
5735        self.semantic_build_progress
5736            .read()
5737            .unwrap_or_else(std::sync::PoisonError::into_inner)
5738            .clone()
5739    }
5740
5741    pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
5742        self.artifact_reload_lock.lock()
5743    }
5744
5745    /// Reset this context's cold semantic seed gate for a newly accepted
5746    /// configure and return the generation token for the worker being spawned.
5747    pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
5748        self.semantic_cold_seed_active
5749            .store(false, Ordering::SeqCst);
5750        self.semantic_callgraph_warm_deferred
5751            .store(false, Ordering::SeqCst);
5752        self.semantic_cold_seed_generation
5753            .fetch_add(1, Ordering::SeqCst)
5754            .wrapping_add(1)
5755    }
5756
5757    pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
5758        Arc::clone(&self.semantic_cold_seed_active)
5759    }
5760
5761    pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
5762        Arc::clone(&self.semantic_cold_seed_generation)
5763    }
5764
5765    pub fn semantic_cold_seed_generation(&self) -> u64 {
5766        self.semantic_cold_seed_generation.load(Ordering::SeqCst)
5767    }
5768
5769    pub fn semantic_cold_seed_active(&self) -> bool {
5770        self.semantic_cold_seed_active.load(Ordering::SeqCst)
5771    }
5772
5773    pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
5774        self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
5775    }
5776
5777    pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
5778        self.semantic_callgraph_warm_deferred
5779            .store(true, Ordering::SeqCst);
5780    }
5781
5782    fn semantic_callgraph_warm_deferred(&self) -> bool {
5783        self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
5784    }
5785
5786    /// Clear the cold-seed gate and resume work that was intentionally held back
5787    /// while the full semantic corpus was accumulating. This entry point is used
5788    /// by the code that drains events from the semantic worker.
5789    pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
5790        self.resume_semantic_cold_seed_deferred_work(false);
5791    }
5792
5793    /// Resume work after the semantic worker has already cleared the atomic gate
5794    /// itself, such as on cached-index load or before a retry backoff sleep.
5795    pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
5796        self.resume_semantic_cold_seed_deferred_work(true);
5797    }
5798
5799    pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
5800        let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
5801        let warm_callgraph = self
5802            .semantic_callgraph_warm_deferred
5803            .swap(false, Ordering::SeqCst);
5804        SemanticColdSeedResume {
5805            request_tier2: force || was_active || warm_callgraph,
5806            warm_callgraph,
5807        }
5808    }
5809
5810    pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
5811        if resume.request_tier2 {
5812            let _ = self.request_tier2_refresh_pull();
5813        }
5814
5815        if !resume.warm_callgraph
5816            || !self.config().callgraph_store
5817            || !self.heavy_root_work_allowed()
5818        {
5819            return;
5820        }
5821
5822        match self.schedule_callgraph_store_warm() {
5823            CallgraphStoreAccess::Ready(_) => {
5824                crate::slog_debug!(
5825                    "deferred callgraph store warm completed after semantic cold seed gate cleared"
5826                );
5827            }
5828            CallgraphStoreAccess::Building => {
5829                crate::slog_info!(
5830                    "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
5831                );
5832            }
5833            CallgraphStoreAccess::Suspended(suspension) => {
5834                crate::slog_warn!(
5835                    "deferred callgraph store warm suspended for {} after {} deaths",
5836                    suspension.domain.as_str(),
5837                    suspension.death_count
5838                );
5839            }
5840            CallgraphStoreAccess::Unavailable => {
5841                crate::slog_info!(
5842                    "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
5843                );
5844            }
5845            CallgraphStoreAccess::Error(error) => {
5846                crate::slog_warn!(
5847                    "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
5848                    error
5849                );
5850            }
5851        }
5852    }
5853
5854    fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
5855        let resume = self.take_semantic_cold_seed_resume(force);
5856        self.apply_semantic_cold_seed_resume(resume);
5857    }
5858
5859    #[doc(hidden)]
5860    pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
5861        self.semantic_cold_seed_active
5862            .store(active, Ordering::SeqCst);
5863    }
5864
5865    #[doc(hidden)]
5866    pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
5867        self.semantic_callgraph_warm_deferred()
5868    }
5869
5870    pub fn install_semantic_refresh_worker(
5871        &self,
5872        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5873        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5874        worker_slot: SemanticRefreshWorkerSlot,
5875    ) {
5876        self.install_semantic_refresh_worker_for_build_epoch(
5877            sender,
5878            event_rx,
5879            worker_slot,
5880            self.semantic_index_rx_epoch(),
5881        );
5882    }
5883
5884    pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
5885        &self,
5886        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5887        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5888        worker_slot: SemanticRefreshWorkerSlot,
5889        build_epoch: u64,
5890    ) {
5891        self.clear_semantic_refresh_worker();
5892        {
5893            let mut receiver = self.semantic_refresh_event_rx.lock();
5894            let mut request = self.semantic_refresh_tx.lock();
5895            let mut worker = self.semantic_refresh_worker.lock();
5896            self.semantic_refresh_generation
5897                .store(self.configure_generation(), Ordering::SeqCst);
5898            self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5899            self.semantic_refresh_build_epoch
5900                .store(build_epoch, Ordering::SeqCst);
5901            *receiver = Some(event_rx);
5902            *request = Some(sender);
5903            *worker = Some(worker_slot);
5904        }
5905    }
5906
5907    pub(crate) fn semantic_refresh_generation(&self) -> u64 {
5908        self.semantic_refresh_generation.load(Ordering::SeqCst)
5909    }
5910
5911    pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
5912        self.semantic_refresh_epoch.load(Ordering::SeqCst)
5913    }
5914
5915    /// Serialize refresh event commit with worker replacement. The receiver
5916    /// lock also couples the generation and epoch to the dequeued channel.
5917    pub(crate) fn with_current_semantic_refresh_rx<R>(
5918        &self,
5919        generation: u64,
5920        epoch: u64,
5921        action: impl FnOnce() -> R,
5922    ) -> Option<R> {
5923        self.run_if_subc_bound_generation(generation, || {
5924            let receiver = self.semantic_refresh_event_rx.lock();
5925            if receiver.is_none()
5926                || self.semantic_refresh_generation() != generation
5927                || self.semantic_refresh_epoch() != epoch
5928            {
5929                return None;
5930            }
5931            Some(action())
5932        })
5933        .flatten()
5934    }
5935
5936    pub(crate) fn clear_semantic_refresh_worker_if_current(
5937        &self,
5938        generation: u64,
5939        epoch: u64,
5940    ) -> Option<u64> {
5941        let worker_slot = {
5942            let mut receiver = self.semantic_refresh_event_rx.lock();
5943            if receiver.is_none()
5944                || self.semantic_refresh_generation() != generation
5945                || self.semantic_refresh_epoch() != epoch
5946            {
5947                return None;
5948            }
5949            let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
5950            self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5951            let mut request = self.semantic_refresh_tx.lock();
5952            let mut worker = self.semantic_refresh_worker.lock();
5953            *receiver = None;
5954            *request = None;
5955            self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5956            self.invalidate_semantic_refresh_probe();
5957            (worker.take(), disconnected_build_epoch)
5958        };
5959        if let Some(worker_slot) = worker_slot.0 {
5960            if let Ok(mut handle) = worker_slot.lock() {
5961                drop(handle.take());
5962            }
5963        }
5964        Some(worker_slot.1)
5965    }
5966
5967    pub fn clear_semantic_refresh_worker(&self) {
5968        let worker_slot = {
5969            let mut receiver = self.semantic_refresh_event_rx.lock();
5970            let mut request = self.semantic_refresh_tx.lock();
5971            let mut worker = self.semantic_refresh_worker.lock();
5972            *receiver = None;
5973            *request = None;
5974            self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5975            self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5976            self.invalidate_semantic_refresh_probe();
5977            worker.take()
5978        };
5979        if let Some(worker_slot) = worker_slot {
5980            if let Ok(mut handle) = worker_slot.lock() {
5981                drop(handle.take());
5982            }
5983        }
5984    }
5985
5986    pub fn semantic_refresh_sender(
5987        &self,
5988    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
5989        self.semantic_refresh_tx.lock().clone()
5990    }
5991
5992    pub(crate) fn semantic_refresh_retry_slots(
5993        &self,
5994    ) -> (
5995        Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
5996        Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
5997    ) {
5998        (
5999            Arc::clone(&self.semantic_refresh_tx),
6000            Arc::clone(&self.pending_semantic_index_paths),
6001        )
6002    }
6003
6004    pub fn semantic_refresh_event_rx(
6005        &self,
6006    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
6007        &self.semantic_refresh_event_rx
6008    }
6009
6010    pub fn with_semantic_refresh_retry_attempts_mut<R>(
6011        &self,
6012        f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
6013    ) -> R {
6014        let mut attempts = self.semantic_refresh_retry_attempts.lock();
6015        f(&mut attempts)
6016    }
6017
6018    pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
6019        let mut attempts = self.semantic_refresh_retry_attempts.lock();
6020        for path in paths {
6021            attempts.remove(path);
6022        }
6023    }
6024
6025    pub fn clear_all_semantic_refresh_retry_attempts(&self) {
6026        self.semantic_refresh_retry_attempts.lock().clear();
6027    }
6028
6029    pub fn semantic_refresh_circuit_is_open(&self) -> bool {
6030        self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
6031    }
6032
6033    pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
6034        let failures = self
6035            .semantic_refresh_circuit
6036            .consecutive_transient_failures
6037            .fetch_add(1, Ordering::SeqCst)
6038            .saturating_add(1);
6039        if failures >= trip_threshold
6040            && !self
6041                .semantic_refresh_circuit
6042                .open
6043                .swap(true, Ordering::SeqCst)
6044        {
6045            crate::slog_warn!(
6046                "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
6047            );
6048        }
6049        self.semantic_refresh_circuit_is_open()
6050    }
6051
6052    pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
6053        self.semantic_refresh_circuit
6054            .consecutive_transient_failures
6055            .store(trip_threshold, Ordering::SeqCst);
6056        if !self
6057            .semantic_refresh_circuit
6058            .open
6059            .swap(true, Ordering::SeqCst)
6060        {
6061            crate::slog_warn!(
6062                "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
6063            );
6064        }
6065    }
6066
6067    pub fn reset_semantic_refresh_transient_failure_count(&self) {
6068        self.semantic_refresh_circuit
6069            .consecutive_transient_failures
6070            .store(0, Ordering::SeqCst);
6071    }
6072
6073    pub fn reset_semantic_refresh_circuit_after_success(&self) {
6074        self.reset_semantic_refresh_transient_failure_count();
6075        self.semantic_refresh_circuit
6076            .probe_ready
6077            .store(false, Ordering::SeqCst);
6078        if self
6079            .semantic_refresh_circuit
6080            .open
6081            .swap(false, Ordering::SeqCst)
6082        {
6083            crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
6084        }
6085    }
6086
6087    pub fn semantic_refresh_transient_failure_count(&self) -> usize {
6088        self.semantic_refresh_circuit
6089            .consecutive_transient_failures
6090            .load(Ordering::SeqCst)
6091    }
6092
6093    pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
6094        self.semantic_refresh_circuit
6095            .probe_in_flight
6096            .load(Ordering::SeqCst)
6097            || self.semantic_refresh_probe_ready()
6098    }
6099
6100    pub fn semantic_refresh_probe_ready(&self) -> bool {
6101        self.semantic_refresh_circuit
6102            .probe_ready
6103            .load(Ordering::SeqCst)
6104    }
6105
6106    pub fn take_semantic_refresh_probe_ready(&self) -> bool {
6107        self.semantic_refresh_circuit
6108            .probe_ready
6109            .swap(false, Ordering::SeqCst)
6110    }
6111
6112    fn invalidate_semantic_refresh_probe(&self) {
6113        self.semantic_refresh_circuit
6114            .probe_token
6115            .fetch_add(1, Ordering::SeqCst);
6116        self.semantic_refresh_circuit
6117            .probe_ready
6118            .store(false, Ordering::SeqCst);
6119        self.semantic_refresh_circuit
6120            .probe_in_flight
6121            .store(false, Ordering::SeqCst);
6122    }
6123
6124    pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
6125        let receiver = self.semantic_refresh_event_rx.lock();
6126        if receiver.is_none()
6127            || self
6128                .semantic_refresh_circuit
6129                .probe_ready
6130                .load(Ordering::SeqCst)
6131            || self
6132                .semantic_refresh_circuit
6133                .probe_in_flight
6134                .swap(true, Ordering::SeqCst)
6135        {
6136            return;
6137        }
6138        let probe_token = self
6139            .semantic_refresh_circuit
6140            .probe_token
6141            .fetch_add(1, Ordering::SeqCst)
6142            .wrapping_add(1);
6143        drop(receiver);
6144
6145        let circuit = Arc::clone(&self.semantic_refresh_circuit);
6146        let session_id = crate::log_ctx::current_session();
6147        std::thread::spawn(move || {
6148            crate::log_ctx::with_session(session_id, || {
6149                std::thread::sleep(delay);
6150                if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
6151                    circuit.probe_ready.store(true, Ordering::SeqCst);
6152                    circuit.probe_in_flight.store(false, Ordering::SeqCst);
6153                }
6154            });
6155        });
6156    }
6157
6158    /// Access the cached semantic embedding model.
6159    pub fn semantic_embedding_model(
6160        &self,
6161    ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
6162        &self.semantic_embedding_model
6163    }
6164
6165    /// Access the file watcher handle (kept alive to continue watching).
6166    pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
6167        &self.watcher
6168    }
6169
6170    /// Access the pre-filtered watcher event receiver.
6171    pub fn watcher_rx(
6172        &self,
6173    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
6174        &self.watcher_rx
6175    }
6176
6177    /// Access continuation state for the bounded watcher drain.
6178    pub(crate) fn watcher_drain_slice(
6179        &self,
6180    ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
6181        &self.watcher_drain_slice
6182    }
6183
6184    /// Include partially consumed dispatch events when reporting drain backlog.
6185    pub fn watcher_drain_pending_path_count(&self) -> usize {
6186        self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
6187            let active_paths = match &state.phase {
6188                WatcherDrainPhase::Collect => 0,
6189                WatcherDrainPhase::Apply { paths, .. } => paths.len(),
6190            };
6191            active_paths + state.pending_paths.len()
6192        })
6193    }
6194
6195    /// Number of path-budgeted watcher batches since this runtime was installed.
6196    pub fn watcher_drain_path_slice_count(&self) -> usize {
6197        self.watcher_drain_slice
6198            .lock()
6199            .as_ref()
6200            .map_or(0, |state| state.path_slice_count)
6201    }
6202
6203    /// Install a watcher filter thread and its dispatch receiver. The caller
6204    /// must have stopped any previous watcher runtime first.
6205    pub fn install_watcher_runtime(
6206        &self,
6207        rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
6208        runtime: WatcherThreadHandle,
6209    ) {
6210        let _runtime_guard = self.watcher_runtime_lock.lock();
6211        let replaced = self.watcher_thread.lock().replace(runtime);
6212        self.app.watcher_started();
6213        if let Some(runtime) = replaced {
6214            Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6215        }
6216        *self.watcher_rx.lock() = Some(rx);
6217        *self.watcher_drain_slice.lock() = None;
6218    }
6219
6220    fn watcher_root_path(&self) -> PathBuf {
6221        self.canonical_cache_root_opt()
6222            .or_else(|| self.config().project_root.clone())
6223            .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
6224    }
6225
6226    fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
6227        const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
6228        // Signal the watcher before scheduling the joiner so teardown does not
6229        // depend on a newly spawned thread winning CPU time under fleet load.
6230        runtime.request_shutdown();
6231        std::thread::spawn(
6232            move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
6233                WatcherJoinOutcome::Joined => {
6234                    app.watcher_stopped();
6235                    crate::slog_info!("watcher stopped: {}", root.display());
6236                }
6237                WatcherJoinOutcome::TimedOut(join) => {
6238                    crate::slog_warn!(
6239                        "watcher stop timed out after {} ms: {}",
6240                        JOIN_TIMEOUT.as_millis(),
6241                        root.display()
6242                    );
6243                    std::thread::spawn(move || {
6244                        let _ = join.join();
6245                        app.watcher_stopped();
6246                        crate::slog_info!("watcher stopped: {}", root.display());
6247                    });
6248                }
6249            },
6250        );
6251    }
6252
6253    fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
6254        let _runtime_guard = self.watcher_runtime_lock.lock();
6255        let runtime = self.watcher_thread.lock().take();
6256        *self.watcher_rx.lock() = None;
6257        *self.watcher_drain_slice.lock() = None;
6258        *self.watcher.lock() = None;
6259        runtime
6260    }
6261
6262    /// Stop the watcher runtime without waiting on its OS thread. Shutdown and
6263    /// the bounded join run on a detached reaper so configure and transport
6264    /// loops never wait on FSEvents or inotify teardown.
6265    pub fn stop_watcher_runtime(&self) {
6266        if let Some(runtime) = self.take_watcher_runtime() {
6267            Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6268        }
6269    }
6270
6271    /// Request watcher shutdown without joining on the executor lane.
6272    pub fn stop_watcher_runtime_in_background(&self) {
6273        self.stop_watcher_runtime();
6274    }
6275
6276    /// Remove a watcher runtime whose OS thread already exited (backend
6277    /// failure while the root was unbound and drains were suppressed).
6278    /// Returns true when a finished corpse was actually removed so the caller
6279    /// can apply watcher-gap invalidation exactly once.
6280    pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
6281        let runtime = {
6282            let _runtime_guard = self.watcher_runtime_lock.lock();
6283            let finished = self
6284                .watcher_thread
6285                .lock()
6286                .as_ref()
6287                .is_some_and(|runtime| runtime.is_finished());
6288            if !finished {
6289                return false;
6290            }
6291            let runtime = self.watcher_thread.lock().take();
6292            *self.watcher_rx.lock() = None;
6293            *self.watcher_drain_slice.lock() = None;
6294            *self.watcher.lock() = None;
6295            runtime
6296        };
6297        if let Some(runtime) = runtime {
6298            Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6299        }
6300        true
6301    }
6302
6303    /// Process-scoped watcher count used by maintenance diagnostics and
6304    /// regression tests. A runtime remains counted until its thread exits.
6305    pub fn watcher_registry_count(&self) -> usize {
6306        self.app.watcher_count()
6307    }
6308
6309    pub(crate) fn watcher_runtime_active(&self) -> bool {
6310        let _runtime_guard = self.watcher_runtime_lock.lock();
6311        // A finished thread is a dead runtime even while its handle is still
6312        // installed (the backend can fail while drains are suppressed for an
6313        // unbound root, leaving the queued error undrained). Treating it as
6314        // active would block watcher restoration on rebind.
6315        let thread_live = self
6316            .watcher_thread
6317            .lock()
6318            .as_ref()
6319            .is_some_and(|runtime| !runtime.is_finished());
6320        thread_live && self.watcher_rx.lock().is_some()
6321    }
6322
6323    /// Return whether artifact eviction would discard work that still needs a
6324    /// live handle. Callers use this as the single safety gate before clearing
6325    /// resident stores and inspect caches.
6326    pub fn artifact_eviction_blocked(&self) -> bool {
6327        if self.standing_artifact_exempt.load(Ordering::Acquire) {
6328            return true;
6329        }
6330        let semantic_refresh_in_flight = match &*self
6331            .semantic_index_status
6332            .read()
6333            .unwrap_or_else(std::sync::PoisonError::into_inner)
6334        {
6335            SemanticIndexStatus::Building { .. } => true,
6336            SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
6337            SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
6338        };
6339        if crate::runtime_drain::any_build_in_flight(self)
6340            || semantic_refresh_in_flight
6341            || self.inspect_manager.tier2_any_in_flight()
6342            || !self.bash_background.running_tasks().is_empty()
6343            || !self.pending_callgraph_store_paths.lock().is_empty()
6344            || !self.pending_search_index_paths.lock().is_empty()
6345            || !self.pending_tier2_paths.lock().is_empty()
6346            || !self.pending_semantic_index_paths.lock().is_empty()
6347            || *self.pending_semantic_corpus_refresh.lock()
6348        {
6349            return true;
6350        }
6351
6352        let search_has_pending_disk_changes = self
6353            .search_index
6354            .read()
6355            .unwrap_or_else(std::sync::PoisonError::into_inner)
6356            .as_ref()
6357            .is_some_and(SearchIndex::has_pending_disk_changes);
6358        search_has_pending_disk_changes
6359    }
6360
6361    /// Drop idle root-scoped artifact handles. Persistent data remains on disk;
6362    /// artifact-backed query paths schedule a background reload on first use.
6363    /// Returns false when an active build, bash task, inspect scan, or pending
6364    /// disk update makes eviction unsafe.
6365    pub fn evict_idle_artifacts(&self) -> bool {
6366        if self.artifact_eviction_blocked() {
6367            return false;
6368        }
6369
6370        self.callgraph_store
6371            .write()
6372            .unwrap_or_else(std::sync::PoisonError::into_inner)
6373            .take();
6374        self.search_index
6375            .write()
6376            .unwrap_or_else(std::sync::PoisonError::into_inner)
6377            .take();
6378        self.semantic_index
6379            .write()
6380            .unwrap_or_else(std::sync::PoisonError::into_inner)
6381            .take();
6382        self.borrowed_index_cache.lock().clear();
6383        self.inspect_manager.evict_idle_caches();
6384        self.reset_symbol_cache();
6385        self.clear_tsconfig_membership_cache();
6386        true
6387    }
6388
6389    /// Test seam for the serialized real-watcher integration suite. Production
6390    /// callers cannot trigger it without the explicit test-only environment flag.
6391    #[doc(hidden)]
6392    pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
6393        if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
6394            return false;
6395        }
6396        if !self.evict_idle_artifacts() {
6397            return false;
6398        }
6399        self.stop_watcher_runtime_in_background();
6400        self.invalidate_artifacts_after_watcher_gap();
6401        true
6402    }
6403
6404    /// Release resources that can be recreated by an equivalent later bind.
6405    /// LSP shutdown can wait on child processes, so all work stays off the
6406    /// executor and subc frame loops.
6407    pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
6408        let ctx = Arc::clone(self);
6409        std::thread::spawn(move || {
6410            if !ctx.subc_unbound_quiesced() {
6411                return;
6412            }
6413            {
6414                let mut lsp = ctx.lsp_manager.lock();
6415                if !ctx.subc_unbound_quiesced() {
6416                    return;
6417                }
6418                lsp.shutdown_all();
6419            }
6420            let _ = ctx.subc_lifecycle.run_if_unbound(|| {
6421                ctx.bash_background.clear_db_pool();
6422                ctx.backup.lock().clear_db_pool();
6423            });
6424        });
6425    }
6426
6427    /// Final cleanup for an actor whose project directory no longer exists.
6428    /// The executor invokes this only after proving the actor has no queued or
6429    /// running jobs, and always from a detached teardown thread.
6430    pub(crate) fn teardown_deleted_root(&self) {
6431        self.bash_background.detach();
6432        self.bash_background.clear_db_pool();
6433        self.backup.lock().clear_db_pool();
6434        self.lsp_manager.lock().shutdown_all();
6435    }
6436
6437    /// Access the LSP manager.
6438    pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
6439        self.lsp_manager.lock()
6440    }
6441
6442    /// Notify LSP servers that a file was written.
6443    /// Call this after write_format_validate in command handlers.
6444    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
6445        let config = self.config();
6446        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6447            if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
6448                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6449            }
6450        }
6451    }
6452
6453    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
6454    /// errors/warnings don't linger in the warm set (no server republishes for
6455    /// a vanished path), keeping the status bar and `aft_inspect` honest.
6456    /// Returns true if any entry was removed. Best-effort: a contended borrow is
6457    /// skipped silently (the watcher drain retries on subsequent events).
6458    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
6459        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6460            lsp.clear_diagnostics_for_file(file_path)
6461        } else {
6462            false
6463        }
6464    }
6465
6466    /// Mark diagnostics stale for a file changed outside AFT's text-sync path.
6467    /// Best-effort: a contended LSP lock is skipped and the next watcher event
6468    /// or scoped diagnostics pull can reconcile the file.
6469    pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
6470        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6471            lsp.mark_diagnostics_stale_for_file(file_path)
6472        } else {
6473            StaleDiagnosticsMark::default()
6474        }
6475    }
6476
6477    /// Resync a watcher-stale diagnosed file with the active LSP server.
6478    ///
6479    /// `workspace/didChangeWatchedFiles` tells servers that the filesystem
6480    /// changed, but it does not update an already-open document's in-memory text.
6481    /// Sending the normal didOpen/didChange path gives push-only servers a chance
6482    /// to publish fresh diagnostics and keeps pull-capable servers' document state
6483    /// current for the next diagnostic request.
6484    pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
6485        if !file_path.is_file() {
6486            return false;
6487        }
6488
6489        let content = match std::fs::read_to_string(file_path) {
6490            Ok(content) => content,
6491            Err(err) => {
6492                crate::slog_warn!(
6493                    "skipping LSP resync for {} after external edit: {}",
6494                    file_path.display(),
6495                    err
6496                );
6497                return false;
6498            }
6499        };
6500
6501        let config = self.config();
6502        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6503            if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
6504                crate::slog_warn!(
6505                    "LSP resync failed for {} after external edit: {}",
6506                    file_path.display(),
6507                    err
6508                );
6509                return false;
6510            }
6511            true
6512        } else {
6513            false
6514        }
6515    }
6516
6517    /// Notify LSP and optionally wait for diagnostics.
6518    ///
6519    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
6520    /// Ensures the matching server is running, sends didOpen/didChange, waits
6521    /// briefly for publishDiagnostics, and returns diagnostics for the file.
6522    ///
6523    /// Pre-edit cached diagnostics are never returned: only entries whose version
6524    /// matches the post-edit document version are authoritative.
6525    pub fn lsp_notify_and_collect_diagnostics(
6526        &self,
6527        file_path: &Path,
6528        content: &str,
6529        timeout: std::time::Duration,
6530    ) -> crate::lsp::manager::PostEditWaitOutcome {
6531        let config = self.config();
6532        let Some(mut lsp) = self.lsp_manager.try_lock() else {
6533            return crate::lsp::manager::PostEditWaitOutcome::default();
6534        };
6535
6536        // Clear any queued notifications before this write so the wait loop only
6537        // observes diagnostics triggered by the current change.
6538        lsp.drain_events();
6539
6540        // Snapshot per-server epochs and document versions BEFORE sending
6541        // didChange so the wait loop can prove freshness without accepting
6542        // stale pre-edit publishes that arrived late.
6543        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
6544
6545        // An explicit diagnostics request still starts matching servers only when
6546        // needed. Record the document version sent to each server so completed results
6547        // stay tied to the server and version that produced them.
6548        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
6549        {
6550            Ok(v) => v,
6551            Err(e) => {
6552                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6553                return crate::lsp::manager::PostEditWaitOutcome::default();
6554            }
6555        };
6556
6557        // No server matched this file — return an empty outcome that's
6558        // honestly `complete: true` (nothing to wait for).
6559        if expected_versions.is_empty() {
6560            return crate::lsp::manager::PostEditWaitOutcome::default();
6561        }
6562
6563        // Register the wake receiver while the manager is still locked. Events
6564        // that raced with registration remain on the raw receiver; events won by
6565        // another drain path wake this waiter after that path updates the store.
6566        let mut wait = lsp.start_post_edit_diagnostics_wait(
6567            file_path,
6568            &expected_versions,
6569            &pre_snapshot,
6570            timeout,
6571        );
6572        let mut complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, None);
6573        drop(lsp);
6574
6575        while !complete && !wait.deadline_reached() {
6576            // Waiting on channel activity does not require access to manager
6577            // state, so other LSP operations can continue their bookkeeping.
6578            let event = wait.next_event();
6579            let mut lsp = self.lsp_manager.lock();
6580            complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, event);
6581        }
6582
6583        self.lsp_manager
6584            .lock()
6585            .finish_post_edit_diagnostics_wait(wait)
6586    }
6587
6588    /// Collect custom server root_markers from user config for use in
6589    /// `is_config_file_path_with_custom` checks (#25).
6590    fn custom_lsp_root_markers(&self) -> Vec<String> {
6591        self.config()
6592            .lsp_servers
6593            .iter()
6594            .flat_map(|s| s.root_markers.iter().cloned())
6595            .collect()
6596    }
6597
6598    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
6599        let custom_markers = self.custom_lsp_root_markers();
6600        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
6601            .iter()
6602            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
6603            .cloned()
6604            .map(|path| {
6605                let change_type = if path.exists() {
6606                    FileChangeType::CHANGED
6607                } else {
6608                    FileChangeType::DELETED
6609                };
6610                (path, change_type)
6611            })
6612            .collect();
6613
6614        self.notify_watched_config_events(&config_paths);
6615    }
6616
6617    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
6618        let paths = params
6619            .get("multi_file_write_paths")
6620            .and_then(|value| value.as_array())?
6621            .iter()
6622            .filter_map(|value| value.as_str())
6623            .map(PathBuf::from)
6624            .collect::<Vec<_>>();
6625
6626        (!paths.is_empty()).then_some(paths)
6627    }
6628
6629    /// Parse config-file watched events from `multi_file_write_paths` when the
6630    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
6631    ///
6632    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
6633    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
6634    /// `notify_watched_config_files()`. Both variants read the same JSON key but
6635    /// with different per-entry schemas — they are NOT redundant.
6636    ///
6637    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
6638    /// and was reachable via the `else if` branch when all entries were objects.
6639    /// Restoring both is correct.
6640    fn watched_file_events_from_params(
6641        params: &serde_json::Value,
6642        extra_markers: &[String],
6643    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
6644        let events = params
6645            .get("multi_file_write_paths")
6646            .and_then(|value| value.as_array())?
6647            .iter()
6648            .filter_map(|entry| {
6649                // Only handle object entries — string entries go through multi_file_write_paths()
6650                let path = entry
6651                    .get("path")
6652                    .and_then(|value| value.as_str())
6653                    .map(PathBuf::from)?;
6654
6655                if !is_config_file_path_with_custom(&path, extra_markers) {
6656                    return None;
6657                }
6658
6659                let change_type = entry
6660                    .get("type")
6661                    .and_then(|value| value.as_str())
6662                    .and_then(Self::parse_file_change_type)
6663                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
6664
6665                Some((path, change_type))
6666            })
6667            .collect::<Vec<_>>();
6668
6669        (!events.is_empty()).then_some(events)
6670    }
6671
6672    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
6673        match value {
6674            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
6675            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
6676            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
6677            _ => None,
6678        }
6679    }
6680
6681    fn change_type_from_current_state(path: &Path) -> FileChangeType {
6682        if path.exists() {
6683            FileChangeType::CHANGED
6684        } else {
6685            FileChangeType::DELETED
6686        }
6687    }
6688
6689    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
6690        if config_paths.is_empty() {
6691            return;
6692        }
6693
6694        let config = self.config();
6695        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6696            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
6697                crate::slog_warn!("watched-file sync error: {}", e);
6698            }
6699        }
6700    }
6701
6702    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
6703        let custom_markers = self.custom_lsp_root_markers();
6704        if !is_config_file_path_with_custom(file_path, &custom_markers) {
6705            return;
6706        }
6707
6708        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
6709    }
6710
6711    /// Post-write LSP hook for multi-file edits. When the patch includes
6712    /// config-file edits, notify active workspace servers via
6713    /// `workspace/didChangeWatchedFiles` before sending the per-document
6714    /// didOpen/didChange for the current file.
6715    pub fn lsp_post_multi_file_write(
6716        &self,
6717        file_path: &Path,
6718        content: &str,
6719        file_paths: &[PathBuf],
6720        params: &serde_json::Value,
6721    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6722        self.notify_watched_config_files(file_paths);
6723        self.add_pending_tier2_paths(file_paths.iter().cloned());
6724        let _ = self.mark_status_bar_tier2_stale();
6725
6726        let wants_diagnostics = params
6727            .get("diagnostics")
6728            .and_then(|v| v.as_bool())
6729            .unwrap_or(false);
6730
6731        if !wants_diagnostics {
6732            self.lsp_notify_file_changed(file_path, content);
6733            return None;
6734        }
6735
6736        let wait_ms = params
6737            .get("wait_ms")
6738            .and_then(|v| v.as_u64())
6739            .unwrap_or(3000)
6740            .min(10_000);
6741
6742        Some(self.lsp_notify_and_collect_diagnostics(
6743            file_path,
6744            content,
6745            std::time::Duration::from_millis(wait_ms),
6746        ))
6747    }
6748
6749    /// Post-write LSP hook: notify server and optionally collect diagnostics.
6750    ///
6751    /// This is the single call site for all command handlers after `write_format_validate`.
6752    /// Behavior:
6753    /// - When `diagnostics: true` is in `params`, notifies the server, waits
6754    ///   until matching diagnostics arrive or the timeout expires, and returns
6755    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
6756    ///   status.
6757    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
6758    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
6759    ///   `None` is what tells the response builder to omit the LSP fields
6760    ///   entirely (preserves the no-diagnostics-requested response shape).
6761    ///
6762    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
6763    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
6764    /// capped at 10000ms.
6765    pub fn lsp_post_write(
6766        &self,
6767        file_path: &Path,
6768        content: &str,
6769        params: &serde_json::Value,
6770    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6771        let wants_diagnostics = params
6772            .get("diagnostics")
6773            .and_then(|v| v.as_bool())
6774            .unwrap_or(false);
6775
6776        let custom_markers = self.custom_lsp_root_markers();
6777        if let Some(file_paths) = Self::multi_file_write_paths(params) {
6778            self.add_pending_tier2_paths(file_paths);
6779        } else {
6780            self.add_pending_tier2_paths([file_path.to_path_buf()]);
6781        }
6782        let _ = self.mark_status_bar_tier2_stale();
6783
6784        if !wants_diagnostics {
6785            if let Some(file_paths) = Self::multi_file_write_paths(params) {
6786                self.notify_watched_config_files(&file_paths);
6787            } else if let Some(config_events) =
6788                Self::watched_file_events_from_params(params, &custom_markers)
6789            {
6790                self.notify_watched_config_events(&config_events);
6791            }
6792            self.lsp_notify_file_changed(file_path, content);
6793            return None;
6794        }
6795
6796        let wait_ms = params
6797            .get("wait_ms")
6798            .and_then(|v| v.as_u64())
6799            .unwrap_or(3000)
6800            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
6801
6802        if let Some(file_paths) = Self::multi_file_write_paths(params) {
6803            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
6804        }
6805
6806        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
6807        {
6808            self.notify_watched_config_events(&config_events);
6809        }
6810
6811        Some(self.lsp_notify_and_collect_diagnostics(
6812            file_path,
6813            content,
6814            std::time::Duration::from_millis(wait_ms),
6815        ))
6816    }
6817
6818    fn resolved_path_restriction_root(&self, root: &Path) -> PathBuf {
6819        let mut memo = self.path_restriction_root_memo.lock();
6820        if let Some(cached) = memo.as_ref() {
6821            if cached.configured_root.as_os_str() == root.as_os_str()
6822                && cached.resolved_root.exists()
6823            {
6824                return cached.resolved_root.clone();
6825            }
6826        }
6827
6828        // A cache hit performs one `exists` stat instead of walking the root's
6829        // symlink chain. If the resolved root disappears, retry canonicalization
6830        // so deletion and recreation can choose its new identity. A retargeted
6831        // configured-root symlink whose previous target still exists is the
6832        // residual window until reconfigure or that target disappears.
6833        #[cfg(test)]
6834        self.path_restriction_root_canonicalizations
6835            .fetch_add(1, Ordering::SeqCst);
6836        let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
6837        *memo = Some(PathRestrictionRootMemo {
6838            configured_root: root.to_path_buf(),
6839            resolved_root: resolved_root.clone(),
6840        });
6841        resolved_root
6842    }
6843
6844    fn path_restriction_context(
6845        &self,
6846        req_id: &str,
6847        path: &Path,
6848    ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
6849        let config = self.config();
6850        let force_restrict = self.request_force_restrict(req_id);
6851        if !config.restrict_to_project_root && !force_restrict {
6852            return Ok(None);
6853        }
6854        let root = match &config.project_root {
6855            Some(root) => root.clone(),
6856            None if force_restrict => {
6857                return Err(crate::protocol::Response::error(
6858                    req_id,
6859                    "path_outside_root",
6860                    "project root is required when path restriction is forced",
6861                ));
6862            }
6863            None => return Ok(None),
6864        };
6865        drop(config);
6866
6867        let raw_root = root.clone();
6868        let resolved_root = self.resolved_path_restriction_root(&root);
6869        let path_for_resolution = if path.is_relative() {
6870            raw_root.join(path)
6871        } else {
6872            path.to_path_buf()
6873        };
6874        Ok(Some(PathRestrictionContext {
6875            raw_root,
6876            resolved_root,
6877            path_for_resolution,
6878        }))
6879    }
6880
6881    /// Resolve a possibly-relative path against the configured project root.
6882    ///
6883    /// Safety arms that key backup/checkpoint state by path (`undo`,
6884    /// `undo_preview`, `edit_history`, `checkpoint`) must resolve relative paths
6885    /// against the request's bound project root BEFORE validation and keying.
6886    /// Otherwise a relative path is joined against the daemon's current working
6887    /// directory by `canonicalize_key`, which differs from the root the mutating
6888    /// tool resolved against — the per-(session, path) stack lookup then misses
6889    /// and the user gets a false `no_undo_history`.
6890    ///
6891    /// When no `project_root` is configured (direct CLI usage), relative paths
6892    /// fall back to the current working directory, matching `canonicalize_key`.
6893    pub fn resolve_relative_path(&self, path: &Path) -> PathBuf {
6894        if path.is_absolute() {
6895            return path.to_path_buf();
6896        }
6897        if let Some(root) = &self.config().project_root {
6898            return root.join(path);
6899        }
6900        std::env::current_dir()
6901            .unwrap_or_else(|_| PathBuf::from("."))
6902            .join(path)
6903    }
6904
6905    /// Validate that a file path falls within the configured project root.
6906    ///
6907    /// When `project_root` is configured (normal plugin usage), this resolves the
6908    /// path and checks it starts with the root. Returns the canonicalized path on
6909    /// success, or an error response on violation.
6910    ///
6911    /// When no `project_root` is configured (direct CLI usage), all paths pass
6912    /// through unrestricted for backward compatibility.
6913    pub fn validate_path(
6914        &self,
6915        req_id: &str,
6916        path: &Path,
6917    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6918        self.validate_path_with_artifact_session(req_id, path, None)
6919    }
6920
6921    /// Validate a write location without following its final path component.
6922    ///
6923    /// Checkpoint creation and restore use this mode because the final component
6924    /// is the object being preserved or replaced. Following a symlink there would
6925    /// authorize its target and change the stored snapshot key. Every ancestor is
6926    /// still resolved so a symlinked parent cannot escape the project root.
6927    pub fn validate_write_location(
6928        &self,
6929        req_id: &str,
6930        path: &Path,
6931    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6932        let Some(PathRestrictionContext {
6933            raw_root,
6934            resolved_root,
6935            path_for_resolution,
6936        }) = self.path_restriction_context(req_id, path)?
6937        else {
6938            return Ok(path.to_path_buf());
6939        };
6940        let normalized = normalize_path(&path_for_resolution);
6941        let Some(file_name) = normalized.file_name() else {
6942            return self.validate_path(req_id, path);
6943        };
6944        let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
6945        let resolved_parent = match std::fs::canonicalize(parent) {
6946            Ok(resolved) => resolved,
6947            Err(_) => {
6948                reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
6949                resolve_with_existing_ancestors(parent)
6950            }
6951        };
6952        let resolved = normalize_path(&resolved_parent.join(file_name));
6953
6954        if !resolved.starts_with(&resolved_root) {
6955            return Err(path_error_response(req_id, path, &resolved_root));
6956        }
6957
6958        Ok(resolved)
6959    }
6960
6961    /// Validate a read path. A file produced by a background bash task may live
6962    /// outside the project root, so the session that owns the registered output
6963    /// may read that specific file. Mutating tools deliberately use
6964    /// [`AppContext::validate_path`] or [`AppContext::validate_write_location`]
6965    /// and never receive this exception.
6966    pub fn validate_read_path(
6967        &self,
6968        req_id: &str,
6969        session_id: &str,
6970        path: &Path,
6971    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6972        self.validate_path_with_artifact_session(req_id, path, Some(session_id))
6973    }
6974
6975    fn validate_path_with_artifact_session(
6976        &self,
6977        req_id: &str,
6978        path: &Path,
6979        artifact_session_id: Option<&str>,
6980    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6981        let Some(PathRestrictionContext {
6982            raw_root,
6983            resolved_root,
6984            path_for_resolution,
6985        }) = self.path_restriction_context(req_id, path)?
6986        else {
6987            // When path restriction is disabled, callers receive the input path
6988            // unchanged instead of an implicitly canonicalized filesystem path.
6989            return Ok(path.to_path_buf());
6990        };
6991
6992        // Resolve the path (follow symlinks, normalize ..). If canonicalization
6993        // fails (e.g. path does not exist or traverses a broken symlink), inspect
6994        // every existing component with lstat before falling back lexically so a
6995        // broken in-root symlink cannot be used to write outside project_root.
6996        let resolved = match std::fs::canonicalize(&path_for_resolution) {
6997            Ok(resolved) => resolved,
6998            Err(_) => {
6999                let normalized = normalize_path(&path_for_resolution);
7000                reject_escaping_symlink(
7001                    req_id,
7002                    &path_for_resolution,
7003                    &normalized,
7004                    &resolved_root,
7005                    &raw_root,
7006                )?;
7007                resolve_with_existing_ancestors(&normalized)
7008            }
7009        };
7010
7011        if !resolved.starts_with(&resolved_root) {
7012            let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
7013                self.bash_background
7014                    .is_session_owned_artifact_path(session_id, &resolved)
7015            });
7016            if !is_owned_bash_artifact {
7017                return Err(path_error_response(req_id, path, &resolved_root));
7018            }
7019        }
7020
7021        Ok(resolved)
7022    }
7023
7024    /// Count active LSP server instances.
7025    pub fn lsp_server_count(&self) -> usize {
7026        self.lsp_manager
7027            .try_lock()
7028            .map(|lsp| lsp.server_count())
7029            .unwrap_or(0)
7030    }
7031
7032    /// Symbol cache statistics from the language provider.
7033    pub fn symbol_cache_stats(&self) -> serde_json::Value {
7034        let entries = self
7035            .symbol_cache
7036            .read()
7037            .map(|cache| cache.len())
7038            .unwrap_or(0);
7039        serde_json::json!({
7040            "local_entries": entries,
7041            "warm_entries": 0,
7042        })
7043    }
7044
7045    fn memory_estimates(&self) -> [crate::memory::MemoryEstimate; 9] {
7046        let semantic = match self.semantic_index.try_read() {
7047            Ok(index) => index
7048                .as_ref()
7049                .map(SemanticIndex::estimated_memory)
7050                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
7051            Err(TryLockError::Poisoned(error)) => error
7052                .into_inner()
7053                .as_ref()
7054                .map(SemanticIndex::estimated_memory)
7055                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
7056            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7057        };
7058        let trigram = match self.search_index.try_read() {
7059            Ok(index) => index
7060                .as_ref()
7061                .map(SearchIndex::estimated_memory)
7062                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
7063            Err(TryLockError::Poisoned(error)) => error
7064                .into_inner()
7065                .as_ref()
7066                .map(SearchIndex::estimated_memory)
7067                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
7068            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7069        };
7070        let symbols = match self.symbol_cache.try_read() {
7071            Ok(cache) => cache.estimated_memory(),
7072            Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
7073            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7074        };
7075        let callgraph = match self.callgraph_store.try_read() {
7076            Ok(store) => store
7077                .as_ref()
7078                .map(|store| store.estimated_memory())
7079                .unwrap_or_else(|| {
7080                    crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
7081                }),
7082            Err(TryLockError::Poisoned(error)) => error
7083                .into_inner()
7084                .as_ref()
7085                .map(|store| store.estimated_memory())
7086                .unwrap_or_else(|| {
7087                    crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
7088                }),
7089            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7090        };
7091        let callgraph_projection = self.inspect_manager.callgraph_projection_estimated_memory();
7092        let inspect = self.inspect_manager.estimated_memory();
7093        let bash = self.bash_background.estimated_memory();
7094        let lsp = self
7095            .lsp_manager
7096            .try_lock()
7097            .map(|lsp| lsp.estimated_memory())
7098            .unwrap_or_else(crate::memory::MemoryEstimate::busy);
7099        // Parsers are created per operation rather than retained in a pool, so
7100        // parser bytes remain an explicit estimation gap instead of a guess.
7101        let parser_pool = crate::memory::MemoryEstimate::not_estimated()
7102            .count("pooled_parsers", 0)
7103            .gap("tree_sitter_parser_bytes");
7104        [
7105            semantic,
7106            trigram,
7107            symbols,
7108            callgraph,
7109            callgraph_projection,
7110            inspect,
7111            bash,
7112            lsp,
7113            parser_pool,
7114        ]
7115    }
7116
7117    /// Build one root's memory estimate using only non-blocking lock attempts.
7118    /// A contended subsystem is represented as `busy` rather than delaying the
7119    /// status control path.
7120    pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
7121        let [semantic, trigram, symbols, callgraph, callgraph_projection, inspect, bash, lsp, parser_pool] =
7122            self.memory_estimates();
7123        crate::memory::RootMemorySnapshot::new(
7124            semantic,
7125            trigram,
7126            symbols,
7127            callgraph,
7128            callgraph_projection,
7129            inspect,
7130            bash,
7131            lsp,
7132            parser_pool,
7133        )
7134    }
7135
7136    /// Pre-aggregate root memory for capped health diagnostics without building
7137    /// the rich per-subsystem detail that the status command returns.
7138    pub(crate) fn memory_root_rollup(&self) -> crate::memory::RootMemoryRollup {
7139        let estimates = self.memory_estimates();
7140        crate::memory::RootMemoryRollup::from_estimates(&[
7141            &estimates[0],
7142            &estimates[1],
7143            &estimates[2],
7144            &estimates[3],
7145            &estimates[4],
7146            &estimates[5],
7147            &estimates[6],
7148            &estimates[7],
7149            &estimates[8],
7150        ])
7151    }
7152
7153    /// Attribute all actor roots registered in this process. Standalone mode
7154    /// has no actor registry, so the current context is inserted directly.
7155    pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
7156        let mut roots = BTreeMap::new();
7157        let (roots_status, contexts) = match self.app.try_memory_contexts() {
7158            Some(contexts) => ("ready", contexts),
7159            None => ("busy", Vec::new()),
7160        };
7161        for (root, context) in contexts {
7162            roots.insert(root.display().to_string(), context.memory_root_snapshot());
7163        }
7164        // Normalize through the same identity the registry keys on: on Windows
7165        // a verbatim `\\?\` current root would otherwise land as a SECOND
7166        // entry for an already-registered root and double-count its memory.
7167        let current_label = current_root
7168            .map(|root| {
7169                cortexkit_paths::ProjectRootId::from_path(root)
7170                    .map(|id| id.as_path().display().to_string())
7171                    .unwrap_or_else(|_| root.display().to_string())
7172            })
7173            .unwrap_or_else(|| "<unconfigured>".to_string());
7174        roots
7175            .entry(current_label)
7176            .or_insert_with(|| self.memory_root_snapshot());
7177        crate::memory::MemorySnapshot::new(roots_status, roots)
7178    }
7179}
7180
7181#[cfg(test)]
7182mod subc_lifecycle_admission_tests {
7183    use super::*;
7184
7185    #[test]
7186    fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
7187        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7188        ctx.note_configure_warm_key("config-a".to_string());
7189        let content_generation = ctx.configure_content_generation();
7190        let lifecycle_generation = ctx.configure_generation();
7191        let search_epoch = ctx.next_search_persist_epoch();
7192        let semantic_epoch = ctx.next_semantic_persist_epoch();
7193        let search_persist_epoch = ctx.search_persist_epoch_flag();
7194        let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
7195
7196        ctx.mark_subc_unbound();
7197        assert!(ctx.configure_generation() > lifecycle_generation);
7198        assert_eq!(ctx.configure_content_generation(), content_generation);
7199        assert_eq!(search_persist_epoch.current(), search_epoch);
7200        assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
7201
7202        ctx.mark_subc_bound();
7203        ctx.note_configure_warm_key("config-b".to_string());
7204        assert!(ctx.configure_content_generation() > content_generation);
7205        let replacement_search_epoch = ctx.next_search_persist_epoch();
7206        let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
7207        assert!(replacement_search_epoch > search_epoch);
7208        assert!(replacement_semantic_epoch > semantic_epoch);
7209        assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
7210        assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
7211    }
7212
7213    #[test]
7214    fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
7215        let admission = SubcLifecycleAdmission::default();
7216        let generation = Arc::new(AtomicU64::new(11));
7217        let expected = generation.load(Ordering::SeqCst);
7218        let starts = Arc::new(AtomicUsize::new(0));
7219        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
7220        let (release_tx, release_rx) = std::sync::mpsc::channel();
7221
7222        let worker_admission = admission.clone();
7223        let worker_generation = Arc::clone(&generation);
7224        let worker_starts = Arc::clone(&starts);
7225        let worker = std::thread::spawn(move || {
7226            worker_admission.run_if_current(&worker_generation, expected, || {
7227                entered_tx.send(()).unwrap();
7228                release_rx.recv().unwrap();
7229                worker_starts.fetch_add(1, Ordering::SeqCst);
7230            })
7231        });
7232        entered_rx.recv().unwrap();
7233
7234        let unbind_admission = admission.clone();
7235        let unbind_generation = Arc::clone(&generation);
7236        let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
7237        let unbind = std::thread::spawn(move || {
7238            unbind_admission.mark_unbound(&unbind_generation);
7239            unbound_tx.send(()).unwrap();
7240        });
7241
7242        assert!(
7243            unbound_rx
7244                .recv_timeout(std::time::Duration::from_millis(50))
7245                .is_err(),
7246            "unbind must wait for an admitted worker-start commit"
7247        );
7248        release_tx.send(()).unwrap();
7249        assert!(worker.join().unwrap().is_some());
7250        unbound_rx
7251            .recv_timeout(std::time::Duration::from_secs(1))
7252            .unwrap();
7253        unbind.join().unwrap();
7254        assert_eq!(starts.load(Ordering::SeqCst), 1);
7255        assert!(
7256            admission
7257                .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
7258                    starts.fetch_add(1, Ordering::SeqCst);
7259                })
7260                .is_none(),
7261            "worker starts after unbind must be denied"
7262        );
7263    }
7264
7265    #[test]
7266    fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
7267        let ctx = Arc::new(AppContext::new(
7268            default_language_provider_factory(),
7269            Config::default(),
7270        ));
7271        let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
7272        let (started_tx, started_rx) = std::sync::mpsc::channel();
7273        let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
7274        let worker_ctx = Arc::clone(&ctx);
7275        let worker = std::thread::spawn(move || {
7276            started_tx.send(()).unwrap();
7277            snapshot_tx
7278                .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
7279                .unwrap();
7280        });
7281        started_rx
7282            .recv_timeout(Duration::from_secs(1))
7283            .expect("health snapshot worker should start");
7284
7285        let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
7286        let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
7287        drop(lifecycle_guard);
7288        worker.join().unwrap();
7289
7290        assert!(
7291            matches!(
7292                snapshot,
7293                Ok(RootHealthSnapshot {
7294                    state: RootHealthState::Busy,
7295                    ..
7296                })
7297            ),
7298            "health snapshots must report busy instead of waiting for lifecycle admission"
7299        );
7300        assert!(
7301            callgraph_receiver_available,
7302            "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
7303        );
7304    }
7305
7306    #[test]
7307    fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
7308        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7309        ctx.set_artifact_owner(
7310            Some(crate::artifact_owner::ArtifactOwnerStatus {
7311                mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
7312                project_key: "borrowed".to_string(),
7313                manifest_path: "manifest.json".to_string(),
7314                owner_project_scope_key: "owner".to_string(),
7315                owner_checkout_path: "/owner".to_string(),
7316                note: None,
7317            }),
7318            None,
7319        );
7320        ctx.update_status_bar_tier2(Some(4), None, None, None, true);
7321
7322        let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
7323
7324        assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
7325    }
7326
7327    #[test]
7328    fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
7329        let root = tempfile::tempdir().unwrap();
7330        let ctx = AppContext::new(
7331            default_language_provider_factory(),
7332            Config {
7333                project_root: Some(root.path().to_path_buf()),
7334                ..Config::default()
7335            },
7336        );
7337        ctx.set_harness(crate::harness::Harness::Opencode);
7338        ctx.set_cache_writer_capabilities(true, true);
7339        ctx.update_status_bar_tier2(Some(4), None, None, None, true);
7340        assert_eq!(
7341            ctx.try_health_snapshot(Path::new("writer-root"))
7342                .tier2
7343                .expect("tier2 health")
7344                .status,
7345            "building"
7346        );
7347
7348        ctx.set_cache_role(true, None);
7349
7350        assert_eq!(
7351            ctx.try_health_snapshot(Path::new("worktree-root"))
7352                .tier2
7353                .expect("tier2 health")
7354                .status,
7355            "disabled"
7356        );
7357        let tier2_snapshot = ctx.tier2_refresh_snapshot().expect("tier2 snapshot");
7358        assert!(!tier2_snapshot.callgraph_writer);
7359    }
7360
7361    #[test]
7362    fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
7363        let temp = tempfile::tempdir().unwrap();
7364        let ctx = AppContext::new(
7365            default_language_provider_factory(),
7366            Config {
7367                project_root: Some(temp.path().to_path_buf()),
7368                semantic_search: true,
7369                ..Config::default()
7370            },
7371        );
7372        *ctx.semantic_index()
7373            .write()
7374            .unwrap_or_else(std::sync::PoisonError::into_inner) =
7375            Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
7376        let mut status = SemanticIndexStatus::ready();
7377        status.add_refreshing_file(temp.path().join("changed.rs"));
7378        *ctx.semantic_index_status()
7379            .write()
7380            .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
7381        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7382        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7383        ctx.install_semantic_refresh_worker_for_build_epoch(
7384            request_tx,
7385            event_rx,
7386            Arc::new(Mutex::new(None)),
7387            ctx.semantic_index_rx_epoch(),
7388        );
7389
7390        ctx.cancel_unbound_artifact_work();
7391
7392        assert!(ctx.semantic_refresh_event_rx().lock().is_none());
7393        assert!(matches!(
7394            &*ctx
7395                .semantic_index_status()
7396                .read()
7397                .unwrap_or_else(std::sync::PoisonError::into_inner),
7398            SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
7399        ));
7400    }
7401
7402    #[test]
7403    fn terminal_empty_search_receiver_reports_completion_work() {
7404        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7405        let (sender, receiver) = crossbeam_channel::unbounded();
7406        let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
7407        let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
7408        drop(sender);
7409        drop(terminal_guard);
7410
7411        assert!(
7412            ctx.completion_drains_have_work(),
7413            "an empty disconnected one-shot receiver must wake the completion drain"
7414        );
7415    }
7416
7417    #[test]
7418    fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
7419        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7420        let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
7421        let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
7422        let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
7423        let replacement_epoch =
7424            ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
7425
7426        assert!(replacement_epoch > old_epoch);
7427        assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
7428        assert!(ctx.semantic_index_rx().lock().is_some());
7429        assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
7430    }
7431
7432    #[test]
7433    fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
7434        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7435        let (old_sender, old_receiver) = crossbeam_channel::unbounded();
7436        let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
7437        let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
7438        let (current_sender, current_receiver) = crossbeam_channel::unbounded();
7439        let current_epoch =
7440            ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
7441        let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
7442        drop(old_sender);
7443        drop(current_sender);
7444
7445        drop(current_guard);
7446        drop(old_guard);
7447
7448        assert!(current_epoch > old_epoch);
7449        assert_eq!(
7450            ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
7451            current_epoch,
7452            "a stale worker must not move the terminal watermark backward"
7453        );
7454        assert!(ctx.completion_drains_have_work());
7455    }
7456
7457    #[test]
7458    fn finished_semantic_refresh_worker_reports_completion_work() {
7459        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7460        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7461        let (event_tx, event_rx) = crossbeam_channel::unbounded();
7462        let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
7463        ctx.install_semantic_refresh_worker_for_build_epoch(
7464            request_tx,
7465            event_rx,
7466            Arc::clone(&worker_slot),
7467            ctx.semantic_index_rx_epoch(),
7468        );
7469        drop(event_tx);
7470        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
7471        while !worker_slot
7472            .lock()
7473            .unwrap_or_else(std::sync::PoisonError::into_inner)
7474            .as_ref()
7475            .is_some_and(std::thread::JoinHandle::is_finished)
7476        {
7477            assert!(
7478                std::time::Instant::now() < deadline,
7479                "worker did not finish"
7480            );
7481            std::thread::yield_now();
7482        }
7483
7484        assert!(
7485            ctx.completion_drains_have_work(),
7486            "a finished refresh worker must wake the completion drain after its event queue empties"
7487        );
7488    }
7489
7490    #[test]
7491    fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
7492        let admission = SubcLifecycleAdmission::default();
7493        let generation = Arc::new(AtomicU64::new(7));
7494        admission.mark_unbound(&generation);
7495        let expected = generation.load(Ordering::SeqCst);
7496        let starts = Arc::new(AtomicUsize::new(0));
7497
7498        let workers = (0..16)
7499            .map(|_| {
7500                let admission = admission.clone();
7501                let generation = Arc::clone(&generation);
7502                let starts = Arc::clone(&starts);
7503                std::thread::spawn(move || {
7504                    admission.run_if_current(&generation, expected, || {
7505                        starts.fetch_add(1, Ordering::SeqCst);
7506                    })
7507                })
7508            })
7509            .collect::<Vec<_>>();
7510
7511        for worker in workers {
7512            assert!(worker.join().unwrap().is_none());
7513        }
7514        assert_eq!(starts.load(Ordering::SeqCst), 0);
7515    }
7516}
7517
7518#[cfg(test)]
7519mod force_restrict_tests {
7520    use super::*;
7521    use crate::language::StubProvider;
7522    use tempfile::TempDir;
7523
7524    fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
7525        AppContext::new(
7526            Box::new(StubProvider),
7527            Config {
7528                project_root,
7529                restrict_to_project_root,
7530                ..Config::default()
7531            },
7532        )
7533    }
7534
7535    #[test]
7536    fn standalone_validate_path_parity_without_force_restrict() {
7537        let root = TempDir::new().expect("root tempdir");
7538        let outside = TempDir::new().expect("outside tempdir");
7539        let outside_path = outside.path().join("outside.txt");
7540
7541        let unrestricted = test_context(Some(root.path().to_path_buf()), false);
7542        assert_eq!(
7543            unrestricted
7544                .validate_path("standalone-unrestricted", &outside_path)
7545                .expect("unrestricted standalone validates"),
7546            outside_path
7547        );
7548
7549        let restricted = test_context(Some(root.path().to_path_buf()), true);
7550        let err = restricted
7551            .validate_path("standalone-restricted", &outside_path)
7552            .expect_err("restricted standalone rejects outside root");
7553        assert_eq!(
7554            serde_json::to_value(err).unwrap()["code"],
7555            "path_outside_root"
7556        );
7557    }
7558
7559    #[test]
7560    fn path_restriction_root_memo_canonicalizes_once_for_1000_validations() {
7561        let root = TempDir::new().expect("root tempdir");
7562        let target = root.path().join("target.txt");
7563        std::fs::write(&target, "inside").expect("write target");
7564        let ctx = test_context(Some(root.path().to_path_buf()), true);
7565
7566        for request in 0..1_000 {
7567            let validated = ctx
7568                .validate_path(&format!("memo-{request}"), &target)
7569                .expect("in-root path validates");
7570            assert_eq!(validated, std::fs::canonicalize(&target).unwrap());
7571        }
7572
7573        assert_eq!(
7574            ctx.path_restriction_root_canonicalizations_for_test(),
7575            1,
7576            "the configured root should be canonicalized once instead of once per validation"
7577        );
7578    }
7579
7580    #[cfg(unix)]
7581    #[test]
7582    fn path_restriction_root_memo_recanonicalizes_after_cached_target_disappears() {
7583        let workspace = TempDir::new().expect("workspace tempdir");
7584        let first_target = workspace.path().join("first-target");
7585        let second_target = workspace.path().join("second-target");
7586        let configured_root = workspace.path().join("configured-root");
7587        std::fs::create_dir_all(&first_target).expect("create first target");
7588        std::fs::create_dir_all(&second_target).expect("create second target");
7589        std::os::unix::fs::symlink(&first_target, &configured_root)
7590            .expect("create configured-root symlink");
7591        std::fs::write(first_target.join("inside.txt"), "first").expect("write first target");
7592
7593        let ctx = test_context(Some(configured_root.clone()), true);
7594        assert_eq!(
7595            ctx.validate_path("first-target", Path::new("inside.txt"))
7596                .expect("first target validates"),
7597            std::fs::canonicalize(first_target.join("inside.txt")).unwrap()
7598        );
7599
7600        // Keep the configured PathBuf unchanged while replacing its resolved
7601        // target. The missing cached target must cause a new canonicalization.
7602        std::fs::remove_dir_all(&first_target).expect("remove first target");
7603        std::fs::remove_file(&configured_root).expect("remove old root symlink");
7604        std::os::unix::fs::symlink(&second_target, &configured_root)
7605            .expect("recreate configured-root symlink");
7606        std::fs::write(second_target.join("inside.txt"), "second").expect("write second target");
7607
7608        assert_eq!(
7609            ctx.validate_path("second-target", Path::new("inside.txt"))
7610                .expect("second target validates"),
7611            std::fs::canonicalize(second_target.join("inside.txt")).unwrap()
7612        );
7613        assert_eq!(ctx.path_restriction_root_canonicalizations_for_test(), 2);
7614    }
7615
7616    #[test]
7617    fn force_restrict_guard_refcounts_duplicate_request_ids() {
7618        let root = TempDir::new().expect("root tempdir");
7619        let outside = TempDir::new().expect("outside tempdir");
7620        let outside_path = outside.path().join("outside.txt");
7621        let ctx = test_context(Some(root.path().to_path_buf()), false);
7622
7623        assert!(ctx.validate_path("dup", &outside_path).is_ok());
7624        let guard1 = ctx.force_restrict_guard("dup");
7625        let guard2 = ctx.force_restrict_guard("dup");
7626        assert!(ctx.validate_path("dup", &outside_path).is_err());
7627        drop(guard1);
7628        assert!(
7629            ctx.validate_path("dup", &outside_path).is_err(),
7630            "duplicate guard must keep the request over-restricted"
7631        );
7632        drop(guard2);
7633        assert!(ctx.validate_path("dup", &outside_path).is_ok());
7634    }
7635
7636    #[test]
7637    fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
7638        let root = TempDir::new().expect("root tempdir");
7639        let outside = TempDir::new().expect("outside tempdir");
7640        let outside_path = outside.path().join("outside.txt");
7641        let ctx = test_context(Some(root.path().to_path_buf()), false);
7642
7643        ctx.with_force_restrict("normal", || {
7644            assert!(ctx.validate_path("normal", &outside_path).is_err());
7645        });
7646        assert!(!ctx.request_force_restrict("normal"));
7647        assert!(ctx.validate_path("normal", &outside_path).is_ok());
7648
7649        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7650            ctx.with_force_restrict("panic", || {
7651                assert!(ctx.validate_path("panic", &outside_path).is_err());
7652                panic!("intentional force-restrict cleanup panic");
7653            });
7654        }));
7655        assert!(panicked.is_err());
7656        assert!(!ctx.request_force_restrict("panic"));
7657        assert!(ctx.validate_path("panic", &outside_path).is_ok());
7658    }
7659
7660    #[cfg(unix)]
7661    #[test]
7662    fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
7663        let root = TempDir::new().expect("root tempdir");
7664        let outside = tempfile::NamedTempFile::new().expect("outside file");
7665        let link = root.path().join("file.txt");
7666        std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
7667        let ctx = test_context(Some(root.path().to_path_buf()), false);
7668        let _guard = ctx.force_restrict_guard("write-location-final-link");
7669
7670        let validated = ctx
7671            .validate_write_location("write-location-final-link", &link)
7672            .expect("the in-root link location is writable");
7673
7674        assert_eq!(
7675            validated,
7676            std::fs::canonicalize(root.path()).unwrap().join("file.txt")
7677        );
7678    }
7679
7680    #[cfg(unix)]
7681    #[test]
7682    fn validate_write_location_rejects_symlinked_parent_escape() {
7683        let root = TempDir::new().expect("root tempdir");
7684        let outside = TempDir::new().expect("outside tempdir");
7685        let linked_parent = root.path().join("linked-parent");
7686        std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
7687        let candidate = linked_parent.join("file.txt");
7688        let ctx = test_context(Some(root.path().to_path_buf()), false);
7689        let _guard = ctx.force_restrict_guard("write-location-parent-link");
7690
7691        let error = ctx
7692            .validate_write_location("write-location-parent-link", &candidate)
7693            .expect_err("a symlinked parent must not escape the project root");
7694
7695        assert_eq!(
7696            serde_json::to_value(error).unwrap()["code"],
7697            "path_outside_root"
7698        );
7699    }
7700
7701    #[cfg(unix)]
7702    #[test]
7703    fn validate_write_location_rejects_outside_link_to_inside_file() {
7704        let root = TempDir::new().expect("root tempdir");
7705        let outside = TempDir::new().expect("outside tempdir");
7706        let inside = root.path().join("inside.txt");
7707        std::fs::write(&inside, "inside").unwrap();
7708        let outside_link = outside.path().join("outside-link.txt");
7709        std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
7710        let ctx = test_context(Some(root.path().to_path_buf()), false);
7711        let _guard = ctx.force_restrict_guard("write-location-outside-link");
7712
7713        let error = ctx
7714            .validate_write_location("write-location-outside-link", &outside_link)
7715            .expect_err("an out-of-root lexical location must remain blocked");
7716
7717        assert_eq!(
7718            serde_json::to_value(error).unwrap()["code"],
7719            "path_outside_root"
7720        );
7721    }
7722
7723    #[test]
7724    fn forced_restrict_without_project_root_fails_closed() {
7725        let ctx = test_context(None, false);
7726        let _guard = ctx.force_restrict_guard("missing-root");
7727        let err = ctx
7728            .validate_path("missing-root", Path::new("relative.txt"))
7729            .expect_err("forced restriction without a root must fail closed");
7730        assert_eq!(
7731            serde_json::to_value(err).unwrap()["code"],
7732            "path_outside_root"
7733        );
7734
7735        let write_err = ctx
7736            .validate_write_location("missing-root", Path::new("relative.txt"))
7737            .expect_err("write-location validation must also fail closed");
7738        assert_eq!(
7739            serde_json::to_value(write_err).unwrap()["code"],
7740            "path_outside_root"
7741        );
7742    }
7743}
7744
7745#[cfg(test)]
7746mod callgraph_store_for_ops_tests {
7747    use super::*;
7748    use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
7749    use crate::parser::TreeSitterProvider;
7750    use crate::protocol::RawRequest;
7751    use serde_json::json;
7752    use std::path::Path;
7753    use std::sync::Barrier;
7754    use tempfile::TempDir;
7755
7756    fn callgraph_build_wait_ms(ms: u64) -> super::CallgraphBuildWaitMsGuard {
7757        super::override_callgraph_build_wait_ms_for_test(ms)
7758    }
7759
7760    fn force_async_callgraph_builds() -> super::CallgraphBuildWaitMsGuard {
7761        callgraph_build_wait_ms(0)
7762    }
7763
7764    fn cold_build_context() -> Arc<AppContext> {
7765        let project = TempDir::new().expect("project tempdir");
7766        let storage = TempDir::new().expect("storage tempdir");
7767        let source_dir = project.path().join("src");
7768        std::fs::create_dir_all(&source_dir).expect("source dir");
7769        std::fs::write(
7770            source_dir.join("lib.rs"),
7771            "pub fn caller() { callee(); }\npub fn callee() {}\n",
7772        )
7773        .expect("source file");
7774
7775        Arc::new(AppContext::new(
7776            Box::new(TreeSitterProvider::new()),
7777            Config {
7778                project_root: Some(project.keep()),
7779                storage_dir: Some(storage.keep()),
7780                callgraph_chunk_size: 1,
7781                ..Config::default()
7782            },
7783        ))
7784    }
7785
7786    fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
7787        let _guard = crate::test_env::process_env_lock();
7788        let prev_home = std::env::var_os("HOME");
7789        let prev_userprofile = std::env::var_os("USERPROFILE");
7790        unsafe {
7791            std::env::set_var("HOME", home);
7792            std::env::set_var("USERPROFILE", home);
7793        }
7794        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
7795        unsafe {
7796            match prev_home {
7797                Some(value) => std::env::set_var("HOME", value),
7798                None => std::env::remove_var("HOME"),
7799            }
7800            match prev_userprofile {
7801                Some(value) => std::env::set_var("USERPROFILE", value),
7802                None => std::env::remove_var("USERPROFILE"),
7803            }
7804        }
7805        match result {
7806            Ok(value) => value,
7807            Err(payload) => std::panic::resume_unwind(payload),
7808        }
7809    }
7810
7811    fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
7812        RawRequest {
7813            id: "cfg".to_string(),
7814            command: "configure".to_string(),
7815            lsp_hints: None,
7816            session_id: None,
7817            params,
7818        }
7819    }
7820
7821    fn user_tier(doc: serde_json::Value) -> serde_json::Value {
7822        json!({
7823            "tier": "user",
7824            "source": "/u/aft.jsonc",
7825            "doc": doc.to_string(),
7826        })
7827    }
7828
7829    fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
7830        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7831        let response = crate::commands::configure::handle_configure(
7832            &configure_request_with_params(json!({
7833                "project_root": project_root,
7834                "harness": "opencode",
7835                "storage_dir": storage_dir,
7836                "config": [user_tier(json!({
7837                    "callgraph_store": true,
7838                    "search_index": true,
7839                    "semantic_search": true,
7840                }))],
7841            })),
7842            &ctx,
7843        );
7844        assert!(response.success, "configure should succeed: {response:?}");
7845        ctx
7846    }
7847
7848    fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
7849        InspectSnapshot::new(
7850            ctx.canonical_cache_root(),
7851            ctx.inspect_dir(),
7852            ctx.config(),
7853            ctx.symbol_cache(),
7854        )
7855    }
7856
7857    fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
7858        let project_root = ctx
7859            .config()
7860            .project_root
7861            .clone()
7862            .expect("test context has a project root");
7863        let files: Vec<PathBuf> = Vec::new();
7864        let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
7865        SemanticIndex::build(&project_root, &files, &mut embed, 1)
7866            .expect("empty semantic index should build")
7867    }
7868
7869    #[test]
7870    fn home_root_gate_blocks_callgraph_store_entry_points() {
7871        let _wait_guard = force_async_callgraph_builds();
7872        let home = TempDir::new().expect("home tempdir");
7873        let storage = TempDir::new().expect("storage tempdir");
7874        let source_dir = home.path().join("src");
7875        std::fs::create_dir_all(&source_dir).expect("source dir");
7876        std::fs::write(
7877            source_dir.join("lib.rs"),
7878            "pub fn caller() { callee(); }\npub fn callee() {}\n",
7879        )
7880        .expect("source file");
7881
7882        with_fake_home_env(home.path(), || {
7883            let ctx = configure_context(home.path(), storage.path());
7884            assert!(
7885                !ctx.heavy_root_work_allowed(),
7886                "HOME root configure must close the heavy-root-work gate"
7887            );
7888            assert!(
7889                !ctx.config().callgraph_store,
7890                "HOME root configure must force-disable the callgraph store"
7891            );
7892            assert!(ctx.is_home_root());
7893            assert!(ctx
7894                .degraded_reasons()
7895                .iter()
7896                .any(|reason| reason == "home_root"));
7897            let status_request = RawRequest {
7898                id: "home-status".to_string(),
7899                command: "status".to_string(),
7900                lsp_hints: None,
7901                session_id: None,
7902                params: json!({}),
7903            };
7904            let status = crate::commands::status::handle_status(&status_request, &ctx);
7905            assert_eq!(status.data["features"]["callgraph_store"], false);
7906            crate::commands::configure::drain_deferred_configure_maintenance(&ctx);
7907            assert!(
7908                ctx.callgraph_store_rx().lock().is_none(),
7909                "HOME root maintenance must not schedule a callgraph build"
7910            );
7911            assert_eq!(
7912                ctx.try_health_snapshot(home.path())
7913                    .callgraph_store
7914                    .as_ref()
7915                    .map(|component| component.status),
7916                Some("disabled"),
7917                "HOME root health must not advertise callgraph building"
7918            );
7919
7920            reset_callgraph_cold_build_spawn_count_for_test();
7921            assert!(matches!(
7922                ctx.callgraph_store_for_ops(),
7923                CallgraphStoreAccess::Unavailable
7924            ));
7925            assert!(
7926                ctx.ensure_callgraph_store()
7927                    .expect("ensure_callgraph_store should not error")
7928                    .is_none(),
7929                "shared gate must also block synchronous standalone callgraph builds"
7930            );
7931            assert_eq!(
7932                callgraph_cold_build_spawn_count_for_test(),
7933                0,
7934                "HOME root gate must not spawn a cold callgraph build"
7935            );
7936
7937            let navigation = RawRequest {
7938                id: "home-callers".to_string(),
7939                command: "callers".to_string(),
7940                lsp_hints: None,
7941                session_id: None,
7942                params: json!({
7943                    "file": source_dir.join("lib.rs"),
7944                    "symbol": "caller",
7945                }),
7946            };
7947            let response = crate::commands::callers::handle_callers(&navigation, &ctx);
7948            assert!(!response.success);
7949            assert_eq!(response.data["code"], "callgraph_disabled");
7950            assert_eq!(response.data["status"], "disabled");
7951            assert_eq!(response.data["reason"], "home_root");
7952            assert!(response.data["message"]
7953                .as_str()
7954                .is_some_and(|message| message.contains("disabled for home roots")));
7955        });
7956    }
7957
7958    #[test]
7959    fn home_root_gate_blocks_inspect_manager_submit_paths() {
7960        let home = TempDir::new().expect("home tempdir");
7961        let storage = TempDir::new().expect("storage tempdir");
7962        let source_dir = home.path().join("src");
7963        std::fs::create_dir_all(&source_dir).expect("source dir");
7964        std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
7965
7966        with_fake_home_env(home.path(), || {
7967            let ctx = configure_context(home.path(), storage.path());
7968            let snapshot = inspect_snapshot(&ctx);
7969            let scope = JobScope::for_project(snapshot.project_root.clone());
7970            let manager = ctx.inspect_manager();
7971
7972            assert!(matches!(
7973                manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
7974                JobOutcome::Failed { .. }
7975            ));
7976
7977            let submission = manager.submit_tier2_run_with_reuse_serial_background(
7978                snapshot,
7979                vec![InspectCategory::DeadCode],
7980            );
7981            assert!(submission.queued_categories.is_empty());
7982            assert!(submission.newly_queued_categories.is_empty());
7983            assert!(submission.deferred_categories.is_empty());
7984            assert_eq!(submission.errors.len(), 1);
7985            assert!(
7986                !manager.tier2_any_in_flight(),
7987                "HOME root gate must reject Tier-2 submission before any job is queued"
7988            );
7989        });
7990    }
7991
7992    #[test]
7993    fn non_home_root_still_allows_callgraph_cold_builds() {
7994        let _env_guard = force_async_callgraph_builds();
7995        reset_callgraph_cold_build_spawn_count_for_test();
7996        let ctx = cold_build_context();
7997
7998        assert!(ctx.heavy_root_work_allowed());
7999        assert!(matches!(
8000            ctx.callgraph_store_for_ops(),
8001            CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8002        ));
8003        assert_eq!(
8004            callgraph_cold_build_spawn_count_for_test(),
8005            1,
8006            "non-home roots must still be able to cold-build the callgraph store"
8007        );
8008
8009        let rx = ctx
8010            .callgraph_store_rx
8011            .lock()
8012            .as_ref()
8013            .cloned()
8014            .expect("non-home cold build should install an in-flight receiver");
8015        rx.recv_timeout(Duration::from_secs(30))
8016            .expect("background cold build should complete");
8017        *ctx.callgraph_store_rx.lock() = None;
8018    }
8019
8020    #[test]
8021    fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
8022        let _env_guard = force_async_callgraph_builds();
8023        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8024        let ctx = cold_build_context();
8025        let (tx, rx) = crossbeam_channel::unbounded();
8026        *ctx.semantic_index_rx().lock() = Some(rx);
8027        ctx.schedule_semantic_cold_seed_gate_for_configure();
8028
8029        assert!(matches!(
8030            ctx.callgraph_store_for_ops(),
8031            CallgraphStoreAccess::Building
8032        ));
8033        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
8034        tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
8035            &ctx,
8036        )))
8037        .expect("send ready event");
8038
8039        crate::runtime_drain::drain_semantic_index_events(&ctx);
8040
8041        assert!(
8042            !ctx.semantic_cold_seed_active(),
8043            "semantic Ready must clear the scheduled cold gate"
8044        );
8045        assert!(
8046            ctx.tier2_pull_demand_pending(),
8047            "semantic Ready must resume deferred Tier-2 work"
8048        );
8049        assert_eq!(
8050            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8051            1,
8052            "semantic Ready must resume the deferred callgraph warm"
8053        );
8054        let rx = ctx
8055            .callgraph_store_rx
8056            .lock()
8057            .as_ref()
8058            .cloned()
8059            .expect("ready resume should install an in-flight callgraph receiver");
8060        rx.recv_timeout(Duration::from_secs(30))
8061            .expect("background cold build should complete");
8062        *ctx.callgraph_store_rx.lock() = None;
8063    }
8064
8065    #[test]
8066    fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
8067        let _env_guard = force_async_callgraph_builds();
8068        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8069        let ctx = cold_build_context();
8070        ctx.schedule_semantic_cold_seed_gate_for_configure();
8071
8072        assert!(matches!(
8073            ctx.callgraph_store_for_ops(),
8074            CallgraphStoreAccess::Building
8075        ));
8076        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
8077        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
8078
8079        assert!(
8080            !ctx.semantic_cold_seed_active(),
8081            "cached-load or retry-wait clear must reopen the semantic cold gate"
8082        );
8083        assert!(
8084            ctx.tier2_pull_demand_pending(),
8085            "cached-load or retry-wait clear must resume deferred Tier-2 work"
8086        );
8087        assert_eq!(
8088            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8089            1,
8090            "cached-load or retry-wait clear must resume deferred callgraph warm"
8091        );
8092        let rx = ctx
8093            .callgraph_store_rx
8094            .lock()
8095            .as_ref()
8096            .cloned()
8097            .expect("gate-clear resume should install an in-flight callgraph receiver");
8098        rx.recv_timeout(Duration::from_secs(30))
8099            .expect("background cold build should complete");
8100        *ctx.callgraph_store_rx.lock() = None;
8101    }
8102
8103    #[test]
8104    fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
8105        let _env_guard = force_async_callgraph_builds();
8106        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8107        let ctx = cold_build_context();
8108
8109        ctx.set_semantic_cold_seed_active_for_test(true);
8110        assert!(
8111            matches!(
8112                ctx.callgraph_store_for_ops(),
8113                CallgraphStoreAccess::Building
8114            ),
8115            "callgraph ops should degrade as building while the semantic cold gate is active"
8116        );
8117        assert_eq!(
8118            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8119            0,
8120            "semantic cold gate must not spawn a competing callgraph cold build"
8121        );
8122        assert!(ctx.semantic_callgraph_warm_deferred_for_test());
8123
8124        ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
8125        assert_eq!(
8126            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8127            1,
8128            "clearing the semantic cold gate should resume the deferred callgraph warm"
8129        );
8130
8131        let rx = ctx
8132            .callgraph_store_rx
8133            .lock()
8134            .as_ref()
8135            .cloned()
8136            .expect("deferred warm should install an in-flight receiver");
8137        rx.recv_timeout(Duration::from_secs(30))
8138            .expect("background cold build should complete");
8139        *ctx.callgraph_store_rx.lock() = None;
8140    }
8141
8142    #[test]
8143    fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
8144        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8145        ctx.schedule_semantic_cold_seed_gate_for_configure();
8146
8147        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
8148
8149        assert!(
8150            !ctx.semantic_cold_seed_active(),
8151            "retry-wait or cached-load events must reopen the semantic cold gate"
8152        );
8153        assert!(
8154            ctx.tier2_pull_demand_pending(),
8155            "clearing the semantic cold gate should kick a Tier-2 pull refresh"
8156        );
8157    }
8158
8159    #[test]
8160    fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
8161        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8162        let (tx, rx) = crossbeam_channel::unbounded();
8163        *ctx.semantic_index_rx().lock() = Some(rx);
8164        ctx.schedule_semantic_cold_seed_gate_for_configure();
8165        tx.send(SemanticIndexEvent::Failed(
8166            "embedding backend failed".to_string(),
8167        ))
8168        .expect("send failed event");
8169
8170        crate::runtime_drain::drain_semantic_index_events(&ctx);
8171
8172        assert!(
8173            !ctx.semantic_cold_seed_active(),
8174            "semantic Failed must clear the scheduled cold gate"
8175        );
8176        assert!(
8177            ctx.tier2_pull_demand_pending(),
8178            "semantic Failed must resume deferred Tier-2 work"
8179        );
8180    }
8181
8182    #[test]
8183    fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
8184        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8185        let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
8186        *ctx.semantic_index_rx().lock() = Some(rx);
8187        ctx.schedule_semantic_cold_seed_gate_for_configure();
8188        drop(tx);
8189
8190        crate::runtime_drain::drain_semantic_index_events(&ctx);
8191
8192        assert!(
8193            !ctx.semantic_cold_seed_active(),
8194            "semantic worker disconnect must clear the scheduled cold gate"
8195        );
8196        assert!(
8197            ctx.tier2_pull_demand_pending(),
8198            "semantic worker disconnect must resume deferred Tier-2 work"
8199        );
8200    }
8201
8202    #[test]
8203    fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
8204        let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8205        let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8206        let base = Instant::now();
8207        ctx_a.reset_tier2_refresh_scheduler_at(base);
8208        ctx_b.reset_tier2_refresh_scheduler_at(base);
8209        ctx_a.set_semantic_cold_seed_active_for_test(true);
8210
8211        assert_eq!(
8212            ctx_a.tick_tier2_refresh_scheduler_at(
8213                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
8214                0,
8215            ),
8216            None,
8217            "root A should defer Tier-2 while its semantic cold seed is active"
8218        );
8219        assert_eq!(
8220            ctx_b.tick_tier2_refresh_scheduler_at(
8221                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
8222                0,
8223            ),
8224            Some(Tier2TriggerReason::ConfigureWarm),
8225            "root B must not inherit root A's semantic cold gate"
8226        );
8227    }
8228
8229    #[test]
8230    fn query_wait_joins_callgraph_build_scheduled_without_wait() {
8231        let _env_guard = callgraph_build_wait_ms(10_000);
8232        let project = TempDir::new().expect("project tempdir");
8233        let storage = TempDir::new().expect("storage tempdir");
8234        std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8235        let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
8236        let project_key = crate::search_index::artifact_cache_key(&project_root);
8237        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
8238        let ctx = Arc::new(AppContext::new(
8239            Box::new(TreeSitterProvider::new()),
8240            Config {
8241                project_root: Some(project_root.clone()),
8242                storage_dir: Some(storage.path().to_path_buf()),
8243                callgraph_chunk_size: 1,
8244                ..Config::default()
8245            },
8246        ));
8247        let (reached, release) = install_callgraph_build_start_gate(project_root);
8248
8249        assert!(matches!(
8250            ctx.schedule_callgraph_store_warm(),
8251            CallgraphStoreAccess::Building
8252        ));
8253        reached
8254            .recv_timeout(Duration::from_secs(2))
8255            .expect("scheduled callgraph worker did not reach start barrier");
8256
8257        let (result_tx, result_rx) = std::sync::mpsc::channel();
8258        let query_ctx = Arc::clone(&ctx);
8259        let query = std::thread::spawn(move || {
8260            result_tx
8261                .send(query_ctx.callgraph_store_for_ops())
8262                .expect("send query result");
8263        });
8264        assert!(
8265            matches!(
8266                result_rx.recv_timeout(Duration::from_millis(100)),
8267                Err(std::sync::mpsc::RecvTimeoutError::Timeout)
8268            ),
8269            "query returned while the scheduled callgraph build was still in flight"
8270        );
8271
8272        release.send(()).expect("release callgraph worker");
8273        assert!(matches!(
8274            result_rx
8275                .recv_timeout(Duration::from_secs(10))
8276                .expect("query did not settle after the callgraph build completed"),
8277            CallgraphStoreAccess::Ready(_)
8278        ));
8279        query.join().expect("callgraph query thread");
8280    }
8281
8282    #[test]
8283    fn inline_wait_settled_event_clears_superseded_receiver() {
8284        let _env_guard = callgraph_build_wait_ms(2_000);
8285        let project = TempDir::new().expect("project tempdir");
8286        let storage = TempDir::new().expect("storage tempdir");
8287        std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8288        let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
8289        let ctx = Arc::new(AppContext::new(
8290            Box::new(TreeSitterProvider::new()),
8291            Config {
8292                project_root: Some(project.path().to_path_buf()),
8293                storage_dir: Some(storage.path().to_path_buf()),
8294                callgraph_chunk_size: 1,
8295                ..Config::default()
8296            },
8297        ));
8298        let (reached, release) = install_callgraph_build_start_gate(project_root);
8299        let request_ctx = Arc::clone(&ctx);
8300        let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
8301        reached
8302            .recv_timeout(Duration::from_secs(2))
8303            .expect("callgraph worker did not reach start barrier");
8304
8305        ctx.next_callgraph_persist_epoch();
8306        release.send(()).unwrap();
8307        assert!(matches!(
8308            request.join().expect("callgraph request thread"),
8309            CallgraphStoreAccess::Building
8310        ));
8311        assert!(
8312            ctx.callgraph_store_rx().lock().is_none(),
8313            "inline Settled handling must retire the matching receiver"
8314        );
8315        assert!(
8316            ctx.callgraph_store()
8317                .read()
8318                .unwrap_or_else(std::sync::PoisonError::into_inner)
8319                .is_none(),
8320            "Settled must not reopen and install an older persisted store"
8321        );
8322    }
8323
8324    #[test]
8325    fn pointer_removal_arm_is_scoped_to_its_callgraph_pointer() {
8326        let temp = TempDir::new().expect("pointer tempdir");
8327        let target = temp.path().join("target.current");
8328        let unrelated = temp.path().join("unrelated.current");
8329        std::fs::write(&target, "target-generation\n").expect("target pointer");
8330        std::fs::write(&unrelated, "unrelated-generation\n").expect("unrelated pointer");
8331        let _arm = install_callgraph_pointer_removal_arm(target.clone());
8332
8333        // The test hook must remove only its target pointer. Completing another
8334        // callgraph build must leave that pointer and the target hook intact.
8335        remove_armed_callgraph_pointer_for_test(&unrelated);
8336        assert!(
8337            unrelated.exists(),
8338            "unrelated pointer must remain published"
8339        );
8340        assert!(target.exists(), "target arm must remain pending");
8341
8342        remove_armed_callgraph_pointer_for_test(&target);
8343        assert!(!target.exists(), "target pointer should consume its arm");
8344        assert!(
8345            unrelated.exists(),
8346            "unrelated pointer must remain published"
8347        );
8348    }
8349
8350    #[test]
8351    fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
8352        let _env_guard = callgraph_build_wait_ms(2_000);
8353        let project = TempDir::new().expect("project tempdir");
8354        let storage = TempDir::new().expect("storage tempdir");
8355        std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8356        let ctx = AppContext::new(
8357            Box::new(TreeSitterProvider::new()),
8358            Config {
8359                project_root: Some(project.path().to_path_buf()),
8360                storage_dir: Some(storage.path().to_path_buf()),
8361                callgraph_chunk_size: 1,
8362                ..Config::default()
8363            },
8364        );
8365        let project_key = crate::search_index::artifact_cache_key(project.path());
8366        crate::root_cache::configure_artifact_access(project.path(), &project_key, false);
8367        let pending = project.path().join("pending.rs");
8368        ctx.add_pending_callgraph_store_paths([pending.clone()]);
8369        let pointer = ctx
8370            .callgraph_store_dir()
8371            .join(format!("{project_key}.current"));
8372        let _remove_pointer_guard = install_callgraph_pointer_removal_arm(pointer);
8373
8374        assert!(matches!(
8375            ctx.callgraph_store_for_ops(),
8376            CallgraphStoreAccess::Building
8377        ));
8378        assert!(
8379            ctx.callgraph_store_rx().lock().is_none(),
8380            "inline Ready must settle after the published pointer disappears"
8381        );
8382        assert_eq!(
8383            ctx.take_pending_callgraph_store_paths(),
8384            vec![pending],
8385            "inline reopen failure must preserve pending watcher paths"
8386        );
8387    }
8388
8389    #[test]
8390    fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
8391        let project = TempDir::new().expect("project tempdir");
8392        let foreign = TempDir::new().expect("foreign tempdir");
8393        let ctx = AppContext::new(
8394            Box::new(TreeSitterProvider::new()),
8395            Config {
8396                project_root: Some(project.path().to_path_buf()),
8397                ..Config::default()
8398            },
8399        );
8400        let inside = project.path().join("kept.rs");
8401        // A late-deferring batch from a superseded root writes into the shared
8402        // pending sink; replaying it into the NEW root's store would index a
8403        // foreign project's files.
8404        let outside = foreign.path().join("previous-root-file.rs");
8405        // Lexical escape: starts_with(project) is true on the raw spelling but
8406        // the path resolves outside the root.
8407        let dotdot_escape = project
8408            .path()
8409            .join("..")
8410            .join(
8411                foreign
8412                    .path()
8413                    .file_name()
8414                    .expect("foreign tempdir has a name"),
8415            )
8416            .join("escaped.rs");
8417        ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
8418
8419        assert_eq!(
8420            ctx.take_pending_callgraph_store_paths(),
8421            vec![inside],
8422            "pending replay must drop foreign and dot-dot-escaping paths"
8423        );
8424    }
8425
8426    #[test]
8427    fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
8428        let project = TempDir::new().expect("project tempdir");
8429        let ctx = AppContext::new(
8430            Box::new(TreeSitterProvider::new()),
8431            Config {
8432                project_root: Some(project.path().to_path_buf()),
8433                semantic_search: true,
8434                ..Config::default()
8435            },
8436        );
8437        ctx.set_canonical_cache_root(project.path().to_path_buf());
8438        // Read-only root: a force token could only be fulfilled by a local
8439        // writer build, which this root will never run.
8440        ctx.set_cache_writer_capabilities(false, true);
8441        *ctx.semantic_index_status()
8442            .write()
8443            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8444
8445        ctx.invalidate_artifacts_after_watcher_gap();
8446
8447        assert!(
8448            matches!(
8449                &*ctx
8450                    .semantic_index_status()
8451                    .read()
8452                    .unwrap_or_else(std::sync::PoisonError::into_inner),
8453                SemanticIndexStatus::Ready { .. }
8454            ),
8455            "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
8456        );
8457        assert_eq!(
8458            ctx.pending_callgraph_store_force_token(),
8459            None,
8460            "read-only root must not be stuck behind an unfulfillable force token"
8461        );
8462    }
8463
8464    #[test]
8465    fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
8466        let project = TempDir::new().expect("project tempdir");
8467        let ctx = AppContext::new(
8468            Box::new(TreeSitterProvider::new()),
8469            Config {
8470                project_root: Some(project.path().to_path_buf()),
8471                ..Config::default()
8472            },
8473        );
8474        ctx.set_canonical_cache_root(project.path().to_path_buf());
8475        ctx.set_cache_writer_capabilities(true, true);
8476
8477        ctx.invalidate_artifacts_after_watcher_gap();
8478
8479        assert!(
8480            ctx.pending_callgraph_store_force_token().is_some(),
8481            "writer roots must still reconcile the store after the unobserved interval"
8482        );
8483        assert!(
8484            matches!(
8485                &*ctx
8486                    .semantic_index_status()
8487                    .read()
8488                    .unwrap_or_else(std::sync::PoisonError::into_inner),
8489                SemanticIndexStatus::Disabled
8490            ),
8491            "semantic-disabled config maps to Disabled status"
8492        );
8493    }
8494
8495    #[cfg(unix)]
8496    #[test]
8497    fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
8498        let project = TempDir::new().expect("project tempdir");
8499        let foreign = TempDir::new().expect("foreign tempdir");
8500        std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
8501        std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
8502        let ctx = AppContext::new(
8503            Box::new(TreeSitterProvider::new()),
8504            Config {
8505                project_root: Some(project.path().to_path_buf()),
8506                ..Config::default()
8507            },
8508        );
8509        // `root/link` targets a foreign directory; `root/link/../secret.rs`
8510        // therefore resolves to `foreign/secret.rs` under filesystem-first
8511        // semantics (matching the store's normalize_file_path). A lexical-first
8512        // filter would erase `link/..` and wrongly keep it as `root/secret.rs`.
8513        std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
8514            .expect("plant symlink");
8515        let escape = project.path().join("link").join("..").join("secret.rs");
8516        // Dead component below the symlink: full canonicalization fails, so
8517        // the ancestor walk must reach and resolve `link` BEFORE any lexical
8518        // `..` resolution — a lexical-first pass would erase `dead/../..` and
8519        // wrongly keep this as `root/deep-secret.rs`.
8520        let dead_component_escape = project
8521            .path()
8522            .join("link")
8523            .join("dead")
8524            .join("..")
8525            .join("..")
8526            .join("deep-secret.rs");
8527        // Re-entry: `dead/..` drains back to the project root, then `link`
8528        // (an EXISTING symlink) must resolve through the filesystem — a
8529        // one-shot lexical pass over the dead tail would erase `link/..` too
8530        // and wrongly keep this as `root/reentry-secret.rs`.
8531        std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
8532            .expect("reentry secret");
8533        let reentry_escape = project
8534            .path()
8535            .join("dead")
8536            .join("..")
8537            .join("link")
8538            .join("..")
8539            .join("reentry-secret.rs");
8540        // Dangling symlink whose `..` re-enters the root: the store cannot
8541        // canonicalize it either and keeps the raw absolute spelling as an
8542        // out-of-root key, so containment must fail closed (a repaired-target
8543        // race could otherwise index outside the root).
8544        std::os::unix::fs::symlink(
8545            foreign.path().join("nonexistent-target"),
8546            project.path().join("dangling"),
8547        )
8548        .expect("plant dangling symlink");
8549        let dangling_reentry = project
8550            .path()
8551            .join("dangling")
8552            .join("..")
8553            .join("via-dangling.rs");
8554        // `..` traversal through a regular file: realpath rejects with
8555        // ENOTDIR; lexically popping the file would fabricate containment.
8556        std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
8557        let through_file = project
8558            .path()
8559            .join("plain.rs")
8560            .join("..")
8561            .join("via-file.rs");
8562        let kept = project.path().join("kept.rs");
8563        ctx.add_pending_callgraph_store_paths([
8564            escape,
8565            dead_component_escape,
8566            reentry_escape,
8567            dangling_reentry,
8568            through_file,
8569            kept.clone(),
8570        ]);
8571
8572        assert_eq!(
8573            ctx.take_pending_callgraph_store_paths(),
8574            vec![kept],
8575            "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
8576        );
8577    }
8578
8579    #[cfg(windows)]
8580    #[test]
8581    fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
8582        // Guard-sensitivity: exercise the classifier directly against a root
8583        // ON THE DRIVE CWD's drive, where join() replaces the root and the
8584        // joined path can genuinely resolve under the drive CWD — without the
8585        // early Prefix/RootDir rejection, a `C:file-under-cwd` spelling whose
8586        // drive CWD happens to sit inside the root would pass the post-join
8587        // prefix check.
8588        let cwd = std::env::current_dir().expect("drive cwd");
8589        let cwd_file = PathBuf::from(format!(
8590            "{}under-drive-cwd.rs",
8591            cwd.components()
8592                .next()
8593                .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
8594                .expect("drive prefix")
8595        ));
8596        assert!(cwd_file.is_relative(), "C:foo must classify as relative");
8597        assert!(
8598            !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
8599            "drive-relative spelling must be rejected even when the drive CWD is inside the root"
8600        );
8601        assert!(
8602            !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
8603            "root-relative spelling must be rejected"
8604        );
8605
8606        let project = TempDir::new().expect("project tempdir");
8607        let ctx = AppContext::new(
8608            Box::new(TreeSitterProvider::new()),
8609            Config {
8610                project_root: Some(project.path().to_path_buf()),
8611                ..Config::default()
8612            },
8613        );
8614        let kept = project.path().join("kept.rs");
8615        ctx.add_pending_callgraph_store_paths([
8616            PathBuf::from("C:drive-relative.rs"),
8617            PathBuf::from(r"\root-relative.rs"),
8618            kept.clone(),
8619        ]);
8620
8621        assert_eq!(
8622            ctx.take_pending_callgraph_store_paths(),
8623            vec![kept],
8624            "drive-relative and root-relative spellings must be rejected"
8625        );
8626    }
8627
8628    #[test]
8629    fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
8630        let project = TempDir::new().expect("project tempdir");
8631        let ctx = AppContext::new(
8632            Box::new(TreeSitterProvider::new()),
8633            Config {
8634                project_root: Some(project.path().to_path_buf()),
8635                ..Config::default()
8636            },
8637        );
8638        // Relative paths are project-root-relative by the callgraph store's
8639        // contract, and pending paths legitimately reference deleted files.
8640        let relative = PathBuf::from("src/relative.rs");
8641        let deleted = project.path().join("never-created.rs");
8642        ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
8643
8644        let mut taken = ctx.take_pending_callgraph_store_paths();
8645        taken.sort();
8646        let mut expected = vec![relative, deleted];
8647        expected.sort();
8648        assert_eq!(
8649            taken, expected,
8650            "root-relative and deleted in-root paths must survive the filter"
8651        );
8652    }
8653
8654    #[test]
8655    fn writer_denied_callgraph_build_is_terminal_not_building() {
8656        let _env_guard = callgraph_build_wait_ms(30_000);
8657        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8658
8659        let denied_ctx = cold_build_context();
8660        let denied_reason = match denied_ctx.callgraph_store_for_ops() {
8661            CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) => reason,
8662            CallgraphStoreAccess::Building => {
8663                panic!("writer-denied build must not remain in the retryable Building state")
8664            }
8665            _ => panic!("unregistered root must terminate with an unavailable reason"),
8666        };
8667        assert!(
8668            denied_reason.contains("could not acquire writer capability"),
8669            "terminal status must explain the writer-capability denial: {denied_reason}"
8670        );
8671        assert!(matches!(
8672            denied_ctx.callgraph_store_for_ops(),
8673            CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
8674                if reason.contains("could not acquire writer capability")
8675        ));
8676        assert_eq!(
8677            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8678            1,
8679            "polling a denied root must not spawn another doomed build"
8680        );
8681
8682        // Control case: granting the artifact-access capability installed by
8683        // `configure_artifact_access` should change this cold build from denied to ready.
8684        let writable_ctx = cold_build_context();
8685        let writable_root = writable_ctx
8686            .config()
8687            .project_root
8688            .clone()
8689            .expect("writable fixture root");
8690        let writable_key = crate::search_index::artifact_cache_key(&writable_root);
8691        crate::root_cache::configure_artifact_access(&writable_root, &writable_key, false);
8692        assert!(
8693            matches!(
8694                writable_ctx.callgraph_store_for_ops(),
8695                CallgraphStoreAccess::Ready(_)
8696            ),
8697            "removing the forced denial must change the terminal status"
8698        );
8699    }
8700
8701    #[test]
8702    fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
8703        let _env_guard = force_async_callgraph_builds();
8704        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8705
8706        let project = TempDir::new().expect("project tempdir");
8707        let storage = TempDir::new().expect("storage tempdir");
8708        let source_dir = project.path().join("src");
8709        std::fs::create_dir_all(&source_dir).expect("source dir");
8710        std::fs::write(
8711            source_dir.join("lib.rs"),
8712            "pub fn caller() { callee(); }\npub fn callee() {}\n",
8713        )
8714        .expect("source file");
8715
8716        let ctx = Arc::new(AppContext::new(
8717            Box::new(TreeSitterProvider::new()),
8718            Config {
8719                project_root: Some(project.path().to_path_buf()),
8720                storage_dir: Some(storage.path().to_path_buf()),
8721                callgraph_chunk_size: 1,
8722                ..Config::default()
8723            },
8724        ));
8725
8726        let barrier = Arc::new(Barrier::new(3));
8727        let handles = (0..2)
8728            .map(|_| {
8729                let ctx = Arc::clone(&ctx);
8730                let barrier = Arc::clone(&barrier);
8731                std::thread::spawn(move || {
8732                    barrier.wait();
8733                    matches!(
8734                        ctx.callgraph_store_for_ops(),
8735                        CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8736                    )
8737                })
8738            })
8739            .collect::<Vec<_>>();
8740
8741        barrier.wait();
8742        for handle in handles {
8743            assert!(
8744                handle.join().expect("callgraph caller thread"),
8745                "cold callgraph ops should report Building or observe the installed store"
8746            );
8747        }
8748
8749        assert_eq!(
8750            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8751            1,
8752            "concurrent cold callers must share one background build"
8753        );
8754
8755        let rx = ctx
8756            .callgraph_store_rx
8757            .lock()
8758            .as_ref()
8759            .cloned()
8760            .expect("in-flight receiver installed before spawn");
8761        rx.recv_timeout(Duration::from_secs(30))
8762            .expect("background cold build should complete");
8763        *ctx.callgraph_store_rx.lock() = None;
8764    }
8765
8766    #[test]
8767    fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
8768        let root = TempDir::new().expect("project tempdir");
8769        let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
8770        let ctx = AppContext::new(
8771            Box::new(TreeSitterProvider::new()),
8772            Config {
8773                project_root: Some(canonical_root.clone()),
8774                ..Config::default()
8775            },
8776        );
8777        *ctx.search_index
8778            .write()
8779            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8780            Some(SearchIndex::build(&canonical_root));
8781        *ctx.semantic_index
8782            .write()
8783            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8784            Some(SemanticIndex::new(canonical_root.clone(), 3));
8785        *ctx.semantic_index_status
8786            .write()
8787            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8788
8789        let artifact = canonical_root.join("verify-artifact.bin");
8790        std::fs::write(&artifact, b"same-size").expect("write verification artifact");
8791        let generation =
8792            crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
8793        crate::cache_freshness::record_verify_completed(
8794            &canonical_root,
8795            crate::cache_freshness::VerifyArtifact::Search,
8796            Some(generation),
8797        );
8798        assert_eq!(
8799            crate::cache_freshness::warm_verify_plan(
8800                &canonical_root,
8801                crate::cache_freshness::VerifyArtifact::Search,
8802                Some(generation),
8803            ),
8804            crate::cache_freshness::WarmVerifyPlan::Skip
8805        );
8806
8807        ctx.invalidate_artifacts_after_watcher_gap();
8808
8809        assert!(ctx
8810            .search_index
8811            .read()
8812            .unwrap_or_else(std::sync::PoisonError::into_inner)
8813            .is_none());
8814        assert!(ctx
8815            .semantic_index
8816            .read()
8817            .unwrap_or_else(std::sync::PoisonError::into_inner)
8818            .is_none());
8819        assert!(ctx.pending_callgraph_store_force_token().is_some());
8820        assert_eq!(
8821            crate::cache_freshness::warm_verify_plan(
8822                &canonical_root,
8823                crate::cache_freshness::VerifyArtifact::Search,
8824                Some(generation),
8825            ),
8826            crate::cache_freshness::WarmVerifyPlan::Strict
8827        );
8828    }
8829
8830    #[test]
8831    fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
8832        let root = TempDir::new().expect("project tempdir");
8833        let ctx = AppContext::new(
8834            Box::new(TreeSitterProvider::new()),
8835            Config {
8836                project_root: Some(root.path().to_path_buf()),
8837                semantic_search: true,
8838                ..Config::default()
8839            },
8840        );
8841        *ctx.semantic_index
8842            .write()
8843            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8844            Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8845        let refreshing_path = root.path().join("src/lib.rs");
8846        {
8847            let mut status = ctx
8848                .semantic_index_status
8849                .write()
8850                .unwrap_or_else(std::sync::PoisonError::into_inner);
8851            *status = SemanticIndexStatus::ready();
8852            status.start_refreshing_file(refreshing_path.clone());
8853        }
8854        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8855        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8856        ctx.install_semantic_refresh_worker_for_build_epoch(
8857            request_tx,
8858            event_rx,
8859            Arc::new(Mutex::new(None)),
8860            ctx.semantic_index_rx_epoch(),
8861        );
8862
8863        ctx.cancel_unbound_artifact_work();
8864
8865        // The cancelled worker will never re-embed the in-flight file; the
8866        // retained pending set is the only record for the replacement worker.
8867        assert_eq!(
8868            ctx.pending_semantic_index_paths
8869                .lock()
8870                .iter()
8871                .cloned()
8872                .collect::<Vec<_>>(),
8873            vec![refreshing_path],
8874            "cancelled in-flight refresh files must transfer to the pending set"
8875        );
8876        assert!(matches!(
8877            &*ctx
8878                .semantic_index_status
8879                .read()
8880                .unwrap_or_else(std::sync::PoisonError::into_inner),
8881            SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
8882        ));
8883    }
8884
8885    #[test]
8886    fn unbind_before_corpus_started_preserves_corpus_intent() {
8887        // The probe stamps `refreshing_corpus` before sending, but the worker
8888        // emits CorpusStarted only after walking the project. An unbind in
8889        // that window must re-derive the corpus intent from the stamped
8890        // status, not lose it.
8891        let root = TempDir::new().expect("project tempdir");
8892        let ctx = AppContext::new(
8893            Box::new(TreeSitterProvider::new()),
8894            Config {
8895                project_root: Some(root.path().to_path_buf()),
8896                semantic_search: true,
8897                ..Config::default()
8898            },
8899        );
8900        *ctx.semantic_index
8901            .write()
8902            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8903            Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8904        *ctx.semantic_index_status
8905            .write()
8906            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
8907            stage: "refreshing_corpus".to_string(),
8908            files: None,
8909            entries_done: None,
8910            entries_total: None,
8911        };
8912        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8913        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8914        ctx.install_semantic_refresh_worker_for_build_epoch(
8915            request_tx,
8916            event_rx,
8917            Arc::new(Mutex::new(None)),
8918            ctx.semantic_index_rx_epoch(),
8919        );
8920
8921        ctx.cancel_unbound_artifact_work();
8922
8923        assert!(
8924            *ctx.pending_semantic_corpus_refresh.lock(),
8925            "corpus intent stamped before CorpusStarted must survive the cancellation"
8926        );
8927    }
8928
8929    #[test]
8930    fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
8931        let root = TempDir::new().expect("project tempdir");
8932        let ctx = AppContext::new(
8933            Box::new(TreeSitterProvider::new()),
8934            Config {
8935                project_root: Some(root.path().to_path_buf()),
8936                ..Config::default()
8937            },
8938        );
8939        // A corpus refresh in flight: resident index marked non-ready plus an
8940        // installed receiver. Cancelling only the receiver would strand the
8941        // non-ready resident (equivalent rebind reloads only a MISSING index).
8942        let mut refreshing = SearchIndex::new();
8943        refreshing.ready = false;
8944        *ctx.search_index
8945            .write()
8946            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
8947        let (_tx, rx) = crossbeam_channel::unbounded();
8948        ctx.install_search_index_rx(rx, ctx.configure_generation());
8949
8950        ctx.cancel_unbound_artifact_work();
8951
8952        assert!(
8953            ctx.search_index
8954                .read()
8955                .unwrap_or_else(std::sync::PoisonError::into_inner)
8956                .is_none(),
8957            "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
8958        );
8959        assert!(ctx
8960            .search_index_rx
8961            .read()
8962            .unwrap_or_else(std::sync::PoisonError::into_inner)
8963            .is_none());
8964    }
8965
8966    #[test]
8967    fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
8968        let root = TempDir::new().expect("project tempdir");
8969        let ctx = AppContext::new(
8970            Box::new(TreeSitterProvider::new()),
8971            Config {
8972                project_root: Some(root.path().to_path_buf()),
8973                ..Config::default()
8974            },
8975        );
8976        *ctx.semantic_index
8977            .write()
8978            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8979            Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8980        let refreshing_path = root.path().join("src/lib.rs");
8981        {
8982            let mut status = ctx
8983                .semantic_index_status
8984                .write()
8985                .unwrap_or_else(std::sync::PoisonError::into_inner);
8986            *status = SemanticIndexStatus::ready();
8987            status.start_refreshing_file(refreshing_path.clone());
8988        }
8989
8990        assert!(ctx.artifact_eviction_blocked());
8991        assert!(!ctx.evict_idle_artifacts());
8992        assert!(ctx
8993            .semantic_index
8994            .read()
8995            .unwrap_or_else(std::sync::PoisonError::into_inner)
8996            .is_some());
8997
8998        ctx.semantic_index_status
8999            .write()
9000            .unwrap_or_else(std::sync::PoisonError::into_inner)
9001            .complete_refreshing_file(&refreshing_path);
9002        assert!(ctx.evict_idle_artifacts());
9003        assert!(ctx
9004            .semantic_index
9005            .read()
9006            .unwrap_or_else(std::sync::PoisonError::into_inner)
9007            .is_none());
9008    }
9009}
9010
9011#[cfg(test)]
9012mod status_emitter_tests {
9013    use super::*;
9014    use crate::parser::TreeSitterProvider;
9015
9016    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
9017        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9018        let (tx, rx) = mpsc::channel();
9019        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9020            let _ = tx.send(frame);
9021        }))));
9022        (ctx, rx)
9023    }
9024
9025    #[test]
9026    fn status_emitter_signal_triggers_push() {
9027        let (ctx, rx) = ctx_with_frame_rx();
9028        ctx.status_emitter().signal(ctx.build_status_snapshot());
9029        let frame = rx
9030            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9031            .expect("status_changed push");
9032        assert!(matches!(frame, PushFrame::StatusChanged(_)));
9033    }
9034
9035    #[test]
9036    fn status_emitter_debounces_burst() {
9037        let (ctx, rx) = ctx_with_frame_rx();
9038        for _ in 0..10 {
9039            ctx.status_emitter().signal(ctx.build_status_snapshot());
9040        }
9041        let frame = rx
9042            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9043            .expect("status_changed push");
9044        assert!(matches!(frame, PushFrame::StatusChanged(_)));
9045        assert!(rx.try_recv().is_err());
9046    }
9047
9048    #[test]
9049    fn status_emitter_separate_windows_separate_pushes() {
9050        let (ctx, rx) = ctx_with_frame_rx();
9051        ctx.status_emitter().signal(ctx.build_status_snapshot());
9052        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9053            .expect("first push");
9054        ctx.status_emitter().signal(ctx.build_status_snapshot());
9055        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9056            .expect("second push");
9057    }
9058
9059    #[test]
9060    fn status_emitter_no_signal_no_push() {
9061        let (_ctx, rx) = ctx_with_frame_rx();
9062        assert!(rx
9063            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
9064            .is_err());
9065    }
9066
9067    #[test]
9068    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
9069        let (ctx, rx) = ctx_with_frame_rx();
9070        drop(ctx);
9071        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
9072    }
9073
9074    #[test]
9075    fn progress_sender_slot_is_per_context_for_shared_app() {
9076        let app = App::default_shared();
9077        let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
9078        let ctx_b = AppContext::from_app(app, Config::default());
9079        let (tx_a, rx_a) = mpsc::channel();
9080        let (tx_b, rx_b) = mpsc::channel();
9081
9082        ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9083            let _ = tx_a.send(frame);
9084        }))));
9085        ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9086            let _ = tx_b.send(frame);
9087        }))));
9088
9089        ctx_a.emit_progress(ProgressFrame {
9090            frame_type: "progress",
9091            request_id: "ctx-a".to_string(),
9092            kind: crate::protocol::ProgressKind::Stdout,
9093            chunk: "a".to_string(),
9094        });
9095        ctx_b.emit_progress(ProgressFrame {
9096            frame_type: "progress",
9097            request_id: "ctx-b".to_string(),
9098            kind: crate::protocol::ProgressKind::Stdout,
9099            chunk: "b".to_string(),
9100        });
9101
9102        match rx_a
9103            .recv_timeout(Duration::from_millis(50))
9104            .expect("ctx A progress frame")
9105        {
9106            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
9107            other => panic!("unexpected frame for ctx A: {other:?}"),
9108        }
9109        assert!(rx_a.try_recv().is_err());
9110
9111        match rx_b
9112            .recv_timeout(Duration::from_millis(50))
9113            .expect("ctx B progress frame")
9114        {
9115            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
9116            other => panic!("unexpected frame for ctx B: {other:?}"),
9117        }
9118        assert!(rx_b.try_recv().is_err());
9119    }
9120}
9121
9122#[cfg(test)]
9123mod health_warming_honesty_tests {
9124    use super::*;
9125    use crate::parser::TreeSitterProvider;
9126
9127    fn ctx_with_config(config: Config) -> AppContext {
9128        AppContext::new(Box::new(TreeSitterProvider::new()), config)
9129    }
9130
9131    fn health_search_status(ctx: &AppContext) -> &'static str {
9132        let root = std::path::Path::new("/tmp/health-warming-honesty-test");
9133        ctx.try_health_snapshot(root)
9134            .search_index
9135            .expect("search_index component present")
9136            .status
9137    }
9138
9139    fn health_tier2_status(ctx: &AppContext) -> &'static str {
9140        let root = std::path::Path::new("/tmp/health-warming-honesty-test");
9141        ctx.try_health_snapshot(root)
9142            .tier2
9143            .expect("tier2 component present")
9144            .status
9145    }
9146
9147    #[test]
9148    fn write_denied_search_index_reports_ready_not_building() {
9149        // A write-denied cold build installs an empty index that is flagged
9150        // build-denied and stays not-ready (so grep keeps the fallback walk).
9151        // Health must treat it as settled, not "building" forever.
9152        let config = Config {
9153            search_index: true,
9154            ..Config::default()
9155        };
9156        let ctx = ctx_with_config(config);
9157        let mut index = SearchIndex::new();
9158        index.build_denied = true;
9159        *ctx.search_index()
9160            .write()
9161            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
9162
9163        assert_eq!(
9164            health_search_status(&ctx),
9165            "ready",
9166            "a build-denied index is a terminal settled state and must not report building forever"
9167        );
9168    }
9169
9170    #[test]
9171    fn in_progress_search_index_still_reports_building() {
9172        // Control: a genuinely not-ready, not-denied index (a real build in
9173        // flight) must still report building — the build-denied carve-out must
9174        // not leak into ordinary in-progress builds.
9175        let config = Config {
9176            search_index: true,
9177            ..Config::default()
9178        };
9179        let ctx = ctx_with_config(config);
9180        let index = SearchIndex::new(); // ready=false, build_denied=false
9181        *ctx.search_index()
9182            .write()
9183            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
9184
9185        assert_eq!(health_search_status(&ctx), "building");
9186    }
9187
9188    #[test]
9189    fn tier2_blocked_on_callgraph_reports_ready_not_building() {
9190        // dead_code is suppressed (None) while the callgraph store is not ready,
9191        // but unused_exports/duplicates are complete and fresh. Health must not
9192        // report tier2 as "building" forever for a cycle that is otherwise
9193        // complete — the callgraph component tells the callgraph story.
9194        let ctx = ctx_with_config(Config::default()); // inspect.enabled defaults true
9195        ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
9196        ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
9197
9198        assert_eq!(
9199            health_tier2_status(&ctx),
9200            "ready",
9201            "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
9202        );
9203    }
9204
9205    #[test]
9206    fn health_tier2_and_inspect_builder_state_read_the_same_registry() {
9207        // Complete published counts must not make health report ready while the
9208        // inspect builder registry still has a live registration for this root.
9209        let ctx = ctx_with_config(Config::default());
9210        ctx.update_status_bar_tier2(Some(1), Some(2), Some(3), None, false);
9211        ctx.inspect_manager()
9212            .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, true);
9213
9214        assert_eq!(health_tier2_status(&ctx), "building");
9215        assert_eq!(
9216            ctx.inspect_manager()
9217                .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
9218            crate::inspect::InspectBuilderState::Building
9219        );
9220
9221        ctx.inspect_manager()
9222            .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, false);
9223
9224        assert_eq!(health_tier2_status(&ctx), "ready");
9225        assert_eq!(
9226            ctx.inspect_manager()
9227                .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
9228            crate::inspect::InspectBuilderState::Absent
9229        );
9230
9231        ctx.inspect_manager().record_tier2_attempt_outcome_for_test(
9232            crate::inspect::InspectCategory::DeadCode,
9233            crate::inspect::JobOutcome::Fresh {
9234                payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
9235            },
9236        );
9237        assert_eq!(
9238            health_tier2_status(&ctx),
9239            "ready",
9240            "a finished callgraph_unavailable attempt must not keep health.tier2=building"
9241        );
9242        assert_eq!(
9243            ctx.inspect_manager()
9244                .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
9245            crate::inspect::InspectBuilderState::Absent
9246        );
9247        assert!(
9248            ctx.inspect_manager()
9249                .tier2_builder_state_detail(crate::inspect::InspectCategory::DeadCode)
9250                .starts_with("last attempt failed: callgraph_unavailable (attempt 1, first at "),
9251            "inspect refusals must carry the failed-attempt history the health surface no longer treats as busy"
9252        );
9253    }
9254
9255    #[test]
9256    fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
9257        // Control: with no callgraph block recorded, a missing dead_code count is
9258        // a genuine in-progress scan and must still report building.
9259        let ctx = ctx_with_config(Config::default());
9260        ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
9261        ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
9262
9263        assert_eq!(health_tier2_status(&ctx), "building");
9264    }
9265}
9266
9267#[cfg(test)]
9268mod status_bar_tests {
9269    use super::*;
9270    use crate::parser::TreeSitterProvider;
9271
9272    fn ctx() -> AppContext {
9273        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
9274    }
9275
9276    #[test]
9277    fn truthful_values_omit_unproven_categories_while_legacy_projection_stays_hidden() {
9278        let ctx = ctx();
9279        let values = ctx.status_bar_count_values();
9280        assert_eq!(values.errors, None);
9281        assert_eq!(values.warnings, None);
9282        assert_eq!(values.dead_code, None);
9283        assert_eq!(values.unused_exports, None);
9284        assert_eq!(values.duplicates, None);
9285        assert_eq!(values.todos, None);
9286        assert!(ctx.status_bar_counts().is_none());
9287
9288        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
9289        let values = ctx.status_bar_count_values();
9290        assert_eq!(values.dead_code, Some(5));
9291        assert_eq!(values.unused_exports, Some(3));
9292        assert_eq!(values.duplicates, Some(7));
9293        assert_eq!(values.todos, Some(2));
9294        assert_eq!(values.errors, None, "no analyzer report is not a clean E0");
9295        assert_eq!(
9296            values.warnings, None,
9297            "no analyzer report is not a clean W0"
9298        );
9299        assert!(!values.tier2_stale);
9300
9301        let legacy = ctx
9302            .status_bar_counts()
9303            .expect("legacy projection is populated");
9304        assert_eq!((legacy.errors, legacy.warnings), (0, 0));
9305    }
9306
9307    #[test]
9308    fn changing_root_clears_project_scoped_status_counts() {
9309        let temp = tempfile::tempdir().expect("tempdir");
9310        let first_root = temp.path().join("first");
9311        let second_root = temp.path().join("second");
9312        std::fs::create_dir_all(&first_root).expect("create first root");
9313        std::fs::create_dir_all(&second_root).expect("create second root");
9314        let ctx = ctx();
9315        ctx.set_canonical_cache_root(first_root);
9316        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
9317        assert!(ctx.status_bar_counts().is_some());
9318
9319        ctx.set_canonical_cache_root(second_root);
9320
9321        let values = ctx.status_bar_count_values();
9322        assert_eq!(values.dead_code, None);
9323        assert_eq!(values.unused_exports, None);
9324        assert_eq!(values.duplicates, None);
9325        assert!(
9326            ctx.status_bar_counts().is_none(),
9327            "counts from the previous root must not appear in a newly bound root"
9328        );
9329    }
9330
9331    #[test]
9332    fn partial_tier2_keeps_proven_categories_and_cache_hit_preserves_omissions() {
9333        let ctx = ctx();
9334        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
9335
9336        let first = ctx.status_bar_count_values();
9337        assert_eq!(first.dead_code, Some(5));
9338        assert_eq!(first.unused_exports, None);
9339        assert_eq!(first.duplicates, None);
9340        assert_eq!(first.todos, None);
9341        assert!(first.tier2_stale);
9342        assert!(ctx.status_bar_counts().is_none());
9343
9344        let cached = ctx.status_bar_count_values();
9345        assert_eq!(cached, first, "a cache hit must preserve every omission");
9346        let cache = ctx
9347            .status_bar_cached
9348            .read()
9349            .unwrap_or_else(std::sync::PoisonError::into_inner);
9350        assert!(cache.valid);
9351        assert_eq!(cache.counts.as_ref(), Some(&first));
9352        drop(cache);
9353
9354        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
9355        let partial = ctx.status_bar_count_values();
9356        assert_eq!(partial.dead_code, Some(5));
9357        assert_eq!(partial.unused_exports, Some(3));
9358        assert_eq!(partial.duplicates, None);
9359
9360        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
9361        let complete = ctx.status_bar_count_values();
9362        assert_eq!(complete.dead_code, Some(5));
9363        assert_eq!(complete.unused_exports, Some(3));
9364        assert_eq!(complete.duplicates, Some(7));
9365    }
9366
9367    #[test]
9368    fn update_with_none_todos_preserves_last_known_todos() {
9369        let ctx = ctx();
9370        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
9371        // A background-scan refresh passes todos=None → todo count preserved.
9372        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
9373        let counts = ctx.status_bar_count_values();
9374        assert_eq!(counts.todos, Some(9));
9375        assert_eq!(counts.dead_code, Some(2));
9376    }
9377
9378    #[test]
9379    fn update_with_none_count_preserves_last_known_count() {
9380        let ctx = ctx();
9381        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
9382        // A refresh that only recomputed dead_code preserves the other two
9383        // real counts rather than overwriting them with a fabricated 0.
9384        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
9385        let counts = ctx.status_bar_count_values();
9386        assert_eq!(counts.dead_code, Some(11));
9387        assert_eq!(counts.unused_exports, Some(20));
9388        assert_eq!(counts.duplicates, Some(30));
9389    }
9390
9391    #[test]
9392    fn mark_stale_sets_flag_after_any_proven_category() {
9393        let ctx = ctx();
9394        ctx.mark_status_bar_tier2_stale();
9395        assert!(!ctx.status_bar_count_values().tier2_stale);
9396
9397        ctx.update_status_bar_tier2(Some(4), None, None, None, false);
9398        ctx.mark_status_bar_tier2_stale();
9399        assert!(ctx.status_bar_count_values().tier2_stale);
9400
9401        // A completed scan clears stale without changing omitted categories.
9402        ctx.update_status_bar_tier2(Some(4), None, None, None, false);
9403        assert!(!ctx.status_bar_count_values().tier2_stale);
9404    }
9405
9406    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
9407    // count (read live from the warm LSP set); clearing that file's diagnostics
9408    // (the deleted-file path) drops it back. This is the AppContext glue between
9409    // the watcher-drain clear and the agent-visible bar.
9410    #[test]
9411    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
9412        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9413        use crate::lsp::registry::ServerKind;
9414        use crate::lsp::roots::ServerKey;
9415
9416        let ctx = ctx();
9417        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
9418
9419        let file = std::path::PathBuf::from("/proj/gone.ts");
9420        {
9421            let mut lsp = ctx.lsp();
9422            lsp.diagnostics_store_mut_for_test().publish(
9423                ServerKey {
9424                    kind: ServerKind::TypeScript,
9425                    root: std::path::PathBuf::from("/proj"),
9426                },
9427                file.clone(),
9428                vec![StoredDiagnostic {
9429                    file: file.clone(),
9430                    line: 1,
9431                    column: 1,
9432                    end_line: 1,
9433                    end_column: 2,
9434                    severity: DiagnosticSeverity::Error,
9435                    message: "boom".into(),
9436                    code: None,
9437                    source: None,
9438                }],
9439            );
9440        }
9441
9442        // Bar reflects the live warm-set error.
9443        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
9444
9445        // Clearing the (now-deleted) file's diagnostics drops the count.
9446        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
9447        assert!(removed);
9448        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9449    }
9450
9451    #[test]
9452    fn status_bar_preserves_authoritative_counts_until_provisional_report_is_promoted() {
9453        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9454        use crate::lsp::registry::ServerKind;
9455        use crate::lsp::roots::ServerKey;
9456
9457        let ctx = ctx();
9458        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9459        let root = std::path::PathBuf::from("/proj");
9460        let file = root.join("src/main.rs");
9461        let key = ServerKey {
9462            kind: ServerKind::Rust,
9463            root,
9464        };
9465        let diagnostic = |severity, message: &str| StoredDiagnostic {
9466            file: file.clone(),
9467            line: 1,
9468            column: 1,
9469            end_line: 1,
9470            end_column: 2,
9471            severity,
9472            message: message.into(),
9473            code: None,
9474            source: None,
9475        };
9476
9477        {
9478            let mut lsp = ctx.lsp();
9479            lsp.diagnostics_store_mut_for_test().publish(
9480                key.clone(),
9481                file.clone(),
9482                vec![diagnostic(DiagnosticSeverity::Error, "settled error")],
9483            );
9484        }
9485        let counts = ctx.status_bar_counts().expect("populated");
9486        assert_eq!((counts.errors, counts.warnings), (1, 0));
9487
9488        {
9489            let mut lsp = ctx.lsp();
9490            lsp.diagnostics_store_mut_for_test()
9491                .publish_full_with_provisional(
9492                    key.clone(),
9493                    file.clone(),
9494                    vec![diagnostic(
9495                        DiagnosticSeverity::Warning,
9496                        "latest warming warning",
9497                    )],
9498                    None,
9499                    None,
9500                    true,
9501                );
9502        }
9503        let counts = ctx.status_bar_counts().expect("populated");
9504        assert_eq!(
9505            (counts.errors, counts.warnings),
9506            (1, 0),
9507            "pre-quiescence diagnostics must not replace authoritative counts"
9508        );
9509
9510        {
9511            let mut lsp = ctx.lsp();
9512            assert!(lsp
9513                .diagnostics_store_mut_for_test()
9514                .promote_provisional_for_server(&key));
9515        }
9516        let counts = ctx.status_bar_counts().expect("populated");
9517        assert_eq!(
9518            (counts.errors, counts.warnings),
9519            (0, 1),
9520            "the latest report becomes authoritative at quiescence"
9521        );
9522    }
9523
9524    #[test]
9525    fn status_bar_filtered_counts_ignore_environmental_flap() {
9526        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9527        use crate::lsp::registry::ServerKind;
9528        use crate::lsp::roots::ServerKey;
9529
9530        let ctx = ctx();
9531        let root = if cfg!(windows) {
9532            std::path::PathBuf::from(r"C:\proj")
9533        } else {
9534            std::path::PathBuf::from("/proj")
9535        };
9536        ctx.set_canonical_cache_root(root.clone());
9537        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9538
9539        let file = root.join("aft.jsonc");
9540        let key = ServerKey {
9541            kind: ServerKind::TypeScript,
9542            root: root.clone(),
9543        };
9544        let env = StoredDiagnostic {
9545            file: file.clone(),
9546            line: 1,
9547            column: 1,
9548            end_line: 1,
9549            end_column: 2,
9550            severity: DiagnosticSeverity::Error,
9551            message: "Failed to load schema from https://example.com/schema.json".into(),
9552            code: None,
9553            source: Some("json".into()),
9554        };
9555
9556        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9557
9558        {
9559            let mut lsp = ctx.lsp();
9560            lsp.diagnostics_store_mut_for_test()
9561                .publish(key.clone(), file.clone(), vec![env]);
9562        }
9563        assert_eq!(
9564            ctx.status_bar_counts().expect("populated").errors,
9565            0,
9566            "environmental publish must not change status-bar E"
9567        );
9568
9569        {
9570            let mut lsp = ctx.lsp();
9571            lsp.diagnostics_store_mut_for_test()
9572                .publish(key, file, vec![]);
9573        }
9574        assert_eq!(
9575            ctx.status_bar_counts().expect("populated").errors,
9576            0,
9577            "environmental clear must not change status-bar E"
9578        );
9579    }
9580}
9581
9582#[cfg(test)]
9583mod harness_path_tests {
9584    use super::*;
9585    use crate::harness::Harness;
9586    use crate::parser::TreeSitterProvider;
9587
9588    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
9589        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9590        ctx.update_config(|config| {
9591            config.storage_dir = Some(storage_dir);
9592        });
9593        ctx.set_harness(harness);
9594        ctx
9595    }
9596
9597    #[test]
9598    fn harness_dir_resolves_correctly() {
9599        let storage = PathBuf::from("/tmp/cortexkit/aft");
9600        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9601
9602        assert_eq!(ctx.harness_dir(), storage.join("pi"));
9603    }
9604
9605    #[test]
9606    fn bash_tasks_dir_uses_hash_session() {
9607        let storage = PathBuf::from("/tmp/cortexkit/aft");
9608        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9609
9610        assert_eq!(
9611            ctx.bash_tasks_dir("ses_abc"),
9612            storage
9613                .join("opencode")
9614                .join("bash-tasks")
9615                .join(hash_session("ses_abc"))
9616        );
9617    }
9618
9619    #[test]
9620    fn backups_dir_includes_path_hash() {
9621        let storage = PathBuf::from("/tmp/cortexkit/aft");
9622        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9623
9624        assert_eq!(
9625            ctx.backups_dir("ses_abc", "pathhash"),
9626            storage
9627                .join("pi")
9628                .join("backups")
9629                .join(hash_session("ses_abc"))
9630                .join("pathhash")
9631        );
9632    }
9633
9634    #[test]
9635    fn filters_dir_under_harness() {
9636        let storage = PathBuf::from("/tmp/cortexkit/aft");
9637        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9638
9639        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
9640    }
9641
9642    #[test]
9643    fn trust_file_is_host_global() {
9644        let storage = PathBuf::from("/tmp/cortexkit/aft");
9645        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9646
9647        assert_eq!(
9648            ctx.trust_file(),
9649            storage.join("trusted-filter-projects.json")
9650        );
9651    }
9652
9653    #[test]
9654    fn same_session_different_harness_resolve_different_paths() {
9655        let storage = PathBuf::from("/tmp/cortexkit/aft");
9656        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9657        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
9658
9659        assert_ne!(
9660            opencode.bash_tasks_dir("ses_same"),
9661            pi.bash_tasks_dir("ses_same")
9662        );
9663    }
9664
9665    #[test]
9666    fn callgraph_and_inspect_dirs_are_root_keyed() {
9667        let temp = tempfile::tempdir().expect("tempdir");
9668        let storage = temp.path().join("storage");
9669        let root = temp.path().join("checkout");
9670        std::fs::create_dir_all(&root).expect("create root");
9671        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9672        ctx.set_canonical_cache_root(root.clone());
9673
9674        assert_eq!(
9675            ctx.callgraph_store_dir(),
9676            storage
9677                .join("callgraph")
9678                .join(crate::search_index::artifact_cache_key(&root))
9679        );
9680        assert_eq!(
9681            ctx.inspect_dir(),
9682            storage
9683                .join("inspect")
9684                .join(crate::path_identity::project_scope_key(&root))
9685        );
9686        assert!(!ctx
9687            .callgraph_store_dir()
9688            .starts_with(storage.join("opencode")));
9689        assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
9690    }
9691
9692    #[test]
9693    fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
9694        let storage = PathBuf::from("/tmp/cortexkit/aft");
9695        let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
9696        ctx.set_cache_writer_capabilities(false, true);
9697
9698        assert!(ctx.shared_artifacts_read_only());
9699        assert!(!ctx.callgraph_writer());
9700        assert!(ctx.inspect_writer());
9701    }
9702}
9703
9704#[cfg(test)]
9705mod shared_db_tests {
9706    use super::*;
9707    use tempfile::tempdir;
9708
9709    #[test]
9710    fn app_contexts_share_one_database_connection() {
9711        let storage = tempdir().expect("storage tempdir");
9712        let root_one = tempdir().expect("first root tempdir");
9713        let root_two = tempdir().expect("second root tempdir");
9714        let app = App::default_shared();
9715        let ctx_one = AppContext::from_app(
9716            Arc::clone(&app),
9717            Config {
9718                project_root: Some(root_one.path().to_path_buf()),
9719                ..Config::default()
9720            },
9721        );
9722        let ctx_two = AppContext::from_app(
9723            Arc::clone(&app),
9724            Config {
9725                project_root: Some(root_two.path().to_path_buf()),
9726                ..Config::default()
9727            },
9728        );
9729        let path = storage.path().join("aft.db");
9730
9731        let first = app.open_db(&path).expect("open shared database");
9732        let second = app.open_db(&path).expect("reuse shared database");
9733
9734        assert!(Arc::ptr_eq(&first, &second));
9735        assert!(Arc::ptr_eq(
9736            &ctx_one.db().expect("first context database"),
9737            &ctx_two.db().expect("second context database")
9738        ));
9739    }
9740}
9741
9742#[cfg(test)]
9743mod gitignore_tests {
9744    use super::*;
9745    use std::fs;
9746    use std::path::Path;
9747    use tempfile::TempDir;
9748
9749    fn make_ctx_with_root(root: &Path) -> AppContext {
9750        let provider = Box::new(crate::parser::TreeSitterProvider::new());
9751        let config = Config {
9752            project_root: Some(root.to_path_buf()),
9753            ..Config::default()
9754        };
9755        AppContext::new(provider, config)
9756    }
9757
9758    /// Helper: returns true when the matcher would skip `path` (as if it
9759    /// arrived via a watcher event for this project root). Canonicalizes
9760    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
9761    /// don't trip the `ignore` crate's "path is expected to be under the
9762    /// root" panic — production code does the same guard via
9763    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
9764    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
9765        let Some(matcher) = ctx.gitignore() else {
9766            return false;
9767        };
9768        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
9769        if !canonical.starts_with(matcher.path()) {
9770            return false;
9771        }
9772        let is_dir = canonical.is_dir();
9773        matcher
9774            .matched_path_or_any_parents(&canonical, is_dir)
9775            .is_ignore()
9776    }
9777
9778    /// Run `f` with global git-ignore discovery neutralized.
9779    ///
9780    /// `rebuild_gitignore` loads git's global excludes via the `ignore`
9781    /// crate, which discovers them from TWO places: `core.excludesfile` in
9782    /// `$HOME/.gitconfig` (or `$XDG_CONFIG_HOME/git/config`), and the default
9783    /// `$XDG_CONFIG_HOME/git/ignore` / `$HOME/.config/git/ignore` locations.
9784    /// A developer machine commonly has one of these, so a "no project ignore
9785    /// → None" assertion is only deterministic when BOTH discovery roots point
9786    /// at an empty directory — neutralizing only `XDG_CONFIG_HOME` still finds
9787    /// a `~/.gitconfig` `core.excludesfile`. Serialized on the process-wide
9788    /// env lock shared with every other HOME-mutating test; env is restored
9789    /// before the closure result is used.
9790    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
9791        let _guard = crate::test_env::process_env_lock();
9792        let tmp = TempDir::new().unwrap();
9793        let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
9794        let prev_home = std::env::var_os("HOME");
9795        let prev_userprofile = std::env::var_os("USERPROFILE");
9796        // SAFETY: serialized by the process env lock; restored immediately
9797        // after `f`.
9798        unsafe {
9799            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
9800            std::env::set_var("HOME", tmp.path());
9801            std::env::set_var("USERPROFILE", tmp.path());
9802        }
9803        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
9804        unsafe {
9805            match prev_xdg {
9806                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
9807                None => std::env::remove_var("XDG_CONFIG_HOME"),
9808            }
9809            match prev_home {
9810                Some(v) => std::env::set_var("HOME", v),
9811                None => std::env::remove_var("HOME"),
9812            }
9813            match prev_userprofile {
9814                Some(v) => std::env::set_var("USERPROFILE", v),
9815                None => std::env::remove_var("USERPROFILE"),
9816            }
9817        }
9818        match result {
9819            Ok(r) => r,
9820            Err(p) => std::panic::resume_unwind(p),
9821        }
9822    }
9823
9824    #[test]
9825    fn rebuild_gitignore_returns_none_without_project_root() {
9826        let provider = Box::new(crate::parser::TreeSitterProvider::new());
9827        let ctx = AppContext::new(provider, Config::default());
9828        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9829        assert!(ctx.gitignore().is_none());
9830    }
9831
9832    #[test]
9833    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
9834        let tmp = TempDir::new().unwrap();
9835        let ctx = make_ctx_with_root(tmp.path());
9836        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9837        assert!(ctx.gitignore().is_none());
9838    }
9839
9840    #[test]
9841    fn matcher_filters_files_in_ignored_dist_dir() {
9842        let tmp = TempDir::new().unwrap();
9843        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
9844        fs::create_dir_all(tmp.path().join("dist")).unwrap();
9845        fs::create_dir_all(tmp.path().join("src")).unwrap();
9846        let dist_file = tmp.path().join("dist").join("bundle.js");
9847        let src_file = tmp.path().join("src").join("app.ts");
9848        fs::write(&dist_file, "x").unwrap();
9849        fs::write(&src_file, "y").unwrap();
9850
9851        let ctx = make_ctx_with_root(tmp.path());
9852        ctx.rebuild_gitignore();
9853
9854        assert!(ctx.gitignore().is_some());
9855        assert!(
9856            is_ignored(&ctx, &dist_file),
9857            "dist/bundle.js should be ignored"
9858        );
9859        assert!(
9860            !is_ignored(&ctx, &src_file),
9861            "src/app.ts should NOT be ignored"
9862        );
9863    }
9864
9865    #[test]
9866    fn matcher_handles_node_modules_and_target() {
9867        let tmp = TempDir::new().unwrap();
9868        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
9869        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
9870        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
9871        let nm_file = tmp.path().join("node_modules/foo/index.js");
9872        let target_file = tmp.path().join("target/debug/aft");
9873        fs::write(&nm_file, "x").unwrap();
9874        fs::write(&target_file, "x").unwrap();
9875
9876        let ctx = make_ctx_with_root(tmp.path());
9877        ctx.rebuild_gitignore();
9878
9879        assert!(is_ignored(&ctx, &nm_file));
9880        assert!(is_ignored(&ctx, &target_file));
9881    }
9882
9883    #[test]
9884    fn matcher_honors_negation_pattern() {
9885        // .gitignore: ignore all *.log files EXCEPT important.log
9886        let tmp = TempDir::new().unwrap();
9887        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
9888        let random_log = tmp.path().join("random.log");
9889        let important_log = tmp.path().join("important.log");
9890        fs::write(&random_log, "x").unwrap();
9891        fs::write(&important_log, "y").unwrap();
9892
9893        let ctx = make_ctx_with_root(tmp.path());
9894        ctx.rebuild_gitignore();
9895
9896        assert!(is_ignored(&ctx, &random_log));
9897        assert!(
9898            !is_ignored(&ctx, &important_log),
9899            "negation pattern should un-ignore important.log"
9900        );
9901    }
9902
9903    #[test]
9904    fn rebuild_picks_up_gitignore_changes() {
9905        let tmp = TempDir::new().unwrap();
9906        let ignore_path = tmp.path().join(".gitignore");
9907        fs::write(&ignore_path, "foo.txt\n").unwrap();
9908        let foo = tmp.path().join("foo.txt");
9909        let bar = tmp.path().join("bar.txt");
9910        fs::write(&foo, "").unwrap();
9911        fs::write(&bar, "").unwrap();
9912
9913        let ctx = make_ctx_with_root(tmp.path());
9914        ctx.rebuild_gitignore();
9915        assert!(is_ignored(&ctx, &foo));
9916        assert!(!is_ignored(&ctx, &bar));
9917
9918        // Now flip the rules: ignore bar.txt instead of foo.txt
9919        fs::write(&ignore_path, "bar.txt\n").unwrap();
9920        ctx.rebuild_gitignore();
9921        assert!(!is_ignored(&ctx, &foo));
9922        assert!(is_ignored(&ctx, &bar));
9923    }
9924
9925    #[test]
9926    fn gitignore_loads_info_exclude_when_present() {
9927        let tmp = TempDir::new().unwrap();
9928        let info_dir = tmp.path().join(".git/info");
9929        fs::create_dir_all(&info_dir).unwrap();
9930        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
9931        let secrets = tmp.path().join("secrets.txt");
9932        let public = tmp.path().join("public.txt");
9933        fs::write(&secrets, "token").unwrap();
9934        fs::write(&public, "ok").unwrap();
9935
9936        let ctx = make_ctx_with_root(tmp.path());
9937        ctx.rebuild_gitignore();
9938
9939        assert!(is_ignored(&ctx, &secrets));
9940        assert!(!is_ignored(&ctx, &public));
9941    }
9942
9943    #[test]
9944    fn matcher_picks_up_nested_gitignore() {
9945        let tmp = TempDir::new().unwrap();
9946        // Root .gitignore is intentionally empty — only the nested one ignores
9947        fs::write(tmp.path().join(".gitignore"), "").unwrap();
9948        let sub = tmp.path().join("packages/foo");
9949        fs::create_dir_all(&sub).unwrap();
9950        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
9951        let generated_file = sub.join("generated").join("out.js");
9952        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
9953        fs::write(&generated_file, "x").unwrap();
9954
9955        let ctx = make_ctx_with_root(tmp.path());
9956        ctx.rebuild_gitignore();
9957
9958        assert!(
9959            is_ignored(&ctx, &generated_file),
9960            "nested gitignore in packages/foo/.gitignore should ignore generated/"
9961        );
9962    }
9963}
9964
9965#[cfg(test)]
9966mod verify_memo_watcher_tests {
9967    use super::*;
9968
9969    #[test]
9970    fn pending_watcher_path_invalidates_root_verify_memo() {
9971        let root_dir = tempfile::tempdir().unwrap();
9972        let root = std::fs::canonicalize(root_dir.path()).unwrap();
9973        let artifact = root.join("cache.bin");
9974        std::fs::write(&artifact, b"generation").unwrap();
9975        let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
9976        crate::cache_freshness::record_verify_completed(
9977            &root,
9978            crate::cache_freshness::VerifyArtifact::Search,
9979            Some(generation),
9980        );
9981        assert_eq!(
9982            crate::cache_freshness::warm_verify_plan(
9983                &root,
9984                crate::cache_freshness::VerifyArtifact::Search,
9985                Some(generation),
9986            ),
9987            crate::cache_freshness::WarmVerifyPlan::Skip
9988        );
9989
9990        let ctx = AppContext::from_app(
9991            App::default_shared(),
9992            Config {
9993                project_root: Some(root.clone()),
9994                ..Config::default()
9995            },
9996        );
9997        ctx.set_canonical_cache_root(root.clone());
9998        ctx.add_pending_search_index_paths([root.join("changed.rs")]);
9999        assert_eq!(
10000            crate::cache_freshness::warm_verify_plan(
10001                &root,
10002                crate::cache_freshness::VerifyArtifact::Search,
10003                Some(generation),
10004            ),
10005            crate::cache_freshness::WarmVerifyPlan::StatFirst
10006        );
10007    }
10008}
10009
10010#[cfg(test)]
10011mod watcher_runtime_state_tests {
10012    use super::*;
10013    use crate::language::StubProvider;
10014
10015    fn test_context() -> AppContext {
10016        AppContext::new(Box::new(StubProvider), Config::default())
10017    }
10018
10019    #[test]
10020    fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
10021        let root = tempfile::tempdir().expect("project tempdir");
10022        let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
10023        let ctx = AppContext::new(
10024            Box::new(StubProvider),
10025            Config {
10026                project_root: Some(canonical_root.clone()),
10027                ..Config::default()
10028            },
10029        );
10030        ctx.set_canonical_cache_root(canonical_root.clone());
10031        // Suppress the physical FSEvents reinstall (parallel in-process tests
10032        // must not install real OS watchers); the property under test is the
10033        // corpse reclaim + invalidation, not the reinstall.
10034        struct DisableWatcherGuard;
10035        impl Drop for DisableWatcherGuard {
10036            fn drop(&mut self) {
10037                unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
10038            }
10039        }
10040        let _env_lock = crate::test_env::process_env_lock();
10041        unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
10042        let _disable_watcher = DisableWatcherGuard;
10043        // Warm state the corpse reclaim must invalidate: resident index +
10044        // warm Skip memo.
10045        *ctx.search_index
10046            .write()
10047            .unwrap_or_else(std::sync::PoisonError::into_inner) =
10048            Some(crate::search_index::SearchIndex::new());
10049        let artifact = canonical_root.join("artifact.bin");
10050        std::fs::write(&artifact, b"artifact").expect("artifact");
10051        let generation = crate::cache_freshness::artifact_generation(&artifact);
10052        crate::cache_freshness::record_verify_completed(
10053            &canonical_root,
10054            crate::cache_freshness::VerifyArtifact::Search,
10055            generation,
10056        );
10057
10058        let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
10059        let _dispatch_tx = dispatch_tx;
10060        // A thread that exits on its own models a backend failure while the
10061        // root was unbound (drains suppressed, queued error undrained).
10062        let join = std::thread::spawn(|| {});
10063        ctx.install_watcher_runtime(
10064            dispatch_rx,
10065            WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
10066        );
10067        let deadline = std::time::Instant::now() + Duration::from_secs(2);
10068        while ctx.watcher_runtime_active() {
10069            assert!(
10070                std::time::Instant::now() < deadline,
10071                "a finished watcher thread must report the runtime inactive"
10072            );
10073            std::thread::yield_now();
10074        }
10075
10076        // The production entry point: rebind restoration must reclaim the
10077        // corpse, invalidate the unobserved-window state, and reinstall.
10078        crate::commands::configure::ensure_project_watcher(&ctx);
10079
10080        assert!(
10081            ctx.search_index
10082                .read()
10083                .unwrap_or_else(std::sync::PoisonError::into_inner)
10084                .is_none(),
10085            "corpse reclaim must drop resident artifacts (events since the failure are lost)"
10086        );
10087        assert_eq!(
10088            crate::cache_freshness::warm_verify_plan(
10089                &canonical_root,
10090                crate::cache_freshness::VerifyArtifact::Search,
10091                generation,
10092            ),
10093            crate::cache_freshness::WarmVerifyPlan::Strict,
10094            "corpse reclaim must force strict re-verification"
10095        );
10096        assert!(
10097            !ctx.take_finished_watcher_runtime(),
10098            "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
10099        );
10100    }
10101
10102    #[test]
10103    fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
10104        let ctx = test_context();
10105        let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
10106        let shutdown = Arc::new(AtomicBool::new(false));
10107        let thread_shutdown = Arc::clone(&shutdown);
10108        let join = std::thread::spawn(move || {
10109            while !thread_shutdown.load(Ordering::SeqCst) {
10110                std::thread::sleep(Duration::from_millis(1));
10111            }
10112            drop(dispatch_tx);
10113        });
10114        ctx.install_watcher_runtime(
10115            dispatch_rx,
10116            WatcherThreadHandle::new(Arc::clone(&shutdown), join),
10117        );
10118        assert!(ctx.watcher_runtime_active());
10119
10120        *ctx.watcher_rx.lock() = None;
10121        assert!(
10122            !ctx.watcher_runtime_active(),
10123            "a thread without its dispatch receiver is not a usable watcher runtime"
10124        );
10125        ctx.stop_watcher_runtime();
10126    }
10127}
10128
10129#[cfg(test)]
10130mod semantic_probe_tests {
10131    use super::*;
10132
10133    #[test]
10134    fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
10135        let root = tempfile::tempdir().unwrap();
10136        let ctx = AppContext::new(
10137            default_language_provider_factory(),
10138            Config {
10139                project_root: Some(root.path().to_path_buf()),
10140                ..Config::default()
10141            },
10142        );
10143        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
10144        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
10145        let worker_slot = Arc::new(Mutex::new(None));
10146        ctx.install_semantic_refresh_worker_for_build_epoch(
10147            request_tx,
10148            event_rx,
10149            worker_slot,
10150            ctx.semantic_index_rx_epoch(),
10151        );
10152
10153        ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
10154        assert!(ctx.semantic_refresh_probe_is_scheduled());
10155        ctx.clear_semantic_refresh_worker();
10156        std::thread::sleep(Duration::from_millis(50));
10157
10158        assert!(!ctx.semantic_refresh_probe_ready());
10159        assert!(!ctx.semantic_refresh_probe_is_scheduled());
10160        assert!(!ctx.completion_drains_have_work());
10161    }
10162}