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