Skip to main content

aft/
context.rs

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