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