Skip to main content

aft/
context.rs

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