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