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