Skip to main content

aft/
context.rs

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