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