Skip to main content

aft/
context.rs

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