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    /// Consume a newly-ready index plane on the query path (`first_query` or a Building wait).
3301    pub(crate) fn note_index_query(
3302        &self,
3303        plane: crate::logging::IndexPlane,
3304        tool: &str,
3305        service_ms: u64,
3306        status: &str,
3307    ) {
3308        let root = self
3309            .canonical_cache_root_opt()
3310            .or_else(|| self.config().project_root.clone());
3311        let Some(root) = root else {
3312            return;
3313        };
3314        crate::logging::note_index_query(plane, &root, tool, service_ms, status);
3315    }
3316
3317    pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
3318        let mut keys = self.artifact_cache_keys.lock();
3319        if let Some(key) = keys.get(canonical_root).cloned() {
3320            return key;
3321        }
3322        let key = crate::search_index::artifact_cache_key(canonical_root);
3323        self.artifact_cache_key_derivations
3324            .fetch_add(1, Ordering::SeqCst);
3325        keys.insert(canonical_root.to_path_buf(), key.clone());
3326        key
3327    }
3328
3329    pub fn memoized_artifact_cache_key_for_configure(
3330        &self,
3331        raw_root: &Path,
3332        canonical_root: &Path,
3333        storage_root: &Path,
3334        git_common_dir: Option<&Path>,
3335    ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
3336        {
3337            let keys = self.artifact_cache_keys.lock();
3338            if let Some(key) = keys
3339                .get(canonical_root)
3340                .or_else(|| keys.get(raw_root))
3341                .cloned()
3342            {
3343                return Ok(key);
3344            }
3345        }
3346
3347        let key = crate::search_index::artifact_cache_key_with_memo(
3348            canonical_root,
3349            raw_root,
3350            storage_root,
3351            git_common_dir,
3352        )?;
3353        self.artifact_cache_key_derivations
3354            .fetch_add(1, Ordering::SeqCst);
3355        let mut keys = self.artifact_cache_keys.lock();
3356        keys.insert(canonical_root.to_path_buf(), key.clone());
3357        keys.insert(raw_root.to_path_buf(), key.clone());
3358        Ok(key)
3359    }
3360
3361    #[cfg(test)]
3362    pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
3363        self.artifact_cache_key_derivations.load(Ordering::SeqCst)
3364    }
3365
3366    pub(crate) fn resolve_external_git_root(
3367        &self,
3368        project_root: &Path,
3369        requested_path: &str,
3370    ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
3371        let raw_path = Path::new(requested_path);
3372        let canonical_requested = if raw_path.is_absolute() {
3373            std::fs::canonicalize(raw_path).ok()
3374        } else {
3375            None
3376        };
3377        if let Some(root) = canonical_requested
3378            .as_deref()
3379            .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
3380        {
3381            return Ok(root);
3382        }
3383
3384        let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
3385            project_root,
3386            requested_path,
3387        )?;
3388        if canonical_requested.as_deref() == Some(root.as_path()) {
3389            self.borrowed_index_cache
3390                .lock()
3391                .remember_resolved_root(root.clone());
3392        }
3393        Ok(root)
3394    }
3395
3396    pub(crate) fn open_borrowed_search_index(
3397        &self,
3398        external_root: &Path,
3399        storage_dir: Option<&Path>,
3400    ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
3401        let canonical_root =
3402            std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3403        let project_key = self.memoized_artifact_cache_key(&canonical_root);
3404        let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
3405            &project_key,
3406            storage_dir,
3407        ) else {
3408            return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3409        };
3410        let key = BorrowedIndexCacheKey {
3411            canonical_root: canonical_root.clone(),
3412            artifact,
3413        };
3414        {
3415            let mut cache = self.borrowed_index_cache.lock();
3416            if let Some(index) = cache.search(&key) {
3417                return index;
3418            }
3419        }
3420
3421        // Artifact parsing can touch many records. Keep this process-local cache
3422        // mutex free so another read-only request is not blocked behind the load.
3423        let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
3424            &canonical_root,
3425            storage_dir,
3426            &project_key,
3427        )
3428        .map(Arc::new);
3429        if !matches!(
3430            opened,
3431            crate::readonly_artifacts::ReadOnlyArtifact::Absent
3432                | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3433        ) {
3434            self.borrowed_index_cache
3435                .lock()
3436                .insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
3437        }
3438        opened
3439    }
3440
3441    pub(crate) fn open_borrowed_semantic_index(
3442        &self,
3443        external_root: &Path,
3444        storage_dir: Option<&Path>,
3445    ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
3446        let canonical_root =
3447            std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3448        let project_key = self.memoized_artifact_cache_key(&canonical_root);
3449        let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
3450            &project_key,
3451            storage_dir,
3452        ) else {
3453            return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3454        };
3455        let key = BorrowedIndexCacheKey {
3456            canonical_root: canonical_root.clone(),
3457            artifact,
3458        };
3459        {
3460            let mut cache = self.borrowed_index_cache.lock();
3461            if let Some(index) = cache.semantic(&key) {
3462                return index;
3463            }
3464        }
3465
3466        // Semantic snapshot parsing follows the same rule: bounded work runs
3467        // without holding the cache's process-wide coordination mutex.
3468        let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
3469            &canonical_root,
3470            storage_dir,
3471            &project_key,
3472        )
3473        .map(Arc::new);
3474        if !matches!(
3475            opened,
3476            crate::readonly_artifacts::ReadOnlyArtifact::Absent
3477                | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3478        ) {
3479            self.borrowed_index_cache
3480                .lock()
3481                .insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
3482        }
3483        opened
3484    }
3485
3486    #[cfg(test)]
3487    pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
3488        self.borrowed_index_cache.lock().entries.len()
3489    }
3490
3491    pub fn configure_generation(&self) -> u64 {
3492        self.configure_generation.load(Ordering::SeqCst)
3493    }
3494
3495    pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
3496        Arc::clone(&self.configure_generation)
3497    }
3498
3499    pub(crate) fn configure_content_generation(&self) -> u64 {
3500        self.configure_content_generation.load(Ordering::SeqCst)
3501    }
3502
3503    pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
3504        Arc::clone(&self.configure_content_generation)
3505    }
3506
3507    pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
3508        let now = Instant::now();
3509        let mut timing = self.configure_phase_timing.lock();
3510        if phase == "canonicalize" {
3511            timing.completed.clear();
3512        } else if timing.phase != "idle" && timing.phase != "ack_ready" {
3513            let previous = timing.phase;
3514            let elapsed = now.saturating_duration_since(timing.started_at);
3515            timing.completed.push((previous, elapsed));
3516        }
3517        timing.phase = phase;
3518        timing.started_at = now;
3519    }
3520
3521    pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
3522        let timing = self.configure_phase_timing.lock();
3523        let mut parts = timing
3524            .completed
3525            .iter()
3526            .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
3527            .collect::<Vec<_>>();
3528        parts.push(format!(
3529            "{}={}ms",
3530            timing.phase,
3531            timing.started_at.elapsed().as_millis()
3532        ));
3533        parts.join(",")
3534    }
3535
3536    pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
3537        self.semantic_fingerprint_generation
3538            .fetch_add(1, Ordering::SeqCst)
3539            .wrapping_add(1)
3540    }
3541
3542    pub fn semantic_fingerprint_generation(&self) -> u64 {
3543        self.semantic_fingerprint_generation.load(Ordering::SeqCst)
3544    }
3545
3546    pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
3547        Arc::clone(&self.semantic_fingerprint_generation)
3548    }
3549
3550    /// Invalidate an in-flight semantic builder when its corpus inputs change.
3551    /// This is intentionally independent from the broad configure generation so
3552    /// unrelated configuration changes can adopt a costly live embedding build.
3553    pub(crate) fn advance_semantic_build_epoch(&self) -> u64 {
3554        self.semantic_build_epoch
3555            .fetch_add(1, Ordering::SeqCst)
3556            .wrapping_add(1)
3557    }
3558
3559    pub(crate) fn semantic_build_epoch(&self) -> u64 {
3560        self.semantic_build_epoch.load(Ordering::SeqCst)
3561    }
3562
3563    pub(crate) fn semantic_build_epoch_flag(&self) -> Arc<AtomicU64> {
3564        Arc::clone(&self.semantic_build_epoch)
3565    }
3566
3567    pub fn configure_warnings_sender(
3568        &self,
3569    ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
3570        self.configure_warnings_tx.clone()
3571    }
3572
3573    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
3574        let mut warnings = Vec::new();
3575        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
3576            warnings.push(warning);
3577        }
3578        warnings
3579    }
3580
3581    pub fn bash_background(&self) -> &BgTaskRegistry {
3582        &self.bash_background
3583    }
3584
3585    #[cfg(unix)]
3586    pub(crate) fn escalation_grants(
3587        &self,
3588    ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
3589        &self.escalation_grants
3590    }
3591
3592    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
3593        self.bash_background.drain_completions()
3594    }
3595
3596    /// Access the language provider.
3597    pub fn provider(&self) -> &dyn LanguageProvider {
3598        self.provider.as_ref()
3599    }
3600
3601    /// Access the backup store.
3602    pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
3603        &self.backup
3604    }
3605
3606    /// Session-scoped hashline bindings installed by successful configure calls.
3607    pub fn hashline_bindings(&self) -> &crate::hashline::integration::BindingRegistry {
3608        &self.hashline_bindings
3609    }
3610
3611    /// Access the checkpoint store.
3612    pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
3613        &self.checkpoint
3614    }
3615
3616    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
3617        self.app.set_db(conn);
3618        self.compression_aggregates.clear();
3619    }
3620
3621    pub fn clear_db(&self) {
3622        self.app.clear_db();
3623        self.compression_aggregates.clear();
3624    }
3625
3626    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
3627        self.app.db()
3628    }
3629
3630    pub(crate) fn compression_aggregate_cache(
3631        &self,
3632    ) -> &crate::db::compression_events::CompressionAggregateCache {
3633        self.compression_aggregates.as_ref()
3634    }
3635
3636    /// Access an owned configuration snapshot.
3637    pub fn config(&self) -> Arc<Config> {
3638        let guard = match self.config.read() {
3639            Ok(guard) => guard,
3640            Err(poisoned) => poisoned.into_inner(),
3641        };
3642        Arc::clone(&*guard)
3643    }
3644
3645    /// Atomically publish a fully-built configuration snapshot.
3646    pub fn set_config(&self, config: Config) {
3647        let next = Arc::new(config);
3648        let project_root_changed = {
3649            let mut guard = self
3650                .config
3651                .write()
3652                .unwrap_or_else(std::sync::PoisonError::into_inner);
3653            // Compare the configured spelling, not a normalized equivalent:
3654            // that spelling is the memo key for containment-root resolution.
3655            let changed = guard.project_root.as_ref().map(|root| root.as_os_str())
3656                != next.project_root.as_ref().map(|root| root.as_os_str());
3657            *guard = next;
3658            changed
3659        };
3660        if project_root_changed {
3661            self.path_restriction_root_memo.lock().take();
3662        }
3663    }
3664
3665    #[cfg(test)]
3666    pub(crate) fn path_restriction_root_memo_is_empty_for_test(&self) -> bool {
3667        self.path_restriction_root_memo.lock().is_none()
3668    }
3669
3670    #[cfg(test)]
3671    pub(crate) fn path_restriction_root_canonicalizations_for_test(&self) -> usize {
3672        self.path_restriction_root_canonicalizations
3673            .load(Ordering::SeqCst)
3674    }
3675
3676    /// Clone-mutate-publish the current configuration without returning a guard.
3677    pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
3678        let mut next = self.config().as_ref().clone();
3679        update(&mut next);
3680        self.set_config(next);
3681    }
3682
3683    pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
3684        let mut requests = self.force_restrict_requests.lock();
3685        *requests.entry(req_id.to_string()).or_insert(0) += 1;
3686        ForceRestrictGuard {
3687            ctx: self,
3688            req_id: req_id.to_string(),
3689        }
3690    }
3691
3692    pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
3693        let _guard = self.force_restrict_guard(req_id);
3694        f()
3695    }
3696
3697    pub fn request_force_restrict(&self, req_id: &str) -> bool {
3698        self.force_restrict_requests.lock().contains_key(req_id)
3699    }
3700
3701    fn release_force_restrict(&self, req_id: &str) {
3702        let mut requests = self.force_restrict_requests.lock();
3703        match requests.get_mut(req_id) {
3704            Some(count) if *count > 1 => *count -= 1,
3705            Some(_) => {
3706                requests.remove(req_id);
3707            }
3708            None => {}
3709        }
3710    }
3711
3712    pub fn set_harness(&self, harness: Harness) {
3713        self.bash_background.set_harness(harness.clone());
3714        *self.harness.lock() = Some(harness);
3715    }
3716
3717    pub fn harness_opt(&self) -> Option<Harness> {
3718        self.harness.lock().clone()
3719    }
3720
3721    pub fn harness(&self) -> Harness {
3722        self.harness_opt()
3723            .expect("harness set by configure before any tool call")
3724    }
3725
3726    pub fn storage_dir(&self) -> PathBuf {
3727        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
3728    }
3729
3730    pub fn harness_dir(&self) -> PathBuf {
3731        self.storage_dir().join(self.harness().storage_segment())
3732    }
3733
3734    /// Refresh the in-memory list of durable build suspensions during health
3735    /// maintenance so reply handling can return the cached snapshot instead of
3736    /// querying storage.
3737    pub(crate) fn refresh_build_suspensions_for_health(
3738        &self,
3739        project_root: &Path,
3740        project_key: Option<&str>,
3741    ) {
3742        let now_ms = SystemTime::now()
3743            .duration_since(UNIX_EPOCH)
3744            .unwrap_or_default()
3745            .as_millis()
3746            .min(u128::from(u64::MAX)) as u64;
3747        self.refresh_build_suspensions_for_health_at(project_root, project_key, now_ms);
3748    }
3749
3750    pub(crate) fn refresh_build_suspensions_for_health_at(
3751        &self,
3752        project_root: &Path,
3753        project_key: Option<&str>,
3754        now_ms: u64,
3755    ) {
3756        let suspended_domains = project_key
3757            .and_then(|key| {
3758                let path = self
3759                    .storage_dir()
3760                    .join("callgraph")
3761                    .join(key)
3762                    .join("build-breaker.sqlite");
3763                path.is_file().then_some(path)
3764            })
3765            .and_then(|path| crate::build_breaker::BuildDeathBreaker::open(path).ok())
3766            .and_then(|breaker| {
3767                breaker
3768                    .active_suspensions_for_root_at(&project_root.display().to_string(), now_ms)
3769                    .ok()
3770            })
3771            .unwrap_or_default()
3772            .into_iter()
3773            .map(|suspension| {
3774                let age_s = suspension.age_seconds_at(now_ms);
3775                SuspendedDomainHealthSnapshot {
3776                    domain: suspension.domain.as_str().to_string(),
3777                    reason: suspension.reason,
3778                    death_count: suspension.death_count,
3779                    age_s,
3780                }
3781            })
3782            .collect();
3783        if let Ok(mut snapshot) = self.health_build_suspensions.write() {
3784            *snapshot = suspended_domains;
3785        }
3786    }
3787
3788    pub fn inspect_dir(&self) -> PathBuf {
3789        if let Some(root) = self
3790            .canonical_cache_root_opt()
3791            .or_else(|| self.config().project_root.clone())
3792        {
3793            self.storage_dir()
3794                .join("inspect")
3795                .join(crate::path_identity::project_scope_key(&root))
3796        } else {
3797            self.storage_dir().join("inspect").join("unconfigured")
3798        }
3799    }
3800
3801    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3802        self.harness_dir()
3803            .join("bash-tasks")
3804            .join(hash_session(session_id))
3805    }
3806
3807    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3808        self.harness_dir()
3809            .join("backups")
3810            .join(hash_session(session_id))
3811            .join(path_hash)
3812    }
3813
3814    pub fn filters_dir(&self) -> PathBuf {
3815        self.harness_dir().join("filters")
3816    }
3817
3818    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
3819    pub fn trust_file(&self) -> PathBuf {
3820        self.storage_dir().join("trusted-filter-projects.json")
3821    }
3822
3823    pub fn set_canonical_cache_root(&self, root: PathBuf) {
3824        debug_assert!(root.is_absolute());
3825        let root_changed = {
3826            let mut current = self.canonical_cache_root.lock();
3827            let changed = current.as_deref() != Some(root.as_path());
3828            *current = Some(root);
3829            changed
3830        };
3831        if root_changed {
3832            let mut tier2 = self
3833                .status_bar_tier2
3834                .write()
3835                .unwrap_or_else(std::sync::PoisonError::into_inner);
3836            let generation = tier2.generation.wrapping_add(1);
3837            *tier2 = StatusBarTier2 {
3838                generation,
3839                ..StatusBarTier2::default()
3840            };
3841            self.status_bar_last_emitted.clear();
3842        }
3843    }
3844
3845    pub fn canonical_cache_root(&self) -> PathBuf {
3846        self.canonical_cache_root
3847            .lock()
3848            .clone()
3849            .expect("canonical_cache_root accessed before handle_configure")
3850    }
3851
3852    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3853        self.canonical_cache_root.lock().clone()
3854    }
3855
3856    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3857        *self.is_worktree_bridge.lock() = is_worktree_bridge;
3858        *self.git_common_dir.lock() = git_common_dir;
3859        // The configure-time worktree probe already applies the test seam, so
3860        // automatic Tier-2 scheduling follows the same effective root role as
3861        // callgraph cold-build gating while explicit inspect demand stays enabled.
3862        self.inspect_manager
3863            .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3864        let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3865        self.callgraph_writer
3866            .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3867    }
3868
3869    pub fn set_artifact_owner(
3870        &self,
3871        status: Option<ArtifactOwnerStatus>,
3872        lease: Option<ArtifactOwnerLease>,
3873    ) {
3874        let read_only = status
3875            .as_ref()
3876            .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3877        self.shared_artifacts_read_only
3878            .store(read_only, Ordering::SeqCst);
3879        self.callgraph_writer
3880            .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3881        self.inspect_writer.store(true, Ordering::SeqCst);
3882        *self.artifact_owner_status.lock() = status;
3883        *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3884    }
3885
3886    pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3887        self.callgraph_writer
3888            .store(callgraph_writer, Ordering::SeqCst);
3889        self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3890    }
3891
3892    pub fn callgraph_writer(&self) -> bool {
3893        self.callgraph_writer.load(Ordering::SeqCst)
3894    }
3895
3896    pub fn inspect_writer(&self) -> bool {
3897        self.inspect_writer.load(Ordering::SeqCst)
3898    }
3899
3900    pub fn shared_artifacts_read_only(&self) -> bool {
3901        !self.callgraph_writer()
3902    }
3903
3904    /// Mark whether this context serves standalone NDJSON requests rather than
3905    /// a live subc route. Only standalone queries may disclose a stale CLI
3906    /// snapshot instead of following the daemon's normal freshness path.
3907    #[doc(hidden)]
3908    pub fn set_daemonless_query_mode(&self, enabled: bool) {
3909        self.daemonless_query_mode.store(enabled, Ordering::SeqCst);
3910    }
3911
3912    pub(crate) fn daemonless_query_mode(&self) -> bool {
3913        self.daemonless_query_mode.load(Ordering::SeqCst)
3914    }
3915
3916    /// True when this root is borrow-only and `worktree.ram_overlay` is on.
3917    ///
3918    /// Search and symbol-cache watcher arms may then apply local edits to the
3919    /// in-RAM trigram delta. Persist stays fail-closed: a borrow-only root
3920    /// never writes the shared `cache.bin`, overlay or not.
3921    pub fn ram_overlay_active(&self) -> bool {
3922        self.shared_artifacts_read_only() && self.config().worktree.ram_overlay
3923    }
3924
3925    pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3926        self.artifact_owner_status.lock().clone()
3927    }
3928
3929    pub fn is_worktree_bridge(&self) -> bool {
3930        *self.is_worktree_bridge.lock()
3931    }
3932
3933    pub fn git_common_dir(&self) -> Option<PathBuf> {
3934        self.git_common_dir.lock().clone()
3935    }
3936
3937    /// Replace the current degraded-mode reasons. Empty vec = full-featured
3938    /// mode (no degradation). Called by `handle_configure` after deciding
3939    /// which subsystems to disable for this project root.
3940    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3941        *self.degraded_reasons.lock() = reasons;
3942    }
3943
3944    pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3945        self.heavy_root_work_allowed
3946            .store(allowed, Ordering::SeqCst);
3947    }
3948
3949    pub fn heavy_root_work_allowed(&self) -> bool {
3950        self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3951    }
3952
3953    fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3954        if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3955            return Some(false);
3956        }
3957        self.subc_lifecycle.try_is_bound()
3958    }
3959
3960    pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3961        let reason = reason.into();
3962        let mut reasons = self.degraded_reasons.lock();
3963        if reasons.iter().any(|existing| existing == &reason) {
3964            return false;
3965        }
3966        reasons.push(reason);
3967        true
3968    }
3969
3970    /// Snapshot of current degraded-mode reasons. Order is stable
3971    /// (insertion order from `set_degraded_reasons`) so UI rendering and
3972    /// snapshot diffs are deterministic.
3973    pub fn degraded_reasons(&self) -> Vec<String> {
3974        self.degraded_reasons.lock().clone()
3975    }
3976
3977    /// True iff at least one degraded reason is recorded.
3978    pub fn is_degraded(&self) -> bool {
3979        !self.degraded_reasons.lock().is_empty()
3980    }
3981
3982    /// True when configure identified the current root as exactly `$HOME`.
3983    /// Home is a user container, never a project root, so callgraph queries
3984    /// must report the intentional disabled state instead of a retryable miss.
3985    pub fn is_home_root(&self) -> bool {
3986        self.degraded_reasons
3987            .lock()
3988            .iter()
3989            .any(|reason| reason == "home_root")
3990    }
3991
3992    pub fn cache_role(&self) -> &'static str {
3993        if self.canonical_cache_root.lock().is_none() {
3994            "not_initialized"
3995        } else if self.is_worktree_bridge() {
3996            "worktree"
3997        } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3998            "read_only"
3999        } else {
4000            "main"
4001        }
4002    }
4003
4004    /// Access the persisted call graph store.
4005    pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
4006        self.callgraph_store.as_ref()
4007    }
4008
4009    pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
4010        self.callgraph_store_force_requested
4011            .fetch_add(1, Ordering::SeqCst)
4012            .wrapping_add(1)
4013    }
4014
4015    pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
4016        let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
4017        let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
4018        (requested > fulfilled).then_some(requested)
4019    }
4020
4021    pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
4022        self.callgraph_store_force_fulfilled
4023            .fetch_max(token, Ordering::SeqCst);
4024    }
4025
4026    #[doc(hidden)]
4027    pub fn record_callgraph_store_build_denied(&self, generation: u64, reason: String) {
4028        *self.callgraph_store_build_denied.lock() = Some((generation, reason));
4029    }
4030
4031    #[doc(hidden)]
4032    pub fn record_callgraph_store_build_suspension(
4033        &self,
4034        generation: u64,
4035        suspension: crate::build_breaker::BuildSuspension,
4036    ) {
4037        *self.callgraph_store_build_suspension.lock() = Some((generation, suspension));
4038    }
4039
4040    #[doc(hidden)]
4041    pub fn clear_callgraph_store_build_denied(&self) {
4042        *self.callgraph_store_build_denied.lock() = None;
4043        *self.callgraph_store_build_suspension.lock() = None;
4044    }
4045
4046    fn callgraph_store_build_suspension(&self) -> Option<crate::build_breaker::BuildSuspension> {
4047        let generation = self.configure_generation();
4048        let mut suspended = self.callgraph_store_build_suspension.lock();
4049        match suspended.as_ref() {
4050            Some((suspended_generation, value)) if *suspended_generation == generation => {
4051                Some(value.clone())
4052            }
4053            Some(_) => {
4054                *suspended = None;
4055                None
4056            }
4057            None => None,
4058        }
4059    }
4060
4061    fn callgraph_store_build_denial(&self) -> Option<String> {
4062        let generation = self.configure_generation();
4063        let mut denied = self.callgraph_store_build_denied.lock();
4064        match denied.as_ref() {
4065            Some((denied_generation, reason)) if *denied_generation == generation => {
4066                Some(reason.clone())
4067            }
4068            Some(_) => {
4069                *denied = None;
4070                None
4071            }
4072            None => None,
4073        }
4074    }
4075
4076    pub fn callgraph_store_dir(&self) -> PathBuf {
4077        if let Some(root) = self.callgraph_project_root() {
4078            self.storage_dir()
4079                .join("callgraph")
4080                .join(self.memoized_artifact_cache_key(&root))
4081        } else {
4082            self.storage_dir().join("callgraph").join("unconfigured")
4083        }
4084    }
4085
4086    pub fn ensure_callgraph_store(
4087        &self,
4088    ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
4089        self.ensure_callgraph_store_with_flag(true)
4090    }
4091
4092    fn ensure_callgraph_store_with_flag(
4093        &self,
4094        respect_config_flag: bool,
4095    ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
4096        if respect_config_flag && !self.config().callgraph_store {
4097            return Ok(None);
4098        }
4099        if !self.heavy_root_work_allowed() {
4100            return Ok(None);
4101        }
4102        self.revalidate_callgraph_store_generation();
4103        let force_token = self.pending_callgraph_store_force_token();
4104        if force_token.is_none() {
4105            if let Some(store) = {
4106                let guard = self
4107                    .callgraph_store
4108                    .read()
4109                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4110                guard.as_ref().map(Arc::clone)
4111            } {
4112                self.schedule_legacy_callgraph_migration_if_needed(
4113                    store.as_ref(),
4114                    store.project_root().to_path_buf(),
4115                    self.callgraph_store_dir(),
4116                );
4117                return Ok(Some(store));
4118            }
4119        }
4120
4121        let Some(project_root) = self.callgraph_project_root() else {
4122            return Ok(None);
4123        };
4124        let callgraph_dir = self.callgraph_store_dir();
4125
4126        // Preserve a readable legacy fallback while writer-capable processes
4127        // migrate it on the cold-build lane. Opening before the writer path is
4128        // also the cheap fast path for an already-published root generation.
4129        if force_token.is_none() {
4130            if let Some(store) =
4131                CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
4132            {
4133                let store = Arc::new(store);
4134                {
4135                    let mut guard = self
4136                        .callgraph_store
4137                        .write()
4138                        .unwrap_or_else(std::sync::PoisonError::into_inner);
4139                    *guard = Some(Arc::clone(&store));
4140                }
4141                self.schedule_legacy_callgraph_migration_if_needed(
4142                    store.as_ref(),
4143                    project_root,
4144                    callgraph_dir,
4145                );
4146                return Ok(Some(store));
4147            }
4148        }
4149
4150        if !self.callgraph_writer() {
4151            return Ok(None);
4152        }
4153        let build_generation = self.configure_generation();
4154        let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4155        let Some(persist_epoch) = self
4156            .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
4157        else {
4158            return Ok(None);
4159        };
4160        // Let the store walk directly into its staging table. Keeping discovery
4161        // inside the builder prevents a second corpus-sized path inventory here.
4162        let (store, _stats) = crate::callgraph_store::with_publish_epoch(
4163            persist_epoch_flag.clone(),
4164            persist_epoch,
4165            || {
4166                if force_token.is_some() {
4167                    CallGraphStore::force_cold_build_with_lease_chunked(
4168                        callgraph_dir.clone(),
4169                        project_root.clone(),
4170                        &[],
4171                        self.config().callgraph_chunk_size,
4172                    )
4173                    .map(|(store, _stats)| (store, ()))
4174                } else {
4175                    CallGraphStore::ensure_built_with_lease_chunked(
4176                        callgraph_dir.clone(),
4177                        project_root.clone(),
4178                        &[],
4179                        self.config().callgraph_chunk_size,
4180                    )
4181                    .map(|(store, _stats)| (store, ()))
4182                }
4183            },
4184        )?;
4185        drop(store);
4186
4187        let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
4188            return Ok(None);
4189        };
4190        let store = Arc::new(store);
4191        self.run_if_subc_bound_generation(build_generation, || {
4192            if persist_epoch_flag.current() != persist_epoch {
4193                return None;
4194            }
4195            let mut guard = self
4196                .callgraph_store
4197                .write()
4198                .unwrap_or_else(std::sync::PoisonError::into_inner);
4199            *guard = Some(Arc::clone(&store));
4200            if let Some(force_token) = force_token {
4201                self.fulfill_callgraph_store_force_token(force_token);
4202            }
4203            Some(Arc::clone(&store))
4204        })
4205        .flatten()
4206        .map_or(Ok(None), |store| Ok(Some(store)))
4207    }
4208
4209    /// Resolve the project root used for the callgraph store: prefer the
4210    /// canonical cache root, falling back to the configured project root.
4211    pub fn callgraph_project_root(&self) -> Option<PathBuf> {
4212        self.canonical_cache_root_opt().or_else(|| {
4213            self.config()
4214                .project_root
4215                .clone()
4216                .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
4217        })
4218    }
4219
4220    /// Drop a cached reader when another process published a newer generation.
4221    /// The next access reopens through the pointer and converges to that
4222    /// generation instead of serving a stale long-lived connection.
4223    pub fn revalidate_callgraph_store_generation(&self) {
4224        let (superseded, legacy_fallback) = {
4225            let guard = self
4226                .callgraph_store
4227                .read()
4228                .unwrap_or_else(std::sync::PoisonError::into_inner);
4229            guard
4230                .as_ref()
4231                .map(|store| (!store.is_current(), store.is_legacy_fallback()))
4232                .unwrap_or((false, false))
4233        };
4234        if !superseded {
4235            return;
4236        }
4237        // A local migration publishes its pointer just before sending the new
4238        // store to the main-loop drain. Keep queries on the fallback during that
4239        // narrow handoff instead of reporting a transient Building state.
4240        if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
4241            return;
4242        }
4243        let mut guard = self
4244            .callgraph_store
4245            .write()
4246            .unwrap_or_else(std::sync::PoisonError::into_inner);
4247        *guard = None;
4248    }
4249
4250    pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
4251        self.callgraph_store_for_ops_with_wait(callgraph_build_wait_window())
4252    }
4253
4254    /// Warm the callgraph store from the transport loop without the query-op wait.
4255    ///
4256    /// Query operations can wait up to `AFT_CALLGRAPH_BUILD_WAIT_MS` for a cold
4257    /// build to become ready. Configure maintenance and work resumed after semantic
4258    /// index initialization run on the loop that reads stdin; waiting there would
4259    /// delay EOF handling until the build or wait window finishes, leaving the
4260    /// process alive after the client closes the pipe.
4261    pub(crate) fn schedule_callgraph_store_warm(&self) -> CallgraphStoreAccess {
4262        self.callgraph_store_for_ops_with_wait(Duration::ZERO)
4263    }
4264
4265    fn callgraph_store_for_ops_with_wait(&self, wait: Duration) -> CallgraphStoreAccess {
4266        if !self.heavy_root_work_allowed() {
4267            return CallgraphStoreAccess::Unavailable;
4268        }
4269        let operation_generation = self.configure_generation();
4270
4271        // Converge to a newer generation another process (or a local cold
4272        // rebuild) may have published: if our resident store is superseded, drop
4273        // it so the open path below reopens via the pointer. Cheap pointer read.
4274        self.revalidate_callgraph_store_generation();
4275        let force_token = self.pending_callgraph_store_force_token();
4276        if force_token.is_none() {
4277            if let Some(store) = {
4278                let guard = self
4279                    .callgraph_store
4280                    .read()
4281                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4282                guard.as_ref().map(Arc::clone)
4283            } {
4284                self.clear_callgraph_store_build_denied();
4285                self.schedule_legacy_callgraph_migration_if_needed(
4286                    store.as_ref(),
4287                    store.project_root().to_path_buf(),
4288                    self.callgraph_store_dir(),
4289                );
4290                return CallgraphStoreAccess::Ready(store);
4291            }
4292        }
4293
4294        if let Some(suspension) = self.callgraph_store_build_suspension() {
4295            return CallgraphStoreAccess::Suspended(suspension);
4296        }
4297        if let Some(reason) = self.callgraph_store_build_denial() {
4298            return CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason));
4299        }
4300
4301        // Query ops share an existing build instead of starting a second one.
4302        // Their bounded wait below must cover work scheduled by maintenance as
4303        // well as work started by the query itself.
4304        let build_in_flight = self.callgraph_store_rx.lock().is_some();
4305
4306        let Some(project_root) = self.callgraph_project_root() else {
4307            return CallgraphStoreAccess::Unavailable;
4308        };
4309        let callgraph_dir = self.callgraph_store_dir();
4310
4311        if !build_in_flight {
4312            match CallGraphStore::cold_build_suspension(&callgraph_dir, &project_root) {
4313                Ok(Some(suspension)) => return CallgraphStoreAccess::Suspended(suspension),
4314                Ok(None) => {}
4315                Err(error) => return CallgraphStoreAccess::Error(error),
4316            }
4317        }
4318
4319        if !build_in_flight {
4320            if force_token.is_none() {
4321                match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
4322                    Ok(Some(store)) => {
4323                        let store = Arc::new(store);
4324                        let installed =
4325                            self.run_if_subc_bound_generation(operation_generation, || {
4326                                let mut guard = self
4327                                    .callgraph_store
4328                                    .write()
4329                                    .unwrap_or_else(std::sync::PoisonError::into_inner);
4330                                *guard = Some(Arc::clone(&store));
4331                                Arc::clone(&store)
4332                            });
4333                        let Some(store) = installed else {
4334                            return CallgraphStoreAccess::Unavailable;
4335                        };
4336                        self.clear_callgraph_store_build_denied();
4337                        self.schedule_legacy_callgraph_migration_if_needed(
4338                            store.as_ref(),
4339                            project_root.clone(),
4340                            callgraph_dir.clone(),
4341                        );
4342                        return CallgraphStoreAccess::Ready(store);
4343                    }
4344                    Ok(None) => {
4345                        if !self.callgraph_writer() {
4346                            return CallgraphStoreAccess::Unavailable;
4347                        }
4348                    }
4349                    Err(error) => {
4350                        if !self.callgraph_writer() {
4351                            return CallgraphStoreAccess::Unavailable;
4352                        }
4353                        crate::slog_warn!(
4354                            "callgraph read-only open failed before writer promotion: {}",
4355                            error
4356                        );
4357                    }
4358                }
4359            } else if !self.callgraph_writer() {
4360                return CallgraphStoreAccess::Unavailable;
4361            }
4362
4363            if self.semantic_cold_seed_active() {
4364                self.defer_callgraph_store_warm_for_semantic_cold_seed();
4365                return CallgraphStoreAccess::Building;
4366            }
4367
4368            // Cold build required: run it off the request thread and return
4369            // `Building` so the agent retries (the watcher keeps the store fresh
4370            // once it lands). By default this never blocks the request thread.
4371            //
4372            // `wait` is the query-op inline window (`AFT_CALLGRAPH_BUILD_WAIT_MS`,
4373            // default 0). Transport-loop warmers pass zero so stdin EOF stays
4374            // observable while the cold build runs in the background.
4375            let work = if let Some(force_token) = force_token {
4376                crate::slog_info!(
4377                    "callgraph cold-build decision: reason=corpus drift; action=force rebuild"
4378                );
4379                CallgraphBackgroundWork::ForceRebuild(force_token)
4380            } else {
4381                crate::slog_info!(
4382                    "callgraph cold-build decision: reason=no current generation; action=ensure build"
4383                );
4384                CallgraphBackgroundWork::Ensure
4385            };
4386            // A concurrent caller may have installed a receiver after the
4387            // snapshot above. The spawn path deduplicates that race, and the
4388            // common wait path below joins whichever build won.
4389            let _ = self.spawn_callgraph_store_cold_build(
4390                project_root.clone(),
4391                callgraph_dir.clone(),
4392                work,
4393            );
4394        }
4395
4396        if !wait.is_zero() {
4397            let (received, receiver_generation, receiver_epoch) = {
4398                let rx_ref = self.callgraph_store_rx.lock();
4399                let Some(rx) = rx_ref.as_ref() else {
4400                    return CallgraphStoreAccess::Building;
4401                };
4402                (
4403                    rx.recv_timeout(wait),
4404                    self.callgraph_store_rx_generation(),
4405                    self.callgraph_store_rx_epoch(),
4406                )
4407            };
4408            match received {
4409                Ok(CallGraphStoreBuildEvent::Ready {
4410                    store,
4411                    fulfilled_force_token,
4412                    publication_epoch,
4413                }) => {
4414                    if self.callgraph_persist_epoch_flag().current() != publication_epoch {
4415                        // Superseded publication: a newer configure owns the
4416                        // pointer. Clear the receiver and report Building so the
4417                        // replacement build's event installs instead.
4418                        drop(store);
4419                        let _ = self.with_current_callgraph_store_rx(
4420                            receiver_generation,
4421                            receiver_epoch,
4422                            |receiver| {
4423                                *receiver = None;
4424                            },
4425                        );
4426                        return CallgraphStoreAccess::Building;
4427                    }
4428                    // The completed build owns the writer lease until dropped;
4429                    // release it before reopening the published generation.
4430                    remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
4431                    drop(store);
4432                    let reopened =
4433                        CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
4434                    let mut pending = Vec::new();
4435                    let outcome = self.with_current_callgraph_store_rx(
4436                        receiver_generation,
4437                        receiver_epoch,
4438                        |receiver| {
4439                            *receiver = None;
4440                            match reopened {
4441                                Ok(Some(store)) => {
4442                                    let ready = Arc::new(store);
4443                                    self.clear_callgraph_store_build_denied();
4444                                    *self
4445                                        .callgraph_store
4446                                        .write()
4447                                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
4448                                        Some(Arc::clone(&ready));
4449                                    // This take and the refresh worker's post-defer re-check form a
4450                                    // check-then-act handoff: the store is installed before the
4451                                    // take, so one site sees parked paths with a ready store and
4452                                    // neither site needs to poll alone.
4453                                    pending = self.take_pending_callgraph_store_paths();
4454                                    if let Some(force_token) = fulfilled_force_token {
4455                                        self.fulfill_callgraph_store_force_token(force_token);
4456                                    }
4457                                    CallgraphStoreAccess::Ready(ready)
4458                                }
4459                                Ok(None) => CallgraphStoreAccess::Building,
4460                                Err(error) => CallgraphStoreAccess::Error(error),
4461                            }
4462                        },
4463                    );
4464                    let Some(outcome) = outcome else {
4465                        return if self.subc_unbound_quiesced()
4466                            || self.configure_generation() != receiver_generation
4467                        {
4468                            CallgraphStoreAccess::Unavailable
4469                        } else {
4470                            CallgraphStoreAccess::Building
4471                        };
4472                    };
4473                    if !pending.is_empty() {
4474                        let _ = self.enqueue_callgraph_store_refresh(pending);
4475                    }
4476                    if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
4477                        let _ = self.request_tier2_refresh_pull();
4478                    }
4479                    return outcome;
4480                }
4481                Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
4482                    let suspended = self.with_current_callgraph_store_rx(
4483                        receiver_generation,
4484                        receiver_epoch,
4485                        |receiver| {
4486                            *receiver = None;
4487                            self.record_callgraph_store_build_suspension(
4488                                receiver_generation,
4489                                suspension.clone(),
4490                            );
4491                            CallgraphStoreAccess::Suspended(suspension)
4492                        },
4493                    );
4494                    return suspended.unwrap_or(CallgraphStoreAccess::Unavailable);
4495                }
4496                Ok(CallGraphStoreBuildEvent::Denied { reason }) => {
4497                    let denied = self.with_current_callgraph_store_rx(
4498                        receiver_generation,
4499                        receiver_epoch,
4500                        |receiver| {
4501                            *receiver = None;
4502                            self.record_callgraph_store_build_denied(
4503                                receiver_generation,
4504                                reason.clone(),
4505                            );
4506                            CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
4507                        },
4508                    );
4509                    return denied.unwrap_or(CallgraphStoreAccess::Unavailable);
4510                }
4511                Ok(CallGraphStoreBuildEvent::Settled) => {
4512                    let _ = self.with_current_callgraph_store_rx(
4513                        receiver_generation,
4514                        receiver_epoch,
4515                        |receiver| *receiver = None,
4516                    );
4517                    return CallgraphStoreAccess::Building;
4518                }
4519                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
4520                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
4521                    let _ = self.with_current_callgraph_store_rx(
4522                        receiver_generation,
4523                        receiver_epoch,
4524                        |receiver| *receiver = None,
4525                    );
4526                }
4527            }
4528        }
4529        CallgraphStoreAccess::Building
4530    }
4531
4532    fn schedule_legacy_callgraph_migration_if_needed(
4533        &self,
4534        store: &ReadonlyCallGraphStore,
4535        project_root: PathBuf,
4536        callgraph_dir: PathBuf,
4537    ) {
4538        if !store.is_legacy_fallback()
4539            || !self.callgraph_writer()
4540            || !self.heavy_root_work_allowed()
4541        {
4542            return;
4543        }
4544        if self.semantic_cold_seed_active() {
4545            self.defer_callgraph_store_warm_for_semantic_cold_seed();
4546            return;
4547        }
4548        let _ = self.spawn_callgraph_store_cold_build(
4549            project_root,
4550            callgraph_dir,
4551            CallgraphBackgroundWork::LegacyMigration,
4552        );
4553    }
4554
4555    fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
4556        let mut roots = self
4557            .configured_session_roots
4558            .lock()
4559            .iter()
4560            .map(|(root, _session)| root.clone())
4561            .collect::<BTreeSet<_>>();
4562        roots.insert(current_root.to_path_buf());
4563        roots
4564            .iter()
4565            // Configure already derived these keys. Re-running the git
4566            // root-commit probe here would block the transport loop on spawn
4567            // retries when git is missing from PATH.
4568            .map(|root| self.memoized_artifact_cache_key(root))
4569            .collect()
4570    }
4571
4572    /// Atomically mark root-keyed callgraph maintenance in flight and spawn it
4573    /// on the cold-build lane. The same receiver/install path handles cold
4574    /// builds and legacy migrations, so watcher edits are queued and replayed
4575    /// against whichever root-keyed generation publishes.
4576    fn spawn_callgraph_store_cold_build(
4577        &self,
4578        project_root: PathBuf,
4579        callgraph_dir: PathBuf,
4580        work: CallgraphBackgroundWork,
4581    ) -> bool {
4582        if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
4583            return false;
4584        }
4585        let generation = self.configure_generation();
4586        self.run_if_subc_bound_generation(generation, || {
4587            self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
4588        })
4589        .unwrap_or(false)
4590    }
4591
4592    /// Start a callgraph worker after lifecycle admission has been acquired.
4593    fn spawn_callgraph_store_cold_build_admitted(
4594        &self,
4595        project_root: PathBuf,
4596        callgraph_dir: PathBuf,
4597        work: CallgraphBackgroundWork,
4598    ) -> bool {
4599        let session_id = crate::log_ctx::current_session();
4600        let chunk_size = self.config().callgraph_chunk_size;
4601        let build_generation = self.configure_generation();
4602        let configured_keys = self.configured_callgraph_keys(&project_root);
4603        let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
4604
4605        let mut rx_guard = self.callgraph_store_rx.lock();
4606        if rx_guard.is_some() {
4607            return false;
4608        }
4609
4610        let limiter = self.cold_build_limiter();
4611        let request = crate::cold_build_limiter::ColdBuildAdmissionRequest::new(
4612            "callgraph-background",
4613            crate::cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
4614        );
4615        let Some(permit) =
4616            crate::cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
4617        else {
4618            crate::slog_info!(
4619                "callgraph store background work deferred by cold build limit ({})",
4620                limiter.limit()
4621            );
4622            return false;
4623        };
4624
4625        let force_token = match work {
4626            CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
4627            CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
4628        };
4629        let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
4630        self.note_callgraph_store_rx_generation(build_generation);
4631        self.next_callgraph_store_rx_epoch();
4632        *rx_guard = Some(rx);
4633        let persist_epoch = self.next_callgraph_persist_epoch();
4634        let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4635
4636        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
4637
4638        std::thread::spawn(move || {
4639            let _permit = permit;
4640            let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
4641            crate::log_ctx::with_session(session_id, || {
4642                wait_on_callgraph_build_start_gate(&project_root);
4643                if persist_epoch_flag.current() != persist_epoch {
4644                    crate::slog_info!(
4645                        "callgraph store background work skipped for superseded epoch {}",
4646                        persist_epoch
4647                    );
4648                    return;
4649                }
4650                let built = crate::callgraph_store::with_publish_epoch(
4651                    persist_epoch_flag.clone(),
4652                    persist_epoch,
4653                    || match work {
4654                        CallgraphBackgroundWork::LegacyMigration => {
4655                            CallGraphStore::migrate_legacy_with_lease(
4656                                callgraph_dir.clone(),
4657                                project_root.clone(),
4658                            )
4659                        }
4660                        CallgraphBackgroundWork::ForceRebuild(_) => {
4661                            let files = crate::callgraph::walk_project_files(&project_root)
4662                                .collect::<Vec<_>>();
4663                            CallGraphStore::force_cold_build_with_lease_chunked(
4664                                callgraph_dir.clone(),
4665                                project_root.clone(),
4666                                &files,
4667                                chunk_size,
4668                            )
4669                            .map(|(store, _)| Some(store))
4670                        }
4671                        CallgraphBackgroundWork::Ensure => {
4672                            let files = crate::callgraph::walk_project_files(&project_root)
4673                                .collect::<Vec<_>>();
4674                            CallGraphStore::ensure_built_with_lease_chunked(
4675                                callgraph_dir.clone(),
4676                                project_root.clone(),
4677                                &files,
4678                                chunk_size,
4679                            )
4680                            .map(|(store, _)| Some(store))
4681                        }
4682                    },
4683                );
4684                match built {
4685                    Ok(Some(store)) => {
4686                        if store.is_legacy_migration() {
4687                            match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
4688                                &callgraph_dir,
4689                                &configured_keys,
4690                            ) {
4691                                Ok(true)
4692                                    if summary_logged
4693                                        .compare_exchange(
4694                                            false,
4695                                            true,
4696                                            Ordering::SeqCst,
4697                                            Ordering::SeqCst,
4698                                        )
4699                                        .is_ok() =>
4700                                {
4701                                    crate::slog_info!(
4702                                        "all legacy callgraph partitions migrated for configured roots"
4703                                    );
4704                                }
4705                                Ok(_) => {}
4706                                Err(error) => crate::slog_warn!(
4707                                    "failed to inspect legacy callgraph migration completion: {}",
4708                                    error
4709                                ),
4710                            }
4711                        }
4712                        if persist_epoch_flag.is_current(persist_epoch) {
4713                            settlement.ready(store);
4714                        } else {
4715                            crate::slog_info!(
4716                                "callgraph store warm build result discarded for superseded publication epoch {}",
4717                                persist_epoch
4718                            );
4719                        }
4720                    }
4721                    Ok(None) => {}
4722                    Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
4723                        crate::slog_info!(
4724                            "callgraph store disk publication skipped for superseded epoch {}",
4725                            persist_epoch
4726                        );
4727                    }
4728                    Err(crate::callgraph_store::CallGraphStoreError::Suspended(suspension)) => {
4729                        crate::slog_warn!(
4730                            "callgraph store background work suspended: {}",
4731                            suspension.reason
4732                        );
4733                        settlement.suspended(suspension);
4734                    }
4735                    Err(crate::callgraph_store::CallGraphStoreError::Unavailable(reason))
4736                        if reason.ends_with("could not acquire writer capability") =>
4737                    {
4738                        crate::slog_warn!(
4739                            "callgraph store background work denied writer capability: {}",
4740                            reason
4741                        );
4742                        settlement.denied(reason);
4743                    }
4744                    Err(error) => {
4745                        crate::slog_warn!("callgraph store background work failed: {}", error);
4746                    }
4747                }
4748            });
4749        });
4750        true
4751    }
4752
4753    /// Access the callgraph-store background-build receiver (drained by the
4754    /// main loop once the cold build completes).
4755    pub fn callgraph_store_rx(
4756        &self,
4757    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
4758        &self.callgraph_store_rx
4759    }
4760
4761    /// Commit a dequeued result only while its lifecycle and receiver identity
4762    /// remain current. Lifecycle admission is intentionally acquired first,
4763    /// matching worker-start paths and preventing a lock-order cycle.
4764    #[doc(hidden)]
4765    pub fn with_current_callgraph_store_rx<R>(
4766        &self,
4767        generation: u64,
4768        epoch: u64,
4769        action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
4770    ) -> Option<R> {
4771        self.run_if_subc_bound_generation(generation, || {
4772            let mut receiver = self.callgraph_store_rx.lock();
4773            if receiver.is_none()
4774                || self.callgraph_store_rx_generation() != generation
4775                || self.callgraph_store_rx_epoch() != epoch
4776            {
4777                return None;
4778            }
4779            Some(action(&mut receiver))
4780        })
4781        .flatten()
4782    }
4783
4784    pub(crate) fn retire_callgraph_store_rx(&self) {
4785        let mut receiver = self.callgraph_store_rx.lock();
4786        *receiver = None;
4787        self.next_callgraph_store_rx_epoch();
4788    }
4789
4790    /// Rebind a live callgraph build receiver while its dedicated publication
4791    /// epoch remains valid for the same root and corpus inputs.
4792    pub(crate) fn adopt_callgraph_store_rx_generation(&self, generation: u64) -> bool {
4793        let receiver = self.callgraph_store_rx.lock();
4794        if receiver.is_none() {
4795            return false;
4796        }
4797        self.note_callgraph_store_rx_generation(generation);
4798        true
4799    }
4800
4801    pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
4802        self.callgraph_store_rx_generation
4803            .store(generation, Ordering::SeqCst);
4804    }
4805
4806    #[doc(hidden)]
4807    pub fn callgraph_store_rx_generation(&self) -> u64 {
4808        self.callgraph_store_rx_generation.load(Ordering::SeqCst)
4809    }
4810
4811    pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
4812        self.callgraph_store_rx_epoch
4813            .fetch_add(1, Ordering::SeqCst)
4814            .wrapping_add(1)
4815    }
4816
4817    #[doc(hidden)]
4818    pub fn callgraph_store_rx_epoch(&self) -> u64 {
4819        self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
4820    }
4821
4822    pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
4823        self.callgraph_persist_epoch.next()
4824    }
4825
4826    #[doc(hidden)]
4827    pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4828        self.callgraph_persist_epoch.clone()
4829    }
4830
4831    /// Record source-file paths that could not be applied to the writable store
4832    /// so the next ready-store replay can refresh them.
4833    pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
4834    where
4835        I: IntoIterator<Item = PathBuf>,
4836    {
4837        self.pending_callgraph_store_paths.lock().extend(paths);
4838    }
4839
4840    pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
4841    where
4842        I: IntoIterator<Item = PathBuf>,
4843    {
4844        let generation = self.configure_generation();
4845        self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
4846    }
4847
4848    pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
4849        &self,
4850        paths: I,
4851        generation: u64,
4852    ) -> bool
4853    where
4854        I: IntoIterator<Item = PathBuf>,
4855    {
4856        let paths = paths.into_iter().collect::<Vec<_>>();
4857        if paths.is_empty() {
4858            return true;
4859        }
4860        // A disabled or degraded root must not create a refresh worker merely
4861        // to discover later that it cannot write the callgraph store.
4862        if !self.config().callgraph_store || !self.heavy_root_work_allowed() {
4863            return true;
4864        }
4865        self.run_if_subc_bound_generation(generation, || {
4866            if !self.callgraph_writer() {
4867                self.add_pending_callgraph_store_paths(paths);
4868                return false;
4869            }
4870            let Some(project_root) = self.callgraph_project_root() else {
4871                self.add_pending_callgraph_store_paths(paths);
4872                return false;
4873            };
4874
4875            // The ticket fences the batch against lifecycle transitions and
4876            // cold-build publications: a superseded batch defers its paths to
4877            // the pending sink instead of committing into a store generation
4878            // that a newer configure no longer owns.
4879            let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
4880                self.subc_lifecycle_admission(),
4881                self.configure_generation_flag(),
4882                generation,
4883                self.callgraph_persist_epoch_flag(),
4884                self.callgraph_persist_epoch_flag().current(),
4885            );
4886            crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
4887                self.callgraph_store_dir(),
4888                project_root,
4889                paths,
4890                Arc::clone(&self.pending_callgraph_store_paths),
4891                crate::callgraph_store::CallgraphRefreshState::new(
4892                    Arc::clone(&self.callgraph_store),
4893                    Arc::clone(&self.heavy_root_work_allowed),
4894                ),
4895                ticket,
4896            )
4897        })
4898        .unwrap_or(false)
4899    }
4900
4901    /// Take and clear paths waiting for a ready writable store.
4902    ///
4903    /// Paths outside the current project root are dropped: the pending sink is
4904    /// shared with detached refresh batches, so a batch superseded by a root
4905    /// change can defer paths from the PREVIOUS root after configure cleared
4906    /// the sink. Replaying those would index foreign files into the new root's
4907    /// store (refresh accepts absolute out-of-root paths).
4908    pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
4909        let roots: Vec<PathBuf> = [
4910            self.canonical_cache_root_opt(),
4911            self.config().project_root.clone(),
4912        ]
4913        .into_iter()
4914        .flatten()
4915        .collect();
4916        std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
4917            .into_iter()
4918            .filter(|path| {
4919                let in_root = pending_path_in_roots(path, &roots);
4920                if !in_root {
4921                    crate::slog_debug!(
4922                        "dropping pending callgraph path outside current root: {}",
4923                        path.display()
4924                    );
4925                }
4926                in_root
4927            })
4928            .collect()
4929    }
4930
4931    /// Access the search index.
4932    pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
4933        &self.search_index
4934    }
4935
4936    /// Access the search-index build receiver.
4937    pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
4938        &self.search_index_rx
4939    }
4940
4941    pub(crate) fn install_search_index_rx(
4942        &self,
4943        receiver: crossbeam_channel::Receiver<SearchIndex>,
4944        generation: u64,
4945    ) -> u64 {
4946        let mut slot = self
4947            .search_index_rx
4948            .write()
4949            .unwrap_or_else(std::sync::PoisonError::into_inner);
4950        self.note_search_index_rx_generation(generation);
4951        let epoch = self.next_search_index_rx_epoch();
4952        *slot = Some(receiver);
4953        epoch
4954    }
4955
4956    pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4957        ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
4958    }
4959
4960    /// Keep generation/epoch validation and receiver mutation under the same
4961    /// lock used by receiver installation.
4962    pub(crate) fn with_current_search_index_rx<R>(
4963        &self,
4964        generation: u64,
4965        epoch: u64,
4966        action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
4967    ) -> Option<R> {
4968        self.run_if_subc_bound_generation(generation, || {
4969            let mut receiver = self
4970                .search_index_rx
4971                .write()
4972                .unwrap_or_else(std::sync::PoisonError::into_inner);
4973            if receiver.is_none()
4974                || self.search_index_rx_generation() != generation
4975                || self.search_index_rx_epoch() != epoch
4976            {
4977                return None;
4978            }
4979            Some(action(&mut receiver))
4980        })
4981        .flatten()
4982    }
4983
4984    pub(crate) fn retire_search_index_rx(&self) {
4985        let mut receiver = self
4986            .search_index_rx
4987            .write()
4988            .unwrap_or_else(std::sync::PoisonError::into_inner);
4989        *receiver = None;
4990        self.next_search_index_rx_epoch();
4991    }
4992
4993    pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4994        self.search_index_rx_generation
4995            .store(generation, Ordering::SeqCst);
4996    }
4997
4998    pub(crate) fn search_index_rx_generation(&self) -> u64 {
4999        self.search_index_rx_generation.load(Ordering::SeqCst)
5000    }
5001
5002    pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
5003        self.search_index_rx_epoch
5004            .fetch_add(1, Ordering::SeqCst)
5005            .wrapping_add(1)
5006    }
5007
5008    pub(crate) fn search_index_rx_epoch(&self) -> u64 {
5009        self.search_index_rx_epoch.load(Ordering::SeqCst)
5010    }
5011
5012    /// Allow one automatic search-index replacement load per configure
5013    /// generation. The drain disconnect path calls this before rescheduling a
5014    /// load whose worker exited without delivering an index; capping it at one
5015    /// prevents a persistently failing worker from being relaunched in a loop on
5016    /// the drain thread. After the cap is hit, the query-triggered reload
5017    /// (`trigger_search_index_reload_if_evicted`) remains the recovery path.
5018    pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
5019        const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
5020        let generation = self.configure_generation();
5021        let mut state = self.search_index_disconnect_reschedule.lock();
5022        if state.0 != generation {
5023            *state = (generation, 0);
5024        }
5025        if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
5026            return false;
5027        }
5028        state.1 += 1;
5029        true
5030    }
5031
5032    pub(crate) fn next_search_persist_epoch(&self) -> u64 {
5033        self.search_persist_epoch.next()
5034    }
5035
5036    pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5037        self.search_persist_epoch.clone()
5038    }
5039
5040    pub fn add_pending_search_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_search_index_paths.lock().extend(paths);
5048        }
5049    }
5050
5051    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
5052        std::mem::take(&mut *self.pending_search_index_paths.lock())
5053            .into_iter()
5054            .collect()
5055    }
5056
5057    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
5058    where
5059        I: IntoIterator<Item = PathBuf>,
5060    {
5061        let paths = paths.into_iter().collect::<Vec<_>>();
5062        if !paths.is_empty() {
5063            self.invalidate_warm_verify_memo();
5064            self.pending_semantic_index_paths.lock().extend(paths);
5065        }
5066    }
5067
5068    pub(crate) fn invalidate_warm_verify_memo(&self) {
5069        if let Some(root) = self.canonical_cache_root_opt() {
5070            crate::cache_freshness::invalidate_verify_memo(&root);
5071        }
5072    }
5073
5074    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
5075        std::mem::take(&mut *self.pending_semantic_index_paths.lock())
5076            .into_iter()
5077            .collect()
5078    }
5079
5080    pub fn mark_pending_semantic_corpus_refresh(&self) {
5081        *self.pending_semantic_corpus_refresh.lock() = true;
5082    }
5083
5084    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
5085        std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
5086    }
5087
5088    pub fn clear_pending_index_updates(&self) {
5089        self.clear_pending_index_updates_with_callgraph(true);
5090    }
5091
5092    pub(crate) fn clear_pending_index_updates_preserving_callgraph(&self) {
5093        self.clear_pending_index_updates_with_callgraph(false);
5094    }
5095
5096    fn clear_pending_index_updates_with_callgraph(&self, clear_callgraph: bool) {
5097        self.pending_search_index_paths.lock().clear();
5098        if clear_callgraph {
5099            self.pending_callgraph_store_paths.lock().clear();
5100        }
5101        self.pending_tier2_paths.lock().clear();
5102        self.pending_semantic_index_paths.lock().clear();
5103        *self.pending_semantic_corpus_refresh.lock() = false;
5104    }
5105
5106    /// Take the retained pending reconciliation state for a transactional
5107    /// teardown. The caller commits the disposal by dropping the returned
5108    /// state after eviction succeeds, or restores it with
5109    /// [`Self::restore_pending_reconciliation_state`] when eviction is blocked
5110    /// by a secondary blocker (running bash, in-flight builds): the paths are
5111    /// the only repair record for consumed watcher events, and the root may
5112    /// rebind before the next reap attempt.
5113    pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
5114        PendingReconciliationState {
5115            search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
5116            callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
5117            tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
5118            semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
5119            corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
5120        }
5121    }
5122
5123    pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
5124        self.pending_search_index_paths.lock().extend(state.search);
5125        self.pending_callgraph_store_paths
5126            .lock()
5127            .extend(state.callgraph);
5128        self.pending_tier2_paths.lock().extend(state.tier2);
5129        self.pending_semantic_index_paths
5130            .lock()
5131            .extend(state.semantic);
5132        if state.corpus_refresh {
5133            *self.pending_semantic_corpus_refresh.lock() = true;
5134        }
5135    }
5136
5137    /// Cancel artifact work that no longer has a bound daemon route to consume it.
5138    /// `mark_subc_unbound` advances the generation under the lifecycle admission
5139    /// gate before this cleanup runs. Clearing receivers lets a later rebind
5140    /// schedule fresh work instead of adopting a disconnected worker forever.
5141    ///
5142    /// Pending watcher-derived path sets are RETAINED: a pre-unbind artifact
5143    /// worker may legitimately finish generation-safe disk persistence during
5144    /// the unbound window (content generation and persist epochs deliberately
5145    /// do not advance on route teardown), and those paths are the only record
5146    /// that its artifact is content-stale. Rebind replays them. Disposal of
5147    /// pending state belongs to non-equivalent configure and TTL eviction
5148    /// (transactional take in the TTL reaper), whose strict invalidation
5149    /// subsumes their purpose.
5150    pub(crate) fn cancel_unbound_artifact_work(&self) {
5151        // A cancelled non-ready search corpus refresh left the resident index
5152        // marked not-ready; retiring its receiver alone would strand it
5153        // (equivalent rebind only reloads a MISSING index). Drop the resident
5154        // index too so the rebind's artifact setup reloads from disk and the
5155        // retained pending paths repair it on install.
5156        let search_refresh_cancelled = self
5157            .search_index_rx
5158            .read()
5159            .unwrap_or_else(std::sync::PoisonError::into_inner)
5160            .is_some();
5161        self.retire_search_index_rx();
5162        if search_refresh_cancelled {
5163            let mut resident = self
5164                .search_index
5165                .write()
5166                .unwrap_or_else(std::sync::PoisonError::into_inner);
5167            if resident.as_ref().is_some_and(|index| !index.ready) {
5168                *resident = None;
5169            }
5170        }
5171        self.retire_callgraph_store_rx();
5172        let semantic_cancelled = self.semantic_index_rx.lock().is_some();
5173        self.retire_semantic_index_rx();
5174        let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
5175        self.clear_semantic_refresh_worker();
5176        self.reset_semantic_cold_seed_gate_for_configure();
5177        let _ = self.inspect_manager.discard_completions();
5178        let _ = self.take_new_reuse_completions();
5179        if semantic_cancelled || semantic_refresh_cancelled {
5180            let has_index = self
5181                .semantic_index
5182                .read()
5183                .unwrap_or_else(std::sync::PoisonError::into_inner)
5184                .is_some();
5185            // In-flight refreshing files were consumed from the watcher; the
5186            // cancelled worker will never re-embed them. Transfer them to the
5187            // retained pending set so the rebind's replacement worker does.
5188            {
5189                let mut status = self
5190                    .semantic_index_status
5191                    .write()
5192                    .unwrap_or_else(std::sync::PoisonError::into_inner);
5193                let refreshing = status.take_refreshing_files();
5194                if !refreshing.is_empty() {
5195                    self.pending_semantic_index_paths.lock().extend(refreshing);
5196                }
5197                if status.corpus_refresh_in_flight() {
5198                    *self.pending_semantic_corpus_refresh.lock() = true;
5199                }
5200                *status = if has_index {
5201                    SemanticIndexStatus::ready()
5202                } else {
5203                    SemanticIndexStatus::Disabled
5204                };
5205            }
5206            self.set_semantic_build_progress(None);
5207        }
5208    }
5209
5210    /// Gate every watcher-maintained artifact after the last route detaches. Files
5211    /// may change before the watcher is restored, so a later bind must reconcile
5212    /// from disk instead of serving retained snapshots that missed those edits.
5213    pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
5214        self.next_search_persist_epoch();
5215        self.next_semantic_persist_epoch();
5216        self.next_callgraph_persist_epoch();
5217
5218        self.search_index
5219            .write()
5220            .unwrap_or_else(std::sync::PoisonError::into_inner)
5221            .take();
5222        self.semantic_index
5223            .write()
5224            .unwrap_or_else(std::sync::PoisonError::into_inner)
5225            .take();
5226        self.callgraph_store
5227            .write()
5228            .unwrap_or_else(std::sync::PoisonError::into_inner)
5229            .take();
5230        // Keep semantic status reloadable when the feature is enabled: the
5231        // query path's self-healing reload only fires from Ready (or Failed on
5232        // read-only roots), so Disabled would strand an already-bound root
5233        // with no way back short of a reconfigure. The advanced persist epoch
5234        // and strict verify memo force the reload to re-verify from disk.
5235        *self
5236            .semantic_index_status
5237            .write()
5238            .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
5239            SemanticIndexStatus::ready()
5240        } else {
5241            SemanticIndexStatus::Disabled
5242        };
5243        // A force token is only fulfillable by a local writer build; read-only
5244        // roots follow the owner's published pointer and would be stuck
5245        // permanently unavailable behind an unfulfillable token.
5246        if self.callgraph_writer() {
5247            self.mark_callgraph_store_force_rebuild();
5248        }
5249
5250        if let Some(root) = self
5251            .canonical_cache_root_opt()
5252            .or_else(|| self.config().project_root.clone())
5253        {
5254            crate::cache_freshness::invalidate_verify_memo_strict(&root);
5255        }
5256        self.borrowed_index_cache.lock().clear();
5257        self.inspect_manager.evict_idle_caches();
5258        self.reset_symbol_cache();
5259        self.clear_tsconfig_membership_cache();
5260    }
5261
5262    fn drain_search_index_events_for_graceful_shutdown(&self) {
5263        crate::runtime_drain::drain_watcher_events(self);
5264        crate::runtime_drain::drain_search_index_events(self);
5265    }
5266
5267    fn search_index_build_in_progress(&self) -> bool {
5268        self.search_index_rx()
5269            .read()
5270            .unwrap_or_else(std::sync::PoisonError::into_inner)
5271            .is_some()
5272    }
5273
5274    /// Graceful EOF teardown can afford a bounded wait for an already running
5275    /// search rebuild to publish. Poll the observable receiver state
5276    /// directly instead of relying on fixed sleeps in callers or tests.
5277    fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
5278        crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
5279        let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
5280        while self.search_index_build_in_progress() && Instant::now() < deadline {
5281            let remaining = deadline.saturating_duration_since(Instant::now());
5282            std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
5283            self.drain_search_index_events_for_graceful_shutdown();
5284        }
5285    }
5286
5287    /// Flush the owner-side trigram delta during an orderly transport shutdown.
5288    /// EOF/Goodbye teardown uses this best-effort path; signal and panic exits
5289    /// intentionally skip it so abrupt shutdown never waits on slow recovery work.
5290    ///
5291    /// Borrow-only roots (including ram-overlay worktrees) return immediately
5292    /// and never write the shared artifact.
5293    #[doc(hidden)]
5294    pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
5295        if self.shared_artifacts_read_only() {
5296            return false;
5297        }
5298
5299        self.drain_search_index_events_for_graceful_shutdown();
5300        if self.search_index_build_in_progress() {
5301            self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
5302            self.drain_search_index_events_for_graceful_shutdown();
5303        }
5304
5305        if self.search_index_build_in_progress() {
5306            return false;
5307        }
5308
5309        let Some(canonical_root) = self.canonical_cache_root_opt() else {
5310            return false;
5311        };
5312        let config = self.config();
5313        let project_key = self.memoized_artifact_cache_key(&canonical_root);
5314        let cache_dir = crate::search_index::resolve_cache_dir_with_key(
5315            &project_key,
5316            config.storage_dir.as_deref(),
5317        );
5318
5319        {
5320            let search_index = self
5321                .search_index()
5322                .read()
5323                .unwrap_or_else(std::sync::PoisonError::into_inner);
5324            let Some(index) = search_index.as_ref() else {
5325                return false;
5326            };
5327            if !index.ready || !index.has_pending_disk_changes() {
5328                return false;
5329            }
5330        }
5331
5332        let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
5333            &cache_dir,
5334            &canonical_root,
5335        ) {
5336            Ok(lock) => lock,
5337            Err(error) => {
5338                crate::slog_warn!(
5339                    "search index: skipped shutdown flush because cache lock was unavailable: {}",
5340                    error
5341                );
5342                return false;
5343            }
5344        };
5345
5346        let mut search_index = self
5347            .search_index()
5348            .write()
5349            .unwrap_or_else(std::sync::PoisonError::into_inner);
5350        let Some(index) = search_index.as_mut() else {
5351            return false;
5352        };
5353        if !index.ready || !index.has_pending_disk_changes() {
5354            return false;
5355        }
5356
5357        let git_head = index.stored_git_head().map(str::to_owned);
5358        index.write_to_disk(&cache_dir, git_head.as_deref())
5359    }
5360
5361    pub fn inspect_manager(&self) -> Arc<InspectManager> {
5362        Arc::clone(&self.inspect_manager)
5363    }
5364
5365    /// Standing ownership exempts a root from idle artifact eviction only. It
5366    /// does not bypass strict verification, budgets, breaker checks, or leases.
5367    pub(crate) fn set_standing_artifact_exempt(&self, exempt: bool) {
5368        self.standing_artifact_exempt
5369            .store(exempt, Ordering::Release);
5370    }
5371
5372    pub(crate) fn cold_build_limiter(&self) -> Arc<crate::cold_build_limiter::ColdBuildLimiter> {
5373        Arc::clone(
5374            &self
5375                .cold_build_limiter
5376                .read()
5377                .unwrap_or_else(std::sync::PoisonError::into_inner),
5378        )
5379    }
5380
5381    /// Give one integration-test context its own maintenance-build capacity.
5382    /// Production contexts continue to share the process-wide limiter.
5383    #[doc(hidden)]
5384    pub fn isolate_cold_build_limiter_for_test(&self, limit: usize) {
5385        let limiter = crate::cold_build_limiter::isolated_limiter(limit);
5386        self.inspect_manager
5387            .set_cold_build_limiter(Arc::clone(&limiter));
5388        *self
5389            .cold_build_limiter
5390            .write()
5391            .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
5392    }
5393
5394    pub fn add_pending_tier2_paths<I>(&self, paths: I)
5395    where
5396        I: IntoIterator<Item = PathBuf>,
5397    {
5398        self.pending_tier2_paths.lock().extend(paths);
5399    }
5400
5401    pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
5402        self.pending_tier2_paths.lock().iter().cloned().collect()
5403    }
5404
5405    pub fn remove_pending_tier2_paths<I>(&self, paths: I)
5406    where
5407        I: IntoIterator<Item = PathBuf>,
5408    {
5409        let mut pending = self.pending_tier2_paths.lock();
5410        for path in paths {
5411            pending.remove(&path);
5412        }
5413    }
5414
5415    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
5416    /// have completed since the last call, advancing the last-seen marker. The
5417    /// per-request inspect drain uses this to refresh the status bar after a
5418    /// background scan — those completions bypass `drain_completions`.
5419    /// Peek variant of `take_new_reuse_completions`: reports whether new reuse
5420    /// completions exist WITHOUT consuming the observation, so the maintenance
5421    /// scheduler's skip probe cannot swallow a status-bar refresh.
5422    pub fn has_new_reuse_completions(&self) -> bool {
5423        self.inspect_manager.reuse_completion_count()
5424            != self.last_seen_reuse_completions.load(Ordering::SeqCst)
5425    }
5426
5427    pub fn take_new_reuse_completions(&self) -> bool {
5428        let current = self.inspect_manager.reuse_completion_count();
5429        let previous = self
5430            .last_seen_reuse_completions
5431            .swap(current, Ordering::SeqCst);
5432        current != previous
5433    }
5434
5435    pub fn reset_tier2_refresh_scheduler(&self) {
5436        self.reset_tier2_refresh_scheduler_at(Instant::now());
5437    }
5438
5439    #[doc(hidden)]
5440    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
5441        self.tier2_refresh_scheduler
5442            .lock()
5443            .reset_after_configure(now);
5444    }
5445
5446    pub fn request_tier2_refresh_pull(&self) -> bool {
5447        let can_schedule = self.inspect_writer()
5448            && self.heavy_root_work_allowed()
5449            && self.inspect_manager.automatic_tier2_refresh_allowed();
5450        self.tier2_refresh_scheduler
5451            .lock()
5452            .request_pull(can_schedule)
5453    }
5454
5455    pub fn tick_tier2_refresh_scheduler(
5456        &self,
5457        changed_path_count: usize,
5458    ) -> Option<Tier2TriggerReason> {
5459        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
5460    }
5461
5462    #[doc(hidden)]
5463    pub fn tick_tier2_refresh_scheduler_at(
5464        &self,
5465        now: Instant,
5466        changed_path_count: usize,
5467    ) -> Option<Tier2TriggerReason> {
5468        let manager = self.inspect_manager();
5469        let can_write = self.inspect_writer()
5470            && self.heavy_root_work_allowed()
5471            && manager.automatic_tier2_refresh_allowed();
5472        let in_flight = manager.tier2_any_in_flight();
5473        let semantic_cold_seed_active = self.semantic_cold_seed_active();
5474        let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
5475            now,
5476            changed_path_count,
5477            can_write,
5478            in_flight,
5479            semantic_cold_seed_active,
5480        );
5481
5482        if let Some(reason) = decision {
5483            self.start_tier2_refresh(reason, manager);
5484        }
5485
5486        decision
5487    }
5488
5489    pub fn note_tier2_refresh_started(&self) {
5490        self.note_tier2_refresh_started_at(Instant::now());
5491    }
5492
5493    #[doc(hidden)]
5494    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
5495        self.tier2_refresh_scheduler
5496            .lock()
5497            .note_external_scan_started(now);
5498    }
5499
5500    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
5501        self.tier2_refresh_scheduler
5502            .lock()
5503            .last_trigger_reason()
5504            .map(Tier2TriggerReason::as_str)
5505    }
5506
5507    #[doc(hidden)]
5508    pub fn tier2_pull_demand_pending(&self) -> bool {
5509        self.tier2_refresh_scheduler.lock().pull_demand_pending()
5510    }
5511
5512    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
5513        let generation = self.configure_generation();
5514        if !self.inspect_writer()
5515            || !self.heavy_root_work_allowed()
5516            || !manager.automatic_tier2_refresh_allowed()
5517            || !self.config().inspect.enabled
5518        {
5519            return;
5520        }
5521        let _ = self.run_if_subc_bound_generation(generation, || {
5522            self.start_tier2_refresh_admitted(reason, manager);
5523        });
5524    }
5525
5526    fn start_tier2_refresh_admitted(
5527        &self,
5528        reason: Tier2TriggerReason,
5529        manager: Arc<InspectManager>,
5530    ) {
5531        let Some(snapshot) = self.tier2_refresh_snapshot() else {
5532            return;
5533        };
5534        let categories = Self::automatic_tier2_refresh_categories(&snapshot);
5535        let submission =
5536            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
5537        if !submission.deferred_categories.is_empty() {
5538            self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
5539            crate::slog_info!(
5540                "tier2 refresh deferred by cold build limit: categories={:?}",
5541                submission
5542                    .deferred_categories
5543                    .iter()
5544                    .map(|category| category.as_str())
5545                    .collect::<Vec<_>>()
5546            );
5547        }
5548        if submission.has_new_work() {
5549            crate::slog_info!(
5550                "tier2 refresh scheduled: reason={}, categories={:?}",
5551                reason.as_str(),
5552                submission
5553                    .newly_queued_categories
5554                    .iter()
5555                    .map(|category| category.as_str())
5556                    .collect::<Vec<_>>()
5557            );
5558        }
5559        for error in submission.errors {
5560            crate::slog_warn!(
5561                "tier2 refresh schedule failed for {}: {}",
5562                error.category,
5563                error.message
5564            );
5565        }
5566    }
5567
5568    fn automatic_tier2_refresh_categories(snapshot: &InspectSnapshot) -> Vec<InspectCategory> {
5569        let callgraph_store_enabled = snapshot.config.callgraph_store;
5570        InspectCategory::active()
5571            .iter()
5572            .copied()
5573            .filter(|category| category.is_tier2())
5574            .filter(|category| {
5575                if *category == InspectCategory::DeadCode && !callgraph_store_enabled {
5576                    // With callgraph_store=false, the scan produces zero reusable
5577                    // contributions: zero contributions → reuse rejection → full rescan →
5578                    // discard, so automatic dead_code work is pure waste.
5579                    return false;
5580                }
5581                true
5582            })
5583            .collect()
5584    }
5585
5586    #[doc(hidden)]
5587    pub fn automatic_tier2_refresh_categories_for_test(&self) -> Vec<InspectCategory> {
5588        self.tier2_refresh_snapshot()
5589            .map(|snapshot| Self::automatic_tier2_refresh_categories(&snapshot))
5590            .unwrap_or_default()
5591    }
5592
5593    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
5594        self.harness_opt()?;
5595        let config = self.config();
5596        let project_root = config
5597            .project_root
5598            .clone()
5599            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
5600        // Normalized, not bare-canonical: scoped diagnostics compare
5601        // LSP-reported paths (normalized form) against this root with
5602        // starts_with, and a verbatim root on Windows matches nothing.
5603        let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
5604        Some(InspectSnapshot::new_with_capabilities(
5605            project_root,
5606            self.inspect_dir(),
5607            config,
5608            self.symbol_cache(),
5609            self.inspect_writer(),
5610            self.callgraph_writer(),
5611        ))
5612    }
5613
5614    /// Access the shared symbol cache.
5615    pub fn symbol_cache(&self) -> SharedSymbolCache {
5616        Arc::clone(&self.symbol_cache)
5617    }
5618
5619    /// Clear the shared symbol cache and return the new active generation.
5620    pub fn reset_symbol_cache(&self) -> u64 {
5621        self.symbol_cache
5622            .write()
5623            .map(|mut cache| cache.reset())
5624            .unwrap_or(0)
5625    }
5626
5627    /// Access the semantic search index.
5628    pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
5629        &self.semantic_index
5630    }
5631
5632    /// Access the semantic-index build receiver.
5633    pub fn semantic_index_rx(
5634        &self,
5635    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
5636        &self.semantic_index_rx
5637    }
5638
5639    pub(crate) fn install_semantic_index_rx(
5640        &self,
5641        receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
5642        generation: u64,
5643    ) -> u64 {
5644        let mut slot = self.semantic_index_rx.lock();
5645        self.note_semantic_index_rx_generation(generation);
5646        let epoch = self.next_semantic_index_rx_epoch();
5647        *slot = Some(receiver);
5648        epoch
5649    }
5650
5651    pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
5652        ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
5653    }
5654
5655    /// Keep generation/epoch validation and receiver mutation under the same
5656    /// lock used by receiver installation.
5657    pub(crate) fn with_current_semantic_index_rx<R>(
5658        &self,
5659        generation: u64,
5660        epoch: u64,
5661        action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
5662    ) -> Option<R> {
5663        self.run_if_subc_bound_generation(generation, || {
5664            let mut receiver = self.semantic_index_rx.lock();
5665            if receiver.is_none()
5666                || self.semantic_index_rx_generation() != generation
5667                || self.semantic_index_rx_epoch() != epoch
5668            {
5669                return None;
5670            }
5671            Some(action(&mut receiver))
5672        })
5673        .flatten()
5674    }
5675
5676    pub(crate) fn retire_semantic_index_rx(&self) {
5677        let mut receiver = self.semantic_index_rx.lock();
5678        *receiver = None;
5679        self.next_semantic_index_rx_epoch();
5680    }
5681
5682    /// Rebind a live semantic build to a newer configure generation when the
5683    /// semantic corpus inputs are unchanged. Its receiver keeps the completed
5684    /// result while the worker's dedicated build epoch remains valid.
5685    pub(crate) fn adopt_semantic_index_rx_generation(&self, generation: u64) -> bool {
5686        let receiver = self.semantic_index_rx.lock();
5687        if receiver.is_none() {
5688            return false;
5689        }
5690        self.note_semantic_index_rx_generation(generation);
5691        true
5692    }
5693
5694    /// Retire a build receiver only if no replacement changed its epoch after
5695    /// the caller inspected it. `None` means a newer receiver won the race;
5696    /// `Some(false)` means the inspected epoch is still current but empty.
5697    pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
5698        let mut receiver = self.semantic_index_rx.lock();
5699        if self.semantic_index_rx_epoch() != expected_epoch {
5700            return None;
5701        }
5702        let retired = receiver.take().is_some();
5703        if retired {
5704            self.next_semantic_index_rx_epoch();
5705        }
5706        Some(retired)
5707    }
5708
5709    pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
5710        self.semantic_index_rx_generation
5711            .store(generation, Ordering::SeqCst);
5712    }
5713
5714    pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
5715        self.semantic_index_rx_generation.load(Ordering::SeqCst)
5716    }
5717
5718    pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
5719        self.semantic_index_rx_epoch
5720            .fetch_add(1, Ordering::SeqCst)
5721            .wrapping_add(1)
5722    }
5723
5724    pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
5725        self.semantic_index_rx_epoch.load(Ordering::SeqCst)
5726    }
5727
5728    pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
5729        self.semantic_persist_epoch.next()
5730    }
5731
5732    pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5733        self.semantic_persist_epoch.clone()
5734    }
5735
5736    pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
5737        Arc::clone(&self.semantic_persist_lock)
5738    }
5739
5740    pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
5741        &self.semantic_index_status
5742    }
5743
5744    pub(crate) fn set_semantic_build_progress(&self, progress: Option<SemanticBuildProgress>) {
5745        *self
5746            .semantic_build_progress
5747            .write()
5748            .unwrap_or_else(std::sync::PoisonError::into_inner) = progress;
5749    }
5750
5751    pub(crate) fn semantic_build_progress(&self) -> Option<SemanticBuildProgress> {
5752        self.semantic_build_progress
5753            .read()
5754            .unwrap_or_else(std::sync::PoisonError::into_inner)
5755            .clone()
5756    }
5757
5758    pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
5759        self.artifact_reload_lock.lock()
5760    }
5761
5762    /// Reset this context's cold semantic seed gate for a newly accepted
5763    /// configure and return the generation token for the worker being spawned.
5764    pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
5765        self.semantic_cold_seed_active
5766            .store(false, Ordering::SeqCst);
5767        self.semantic_callgraph_warm_deferred
5768            .store(false, Ordering::SeqCst);
5769        self.semantic_cold_seed_generation
5770            .fetch_add(1, Ordering::SeqCst)
5771            .wrapping_add(1)
5772    }
5773
5774    pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
5775        Arc::clone(&self.semantic_cold_seed_active)
5776    }
5777
5778    pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
5779        Arc::clone(&self.semantic_cold_seed_generation)
5780    }
5781
5782    pub fn semantic_cold_seed_generation(&self) -> u64 {
5783        self.semantic_cold_seed_generation.load(Ordering::SeqCst)
5784    }
5785
5786    pub fn semantic_cold_seed_active(&self) -> bool {
5787        self.semantic_cold_seed_active.load(Ordering::SeqCst)
5788    }
5789
5790    pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
5791        self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
5792    }
5793
5794    pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
5795        self.semantic_callgraph_warm_deferred
5796            .store(true, Ordering::SeqCst);
5797    }
5798
5799    fn semantic_callgraph_warm_deferred(&self) -> bool {
5800        self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
5801    }
5802
5803    /// Clear the cold-seed gate and resume work that was intentionally held back
5804    /// while the full semantic corpus was accumulating. This entry point is used
5805    /// by the code that drains events from the semantic worker.
5806    pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
5807        self.resume_semantic_cold_seed_deferred_work(false);
5808    }
5809
5810    /// Resume work after the semantic worker has already cleared the atomic gate
5811    /// itself, such as on cached-index load or before a retry backoff sleep.
5812    pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
5813        self.resume_semantic_cold_seed_deferred_work(true);
5814    }
5815
5816    pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
5817        let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
5818        let warm_callgraph = self
5819            .semantic_callgraph_warm_deferred
5820            .swap(false, Ordering::SeqCst);
5821        SemanticColdSeedResume {
5822            request_tier2: force || was_active || warm_callgraph,
5823            warm_callgraph,
5824        }
5825    }
5826
5827    pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
5828        if resume.request_tier2 {
5829            let _ = self.request_tier2_refresh_pull();
5830        }
5831
5832        if !resume.warm_callgraph
5833            || !self.config().callgraph_store
5834            || !self.heavy_root_work_allowed()
5835        {
5836            return;
5837        }
5838
5839        match self.schedule_callgraph_store_warm() {
5840            CallgraphStoreAccess::Ready(_) => {
5841                crate::slog_debug!(
5842                    "deferred callgraph store warm completed after semantic cold seed gate cleared"
5843                );
5844            }
5845            CallgraphStoreAccess::Building => {
5846                crate::slog_info!(
5847                    "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
5848                );
5849            }
5850            CallgraphStoreAccess::Suspended(suspension) => {
5851                crate::slog_warn!(
5852                    "deferred callgraph store warm suspended for {} after {} deaths",
5853                    suspension.domain.as_str(),
5854                    suspension.death_count
5855                );
5856            }
5857            CallgraphStoreAccess::Unavailable => {
5858                crate::slog_info!(
5859                    "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
5860                );
5861            }
5862            CallgraphStoreAccess::Error(error) => {
5863                crate::slog_warn!(
5864                    "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
5865                    error
5866                );
5867            }
5868        }
5869    }
5870
5871    fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
5872        let resume = self.take_semantic_cold_seed_resume(force);
5873        self.apply_semantic_cold_seed_resume(resume);
5874    }
5875
5876    #[doc(hidden)]
5877    pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
5878        self.semantic_cold_seed_active
5879            .store(active, Ordering::SeqCst);
5880    }
5881
5882    #[doc(hidden)]
5883    pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
5884        self.semantic_callgraph_warm_deferred()
5885    }
5886
5887    pub fn install_semantic_refresh_worker(
5888        &self,
5889        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5890        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5891        worker_slot: SemanticRefreshWorkerSlot,
5892    ) {
5893        self.install_semantic_refresh_worker_for_build_epoch(
5894            sender,
5895            event_rx,
5896            worker_slot,
5897            self.semantic_index_rx_epoch(),
5898        );
5899    }
5900
5901    pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
5902        &self,
5903        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5904        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5905        worker_slot: SemanticRefreshWorkerSlot,
5906        build_epoch: u64,
5907    ) {
5908        self.clear_semantic_refresh_worker();
5909        {
5910            let mut receiver = self.semantic_refresh_event_rx.lock();
5911            let mut request = self.semantic_refresh_tx.lock();
5912            let mut worker = self.semantic_refresh_worker.lock();
5913            self.semantic_refresh_generation
5914                .store(self.configure_generation(), Ordering::SeqCst);
5915            self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5916            self.semantic_refresh_build_epoch
5917                .store(build_epoch, Ordering::SeqCst);
5918            *receiver = Some(event_rx);
5919            *request = Some(sender);
5920            *worker = Some(worker_slot);
5921        }
5922    }
5923
5924    pub(crate) fn semantic_refresh_generation(&self) -> u64 {
5925        self.semantic_refresh_generation.load(Ordering::SeqCst)
5926    }
5927
5928    pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
5929        self.semantic_refresh_epoch.load(Ordering::SeqCst)
5930    }
5931
5932    /// Serialize refresh event commit with worker replacement. The receiver
5933    /// lock also couples the generation and epoch to the dequeued channel.
5934    pub(crate) fn with_current_semantic_refresh_rx<R>(
5935        &self,
5936        generation: u64,
5937        epoch: u64,
5938        action: impl FnOnce() -> R,
5939    ) -> Option<R> {
5940        self.run_if_subc_bound_generation(generation, || {
5941            let receiver = self.semantic_refresh_event_rx.lock();
5942            if receiver.is_none()
5943                || self.semantic_refresh_generation() != generation
5944                || self.semantic_refresh_epoch() != epoch
5945            {
5946                return None;
5947            }
5948            Some(action())
5949        })
5950        .flatten()
5951    }
5952
5953    pub(crate) fn clear_semantic_refresh_worker_if_current(
5954        &self,
5955        generation: u64,
5956        epoch: u64,
5957    ) -> Option<u64> {
5958        let worker_slot = {
5959            let mut receiver = self.semantic_refresh_event_rx.lock();
5960            if receiver.is_none()
5961                || self.semantic_refresh_generation() != generation
5962                || self.semantic_refresh_epoch() != epoch
5963            {
5964                return None;
5965            }
5966            let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
5967            self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5968            let mut request = self.semantic_refresh_tx.lock();
5969            let mut worker = self.semantic_refresh_worker.lock();
5970            *receiver = None;
5971            *request = None;
5972            self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5973            self.invalidate_semantic_refresh_probe();
5974            (worker.take(), disconnected_build_epoch)
5975        };
5976        if let Some(worker_slot) = worker_slot.0 {
5977            if let Ok(mut handle) = worker_slot.lock() {
5978                drop(handle.take());
5979            }
5980        }
5981        Some(worker_slot.1)
5982    }
5983
5984    pub fn clear_semantic_refresh_worker(&self) {
5985        let worker_slot = {
5986            let mut receiver = self.semantic_refresh_event_rx.lock();
5987            let mut request = self.semantic_refresh_tx.lock();
5988            let mut worker = self.semantic_refresh_worker.lock();
5989            *receiver = None;
5990            *request = None;
5991            self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5992            self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5993            self.invalidate_semantic_refresh_probe();
5994            worker.take()
5995        };
5996        if let Some(worker_slot) = worker_slot {
5997            if let Ok(mut handle) = worker_slot.lock() {
5998                drop(handle.take());
5999            }
6000        }
6001    }
6002
6003    pub fn semantic_refresh_sender(
6004        &self,
6005    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
6006        self.semantic_refresh_tx.lock().clone()
6007    }
6008
6009    pub(crate) fn semantic_refresh_retry_slots(
6010        &self,
6011    ) -> (
6012        Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
6013        Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
6014    ) {
6015        (
6016            Arc::clone(&self.semantic_refresh_tx),
6017            Arc::clone(&self.pending_semantic_index_paths),
6018        )
6019    }
6020
6021    pub fn semantic_refresh_event_rx(
6022        &self,
6023    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
6024        &self.semantic_refresh_event_rx
6025    }
6026
6027    pub fn with_semantic_refresh_retry_attempts_mut<R>(
6028        &self,
6029        f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
6030    ) -> R {
6031        let mut attempts = self.semantic_refresh_retry_attempts.lock();
6032        f(&mut attempts)
6033    }
6034
6035    pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
6036        let mut attempts = self.semantic_refresh_retry_attempts.lock();
6037        for path in paths {
6038            attempts.remove(path);
6039        }
6040    }
6041
6042    pub fn clear_all_semantic_refresh_retry_attempts(&self) {
6043        self.semantic_refresh_retry_attempts.lock().clear();
6044    }
6045
6046    pub fn semantic_refresh_circuit_is_open(&self) -> bool {
6047        self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
6048    }
6049
6050    pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
6051        let failures = self
6052            .semantic_refresh_circuit
6053            .consecutive_transient_failures
6054            .fetch_add(1, Ordering::SeqCst)
6055            .saturating_add(1);
6056        if failures >= trip_threshold
6057            && !self
6058                .semantic_refresh_circuit
6059                .open
6060                .swap(true, Ordering::SeqCst)
6061        {
6062            crate::slog_warn!(
6063                "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
6064            );
6065        }
6066        self.semantic_refresh_circuit_is_open()
6067    }
6068
6069    pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
6070        self.semantic_refresh_circuit
6071            .consecutive_transient_failures
6072            .store(trip_threshold, Ordering::SeqCst);
6073        if !self
6074            .semantic_refresh_circuit
6075            .open
6076            .swap(true, Ordering::SeqCst)
6077        {
6078            crate::slog_warn!(
6079                "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
6080            );
6081        }
6082    }
6083
6084    pub fn reset_semantic_refresh_transient_failure_count(&self) {
6085        self.semantic_refresh_circuit
6086            .consecutive_transient_failures
6087            .store(0, Ordering::SeqCst);
6088    }
6089
6090    pub fn reset_semantic_refresh_circuit_after_success(&self) {
6091        self.reset_semantic_refresh_transient_failure_count();
6092        self.semantic_refresh_circuit
6093            .probe_ready
6094            .store(false, Ordering::SeqCst);
6095        if self
6096            .semantic_refresh_circuit
6097            .open
6098            .swap(false, Ordering::SeqCst)
6099        {
6100            crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
6101        }
6102    }
6103
6104    pub fn semantic_refresh_transient_failure_count(&self) -> usize {
6105        self.semantic_refresh_circuit
6106            .consecutive_transient_failures
6107            .load(Ordering::SeqCst)
6108    }
6109
6110    pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
6111        self.semantic_refresh_circuit
6112            .probe_in_flight
6113            .load(Ordering::SeqCst)
6114            || self.semantic_refresh_probe_ready()
6115    }
6116
6117    pub fn semantic_refresh_probe_ready(&self) -> bool {
6118        self.semantic_refresh_circuit
6119            .probe_ready
6120            .load(Ordering::SeqCst)
6121    }
6122
6123    pub fn take_semantic_refresh_probe_ready(&self) -> bool {
6124        self.semantic_refresh_circuit
6125            .probe_ready
6126            .swap(false, Ordering::SeqCst)
6127    }
6128
6129    fn invalidate_semantic_refresh_probe(&self) {
6130        self.semantic_refresh_circuit
6131            .probe_token
6132            .fetch_add(1, Ordering::SeqCst);
6133        self.semantic_refresh_circuit
6134            .probe_ready
6135            .store(false, Ordering::SeqCst);
6136        self.semantic_refresh_circuit
6137            .probe_in_flight
6138            .store(false, Ordering::SeqCst);
6139    }
6140
6141    pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
6142        let receiver = self.semantic_refresh_event_rx.lock();
6143        if receiver.is_none()
6144            || self
6145                .semantic_refresh_circuit
6146                .probe_ready
6147                .load(Ordering::SeqCst)
6148            || self
6149                .semantic_refresh_circuit
6150                .probe_in_flight
6151                .swap(true, Ordering::SeqCst)
6152        {
6153            return;
6154        }
6155        let probe_token = self
6156            .semantic_refresh_circuit
6157            .probe_token
6158            .fetch_add(1, Ordering::SeqCst)
6159            .wrapping_add(1);
6160        drop(receiver);
6161
6162        let circuit = Arc::clone(&self.semantic_refresh_circuit);
6163        let session_id = crate::log_ctx::current_session();
6164        std::thread::spawn(move || {
6165            crate::log_ctx::with_session(session_id, || {
6166                std::thread::sleep(delay);
6167                if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
6168                    circuit.probe_ready.store(true, Ordering::SeqCst);
6169                    circuit.probe_in_flight.store(false, Ordering::SeqCst);
6170                }
6171            });
6172        });
6173    }
6174
6175    /// Access the cached semantic embedding model.
6176    pub fn semantic_embedding_model(
6177        &self,
6178    ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
6179        &self.semantic_embedding_model
6180    }
6181
6182    /// Access the file watcher handle (kept alive to continue watching).
6183    pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
6184        &self.watcher
6185    }
6186
6187    /// Access the pre-filtered watcher event receiver.
6188    pub fn watcher_rx(
6189        &self,
6190    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
6191        &self.watcher_rx
6192    }
6193
6194    /// Access continuation state for the bounded watcher drain.
6195    pub(crate) fn watcher_drain_slice(
6196        &self,
6197    ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
6198        &self.watcher_drain_slice
6199    }
6200
6201    /// Include partially consumed dispatch events when reporting drain backlog.
6202    pub fn watcher_drain_pending_path_count(&self) -> usize {
6203        self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
6204            let active_paths = match &state.phase {
6205                WatcherDrainPhase::Collect => 0,
6206                WatcherDrainPhase::Apply { paths, .. } => paths.len(),
6207            };
6208            active_paths + state.pending_paths.len()
6209        })
6210    }
6211
6212    /// Number of path-budgeted watcher batches since this runtime was installed.
6213    pub fn watcher_drain_path_slice_count(&self) -> usize {
6214        self.watcher_drain_slice
6215            .lock()
6216            .as_ref()
6217            .map_or(0, |state| state.path_slice_count)
6218    }
6219
6220    /// Install a watcher filter thread and its dispatch receiver. The caller
6221    /// must have stopped any previous watcher runtime first.
6222    pub fn install_watcher_runtime(
6223        &self,
6224        rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
6225        runtime: WatcherThreadHandle,
6226    ) {
6227        let _runtime_guard = self.watcher_runtime_lock.lock();
6228        let replaced = self.watcher_thread.lock().replace(runtime);
6229        self.app.watcher_started();
6230        if let Some(runtime) = replaced {
6231            Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6232        }
6233        *self.watcher_rx.lock() = Some(rx);
6234        *self.watcher_drain_slice.lock() = None;
6235    }
6236
6237    fn watcher_root_path(&self) -> PathBuf {
6238        self.canonical_cache_root_opt()
6239            .or_else(|| self.config().project_root.clone())
6240            .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
6241    }
6242
6243    fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
6244        const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
6245        // Signal the watcher before scheduling the joiner so teardown does not
6246        // depend on a newly spawned thread winning CPU time under fleet load.
6247        runtime.request_shutdown();
6248        std::thread::spawn(
6249            move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
6250                WatcherJoinOutcome::Joined => {
6251                    app.watcher_stopped();
6252                    crate::slog_info!("watcher stopped: {}", root.display());
6253                }
6254                WatcherJoinOutcome::TimedOut(join) => {
6255                    crate::slog_warn!(
6256                        "watcher stop timed out after {} ms: {}",
6257                        JOIN_TIMEOUT.as_millis(),
6258                        root.display()
6259                    );
6260                    std::thread::spawn(move || {
6261                        let _ = join.join();
6262                        app.watcher_stopped();
6263                        crate::slog_info!("watcher stopped: {}", root.display());
6264                    });
6265                }
6266            },
6267        );
6268    }
6269
6270    fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
6271        let _runtime_guard = self.watcher_runtime_lock.lock();
6272        let runtime = self.watcher_thread.lock().take();
6273        *self.watcher_rx.lock() = None;
6274        *self.watcher_drain_slice.lock() = None;
6275        *self.watcher.lock() = None;
6276        runtime
6277    }
6278
6279    /// Stop the watcher runtime without waiting on its OS thread. Shutdown and
6280    /// the bounded join run on a detached reaper so configure and transport
6281    /// loops never wait on FSEvents or inotify teardown.
6282    pub fn stop_watcher_runtime(&self) {
6283        if let Some(runtime) = self.take_watcher_runtime() {
6284            Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6285        }
6286    }
6287
6288    /// Request watcher shutdown without joining on the executor lane.
6289    pub fn stop_watcher_runtime_in_background(&self) {
6290        self.stop_watcher_runtime();
6291    }
6292
6293    /// Remove a watcher runtime whose OS thread already exited (backend
6294    /// failure while the root was unbound and drains were suppressed).
6295    /// Returns true when a finished corpse was actually removed so the caller
6296    /// can apply watcher-gap invalidation exactly once.
6297    pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
6298        let runtime = {
6299            let _runtime_guard = self.watcher_runtime_lock.lock();
6300            let finished = self
6301                .watcher_thread
6302                .lock()
6303                .as_ref()
6304                .is_some_and(|runtime| runtime.is_finished());
6305            if !finished {
6306                return false;
6307            }
6308            let runtime = self.watcher_thread.lock().take();
6309            *self.watcher_rx.lock() = None;
6310            *self.watcher_drain_slice.lock() = None;
6311            *self.watcher.lock() = None;
6312            runtime
6313        };
6314        if let Some(runtime) = runtime {
6315            Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6316        }
6317        true
6318    }
6319
6320    /// Process-scoped watcher count used by maintenance diagnostics and
6321    /// regression tests. A runtime remains counted until its thread exits.
6322    pub fn watcher_registry_count(&self) -> usize {
6323        self.app.watcher_count()
6324    }
6325
6326    pub(crate) fn watcher_runtime_active(&self) -> bool {
6327        let _runtime_guard = self.watcher_runtime_lock.lock();
6328        // A finished thread is a dead runtime even while its handle is still
6329        // installed (the backend can fail while drains are suppressed for an
6330        // unbound root, leaving the queued error undrained). Treating it as
6331        // active would block watcher restoration on rebind.
6332        let thread_live = self
6333            .watcher_thread
6334            .lock()
6335            .as_ref()
6336            .is_some_and(|runtime| !runtime.is_finished());
6337        thread_live && self.watcher_rx.lock().is_some()
6338    }
6339
6340    /// Return whether artifact eviction would discard work that still needs a
6341    /// live handle. Callers use this as the single safety gate before clearing
6342    /// resident stores and inspect caches.
6343    pub fn artifact_eviction_blocked(&self) -> bool {
6344        if self.standing_artifact_exempt.load(Ordering::Acquire) {
6345            return true;
6346        }
6347        let semantic_refresh_in_flight = match &*self
6348            .semantic_index_status
6349            .read()
6350            .unwrap_or_else(std::sync::PoisonError::into_inner)
6351        {
6352            SemanticIndexStatus::Building { .. } => true,
6353            SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
6354            SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
6355        };
6356        if crate::runtime_drain::any_build_in_flight(self)
6357            || semantic_refresh_in_flight
6358            || self.inspect_manager.tier2_any_in_flight()
6359            || !self.bash_background.running_tasks().is_empty()
6360            || !self.pending_callgraph_store_paths.lock().is_empty()
6361            || !self.pending_search_index_paths.lock().is_empty()
6362            || !self.pending_tier2_paths.lock().is_empty()
6363            || !self.pending_semantic_index_paths.lock().is_empty()
6364            || *self.pending_semantic_corpus_refresh.lock()
6365        {
6366            return true;
6367        }
6368
6369        let search_has_pending_disk_changes = self
6370            .search_index
6371            .read()
6372            .unwrap_or_else(std::sync::PoisonError::into_inner)
6373            .as_ref()
6374            .is_some_and(SearchIndex::has_pending_disk_changes);
6375        search_has_pending_disk_changes
6376    }
6377
6378    /// Drop idle root-scoped artifact handles. Persistent data remains on disk;
6379    /// artifact-backed query paths schedule a background reload on first use.
6380    /// Returns false when an active build, bash task, inspect scan, or pending
6381    /// disk update makes eviction unsafe.
6382    pub fn evict_idle_artifacts(&self) -> bool {
6383        if self.artifact_eviction_blocked() {
6384            return false;
6385        }
6386
6387        self.callgraph_store
6388            .write()
6389            .unwrap_or_else(std::sync::PoisonError::into_inner)
6390            .take();
6391        self.search_index
6392            .write()
6393            .unwrap_or_else(std::sync::PoisonError::into_inner)
6394            .take();
6395        self.semantic_index
6396            .write()
6397            .unwrap_or_else(std::sync::PoisonError::into_inner)
6398            .take();
6399        self.borrowed_index_cache.lock().clear();
6400        self.inspect_manager.evict_idle_caches();
6401        self.reset_symbol_cache();
6402        self.clear_tsconfig_membership_cache();
6403        true
6404    }
6405
6406    /// Test seam for the serialized real-watcher integration suite. Production
6407    /// callers cannot trigger it without the explicit test-only environment flag.
6408    #[doc(hidden)]
6409    pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
6410        if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
6411            return false;
6412        }
6413        if !self.evict_idle_artifacts() {
6414            return false;
6415        }
6416        self.stop_watcher_runtime_in_background();
6417        self.invalidate_artifacts_after_watcher_gap();
6418        true
6419    }
6420
6421    /// Release resources that can be recreated by an equivalent later bind.
6422    /// LSP shutdown can wait on child processes, so all work stays off the
6423    /// executor and subc frame loops.
6424    pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
6425        let ctx = Arc::clone(self);
6426        std::thread::spawn(move || {
6427            if !ctx.subc_unbound_quiesced() {
6428                return;
6429            }
6430            {
6431                let mut lsp = ctx.lsp_manager.lock();
6432                if !ctx.subc_unbound_quiesced() {
6433                    return;
6434                }
6435                lsp.shutdown_all();
6436            }
6437            let _ = ctx.subc_lifecycle.run_if_unbound(|| {
6438                ctx.bash_background.clear_db_pool();
6439                ctx.backup.lock().clear_db_pool();
6440            });
6441        });
6442    }
6443
6444    /// Final cleanup for an actor whose project directory no longer exists.
6445    /// The executor invokes this only after proving the actor has no queued or
6446    /// running jobs, and always from a detached teardown thread.
6447    pub(crate) fn teardown_deleted_root(&self) {
6448        self.bash_background.detach();
6449        self.bash_background.clear_db_pool();
6450        self.backup.lock().clear_db_pool();
6451        self.lsp_manager.lock().shutdown_all();
6452    }
6453
6454    /// Access the LSP manager.
6455    pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
6456        self.lsp_manager.lock()
6457    }
6458
6459    /// Notify LSP servers that a file was written.
6460    /// Call this after write_format_validate in command handlers.
6461    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
6462        let config = self.config();
6463        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6464            if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
6465                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6466            }
6467        }
6468    }
6469
6470    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
6471    /// errors/warnings don't linger in the warm set (no server republishes for
6472    /// a vanished path), keeping the status bar and `aft_inspect` honest.
6473    /// Returns true if any entry was removed. Best-effort: a contended borrow is
6474    /// skipped silently (the watcher drain retries on subsequent events).
6475    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
6476        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6477            lsp.clear_diagnostics_for_file(file_path)
6478        } else {
6479            false
6480        }
6481    }
6482
6483    /// Mark diagnostics stale for a file changed outside AFT's text-sync path.
6484    /// Best-effort: a contended LSP lock is skipped and the next watcher event
6485    /// or scoped diagnostics pull can reconcile the file.
6486    pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
6487        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6488            lsp.mark_diagnostics_stale_for_file(file_path)
6489        } else {
6490            StaleDiagnosticsMark::default()
6491        }
6492    }
6493
6494    /// Resync a watcher-stale diagnosed file with the active LSP server.
6495    ///
6496    /// `workspace/didChangeWatchedFiles` tells servers that the filesystem
6497    /// changed, but it does not update an already-open document's in-memory text.
6498    /// Sending the normal didOpen/didChange path gives push-only servers a chance
6499    /// to publish fresh diagnostics and keeps pull-capable servers' document state
6500    /// current for the next diagnostic request.
6501    pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
6502        if !file_path.is_file() {
6503            return false;
6504        }
6505
6506        let content = match std::fs::read_to_string(file_path) {
6507            Ok(content) => content,
6508            Err(err) => {
6509                crate::slog_warn!(
6510                    "skipping LSP resync for {} after external edit: {}",
6511                    file_path.display(),
6512                    err
6513                );
6514                return false;
6515            }
6516        };
6517
6518        let config = self.config();
6519        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6520            if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
6521                crate::slog_warn!(
6522                    "LSP resync failed for {} after external edit: {}",
6523                    file_path.display(),
6524                    err
6525                );
6526                return false;
6527            }
6528            true
6529        } else {
6530            false
6531        }
6532    }
6533
6534    /// Notify LSP and optionally wait for diagnostics.
6535    ///
6536    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
6537    /// Ensures the matching server is running, sends didOpen/didChange, waits
6538    /// briefly for publishDiagnostics, and returns diagnostics for the file.
6539    ///
6540    /// Pre-edit cached diagnostics are never returned: only entries whose version
6541    /// matches the post-edit document version are authoritative.
6542    pub fn lsp_notify_and_collect_diagnostics(
6543        &self,
6544        file_path: &Path,
6545        content: &str,
6546        timeout: std::time::Duration,
6547    ) -> crate::lsp::manager::PostEditWaitOutcome {
6548        let config = self.config();
6549        let Some(mut lsp) = self.lsp_manager.try_lock() else {
6550            return crate::lsp::manager::PostEditWaitOutcome::default();
6551        };
6552
6553        // Clear any queued notifications before this write so the wait loop only
6554        // observes diagnostics triggered by the current change.
6555        lsp.drain_events();
6556
6557        // Snapshot per-server epochs and document versions BEFORE sending
6558        // didChange so the wait loop can prove freshness without accepting
6559        // stale pre-edit publishes that arrived late.
6560        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
6561
6562        // An explicit diagnostics request still starts matching servers only when
6563        // needed. Record the document version sent to each server so completed results
6564        // stay tied to the server and version that produced them.
6565        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
6566        {
6567            Ok(v) => v,
6568            Err(e) => {
6569                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6570                return crate::lsp::manager::PostEditWaitOutcome::default();
6571            }
6572        };
6573
6574        // No server matched this file — return an empty outcome that's
6575        // honestly `complete: true` (nothing to wait for).
6576        if expected_versions.is_empty() {
6577            return crate::lsp::manager::PostEditWaitOutcome::default();
6578        }
6579
6580        // Register the wake receiver while the manager is still locked. Events
6581        // that raced with registration remain on the raw receiver; events won by
6582        // another drain path wake this waiter after that path updates the store.
6583        let mut wait = lsp.start_post_edit_diagnostics_wait(
6584            file_path,
6585            &expected_versions,
6586            &pre_snapshot,
6587            timeout,
6588        );
6589        let mut complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, None);
6590        drop(lsp);
6591
6592        while !complete && !wait.deadline_reached() {
6593            // Waiting on channel activity does not require access to manager
6594            // state, so other LSP operations can continue their bookkeeping.
6595            let event = wait.next_event();
6596            let mut lsp = self.lsp_manager.lock();
6597            complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, event);
6598        }
6599
6600        self.lsp_manager
6601            .lock()
6602            .finish_post_edit_diagnostics_wait(wait)
6603    }
6604
6605    /// Collect custom server root_markers from user config for use in
6606    /// `is_config_file_path_with_custom` checks (#25).
6607    fn custom_lsp_root_markers(&self) -> Vec<String> {
6608        self.config()
6609            .lsp_servers
6610            .iter()
6611            .flat_map(|s| s.root_markers.iter().cloned())
6612            .collect()
6613    }
6614
6615    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
6616        let custom_markers = self.custom_lsp_root_markers();
6617        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
6618            .iter()
6619            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
6620            .cloned()
6621            .map(|path| {
6622                let change_type = if path.exists() {
6623                    FileChangeType::CHANGED
6624                } else {
6625                    FileChangeType::DELETED
6626                };
6627                (path, change_type)
6628            })
6629            .collect();
6630
6631        self.notify_watched_config_events(&config_paths);
6632    }
6633
6634    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
6635        let paths = params
6636            .get("multi_file_write_paths")
6637            .and_then(|value| value.as_array())?
6638            .iter()
6639            .filter_map(|value| value.as_str())
6640            .map(PathBuf::from)
6641            .collect::<Vec<_>>();
6642
6643        (!paths.is_empty()).then_some(paths)
6644    }
6645
6646    /// Parse config-file watched events from `multi_file_write_paths` when the
6647    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
6648    ///
6649    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
6650    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
6651    /// `notify_watched_config_files()`. Both variants read the same JSON key but
6652    /// with different per-entry schemas — they are NOT redundant.
6653    ///
6654    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
6655    /// and was reachable via the `else if` branch when all entries were objects.
6656    /// Restoring both is correct.
6657    fn watched_file_events_from_params(
6658        params: &serde_json::Value,
6659        extra_markers: &[String],
6660    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
6661        let events = params
6662            .get("multi_file_write_paths")
6663            .and_then(|value| value.as_array())?
6664            .iter()
6665            .filter_map(|entry| {
6666                // Only handle object entries — string entries go through multi_file_write_paths()
6667                let path = entry
6668                    .get("path")
6669                    .and_then(|value| value.as_str())
6670                    .map(PathBuf::from)?;
6671
6672                if !is_config_file_path_with_custom(&path, extra_markers) {
6673                    return None;
6674                }
6675
6676                let change_type = entry
6677                    .get("type")
6678                    .and_then(|value| value.as_str())
6679                    .and_then(Self::parse_file_change_type)
6680                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
6681
6682                Some((path, change_type))
6683            })
6684            .collect::<Vec<_>>();
6685
6686        (!events.is_empty()).then_some(events)
6687    }
6688
6689    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
6690        match value {
6691            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
6692            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
6693            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
6694            _ => None,
6695        }
6696    }
6697
6698    fn change_type_from_current_state(path: &Path) -> FileChangeType {
6699        if path.exists() {
6700            FileChangeType::CHANGED
6701        } else {
6702            FileChangeType::DELETED
6703        }
6704    }
6705
6706    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
6707        if config_paths.is_empty() {
6708            return;
6709        }
6710
6711        let config = self.config();
6712        if let Some(mut lsp) = self.lsp_manager.try_lock() {
6713            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
6714                crate::slog_warn!("watched-file sync error: {}", e);
6715            }
6716        }
6717    }
6718
6719    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
6720        let custom_markers = self.custom_lsp_root_markers();
6721        if !is_config_file_path_with_custom(file_path, &custom_markers) {
6722            return;
6723        }
6724
6725        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
6726    }
6727
6728    /// Post-write LSP hook for multi-file edits. When the patch includes
6729    /// config-file edits, notify active workspace servers via
6730    /// `workspace/didChangeWatchedFiles` before sending the per-document
6731    /// didOpen/didChange for the current file.
6732    pub fn lsp_post_multi_file_write(
6733        &self,
6734        file_path: &Path,
6735        content: &str,
6736        file_paths: &[PathBuf],
6737        params: &serde_json::Value,
6738    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6739        self.notify_watched_config_files(file_paths);
6740        self.add_pending_tier2_paths(file_paths.iter().cloned());
6741        let _ = self.mark_status_bar_tier2_stale();
6742
6743        let wants_diagnostics = params
6744            .get("diagnostics")
6745            .and_then(|v| v.as_bool())
6746            .unwrap_or(false);
6747
6748        if !wants_diagnostics {
6749            self.lsp_notify_file_changed(file_path, content);
6750            return None;
6751        }
6752
6753        let wait_ms = params
6754            .get("wait_ms")
6755            .and_then(|v| v.as_u64())
6756            .unwrap_or(3000)
6757            .min(10_000);
6758
6759        Some(self.lsp_notify_and_collect_diagnostics(
6760            file_path,
6761            content,
6762            std::time::Duration::from_millis(wait_ms),
6763        ))
6764    }
6765
6766    /// Post-write LSP hook: notify server and optionally collect diagnostics.
6767    ///
6768    /// This is the single call site for all command handlers after `write_format_validate`.
6769    /// Behavior:
6770    /// - When `diagnostics: true` is in `params`, notifies the server, waits
6771    ///   until matching diagnostics arrive or the timeout expires, and returns
6772    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
6773    ///   status.
6774    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
6775    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
6776    ///   `None` is what tells the response builder to omit the LSP fields
6777    ///   entirely (preserves the no-diagnostics-requested response shape).
6778    ///
6779    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
6780    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
6781    /// capped at 10000ms.
6782    pub fn lsp_post_write(
6783        &self,
6784        file_path: &Path,
6785        content: &str,
6786        params: &serde_json::Value,
6787    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6788        let wants_diagnostics = params
6789            .get("diagnostics")
6790            .and_then(|v| v.as_bool())
6791            .unwrap_or(false);
6792
6793        let custom_markers = self.custom_lsp_root_markers();
6794        if let Some(file_paths) = Self::multi_file_write_paths(params) {
6795            self.add_pending_tier2_paths(file_paths);
6796        } else {
6797            self.add_pending_tier2_paths([file_path.to_path_buf()]);
6798        }
6799        let _ = self.mark_status_bar_tier2_stale();
6800
6801        if !wants_diagnostics {
6802            if let Some(file_paths) = Self::multi_file_write_paths(params) {
6803                self.notify_watched_config_files(&file_paths);
6804            } else if let Some(config_events) =
6805                Self::watched_file_events_from_params(params, &custom_markers)
6806            {
6807                self.notify_watched_config_events(&config_events);
6808            }
6809            self.lsp_notify_file_changed(file_path, content);
6810            return None;
6811        }
6812
6813        let wait_ms = params
6814            .get("wait_ms")
6815            .and_then(|v| v.as_u64())
6816            .unwrap_or(3000)
6817            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
6818
6819        if let Some(file_paths) = Self::multi_file_write_paths(params) {
6820            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
6821        }
6822
6823        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
6824        {
6825            self.notify_watched_config_events(&config_events);
6826        }
6827
6828        Some(self.lsp_notify_and_collect_diagnostics(
6829            file_path,
6830            content,
6831            std::time::Duration::from_millis(wait_ms),
6832        ))
6833    }
6834
6835    fn resolved_path_restriction_root(&self, root: &Path) -> PathBuf {
6836        let mut memo = self.path_restriction_root_memo.lock();
6837        if let Some(cached) = memo.as_ref() {
6838            if cached.configured_root.as_os_str() == root.as_os_str()
6839                && cached.resolved_root.exists()
6840            {
6841                return cached.resolved_root.clone();
6842            }
6843        }
6844
6845        // A cache hit performs one `exists` stat instead of walking the root's
6846        // symlink chain. If the resolved root disappears, retry canonicalization
6847        // so deletion and recreation can choose its new identity. A retargeted
6848        // configured-root symlink whose previous target still exists is the
6849        // residual window until reconfigure or that target disappears.
6850        #[cfg(test)]
6851        self.path_restriction_root_canonicalizations
6852            .fetch_add(1, Ordering::SeqCst);
6853        let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
6854        *memo = Some(PathRestrictionRootMemo {
6855            configured_root: root.to_path_buf(),
6856            resolved_root: resolved_root.clone(),
6857        });
6858        resolved_root
6859    }
6860
6861    fn path_restriction_context(
6862        &self,
6863        req_id: &str,
6864        path: &Path,
6865    ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
6866        let config = self.config();
6867        let force_restrict = self.request_force_restrict(req_id);
6868        if !config.restrict_to_project_root && !force_restrict {
6869            return Ok(None);
6870        }
6871        let root = match &config.project_root {
6872            Some(root) => root.clone(),
6873            None if force_restrict => {
6874                return Err(crate::protocol::Response::error(
6875                    req_id,
6876                    "path_outside_root",
6877                    "project root is required when path restriction is forced",
6878                ));
6879            }
6880            None => return Ok(None),
6881        };
6882        drop(config);
6883
6884        let raw_root = root.clone();
6885        let resolved_root = self.resolved_path_restriction_root(&root);
6886        let path_for_resolution = if path.is_relative() {
6887            raw_root.join(path)
6888        } else {
6889            path.to_path_buf()
6890        };
6891        Ok(Some(PathRestrictionContext {
6892            raw_root,
6893            resolved_root,
6894            path_for_resolution,
6895        }))
6896    }
6897
6898    /// Resolve a possibly-relative path against the configured project root.
6899    ///
6900    /// Safety arms that key backup/checkpoint state by path (`undo`,
6901    /// `undo_preview`, `edit_history`, `checkpoint`) must resolve relative paths
6902    /// against the request's bound project root BEFORE validation and keying.
6903    /// Otherwise a relative path is joined against the daemon's current working
6904    /// directory by `canonicalize_key`, which differs from the root the mutating
6905    /// tool resolved against — the per-(session, path) stack lookup then misses
6906    /// and the user gets a false `no_undo_history`.
6907    ///
6908    /// When no `project_root` is configured (direct CLI usage), relative paths
6909    /// fall back to the current working directory, matching `canonicalize_key`.
6910    pub fn resolve_relative_path(&self, path: &Path) -> PathBuf {
6911        if path.is_absolute() {
6912            return path.to_path_buf();
6913        }
6914        if let Some(root) = &self.config().project_root {
6915            return root.join(path);
6916        }
6917        std::env::current_dir()
6918            .unwrap_or_else(|_| PathBuf::from("."))
6919            .join(path)
6920    }
6921
6922    /// Validate that a file path falls within the configured project root.
6923    ///
6924    /// When `project_root` is configured (normal plugin usage), this resolves the
6925    /// path and checks it starts with the root. Returns the canonicalized path on
6926    /// success, or an error response on violation.
6927    ///
6928    /// When no `project_root` is configured (direct CLI usage), all paths pass
6929    /// through unrestricted for backward compatibility.
6930    pub fn validate_path(
6931        &self,
6932        req_id: &str,
6933        path: &Path,
6934    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6935        self.validate_path_with_artifact_session(req_id, path, None)
6936    }
6937
6938    /// Validate a write location without following its final path component.
6939    ///
6940    /// Checkpoint creation and restore use this mode because the final component
6941    /// is the object being preserved or replaced. Following a symlink there would
6942    /// authorize its target and change the stored snapshot key. Every ancestor is
6943    /// still resolved so a symlinked parent cannot escape the project root.
6944    pub fn validate_write_location(
6945        &self,
6946        req_id: &str,
6947        path: &Path,
6948    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6949        let Some(PathRestrictionContext {
6950            raw_root,
6951            resolved_root,
6952            path_for_resolution,
6953        }) = self.path_restriction_context(req_id, path)?
6954        else {
6955            return Ok(path.to_path_buf());
6956        };
6957        let normalized = normalize_path(&path_for_resolution);
6958        let Some(file_name) = normalized.file_name() else {
6959            return self.validate_path(req_id, path);
6960        };
6961        let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
6962        let resolved_parent = match std::fs::canonicalize(parent) {
6963            Ok(resolved) => resolved,
6964            Err(_) => {
6965                reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
6966                resolve_with_existing_ancestors(parent)
6967            }
6968        };
6969        let resolved = normalize_path(&resolved_parent.join(file_name));
6970
6971        if !resolved.starts_with(&resolved_root) {
6972            return Err(path_error_response(req_id, path, &resolved_root));
6973        }
6974
6975        Ok(resolved)
6976    }
6977
6978    /// Validate a read path. A file produced by a background bash task may live
6979    /// outside the project root, so the session that owns the registered output
6980    /// may read that specific file. Mutating tools deliberately use
6981    /// [`AppContext::validate_path`] or [`AppContext::validate_write_location`]
6982    /// and never receive this exception.
6983    pub fn validate_read_path(
6984        &self,
6985        req_id: &str,
6986        session_id: &str,
6987        path: &Path,
6988    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6989        self.validate_path_with_artifact_session(req_id, path, Some(session_id))
6990    }
6991
6992    fn validate_path_with_artifact_session(
6993        &self,
6994        req_id: &str,
6995        path: &Path,
6996        artifact_session_id: Option<&str>,
6997    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6998        let Some(PathRestrictionContext {
6999            raw_root,
7000            resolved_root,
7001            path_for_resolution,
7002        }) = self.path_restriction_context(req_id, path)?
7003        else {
7004            // When path restriction is disabled, callers receive the input path
7005            // unchanged instead of an implicitly canonicalized filesystem path.
7006            return Ok(path.to_path_buf());
7007        };
7008
7009        // Resolve the path (follow symlinks, normalize ..). If canonicalization
7010        // fails (e.g. path does not exist or traverses a broken symlink), inspect
7011        // every existing component with lstat before falling back lexically so a
7012        // broken in-root symlink cannot be used to write outside project_root.
7013        let resolved = match std::fs::canonicalize(&path_for_resolution) {
7014            Ok(resolved) => resolved,
7015            Err(_) => {
7016                let normalized = normalize_path(&path_for_resolution);
7017                reject_escaping_symlink(
7018                    req_id,
7019                    &path_for_resolution,
7020                    &normalized,
7021                    &resolved_root,
7022                    &raw_root,
7023                )?;
7024                resolve_with_existing_ancestors(&normalized)
7025            }
7026        };
7027
7028        if !resolved.starts_with(&resolved_root) {
7029            let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
7030                self.bash_background
7031                    .is_session_owned_artifact_path(session_id, &resolved)
7032            });
7033            if !is_owned_bash_artifact {
7034                return Err(path_error_response(req_id, path, &resolved_root));
7035            }
7036        }
7037
7038        Ok(resolved)
7039    }
7040
7041    /// Count active LSP server instances.
7042    pub fn lsp_server_count(&self) -> usize {
7043        self.lsp_manager
7044            .try_lock()
7045            .map(|lsp| lsp.server_count())
7046            .unwrap_or(0)
7047    }
7048
7049    /// Symbol cache statistics from the language provider.
7050    pub fn symbol_cache_stats(&self) -> serde_json::Value {
7051        let entries = self
7052            .symbol_cache
7053            .read()
7054            .map(|cache| cache.len())
7055            .unwrap_or(0);
7056        serde_json::json!({
7057            "local_entries": entries,
7058            "warm_entries": 0,
7059        })
7060    }
7061
7062    fn memory_estimates(&self) -> [crate::memory::MemoryEstimate; 9] {
7063        let semantic = match self.semantic_index.try_read() {
7064            Ok(index) => index
7065                .as_ref()
7066                .map(SemanticIndex::estimated_memory)
7067                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
7068            Err(TryLockError::Poisoned(error)) => error
7069                .into_inner()
7070                .as_ref()
7071                .map(SemanticIndex::estimated_memory)
7072                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
7073            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7074        };
7075        let trigram = match self.search_index.try_read() {
7076            Ok(index) => index
7077                .as_ref()
7078                .map(SearchIndex::estimated_memory)
7079                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
7080            Err(TryLockError::Poisoned(error)) => error
7081                .into_inner()
7082                .as_ref()
7083                .map(SearchIndex::estimated_memory)
7084                .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
7085            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7086        };
7087        let symbols = match self.symbol_cache.try_read() {
7088            Ok(cache) => cache.estimated_memory(),
7089            Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
7090            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7091        };
7092        let callgraph = match self.callgraph_store.try_read() {
7093            Ok(store) => store
7094                .as_ref()
7095                .map(|store| store.estimated_memory())
7096                .unwrap_or_else(|| {
7097                    crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
7098                }),
7099            Err(TryLockError::Poisoned(error)) => error
7100                .into_inner()
7101                .as_ref()
7102                .map(|store| store.estimated_memory())
7103                .unwrap_or_else(|| {
7104                    crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
7105                }),
7106            Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7107        };
7108        let callgraph_projection = self.inspect_manager.callgraph_projection_estimated_memory();
7109        let inspect = self.inspect_manager.estimated_memory();
7110        let bash = self.bash_background.estimated_memory();
7111        let lsp = self
7112            .lsp_manager
7113            .try_lock()
7114            .map(|lsp| lsp.estimated_memory())
7115            .unwrap_or_else(crate::memory::MemoryEstimate::busy);
7116        // Parsers are created per operation rather than retained in a pool, so
7117        // parser bytes remain an explicit estimation gap instead of a guess.
7118        let parser_pool = crate::memory::MemoryEstimate::not_estimated()
7119            .count("pooled_parsers", 0)
7120            .gap("tree_sitter_parser_bytes");
7121        [
7122            semantic,
7123            trigram,
7124            symbols,
7125            callgraph,
7126            callgraph_projection,
7127            inspect,
7128            bash,
7129            lsp,
7130            parser_pool,
7131        ]
7132    }
7133
7134    /// Build one root's memory estimate using only non-blocking lock attempts.
7135    /// A contended subsystem is represented as `busy` rather than delaying the
7136    /// status control path.
7137    pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
7138        let [semantic, trigram, symbols, callgraph, callgraph_projection, inspect, bash, lsp, parser_pool] =
7139            self.memory_estimates();
7140        crate::memory::RootMemorySnapshot::new(
7141            semantic,
7142            trigram,
7143            symbols,
7144            callgraph,
7145            callgraph_projection,
7146            inspect,
7147            bash,
7148            lsp,
7149            parser_pool,
7150        )
7151    }
7152
7153    /// Pre-aggregate root memory for capped health diagnostics without building
7154    /// the rich per-subsystem detail that the status command returns.
7155    pub(crate) fn memory_root_rollup(&self) -> crate::memory::RootMemoryRollup {
7156        let estimates = self.memory_estimates();
7157        crate::memory::RootMemoryRollup::from_estimates(&[
7158            &estimates[0],
7159            &estimates[1],
7160            &estimates[2],
7161            &estimates[3],
7162            &estimates[4],
7163            &estimates[5],
7164            &estimates[6],
7165            &estimates[7],
7166            &estimates[8],
7167        ])
7168    }
7169
7170    /// Attribute all actor roots registered in this process. Standalone mode
7171    /// has no actor registry, so the current context is inserted directly.
7172    pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
7173        let mut roots = BTreeMap::new();
7174        let (roots_status, contexts) = match self.app.try_memory_contexts() {
7175            Some(contexts) => ("ready", contexts),
7176            None => ("busy", Vec::new()),
7177        };
7178        for (root, context) in contexts {
7179            roots.insert(root.display().to_string(), context.memory_root_snapshot());
7180        }
7181        // Normalize through the same identity the registry keys on: on Windows
7182        // a verbatim `\\?\` current root would otherwise land as a SECOND
7183        // entry for an already-registered root and double-count its memory.
7184        let current_label = current_root
7185            .map(|root| {
7186                cortexkit_paths::ProjectRootId::from_path(root)
7187                    .map(|id| id.as_path().display().to_string())
7188                    .unwrap_or_else(|_| root.display().to_string())
7189            })
7190            .unwrap_or_else(|| "<unconfigured>".to_string());
7191        roots
7192            .entry(current_label)
7193            .or_insert_with(|| self.memory_root_snapshot());
7194        crate::memory::MemorySnapshot::new(roots_status, roots)
7195    }
7196}
7197
7198#[cfg(test)]
7199mod subc_lifecycle_admission_tests {
7200    use super::*;
7201
7202    #[test]
7203    fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
7204        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7205        ctx.note_configure_warm_key("config-a".to_string());
7206        let content_generation = ctx.configure_content_generation();
7207        let lifecycle_generation = ctx.configure_generation();
7208        let search_epoch = ctx.next_search_persist_epoch();
7209        let semantic_epoch = ctx.next_semantic_persist_epoch();
7210        let search_persist_epoch = ctx.search_persist_epoch_flag();
7211        let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
7212
7213        ctx.mark_subc_unbound();
7214        assert!(ctx.configure_generation() > lifecycle_generation);
7215        assert_eq!(ctx.configure_content_generation(), content_generation);
7216        assert_eq!(search_persist_epoch.current(), search_epoch);
7217        assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
7218
7219        ctx.mark_subc_bound();
7220        ctx.note_configure_warm_key("config-b".to_string());
7221        assert!(ctx.configure_content_generation() > content_generation);
7222        let replacement_search_epoch = ctx.next_search_persist_epoch();
7223        let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
7224        assert!(replacement_search_epoch > search_epoch);
7225        assert!(replacement_semantic_epoch > semantic_epoch);
7226        assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
7227        assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
7228    }
7229
7230    #[test]
7231    fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
7232        let admission = SubcLifecycleAdmission::default();
7233        let generation = Arc::new(AtomicU64::new(11));
7234        let expected = generation.load(Ordering::SeqCst);
7235        let starts = Arc::new(AtomicUsize::new(0));
7236        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
7237        let (release_tx, release_rx) = std::sync::mpsc::channel();
7238
7239        let worker_admission = admission.clone();
7240        let worker_generation = Arc::clone(&generation);
7241        let worker_starts = Arc::clone(&starts);
7242        let worker = std::thread::spawn(move || {
7243            worker_admission.run_if_current(&worker_generation, expected, || {
7244                entered_tx.send(()).unwrap();
7245                release_rx.recv().unwrap();
7246                worker_starts.fetch_add(1, Ordering::SeqCst);
7247            })
7248        });
7249        entered_rx.recv().unwrap();
7250
7251        let unbind_admission = admission.clone();
7252        let unbind_generation = Arc::clone(&generation);
7253        let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
7254        let unbind = std::thread::spawn(move || {
7255            unbind_admission.mark_unbound(&unbind_generation);
7256            unbound_tx.send(()).unwrap();
7257        });
7258
7259        assert!(
7260            unbound_rx
7261                .recv_timeout(std::time::Duration::from_millis(50))
7262                .is_err(),
7263            "unbind must wait for an admitted worker-start commit"
7264        );
7265        release_tx.send(()).unwrap();
7266        assert!(worker.join().unwrap().is_some());
7267        unbound_rx
7268            .recv_timeout(std::time::Duration::from_secs(1))
7269            .unwrap();
7270        unbind.join().unwrap();
7271        assert_eq!(starts.load(Ordering::SeqCst), 1);
7272        assert!(
7273            admission
7274                .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
7275                    starts.fetch_add(1, Ordering::SeqCst);
7276                })
7277                .is_none(),
7278            "worker starts after unbind must be denied"
7279        );
7280    }
7281
7282    #[test]
7283    fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
7284        let ctx = Arc::new(AppContext::new(
7285            default_language_provider_factory(),
7286            Config::default(),
7287        ));
7288        let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
7289        let (started_tx, started_rx) = std::sync::mpsc::channel();
7290        let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
7291        let worker_ctx = Arc::clone(&ctx);
7292        let worker = std::thread::spawn(move || {
7293            started_tx.send(()).unwrap();
7294            snapshot_tx
7295                .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
7296                .unwrap();
7297        });
7298        started_rx
7299            .recv_timeout(Duration::from_secs(1))
7300            .expect("health snapshot worker should start");
7301
7302        let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
7303        let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
7304        drop(lifecycle_guard);
7305        worker.join().unwrap();
7306
7307        assert!(
7308            matches!(
7309                snapshot,
7310                Ok(RootHealthSnapshot {
7311                    state: RootHealthState::Busy,
7312                    ..
7313                })
7314            ),
7315            "health snapshots must report busy instead of waiting for lifecycle admission"
7316        );
7317        assert!(
7318            callgraph_receiver_available,
7319            "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
7320        );
7321    }
7322
7323    #[test]
7324    fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
7325        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7326        ctx.set_artifact_owner(
7327            Some(crate::artifact_owner::ArtifactOwnerStatus {
7328                mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
7329                project_key: "borrowed".to_string(),
7330                manifest_path: "manifest.json".to_string(),
7331                owner_project_scope_key: "owner".to_string(),
7332                owner_checkout_path: "/owner".to_string(),
7333                note: None,
7334            }),
7335            None,
7336        );
7337        ctx.update_status_bar_tier2(Some(4), None, None, None, true);
7338
7339        let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
7340
7341        assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
7342    }
7343
7344    #[test]
7345    fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
7346        let root = tempfile::tempdir().unwrap();
7347        let ctx = AppContext::new(
7348            default_language_provider_factory(),
7349            Config {
7350                project_root: Some(root.path().to_path_buf()),
7351                ..Config::default()
7352            },
7353        );
7354        ctx.set_harness(crate::harness::Harness::Opencode);
7355        ctx.set_cache_writer_capabilities(true, true);
7356        ctx.update_status_bar_tier2(Some(4), None, None, None, true);
7357        assert_eq!(
7358            ctx.try_health_snapshot(Path::new("writer-root"))
7359                .tier2
7360                .expect("tier2 health")
7361                .status,
7362            "building"
7363        );
7364
7365        ctx.set_cache_role(true, None);
7366
7367        assert_eq!(
7368            ctx.try_health_snapshot(Path::new("worktree-root"))
7369                .tier2
7370                .expect("tier2 health")
7371                .status,
7372            "disabled"
7373        );
7374        let tier2_snapshot = ctx.tier2_refresh_snapshot().expect("tier2 snapshot");
7375        assert!(!tier2_snapshot.callgraph_writer);
7376    }
7377
7378    #[test]
7379    fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
7380        let temp = tempfile::tempdir().unwrap();
7381        let ctx = AppContext::new(
7382            default_language_provider_factory(),
7383            Config {
7384                project_root: Some(temp.path().to_path_buf()),
7385                semantic_search: true,
7386                ..Config::default()
7387            },
7388        );
7389        *ctx.semantic_index()
7390            .write()
7391            .unwrap_or_else(std::sync::PoisonError::into_inner) =
7392            Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
7393        let mut status = SemanticIndexStatus::ready();
7394        status.add_refreshing_file(temp.path().join("changed.rs"));
7395        *ctx.semantic_index_status()
7396            .write()
7397            .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
7398        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7399        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7400        ctx.install_semantic_refresh_worker_for_build_epoch(
7401            request_tx,
7402            event_rx,
7403            Arc::new(Mutex::new(None)),
7404            ctx.semantic_index_rx_epoch(),
7405        );
7406
7407        ctx.cancel_unbound_artifact_work();
7408
7409        assert!(ctx.semantic_refresh_event_rx().lock().is_none());
7410        assert!(matches!(
7411            &*ctx
7412                .semantic_index_status()
7413                .read()
7414                .unwrap_or_else(std::sync::PoisonError::into_inner),
7415            SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
7416        ));
7417    }
7418
7419    #[test]
7420    fn terminal_empty_search_receiver_reports_completion_work() {
7421        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7422        let (sender, receiver) = crossbeam_channel::unbounded();
7423        let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
7424        let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
7425        drop(sender);
7426        drop(terminal_guard);
7427
7428        assert!(
7429            ctx.completion_drains_have_work(),
7430            "an empty disconnected one-shot receiver must wake the completion drain"
7431        );
7432    }
7433
7434    #[test]
7435    fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
7436        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7437        let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
7438        let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
7439        let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
7440        let replacement_epoch =
7441            ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
7442
7443        assert!(replacement_epoch > old_epoch);
7444        assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
7445        assert!(ctx.semantic_index_rx().lock().is_some());
7446        assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
7447    }
7448
7449    #[test]
7450    fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
7451        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7452        let (old_sender, old_receiver) = crossbeam_channel::unbounded();
7453        let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
7454        let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
7455        let (current_sender, current_receiver) = crossbeam_channel::unbounded();
7456        let current_epoch =
7457            ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
7458        let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
7459        drop(old_sender);
7460        drop(current_sender);
7461
7462        drop(current_guard);
7463        drop(old_guard);
7464
7465        assert!(current_epoch > old_epoch);
7466        assert_eq!(
7467            ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
7468            current_epoch,
7469            "a stale worker must not move the terminal watermark backward"
7470        );
7471        assert!(ctx.completion_drains_have_work());
7472    }
7473
7474    #[test]
7475    fn finished_semantic_refresh_worker_reports_completion_work() {
7476        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7477        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7478        let (event_tx, event_rx) = crossbeam_channel::unbounded();
7479        let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
7480        ctx.install_semantic_refresh_worker_for_build_epoch(
7481            request_tx,
7482            event_rx,
7483            Arc::clone(&worker_slot),
7484            ctx.semantic_index_rx_epoch(),
7485        );
7486        drop(event_tx);
7487        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
7488        while !worker_slot
7489            .lock()
7490            .unwrap_or_else(std::sync::PoisonError::into_inner)
7491            .as_ref()
7492            .is_some_and(std::thread::JoinHandle::is_finished)
7493        {
7494            assert!(
7495                std::time::Instant::now() < deadline,
7496                "worker did not finish"
7497            );
7498            std::thread::yield_now();
7499        }
7500
7501        assert!(
7502            ctx.completion_drains_have_work(),
7503            "a finished refresh worker must wake the completion drain after its event queue empties"
7504        );
7505    }
7506
7507    #[test]
7508    fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
7509        let admission = SubcLifecycleAdmission::default();
7510        let generation = Arc::new(AtomicU64::new(7));
7511        admission.mark_unbound(&generation);
7512        let expected = generation.load(Ordering::SeqCst);
7513        let starts = Arc::new(AtomicUsize::new(0));
7514
7515        let workers = (0..16)
7516            .map(|_| {
7517                let admission = admission.clone();
7518                let generation = Arc::clone(&generation);
7519                let starts = Arc::clone(&starts);
7520                std::thread::spawn(move || {
7521                    admission.run_if_current(&generation, expected, || {
7522                        starts.fetch_add(1, Ordering::SeqCst);
7523                    })
7524                })
7525            })
7526            .collect::<Vec<_>>();
7527
7528        for worker in workers {
7529            assert!(worker.join().unwrap().is_none());
7530        }
7531        assert_eq!(starts.load(Ordering::SeqCst), 0);
7532    }
7533}
7534
7535#[cfg(test)]
7536mod force_restrict_tests {
7537    use super::*;
7538    use crate::language::StubProvider;
7539    use tempfile::TempDir;
7540
7541    fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
7542        AppContext::new(
7543            Box::new(StubProvider),
7544            Config {
7545                project_root,
7546                restrict_to_project_root,
7547                ..Config::default()
7548            },
7549        )
7550    }
7551
7552    #[test]
7553    fn standalone_validate_path_parity_without_force_restrict() {
7554        let root = TempDir::new().expect("root tempdir");
7555        let outside = TempDir::new().expect("outside tempdir");
7556        let outside_path = outside.path().join("outside.txt");
7557
7558        let unrestricted = test_context(Some(root.path().to_path_buf()), false);
7559        assert_eq!(
7560            unrestricted
7561                .validate_path("standalone-unrestricted", &outside_path)
7562                .expect("unrestricted standalone validates"),
7563            outside_path
7564        );
7565
7566        let restricted = test_context(Some(root.path().to_path_buf()), true);
7567        let err = restricted
7568            .validate_path("standalone-restricted", &outside_path)
7569            .expect_err("restricted standalone rejects outside root");
7570        assert_eq!(
7571            serde_json::to_value(err).unwrap()["code"],
7572            "path_outside_root"
7573        );
7574    }
7575
7576    #[test]
7577    fn path_restriction_root_memo_canonicalizes_once_for_1000_validations() {
7578        let root = TempDir::new().expect("root tempdir");
7579        let target = root.path().join("target.txt");
7580        std::fs::write(&target, "inside").expect("write target");
7581        let ctx = test_context(Some(root.path().to_path_buf()), true);
7582
7583        for request in 0..1_000 {
7584            let validated = ctx
7585                .validate_path(&format!("memo-{request}"), &target)
7586                .expect("in-root path validates");
7587            assert_eq!(validated, std::fs::canonicalize(&target).unwrap());
7588        }
7589
7590        assert_eq!(
7591            ctx.path_restriction_root_canonicalizations_for_test(),
7592            1,
7593            "the configured root should be canonicalized once instead of once per validation"
7594        );
7595    }
7596
7597    #[cfg(unix)]
7598    #[test]
7599    fn path_restriction_root_memo_recanonicalizes_after_cached_target_disappears() {
7600        let workspace = TempDir::new().expect("workspace tempdir");
7601        let first_target = workspace.path().join("first-target");
7602        let second_target = workspace.path().join("second-target");
7603        let configured_root = workspace.path().join("configured-root");
7604        std::fs::create_dir_all(&first_target).expect("create first target");
7605        std::fs::create_dir_all(&second_target).expect("create second target");
7606        std::os::unix::fs::symlink(&first_target, &configured_root)
7607            .expect("create configured-root symlink");
7608        std::fs::write(first_target.join("inside.txt"), "first").expect("write first target");
7609
7610        let ctx = test_context(Some(configured_root.clone()), true);
7611        assert_eq!(
7612            ctx.validate_path("first-target", Path::new("inside.txt"))
7613                .expect("first target validates"),
7614            std::fs::canonicalize(first_target.join("inside.txt")).unwrap()
7615        );
7616
7617        // Keep the configured PathBuf unchanged while replacing its resolved
7618        // target. The missing cached target must cause a new canonicalization.
7619        std::fs::remove_dir_all(&first_target).expect("remove first target");
7620        std::fs::remove_file(&configured_root).expect("remove old root symlink");
7621        std::os::unix::fs::symlink(&second_target, &configured_root)
7622            .expect("recreate configured-root symlink");
7623        std::fs::write(second_target.join("inside.txt"), "second").expect("write second target");
7624
7625        assert_eq!(
7626            ctx.validate_path("second-target", Path::new("inside.txt"))
7627                .expect("second target validates"),
7628            std::fs::canonicalize(second_target.join("inside.txt")).unwrap()
7629        );
7630        assert_eq!(ctx.path_restriction_root_canonicalizations_for_test(), 2);
7631    }
7632
7633    #[test]
7634    fn force_restrict_guard_refcounts_duplicate_request_ids() {
7635        let root = TempDir::new().expect("root tempdir");
7636        let outside = TempDir::new().expect("outside tempdir");
7637        let outside_path = outside.path().join("outside.txt");
7638        let ctx = test_context(Some(root.path().to_path_buf()), false);
7639
7640        assert!(ctx.validate_path("dup", &outside_path).is_ok());
7641        let guard1 = ctx.force_restrict_guard("dup");
7642        let guard2 = ctx.force_restrict_guard("dup");
7643        assert!(ctx.validate_path("dup", &outside_path).is_err());
7644        drop(guard1);
7645        assert!(
7646            ctx.validate_path("dup", &outside_path).is_err(),
7647            "duplicate guard must keep the request over-restricted"
7648        );
7649        drop(guard2);
7650        assert!(ctx.validate_path("dup", &outside_path).is_ok());
7651    }
7652
7653    #[test]
7654    fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
7655        let root = TempDir::new().expect("root tempdir");
7656        let outside = TempDir::new().expect("outside tempdir");
7657        let outside_path = outside.path().join("outside.txt");
7658        let ctx = test_context(Some(root.path().to_path_buf()), false);
7659
7660        ctx.with_force_restrict("normal", || {
7661            assert!(ctx.validate_path("normal", &outside_path).is_err());
7662        });
7663        assert!(!ctx.request_force_restrict("normal"));
7664        assert!(ctx.validate_path("normal", &outside_path).is_ok());
7665
7666        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7667            ctx.with_force_restrict("panic", || {
7668                assert!(ctx.validate_path("panic", &outside_path).is_err());
7669                panic!("intentional force-restrict cleanup panic");
7670            });
7671        }));
7672        assert!(panicked.is_err());
7673        assert!(!ctx.request_force_restrict("panic"));
7674        assert!(ctx.validate_path("panic", &outside_path).is_ok());
7675    }
7676
7677    #[cfg(unix)]
7678    #[test]
7679    fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
7680        let root = TempDir::new().expect("root tempdir");
7681        let outside = tempfile::NamedTempFile::new().expect("outside file");
7682        let link = root.path().join("file.txt");
7683        std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
7684        let ctx = test_context(Some(root.path().to_path_buf()), false);
7685        let _guard = ctx.force_restrict_guard("write-location-final-link");
7686
7687        let validated = ctx
7688            .validate_write_location("write-location-final-link", &link)
7689            .expect("the in-root link location is writable");
7690
7691        assert_eq!(
7692            validated,
7693            std::fs::canonicalize(root.path()).unwrap().join("file.txt")
7694        );
7695    }
7696
7697    #[cfg(unix)]
7698    #[test]
7699    fn validate_write_location_rejects_symlinked_parent_escape() {
7700        let root = TempDir::new().expect("root tempdir");
7701        let outside = TempDir::new().expect("outside tempdir");
7702        let linked_parent = root.path().join("linked-parent");
7703        std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
7704        let candidate = linked_parent.join("file.txt");
7705        let ctx = test_context(Some(root.path().to_path_buf()), false);
7706        let _guard = ctx.force_restrict_guard("write-location-parent-link");
7707
7708        let error = ctx
7709            .validate_write_location("write-location-parent-link", &candidate)
7710            .expect_err("a symlinked parent must not escape the project root");
7711
7712        assert_eq!(
7713            serde_json::to_value(error).unwrap()["code"],
7714            "path_outside_root"
7715        );
7716    }
7717
7718    #[cfg(unix)]
7719    #[test]
7720    fn validate_write_location_rejects_outside_link_to_inside_file() {
7721        let root = TempDir::new().expect("root tempdir");
7722        let outside = TempDir::new().expect("outside tempdir");
7723        let inside = root.path().join("inside.txt");
7724        std::fs::write(&inside, "inside").unwrap();
7725        let outside_link = outside.path().join("outside-link.txt");
7726        std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
7727        let ctx = test_context(Some(root.path().to_path_buf()), false);
7728        let _guard = ctx.force_restrict_guard("write-location-outside-link");
7729
7730        let error = ctx
7731            .validate_write_location("write-location-outside-link", &outside_link)
7732            .expect_err("an out-of-root lexical location must remain blocked");
7733
7734        assert_eq!(
7735            serde_json::to_value(error).unwrap()["code"],
7736            "path_outside_root"
7737        );
7738    }
7739
7740    #[test]
7741    fn forced_restrict_without_project_root_fails_closed() {
7742        let ctx = test_context(None, false);
7743        let _guard = ctx.force_restrict_guard("missing-root");
7744        let err = ctx
7745            .validate_path("missing-root", Path::new("relative.txt"))
7746            .expect_err("forced restriction without a root must fail closed");
7747        assert_eq!(
7748            serde_json::to_value(err).unwrap()["code"],
7749            "path_outside_root"
7750        );
7751
7752        let write_err = ctx
7753            .validate_write_location("missing-root", Path::new("relative.txt"))
7754            .expect_err("write-location validation must also fail closed");
7755        assert_eq!(
7756            serde_json::to_value(write_err).unwrap()["code"],
7757            "path_outside_root"
7758        );
7759    }
7760}
7761
7762#[cfg(test)]
7763mod callgraph_store_for_ops_tests {
7764    use super::*;
7765    use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
7766    use crate::parser::TreeSitterProvider;
7767    use crate::protocol::RawRequest;
7768    use serde_json::json;
7769    use std::path::Path;
7770    use std::sync::Barrier;
7771    use tempfile::TempDir;
7772
7773    fn callgraph_build_wait_ms(ms: u64) -> super::CallgraphBuildWaitMsGuard {
7774        super::override_callgraph_build_wait_ms_for_test(ms)
7775    }
7776
7777    fn force_async_callgraph_builds() -> super::CallgraphBuildWaitMsGuard {
7778        callgraph_build_wait_ms(0)
7779    }
7780
7781    fn cold_build_context() -> Arc<AppContext> {
7782        let project = TempDir::new().expect("project tempdir");
7783        let storage = TempDir::new().expect("storage tempdir");
7784        let source_dir = project.path().join("src");
7785        std::fs::create_dir_all(&source_dir).expect("source dir");
7786        std::fs::write(
7787            source_dir.join("lib.rs"),
7788            "pub fn caller() { callee(); }\npub fn callee() {}\n",
7789        )
7790        .expect("source file");
7791
7792        Arc::new(AppContext::new(
7793            Box::new(TreeSitterProvider::new()),
7794            Config {
7795                project_root: Some(project.keep()),
7796                storage_dir: Some(storage.keep()),
7797                callgraph_chunk_size: 1,
7798                ..Config::default()
7799            },
7800        ))
7801    }
7802
7803    fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
7804        let _guard = crate::test_env::process_env_lock();
7805        let prev_home = std::env::var_os("HOME");
7806        let prev_userprofile = std::env::var_os("USERPROFILE");
7807        unsafe {
7808            std::env::set_var("HOME", home);
7809            std::env::set_var("USERPROFILE", home);
7810        }
7811        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
7812        unsafe {
7813            match prev_home {
7814                Some(value) => std::env::set_var("HOME", value),
7815                None => std::env::remove_var("HOME"),
7816            }
7817            match prev_userprofile {
7818                Some(value) => std::env::set_var("USERPROFILE", value),
7819                None => std::env::remove_var("USERPROFILE"),
7820            }
7821        }
7822        match result {
7823            Ok(value) => value,
7824            Err(payload) => std::panic::resume_unwind(payload),
7825        }
7826    }
7827
7828    fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
7829        RawRequest {
7830            id: "cfg".to_string(),
7831            command: "configure".to_string(),
7832            lsp_hints: None,
7833            session_id: None,
7834            params,
7835        }
7836    }
7837
7838    fn user_tier(doc: serde_json::Value) -> serde_json::Value {
7839        json!({
7840            "tier": "user",
7841            "source": "/u/aft.jsonc",
7842            "doc": doc.to_string(),
7843        })
7844    }
7845
7846    fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
7847        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7848        let response = crate::commands::configure::handle_configure(
7849            &configure_request_with_params(json!({
7850                "project_root": project_root,
7851                "harness": "opencode",
7852                "storage_dir": storage_dir,
7853                "config": [user_tier(json!({
7854                    "callgraph_store": true,
7855                    "search_index": true,
7856                    "semantic_search": true,
7857                }))],
7858            })),
7859            &ctx,
7860        );
7861        assert!(response.success, "configure should succeed: {response:?}");
7862        ctx
7863    }
7864
7865    fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
7866        InspectSnapshot::new(
7867            ctx.canonical_cache_root(),
7868            ctx.inspect_dir(),
7869            ctx.config(),
7870            ctx.symbol_cache(),
7871        )
7872    }
7873
7874    fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
7875        let project_root = ctx
7876            .config()
7877            .project_root
7878            .clone()
7879            .expect("test context has a project root");
7880        let files: Vec<PathBuf> = Vec::new();
7881        let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
7882        SemanticIndex::build(&project_root, &files, &mut embed, 1)
7883            .expect("empty semantic index should build")
7884    }
7885
7886    #[test]
7887    fn home_root_gate_blocks_callgraph_store_entry_points() {
7888        let _wait_guard = force_async_callgraph_builds();
7889        let home = TempDir::new().expect("home tempdir");
7890        let storage = TempDir::new().expect("storage tempdir");
7891        let source_dir = home.path().join("src");
7892        std::fs::create_dir_all(&source_dir).expect("source dir");
7893        std::fs::write(
7894            source_dir.join("lib.rs"),
7895            "pub fn caller() { callee(); }\npub fn callee() {}\n",
7896        )
7897        .expect("source file");
7898
7899        with_fake_home_env(home.path(), || {
7900            let ctx = configure_context(home.path(), storage.path());
7901            assert!(
7902                !ctx.heavy_root_work_allowed(),
7903                "HOME root configure must close the heavy-root-work gate"
7904            );
7905            assert!(
7906                !ctx.config().callgraph_store,
7907                "HOME root configure must force-disable the callgraph store"
7908            );
7909            assert!(ctx.is_home_root());
7910            assert!(ctx
7911                .degraded_reasons()
7912                .iter()
7913                .any(|reason| reason == "home_root"));
7914            let status_request = RawRequest {
7915                id: "home-status".to_string(),
7916                command: "status".to_string(),
7917                lsp_hints: None,
7918                session_id: None,
7919                params: json!({}),
7920            };
7921            let status = crate::commands::status::handle_status(&status_request, &ctx);
7922            assert_eq!(status.data["features"]["callgraph_store"], false);
7923            crate::commands::configure::drain_deferred_configure_maintenance(&ctx);
7924            assert!(
7925                ctx.callgraph_store_rx().lock().is_none(),
7926                "HOME root maintenance must not schedule a callgraph build"
7927            );
7928            assert_eq!(
7929                ctx.try_health_snapshot(home.path())
7930                    .callgraph_store
7931                    .as_ref()
7932                    .map(|component| component.status),
7933                Some("disabled"),
7934                "HOME root health must not advertise callgraph building"
7935            );
7936
7937            reset_callgraph_cold_build_spawn_count_for_test();
7938            assert!(matches!(
7939                ctx.callgraph_store_for_ops(),
7940                CallgraphStoreAccess::Unavailable
7941            ));
7942            assert!(
7943                ctx.ensure_callgraph_store()
7944                    .expect("ensure_callgraph_store should not error")
7945                    .is_none(),
7946                "shared gate must also block synchronous standalone callgraph builds"
7947            );
7948            assert_eq!(
7949                callgraph_cold_build_spawn_count_for_test(),
7950                0,
7951                "HOME root gate must not spawn a cold callgraph build"
7952            );
7953
7954            let navigation = RawRequest {
7955                id: "home-callers".to_string(),
7956                command: "callers".to_string(),
7957                lsp_hints: None,
7958                session_id: None,
7959                params: json!({
7960                    "file": source_dir.join("lib.rs"),
7961                    "symbol": "caller",
7962                }),
7963            };
7964            let response = crate::commands::callers::handle_callers(&navigation, &ctx);
7965            assert!(!response.success);
7966            assert_eq!(response.data["code"], "callgraph_disabled");
7967            assert_eq!(response.data["status"], "disabled");
7968            assert_eq!(response.data["reason"], "home_root");
7969            assert!(response.data["message"]
7970                .as_str()
7971                .is_some_and(|message| message.contains("disabled for home roots")));
7972        });
7973    }
7974
7975    #[test]
7976    fn home_root_gate_blocks_inspect_manager_submit_paths() {
7977        let home = TempDir::new().expect("home tempdir");
7978        let storage = TempDir::new().expect("storage tempdir");
7979        let source_dir = home.path().join("src");
7980        std::fs::create_dir_all(&source_dir).expect("source dir");
7981        std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
7982
7983        with_fake_home_env(home.path(), || {
7984            let ctx = configure_context(home.path(), storage.path());
7985            let snapshot = inspect_snapshot(&ctx);
7986            let scope = JobScope::for_project(snapshot.project_root.clone());
7987            let manager = ctx.inspect_manager();
7988
7989            assert!(matches!(
7990                manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
7991                JobOutcome::Failed { .. }
7992            ));
7993
7994            let submission = manager.submit_tier2_run_with_reuse_serial_background(
7995                snapshot,
7996                vec![InspectCategory::DeadCode],
7997            );
7998            assert!(submission.queued_categories.is_empty());
7999            assert!(submission.newly_queued_categories.is_empty());
8000            assert!(submission.deferred_categories.is_empty());
8001            assert_eq!(submission.errors.len(), 1);
8002            assert!(
8003                !manager.tier2_any_in_flight(),
8004                "HOME root gate must reject Tier-2 submission before any job is queued"
8005            );
8006        });
8007    }
8008
8009    #[test]
8010    fn non_home_root_still_allows_callgraph_cold_builds() {
8011        let _env_guard = force_async_callgraph_builds();
8012        reset_callgraph_cold_build_spawn_count_for_test();
8013        let ctx = cold_build_context();
8014
8015        assert!(ctx.heavy_root_work_allowed());
8016        assert!(matches!(
8017            ctx.callgraph_store_for_ops(),
8018            CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8019        ));
8020        assert_eq!(
8021            callgraph_cold_build_spawn_count_for_test(),
8022            1,
8023            "non-home roots must still be able to cold-build the callgraph store"
8024        );
8025
8026        let rx = ctx
8027            .callgraph_store_rx
8028            .lock()
8029            .as_ref()
8030            .cloned()
8031            .expect("non-home cold build should install an in-flight receiver");
8032        rx.recv_timeout(Duration::from_secs(30))
8033            .expect("background cold build should complete");
8034        *ctx.callgraph_store_rx.lock() = None;
8035    }
8036
8037    #[test]
8038    fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
8039        let _env_guard = force_async_callgraph_builds();
8040        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8041        let ctx = cold_build_context();
8042        let (tx, rx) = crossbeam_channel::unbounded();
8043        *ctx.semantic_index_rx().lock() = Some(rx);
8044        ctx.schedule_semantic_cold_seed_gate_for_configure();
8045
8046        assert!(matches!(
8047            ctx.callgraph_store_for_ops(),
8048            CallgraphStoreAccess::Building
8049        ));
8050        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
8051        tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
8052            &ctx,
8053        )))
8054        .expect("send ready event");
8055
8056        crate::runtime_drain::drain_semantic_index_events(&ctx);
8057
8058        assert!(
8059            !ctx.semantic_cold_seed_active(),
8060            "semantic Ready must clear the scheduled cold gate"
8061        );
8062        assert!(
8063            ctx.tier2_pull_demand_pending(),
8064            "semantic Ready must resume deferred Tier-2 work"
8065        );
8066        assert_eq!(
8067            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8068            1,
8069            "semantic Ready must resume the deferred callgraph warm"
8070        );
8071        let rx = ctx
8072            .callgraph_store_rx
8073            .lock()
8074            .as_ref()
8075            .cloned()
8076            .expect("ready resume should install an in-flight callgraph receiver");
8077        rx.recv_timeout(Duration::from_secs(30))
8078            .expect("background cold build should complete");
8079        *ctx.callgraph_store_rx.lock() = None;
8080    }
8081
8082    #[test]
8083    fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
8084        let _env_guard = force_async_callgraph_builds();
8085        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8086        let ctx = cold_build_context();
8087        ctx.schedule_semantic_cold_seed_gate_for_configure();
8088
8089        assert!(matches!(
8090            ctx.callgraph_store_for_ops(),
8091            CallgraphStoreAccess::Building
8092        ));
8093        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
8094        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
8095
8096        assert!(
8097            !ctx.semantic_cold_seed_active(),
8098            "cached-load or retry-wait clear must reopen the semantic cold gate"
8099        );
8100        assert!(
8101            ctx.tier2_pull_demand_pending(),
8102            "cached-load or retry-wait clear must resume deferred Tier-2 work"
8103        );
8104        assert_eq!(
8105            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8106            1,
8107            "cached-load or retry-wait clear must resume deferred callgraph warm"
8108        );
8109        let rx = ctx
8110            .callgraph_store_rx
8111            .lock()
8112            .as_ref()
8113            .cloned()
8114            .expect("gate-clear resume should install an in-flight callgraph receiver");
8115        rx.recv_timeout(Duration::from_secs(30))
8116            .expect("background cold build should complete");
8117        *ctx.callgraph_store_rx.lock() = None;
8118    }
8119
8120    #[test]
8121    fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
8122        let _env_guard = force_async_callgraph_builds();
8123        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8124        let ctx = cold_build_context();
8125
8126        ctx.set_semantic_cold_seed_active_for_test(true);
8127        assert!(
8128            matches!(
8129                ctx.callgraph_store_for_ops(),
8130                CallgraphStoreAccess::Building
8131            ),
8132            "callgraph ops should degrade as building while the semantic cold gate is active"
8133        );
8134        assert_eq!(
8135            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8136            0,
8137            "semantic cold gate must not spawn a competing callgraph cold build"
8138        );
8139        assert!(ctx.semantic_callgraph_warm_deferred_for_test());
8140
8141        ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
8142        assert_eq!(
8143            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8144            1,
8145            "clearing the semantic cold gate should resume the deferred callgraph warm"
8146        );
8147
8148        let rx = ctx
8149            .callgraph_store_rx
8150            .lock()
8151            .as_ref()
8152            .cloned()
8153            .expect("deferred warm should install an in-flight receiver");
8154        rx.recv_timeout(Duration::from_secs(30))
8155            .expect("background cold build should complete");
8156        *ctx.callgraph_store_rx.lock() = None;
8157    }
8158
8159    #[test]
8160    fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
8161        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8162        ctx.schedule_semantic_cold_seed_gate_for_configure();
8163
8164        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
8165
8166        assert!(
8167            !ctx.semantic_cold_seed_active(),
8168            "retry-wait or cached-load events must reopen the semantic cold gate"
8169        );
8170        assert!(
8171            ctx.tier2_pull_demand_pending(),
8172            "clearing the semantic cold gate should kick a Tier-2 pull refresh"
8173        );
8174    }
8175
8176    #[test]
8177    fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
8178        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8179        let (tx, rx) = crossbeam_channel::unbounded();
8180        *ctx.semantic_index_rx().lock() = Some(rx);
8181        ctx.schedule_semantic_cold_seed_gate_for_configure();
8182        tx.send(SemanticIndexEvent::Failed(
8183            "embedding backend failed".to_string(),
8184        ))
8185        .expect("send failed event");
8186
8187        crate::runtime_drain::drain_semantic_index_events(&ctx);
8188
8189        assert!(
8190            !ctx.semantic_cold_seed_active(),
8191            "semantic Failed must clear the scheduled cold gate"
8192        );
8193        assert!(
8194            ctx.tier2_pull_demand_pending(),
8195            "semantic Failed must resume deferred Tier-2 work"
8196        );
8197    }
8198
8199    #[test]
8200    fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
8201        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8202        let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
8203        *ctx.semantic_index_rx().lock() = Some(rx);
8204        ctx.schedule_semantic_cold_seed_gate_for_configure();
8205        drop(tx);
8206
8207        crate::runtime_drain::drain_semantic_index_events(&ctx);
8208
8209        assert!(
8210            !ctx.semantic_cold_seed_active(),
8211            "semantic worker disconnect must clear the scheduled cold gate"
8212        );
8213        assert!(
8214            ctx.tier2_pull_demand_pending(),
8215            "semantic worker disconnect must resume deferred Tier-2 work"
8216        );
8217    }
8218
8219    #[test]
8220    fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
8221        let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8222        let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8223        let base = Instant::now();
8224        ctx_a.reset_tier2_refresh_scheduler_at(base);
8225        ctx_b.reset_tier2_refresh_scheduler_at(base);
8226        ctx_a.set_semantic_cold_seed_active_for_test(true);
8227
8228        assert_eq!(
8229            ctx_a.tick_tier2_refresh_scheduler_at(
8230                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
8231                0,
8232            ),
8233            None,
8234            "root A should defer Tier-2 while its semantic cold seed is active"
8235        );
8236        assert_eq!(
8237            ctx_b.tick_tier2_refresh_scheduler_at(
8238                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
8239                0,
8240            ),
8241            Some(Tier2TriggerReason::ConfigureWarm),
8242            "root B must not inherit root A's semantic cold gate"
8243        );
8244    }
8245
8246    #[test]
8247    fn query_wait_joins_callgraph_build_scheduled_without_wait() {
8248        let _env_guard = callgraph_build_wait_ms(10_000);
8249        let project = TempDir::new().expect("project tempdir");
8250        let storage = TempDir::new().expect("storage tempdir");
8251        std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8252        let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
8253        let project_key = crate::search_index::artifact_cache_key(&project_root);
8254        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
8255        let ctx = Arc::new(AppContext::new(
8256            Box::new(TreeSitterProvider::new()),
8257            Config {
8258                project_root: Some(project_root.clone()),
8259                storage_dir: Some(storage.path().to_path_buf()),
8260                callgraph_chunk_size: 1,
8261                ..Config::default()
8262            },
8263        ));
8264        let (reached, release) = install_callgraph_build_start_gate(project_root);
8265
8266        assert!(matches!(
8267            ctx.schedule_callgraph_store_warm(),
8268            CallgraphStoreAccess::Building
8269        ));
8270        reached
8271            .recv_timeout(Duration::from_secs(2))
8272            .expect("scheduled callgraph worker did not reach start barrier");
8273
8274        let (result_tx, result_rx) = std::sync::mpsc::channel();
8275        let query_ctx = Arc::clone(&ctx);
8276        let query = std::thread::spawn(move || {
8277            result_tx
8278                .send(query_ctx.callgraph_store_for_ops())
8279                .expect("send query result");
8280        });
8281        assert!(
8282            matches!(
8283                result_rx.recv_timeout(Duration::from_millis(100)),
8284                Err(std::sync::mpsc::RecvTimeoutError::Timeout)
8285            ),
8286            "query returned while the scheduled callgraph build was still in flight"
8287        );
8288
8289        release.send(()).expect("release callgraph worker");
8290        assert!(matches!(
8291            result_rx
8292                .recv_timeout(Duration::from_secs(10))
8293                .expect("query did not settle after the callgraph build completed"),
8294            CallgraphStoreAccess::Ready(_)
8295        ));
8296        query.join().expect("callgraph query thread");
8297    }
8298
8299    #[test]
8300    fn inline_wait_settled_event_clears_superseded_receiver() {
8301        let _env_guard = callgraph_build_wait_ms(2_000);
8302        let project = TempDir::new().expect("project tempdir");
8303        let storage = TempDir::new().expect("storage tempdir");
8304        std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8305        let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
8306        let ctx = Arc::new(AppContext::new(
8307            Box::new(TreeSitterProvider::new()),
8308            Config {
8309                project_root: Some(project.path().to_path_buf()),
8310                storage_dir: Some(storage.path().to_path_buf()),
8311                callgraph_chunk_size: 1,
8312                ..Config::default()
8313            },
8314        ));
8315        let (reached, release) = install_callgraph_build_start_gate(project_root);
8316        let request_ctx = Arc::clone(&ctx);
8317        let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
8318        reached
8319            .recv_timeout(Duration::from_secs(2))
8320            .expect("callgraph worker did not reach start barrier");
8321
8322        ctx.next_callgraph_persist_epoch();
8323        release.send(()).unwrap();
8324        assert!(matches!(
8325            request.join().expect("callgraph request thread"),
8326            CallgraphStoreAccess::Building
8327        ));
8328        assert!(
8329            ctx.callgraph_store_rx().lock().is_none(),
8330            "inline Settled handling must retire the matching receiver"
8331        );
8332        assert!(
8333            ctx.callgraph_store()
8334                .read()
8335                .unwrap_or_else(std::sync::PoisonError::into_inner)
8336                .is_none(),
8337            "Settled must not reopen and install an older persisted store"
8338        );
8339    }
8340
8341    #[test]
8342    fn pointer_removal_arm_is_scoped_to_its_callgraph_pointer() {
8343        let temp = TempDir::new().expect("pointer tempdir");
8344        let target = temp.path().join("target.current");
8345        let unrelated = temp.path().join("unrelated.current");
8346        std::fs::write(&target, "target-generation\n").expect("target pointer");
8347        std::fs::write(&unrelated, "unrelated-generation\n").expect("unrelated pointer");
8348        let _arm = install_callgraph_pointer_removal_arm(target.clone());
8349
8350        // The test hook must remove only its target pointer. Completing another
8351        // callgraph build must leave that pointer and the target hook intact.
8352        remove_armed_callgraph_pointer_for_test(&unrelated);
8353        assert!(
8354            unrelated.exists(),
8355            "unrelated pointer must remain published"
8356        );
8357        assert!(target.exists(), "target arm must remain pending");
8358
8359        remove_armed_callgraph_pointer_for_test(&target);
8360        assert!(!target.exists(), "target pointer should consume its arm");
8361        assert!(
8362            unrelated.exists(),
8363            "unrelated pointer must remain published"
8364        );
8365    }
8366
8367    #[test]
8368    fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
8369        let _env_guard = callgraph_build_wait_ms(2_000);
8370        let project = TempDir::new().expect("project tempdir");
8371        let storage = TempDir::new().expect("storage tempdir");
8372        std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8373        let ctx = AppContext::new(
8374            Box::new(TreeSitterProvider::new()),
8375            Config {
8376                project_root: Some(project.path().to_path_buf()),
8377                storage_dir: Some(storage.path().to_path_buf()),
8378                callgraph_chunk_size: 1,
8379                ..Config::default()
8380            },
8381        );
8382        let project_key = crate::search_index::artifact_cache_key(project.path());
8383        crate::root_cache::configure_artifact_access(project.path(), &project_key, false);
8384        let pending = project.path().join("pending.rs");
8385        ctx.add_pending_callgraph_store_paths([pending.clone()]);
8386        let pointer = ctx
8387            .callgraph_store_dir()
8388            .join(format!("{project_key}.current"));
8389        let _remove_pointer_guard = install_callgraph_pointer_removal_arm(pointer);
8390
8391        assert!(matches!(
8392            ctx.callgraph_store_for_ops(),
8393            CallgraphStoreAccess::Building
8394        ));
8395        assert!(
8396            ctx.callgraph_store_rx().lock().is_none(),
8397            "inline Ready must settle after the published pointer disappears"
8398        );
8399        assert_eq!(
8400            ctx.take_pending_callgraph_store_paths(),
8401            vec![pending],
8402            "inline reopen failure must preserve pending watcher paths"
8403        );
8404    }
8405
8406    #[test]
8407    fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
8408        let project = TempDir::new().expect("project tempdir");
8409        let foreign = TempDir::new().expect("foreign tempdir");
8410        let ctx = AppContext::new(
8411            Box::new(TreeSitterProvider::new()),
8412            Config {
8413                project_root: Some(project.path().to_path_buf()),
8414                ..Config::default()
8415            },
8416        );
8417        let inside = project.path().join("kept.rs");
8418        // A late-deferring batch from a superseded root writes into the shared
8419        // pending sink; replaying it into the NEW root's store would index a
8420        // foreign project's files.
8421        let outside = foreign.path().join("previous-root-file.rs");
8422        // Lexical escape: starts_with(project) is true on the raw spelling but
8423        // the path resolves outside the root.
8424        let dotdot_escape = project
8425            .path()
8426            .join("..")
8427            .join(
8428                foreign
8429                    .path()
8430                    .file_name()
8431                    .expect("foreign tempdir has a name"),
8432            )
8433            .join("escaped.rs");
8434        ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
8435
8436        assert_eq!(
8437            ctx.take_pending_callgraph_store_paths(),
8438            vec![inside],
8439            "pending replay must drop foreign and dot-dot-escaping paths"
8440        );
8441    }
8442
8443    #[test]
8444    fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
8445        let project = TempDir::new().expect("project tempdir");
8446        let ctx = AppContext::new(
8447            Box::new(TreeSitterProvider::new()),
8448            Config {
8449                project_root: Some(project.path().to_path_buf()),
8450                semantic_search: true,
8451                ..Config::default()
8452            },
8453        );
8454        ctx.set_canonical_cache_root(project.path().to_path_buf());
8455        // Read-only root: a force token could only be fulfilled by a local
8456        // writer build, which this root will never run.
8457        ctx.set_cache_writer_capabilities(false, true);
8458        *ctx.semantic_index_status()
8459            .write()
8460            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8461
8462        ctx.invalidate_artifacts_after_watcher_gap();
8463
8464        assert!(
8465            matches!(
8466                &*ctx
8467                    .semantic_index_status()
8468                    .read()
8469                    .unwrap_or_else(std::sync::PoisonError::into_inner),
8470                SemanticIndexStatus::Ready { .. }
8471            ),
8472            "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
8473        );
8474        assert_eq!(
8475            ctx.pending_callgraph_store_force_token(),
8476            None,
8477            "read-only root must not be stuck behind an unfulfillable force token"
8478        );
8479    }
8480
8481    #[test]
8482    fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
8483        let project = TempDir::new().expect("project tempdir");
8484        let ctx = AppContext::new(
8485            Box::new(TreeSitterProvider::new()),
8486            Config {
8487                project_root: Some(project.path().to_path_buf()),
8488                ..Config::default()
8489            },
8490        );
8491        ctx.set_canonical_cache_root(project.path().to_path_buf());
8492        ctx.set_cache_writer_capabilities(true, true);
8493
8494        ctx.invalidate_artifacts_after_watcher_gap();
8495
8496        assert!(
8497            ctx.pending_callgraph_store_force_token().is_some(),
8498            "writer roots must still reconcile the store after the unobserved interval"
8499        );
8500        assert!(
8501            matches!(
8502                &*ctx
8503                    .semantic_index_status()
8504                    .read()
8505                    .unwrap_or_else(std::sync::PoisonError::into_inner),
8506                SemanticIndexStatus::Disabled
8507            ),
8508            "semantic-disabled config maps to Disabled status"
8509        );
8510    }
8511
8512    #[cfg(unix)]
8513    #[test]
8514    fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
8515        let project = TempDir::new().expect("project tempdir");
8516        let foreign = TempDir::new().expect("foreign tempdir");
8517        std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
8518        std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
8519        let ctx = AppContext::new(
8520            Box::new(TreeSitterProvider::new()),
8521            Config {
8522                project_root: Some(project.path().to_path_buf()),
8523                ..Config::default()
8524            },
8525        );
8526        // `root/link` targets a foreign directory; `root/link/../secret.rs`
8527        // therefore resolves to `foreign/secret.rs` under filesystem-first
8528        // semantics (matching the store's normalize_file_path). A lexical-first
8529        // filter would erase `link/..` and wrongly keep it as `root/secret.rs`.
8530        std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
8531            .expect("plant symlink");
8532        let escape = project.path().join("link").join("..").join("secret.rs");
8533        // Dead component below the symlink: full canonicalization fails, so
8534        // the ancestor walk must reach and resolve `link` BEFORE any lexical
8535        // `..` resolution — a lexical-first pass would erase `dead/../..` and
8536        // wrongly keep this as `root/deep-secret.rs`.
8537        let dead_component_escape = project
8538            .path()
8539            .join("link")
8540            .join("dead")
8541            .join("..")
8542            .join("..")
8543            .join("deep-secret.rs");
8544        // Re-entry: `dead/..` drains back to the project root, then `link`
8545        // (an EXISTING symlink) must resolve through the filesystem — a
8546        // one-shot lexical pass over the dead tail would erase `link/..` too
8547        // and wrongly keep this as `root/reentry-secret.rs`.
8548        std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
8549            .expect("reentry secret");
8550        let reentry_escape = project
8551            .path()
8552            .join("dead")
8553            .join("..")
8554            .join("link")
8555            .join("..")
8556            .join("reentry-secret.rs");
8557        // Dangling symlink whose `..` re-enters the root: the store cannot
8558        // canonicalize it either and keeps the raw absolute spelling as an
8559        // out-of-root key, so containment must fail closed (a repaired-target
8560        // race could otherwise index outside the root).
8561        std::os::unix::fs::symlink(
8562            foreign.path().join("nonexistent-target"),
8563            project.path().join("dangling"),
8564        )
8565        .expect("plant dangling symlink");
8566        let dangling_reentry = project
8567            .path()
8568            .join("dangling")
8569            .join("..")
8570            .join("via-dangling.rs");
8571        // `..` traversal through a regular file: realpath rejects with
8572        // ENOTDIR; lexically popping the file would fabricate containment.
8573        std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
8574        let through_file = project
8575            .path()
8576            .join("plain.rs")
8577            .join("..")
8578            .join("via-file.rs");
8579        let kept = project.path().join("kept.rs");
8580        ctx.add_pending_callgraph_store_paths([
8581            escape,
8582            dead_component_escape,
8583            reentry_escape,
8584            dangling_reentry,
8585            through_file,
8586            kept.clone(),
8587        ]);
8588
8589        assert_eq!(
8590            ctx.take_pending_callgraph_store_paths(),
8591            vec![kept],
8592            "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
8593        );
8594    }
8595
8596    #[cfg(windows)]
8597    #[test]
8598    fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
8599        // Guard-sensitivity: exercise the classifier directly against a root
8600        // ON THE DRIVE CWD's drive, where join() replaces the root and the
8601        // joined path can genuinely resolve under the drive CWD — without the
8602        // early Prefix/RootDir rejection, a `C:file-under-cwd` spelling whose
8603        // drive CWD happens to sit inside the root would pass the post-join
8604        // prefix check.
8605        let cwd = std::env::current_dir().expect("drive cwd");
8606        let cwd_file = PathBuf::from(format!(
8607            "{}under-drive-cwd.rs",
8608            cwd.components()
8609                .next()
8610                .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
8611                .expect("drive prefix")
8612        ));
8613        assert!(cwd_file.is_relative(), "C:foo must classify as relative");
8614        assert!(
8615            !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
8616            "drive-relative spelling must be rejected even when the drive CWD is inside the root"
8617        );
8618        assert!(
8619            !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
8620            "root-relative spelling must be rejected"
8621        );
8622
8623        let project = TempDir::new().expect("project tempdir");
8624        let ctx = AppContext::new(
8625            Box::new(TreeSitterProvider::new()),
8626            Config {
8627                project_root: Some(project.path().to_path_buf()),
8628                ..Config::default()
8629            },
8630        );
8631        let kept = project.path().join("kept.rs");
8632        ctx.add_pending_callgraph_store_paths([
8633            PathBuf::from("C:drive-relative.rs"),
8634            PathBuf::from(r"\root-relative.rs"),
8635            kept.clone(),
8636        ]);
8637
8638        assert_eq!(
8639            ctx.take_pending_callgraph_store_paths(),
8640            vec![kept],
8641            "drive-relative and root-relative spellings must be rejected"
8642        );
8643    }
8644
8645    #[test]
8646    fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
8647        let project = TempDir::new().expect("project tempdir");
8648        let ctx = AppContext::new(
8649            Box::new(TreeSitterProvider::new()),
8650            Config {
8651                project_root: Some(project.path().to_path_buf()),
8652                ..Config::default()
8653            },
8654        );
8655        // Relative paths are project-root-relative by the callgraph store's
8656        // contract, and pending paths legitimately reference deleted files.
8657        let relative = PathBuf::from("src/relative.rs");
8658        let deleted = project.path().join("never-created.rs");
8659        ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
8660
8661        let mut taken = ctx.take_pending_callgraph_store_paths();
8662        taken.sort();
8663        let mut expected = vec![relative, deleted];
8664        expected.sort();
8665        assert_eq!(
8666            taken, expected,
8667            "root-relative and deleted in-root paths must survive the filter"
8668        );
8669    }
8670
8671    #[test]
8672    fn writer_denied_callgraph_build_is_terminal_not_building() {
8673        let _env_guard = callgraph_build_wait_ms(30_000);
8674        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8675
8676        let denied_ctx = cold_build_context();
8677        let denied_reason = match denied_ctx.callgraph_store_for_ops() {
8678            CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) => reason,
8679            CallgraphStoreAccess::Building => {
8680                panic!("writer-denied build must not remain in the retryable Building state")
8681            }
8682            _ => panic!("unregistered root must terminate with an unavailable reason"),
8683        };
8684        assert!(
8685            denied_reason.contains("could not acquire writer capability"),
8686            "terminal status must explain the writer-capability denial: {denied_reason}"
8687        );
8688        assert!(matches!(
8689            denied_ctx.callgraph_store_for_ops(),
8690            CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
8691                if reason.contains("could not acquire writer capability")
8692        ));
8693        assert_eq!(
8694            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8695            1,
8696            "polling a denied root must not spawn another doomed build"
8697        );
8698
8699        // Control case: granting the artifact-access capability installed by
8700        // `configure_artifact_access` should change this cold build from denied to ready.
8701        let writable_ctx = cold_build_context();
8702        let writable_root = writable_ctx
8703            .config()
8704            .project_root
8705            .clone()
8706            .expect("writable fixture root");
8707        let writable_key = crate::search_index::artifact_cache_key(&writable_root);
8708        crate::root_cache::configure_artifact_access(&writable_root, &writable_key, false);
8709        assert!(
8710            matches!(
8711                writable_ctx.callgraph_store_for_ops(),
8712                CallgraphStoreAccess::Ready(_)
8713            ),
8714            "removing the forced denial must change the terminal status"
8715        );
8716    }
8717
8718    #[test]
8719    fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
8720        let _env_guard = force_async_callgraph_builds();
8721        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8722
8723        let project = TempDir::new().expect("project tempdir");
8724        let storage = TempDir::new().expect("storage tempdir");
8725        let source_dir = project.path().join("src");
8726        std::fs::create_dir_all(&source_dir).expect("source dir");
8727        std::fs::write(
8728            source_dir.join("lib.rs"),
8729            "pub fn caller() { callee(); }\npub fn callee() {}\n",
8730        )
8731        .expect("source file");
8732
8733        let ctx = Arc::new(AppContext::new(
8734            Box::new(TreeSitterProvider::new()),
8735            Config {
8736                project_root: Some(project.path().to_path_buf()),
8737                storage_dir: Some(storage.path().to_path_buf()),
8738                callgraph_chunk_size: 1,
8739                ..Config::default()
8740            },
8741        ));
8742
8743        let barrier = Arc::new(Barrier::new(3));
8744        let handles = (0..2)
8745            .map(|_| {
8746                let ctx = Arc::clone(&ctx);
8747                let barrier = Arc::clone(&barrier);
8748                std::thread::spawn(move || {
8749                    barrier.wait();
8750                    matches!(
8751                        ctx.callgraph_store_for_ops(),
8752                        CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8753                    )
8754                })
8755            })
8756            .collect::<Vec<_>>();
8757
8758        barrier.wait();
8759        for handle in handles {
8760            assert!(
8761                handle.join().expect("callgraph caller thread"),
8762                "cold callgraph ops should report Building or observe the installed store"
8763            );
8764        }
8765
8766        assert_eq!(
8767            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8768            1,
8769            "concurrent cold callers must share one background build"
8770        );
8771
8772        let rx = ctx
8773            .callgraph_store_rx
8774            .lock()
8775            .as_ref()
8776            .cloned()
8777            .expect("in-flight receiver installed before spawn");
8778        rx.recv_timeout(Duration::from_secs(30))
8779            .expect("background cold build should complete");
8780        *ctx.callgraph_store_rx.lock() = None;
8781    }
8782
8783    #[test]
8784    fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
8785        let root = TempDir::new().expect("project tempdir");
8786        let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
8787        let ctx = AppContext::new(
8788            Box::new(TreeSitterProvider::new()),
8789            Config {
8790                project_root: Some(canonical_root.clone()),
8791                ..Config::default()
8792            },
8793        );
8794        *ctx.search_index
8795            .write()
8796            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8797            Some(SearchIndex::build(&canonical_root));
8798        *ctx.semantic_index
8799            .write()
8800            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8801            Some(SemanticIndex::new(canonical_root.clone(), 3));
8802        *ctx.semantic_index_status
8803            .write()
8804            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8805
8806        let artifact = canonical_root.join("verify-artifact.bin");
8807        std::fs::write(&artifact, b"same-size").expect("write verification artifact");
8808        let generation =
8809            crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
8810        crate::cache_freshness::record_verify_completed(
8811            &canonical_root,
8812            crate::cache_freshness::VerifyArtifact::Search,
8813            Some(generation),
8814        );
8815        assert_eq!(
8816            crate::cache_freshness::warm_verify_plan(
8817                &canonical_root,
8818                crate::cache_freshness::VerifyArtifact::Search,
8819                Some(generation),
8820            ),
8821            crate::cache_freshness::WarmVerifyPlan::Skip
8822        );
8823
8824        ctx.invalidate_artifacts_after_watcher_gap();
8825
8826        assert!(ctx
8827            .search_index
8828            .read()
8829            .unwrap_or_else(std::sync::PoisonError::into_inner)
8830            .is_none());
8831        assert!(ctx
8832            .semantic_index
8833            .read()
8834            .unwrap_or_else(std::sync::PoisonError::into_inner)
8835            .is_none());
8836        assert!(ctx.pending_callgraph_store_force_token().is_some());
8837        assert_eq!(
8838            crate::cache_freshness::warm_verify_plan(
8839                &canonical_root,
8840                crate::cache_freshness::VerifyArtifact::Search,
8841                Some(generation),
8842            ),
8843            crate::cache_freshness::WarmVerifyPlan::Strict
8844        );
8845    }
8846
8847    #[test]
8848    fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
8849        let root = TempDir::new().expect("project tempdir");
8850        let ctx = AppContext::new(
8851            Box::new(TreeSitterProvider::new()),
8852            Config {
8853                project_root: Some(root.path().to_path_buf()),
8854                semantic_search: true,
8855                ..Config::default()
8856            },
8857        );
8858        *ctx.semantic_index
8859            .write()
8860            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8861            Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8862        let refreshing_path = root.path().join("src/lib.rs");
8863        {
8864            let mut status = ctx
8865                .semantic_index_status
8866                .write()
8867                .unwrap_or_else(std::sync::PoisonError::into_inner);
8868            *status = SemanticIndexStatus::ready();
8869            status.start_refreshing_file(refreshing_path.clone());
8870        }
8871        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8872        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8873        ctx.install_semantic_refresh_worker_for_build_epoch(
8874            request_tx,
8875            event_rx,
8876            Arc::new(Mutex::new(None)),
8877            ctx.semantic_index_rx_epoch(),
8878        );
8879
8880        ctx.cancel_unbound_artifact_work();
8881
8882        // The cancelled worker will never re-embed the in-flight file; the
8883        // retained pending set is the only record for the replacement worker.
8884        assert_eq!(
8885            ctx.pending_semantic_index_paths
8886                .lock()
8887                .iter()
8888                .cloned()
8889                .collect::<Vec<_>>(),
8890            vec![refreshing_path],
8891            "cancelled in-flight refresh files must transfer to the pending set"
8892        );
8893        assert!(matches!(
8894            &*ctx
8895                .semantic_index_status
8896                .read()
8897                .unwrap_or_else(std::sync::PoisonError::into_inner),
8898            SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
8899        ));
8900    }
8901
8902    #[test]
8903    fn unbind_before_corpus_started_preserves_corpus_intent() {
8904        // The probe stamps `refreshing_corpus` before sending, but the worker
8905        // emits CorpusStarted only after walking the project. An unbind in
8906        // that window must re-derive the corpus intent from the stamped
8907        // status, not lose it.
8908        let root = TempDir::new().expect("project tempdir");
8909        let ctx = AppContext::new(
8910            Box::new(TreeSitterProvider::new()),
8911            Config {
8912                project_root: Some(root.path().to_path_buf()),
8913                semantic_search: true,
8914                ..Config::default()
8915            },
8916        );
8917        *ctx.semantic_index
8918            .write()
8919            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8920            Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8921        *ctx.semantic_index_status
8922            .write()
8923            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
8924            stage: "refreshing_corpus".to_string(),
8925            files: None,
8926            entries_done: None,
8927            entries_total: None,
8928        };
8929        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8930        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8931        ctx.install_semantic_refresh_worker_for_build_epoch(
8932            request_tx,
8933            event_rx,
8934            Arc::new(Mutex::new(None)),
8935            ctx.semantic_index_rx_epoch(),
8936        );
8937
8938        ctx.cancel_unbound_artifact_work();
8939
8940        assert!(
8941            *ctx.pending_semantic_corpus_refresh.lock(),
8942            "corpus intent stamped before CorpusStarted must survive the cancellation"
8943        );
8944    }
8945
8946    #[test]
8947    fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
8948        let root = TempDir::new().expect("project tempdir");
8949        let ctx = AppContext::new(
8950            Box::new(TreeSitterProvider::new()),
8951            Config {
8952                project_root: Some(root.path().to_path_buf()),
8953                ..Config::default()
8954            },
8955        );
8956        // A corpus refresh in flight: resident index marked non-ready plus an
8957        // installed receiver. Cancelling only the receiver would strand the
8958        // non-ready resident (equivalent rebind reloads only a MISSING index).
8959        let mut refreshing = SearchIndex::new();
8960        refreshing.ready = false;
8961        *ctx.search_index
8962            .write()
8963            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
8964        let (_tx, rx) = crossbeam_channel::unbounded();
8965        ctx.install_search_index_rx(rx, ctx.configure_generation());
8966
8967        ctx.cancel_unbound_artifact_work();
8968
8969        assert!(
8970            ctx.search_index
8971                .read()
8972                .unwrap_or_else(std::sync::PoisonError::into_inner)
8973                .is_none(),
8974            "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
8975        );
8976        assert!(ctx
8977            .search_index_rx
8978            .read()
8979            .unwrap_or_else(std::sync::PoisonError::into_inner)
8980            .is_none());
8981    }
8982
8983    #[test]
8984    fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
8985        let root = TempDir::new().expect("project tempdir");
8986        let ctx = AppContext::new(
8987            Box::new(TreeSitterProvider::new()),
8988            Config {
8989                project_root: Some(root.path().to_path_buf()),
8990                ..Config::default()
8991            },
8992        );
8993        *ctx.semantic_index
8994            .write()
8995            .unwrap_or_else(std::sync::PoisonError::into_inner) =
8996            Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8997        let refreshing_path = root.path().join("src/lib.rs");
8998        {
8999            let mut status = ctx
9000                .semantic_index_status
9001                .write()
9002                .unwrap_or_else(std::sync::PoisonError::into_inner);
9003            *status = SemanticIndexStatus::ready();
9004            status.start_refreshing_file(refreshing_path.clone());
9005        }
9006
9007        assert!(ctx.artifact_eviction_blocked());
9008        assert!(!ctx.evict_idle_artifacts());
9009        assert!(ctx
9010            .semantic_index
9011            .read()
9012            .unwrap_or_else(std::sync::PoisonError::into_inner)
9013            .is_some());
9014
9015        ctx.semantic_index_status
9016            .write()
9017            .unwrap_or_else(std::sync::PoisonError::into_inner)
9018            .complete_refreshing_file(&refreshing_path);
9019        assert!(ctx.evict_idle_artifacts());
9020        assert!(ctx
9021            .semantic_index
9022            .read()
9023            .unwrap_or_else(std::sync::PoisonError::into_inner)
9024            .is_none());
9025    }
9026}
9027
9028#[cfg(test)]
9029mod status_emitter_tests {
9030    use super::*;
9031    use crate::parser::TreeSitterProvider;
9032
9033    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
9034        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9035        let (tx, rx) = mpsc::channel();
9036        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9037            let _ = tx.send(frame);
9038        }))));
9039        (ctx, rx)
9040    }
9041
9042    #[test]
9043    fn status_emitter_signal_triggers_push() {
9044        let (ctx, rx) = ctx_with_frame_rx();
9045        ctx.status_emitter().signal(ctx.build_status_snapshot());
9046        let frame = rx
9047            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9048            .expect("status_changed push");
9049        assert!(matches!(frame, PushFrame::StatusChanged(_)));
9050    }
9051
9052    #[test]
9053    fn status_emitter_debounces_burst() {
9054        let (ctx, rx) = ctx_with_frame_rx();
9055        for _ in 0..10 {
9056            ctx.status_emitter().signal(ctx.build_status_snapshot());
9057        }
9058        let frame = rx
9059            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9060            .expect("status_changed push");
9061        assert!(matches!(frame, PushFrame::StatusChanged(_)));
9062        assert!(rx.try_recv().is_err());
9063    }
9064
9065    #[test]
9066    fn status_emitter_separate_windows_separate_pushes() {
9067        let (ctx, rx) = ctx_with_frame_rx();
9068        ctx.status_emitter().signal(ctx.build_status_snapshot());
9069        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9070            .expect("first push");
9071        ctx.status_emitter().signal(ctx.build_status_snapshot());
9072        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9073            .expect("second push");
9074    }
9075
9076    #[test]
9077    fn status_emitter_no_signal_no_push() {
9078        let (_ctx, rx) = ctx_with_frame_rx();
9079        assert!(rx
9080            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
9081            .is_err());
9082    }
9083
9084    #[test]
9085    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
9086        let (ctx, rx) = ctx_with_frame_rx();
9087        drop(ctx);
9088        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
9089    }
9090
9091    #[test]
9092    fn progress_sender_slot_is_per_context_for_shared_app() {
9093        let app = App::default_shared();
9094        let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
9095        let ctx_b = AppContext::from_app(app, Config::default());
9096        let (tx_a, rx_a) = mpsc::channel();
9097        let (tx_b, rx_b) = mpsc::channel();
9098
9099        ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9100            let _ = tx_a.send(frame);
9101        }))));
9102        ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9103            let _ = tx_b.send(frame);
9104        }))));
9105
9106        ctx_a.emit_progress(ProgressFrame {
9107            frame_type: "progress",
9108            request_id: "ctx-a".to_string(),
9109            kind: crate::protocol::ProgressKind::Stdout,
9110            chunk: "a".to_string(),
9111        });
9112        ctx_b.emit_progress(ProgressFrame {
9113            frame_type: "progress",
9114            request_id: "ctx-b".to_string(),
9115            kind: crate::protocol::ProgressKind::Stdout,
9116            chunk: "b".to_string(),
9117        });
9118
9119        match rx_a
9120            .recv_timeout(Duration::from_millis(50))
9121            .expect("ctx A progress frame")
9122        {
9123            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
9124            other => panic!("unexpected frame for ctx A: {other:?}"),
9125        }
9126        assert!(rx_a.try_recv().is_err());
9127
9128        match rx_b
9129            .recv_timeout(Duration::from_millis(50))
9130            .expect("ctx B progress frame")
9131        {
9132            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
9133            other => panic!("unexpected frame for ctx B: {other:?}"),
9134        }
9135        assert!(rx_b.try_recv().is_err());
9136    }
9137}
9138
9139#[cfg(test)]
9140mod health_warming_honesty_tests {
9141    use super::*;
9142    use crate::parser::TreeSitterProvider;
9143
9144    fn ctx_with_config(config: Config) -> AppContext {
9145        AppContext::new(Box::new(TreeSitterProvider::new()), config)
9146    }
9147
9148    fn health_search_status(ctx: &AppContext) -> &'static str {
9149        let root = std::path::Path::new("/tmp/health-warming-honesty-test");
9150        ctx.try_health_snapshot(root)
9151            .search_index
9152            .expect("search_index component present")
9153            .status
9154    }
9155
9156    fn health_tier2_status(ctx: &AppContext) -> &'static str {
9157        let root = std::path::Path::new("/tmp/health-warming-honesty-test");
9158        ctx.try_health_snapshot(root)
9159            .tier2
9160            .expect("tier2 component present")
9161            .status
9162    }
9163
9164    #[test]
9165    fn write_denied_search_index_reports_ready_not_building() {
9166        // A write-denied cold build installs an empty index that is flagged
9167        // build-denied and stays not-ready (so grep keeps the fallback walk).
9168        // Health must treat it as settled, not "building" forever.
9169        let config = Config {
9170            search_index: true,
9171            ..Config::default()
9172        };
9173        let ctx = ctx_with_config(config);
9174        let mut index = SearchIndex::new();
9175        index.build_denied = true;
9176        *ctx.search_index()
9177            .write()
9178            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
9179
9180        assert_eq!(
9181            health_search_status(&ctx),
9182            "ready",
9183            "a build-denied index is a terminal settled state and must not report building forever"
9184        );
9185    }
9186
9187    #[test]
9188    fn in_progress_search_index_still_reports_building() {
9189        // Control: a genuinely not-ready, not-denied index (a real build in
9190        // flight) must still report building — the build-denied carve-out must
9191        // not leak into ordinary in-progress builds.
9192        let config = Config {
9193            search_index: true,
9194            ..Config::default()
9195        };
9196        let ctx = ctx_with_config(config);
9197        let index = SearchIndex::new(); // ready=false, build_denied=false
9198        *ctx.search_index()
9199            .write()
9200            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
9201
9202        assert_eq!(health_search_status(&ctx), "building");
9203    }
9204
9205    #[test]
9206    fn tier2_blocked_on_callgraph_reports_ready_not_building() {
9207        // dead_code is suppressed (None) while the callgraph store is not ready,
9208        // but unused_exports/duplicates are complete and fresh. Health must not
9209        // report tier2 as "building" forever for a cycle that is otherwise
9210        // complete — the callgraph component tells the callgraph story.
9211        let ctx = ctx_with_config(Config::default()); // inspect.enabled defaults true
9212        ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
9213        ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
9214
9215        assert_eq!(
9216            health_tier2_status(&ctx),
9217            "ready",
9218            "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
9219        );
9220    }
9221
9222    #[test]
9223    fn health_tier2_and_inspect_builder_state_read_the_same_registry() {
9224        // Complete published counts must not make health report ready while the
9225        // inspect builder registry still has a live registration for this root.
9226        let ctx = ctx_with_config(Config::default());
9227        ctx.update_status_bar_tier2(Some(1), Some(2), Some(3), None, false);
9228        ctx.inspect_manager()
9229            .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, true);
9230
9231        assert_eq!(health_tier2_status(&ctx), "building");
9232        assert_eq!(
9233            ctx.inspect_manager()
9234                .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
9235            crate::inspect::InspectBuilderState::Building
9236        );
9237
9238        ctx.inspect_manager()
9239            .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, false);
9240
9241        assert_eq!(health_tier2_status(&ctx), "ready");
9242        assert_eq!(
9243            ctx.inspect_manager()
9244                .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
9245            crate::inspect::InspectBuilderState::Absent
9246        );
9247
9248        ctx.inspect_manager().record_tier2_attempt_outcome_for_test(
9249            crate::inspect::InspectCategory::DeadCode,
9250            crate::inspect::JobOutcome::Fresh {
9251                payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
9252            },
9253        );
9254        assert_eq!(
9255            health_tier2_status(&ctx),
9256            "ready",
9257            "a finished callgraph_unavailable attempt must not keep health.tier2=building"
9258        );
9259        assert_eq!(
9260            ctx.inspect_manager()
9261                .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
9262            crate::inspect::InspectBuilderState::Absent
9263        );
9264        assert!(
9265            ctx.inspect_manager()
9266                .tier2_builder_state_detail(crate::inspect::InspectCategory::DeadCode)
9267                .starts_with("last attempt failed: callgraph_unavailable (attempt 1, first at "),
9268            "inspect refusals must carry the failed-attempt history the health surface no longer treats as busy"
9269        );
9270    }
9271
9272    #[test]
9273    fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
9274        // Control: with no callgraph block recorded, a missing dead_code count is
9275        // a genuine in-progress scan and must still report building.
9276        let ctx = ctx_with_config(Config::default());
9277        ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
9278        ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
9279
9280        assert_eq!(health_tier2_status(&ctx), "building");
9281    }
9282}
9283
9284#[cfg(test)]
9285mod status_bar_tests {
9286    use super::*;
9287    use crate::parser::TreeSitterProvider;
9288
9289    fn ctx() -> AppContext {
9290        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
9291    }
9292
9293    #[test]
9294    fn truthful_values_omit_unproven_categories_while_legacy_projection_stays_hidden() {
9295        let ctx = ctx();
9296        let values = ctx.status_bar_count_values();
9297        assert_eq!(values.errors, None);
9298        assert_eq!(values.warnings, None);
9299        assert_eq!(values.dead_code, None);
9300        assert_eq!(values.unused_exports, None);
9301        assert_eq!(values.duplicates, None);
9302        assert_eq!(values.todos, None);
9303        assert!(ctx.status_bar_counts().is_none());
9304
9305        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
9306        let values = ctx.status_bar_count_values();
9307        assert_eq!(values.dead_code, Some(5));
9308        assert_eq!(values.unused_exports, Some(3));
9309        assert_eq!(values.duplicates, Some(7));
9310        assert_eq!(values.todos, Some(2));
9311        assert_eq!(values.errors, None, "no analyzer report is not a clean E0");
9312        assert_eq!(
9313            values.warnings, None,
9314            "no analyzer report is not a clean W0"
9315        );
9316        assert!(!values.tier2_stale);
9317
9318        let legacy = ctx
9319            .status_bar_counts()
9320            .expect("legacy projection is populated");
9321        assert_eq!((legacy.errors, legacy.warnings), (0, 0));
9322    }
9323
9324    #[test]
9325    fn changing_root_clears_project_scoped_status_counts() {
9326        let temp = tempfile::tempdir().expect("tempdir");
9327        let first_root = temp.path().join("first");
9328        let second_root = temp.path().join("second");
9329        std::fs::create_dir_all(&first_root).expect("create first root");
9330        std::fs::create_dir_all(&second_root).expect("create second root");
9331        let ctx = ctx();
9332        ctx.set_canonical_cache_root(first_root);
9333        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
9334        assert!(ctx.status_bar_counts().is_some());
9335
9336        ctx.set_canonical_cache_root(second_root);
9337
9338        let values = ctx.status_bar_count_values();
9339        assert_eq!(values.dead_code, None);
9340        assert_eq!(values.unused_exports, None);
9341        assert_eq!(values.duplicates, None);
9342        assert!(
9343            ctx.status_bar_counts().is_none(),
9344            "counts from the previous root must not appear in a newly bound root"
9345        );
9346    }
9347
9348    #[test]
9349    fn partial_tier2_keeps_proven_categories_and_cache_hit_preserves_omissions() {
9350        let ctx = ctx();
9351        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
9352
9353        let first = ctx.status_bar_count_values();
9354        assert_eq!(first.dead_code, Some(5));
9355        assert_eq!(first.unused_exports, None);
9356        assert_eq!(first.duplicates, None);
9357        assert_eq!(first.todos, None);
9358        assert!(first.tier2_stale);
9359        assert!(ctx.status_bar_counts().is_none());
9360
9361        let cached = ctx.status_bar_count_values();
9362        assert_eq!(cached, first, "a cache hit must preserve every omission");
9363        let cache = ctx
9364            .status_bar_cached
9365            .read()
9366            .unwrap_or_else(std::sync::PoisonError::into_inner);
9367        assert!(cache.valid);
9368        assert_eq!(cache.counts.as_ref(), Some(&first));
9369        drop(cache);
9370
9371        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
9372        let partial = ctx.status_bar_count_values();
9373        assert_eq!(partial.dead_code, Some(5));
9374        assert_eq!(partial.unused_exports, Some(3));
9375        assert_eq!(partial.duplicates, None);
9376
9377        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
9378        let complete = ctx.status_bar_count_values();
9379        assert_eq!(complete.dead_code, Some(5));
9380        assert_eq!(complete.unused_exports, Some(3));
9381        assert_eq!(complete.duplicates, Some(7));
9382    }
9383
9384    #[test]
9385    fn update_with_none_todos_preserves_last_known_todos() {
9386        let ctx = ctx();
9387        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
9388        // A background-scan refresh passes todos=None → todo count preserved.
9389        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
9390        let counts = ctx.status_bar_count_values();
9391        assert_eq!(counts.todos, Some(9));
9392        assert_eq!(counts.dead_code, Some(2));
9393    }
9394
9395    #[test]
9396    fn update_with_none_count_preserves_last_known_count() {
9397        let ctx = ctx();
9398        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
9399        // A refresh that only recomputed dead_code preserves the other two
9400        // real counts rather than overwriting them with a fabricated 0.
9401        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
9402        let counts = ctx.status_bar_count_values();
9403        assert_eq!(counts.dead_code, Some(11));
9404        assert_eq!(counts.unused_exports, Some(20));
9405        assert_eq!(counts.duplicates, Some(30));
9406    }
9407
9408    #[test]
9409    fn mark_stale_sets_flag_after_any_proven_category() {
9410        let ctx = ctx();
9411        ctx.mark_status_bar_tier2_stale();
9412        assert!(!ctx.status_bar_count_values().tier2_stale);
9413
9414        ctx.update_status_bar_tier2(Some(4), None, None, None, false);
9415        ctx.mark_status_bar_tier2_stale();
9416        assert!(ctx.status_bar_count_values().tier2_stale);
9417
9418        // A completed scan clears stale without changing omitted categories.
9419        ctx.update_status_bar_tier2(Some(4), None, None, None, false);
9420        assert!(!ctx.status_bar_count_values().tier2_stale);
9421    }
9422
9423    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
9424    // count (read live from the warm LSP set); clearing that file's diagnostics
9425    // (the deleted-file path) drops it back. This is the AppContext glue between
9426    // the watcher-drain clear and the agent-visible bar.
9427    #[test]
9428    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
9429        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9430        use crate::lsp::registry::ServerKind;
9431        use crate::lsp::roots::ServerKey;
9432
9433        let ctx = ctx();
9434        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
9435
9436        let file = std::path::PathBuf::from("/proj/gone.ts");
9437        {
9438            let mut lsp = ctx.lsp();
9439            lsp.diagnostics_store_mut_for_test().publish(
9440                ServerKey {
9441                    kind: ServerKind::TypeScript,
9442                    root: std::path::PathBuf::from("/proj"),
9443                },
9444                file.clone(),
9445                vec![StoredDiagnostic {
9446                    file: file.clone(),
9447                    line: 1,
9448                    column: 1,
9449                    end_line: 1,
9450                    end_column: 2,
9451                    severity: DiagnosticSeverity::Error,
9452                    message: "boom".into(),
9453                    code: None,
9454                    source: None,
9455                }],
9456            );
9457        }
9458
9459        // Bar reflects the live warm-set error.
9460        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
9461
9462        // Clearing the (now-deleted) file's diagnostics drops the count.
9463        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
9464        assert!(removed);
9465        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9466    }
9467
9468    #[test]
9469    fn status_bar_preserves_authoritative_counts_until_provisional_report_is_promoted() {
9470        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9471        use crate::lsp::registry::ServerKind;
9472        use crate::lsp::roots::ServerKey;
9473
9474        let ctx = ctx();
9475        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9476        let root = std::path::PathBuf::from("/proj");
9477        let file = root.join("src/main.rs");
9478        let key = ServerKey {
9479            kind: ServerKind::Rust,
9480            root,
9481        };
9482        let diagnostic = |severity, message: &str| StoredDiagnostic {
9483            file: file.clone(),
9484            line: 1,
9485            column: 1,
9486            end_line: 1,
9487            end_column: 2,
9488            severity,
9489            message: message.into(),
9490            code: None,
9491            source: None,
9492        };
9493
9494        {
9495            let mut lsp = ctx.lsp();
9496            lsp.diagnostics_store_mut_for_test().publish(
9497                key.clone(),
9498                file.clone(),
9499                vec![diagnostic(DiagnosticSeverity::Error, "settled error")],
9500            );
9501        }
9502        let counts = ctx.status_bar_counts().expect("populated");
9503        assert_eq!((counts.errors, counts.warnings), (1, 0));
9504
9505        {
9506            let mut lsp = ctx.lsp();
9507            lsp.diagnostics_store_mut_for_test()
9508                .publish_full_with_provisional(
9509                    key.clone(),
9510                    file.clone(),
9511                    vec![diagnostic(
9512                        DiagnosticSeverity::Warning,
9513                        "latest warming warning",
9514                    )],
9515                    None,
9516                    None,
9517                    true,
9518                );
9519        }
9520        let counts = ctx.status_bar_counts().expect("populated");
9521        assert_eq!(
9522            (counts.errors, counts.warnings),
9523            (1, 0),
9524            "pre-quiescence diagnostics must not replace authoritative counts"
9525        );
9526
9527        {
9528            let mut lsp = ctx.lsp();
9529            assert!(lsp
9530                .diagnostics_store_mut_for_test()
9531                .promote_provisional_for_server(&key));
9532        }
9533        let counts = ctx.status_bar_counts().expect("populated");
9534        assert_eq!(
9535            (counts.errors, counts.warnings),
9536            (0, 1),
9537            "the latest report becomes authoritative at quiescence"
9538        );
9539    }
9540
9541    #[test]
9542    fn status_bar_filtered_counts_ignore_environmental_flap() {
9543        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9544        use crate::lsp::registry::ServerKind;
9545        use crate::lsp::roots::ServerKey;
9546
9547        let ctx = ctx();
9548        let root = if cfg!(windows) {
9549            std::path::PathBuf::from(r"C:\proj")
9550        } else {
9551            std::path::PathBuf::from("/proj")
9552        };
9553        ctx.set_canonical_cache_root(root.clone());
9554        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9555
9556        let file = root.join("aft.jsonc");
9557        let key = ServerKey {
9558            kind: ServerKind::TypeScript,
9559            root: root.clone(),
9560        };
9561        let env = StoredDiagnostic {
9562            file: file.clone(),
9563            line: 1,
9564            column: 1,
9565            end_line: 1,
9566            end_column: 2,
9567            severity: DiagnosticSeverity::Error,
9568            message: "Failed to load schema from https://example.com/schema.json".into(),
9569            code: None,
9570            source: Some("json".into()),
9571        };
9572
9573        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9574
9575        {
9576            let mut lsp = ctx.lsp();
9577            lsp.diagnostics_store_mut_for_test()
9578                .publish(key.clone(), file.clone(), vec![env]);
9579        }
9580        assert_eq!(
9581            ctx.status_bar_counts().expect("populated").errors,
9582            0,
9583            "environmental publish must not change status-bar E"
9584        );
9585
9586        {
9587            let mut lsp = ctx.lsp();
9588            lsp.diagnostics_store_mut_for_test()
9589                .publish(key, file, vec![]);
9590        }
9591        assert_eq!(
9592            ctx.status_bar_counts().expect("populated").errors,
9593            0,
9594            "environmental clear must not change status-bar E"
9595        );
9596    }
9597}
9598
9599#[cfg(test)]
9600mod harness_path_tests {
9601    use super::*;
9602    use crate::harness::Harness;
9603    use crate::parser::TreeSitterProvider;
9604
9605    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
9606        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9607        ctx.update_config(|config| {
9608            config.storage_dir = Some(storage_dir);
9609        });
9610        ctx.set_harness(harness);
9611        ctx
9612    }
9613
9614    #[test]
9615    fn harness_dir_resolves_correctly() {
9616        let storage = PathBuf::from("/tmp/cortexkit/aft");
9617        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9618
9619        assert_eq!(ctx.harness_dir(), storage.join("pi"));
9620    }
9621
9622    #[test]
9623    fn bash_tasks_dir_uses_hash_session() {
9624        let storage = PathBuf::from("/tmp/cortexkit/aft");
9625        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9626
9627        assert_eq!(
9628            ctx.bash_tasks_dir("ses_abc"),
9629            storage
9630                .join("opencode")
9631                .join("bash-tasks")
9632                .join(hash_session("ses_abc"))
9633        );
9634    }
9635
9636    #[test]
9637    fn backups_dir_includes_path_hash() {
9638        let storage = PathBuf::from("/tmp/cortexkit/aft");
9639        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9640
9641        assert_eq!(
9642            ctx.backups_dir("ses_abc", "pathhash"),
9643            storage
9644                .join("pi")
9645                .join("backups")
9646                .join(hash_session("ses_abc"))
9647                .join("pathhash")
9648        );
9649    }
9650
9651    #[test]
9652    fn filters_dir_under_harness() {
9653        let storage = PathBuf::from("/tmp/cortexkit/aft");
9654        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9655
9656        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
9657    }
9658
9659    #[test]
9660    fn trust_file_is_host_global() {
9661        let storage = PathBuf::from("/tmp/cortexkit/aft");
9662        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9663
9664        assert_eq!(
9665            ctx.trust_file(),
9666            storage.join("trusted-filter-projects.json")
9667        );
9668    }
9669
9670    #[test]
9671    fn same_session_different_harness_resolve_different_paths() {
9672        let storage = PathBuf::from("/tmp/cortexkit/aft");
9673        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9674        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
9675
9676        assert_ne!(
9677            opencode.bash_tasks_dir("ses_same"),
9678            pi.bash_tasks_dir("ses_same")
9679        );
9680    }
9681
9682    #[test]
9683    fn callgraph_and_inspect_dirs_are_root_keyed() {
9684        let temp = tempfile::tempdir().expect("tempdir");
9685        let storage = temp.path().join("storage");
9686        let root = temp.path().join("checkout");
9687        std::fs::create_dir_all(&root).expect("create root");
9688        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9689        ctx.set_canonical_cache_root(root.clone());
9690
9691        assert_eq!(
9692            ctx.callgraph_store_dir(),
9693            storage
9694                .join("callgraph")
9695                .join(crate::search_index::artifact_cache_key(&root))
9696        );
9697        assert_eq!(
9698            ctx.inspect_dir(),
9699            storage
9700                .join("inspect")
9701                .join(crate::path_identity::project_scope_key(&root))
9702        );
9703        assert!(!ctx
9704            .callgraph_store_dir()
9705            .starts_with(storage.join("opencode")));
9706        assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
9707    }
9708
9709    #[test]
9710    fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
9711        let storage = PathBuf::from("/tmp/cortexkit/aft");
9712        let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
9713        ctx.set_cache_writer_capabilities(false, true);
9714
9715        assert!(ctx.shared_artifacts_read_only());
9716        assert!(!ctx.callgraph_writer());
9717        assert!(ctx.inspect_writer());
9718    }
9719}
9720
9721#[cfg(test)]
9722mod shared_db_tests {
9723    use super::*;
9724    use tempfile::tempdir;
9725
9726    #[test]
9727    fn app_contexts_share_one_database_connection() {
9728        let storage = tempdir().expect("storage tempdir");
9729        let root_one = tempdir().expect("first root tempdir");
9730        let root_two = tempdir().expect("second root tempdir");
9731        let app = App::default_shared();
9732        let ctx_one = AppContext::from_app(
9733            Arc::clone(&app),
9734            Config {
9735                project_root: Some(root_one.path().to_path_buf()),
9736                ..Config::default()
9737            },
9738        );
9739        let ctx_two = AppContext::from_app(
9740            Arc::clone(&app),
9741            Config {
9742                project_root: Some(root_two.path().to_path_buf()),
9743                ..Config::default()
9744            },
9745        );
9746        let path = storage.path().join("aft.db");
9747
9748        let first = app.open_db(&path).expect("open shared database");
9749        let second = app.open_db(&path).expect("reuse shared database");
9750
9751        assert!(Arc::ptr_eq(&first, &second));
9752        assert!(Arc::ptr_eq(
9753            &ctx_one.db().expect("first context database"),
9754            &ctx_two.db().expect("second context database")
9755        ));
9756    }
9757}
9758
9759#[cfg(test)]
9760mod gitignore_tests {
9761    use super::*;
9762    use std::fs;
9763    use std::path::Path;
9764    use tempfile::TempDir;
9765
9766    fn make_ctx_with_root(root: &Path) -> AppContext {
9767        let provider = Box::new(crate::parser::TreeSitterProvider::new());
9768        let config = Config {
9769            project_root: Some(root.to_path_buf()),
9770            ..Config::default()
9771        };
9772        AppContext::new(provider, config)
9773    }
9774
9775    /// Helper: returns true when the matcher would skip `path` (as if it
9776    /// arrived via a watcher event for this project root). Canonicalizes
9777    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
9778    /// don't trip the `ignore` crate's "path is expected to be under the
9779    /// root" panic — production code does the same guard via
9780    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
9781    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
9782        let Some(matcher) = ctx.gitignore() else {
9783            return false;
9784        };
9785        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
9786        if !canonical.starts_with(matcher.path()) {
9787            return false;
9788        }
9789        let is_dir = canonical.is_dir();
9790        matcher
9791            .matched_path_or_any_parents(&canonical, is_dir)
9792            .is_ignore()
9793    }
9794
9795    /// Run `f` with global git-ignore discovery neutralized.
9796    ///
9797    /// `rebuild_gitignore` loads git's global excludes via the `ignore`
9798    /// crate, which discovers them from TWO places: `core.excludesfile` in
9799    /// `$HOME/.gitconfig` (or `$XDG_CONFIG_HOME/git/config`), and the default
9800    /// `$XDG_CONFIG_HOME/git/ignore` / `$HOME/.config/git/ignore` locations.
9801    /// A developer machine commonly has one of these, so a "no project ignore
9802    /// → None" assertion is only deterministic when BOTH discovery roots point
9803    /// at an empty directory — neutralizing only `XDG_CONFIG_HOME` still finds
9804    /// a `~/.gitconfig` `core.excludesfile`. Serialized on the process-wide
9805    /// env lock shared with every other HOME-mutating test; env is restored
9806    /// before the closure result is used.
9807    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
9808        let _guard = crate::test_env::process_env_lock();
9809        let tmp = TempDir::new().unwrap();
9810        let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
9811        let prev_home = std::env::var_os("HOME");
9812        let prev_userprofile = std::env::var_os("USERPROFILE");
9813        // SAFETY: serialized by the process env lock; restored immediately
9814        // after `f`.
9815        unsafe {
9816            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
9817            std::env::set_var("HOME", tmp.path());
9818            std::env::set_var("USERPROFILE", tmp.path());
9819        }
9820        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
9821        unsafe {
9822            match prev_xdg {
9823                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
9824                None => std::env::remove_var("XDG_CONFIG_HOME"),
9825            }
9826            match prev_home {
9827                Some(v) => std::env::set_var("HOME", v),
9828                None => std::env::remove_var("HOME"),
9829            }
9830            match prev_userprofile {
9831                Some(v) => std::env::set_var("USERPROFILE", v),
9832                None => std::env::remove_var("USERPROFILE"),
9833            }
9834        }
9835        match result {
9836            Ok(r) => r,
9837            Err(p) => std::panic::resume_unwind(p),
9838        }
9839    }
9840
9841    #[test]
9842    fn rebuild_gitignore_returns_none_without_project_root() {
9843        let provider = Box::new(crate::parser::TreeSitterProvider::new());
9844        let ctx = AppContext::new(provider, Config::default());
9845        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9846        assert!(ctx.gitignore().is_none());
9847    }
9848
9849    #[test]
9850    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
9851        let tmp = TempDir::new().unwrap();
9852        let ctx = make_ctx_with_root(tmp.path());
9853        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9854        assert!(ctx.gitignore().is_none());
9855    }
9856
9857    #[test]
9858    fn matcher_filters_files_in_ignored_dist_dir() {
9859        let tmp = TempDir::new().unwrap();
9860        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
9861        fs::create_dir_all(tmp.path().join("dist")).unwrap();
9862        fs::create_dir_all(tmp.path().join("src")).unwrap();
9863        let dist_file = tmp.path().join("dist").join("bundle.js");
9864        let src_file = tmp.path().join("src").join("app.ts");
9865        fs::write(&dist_file, "x").unwrap();
9866        fs::write(&src_file, "y").unwrap();
9867
9868        let ctx = make_ctx_with_root(tmp.path());
9869        ctx.rebuild_gitignore();
9870
9871        assert!(ctx.gitignore().is_some());
9872        assert!(
9873            is_ignored(&ctx, &dist_file),
9874            "dist/bundle.js should be ignored"
9875        );
9876        assert!(
9877            !is_ignored(&ctx, &src_file),
9878            "src/app.ts should NOT be ignored"
9879        );
9880    }
9881
9882    #[test]
9883    fn matcher_handles_node_modules_and_target() {
9884        let tmp = TempDir::new().unwrap();
9885        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
9886        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
9887        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
9888        let nm_file = tmp.path().join("node_modules/foo/index.js");
9889        let target_file = tmp.path().join("target/debug/aft");
9890        fs::write(&nm_file, "x").unwrap();
9891        fs::write(&target_file, "x").unwrap();
9892
9893        let ctx = make_ctx_with_root(tmp.path());
9894        ctx.rebuild_gitignore();
9895
9896        assert!(is_ignored(&ctx, &nm_file));
9897        assert!(is_ignored(&ctx, &target_file));
9898    }
9899
9900    #[test]
9901    fn matcher_honors_negation_pattern() {
9902        // .gitignore: ignore all *.log files EXCEPT important.log
9903        let tmp = TempDir::new().unwrap();
9904        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
9905        let random_log = tmp.path().join("random.log");
9906        let important_log = tmp.path().join("important.log");
9907        fs::write(&random_log, "x").unwrap();
9908        fs::write(&important_log, "y").unwrap();
9909
9910        let ctx = make_ctx_with_root(tmp.path());
9911        ctx.rebuild_gitignore();
9912
9913        assert!(is_ignored(&ctx, &random_log));
9914        assert!(
9915            !is_ignored(&ctx, &important_log),
9916            "negation pattern should un-ignore important.log"
9917        );
9918    }
9919
9920    #[test]
9921    fn rebuild_picks_up_gitignore_changes() {
9922        let tmp = TempDir::new().unwrap();
9923        let ignore_path = tmp.path().join(".gitignore");
9924        fs::write(&ignore_path, "foo.txt\n").unwrap();
9925        let foo = tmp.path().join("foo.txt");
9926        let bar = tmp.path().join("bar.txt");
9927        fs::write(&foo, "").unwrap();
9928        fs::write(&bar, "").unwrap();
9929
9930        let ctx = make_ctx_with_root(tmp.path());
9931        ctx.rebuild_gitignore();
9932        assert!(is_ignored(&ctx, &foo));
9933        assert!(!is_ignored(&ctx, &bar));
9934
9935        // Now flip the rules: ignore bar.txt instead of foo.txt
9936        fs::write(&ignore_path, "bar.txt\n").unwrap();
9937        ctx.rebuild_gitignore();
9938        assert!(!is_ignored(&ctx, &foo));
9939        assert!(is_ignored(&ctx, &bar));
9940    }
9941
9942    #[test]
9943    fn gitignore_loads_info_exclude_when_present() {
9944        let tmp = TempDir::new().unwrap();
9945        let info_dir = tmp.path().join(".git/info");
9946        fs::create_dir_all(&info_dir).unwrap();
9947        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
9948        let secrets = tmp.path().join("secrets.txt");
9949        let public = tmp.path().join("public.txt");
9950        fs::write(&secrets, "token").unwrap();
9951        fs::write(&public, "ok").unwrap();
9952
9953        let ctx = make_ctx_with_root(tmp.path());
9954        ctx.rebuild_gitignore();
9955
9956        assert!(is_ignored(&ctx, &secrets));
9957        assert!(!is_ignored(&ctx, &public));
9958    }
9959
9960    #[test]
9961    fn matcher_picks_up_nested_gitignore() {
9962        let tmp = TempDir::new().unwrap();
9963        // Root .gitignore is intentionally empty — only the nested one ignores
9964        fs::write(tmp.path().join(".gitignore"), "").unwrap();
9965        let sub = tmp.path().join("packages/foo");
9966        fs::create_dir_all(&sub).unwrap();
9967        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
9968        let generated_file = sub.join("generated").join("out.js");
9969        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
9970        fs::write(&generated_file, "x").unwrap();
9971
9972        let ctx = make_ctx_with_root(tmp.path());
9973        ctx.rebuild_gitignore();
9974
9975        assert!(
9976            is_ignored(&ctx, &generated_file),
9977            "nested gitignore in packages/foo/.gitignore should ignore generated/"
9978        );
9979    }
9980}
9981
9982#[cfg(test)]
9983mod verify_memo_watcher_tests {
9984    use super::*;
9985
9986    #[test]
9987    fn pending_watcher_path_invalidates_root_verify_memo() {
9988        let root_dir = tempfile::tempdir().unwrap();
9989        let root = std::fs::canonicalize(root_dir.path()).unwrap();
9990        let artifact = root.join("cache.bin");
9991        std::fs::write(&artifact, b"generation").unwrap();
9992        let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
9993        crate::cache_freshness::record_verify_completed(
9994            &root,
9995            crate::cache_freshness::VerifyArtifact::Search,
9996            Some(generation),
9997        );
9998        assert_eq!(
9999            crate::cache_freshness::warm_verify_plan(
10000                &root,
10001                crate::cache_freshness::VerifyArtifact::Search,
10002                Some(generation),
10003            ),
10004            crate::cache_freshness::WarmVerifyPlan::Skip
10005        );
10006
10007        let ctx = AppContext::from_app(
10008            App::default_shared(),
10009            Config {
10010                project_root: Some(root.clone()),
10011                ..Config::default()
10012            },
10013        );
10014        ctx.set_canonical_cache_root(root.clone());
10015        ctx.add_pending_search_index_paths([root.join("changed.rs")]);
10016        assert_eq!(
10017            crate::cache_freshness::warm_verify_plan(
10018                &root,
10019                crate::cache_freshness::VerifyArtifact::Search,
10020                Some(generation),
10021            ),
10022            crate::cache_freshness::WarmVerifyPlan::StatFirst
10023        );
10024    }
10025}
10026
10027#[cfg(test)]
10028mod watcher_runtime_state_tests {
10029    use super::*;
10030    use crate::language::StubProvider;
10031
10032    fn test_context() -> AppContext {
10033        AppContext::new(Box::new(StubProvider), Config::default())
10034    }
10035
10036    #[test]
10037    fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
10038        let root = tempfile::tempdir().expect("project tempdir");
10039        let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
10040        let ctx = AppContext::new(
10041            Box::new(StubProvider),
10042            Config {
10043                project_root: Some(canonical_root.clone()),
10044                ..Config::default()
10045            },
10046        );
10047        ctx.set_canonical_cache_root(canonical_root.clone());
10048        // Suppress the physical FSEvents reinstall (parallel in-process tests
10049        // must not install real OS watchers); the property under test is the
10050        // corpse reclaim + invalidation, not the reinstall.
10051        struct DisableWatcherGuard;
10052        impl Drop for DisableWatcherGuard {
10053            fn drop(&mut self) {
10054                unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
10055            }
10056        }
10057        let _env_lock = crate::test_env::process_env_lock();
10058        unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
10059        let _disable_watcher = DisableWatcherGuard;
10060        // Warm state the corpse reclaim must invalidate: resident index +
10061        // warm Skip memo.
10062        *ctx.search_index
10063            .write()
10064            .unwrap_or_else(std::sync::PoisonError::into_inner) =
10065            Some(crate::search_index::SearchIndex::new());
10066        let artifact = canonical_root.join("artifact.bin");
10067        std::fs::write(&artifact, b"artifact").expect("artifact");
10068        let generation = crate::cache_freshness::artifact_generation(&artifact);
10069        crate::cache_freshness::record_verify_completed(
10070            &canonical_root,
10071            crate::cache_freshness::VerifyArtifact::Search,
10072            generation,
10073        );
10074
10075        let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
10076        let _dispatch_tx = dispatch_tx;
10077        // A thread that exits on its own models a backend failure while the
10078        // root was unbound (drains suppressed, queued error undrained).
10079        let join = std::thread::spawn(|| {});
10080        ctx.install_watcher_runtime(
10081            dispatch_rx,
10082            WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
10083        );
10084        let deadline = std::time::Instant::now() + Duration::from_secs(2);
10085        while ctx.watcher_runtime_active() {
10086            assert!(
10087                std::time::Instant::now() < deadline,
10088                "a finished watcher thread must report the runtime inactive"
10089            );
10090            std::thread::yield_now();
10091        }
10092
10093        // The production entry point: rebind restoration must reclaim the
10094        // corpse, invalidate the unobserved-window state, and reinstall.
10095        crate::commands::configure::ensure_project_watcher(&ctx);
10096
10097        assert!(
10098            ctx.search_index
10099                .read()
10100                .unwrap_or_else(std::sync::PoisonError::into_inner)
10101                .is_none(),
10102            "corpse reclaim must drop resident artifacts (events since the failure are lost)"
10103        );
10104        assert_eq!(
10105            crate::cache_freshness::warm_verify_plan(
10106                &canonical_root,
10107                crate::cache_freshness::VerifyArtifact::Search,
10108                generation,
10109            ),
10110            crate::cache_freshness::WarmVerifyPlan::Strict,
10111            "corpse reclaim must force strict re-verification"
10112        );
10113        assert!(
10114            !ctx.take_finished_watcher_runtime(),
10115            "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
10116        );
10117    }
10118
10119    #[test]
10120    fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
10121        let ctx = test_context();
10122        let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
10123        let shutdown = Arc::new(AtomicBool::new(false));
10124        let thread_shutdown = Arc::clone(&shutdown);
10125        let join = std::thread::spawn(move || {
10126            while !thread_shutdown.load(Ordering::SeqCst) {
10127                std::thread::sleep(Duration::from_millis(1));
10128            }
10129            drop(dispatch_tx);
10130        });
10131        ctx.install_watcher_runtime(
10132            dispatch_rx,
10133            WatcherThreadHandle::new(Arc::clone(&shutdown), join),
10134        );
10135        assert!(ctx.watcher_runtime_active());
10136
10137        *ctx.watcher_rx.lock() = None;
10138        assert!(
10139            !ctx.watcher_runtime_active(),
10140            "a thread without its dispatch receiver is not a usable watcher runtime"
10141        );
10142        ctx.stop_watcher_runtime();
10143    }
10144}
10145
10146#[cfg(test)]
10147mod semantic_probe_tests {
10148    use super::*;
10149
10150    #[test]
10151    fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
10152        let root = tempfile::tempdir().unwrap();
10153        let ctx = AppContext::new(
10154            default_language_provider_factory(),
10155            Config {
10156                project_root: Some(root.path().to_path_buf()),
10157                ..Config::default()
10158            },
10159        );
10160        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
10161        let (_event_tx, event_rx) = crossbeam_channel::unbounded();
10162        let worker_slot = Arc::new(Mutex::new(None));
10163        ctx.install_semantic_refresh_worker_for_build_epoch(
10164            request_tx,
10165            event_rx,
10166            worker_slot,
10167            ctx.semantic_index_rx_epoch(),
10168        );
10169
10170        ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
10171        assert!(ctx.semantic_refresh_probe_is_scheduled());
10172        ctx.clear_semantic_refresh_worker();
10173        std::thread::sleep(Duration::from_millis(50));
10174
10175        assert!(!ctx.semantic_refresh_probe_ready());
10176        assert!(!ctx.semantic_refresh_probe_is_scheduled());
10177        assert!(!ctx.completion_drains_have_work());
10178    }
10179}