Skip to main content

aft/
context.rs

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