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