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