Skip to main content

aft/
context.rs

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