Skip to main content

aft/
context.rs

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