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