Skip to main content

aft/
context.rs

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