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};
6use std::time::{Duration, Instant};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11use serde::Serialize;
12
13use crate::artifact_owner::{
14    ArtifactOwnerLease, ArtifactOwnerLeaseRegistration, ArtifactOwnerMode, ArtifactOwnerStatus,
15};
16use crate::backup::hash_session;
17use crate::backup::BackupStore;
18use crate::bash_background::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
19use crate::callgraph_store::{CallGraphStore, CallGraphStoreError};
20use crate::checkpoint::CheckpointStore;
21use crate::config::Config;
22use crate::harness::Harness;
23use crate::inspect::{
24    InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
25};
26use crate::language::LanguageProvider;
27use crate::lsp::manager::{LspManager, StaleDiagnosticsMark};
28use crate::lsp::registry::is_config_file_path_with_custom;
29use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
30use crate::protocol::{
31    ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
32};
33use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
34
35pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
36pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
37pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
38const STATUS_DEBOUNCE_MS: u64 = 1_000;
39
40/// Agent status-bar counts — the IDE-style "status bar" surfaced to the agent
41/// on every tool result (emit-on-change). `errors`/`warnings` are read LIVE
42/// from the continuously-drained LSP diagnostics store; the Tier-2 counts
43/// (`dead_code`/`unused_exports`/`duplicates`) and `todos` are last-known,
44/// refreshed when `aft_inspect` runs or a background Tier-2 scan completes.
45/// `tier2_stale` marks the Tier-2 counts as not-yet-reconciled with the latest
46/// edits (rendered with a `~` marker so the agent never reads them as live).
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct StatusBarCounts {
49    pub errors: usize,
50    pub warnings: usize,
51    pub dead_code: usize,
52    pub unused_exports: usize,
53    pub duplicates: usize,
54    pub todos: usize,
55    pub tier2_stale: bool,
56}
57
58/// Last-known Tier-2 + todos counts, refreshed off the hot path. `errors` and
59/// `warnings` are intentionally NOT cached here — they're read live per attach.
60///
61/// Each Tier-2 category is `Option`: `None` means "no scan has ever produced a
62/// count for this category", so we never fabricate a `0`. The bar is only
63/// surfaced once all three Tier-2 categories hold a real value — a partially
64/// completed cold scan (e.g. dead_code done, unused_exports/duplicates still
65/// running) must not render `D<real> U0 C0` and lie about project health (#1).
66#[derive(Debug, Clone, Default)]
67struct StatusBarTier2 {
68    dead_code: Option<usize>,
69    unused_exports: Option<usize>,
70    duplicates: Option<usize>,
71    todos: Option<usize>,
72    stale: bool,
73}
74
75#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum RootHealthState {
78    Ready,
79    Busy,
80}
81
82#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
83pub struct HealthComponentSnapshot {
84    pub status: &'static str,
85}
86
87#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
88pub struct Tier2HealthSnapshot {
89    pub status: &'static str,
90}
91
92#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
93pub struct RootHealthSnapshot {
94    pub project_root: String,
95    pub actor_count: usize,
96    pub state: RootHealthState,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub search_index: Option<HealthComponentSnapshot>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub semantic_index: Option<HealthComponentSnapshot>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub callgraph_store: Option<HealthComponentSnapshot>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub tier2: Option<Tier2HealthSnapshot>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub bash: Option<BgTaskHealthCounts>,
107}
108
109impl RootHealthSnapshot {
110    fn busy(project_root: &Path) -> Self {
111        Self {
112            project_root: project_root.display().to_string(),
113            actor_count: 1,
114            state: RootHealthState::Busy,
115            search_index: None,
116            semantic_index: None,
117            callgraph_store: None,
118            tier2: None,
119            bash: None,
120        }
121    }
122
123    pub fn is_fully_ready(&self) -> bool {
124        let component_is_satisfied =
125            |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
126        let tier2_is_satisfied =
127            |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
128
129        matches!(self.state, RootHealthState::Ready)
130            && self
131                .search_index
132                .as_ref()
133                .is_some_and(component_is_satisfied)
134            && self
135                .semantic_index
136                .as_ref()
137                .is_some_and(component_is_satisfied)
138            && self
139                .callgraph_store
140                .as_ref()
141                .is_some_and(component_is_satisfied)
142            && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
143    }
144}
145
146pub struct StatusEmitter {
147    latest: Arc<Mutex<Option<StatusPayload>>>,
148    notify: mpsc::Sender<()>,
149}
150
151#[derive(Clone, Debug, Default)]
152struct ConfigureWarmState {
153    generation: u64,
154    key: Option<String>,
155}
156
157#[derive(Clone, Debug)]
158pub(crate) struct ConfigureMaintenanceJob {
159    pub(crate) generation: u64,
160    pub(crate) root_path: PathBuf,
161    pub(crate) canonical_cache_root: PathBuf,
162    pub(crate) harness: Harness,
163    pub(crate) storage_root: PathBuf,
164    pub(crate) harness_dir: PathBuf,
165    pub(crate) session_id: String,
166    pub(crate) home_match: bool,
167    pub(crate) format_tool_cache_clear_needed: bool,
168    pub(crate) run_bash_replay: bool,
169    pub(crate) refresh_project_runtime: bool,
170    pub(crate) sync_bash_compress_flag: bool,
171    pub(crate) reset_filter_registry: bool,
172    pub(crate) clear_failed_spawns: bool,
173    pub(crate) warm_callgraph_store: bool,
174}
175
176impl StatusEmitter {
177    fn new(progress_sender: SharedProgressSender) -> Self {
178        let (notify, rx) = mpsc::channel();
179        let latest = Arc::new(Mutex::new(None));
180        let latest_for_thread = Arc::clone(&latest);
181        std::thread::spawn(move || {
182            status_debounce_loop(rx, latest_for_thread, progress_sender);
183        });
184        Self { latest, notify }
185    }
186
187    pub fn signal(&self, snapshot: StatusPayload) {
188        if let Ok(mut latest) = self.latest.lock() {
189            *latest = Some(snapshot);
190        }
191        let _ = self.notify.send(());
192    }
193}
194
195fn status_debounce_loop(
196    rx: mpsc::Receiver<()>,
197    latest: Arc<Mutex<Option<StatusPayload>>>,
198    progress_sender: SharedProgressSender,
199) {
200    while rx.recv().is_ok() {
201        let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
202        while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
203            match rx.recv_timeout(remaining) {
204                Ok(()) => continue,
205                Err(mpsc::RecvTimeoutError::Timeout) => break,
206                Err(mpsc::RecvTimeoutError::Disconnected) => return,
207            }
208        }
209
210        let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
211        let Some(snapshot) = snapshot else { continue };
212        let sender = progress_sender
213            .lock()
214            .ok()
215            .and_then(|sender| sender.clone());
216        if let Some(sender) = sender {
217            sender(PushFrame::StatusChanged(StatusChangedFrame::new(
218                None, snapshot,
219            )));
220        }
221    }
222}
223use crate::cache_freshness::FileFreshness;
224use crate::search_index::SearchIndex;
225use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
226
227// `SemanticIndexStatus::Ready` exposes a unique `refreshing` path list. Keep
228// per-path queue accounting separately so repeated edits to the same file do not
229// let an older refresh completion remove the path while newer work is pending.
230#[derive(Debug, Default, Clone)]
231#[doc(hidden)]
232pub struct SemanticRefreshAccounting {
233    #[doc(hidden)]
234    pub pending: usize,
235    #[doc(hidden)]
236    pub in_flight: usize,
237}
238
239#[derive(Debug, Default)]
240struct SemanticRefreshCircuit {
241    consecutive_transient_failures: AtomicUsize,
242    open: AtomicBool,
243    probe_in_flight: AtomicBool,
244    probe_ready: AtomicBool,
245}
246
247fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
248    if !refreshing.iter().any(|existing| existing == &path) {
249        refreshing.push(path);
250        refreshing.sort();
251    }
252}
253
254fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
255    refreshing.retain(|existing| existing != path);
256}
257
258#[derive(Debug, Clone)]
259pub enum SemanticIndexStatus {
260    Disabled,
261    Building {
262        /// Cold-build only — index is not queryable.
263        stage: String,
264        files: Option<usize>,
265        entries_done: Option<usize>,
266        entries_total: Option<usize>,
267    },
268    Ready {
269        /// Files currently being re-embedded after recent edits. The index is
270        /// still queryable; results for these files may be temporarily missing.
271        refreshing: Vec<PathBuf>,
272        /// Per-root queue accounting for repeated refreshes of the same path.
273        /// Kept on the status value so two AppContexts in one process cannot
274        /// share refresh-completion state.
275        #[doc(hidden)]
276        accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
277    },
278    Failed(String),
279}
280
281impl SemanticIndexStatus {
282    pub fn ready() -> Self {
283        Self::Ready {
284            refreshing: Vec::new(),
285            accounting: BTreeMap::new(),
286        }
287    }
288
289    pub fn add_refreshing_file(&mut self, path: PathBuf) {
290        if let Self::Ready {
291            refreshing,
292            accounting,
293        } = self
294        {
295            let state = accounting.entry(path.clone()).or_default();
296            state.pending = state.pending.saturating_add(1);
297            ensure_refreshing_path(refreshing, path);
298        }
299    }
300
301    pub fn start_refreshing_file(&mut self, path: PathBuf) {
302        if let Self::Ready {
303            refreshing,
304            accounting,
305        } = self
306        {
307            let state = accounting.entry(path.clone()).or_default();
308            if state.pending == 0 {
309                state.pending = 1;
310            }
311            if state.in_flight == 0 {
312                state.in_flight = state.pending;
313            }
314            ensure_refreshing_path(refreshing, path);
315        }
316    }
317
318    pub fn cancel_refreshing_file(&mut self, path: &Path) {
319        self.finish_refreshing_file(path, false);
320    }
321
322    pub fn complete_refreshing_file(&mut self, path: &Path) {
323        self.finish_refreshing_file(path, true);
324    }
325
326    pub fn remove_refreshing_file(&mut self, path: &Path) {
327        self.complete_refreshing_file(path);
328    }
329
330    fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
331        if let Self::Ready {
332            refreshing,
333            accounting,
334        } = self
335        {
336            let mut keep_refreshing = false;
337            if let Some(state) = accounting.get_mut(path) {
338                let finished = if complete_in_flight {
339                    state.in_flight.max(1)
340                } else {
341                    1
342                };
343                state.pending = state.pending.saturating_sub(finished);
344                if complete_in_flight {
345                    state.in_flight = 0;
346                } else {
347                    state.in_flight = state.in_flight.min(state.pending);
348                }
349                keep_refreshing = state.pending > 0;
350                if !keep_refreshing {
351                    accounting.remove(path);
352                }
353            }
354
355            if !keep_refreshing {
356                remove_refreshing_path(refreshing, path);
357            }
358        }
359    }
360
361    pub fn refreshing_count(&self) -> usize {
362        match self {
363            Self::Ready { refreshing, .. } => refreshing.len(),
364            _ => 0,
365        }
366    }
367}
368
369pub enum SemanticIndexEvent {
370    Progress {
371        stage: String,
372        files: Option<usize>,
373        entries_done: Option<usize>,
374        entries_total: Option<usize>,
375    },
376    /// Emitted when the semantic worker avoids or pauses full project corpus
377    /// collection before reaching terminal Ready/Failed, such as after loading a
378    /// cached index or while waiting to retry an embedding backend with no vectors
379    /// retained. Work that was waiting for the full index can proceed.
380    ColdSeedGateCleared,
381    Ready(SemanticIndex),
382    Failed(String),
383}
384
385#[derive(Debug, Clone)]
386pub enum SemanticRefreshRequest {
387    Files {
388        paths: Vec<PathBuf>,
389    },
390    /// Refresh the whole semantic corpus on the refresh worker. The worker owns
391    /// the project walk so watcher/configure drains never do corpus-scale work
392    /// on the single dispatch thread before scheduling embedding.
393    Corpus,
394}
395
396#[derive(Debug)]
397pub enum SemanticRefreshEvent {
398    Started {
399        paths: Vec<PathBuf>,
400    },
401    CorpusStarted {
402        files: usize,
403    },
404    Completed {
405        added_entries: Vec<EmbeddingEntry>,
406        updated_metadata: Vec<(PathBuf, FileFreshness)>,
407        completed_paths: Vec<PathBuf>,
408    },
409    CorpusCompleted {
410        index: SemanticIndex,
411        changed: usize,
412        added: usize,
413        deleted: usize,
414        total_processed: usize,
415    },
416    Failed {
417        paths: Vec<PathBuf>,
418        error: String,
419    },
420    CorpusFailed {
421        error: String,
422    },
423}
424
425pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
426
427/// Normalize a path by resolving `.` and `..` components lexically,
428/// without touching the filesystem. This prevents path traversal
429/// attacks when `fs::canonicalize` fails (e.g. for non-existent paths).
430fn normalize_path(path: &Path) -> PathBuf {
431    let mut result = PathBuf::new();
432    for component in path.components() {
433        match component {
434            Component::ParentDir => {
435                // Pop the last component unless we're at root or have no components
436                if !result.pop() {
437                    result.push(component);
438                }
439            }
440            Component::CurDir => {} // Skip `.`
441            _ => result.push(component),
442        }
443    }
444    result
445}
446
447fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
448    let mut existing = path.to_path_buf();
449    let mut tail_segments = Vec::new();
450
451    while !existing.exists() {
452        if let Some(name) = existing.file_name() {
453            tail_segments.push(name.to_owned());
454        } else {
455            break;
456        }
457
458        existing = match existing.parent() {
459            Some(parent) => parent.to_path_buf(),
460            None => break,
461        };
462    }
463
464    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
465    for segment in tail_segments.into_iter().rev() {
466        resolved.push(segment);
467    }
468
469    resolved
470}
471
472fn path_error_response(
473    req_id: &str,
474    path: &Path,
475    resolved_root: &Path,
476) -> crate::protocol::Response {
477    crate::protocol::Response::error(
478        req_id,
479        "path_outside_root",
480        format!(
481            "path '{}' is outside the project root '{}'",
482            path.display(),
483            resolved_root.display()
484        ),
485    )
486}
487
488/// Walk `candidate` component-by-component. For any component that is a
489/// symlink on disk, iteratively follow the full chain (up to 40 hops) and
490/// reject if any hop's resolved target lies outside `resolved_root`.
491///
492/// This is the fallback path used when `fs::canonicalize` fails (e.g. on
493/// Linux with broken symlink chains pointing to non-existent destinations).
494/// On macOS `canonicalize` also fails for broken symlinks but the returned
495/// `/var/...` tempdir paths diverge from `resolved_root`'s `/private/var/...`
496/// form, so we must accept either form when deciding which symlinks to check.
497fn reject_escaping_symlink(
498    req_id: &str,
499    original_path: &Path,
500    candidate: &Path,
501    resolved_root: &Path,
502    raw_root: &Path,
503) -> Result<(), crate::protocol::Response> {
504    let mut current = PathBuf::new();
505
506    for component in candidate.components() {
507        current.push(component);
508
509        let Ok(metadata) = std::fs::symlink_metadata(&current) else {
510            continue;
511        };
512
513        if !metadata.file_type().is_symlink() {
514            continue;
515        }
516
517        // Only check symlinks that live inside the project root. This skips
518        // OS-level prefix symlinks (macOS /var → /private/var) that are not
519        // inside our project directory and whose "escaping" is harmless.
520        //
521        // We compare against BOTH the canonicalized root (resolved_root, e.g.
522        // /private/var/.../project) AND the raw root (e.g. /var/.../project)
523        // because tempdir() returns raw paths while fs::canonicalize returns
524        // the resolved form — and our `current` may be in either form.
525        let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
526        if !inside_root {
527            continue;
528        }
529
530        iterative_follow_chain(req_id, original_path, &current, resolved_root)?;
531    }
532
533    Ok(())
534}
535
536/// Iteratively follow a symlink chain from `link` and reject if any hop's
537/// resolved target is outside `resolved_root`. Depth-capped at 40 hops.
538fn iterative_follow_chain(
539    req_id: &str,
540    original_path: &Path,
541    start: &Path,
542    resolved_root: &Path,
543) -> Result<(), crate::protocol::Response> {
544    let mut link = start.to_path_buf();
545    let mut depth = 0usize;
546
547    loop {
548        if depth > 40 {
549            return Err(path_error_response(req_id, original_path, resolved_root));
550        }
551
552        let target = match std::fs::read_link(&link) {
553            Ok(t) => t,
554            Err(_) => {
555                // Can't read the link — treat as escaping to be safe.
556                return Err(path_error_response(req_id, original_path, resolved_root));
557            }
558        };
559
560        let resolved_target = if target.is_absolute() {
561            normalize_path(&target)
562        } else {
563            let parent = link.parent().unwrap_or_else(|| Path::new(""));
564            normalize_path(&parent.join(&target))
565        };
566
567        // Check boundary: use canonicalized target when available (handles
568        // macOS /var → /private/var aliasing), fall back to the normalized
569        // path when canonicalize fails (e.g. broken symlink on Linux).
570        let canonical_target =
571            std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
572
573        if !canonical_target.starts_with(resolved_root)
574            && !resolved_target.starts_with(resolved_root)
575        {
576            return Err(path_error_response(req_id, original_path, resolved_root));
577        }
578
579        // If the target is itself a symlink, follow the next hop.
580        match std::fs::symlink_metadata(&resolved_target) {
581            Ok(meta) if meta.file_type().is_symlink() => {
582                link = resolved_target;
583                depth += 1;
584            }
585            _ => break, // Non-symlink or non-existent target — chain ends here.
586        }
587    }
588
589    Ok(())
590}
591
592pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
593
594pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
595    Box::new(TreeSitterProvider::new())
596}
597
598/// Process-global services shared by all project actors in this AFT process.
599///
600/// `App` owns only true process services. Per-root caches and the live
601/// language provider instance stay in [`AppContext`].
602pub struct App {
603    db: parking_lot::Mutex<Option<Arc<Mutex<Connection>>>>,
604    lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
605    stdout_writer: SharedStdoutWriter,
606    provider_factory: LanguageProviderFactory,
607}
608
609impl App {
610    pub fn new(provider_factory: LanguageProviderFactory) -> Self {
611        Self {
612            db: parking_lot::Mutex::new(None),
613            lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
614            stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
615            provider_factory,
616        }
617    }
618
619    /// Create the shared process `App` handle required by the actor split.
620    pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
621        Arc::new(Self::new(provider_factory))
622    }
623
624    pub fn default_shared() -> Arc<Self> {
625        Self::shared(default_language_provider_factory)
626    }
627
628    pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
629        (self.provider_factory)()
630    }
631
632    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
633        self.lsp_child_registry.clone()
634    }
635
636    pub fn stdout_writer(&self) -> SharedStdoutWriter {
637        Arc::clone(&self.stdout_writer)
638    }
639
640    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
641        *self.db.lock() = Some(conn);
642    }
643
644    pub fn clear_db(&self) {
645        *self.db.lock() = None;
646    }
647
648    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
649        self.db.lock().clone()
650    }
651}
652
653impl Default for App {
654    fn default() -> Self {
655        Self::new(default_language_provider_factory)
656    }
657}
658
659const _: fn() = || {
660    fn assert_send_sync<T: Send + Sync>() {}
661    fn assert_send<T: Send>() {}
662
663    assert_send_sync::<App>();
664    assert_send_sync::<AppContext>();
665    assert_send::<crate::lsp::manager::LspManager>();
666    assert_send::<crate::semantic_index::EmbeddingModel>();
667};
668
669/// Shared application context threaded through all command handlers.
670///
671/// Holds the language provider, backup/checkpoint stores, and configuration.
672/// Constructed once at startup and passed by
673/// reference to `dispatch`.
674///
675/// Write-rarely stores use `parking_lot::Mutex` for interior mutability so this
676/// context can become thread-safe while preserving the current single-request
677/// dispatch behavior. `config` is a thread-safe owned snapshot so future
678/// read-only dispatch can hold configuration across other work without holding
679/// a lock guard.
680pub struct AppContext {
681    app: Arc<App>,
682    provider: Box<dyn LanguageProvider>,
683    backup: parking_lot::Mutex<BackupStore>,
684    checkpoint: parking_lot::Mutex<CheckpointStore>,
685    config: RwLock<Arc<Config>>,
686    force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
687    pub harness: parking_lot::Mutex<Option<Harness>>,
688    canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
689    is_worktree_bridge: parking_lot::Mutex<bool>,
690    git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
691    shared_artifacts_read_only: parking_lot::Mutex<bool>,
692    artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
693    artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
694    /// Reasons (if any) why heavy AFT subsystems were auto-disabled for the
695    /// current project root. Populated by `handle_configure` based on the
696    /// canonical project root. Each reason is a stable machine-readable string
697    /// (e.g. `"home_root"`, `"watcher_unavailable"`) so the plugin can render
698    /// distinct degraded-mode UI states without re-deriving the reason locally.
699    /// Empty when the project is healthy / full-featured.
700    degraded_reasons: parking_lot::Mutex<Vec<String>>,
701    /// Configure-time gate for project-wide scans, builds, and watcher-driven
702    /// refreshes that would otherwise walk the whole root. `handle_configure`
703    /// closes it for degraded home roots and every heavy-work entry point reads
704    /// the same atomic so the decision cannot drift after configure returns.
705    heavy_root_work_allowed: Arc<AtomicBool>,
706    callgraph_store: RwLock<Option<Arc<CallGraphStore>>>,
707    callgraph_store_force_rebuild: parking_lot::Mutex<bool>,
708    callgraph_store_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>>,
709    pending_callgraph_store_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
710    search_index: RwLock<Option<SearchIndex>>,
711    search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
712    pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
713    symbol_cache: SharedSymbolCache,
714    inspect_manager: Arc<InspectManager>,
715    tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
716    pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
717    semantic_index: RwLock<Option<SemanticIndex>>,
718    semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
719    semantic_index_status: RwLock<SemanticIndexStatus>,
720    /// True while this context has a cold semantic seed scheduled or actively
721    /// collecting/embedding/persisting the full project corpus. The semantic
722    /// worker clears it as soon as it proves the cached/incremental path is in use.
723    semantic_cold_seed_active: Arc<AtomicBool>,
724    /// Monotonic generation that prevents a superseded semantic worker from
725    /// reopening the cold-seed gate after a later configure has reset it.
726    semantic_cold_seed_generation: Arc<AtomicU64>,
727    semantic_callgraph_warm_deferred: AtomicBool,
728    pending_semantic_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
729    pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
730    semantic_refresh_tx:
731        parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>,
732    semantic_refresh_event_rx:
733        parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
734    semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
735    semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
736    semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
737    semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
738    watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
739    watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
740    watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
741    lsp_manager: parking_lot::Mutex<LspManager>,
742    configure_generation: Arc<AtomicU64>,
743    configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
744    configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
745    configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
746    artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
747    artifact_cache_key_derivations: AtomicU64,
748    /// Last-seen value of `InspectManager::reuse_completion_count()`, so the
749    /// per-request inspect drain can detect watcher-driven Tier-2 scans that
750    /// finished since the previous tick and refresh the status bar (#3).
751    last_seen_reuse_completions: AtomicU64,
752    configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
753    configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
754    /// Per-context push sender slot. Status and background-bash emitters share
755    /// this Arc so a sender installed after construction is observed at emit time.
756    progress_sender: SharedProgressSender,
757    status_emitter: StatusEmitter,
758    /// Last status-bar payload attached to a tool response for this project root.
759    /// Deduping here (not in a process-global static) lets daemon roots emit the
760    /// same counts independently.
761    status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
762    bash_background: BgTaskRegistry,
763    /// Thread-safe registry of TOML output filters. Lazy-built on first
764    /// access; populated atomically via `RwLock`. Shared between command
765    /// handlers (which use it through `filter_registry()` -> read guard) and
766    /// the `BgTaskRegistry` watchdog thread (which uses it through
767    /// `compress::compress_with_registry`). Reloaded when configure changes
768    /// the project root or storage_dir; see [`AppContext::reset_filter_registry`].
769    filter_registry: crate::compress::SharedFilterRegistry,
770    filter_registry_rebuild_count: AtomicU64,
771    /// Set to true once the filter_registry has been populated. Avoids
772    /// double-loading on hot paths without holding a write lock.
773    filter_registry_loaded: std::sync::atomic::AtomicBool,
774    /// Live `experimental.bash.compress` flag, kept in sync with `config`
775    /// from the configure handler. Exposed via [`AppContext::bash_compress_flag`]
776    /// so the BgTaskRegistry's watchdog-thread compressor can read it without
777    /// holding the config refcell.
778    bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
779    /// Project gitignore matcher, rebuilt by [`AppContext::rebuild_gitignore`]
780    /// whenever `project_root` changes or a watcher event reports a
781    /// `.gitignore` write. Used by the watcher event filter to decide which
782    /// path-changes are interesting to AFT's caches. `None` when no project
783    /// root is configured or when the project has no gitignore files; in that
784    /// case the watcher falls back to a small hardcoded infra-directory skip.
785    gitignore: SharedGitignore,
786    gitignore_generation: Arc<AtomicU64>,
787    /// Last-known Tier-2 + todos counts for the agent status bar, refreshed off
788    /// the hot path (on `aft_inspect` reads and background Tier-2 completions).
789    /// Errors/warnings are read live and not stored here.
790    status_bar_tier2: RwLock<StatusBarTier2>,
791    /// Persistent TypeScript-project membership cache for the status-bar E/W
792    /// count. The bar reads E/W live on every tool result, so resolving the
793    /// nearest tsconfig (read + parse + glob-compile) per drain is too costly;
794    /// this memoizes per tsconfig dir. Invalidated wholesale on any
795    /// tsconfig-like watcher event and on `configure`. Owned here (not in
796    /// `DiagnosticsStore`, which stays raw policy-free) per the v0.35 council.
797    tsconfig_membership:
798        parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
799}
800
801/// RAII guard for a server-owned request-scoped path-restriction override.
802///
803/// Guards are refcounted by request id so duplicated ids over-restrict until the
804/// last worker exits, rather than letting one completion disable another
805/// in-flight request's containment.
806pub struct ForceRestrictGuard<'a> {
807    ctx: &'a AppContext,
808    req_id: String,
809}
810
811impl Drop for ForceRestrictGuard<'_> {
812    fn drop(&mut self) {
813        self.ctx.release_force_restrict(&self.req_id);
814    }
815}
816
817impl Drop for AppContext {
818    fn drop(&mut self) {
819        self.artifact_owner_lease.get_mut().take();
820        if let Some(runtime) = self.watcher_thread.get_mut().take() {
821            runtime.shutdown_and_join();
822        }
823    }
824}
825
826/// Result of requesting the persisted callgraph store for a store-backed op.
827///
828/// The five edge-query ops never block the request thread on a cold build:
829/// a genuine cold build is kicked off in the background and `Building` is
830/// returned so the agent retries, mirroring how semantic search reports a
831/// build in progress. Warm restarts open the on-disk DB synchronously, so
832/// `Building` is only ever seen during a true first cold build.
833pub enum CallgraphStoreAccess {
834    /// Store is resident and queryable.
835    Ready(Arc<CallGraphStore>),
836    /// A cold build is in flight (or was just started); retry shortly.
837    Building,
838    /// Not configured, or a read-only worktree whose store was never built.
839    Unavailable,
840    /// A store open/build check failed with a real error (DB/IO).
841    Error(CallGraphStoreError),
842}
843
844/// Inline wait window for a callgraph-store cold build before returning
845/// `Building`. Default `0` (pure-async: never block the request thread).
846/// Tests set `AFT_CALLGRAPH_BUILD_WAIT_MS` large so small fixture builds
847/// resolve to `Ready` synchronously and exercise query correctness directly.
848fn callgraph_build_wait_window() -> Duration {
849    std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
850        .ok()
851        .and_then(|raw| raw.parse::<u64>().ok())
852        .map(Duration::from_millis)
853        .unwrap_or(Duration::ZERO)
854}
855
856static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
857
858#[doc(hidden)]
859pub fn reset_callgraph_cold_build_spawn_count_for_test() {
860    CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
861}
862
863#[doc(hidden)]
864pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
865    CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
866}
867
868impl AppContext {
869    pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
870        Self::with_app_and_provider(App::default_shared(), provider, config)
871    }
872
873    pub fn from_app(app: Arc<App>, config: Config) -> Self {
874        let provider = app.create_provider();
875        Self::with_app_and_provider(app, provider, config)
876    }
877
878    pub fn with_app_and_provider(
879        app: Arc<App>,
880        provider: Box<dyn LanguageProvider>,
881        config: Config,
882    ) -> Self {
883        let bash_compress_enabled = config.experimental_bash_compress;
884        let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
885        let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
886        let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
887        let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
888        let symbol_cache = provider
889            .as_any()
890            .downcast_ref::<TreeSitterProvider>()
891            .map(|provider| provider.symbol_cache())
892            .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
893        let mut lsp_manager = LspManager::new();
894        lsp_manager.set_child_registry(app.lsp_child_registry());
895        // Apply the configured diagnostic LRU cap (default 5000, 0 = unbounded)
896        // so the documented `lsp.diagnostic_cache_size` knob takes effect.
897        lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
898        AppContext {
899            app: Arc::clone(&app),
900            provider,
901            backup: parking_lot::Mutex::new(BackupStore::new()),
902            checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
903            config: RwLock::new(Arc::new(config)),
904            force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
905            harness: parking_lot::Mutex::new(None),
906            canonical_cache_root: parking_lot::Mutex::new(None),
907            is_worktree_bridge: parking_lot::Mutex::new(false),
908            git_common_dir: parking_lot::Mutex::new(None),
909            shared_artifacts_read_only: parking_lot::Mutex::new(false),
910            artifact_owner_status: parking_lot::Mutex::new(None),
911            artifact_owner_lease: parking_lot::Mutex::new(None),
912            degraded_reasons: parking_lot::Mutex::new(Vec::new()),
913            heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
914            callgraph_store: RwLock::new(None),
915            callgraph_store_force_rebuild: parking_lot::Mutex::new(false),
916            callgraph_store_rx: parking_lot::Mutex::new(None),
917            pending_callgraph_store_paths: parking_lot::Mutex::new(BTreeSet::new()),
918            search_index: RwLock::new(None),
919            search_index_rx: RwLock::new(None),
920            pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
921            symbol_cache,
922            inspect_manager: Arc::new(InspectManager::with_heavy_root_work_gate(Arc::clone(
923                &heavy_root_work_allowed,
924            ))),
925            tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
926            pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
927            semantic_index: RwLock::new(None),
928            semantic_index_rx: parking_lot::Mutex::new(None),
929            semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
930            semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
931            semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
932            semantic_callgraph_warm_deferred: AtomicBool::new(false),
933            pending_semantic_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
934            pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
935            semantic_refresh_tx: parking_lot::Mutex::new(None),
936            semantic_refresh_event_rx: parking_lot::Mutex::new(None),
937            semantic_refresh_worker: parking_lot::Mutex::new(None),
938            semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
939            semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
940            semantic_embedding_model: parking_lot::Mutex::new(None),
941            watcher: parking_lot::Mutex::new(None),
942            watcher_rx: parking_lot::Mutex::new(None),
943            watcher_thread: parking_lot::Mutex::new(None),
944            lsp_manager: parking_lot::Mutex::new(lsp_manager),
945            configure_generation: Arc::new(AtomicU64::new(0)),
946            configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
947            configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
948            configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
949            artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
950            artifact_cache_key_derivations: AtomicU64::new(0),
951            last_seen_reuse_completions: AtomicU64::new(0),
952            configure_warnings_tx,
953            configure_warnings_rx,
954            progress_sender: Arc::clone(&progress_sender),
955            status_emitter,
956            status_bar_last_emitted: RwLock::new(None),
957            bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
958            filter_registry: Arc::new(std::sync::RwLock::new(
959                crate::compress::toml_filter::FilterRegistry::default(),
960            )),
961            filter_registry_rebuild_count: AtomicU64::new(0),
962            filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
963            bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
964            gitignore: Arc::new(std::sync::RwLock::new(None)),
965            gitignore_generation: Arc::new(AtomicU64::new(0)),
966            status_bar_tier2: RwLock::new(StatusBarTier2::default()),
967            tsconfig_membership: parking_lot::Mutex::new(
968                crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
969            ),
970        }
971    }
972
973    /// Current agent status-bar counts. `errors`/`warnings` are read LIVE from
974    /// the LSP diagnostics store (continuously drained, no round-trip); the
975    /// Tier-2 + todos counts are the last-known cached values. Returns `None`
976    /// until the Tier-2 cache has been populated at least once, so we never
977    /// surface a bar that misleadingly claims "0 dead code" before any scan.
978    pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
979        // All three Tier-2 categories must hold a real value before the bar is
980        // surfaced — otherwise a partially-scanned cold run would render a
981        // fabricated `0` for the not-yet-completed categories (#1). Extract the
982        // values under a short read guard, drop it, then compute E/W (which
983        // touches other state) with no status-bar guard held.
984        let (dead_code, unused_exports, duplicates, todos, tier2_stale) = {
985            let tier2 = self
986                .status_bar_tier2
987                .read()
988                .unwrap_or_else(std::sync::PoisonError::into_inner);
989            let (Some(dead_code), Some(unused_exports), Some(duplicates)) =
990                (tier2.dead_code, tier2.unused_exports, tier2.duplicates)
991            else {
992                return None;
993            };
994            (
995                dead_code,
996                unused_exports,
997                duplicates,
998                tier2.todos.unwrap_or(0),
999                tier2.stale,
1000            )
1001        };
1002        let (errors, warnings) = self.status_bar_error_warning_counts();
1003        Some(StatusBarCounts {
1004            errors,
1005            warnings,
1006            dead_code,
1007            unused_exports,
1008            duplicates,
1009            todos,
1010            tier2_stale,
1011        })
1012    }
1013
1014    pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
1015        let config = match self.config.try_read() {
1016            Ok(guard) => Arc::clone(&*guard),
1017            Err(_) => return RootHealthSnapshot::busy(project_root),
1018        };
1019        let search_index = match self.search_index.try_read() {
1020            Ok(guard) => guard,
1021            Err(_) => return RootHealthSnapshot::busy(project_root),
1022        };
1023        let search_index_rx = match self.search_index_rx.try_read() {
1024            Ok(guard) => guard,
1025            Err(_) => return RootHealthSnapshot::busy(project_root),
1026        };
1027        let semantic_status = match self.semantic_index_status.try_read() {
1028            Ok(guard) => guard,
1029            Err(_) => return RootHealthSnapshot::busy(project_root),
1030        };
1031        let callgraph_store = match self.callgraph_store.try_read() {
1032            Ok(guard) => guard,
1033            Err(_) => return RootHealthSnapshot::busy(project_root),
1034        };
1035        let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
1036            Some(guard) => guard,
1037            None => return RootHealthSnapshot::busy(project_root),
1038        };
1039        let tier2 = match self.status_bar_tier2.try_read() {
1040            Ok(guard) => guard,
1041            Err(_) => return RootHealthSnapshot::busy(project_root),
1042        };
1043        let bash = match self.bash_background.try_health_counts() {
1044            Some(counts) => counts,
1045            None => return RootHealthSnapshot::busy(project_root),
1046        };
1047
1048        let search_index_status = if search_index.as_ref().is_some_and(|index| index.ready) {
1049            "ready"
1050        } else if config.search_index
1051            || search_index.as_ref().is_some()
1052            || search_index_rx.as_ref().is_some()
1053        {
1054            "building"
1055        } else {
1056            "disabled"
1057        };
1058        let semantic_index_status = match &*semantic_status {
1059            SemanticIndexStatus::Ready { .. } => "ready",
1060            SemanticIndexStatus::Building { .. } => "building",
1061            SemanticIndexStatus::Disabled => "disabled",
1062            SemanticIndexStatus::Failed(_) => "degraded",
1063        };
1064        let callgraph_store_status = if !self.heavy_root_work_allowed() {
1065            "disabled"
1066        } else if callgraph_store.as_ref().is_some() {
1067            "ready"
1068        } else if callgraph_store_rx.is_some() || config.callgraph_store {
1069            "building"
1070        } else {
1071            "disabled"
1072        };
1073        let tier2_status = if !config.inspect.enabled
1074            || (tier2.dead_code.is_none()
1075                && tier2.unused_exports.is_none()
1076                && tier2.duplicates.is_none())
1077        {
1078            "disabled"
1079        } else if tier2.dead_code.is_some()
1080            && tier2.unused_exports.is_some()
1081            && tier2.duplicates.is_some()
1082            && !tier2.stale
1083        {
1084            "ready"
1085        } else {
1086            "building"
1087        };
1088
1089        RootHealthSnapshot {
1090            project_root: project_root.display().to_string(),
1091            actor_count: 1,
1092            state: RootHealthState::Ready,
1093            search_index: Some(HealthComponentSnapshot {
1094                status: search_index_status,
1095            }),
1096            semantic_index: Some(HealthComponentSnapshot {
1097                status: semantic_index_status,
1098            }),
1099            callgraph_store: Some(HealthComponentSnapshot {
1100                status: callgraph_store_status,
1101            }),
1102            tier2: Some(Tier2HealthSnapshot {
1103                status: tier2_status,
1104            }),
1105            bash: Some(bash),
1106        }
1107    }
1108
1109    pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
1110        let mut last = self
1111            .status_bar_last_emitted
1112            .write()
1113            .unwrap_or_else(std::sync::PoisonError::into_inner);
1114        if last.as_ref() == Some(counts) {
1115            return false;
1116        }
1117        *last = Some(counts.clone());
1118        true
1119    }
1120
1121    /// Error/warning counts for the agent status bar, filtered to match
1122    /// `aft_inspect`/`tsc` (v0.35 council): only diagnostics under the canonical
1123    /// project root, with build-excluded TS/JS files skipped via the persistent
1124    /// tsconfig-membership cache, and cross-server duplicates collapsed. Falls
1125    /// back to the raw warm count before configure has set a canonical root.
1126    fn status_bar_error_warning_counts(&self) -> (usize, usize) {
1127        let Some(root) = self.canonical_cache_root_opt() else {
1128            // Pre-configure: no project root to scope against. Raw count is the
1129            // best available signal (and the bar is gated on Tier-2 anyway).
1130            return self.lsp_manager.lock().warm_error_warning_counts();
1131        };
1132        let lsp = self.lsp_manager.lock();
1133        let mut membership = self.tsconfig_membership.lock();
1134        lsp.filtered_error_warning_counts(|file| {
1135            file.starts_with(&root) && !membership.should_skip_diagnostics(file)
1136        })
1137    }
1138
1139    /// Invalidate the status-bar tsconfig-membership cache. Called from the
1140    /// watcher seam when a tsconfig-like file changes and from `configure`
1141    /// when the project root changes, so the next bar count re-reads from disk.
1142    pub fn clear_tsconfig_membership_cache(&self) {
1143        self.tsconfig_membership.lock().clear();
1144    }
1145
1146    #[cfg(test)]
1147    pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
1148        self.tsconfig_membership.lock().clear_generation()
1149    }
1150
1151    /// Mark the status-bar Tier-2 counts stale (rendered with `~`) without
1152    /// changing the numbers — called when the watcher sees a source-file change,
1153    /// so the bar honestly signals the counts predate the latest edit until the
1154    /// next background scan completes. Returns true only when the visible stale
1155    /// bit flips. No-op before the first populate.
1156    pub fn mark_status_bar_tier2_stale(&self) -> bool {
1157        let mut tier2 = self
1158            .status_bar_tier2
1159            .write()
1160            .unwrap_or_else(std::sync::PoisonError::into_inner);
1161        // No-op before the first full populate (nothing real to mark stale).
1162        if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
1163        {
1164            let changed = !tier2.stale;
1165            tier2.stale = true;
1166            return changed;
1167        }
1168        false
1169    }
1170
1171    /// Refresh the cached Tier-2 + todos counts for the status bar. Each count
1172    /// is `Option`: `None` preserves the last-known value (the category wasn't
1173    /// recomputed or has no real aggregate yet) so we never overwrite a real
1174    /// count with a fabricated `0`. `stale` marks the Tier-2 numbers as
1175    /// not-yet-reconciled with the latest edits.
1176    pub fn update_status_bar_tier2(
1177        &self,
1178        dead_code: Option<usize>,
1179        unused_exports: Option<usize>,
1180        duplicates: Option<usize>,
1181        todos: Option<usize>,
1182        stale: bool,
1183    ) {
1184        let mut tier2 = self
1185            .status_bar_tier2
1186            .write()
1187            .unwrap_or_else(std::sync::PoisonError::into_inner);
1188        if let Some(dead_code) = dead_code {
1189            tier2.dead_code = Some(dead_code);
1190        }
1191        if let Some(unused_exports) = unused_exports {
1192            tier2.unused_exports = Some(unused_exports);
1193        }
1194        if let Some(duplicates) = duplicates {
1195            tier2.duplicates = Some(duplicates);
1196        }
1197        if let Some(todos) = todos {
1198            tier2.todos = Some(todos);
1199        }
1200        tier2.stale = stale;
1201    }
1202
1203    /// Borrow the cached project gitignore matcher. Returns `None` when no
1204    /// project_root is configured or when the project has no gitignore files.
1205    pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
1206        self.gitignore
1207            .read()
1208            .unwrap_or_else(|poisoned| poisoned.into_inner())
1209            .clone()
1210    }
1211
1212    /// Shared gitignore matcher handle for the watcher filter thread.
1213    pub fn shared_gitignore(&self) -> SharedGitignore {
1214        Arc::clone(&self.gitignore)
1215    }
1216
1217    /// Monotonic generation bumped after every matcher rebuild/clear. The
1218    /// watcher filter thread uses it to wait until the main thread has rebuilt
1219    /// ignore rules after it reports an ignore-file change.
1220    pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
1221        Arc::clone(&self.gitignore_generation)
1222    }
1223
1224    fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
1225        *self
1226            .gitignore
1227            .write()
1228            .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
1229        self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
1230    }
1231
1232    /// Rebuild the gitignore matcher from the current `project_root` and
1233    /// cache it. Called by the configure handler whenever the project root
1234    /// changes, and by the watcher event drain when a `.gitignore` file
1235    /// itself is modified.
1236    ///
1237    /// The builder honors:
1238    /// - `<project_root>/.gitignore`
1239    /// - Git's global excludes file (the same source used by `ignore::WalkBuilder`)
1240    /// - the repository's real `info/exclude` file, resolved through Git's
1241    ///   common dir for linked worktrees
1242    /// - nested `.gitignore` files (each `.gitignore` discovered during
1243    ///   the recursive walk)
1244    ///
1245    /// Stores `None` if there's no project_root or no matchable gitignore
1246    /// files. Logs build errors but never fails configure.
1247    /// Clear any cached gitignore matcher without rebuilding.
1248    ///
1249    /// Used by `handle_configure` in degraded mode (e.g. `project_root == $HOME`)
1250    /// where running the gitignore-discovery walk would exceed the configure
1251    /// budget. The watcher event filter falls back to the hardcoded infra-dir
1252    /// skip list when no matcher is present.
1253    pub fn clear_gitignore(&self) {
1254        self.set_gitignore(None);
1255    }
1256
1257    pub fn rebuild_gitignore(&self) {
1258        use ignore::gitignore::GitignoreBuilder;
1259        use std::path::Path;
1260        let root_raw = match self.config().project_root.clone() {
1261            Some(r) => r,
1262            None => {
1263                self.set_gitignore(None);
1264                return;
1265            }
1266        };
1267        // Canonicalize the root so symlink-prefix mismatches don't cause
1268        // `Gitignore::matched_path_or_any_parents` to panic on watcher event
1269        // paths. macOS routinely surfaces `/private/var/...` while `project_root`
1270        // arrives as `/var/...` (a symlink to `/private/var`); the `ignore`
1271        // crate's matcher panics when a query path isn't lexically under the
1272        // matcher's root. Canonicalizing both ends (here for root, naturally
1273        // for watcher events on macOS) keeps them in the same prefix space.
1274        let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
1275        let mut builder = GitignoreBuilder::new(&root);
1276        // Git's global excludes file — keep the live watcher matcher aligned
1277        // with the project walkers (`WalkBuilder::git_global(true)`). The
1278        // ignore crate exposes the same path discovery it uses internally, so
1279        // this handles the default XDG location and configured excludesFile.
1280        if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
1281            if global_ignore.is_file() {
1282                if let Some(err) = builder.add(&global_ignore) {
1283                    crate::slog_warn!(
1284                        "global gitignore parse error in {}: {}",
1285                        global_ignore.display(),
1286                        err
1287                    );
1288                }
1289            }
1290        }
1291        // Add root .gitignore (the most common case)
1292        let root_ignore = Path::new(&root).join(".gitignore");
1293        if root_ignore.exists() {
1294            if let Some(err) = builder.add(&root_ignore) {
1295                crate::slog_warn!(
1296                    "gitignore parse error in {}: {}",
1297                    root_ignore.display(),
1298                    err
1299                );
1300            }
1301        }
1302        // Root .aftignore — AFT-specific ignores layered on top of .gitignore.
1303        // Lets users exclude paths git can't (e.g. submodules) from AFT's
1304        // walks/indexes. Honored by the watcher matcher too, so edits under an
1305        // aftignored path don't trigger reindexing.
1306        let root_aftignore = Path::new(&root).join(".aftignore");
1307        if root_aftignore.exists() {
1308            if let Some(err) = builder.add(&root_aftignore) {
1309                crate::slog_warn!(
1310                    "aftignore parse error in {}: {}",
1311                    root_aftignore.display(),
1312                    err
1313                );
1314            }
1315        }
1316        // .git/info/exclude — manually added because GitignoreBuilder::new()
1317        // does not auto-discover it (verified against ignore-0.4.25 source).
1318        // In linked worktrees this lives under the repository common dir, not
1319        // under `<worktree>/.git/info/exclude` (where `.git` is only a file).
1320        let info_exclude = self
1321            .git_common_dir
1322            .lock()
1323            .clone()
1324            .unwrap_or_else(|| Path::new(&root).join(".git"))
1325            .join("info")
1326            .join("exclude");
1327        if info_exclude.exists() {
1328            if let Some(err) = builder.add(&info_exclude) {
1329                crate::slog_warn!(
1330                    "gitignore parse error in {}: {}",
1331                    info_exclude.display(),
1332                    err
1333                );
1334            }
1335        }
1336        // Walk the project to pick up nested .gitignore/.aftignore files at
1337        // arbitrary depth. The main project walkers honor deeply nested ignore
1338        // files, so the watcher matcher must do the same or live invalidation
1339        // can disagree with startup indexing. Skip obvious infra dirs so we
1340        // don't accidentally load a vendored repo's ignore file as ours.
1341        let walker = ignore::WalkBuilder::new(&root)
1342            .standard_filters(true)
1343            // Hidden files are filtered by default, but `.gitignore` starts with
1344            // `.` so we need to traverse "hidden" entries to find nested ones.
1345            // No `max_depth`: nested `.gitignore`/`.aftignore` files are honored
1346            // at arbitrary depth (see configure_watcher_honors_deep_nested_aftignore).
1347            // The walk is pruned by standard gitignore filters plus the infra
1348            // skip below; configure never runs this against `$HOME` (guarded by
1349            // `home_match`), and tests use bounded roots rather than `/`.
1350            .hidden(false)
1351            .filter_entry(|entry| {
1352                let name = entry.file_name().to_string_lossy();
1353                !matches!(
1354                    name.as_ref(),
1355                    "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
1356                )
1357            })
1358            .build();
1359        for entry in walker.flatten() {
1360            let file_name = entry.file_name();
1361            let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
1362            let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
1363            if is_nested_gitignore || is_nested_aftignore {
1364                if let Some(err) = builder.add(entry.path()) {
1365                    crate::slog_warn!(
1366                        "nested ignore parse error in {}: {}",
1367                        entry.path().display(),
1368                        err
1369                    );
1370                }
1371            }
1372        }
1373        match builder.build() {
1374            Ok(gi) => {
1375                let count = gi.num_ignores();
1376                if count > 0 {
1377                    crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
1378                    self.set_gitignore(Some(Arc::new(gi)));
1379                } else {
1380                    self.set_gitignore(None);
1381                }
1382            }
1383            Err(err) => {
1384                crate::slog_warn!("gitignore matcher build failed: {}", err);
1385                self.set_gitignore(None);
1386            }
1387        }
1388    }
1389
1390    /// Shared atomic mirror of `experimental.bash.compress`. Updated by the
1391    /// configure handler. Read by the BgTaskRegistry compressor closure.
1392    pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
1393        Arc::clone(&self.bash_compress_flag)
1394    }
1395
1396    /// Update the shared `bash_compress_flag` mirror. Call this from the
1397    /// configure handler whenever `experimental.bash.compress` changes so the
1398    /// BgTaskRegistry watchdog sees the new value on the next completion.
1399    pub fn sync_bash_compress_flag(&self) {
1400        let value = self.config().experimental_bash_compress;
1401        self.bash_compress_flag
1402            .store(value, std::sync::atomic::Ordering::Relaxed);
1403    }
1404
1405    pub fn set_bash_compress_enabled(&self, enabled: bool) {
1406        self.update_config(|config| {
1407            config.experimental_bash_compress = enabled;
1408        });
1409        self.bash_compress_flag
1410            .store(enabled, std::sync::atomic::Ordering::Relaxed);
1411    }
1412
1413    /// Read-only access to the TOML filter registry, building it lazily on
1414    /// first use. Returns an `RwLockReadGuard` that callers can `lookup`
1415    /// against directly.
1416    pub fn filter_registry(
1417        &self,
1418    ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
1419        self.ensure_filter_registry_loaded();
1420        match self.filter_registry.read() {
1421            Ok(g) => g,
1422            Err(poisoned) => poisoned.into_inner(),
1423        }
1424    }
1425
1426    /// Returns the shared `Arc<RwLock<FilterRegistry>>` handle so threads
1427    /// outside `AppContext` (notably the bash watchdog) can read it without
1428    /// touching the rest of the context.
1429    pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
1430        self.ensure_filter_registry_loaded();
1431        Arc::clone(&self.filter_registry)
1432    }
1433
1434    /// Force a fresh load of the TOML filter registry. Called when configure
1435    /// changes the project root, storage_dir, or trust state so subsequent
1436    /// `compress::compress` calls pick up new filters.
1437    pub fn reset_filter_registry(&self) {
1438        let new_registry = crate::compress::build_registry_for_context(self);
1439        self.filter_registry_rebuild_count
1440            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1441        match self.filter_registry.write() {
1442            Ok(mut slot) => *slot = new_registry,
1443            Err(poisoned) => *poisoned.into_inner() = new_registry,
1444        }
1445        self.filter_registry_loaded
1446            .store(true, std::sync::atomic::Ordering::Release);
1447    }
1448
1449    fn ensure_filter_registry_loaded(&self) {
1450        use std::sync::atomic::Ordering;
1451        if self.filter_registry_loaded.load(Ordering::Acquire) {
1452            return;
1453        }
1454        // Build outside the lock to avoid blocking other readers during a
1455        // multi-file TOML parse.
1456        let new_registry = crate::compress::build_registry_for_context(self);
1457        self.filter_registry_rebuild_count
1458            .fetch_add(1, Ordering::SeqCst);
1459        if let Ok(mut slot) = self.filter_registry.write() {
1460            *slot = new_registry;
1461            self.filter_registry_loaded.store(true, Ordering::Release);
1462        }
1463    }
1464
1465    #[cfg(test)]
1466    pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
1467        self.filter_registry_rebuild_count.load(Ordering::SeqCst)
1468    }
1469
1470    pub fn app(&self) -> Arc<App> {
1471        Arc::clone(&self.app)
1472    }
1473
1474    /// Clone the LSP child registry handle. Used by main.rs to give the
1475    /// signal handler thread a way to SIGKILL LSP children on shutdown.
1476    pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1477        self.app.lsp_child_registry()
1478    }
1479
1480    pub fn stdout_writer(&self) -> SharedStdoutWriter {
1481        self.app.stdout_writer()
1482    }
1483
1484    pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
1485        if let Ok(mut progress_sender) = self.progress_sender.lock() {
1486            *progress_sender = sender;
1487        }
1488    }
1489
1490    pub fn emit_progress(&self, frame: ProgressFrame) {
1491        let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
1492            return;
1493        };
1494        if let Some(sender) = progress_sender.as_ref() {
1495            sender(PushFrame::Progress(frame));
1496        }
1497    }
1498
1499    pub fn status_emitter(&self) -> &StatusEmitter {
1500        &self.status_emitter
1501    }
1502
1503    /// Get a clone of the current progress sender for use from background
1504    /// threads. Returns `None` when the main loop hasn't installed one (tests,
1505    /// CLI without push frames).
1506    ///
1507    /// Used by `configure`'s deferred file-walk thread to push warnings after
1508    /// configure has already returned, so configure latency stays sub-100 ms
1509    /// even on huge directories.
1510    pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
1511        self.progress_sender
1512            .lock()
1513            .ok()
1514            .and_then(|sender| sender.clone())
1515    }
1516
1517    pub fn advance_configure_generation(&self) -> u64 {
1518        self.configure_generation
1519            .fetch_add(1, Ordering::SeqCst)
1520            .wrapping_add(1)
1521    }
1522
1523    /// Record the warm-maintenance key for a successful configure and return
1524    /// the generation this configure operates under.
1525    ///
1526    /// An unchanged key ADOPTS the running generation without advancing it:
1527    /// in-flight build workers gate their publish on the generation flag being
1528    /// unchanged, so advancing on an equivalent rebind would silently discard
1529    /// every adopted build's result at completion (the receiver never
1530    /// resolves, and long builds can never finish under rebind traffic). Only
1531    /// a genuinely different warm config advances the generation, which is
1532    /// what cancels superseded in-flight builds.
1533    pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
1534        let mut state = self.configure_warm_state.lock();
1535        let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
1536        let generation = if equivalent {
1537            self.configure_generation()
1538        } else {
1539            self.advance_configure_generation()
1540        };
1541        state.generation = generation;
1542        state.key = Some(key);
1543        (generation, equivalent)
1544    }
1545
1546    pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
1547        self.configured_session_roots
1548            .lock()
1549            .insert((root, session_id))
1550    }
1551
1552    /// Undo [`Self::note_configure_session_binding`] when the maintenance job
1553    /// carrying the session's bash replay was dropped as stale: the session has
1554    /// not actually been replayed, so its next bind must count as first again.
1555    pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
1556        self.configured_session_roots
1557            .lock()
1558            .remove(&(root.to_path_buf(), session_id.to_string()));
1559    }
1560
1561    pub(crate) fn enqueue_configure_maintenance(&self, job: ConfigureMaintenanceJob) {
1562        self.configure_maintenance_jobs.lock().push_back(job);
1563    }
1564
1565    pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
1566        self.configure_maintenance_jobs.lock().drain(..).collect()
1567    }
1568
1569    pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
1570        let mut keys = self.artifact_cache_keys.lock();
1571        if let Some(key) = keys.get(canonical_root).cloned() {
1572            return key;
1573        }
1574        let key = crate::search_index::artifact_cache_key(canonical_root);
1575        self.artifact_cache_key_derivations
1576            .fetch_add(1, Ordering::SeqCst);
1577        keys.insert(canonical_root.to_path_buf(), key.clone());
1578        key
1579    }
1580
1581    #[cfg(test)]
1582    pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
1583        self.artifact_cache_key_derivations.load(Ordering::SeqCst)
1584    }
1585
1586    pub fn configure_generation(&self) -> u64 {
1587        self.configure_generation.load(Ordering::SeqCst)
1588    }
1589
1590    pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
1591        Arc::clone(&self.configure_generation)
1592    }
1593
1594    pub fn configure_warnings_sender(
1595        &self,
1596    ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
1597        self.configure_warnings_tx.clone()
1598    }
1599
1600    pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
1601        let mut warnings = Vec::new();
1602        while let Ok(warning) = self.configure_warnings_rx.try_recv() {
1603            warnings.push(warning);
1604        }
1605        warnings
1606    }
1607
1608    pub fn bash_background(&self) -> &BgTaskRegistry {
1609        &self.bash_background
1610    }
1611
1612    pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
1613        self.bash_background.drain_completions()
1614    }
1615
1616    /// Access the language provider.
1617    pub fn provider(&self) -> &dyn LanguageProvider {
1618        self.provider.as_ref()
1619    }
1620
1621    /// Access the backup store.
1622    pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
1623        &self.backup
1624    }
1625
1626    /// Access the checkpoint store.
1627    pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
1628        &self.checkpoint
1629    }
1630
1631    pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1632        self.app.set_db(conn);
1633    }
1634
1635    pub fn clear_db(&self) {
1636        self.app.clear_db();
1637    }
1638
1639    pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1640        self.app.db()
1641    }
1642
1643    /// Access an owned configuration snapshot.
1644    pub fn config(&self) -> Arc<Config> {
1645        let guard = match self.config.read() {
1646            Ok(guard) => guard,
1647            Err(poisoned) => poisoned.into_inner(),
1648        };
1649        Arc::clone(&*guard)
1650    }
1651
1652    /// Atomically publish a fully-built configuration snapshot.
1653    pub fn set_config(&self, config: Config) {
1654        let next = Arc::new(config);
1655        match self.config.write() {
1656            Ok(mut guard) => *guard = next,
1657            Err(poisoned) => *poisoned.into_inner() = next,
1658        }
1659    }
1660
1661    /// Clone-mutate-publish the current configuration without returning a guard.
1662    pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
1663        let mut next = self.config().as_ref().clone();
1664        update(&mut next);
1665        self.set_config(next);
1666    }
1667
1668    pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
1669        let mut requests = self.force_restrict_requests.lock();
1670        *requests.entry(req_id.to_string()).or_insert(0) += 1;
1671        ForceRestrictGuard {
1672            ctx: self,
1673            req_id: req_id.to_string(),
1674        }
1675    }
1676
1677    pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
1678        let _guard = self.force_restrict_guard(req_id);
1679        f()
1680    }
1681
1682    pub fn request_force_restrict(&self, req_id: &str) -> bool {
1683        self.force_restrict_requests.lock().contains_key(req_id)
1684    }
1685
1686    fn release_force_restrict(&self, req_id: &str) {
1687        let mut requests = self.force_restrict_requests.lock();
1688        match requests.get_mut(req_id) {
1689            Some(count) if *count > 1 => *count -= 1,
1690            Some(_) => {
1691                requests.remove(req_id);
1692            }
1693            None => {}
1694        }
1695    }
1696
1697    pub fn set_harness(&self, harness: Harness) {
1698        self.bash_background.set_harness(harness.clone());
1699        *self.harness.lock() = Some(harness);
1700    }
1701
1702    pub fn harness_opt(&self) -> Option<Harness> {
1703        self.harness.lock().clone()
1704    }
1705
1706    pub fn harness(&self) -> Harness {
1707        self.harness_opt()
1708            .expect("harness set by configure before any tool call")
1709    }
1710
1711    pub fn storage_dir(&self) -> PathBuf {
1712        crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
1713    }
1714
1715    pub fn harness_dir(&self) -> PathBuf {
1716        self.storage_dir().join(self.harness().storage_segment())
1717    }
1718
1719    pub fn inspect_dir(&self) -> PathBuf {
1720        self.harness_dir().join("inspect")
1721    }
1722
1723    pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
1724        self.harness_dir()
1725            .join("bash-tasks")
1726            .join(hash_session(session_id))
1727    }
1728
1729    pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
1730        self.harness_dir()
1731            .join("backups")
1732            .join(hash_session(session_id))
1733            .join(path_hash)
1734    }
1735
1736    pub fn filters_dir(&self) -> PathBuf {
1737        self.harness_dir().join("filters")
1738    }
1739
1740    /// HOST-GLOBAL — NOT under harness_dir. Read by trust.rs across both harnesses.
1741    pub fn trust_file(&self) -> PathBuf {
1742        self.storage_dir().join("trusted-filter-projects.json")
1743    }
1744
1745    pub fn set_canonical_cache_root(&self, root: PathBuf) {
1746        debug_assert!(root.is_absolute());
1747        *self.canonical_cache_root.lock() = Some(root);
1748    }
1749
1750    pub fn canonical_cache_root(&self) -> PathBuf {
1751        self.canonical_cache_root
1752            .lock()
1753            .clone()
1754            .expect("canonical_cache_root accessed before handle_configure")
1755    }
1756
1757    pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
1758        self.canonical_cache_root.lock().clone()
1759    }
1760
1761    pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
1762        *self.is_worktree_bridge.lock() = is_worktree_bridge;
1763        *self.git_common_dir.lock() = git_common_dir;
1764    }
1765
1766    pub fn set_artifact_owner(
1767        &self,
1768        status: Option<ArtifactOwnerStatus>,
1769        lease: Option<ArtifactOwnerLease>,
1770    ) {
1771        let read_only = status
1772            .as_ref()
1773            .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
1774        *self.shared_artifacts_read_only.lock() = read_only;
1775        *self.artifact_owner_status.lock() = status;
1776        *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
1777    }
1778
1779    pub fn shared_artifacts_read_only(&self) -> bool {
1780        self.is_worktree_bridge() || *self.shared_artifacts_read_only.lock()
1781    }
1782
1783    pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
1784        self.artifact_owner_status.lock().clone()
1785    }
1786
1787    pub fn is_worktree_bridge(&self) -> bool {
1788        *self.is_worktree_bridge.lock()
1789    }
1790
1791    pub fn git_common_dir(&self) -> Option<PathBuf> {
1792        self.git_common_dir.lock().clone()
1793    }
1794
1795    /// Replace the current degraded-mode reasons. Empty vec = full-featured
1796    /// mode (no degradation). Called by `handle_configure` after deciding
1797    /// which subsystems to disable for this project root.
1798    pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
1799        *self.degraded_reasons.lock() = reasons;
1800    }
1801
1802    pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
1803        self.heavy_root_work_allowed
1804            .store(allowed, Ordering::SeqCst);
1805    }
1806
1807    pub fn heavy_root_work_allowed(&self) -> bool {
1808        self.heavy_root_work_allowed.load(Ordering::SeqCst)
1809    }
1810
1811    pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
1812        let reason = reason.into();
1813        let mut reasons = self.degraded_reasons.lock();
1814        if reasons.iter().any(|existing| existing == &reason) {
1815            return false;
1816        }
1817        reasons.push(reason);
1818        true
1819    }
1820
1821    /// Snapshot of current degraded-mode reasons. Order is stable
1822    /// (insertion order from `set_degraded_reasons`) so UI rendering and
1823    /// snapshot diffs are deterministic.
1824    pub fn degraded_reasons(&self) -> Vec<String> {
1825        self.degraded_reasons.lock().clone()
1826    }
1827
1828    /// True iff at least one degraded reason is recorded.
1829    pub fn is_degraded(&self) -> bool {
1830        !self.degraded_reasons.lock().is_empty()
1831    }
1832
1833    pub fn cache_role(&self) -> &'static str {
1834        if self.canonical_cache_root.lock().is_none() {
1835            "not_initialized"
1836        } else if self.is_worktree_bridge() {
1837            "worktree"
1838        } else if *self.shared_artifacts_read_only.lock() {
1839            "read_only"
1840        } else {
1841            "main"
1842        }
1843    }
1844
1845    /// Access the persisted call graph store.
1846    pub fn callgraph_store(&self) -> &RwLock<Option<Arc<CallGraphStore>>> {
1847        &self.callgraph_store
1848    }
1849
1850    pub fn mark_callgraph_store_force_rebuild(&self) {
1851        *self.callgraph_store_force_rebuild.lock() = true;
1852    }
1853
1854    fn take_callgraph_store_force_rebuild(&self) -> bool {
1855        let mut force = self.callgraph_store_force_rebuild.lock();
1856        let was_forced = *force;
1857        *force = false;
1858        was_forced
1859    }
1860
1861    pub fn callgraph_store_dir(&self) -> PathBuf {
1862        match self.harness_opt() {
1863            Some(harness) => self
1864                .storage_dir()
1865                .join(harness.storage_segment())
1866                .join("callgraph"),
1867            None => self.storage_dir().join("callgraph"),
1868        }
1869    }
1870
1871    pub fn ensure_callgraph_store(
1872        &self,
1873    ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1874        self.ensure_callgraph_store_with_flag(true)
1875    }
1876
1877    fn ensure_callgraph_store_with_flag(
1878        &self,
1879        respect_config_flag: bool,
1880    ) -> Result<Option<Arc<CallGraphStore>>, CallGraphStoreError> {
1881        if respect_config_flag && !self.config().callgraph_store {
1882            return Ok(None);
1883        }
1884        if !self.heavy_root_work_allowed() {
1885            return Ok(None);
1886        }
1887        if let Some(store) = {
1888            let guard = self
1889                .callgraph_store
1890                .read()
1891                .unwrap_or_else(std::sync::PoisonError::into_inner);
1892            guard.as_ref().map(Arc::clone)
1893        } {
1894            return Ok(Some(store));
1895        }
1896
1897        let Some(project_root) = self.callgraph_project_root() else {
1898            return Ok(None);
1899        };
1900        let callgraph_dir = self.callgraph_store_dir();
1901        let force_rebuild = self.take_callgraph_store_force_rebuild();
1902        let store = if self.is_worktree_bridge() {
1903            CallGraphStore::open_readonly(callgraph_dir, project_root)?
1904        } else if force_rebuild {
1905            let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1906            let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
1907                callgraph_dir,
1908                project_root,
1909                &files,
1910                self.config().callgraph_chunk_size,
1911            )?;
1912            Some(store)
1913        } else if CallGraphStore::needs_cold_build(&callgraph_dir, &project_root)? {
1914            let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
1915            let (store, _stats) = CallGraphStore::ensure_built_with_lease_chunked(
1916                callgraph_dir,
1917                project_root,
1918                &files,
1919                self.config().callgraph_chunk_size,
1920            )?;
1921            Some(store)
1922        } else {
1923            Some(CallGraphStore::open(callgraph_dir, project_root)?)
1924        };
1925
1926        let Some(store) = store else {
1927            return Ok(None);
1928        };
1929        let store = Arc::new(store);
1930        {
1931            let mut guard = self
1932                .callgraph_store
1933                .write()
1934                .unwrap_or_else(std::sync::PoisonError::into_inner);
1935            *guard = Some(Arc::clone(&store));
1936        }
1937        Ok(Some(store))
1938    }
1939
1940    /// Resolve the project root used for the callgraph store: prefer the
1941    /// canonical cache root, falling back to the configured project root.
1942    fn callgraph_project_root(&self) -> Option<PathBuf> {
1943        self.canonical_cache_root_opt().or_else(|| {
1944            self.config()
1945                .project_root
1946                .clone()
1947                .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
1948        })
1949    }
1950
1951    /// Access the persisted callgraph store for the five store-backed edge-query
1952    /// ops **without ever blocking the request thread on a cold build**.
1953    ///
1954    /// - Store resident          -> `Ready`.
1955    /// - Warm on-disk DB present  -> opened synchronously (cheap) -> `Ready`.
1956    /// - Genuine cold build needed -> kicked off in the background, returns
1957    ///   `Building`; the watcher keeps the store fresh once it lands.
1958    /// - Worktree without a built store, or not configured -> `Unavailable`.
1959    ///
1960    /// A build already in flight (`callgraph_store_rx` set) also returns
1961    /// `Building` without starting a second build.
1962    /// Drop the resident callgraph store when another process (or a local cold
1963    /// rebuild) has published a newer generation, so the next access reopens via
1964    /// the pointer. No-op when no store is resident, a build is in flight, or the
1965    /// store is still current. Must run before serving ops AND before any
1966    /// incremental write, so every process converges on the current generation
1967    /// rather than writing to a stale one.
1968    pub fn revalidate_callgraph_store_generation(&self) {
1969        // Never disturb the store while a background build's result is pending
1970        // install (the rx-install path replaces it wholesale).
1971        if self.callgraph_store_rx.lock().is_some() {
1972            return;
1973        }
1974        let superseded = {
1975            let guard = self
1976                .callgraph_store
1977                .read()
1978                .unwrap_or_else(std::sync::PoisonError::into_inner);
1979            guard.as_ref().is_some_and(|store| !store.is_current())
1980        };
1981        if superseded {
1982            let mut guard = self
1983                .callgraph_store
1984                .write()
1985                .unwrap_or_else(std::sync::PoisonError::into_inner);
1986            *guard = None;
1987        }
1988    }
1989
1990    pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
1991        if !self.heavy_root_work_allowed() {
1992            return CallgraphStoreAccess::Unavailable;
1993        }
1994
1995        // Converge to a newer generation another process (or a local cold
1996        // rebuild) may have published: if our resident store is superseded, drop
1997        // it so the open path below reopens via the pointer. Cheap pointer read.
1998        self.revalidate_callgraph_store_generation();
1999        if let Some(store) = {
2000            let guard = self
2001                .callgraph_store
2002                .read()
2003                .unwrap_or_else(std::sync::PoisonError::into_inner);
2004            guard.as_ref().map(Arc::clone)
2005        } {
2006            return CallgraphStoreAccess::Ready(store);
2007        }
2008
2009        // A background build is already running; don't start a second one.
2010        if self.callgraph_store_rx.lock().is_some() {
2011            return CallgraphStoreAccess::Building;
2012        }
2013
2014        let Some(project_root) = self.callgraph_project_root() else {
2015            return CallgraphStoreAccess::Unavailable;
2016        };
2017        let callgraph_dir = self.callgraph_store_dir();
2018
2019        // Worktree bridges are read-only: open whatever the main checkout built,
2020        // never cold-build here.
2021        if self.is_worktree_bridge() {
2022            match CallGraphStore::open_readonly(callgraph_dir, project_root) {
2023                Ok(Some(store)) => {
2024                    let store = Arc::new(store);
2025                    {
2026                        let mut guard = self
2027                            .callgraph_store
2028                            .write()
2029                            .unwrap_or_else(std::sync::PoisonError::into_inner);
2030                        *guard = Some(Arc::clone(&store));
2031                    }
2032                    return CallgraphStoreAccess::Ready(store);
2033                }
2034                Ok(None) | Err(_) => return CallgraphStoreAccess::Unavailable,
2035            }
2036        }
2037
2038        let force_rebuild = *self.callgraph_store_force_rebuild.lock();
2039        // Warm path: a fresh on-disk DB exists -> open synchronously (cheap, no
2040        // "building" delay). Only a genuine cold build goes to the background.
2041        if !force_rebuild {
2042            match CallGraphStore::needs_cold_build(&callgraph_dir, &project_root) {
2043                Ok(false) => match CallGraphStore::open(callgraph_dir, project_root) {
2044                    Ok(store) => {
2045                        let store = Arc::new(store);
2046                        {
2047                            let mut guard = self
2048                                .callgraph_store
2049                                .write()
2050                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2051                            *guard = Some(Arc::clone(&store));
2052                        }
2053                        return CallgraphStoreAccess::Ready(store);
2054                    }
2055                    Err(error) => return CallgraphStoreAccess::Error(error),
2056                },
2057                Ok(true) => {}
2058                Err(error) => return CallgraphStoreAccess::Error(error),
2059            }
2060        }
2061
2062        if self.semantic_cold_seed_active() {
2063            self.defer_callgraph_store_warm_for_semantic_cold_seed();
2064            return CallgraphStoreAccess::Building;
2065        }
2066
2067        // Cold build required: run it off the request thread and return
2068        // `Building` so the agent retries (the watcher keeps the store fresh
2069        // once it lands). By default this never blocks the request thread.
2070        //
2071        // `AFT_CALLGRAPH_BUILD_WAIT_MS` (default 0) optionally waits a bounded
2072        // window inline for the build to land before returning `Building`; tests
2073        // set it large so fixture builds resolve to `Ready` synchronously.
2074        if !self.spawn_callgraph_store_cold_build(project_root, callgraph_dir, force_rebuild) {
2075            return CallgraphStoreAccess::Building;
2076        }
2077
2078        let wait = callgraph_build_wait_window();
2079        if !wait.is_zero() {
2080            let received = {
2081                let rx_ref = self.callgraph_store_rx.lock();
2082                let Some(rx) = rx_ref.as_ref() else {
2083                    return CallgraphStoreAccess::Building;
2084                };
2085                rx.recv_timeout(wait)
2086            };
2087            match received {
2088                Ok(store) => {
2089                    // Replay any source files the watcher saw during the wait so
2090                    // the installed store reflects mid-build edits (mirrors the
2091                    // drain install path). Empty in the common case.
2092                    let pending = self.take_pending_callgraph_store_paths();
2093                    if !pending.is_empty() {
2094                        if let Err(error) = store.refresh_files(&pending) {
2095                            crate::slog_warn!(
2096                                "callgraph store inline post-build refresh failed: {}",
2097                                error
2098                            );
2099                            let _ = store.mark_files_stale(&pending);
2100                        }
2101                    }
2102                    let store = Arc::new(store);
2103                    {
2104                        let mut guard = self
2105                            .callgraph_store
2106                            .write()
2107                            .unwrap_or_else(std::sync::PoisonError::into_inner);
2108                        *guard = Some(Arc::clone(&store));
2109                    }
2110                    *self.callgraph_store_rx.lock() = None;
2111                    return CallgraphStoreAccess::Ready(store);
2112                }
2113                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
2114                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
2115                    // Build failed before sending; clear the receiver so a later
2116                    // op restarts the build instead of waiting on a dead channel.
2117                    *self.callgraph_store_rx.lock() = None;
2118                }
2119            }
2120        }
2121        CallgraphStoreAccess::Building
2122    }
2123
2124    /// Atomically mark a cold build in-flight and spawn the background builder.
2125    ///
2126    /// The `callgraph_store_rx` lock covers the full check + receiver install +
2127    /// thread spawn sequence, so concurrent cold callers cannot both observe an
2128    /// empty in-flight slot and double-spawn builders. Returns `false` when
2129    /// another caller already has a build in flight.
2130    fn spawn_callgraph_store_cold_build(
2131        &self,
2132        project_root: PathBuf,
2133        callgraph_dir: PathBuf,
2134        force_rebuild: bool,
2135    ) -> bool {
2136        if !self.heavy_root_work_allowed() {
2137            return false;
2138        }
2139
2140        let session_id = crate::log_ctx::current_session();
2141        let chunk_size = self.config().callgraph_chunk_size;
2142        let build_generation = self.configure_generation();
2143        let generation_flag = self.configure_generation_flag();
2144
2145        let mut rx_guard = self.callgraph_store_rx.lock();
2146        if rx_guard.is_some() {
2147            return false;
2148        }
2149
2150        let Some(permit) = crate::cold_build_limiter::try_acquire() else {
2151            crate::slog_info!(
2152                "callgraph store cold build deferred by cold build limit ({})",
2153                crate::cold_build_limiter::limit()
2154            );
2155            return false;
2156        };
2157
2158        if force_rebuild {
2159            // Consume the force flag now so a follow-up request doesn't queue a
2160            // second forced build while this one is in flight.
2161            self.take_callgraph_store_force_rebuild();
2162        }
2163        let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStore>();
2164        *rx_guard = Some(rx);
2165
2166        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
2167
2168        std::thread::spawn(move || {
2169            let _permit = permit;
2170            crate::log_ctx::with_session(session_id, || {
2171                let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
2172                let built = if force_rebuild {
2173                    CallGraphStore::cold_build_with_lease_chunked(
2174                        callgraph_dir,
2175                        project_root,
2176                        &files,
2177                        chunk_size,
2178                    )
2179                    .map(|(store, _)| store)
2180                } else {
2181                    CallGraphStore::ensure_built_with_lease_chunked(
2182                        callgraph_dir,
2183                        project_root,
2184                        &files,
2185                        chunk_size,
2186                    )
2187                    .map(|(store, _)| store)
2188                };
2189                match built {
2190                    Ok(store) => {
2191                        if generation_flag.load(Ordering::SeqCst) == build_generation {
2192                            let _ = tx.send(store);
2193                        } else {
2194                            crate::slog_info!(
2195                                "callgraph store warm build result discarded for stale generation {}",
2196                                build_generation
2197                            );
2198                        }
2199                    }
2200                    Err(error) => {
2201                        crate::slog_warn!("callgraph store cold build failed: {}", error);
2202                        // Dropping tx disconnects the channel; the drain clears
2203                        // the receiver so a later op can retry the build.
2204                    }
2205                }
2206            });
2207        });
2208        true
2209    }
2210
2211    /// Access the callgraph-store background-build receiver (drained by the
2212    /// main loop once the cold build completes).
2213    pub fn callgraph_store_rx(
2214        &self,
2215    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStore>>> {
2216        &self.callgraph_store_rx
2217    }
2218
2219    /// Record source-file paths that changed while a cold build was in flight,
2220    /// so they can be refreshed once the freshly-built store is installed.
2221    pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
2222    where
2223        I: IntoIterator<Item = PathBuf>,
2224    {
2225        self.pending_callgraph_store_paths.lock().extend(paths);
2226    }
2227
2228    /// Take and clear the paths that changed during a background cold build.
2229    pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
2230        std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
2231            .into_iter()
2232            .collect()
2233    }
2234
2235    /// Access the search index.
2236    pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
2237        &self.search_index
2238    }
2239
2240    /// Access the search-index build receiver.
2241    pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
2242        &self.search_index_rx
2243    }
2244
2245    pub fn add_pending_search_index_paths<I>(&self, paths: I)
2246    where
2247        I: IntoIterator<Item = PathBuf>,
2248    {
2249        self.pending_search_index_paths.lock().extend(paths);
2250    }
2251
2252    pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
2253        std::mem::take(&mut *self.pending_search_index_paths.lock())
2254            .into_iter()
2255            .collect()
2256    }
2257
2258    pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
2259    where
2260        I: IntoIterator<Item = PathBuf>,
2261    {
2262        self.pending_semantic_index_paths.lock().extend(paths);
2263    }
2264
2265    pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
2266        std::mem::take(&mut *self.pending_semantic_index_paths.lock())
2267            .into_iter()
2268            .collect()
2269    }
2270
2271    pub fn mark_pending_semantic_corpus_refresh(&self) {
2272        *self.pending_semantic_corpus_refresh.lock() = true;
2273    }
2274
2275    pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
2276        std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
2277    }
2278
2279    pub fn clear_pending_index_updates(&self) {
2280        self.pending_search_index_paths.lock().clear();
2281        self.pending_callgraph_store_paths.lock().clear();
2282        self.pending_tier2_paths.lock().clear();
2283        self.pending_semantic_index_paths.lock().clear();
2284        *self.pending_semantic_corpus_refresh.lock() = false;
2285    }
2286
2287    /// Flush the owner-side trigram delta during an orderly transport shutdown.
2288    /// EOF/Goodbye teardown uses this best-effort path; signal and panic exits
2289    /// intentionally skip it so abrupt shutdown never waits on slow recovery work.
2290    #[doc(hidden)]
2291    pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
2292        if self.shared_artifacts_read_only() {
2293            return false;
2294        }
2295
2296        crate::runtime_drain::drain_watcher_events(self);
2297        crate::runtime_drain::drain_search_index_events(self);
2298
2299        if self
2300            .search_index_rx()
2301            .read()
2302            .unwrap_or_else(std::sync::PoisonError::into_inner)
2303            .is_some()
2304        {
2305            return false;
2306        }
2307
2308        let Some(canonical_root) = self.canonical_cache_root_opt() else {
2309            return false;
2310        };
2311        let config = self.config();
2312        let cache_dir =
2313            crate::search_index::resolve_cache_dir(&canonical_root, config.storage_dir.as_deref());
2314
2315        {
2316            let search_index = self
2317                .search_index()
2318                .read()
2319                .unwrap_or_else(std::sync::PoisonError::into_inner);
2320            let Some(index) = search_index.as_ref() else {
2321                return false;
2322            };
2323            if !index.ready || !index.has_pending_disk_changes() {
2324                return false;
2325            }
2326        }
2327
2328        let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(&cache_dir)
2329        {
2330            Ok(lock) => lock,
2331            Err(error) => {
2332                crate::slog_warn!(
2333                    "search index: skipped shutdown flush because cache lock was unavailable: {}",
2334                    error
2335                );
2336                return false;
2337            }
2338        };
2339
2340        let mut search_index = self
2341            .search_index()
2342            .write()
2343            .unwrap_or_else(std::sync::PoisonError::into_inner);
2344        let Some(index) = search_index.as_mut() else {
2345            return false;
2346        };
2347        if !index.ready || !index.has_pending_disk_changes() {
2348            return false;
2349        }
2350
2351        let git_head = index.stored_git_head().map(str::to_owned);
2352        index.write_to_disk(&cache_dir, git_head.as_deref());
2353        true
2354    }
2355
2356    pub fn inspect_manager(&self) -> Arc<InspectManager> {
2357        Arc::clone(&self.inspect_manager)
2358    }
2359
2360    pub fn add_pending_tier2_paths<I>(&self, paths: I)
2361    where
2362        I: IntoIterator<Item = PathBuf>,
2363    {
2364        self.pending_tier2_paths.lock().extend(paths);
2365    }
2366
2367    pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
2368        self.pending_tier2_paths.lock().iter().cloned().collect()
2369    }
2370
2371    pub fn remove_pending_tier2_paths<I>(&self, paths: I)
2372    where
2373        I: IntoIterator<Item = PathBuf>,
2374    {
2375        let mut pending = self.pending_tier2_paths.lock();
2376        for path in paths {
2377            pending.remove(&path);
2378        }
2379    }
2380
2381    /// Returns true when one or more watcher-driven (reuse-path) Tier-2 scans
2382    /// have completed since the last call, advancing the last-seen marker. The
2383    /// per-request inspect drain uses this to refresh the status bar after a
2384    /// background scan — those completions bypass `drain_completions`.
2385    pub fn take_new_reuse_completions(&self) -> bool {
2386        let current = self.inspect_manager.reuse_completion_count();
2387        let previous = self
2388            .last_seen_reuse_completions
2389            .swap(current, Ordering::SeqCst);
2390        current != previous
2391    }
2392
2393    pub fn reset_tier2_refresh_scheduler(&self) {
2394        self.reset_tier2_refresh_scheduler_at(Instant::now());
2395    }
2396
2397    #[doc(hidden)]
2398    pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
2399        self.tier2_refresh_scheduler
2400            .lock()
2401            .reset_after_configure(now);
2402    }
2403
2404    pub fn request_tier2_refresh_pull(&self) -> bool {
2405        let can_schedule = !self.is_worktree_bridge() && self.heavy_root_work_allowed();
2406        self.tier2_refresh_scheduler
2407            .lock()
2408            .request_pull(can_schedule)
2409    }
2410
2411    pub fn tick_tier2_refresh_scheduler(
2412        &self,
2413        changed_path_count: usize,
2414    ) -> Option<Tier2TriggerReason> {
2415        self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
2416    }
2417
2418    #[doc(hidden)]
2419    pub fn tick_tier2_refresh_scheduler_at(
2420        &self,
2421        now: Instant,
2422        changed_path_count: usize,
2423    ) -> Option<Tier2TriggerReason> {
2424        let manager = self.inspect_manager();
2425        let can_write = !self.is_worktree_bridge() && self.heavy_root_work_allowed();
2426        let in_flight = manager.tier2_any_in_flight();
2427        let semantic_cold_seed_active = self.semantic_cold_seed_active();
2428        let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
2429            now,
2430            changed_path_count,
2431            can_write,
2432            in_flight,
2433            semantic_cold_seed_active,
2434        );
2435
2436        if let Some(reason) = decision {
2437            self.start_tier2_refresh(reason, manager);
2438        }
2439
2440        decision
2441    }
2442
2443    pub fn note_tier2_refresh_started(&self) {
2444        self.note_tier2_refresh_started_at(Instant::now());
2445    }
2446
2447    #[doc(hidden)]
2448    pub fn note_tier2_refresh_started_at(&self, now: Instant) {
2449        self.tier2_refresh_scheduler
2450            .lock()
2451            .note_external_scan_started(now);
2452    }
2453
2454    pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
2455        self.tier2_refresh_scheduler
2456            .lock()
2457            .last_trigger_reason()
2458            .map(Tier2TriggerReason::as_str)
2459    }
2460
2461    #[doc(hidden)]
2462    pub fn tier2_pull_demand_pending(&self) -> bool {
2463        self.tier2_refresh_scheduler.lock().pull_demand_pending()
2464    }
2465
2466    fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
2467        if self.is_worktree_bridge()
2468            || !self.heavy_root_work_allowed()
2469            || !self.config().inspect.enabled
2470        {
2471            return;
2472        }
2473        let Some(snapshot) = self.tier2_refresh_snapshot() else {
2474            return;
2475        };
2476        let categories = InspectCategory::active()
2477            .iter()
2478            .copied()
2479            .filter(|category| category.is_tier2())
2480            .collect::<Vec<_>>();
2481        let submission =
2482            manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
2483        if !submission.deferred_categories.is_empty() {
2484            self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
2485            crate::slog_info!(
2486                "tier2 refresh deferred by cold build limit: categories={:?}",
2487                submission
2488                    .deferred_categories
2489                    .iter()
2490                    .map(|category| category.as_str())
2491                    .collect::<Vec<_>>()
2492            );
2493        }
2494        if submission.has_new_work() {
2495            crate::slog_info!(
2496                "tier2 refresh scheduled: reason={}, categories={:?}",
2497                reason.as_str(),
2498                submission
2499                    .newly_queued_categories
2500                    .iter()
2501                    .map(|category| category.as_str())
2502                    .collect::<Vec<_>>()
2503            );
2504        }
2505        for error in submission.errors {
2506            crate::slog_warn!(
2507                "tier2 refresh schedule failed for {}: {}",
2508                error.category,
2509                error.message
2510            );
2511        }
2512    }
2513
2514    fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
2515        self.harness_opt()?;
2516        let config = self.config();
2517        let project_root = config
2518            .project_root
2519            .clone()
2520            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2521        let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
2522        Some(InspectSnapshot::new(
2523            project_root,
2524            self.inspect_dir(),
2525            config,
2526            self.symbol_cache(),
2527        ))
2528    }
2529
2530    /// Access the shared symbol cache.
2531    pub fn symbol_cache(&self) -> SharedSymbolCache {
2532        Arc::clone(&self.symbol_cache)
2533    }
2534
2535    /// Clear the shared symbol cache and return the new active generation.
2536    pub fn reset_symbol_cache(&self) -> u64 {
2537        self.symbol_cache
2538            .write()
2539            .map(|mut cache| cache.reset())
2540            .unwrap_or(0)
2541    }
2542
2543    /// Access the semantic search index.
2544    pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
2545        &self.semantic_index
2546    }
2547
2548    /// Access the semantic-index build receiver.
2549    pub fn semantic_index_rx(
2550        &self,
2551    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
2552        &self.semantic_index_rx
2553    }
2554
2555    pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
2556        &self.semantic_index_status
2557    }
2558
2559    /// Reset this context's cold semantic seed gate for a newly accepted
2560    /// configure and return the generation token for the worker being spawned.
2561    pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
2562        self.semantic_cold_seed_active
2563            .store(false, Ordering::SeqCst);
2564        self.semantic_callgraph_warm_deferred
2565            .store(false, Ordering::SeqCst);
2566        self.semantic_cold_seed_generation
2567            .fetch_add(1, Ordering::SeqCst)
2568            .wrapping_add(1)
2569    }
2570
2571    pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
2572        Arc::clone(&self.semantic_cold_seed_active)
2573    }
2574
2575    pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
2576        Arc::clone(&self.semantic_cold_seed_generation)
2577    }
2578
2579    pub fn semantic_cold_seed_active(&self) -> bool {
2580        self.semantic_cold_seed_active.load(Ordering::SeqCst)
2581    }
2582
2583    pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
2584        self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
2585    }
2586
2587    pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
2588        self.semantic_callgraph_warm_deferred
2589            .store(true, Ordering::SeqCst);
2590    }
2591
2592    fn semantic_callgraph_warm_deferred(&self) -> bool {
2593        self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
2594    }
2595
2596    /// Clear the cold-seed gate and resume work that was intentionally held back
2597    /// while the full semantic corpus was accumulating. This entry point is used
2598    /// by the code that drains events from the semantic worker.
2599    pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
2600        self.resume_semantic_cold_seed_deferred_work(false);
2601    }
2602
2603    /// Resume work after the semantic worker has already cleared the atomic gate
2604    /// itself, such as on cached-index load or before a retry backoff sleep.
2605    pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
2606        self.resume_semantic_cold_seed_deferred_work(true);
2607    }
2608
2609    fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
2610        let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
2611        let had_deferred_callgraph = self.semantic_callgraph_warm_deferred();
2612
2613        if force || was_active || had_deferred_callgraph {
2614            let _ = self.request_tier2_refresh_pull();
2615        }
2616
2617        if self
2618            .semantic_callgraph_warm_deferred
2619            .swap(false, Ordering::SeqCst)
2620        {
2621            if !self.config().callgraph_store || !self.heavy_root_work_allowed() {
2622                return;
2623            }
2624
2625            match self.callgraph_store_for_ops() {
2626                CallgraphStoreAccess::Ready(_) => {
2627                    crate::slog_debug!(
2628                        "deferred callgraph store warm completed after semantic cold seed gate cleared"
2629                    );
2630                }
2631                CallgraphStoreAccess::Building => {
2632                    crate::slog_info!(
2633                        "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
2634                    );
2635                }
2636                CallgraphStoreAccess::Unavailable => {
2637                    crate::slog_info!(
2638                        "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
2639                    );
2640                }
2641                CallgraphStoreAccess::Error(error) => {
2642                    crate::slog_warn!(
2643                        "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
2644                        error
2645                    );
2646                }
2647            }
2648        }
2649    }
2650
2651    #[doc(hidden)]
2652    pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
2653        self.semantic_cold_seed_active
2654            .store(active, Ordering::SeqCst);
2655    }
2656
2657    #[doc(hidden)]
2658    pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
2659        self.semantic_callgraph_warm_deferred()
2660    }
2661
2662    pub fn install_semantic_refresh_worker(
2663        &self,
2664        sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
2665        event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
2666        worker_slot: SemanticRefreshWorkerSlot,
2667    ) {
2668        self.clear_semantic_refresh_worker();
2669        *self.semantic_refresh_tx.lock() = Some(sender);
2670        *self.semantic_refresh_event_rx.lock() = Some(event_rx);
2671        *self.semantic_refresh_worker.lock() = Some(worker_slot);
2672    }
2673
2674    pub fn clear_semantic_refresh_worker(&self) {
2675        *self.semantic_refresh_tx.lock() = None;
2676        *self.semantic_refresh_event_rx.lock() = None;
2677        if let Some(worker_slot) = self.semantic_refresh_worker.lock().take() {
2678            if let Ok(mut handle) = worker_slot.lock() {
2679                drop(handle.take());
2680            }
2681        }
2682    }
2683
2684    pub fn semantic_refresh_sender(
2685        &self,
2686    ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
2687        self.semantic_refresh_tx.lock().clone()
2688    }
2689
2690    pub fn semantic_refresh_event_rx(
2691        &self,
2692    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
2693        &self.semantic_refresh_event_rx
2694    }
2695
2696    pub fn with_semantic_refresh_retry_attempts_mut<R>(
2697        &self,
2698        f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
2699    ) -> R {
2700        let mut attempts = self.semantic_refresh_retry_attempts.lock();
2701        f(&mut attempts)
2702    }
2703
2704    pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
2705        let mut attempts = self.semantic_refresh_retry_attempts.lock();
2706        for path in paths {
2707            attempts.remove(path);
2708        }
2709    }
2710
2711    pub fn clear_all_semantic_refresh_retry_attempts(&self) {
2712        self.semantic_refresh_retry_attempts.lock().clear();
2713    }
2714
2715    pub fn semantic_refresh_circuit_is_open(&self) -> bool {
2716        self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
2717    }
2718
2719    pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
2720        let failures = self
2721            .semantic_refresh_circuit
2722            .consecutive_transient_failures
2723            .fetch_add(1, Ordering::SeqCst)
2724            .saturating_add(1);
2725        if failures >= trip_threshold
2726            && !self
2727                .semantic_refresh_circuit
2728                .open
2729                .swap(true, Ordering::SeqCst)
2730        {
2731            crate::slog_warn!(
2732                "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
2733            );
2734        }
2735        self.semantic_refresh_circuit_is_open()
2736    }
2737
2738    pub fn reset_semantic_refresh_transient_failure_count(&self) {
2739        self.semantic_refresh_circuit
2740            .consecutive_transient_failures
2741            .store(0, Ordering::SeqCst);
2742    }
2743
2744    pub fn reset_semantic_refresh_circuit_after_success(&self) {
2745        self.reset_semantic_refresh_transient_failure_count();
2746        self.semantic_refresh_circuit
2747            .probe_ready
2748            .store(false, Ordering::SeqCst);
2749        if self
2750            .semantic_refresh_circuit
2751            .open
2752            .swap(false, Ordering::SeqCst)
2753        {
2754            crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
2755        }
2756    }
2757
2758    pub fn semantic_refresh_transient_failure_count(&self) -> usize {
2759        self.semantic_refresh_circuit
2760            .consecutive_transient_failures
2761            .load(Ordering::SeqCst)
2762    }
2763
2764    pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
2765        self.semantic_refresh_circuit
2766            .probe_in_flight
2767            .load(Ordering::SeqCst)
2768            || self
2769                .semantic_refresh_circuit
2770                .probe_ready
2771                .load(Ordering::SeqCst)
2772    }
2773
2774    pub fn take_semantic_refresh_probe_ready(&self) -> bool {
2775        self.semantic_refresh_circuit
2776            .probe_ready
2777            .swap(false, Ordering::SeqCst)
2778    }
2779
2780    pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
2781        if self
2782            .semantic_refresh_circuit
2783            .probe_ready
2784            .load(Ordering::SeqCst)
2785        {
2786            return;
2787        }
2788        if self
2789            .semantic_refresh_circuit
2790            .probe_in_flight
2791            .swap(true, Ordering::SeqCst)
2792        {
2793            return;
2794        }
2795        if self
2796            .semantic_refresh_circuit
2797            .probe_ready
2798            .load(Ordering::SeqCst)
2799        {
2800            self.semantic_refresh_circuit
2801                .probe_in_flight
2802                .store(false, Ordering::SeqCst);
2803            return;
2804        }
2805
2806        let circuit = Arc::clone(&self.semantic_refresh_circuit);
2807        let session_id = crate::log_ctx::current_session();
2808        std::thread::spawn(move || {
2809            crate::log_ctx::with_session(session_id, || {
2810                std::thread::sleep(delay);
2811                circuit.probe_ready.store(true, Ordering::SeqCst);
2812                circuit.probe_in_flight.store(false, Ordering::SeqCst);
2813            });
2814        });
2815    }
2816
2817    /// Access the cached semantic embedding model.
2818    pub fn semantic_embedding_model(
2819        &self,
2820    ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
2821        &self.semantic_embedding_model
2822    }
2823
2824    /// Access the file watcher handle (kept alive to continue watching).
2825    pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
2826        &self.watcher
2827    }
2828
2829    /// Access the pre-filtered watcher event receiver.
2830    pub fn watcher_rx(
2831        &self,
2832    ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
2833        &self.watcher_rx
2834    }
2835
2836    /// Install a watcher filter thread and its dispatch receiver. The caller
2837    /// must have stopped any previous watcher runtime first.
2838    pub fn install_watcher_runtime(
2839        &self,
2840        rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
2841        runtime: WatcherThreadHandle,
2842    ) {
2843        *self.watcher_rx.lock() = Some(rx);
2844        *self.watcher_thread.lock() = Some(runtime);
2845    }
2846
2847    /// Stop the watcher filter thread (if any) and clear the dispatch receiver.
2848    /// Used on reconfigure, watcher failure, root deletion, and test teardown.
2849    pub fn stop_watcher_runtime(&self) {
2850        if let Some(runtime) = self.watcher_thread.lock().take() {
2851            runtime.shutdown_and_join();
2852        }
2853        *self.watcher_rx.lock() = None;
2854        *self.watcher.lock() = None;
2855    }
2856
2857    /// Access the LSP manager.
2858    pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
2859        self.lsp_manager.lock()
2860    }
2861
2862    /// Notify LSP servers that a file was written.
2863    /// Call this after write_format_validate in command handlers.
2864    pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
2865        let config = self.config();
2866        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2867            if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
2868                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2869            }
2870        }
2871    }
2872
2873    /// Drop cached LSP diagnostics for a deleted/renamed-away file so its
2874    /// errors/warnings don't linger in the warm set (no server republishes for
2875    /// a vanished path), keeping the status bar and `aft_inspect` honest.
2876    /// Returns true if any entry was removed. Best-effort: a contended borrow is
2877    /// skipped silently (the watcher drain retries on subsequent events).
2878    pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
2879        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2880            lsp.clear_diagnostics_for_file(file_path)
2881        } else {
2882            false
2883        }
2884    }
2885
2886    /// Mark diagnostics stale for a file changed outside AFT's text-sync path.
2887    /// Best-effort: a contended LSP lock is skipped and the next watcher event
2888    /// or scoped diagnostics pull can reconcile the file.
2889    pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
2890        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2891            lsp.mark_diagnostics_stale_for_file(file_path)
2892        } else {
2893            StaleDiagnosticsMark::default()
2894        }
2895    }
2896
2897    /// Resync a watcher-stale diagnosed file with the active LSP server.
2898    ///
2899    /// `workspace/didChangeWatchedFiles` tells servers that the filesystem
2900    /// changed, but it does not update an already-open document's in-memory text.
2901    /// Sending the normal didOpen/didChange path gives push-only servers a chance
2902    /// to publish fresh diagnostics and keeps pull-capable servers' document state
2903    /// current for the next diagnostic request.
2904    pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
2905        if !file_path.is_file() {
2906            return false;
2907        }
2908
2909        let content = match std::fs::read_to_string(file_path) {
2910            Ok(content) => content,
2911            Err(err) => {
2912                crate::slog_warn!(
2913                    "skipping LSP resync for {} after external edit: {}",
2914                    file_path.display(),
2915                    err
2916                );
2917                return false;
2918            }
2919        };
2920
2921        let config = self.config();
2922        if let Some(mut lsp) = self.lsp_manager.try_lock() {
2923            if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
2924                crate::slog_warn!(
2925                    "LSP resync failed for {} after external edit: {}",
2926                    file_path.display(),
2927                    err
2928                );
2929                return false;
2930            }
2931            true
2932        } else {
2933            false
2934        }
2935    }
2936
2937    /// Notify LSP and optionally wait for diagnostics.
2938    ///
2939    /// Call this after `write_format_validate` when the request has `"diagnostics": true`.
2940    /// Sends didChange to the server, waits briefly for publishDiagnostics, and returns
2941    /// any diagnostics for the file. If no server is running, returns empty immediately.
2942    ///
2943    /// v0.17.3: this is the version-aware path. Pre-edit cached diagnostics
2944    /// are NEVER returned — only entries whose `version` matches the
2945    /// post-edit document version (or, for unversioned servers, whose
2946    /// `epoch` advanced past the pre-edit snapshot).
2947    pub fn lsp_notify_and_collect_diagnostics(
2948        &self,
2949        file_path: &Path,
2950        content: &str,
2951        timeout: std::time::Duration,
2952    ) -> crate::lsp::manager::PostEditWaitOutcome {
2953        let config = self.config();
2954        let Some(mut lsp) = self.lsp_manager.try_lock() else {
2955            return crate::lsp::manager::PostEditWaitOutcome::default();
2956        };
2957
2958        // Clear any queued notifications before this write so the wait loop only
2959        // observes diagnostics triggered by the current change.
2960        lsp.drain_events();
2961
2962        // Snapshot per-server epochs and document versions BEFORE sending
2963        // didChange so the wait loop can prove freshness without accepting
2964        // stale pre-edit publishes that arrived late.
2965        let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
2966
2967        // Send didChange/didOpen and capture per-server target version.
2968        let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
2969        {
2970            Ok(v) => v,
2971            Err(e) => {
2972                crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
2973                return crate::lsp::manager::PostEditWaitOutcome::default();
2974            }
2975        };
2976
2977        // No server matched this file — return an empty outcome that's
2978        // honestly `complete: true` (nothing to wait for).
2979        if expected_versions.is_empty() {
2980            return crate::lsp::manager::PostEditWaitOutcome::default();
2981        }
2982
2983        lsp.wait_for_post_edit_diagnostics(
2984            file_path,
2985            &config,
2986            &expected_versions,
2987            &pre_snapshot,
2988            timeout,
2989        )
2990    }
2991
2992    /// Collect custom server root_markers from user config for use in
2993    /// `is_config_file_path_with_custom` checks (#25).
2994    fn custom_lsp_root_markers(&self) -> Vec<String> {
2995        self.config()
2996            .lsp_servers
2997            .iter()
2998            .flat_map(|s| s.root_markers.iter().cloned())
2999            .collect()
3000    }
3001
3002    fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
3003        let custom_markers = self.custom_lsp_root_markers();
3004        let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
3005            .iter()
3006            .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
3007            .cloned()
3008            .map(|path| {
3009                let change_type = if path.exists() {
3010                    FileChangeType::CHANGED
3011                } else {
3012                    FileChangeType::DELETED
3013                };
3014                (path, change_type)
3015            })
3016            .collect();
3017
3018        self.notify_watched_config_events(&config_paths);
3019    }
3020
3021    fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
3022        let paths = params
3023            .get("multi_file_write_paths")
3024            .and_then(|value| value.as_array())?
3025            .iter()
3026            .filter_map(|value| value.as_str())
3027            .map(PathBuf::from)
3028            .collect::<Vec<_>>();
3029
3030        (!paths.is_empty()).then_some(paths)
3031    }
3032
3033    /// Parse config-file watched events from `multi_file_write_paths` when the
3034    /// array contains object entries `{ "path": "...", "type": "created|changed|deleted" }`.
3035    ///
3036    /// This handles the OBJECT variant of `multi_file_write_paths`. The STRING
3037    /// variant (bare path strings) is handled by `multi_file_write_paths()` and
3038    /// `notify_watched_config_files()`. Both variants read the same JSON key but
3039    /// with different per-entry schemas — they are NOT redundant.
3040    ///
3041    /// #18 note: in older code this function also existed alongside `multi_file_write_paths()`
3042    /// and was reachable via the `else if` branch when all entries were objects.
3043    /// Restoring both is correct.
3044    fn watched_file_events_from_params(
3045        params: &serde_json::Value,
3046        extra_markers: &[String],
3047    ) -> Option<Vec<(PathBuf, FileChangeType)>> {
3048        let events = params
3049            .get("multi_file_write_paths")
3050            .and_then(|value| value.as_array())?
3051            .iter()
3052            .filter_map(|entry| {
3053                // Only handle object entries — string entries go through multi_file_write_paths()
3054                let path = entry
3055                    .get("path")
3056                    .and_then(|value| value.as_str())
3057                    .map(PathBuf::from)?;
3058
3059                if !is_config_file_path_with_custom(&path, extra_markers) {
3060                    return None;
3061                }
3062
3063                let change_type = entry
3064                    .get("type")
3065                    .and_then(|value| value.as_str())
3066                    .and_then(Self::parse_file_change_type)
3067                    .unwrap_or_else(|| Self::change_type_from_current_state(&path));
3068
3069                Some((path, change_type))
3070            })
3071            .collect::<Vec<_>>();
3072
3073        (!events.is_empty()).then_some(events)
3074    }
3075
3076    fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
3077        match value {
3078            "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
3079            "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
3080            "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
3081            _ => None,
3082        }
3083    }
3084
3085    fn change_type_from_current_state(path: &Path) -> FileChangeType {
3086        if path.exists() {
3087            FileChangeType::CHANGED
3088        } else {
3089            FileChangeType::DELETED
3090        }
3091    }
3092
3093    fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
3094        if config_paths.is_empty() {
3095            return;
3096        }
3097
3098        let config = self.config();
3099        if let Some(mut lsp) = self.lsp_manager.try_lock() {
3100            if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
3101                crate::slog_warn!("watched-file sync error: {}", e);
3102            }
3103        }
3104    }
3105
3106    pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
3107        let custom_markers = self.custom_lsp_root_markers();
3108        if !is_config_file_path_with_custom(file_path, &custom_markers) {
3109            return;
3110        }
3111
3112        self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
3113    }
3114
3115    /// Post-write LSP hook for multi-file edits. When the patch includes
3116    /// config-file edits, notify active workspace servers via
3117    /// `workspace/didChangeWatchedFiles` before sending the per-document
3118    /// didOpen/didChange for the current file.
3119    pub fn lsp_post_multi_file_write(
3120        &self,
3121        file_path: &Path,
3122        content: &str,
3123        file_paths: &[PathBuf],
3124        params: &serde_json::Value,
3125    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
3126        self.notify_watched_config_files(file_paths);
3127        self.add_pending_tier2_paths(file_paths.iter().cloned());
3128        let _ = self.mark_status_bar_tier2_stale();
3129
3130        let wants_diagnostics = params
3131            .get("diagnostics")
3132            .and_then(|v| v.as_bool())
3133            .unwrap_or(false);
3134
3135        if !wants_diagnostics {
3136            self.lsp_notify_file_changed(file_path, content);
3137            return None;
3138        }
3139
3140        let wait_ms = params
3141            .get("wait_ms")
3142            .and_then(|v| v.as_u64())
3143            .unwrap_or(3000)
3144            .min(10_000);
3145
3146        Some(self.lsp_notify_and_collect_diagnostics(
3147            file_path,
3148            content,
3149            std::time::Duration::from_millis(wait_ms),
3150        ))
3151    }
3152
3153    /// Post-write LSP hook: notify server and optionally collect diagnostics.
3154    ///
3155    /// This is the single call site for all command handlers after `write_format_validate`.
3156    /// Behavior:
3157    /// - When `diagnostics: true` is in `params`, notifies the server, waits
3158    ///   until matching diagnostics arrive or the timeout expires, and returns
3159    ///   `Some(outcome)` with the verified-fresh diagnostics + per-server
3160    ///   status.
3161    /// - When `diagnostics: false` (or absent), just notifies (fire-and-forget)
3162    ///   and returns `None`. Callers must NOT wrap this in `Some(...)`; the
3163    ///   `None` is what tells the response builder to omit the LSP fields
3164    ///   entirely (preserves the no-diagnostics-requested response shape).
3165    ///
3166    /// v0.17.3: default `wait_ms` raised from 1500 to 3000 because real-world
3167    /// tsserver re-analysis on monorepo files routinely takes 2-5s. Still
3168    /// capped at 10000ms.
3169    pub fn lsp_post_write(
3170        &self,
3171        file_path: &Path,
3172        content: &str,
3173        params: &serde_json::Value,
3174    ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
3175        let wants_diagnostics = params
3176            .get("diagnostics")
3177            .and_then(|v| v.as_bool())
3178            .unwrap_or(false);
3179
3180        let custom_markers = self.custom_lsp_root_markers();
3181        if let Some(file_paths) = Self::multi_file_write_paths(params) {
3182            self.add_pending_tier2_paths(file_paths);
3183        } else {
3184            self.add_pending_tier2_paths([file_path.to_path_buf()]);
3185        }
3186        let _ = self.mark_status_bar_tier2_stale();
3187
3188        if !wants_diagnostics {
3189            if let Some(file_paths) = Self::multi_file_write_paths(params) {
3190                self.notify_watched_config_files(&file_paths);
3191            } else if let Some(config_events) =
3192                Self::watched_file_events_from_params(params, &custom_markers)
3193            {
3194                self.notify_watched_config_events(&config_events);
3195            }
3196            self.lsp_notify_file_changed(file_path, content);
3197            return None;
3198        }
3199
3200        let wait_ms = params
3201            .get("wait_ms")
3202            .and_then(|v| v.as_u64())
3203            .unwrap_or(3000)
3204            .min(10_000); // Cap at 10 seconds to prevent hangs from adversarial input
3205
3206        if let Some(file_paths) = Self::multi_file_write_paths(params) {
3207            return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
3208        }
3209
3210        if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
3211        {
3212            self.notify_watched_config_events(&config_events);
3213        }
3214
3215        Some(self.lsp_notify_and_collect_diagnostics(
3216            file_path,
3217            content,
3218            std::time::Duration::from_millis(wait_ms),
3219        ))
3220    }
3221
3222    /// Validate that a file path falls within the configured project root.
3223    ///
3224    /// When `project_root` is configured (normal plugin usage), this resolves the
3225    /// path and checks it starts with the root. Returns the canonicalized path on
3226    /// success, or an error response on violation.
3227    ///
3228    /// When no `project_root` is configured (direct CLI usage), all paths pass
3229    /// through unrestricted for backward compatibility.
3230    pub fn validate_path(
3231        &self,
3232        req_id: &str,
3233        path: &Path,
3234    ) -> Result<std::path::PathBuf, crate::protocol::Response> {
3235        let config = self.config();
3236        let force_restrict = self.request_force_restrict(req_id);
3237        let enforce = config.restrict_to_project_root || force_restrict;
3238        // When no restriction is configured or forced (the default standalone
3239        // path), preserve the historical passthrough behavior exactly.
3240        if !enforce {
3241            return Ok(path.to_path_buf());
3242        }
3243        let root = match &config.project_root {
3244            Some(r) => r.clone(),
3245            None if force_restrict => {
3246                return Err(crate::protocol::Response::error(
3247                    req_id,
3248                    "path_outside_root",
3249                    "project root is required when path restriction is forced",
3250                ));
3251            }
3252            None => return Ok(path.to_path_buf()), // No root configured, allow all
3253        };
3254        drop(config);
3255
3256        // Keep the raw root for symlink-guard comparisons. On macOS, tempdir()
3257        // returns /var/... paths while canonicalize gives /private/var/...; we
3258        // need both forms so reject_escaping_symlink can recognise in-root
3259        // symlinks regardless of which prefix form `current` happens to have.
3260        let raw_root = root.clone();
3261        let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
3262
3263        // Resolve the path (follow symlinks, normalize ..). If canonicalization
3264        // fails (e.g. path does not exist or traverses a broken symlink), inspect
3265        // every existing component with lstat before falling back lexically so a
3266        // broken in-root symlink cannot be used to write outside project_root.
3267        let path_for_resolution = if path.is_relative() {
3268            raw_root.join(path)
3269        } else {
3270            path.to_path_buf()
3271        };
3272        let resolved = match std::fs::canonicalize(&path_for_resolution) {
3273            Ok(resolved) => resolved,
3274            Err(_) => {
3275                let normalized = normalize_path(&path_for_resolution);
3276                reject_escaping_symlink(
3277                    req_id,
3278                    &path_for_resolution,
3279                    &normalized,
3280                    &resolved_root,
3281                    &raw_root,
3282                )?;
3283                resolve_with_existing_ancestors(&normalized)
3284            }
3285        };
3286
3287        if !resolved.starts_with(&resolved_root) {
3288            return Err(path_error_response(req_id, path, &resolved_root));
3289        }
3290
3291        Ok(resolved)
3292    }
3293
3294    /// Count active LSP server instances.
3295    pub fn lsp_server_count(&self) -> usize {
3296        self.lsp_manager
3297            .try_lock()
3298            .map(|lsp| lsp.server_count())
3299            .unwrap_or(0)
3300    }
3301
3302    /// Symbol cache statistics from the language provider.
3303    pub fn symbol_cache_stats(&self) -> serde_json::Value {
3304        let entries = self
3305            .symbol_cache
3306            .read()
3307            .map(|cache| cache.len())
3308            .unwrap_or(0);
3309        serde_json::json!({
3310            "local_entries": entries,
3311            "warm_entries": 0,
3312        })
3313    }
3314}
3315
3316#[cfg(test)]
3317mod force_restrict_tests {
3318    use super::*;
3319    use crate::language::StubProvider;
3320    use tempfile::TempDir;
3321
3322    fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
3323        AppContext::new(
3324            Box::new(StubProvider),
3325            Config {
3326                project_root,
3327                restrict_to_project_root,
3328                ..Config::default()
3329            },
3330        )
3331    }
3332
3333    #[test]
3334    fn standalone_validate_path_parity_without_force_restrict() {
3335        let root = TempDir::new().expect("root tempdir");
3336        let outside = TempDir::new().expect("outside tempdir");
3337        let outside_path = outside.path().join("outside.txt");
3338
3339        let unrestricted = test_context(Some(root.path().to_path_buf()), false);
3340        assert_eq!(
3341            unrestricted
3342                .validate_path("standalone-unrestricted", &outside_path)
3343                .expect("unrestricted standalone validates"),
3344            outside_path
3345        );
3346
3347        let restricted = test_context(Some(root.path().to_path_buf()), true);
3348        let err = restricted
3349            .validate_path("standalone-restricted", &outside_path)
3350            .expect_err("restricted standalone rejects outside root");
3351        assert_eq!(
3352            serde_json::to_value(err).unwrap()["code"],
3353            "path_outside_root"
3354        );
3355    }
3356
3357    #[test]
3358    fn force_restrict_guard_refcounts_duplicate_request_ids() {
3359        let root = TempDir::new().expect("root tempdir");
3360        let outside = TempDir::new().expect("outside tempdir");
3361        let outside_path = outside.path().join("outside.txt");
3362        let ctx = test_context(Some(root.path().to_path_buf()), false);
3363
3364        assert!(ctx.validate_path("dup", &outside_path).is_ok());
3365        let guard1 = ctx.force_restrict_guard("dup");
3366        let guard2 = ctx.force_restrict_guard("dup");
3367        assert!(ctx.validate_path("dup", &outside_path).is_err());
3368        drop(guard1);
3369        assert!(
3370            ctx.validate_path("dup", &outside_path).is_err(),
3371            "duplicate guard must keep the request over-restricted"
3372        );
3373        drop(guard2);
3374        assert!(ctx.validate_path("dup", &outside_path).is_ok());
3375    }
3376
3377    #[test]
3378    fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
3379        let root = TempDir::new().expect("root tempdir");
3380        let outside = TempDir::new().expect("outside tempdir");
3381        let outside_path = outside.path().join("outside.txt");
3382        let ctx = test_context(Some(root.path().to_path_buf()), false);
3383
3384        ctx.with_force_restrict("normal", || {
3385            assert!(ctx.validate_path("normal", &outside_path).is_err());
3386        });
3387        assert!(!ctx.request_force_restrict("normal"));
3388        assert!(ctx.validate_path("normal", &outside_path).is_ok());
3389
3390        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3391            ctx.with_force_restrict("panic", || {
3392                assert!(ctx.validate_path("panic", &outside_path).is_err());
3393                panic!("intentional force-restrict cleanup panic");
3394            });
3395        }));
3396        assert!(panicked.is_err());
3397        assert!(!ctx.request_force_restrict("panic"));
3398        assert!(ctx.validate_path("panic", &outside_path).is_ok());
3399    }
3400
3401    #[test]
3402    fn forced_restrict_without_project_root_fails_closed() {
3403        let ctx = test_context(None, false);
3404        let _guard = ctx.force_restrict_guard("missing-root");
3405        let err = ctx
3406            .validate_path("missing-root", Path::new("relative.txt"))
3407            .expect_err("forced restriction without a root must fail closed");
3408        assert_eq!(
3409            serde_json::to_value(err).unwrap()["code"],
3410            "path_outside_root"
3411        );
3412    }
3413}
3414
3415#[cfg(test)]
3416mod callgraph_store_for_ops_tests {
3417    use super::*;
3418    use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
3419    use crate::parser::TreeSitterProvider;
3420    use crate::protocol::RawRequest;
3421    use serde_json::json;
3422    use std::ffi::OsString;
3423    use std::path::Path;
3424    use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
3425    use tempfile::TempDir;
3426
3427    struct CallgraphWaitWindowEnvGuard {
3428        _guard: MutexGuard<'static, ()>,
3429        previous: Option<OsString>,
3430    }
3431
3432    impl Drop for CallgraphWaitWindowEnvGuard {
3433        fn drop(&mut self) {
3434            // SAFETY: serialized by the process-local guard held for this
3435            // helper's lifetime, and restored before the guard is released.
3436            unsafe {
3437                match &self.previous {
3438                    Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
3439                    None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
3440                }
3441            }
3442        }
3443    }
3444
3445    fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
3446        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
3447        let guard = LOCK
3448            .get_or_init(|| StdMutex::new(()))
3449            .lock()
3450            .unwrap_or_else(|error| error.into_inner());
3451        let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
3452        // SAFETY: serialized by LOCK above and restored by the returned guard.
3453        unsafe {
3454            std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", "0");
3455        }
3456        CallgraphWaitWindowEnvGuard {
3457            _guard: guard,
3458            previous,
3459        }
3460    }
3461
3462    fn cold_build_context() -> Arc<AppContext> {
3463        let project = TempDir::new().expect("project tempdir");
3464        let storage = TempDir::new().expect("storage tempdir");
3465        let source_dir = project.path().join("src");
3466        std::fs::create_dir_all(&source_dir).expect("source dir");
3467        std::fs::write(
3468            source_dir.join("lib.rs"),
3469            "pub fn caller() { callee(); }\npub fn callee() {}\n",
3470        )
3471        .expect("source file");
3472
3473        Arc::new(AppContext::new(
3474            Box::new(TreeSitterProvider::new()),
3475            Config {
3476                project_root: Some(project.keep()),
3477                storage_dir: Some(storage.keep()),
3478                callgraph_chunk_size: 1,
3479                ..Config::default()
3480            },
3481        ))
3482    }
3483
3484    fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
3485        let _guard = crate::test_env::process_env_lock();
3486        let prev_home = std::env::var_os("HOME");
3487        let prev_userprofile = std::env::var_os("USERPROFILE");
3488        unsafe {
3489            std::env::set_var("HOME", home);
3490            std::env::set_var("USERPROFILE", home);
3491        }
3492        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
3493        unsafe {
3494            match prev_home {
3495                Some(value) => std::env::set_var("HOME", value),
3496                None => std::env::remove_var("HOME"),
3497            }
3498            match prev_userprofile {
3499                Some(value) => std::env::set_var("USERPROFILE", value),
3500                None => std::env::remove_var("USERPROFILE"),
3501            }
3502        }
3503        match result {
3504            Ok(value) => value,
3505            Err(payload) => std::panic::resume_unwind(payload),
3506        }
3507    }
3508
3509    fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
3510        RawRequest {
3511            id: "cfg".to_string(),
3512            command: "configure".to_string(),
3513            lsp_hints: None,
3514            session_id: None,
3515            params,
3516        }
3517    }
3518
3519    fn user_tier(doc: serde_json::Value) -> serde_json::Value {
3520        json!({
3521            "tier": "user",
3522            "source": "/u/aft.jsonc",
3523            "doc": doc.to_string(),
3524        })
3525    }
3526
3527    fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
3528        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3529        let response = crate::commands::configure::handle_configure(
3530            &configure_request_with_params(json!({
3531                "project_root": project_root,
3532                "harness": "opencode",
3533                "storage_dir": storage_dir,
3534                "config": [user_tier(json!({
3535                    "callgraph_store": true,
3536                    "search_index": true,
3537                    "semantic_search": true,
3538                }))],
3539            })),
3540            &ctx,
3541        );
3542        assert!(response.success, "configure should succeed: {response:?}");
3543        ctx
3544    }
3545
3546    fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
3547        InspectSnapshot::new(
3548            ctx.canonical_cache_root(),
3549            ctx.inspect_dir(),
3550            ctx.config(),
3551            ctx.symbol_cache(),
3552        )
3553    }
3554
3555    fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
3556        let project_root = ctx
3557            .config()
3558            .project_root
3559            .clone()
3560            .expect("test context has a project root");
3561        let files: Vec<PathBuf> = Vec::new();
3562        let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
3563        SemanticIndex::build(&project_root, &files, &mut embed, 1)
3564            .expect("empty semantic index should build")
3565    }
3566
3567    #[test]
3568    fn home_root_gate_blocks_callgraph_store_entry_points() {
3569        let _wait_guard = force_async_callgraph_builds();
3570        let home = TempDir::new().expect("home tempdir");
3571        let storage = TempDir::new().expect("storage tempdir");
3572        let source_dir = home.path().join("src");
3573        std::fs::create_dir_all(&source_dir).expect("source dir");
3574        std::fs::write(
3575            source_dir.join("lib.rs"),
3576            "pub fn caller() { callee(); }\npub fn callee() {}\n",
3577        )
3578        .expect("source file");
3579
3580        with_fake_home_env(home.path(), || {
3581            let ctx = configure_context(home.path(), storage.path());
3582            assert!(
3583                !ctx.heavy_root_work_allowed(),
3584                "HOME root configure must close the heavy-root-work gate"
3585            );
3586            assert_eq!(
3587                ctx.try_health_snapshot(home.path())
3588                    .callgraph_store
3589                    .as_ref()
3590                    .map(|component| component.status),
3591                Some("disabled"),
3592                "HOME root health must not advertise callgraph building"
3593            );
3594
3595            reset_callgraph_cold_build_spawn_count_for_test();
3596            assert!(matches!(
3597                ctx.callgraph_store_for_ops(),
3598                CallgraphStoreAccess::Unavailable
3599            ));
3600            assert!(
3601                ctx.ensure_callgraph_store()
3602                    .expect("ensure_callgraph_store should not error")
3603                    .is_none(),
3604                "shared gate must also block synchronous standalone callgraph builds"
3605            );
3606            assert_eq!(
3607                callgraph_cold_build_spawn_count_for_test(),
3608                0,
3609                "HOME root gate must not spawn a cold callgraph build"
3610            );
3611        });
3612    }
3613
3614    #[test]
3615    fn home_root_gate_blocks_inspect_manager_submit_paths() {
3616        let home = TempDir::new().expect("home tempdir");
3617        let storage = TempDir::new().expect("storage tempdir");
3618        let source_dir = home.path().join("src");
3619        std::fs::create_dir_all(&source_dir).expect("source dir");
3620        std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
3621
3622        with_fake_home_env(home.path(), || {
3623            let ctx = configure_context(home.path(), storage.path());
3624            let snapshot = inspect_snapshot(&ctx);
3625            let scope = JobScope::for_project(snapshot.project_root.clone());
3626            let manager = ctx.inspect_manager();
3627
3628            assert!(matches!(
3629                manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
3630                JobOutcome::Failed { .. }
3631            ));
3632
3633            let submission = manager.submit_tier2_run_with_reuse_serial_background(
3634                snapshot,
3635                vec![InspectCategory::DeadCode],
3636            );
3637            assert!(submission.queued_categories.is_empty());
3638            assert!(submission.newly_queued_categories.is_empty());
3639            assert!(submission.deferred_categories.is_empty());
3640            assert_eq!(submission.errors.len(), 1);
3641            assert!(
3642                !manager.tier2_any_in_flight(),
3643                "HOME root gate must reject Tier-2 submission before any job is queued"
3644            );
3645        });
3646    }
3647
3648    #[test]
3649    fn non_home_root_still_allows_callgraph_cold_builds() {
3650        let _env_guard = force_async_callgraph_builds();
3651        reset_callgraph_cold_build_spawn_count_for_test();
3652        let ctx = cold_build_context();
3653
3654        assert!(ctx.heavy_root_work_allowed());
3655        assert!(matches!(
3656            ctx.callgraph_store_for_ops(),
3657            CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3658        ));
3659        assert_eq!(
3660            callgraph_cold_build_spawn_count_for_test(),
3661            1,
3662            "non-home roots must still be able to cold-build the callgraph store"
3663        );
3664
3665        let rx = ctx
3666            .callgraph_store_rx
3667            .lock()
3668            .as_ref()
3669            .cloned()
3670            .expect("non-home cold build should install an in-flight receiver");
3671        rx.recv_timeout(Duration::from_secs(30))
3672            .expect("background cold build should complete");
3673        *ctx.callgraph_store_rx.lock() = None;
3674    }
3675
3676    #[test]
3677    fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
3678        let _env_guard = force_async_callgraph_builds();
3679        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3680        let ctx = cold_build_context();
3681        let (tx, rx) = crossbeam_channel::unbounded();
3682        *ctx.semantic_index_rx().lock() = Some(rx);
3683        ctx.schedule_semantic_cold_seed_gate_for_configure();
3684
3685        assert!(matches!(
3686            ctx.callgraph_store_for_ops(),
3687            CallgraphStoreAccess::Building
3688        ));
3689        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3690        tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
3691            &ctx,
3692        )))
3693        .expect("send ready event");
3694
3695        crate::runtime_drain::drain_semantic_index_events(&ctx);
3696
3697        assert!(
3698            !ctx.semantic_cold_seed_active(),
3699            "semantic Ready must clear the scheduled cold gate"
3700        );
3701        assert!(
3702            ctx.tier2_pull_demand_pending(),
3703            "semantic Ready must resume deferred Tier-2 work"
3704        );
3705        assert_eq!(
3706            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3707            1,
3708            "semantic Ready must resume the deferred callgraph warm"
3709        );
3710        let rx = ctx
3711            .callgraph_store_rx
3712            .lock()
3713            .as_ref()
3714            .cloned()
3715            .expect("ready resume should install an in-flight callgraph receiver");
3716        rx.recv_timeout(Duration::from_secs(30))
3717            .expect("background cold build should complete");
3718        *ctx.callgraph_store_rx.lock() = None;
3719    }
3720
3721    #[test]
3722    fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
3723        let _env_guard = force_async_callgraph_builds();
3724        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3725        let ctx = cold_build_context();
3726        ctx.schedule_semantic_cold_seed_gate_for_configure();
3727
3728        assert!(matches!(
3729            ctx.callgraph_store_for_ops(),
3730            CallgraphStoreAccess::Building
3731        ));
3732        assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
3733        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3734
3735        assert!(
3736            !ctx.semantic_cold_seed_active(),
3737            "cached-load or retry-wait clear must reopen the semantic cold gate"
3738        );
3739        assert!(
3740            ctx.tier2_pull_demand_pending(),
3741            "cached-load or retry-wait clear must resume deferred Tier-2 work"
3742        );
3743        assert_eq!(
3744            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3745            1,
3746            "cached-load or retry-wait clear must resume deferred callgraph warm"
3747        );
3748        let rx = ctx
3749            .callgraph_store_rx
3750            .lock()
3751            .as_ref()
3752            .cloned()
3753            .expect("gate-clear resume should install an in-flight callgraph receiver");
3754        rx.recv_timeout(Duration::from_secs(30))
3755            .expect("background cold build should complete");
3756        *ctx.callgraph_store_rx.lock() = None;
3757    }
3758
3759    #[test]
3760    fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
3761        let _env_guard = force_async_callgraph_builds();
3762        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3763        let ctx = cold_build_context();
3764
3765        ctx.set_semantic_cold_seed_active_for_test(true);
3766        assert!(
3767            matches!(
3768                ctx.callgraph_store_for_ops(),
3769                CallgraphStoreAccess::Building
3770            ),
3771            "callgraph ops should degrade as building while the semantic cold gate is active"
3772        );
3773        assert_eq!(
3774            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3775            0,
3776            "semantic cold gate must not spawn a competing callgraph cold build"
3777        );
3778        assert!(ctx.semantic_callgraph_warm_deferred_for_test());
3779
3780        ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
3781        assert_eq!(
3782            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3783            1,
3784            "clearing the semantic cold gate should resume the deferred callgraph warm"
3785        );
3786
3787        let rx = ctx
3788            .callgraph_store_rx
3789            .lock()
3790            .as_ref()
3791            .cloned()
3792            .expect("deferred warm should install an in-flight receiver");
3793        rx.recv_timeout(Duration::from_secs(30))
3794            .expect("background cold build should complete");
3795        *ctx.callgraph_store_rx.lock() = None;
3796    }
3797
3798    #[test]
3799    fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
3800        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3801        ctx.schedule_semantic_cold_seed_gate_for_configure();
3802
3803        ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
3804
3805        assert!(
3806            !ctx.semantic_cold_seed_active(),
3807            "retry-wait or cached-load events must reopen the semantic cold gate"
3808        );
3809        assert!(
3810            ctx.tier2_pull_demand_pending(),
3811            "clearing the semantic cold gate should kick a Tier-2 pull refresh"
3812        );
3813    }
3814
3815    #[test]
3816    fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
3817        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3818        let (tx, rx) = crossbeam_channel::unbounded();
3819        *ctx.semantic_index_rx().lock() = Some(rx);
3820        ctx.schedule_semantic_cold_seed_gate_for_configure();
3821        tx.send(SemanticIndexEvent::Failed(
3822            "embedding backend failed".to_string(),
3823        ))
3824        .expect("send failed event");
3825
3826        crate::runtime_drain::drain_semantic_index_events(&ctx);
3827
3828        assert!(
3829            !ctx.semantic_cold_seed_active(),
3830            "semantic Failed must clear the scheduled cold gate"
3831        );
3832        assert!(
3833            ctx.tier2_pull_demand_pending(),
3834            "semantic Failed must resume deferred Tier-2 work"
3835        );
3836    }
3837
3838    #[test]
3839    fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
3840        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3841        let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
3842        *ctx.semantic_index_rx().lock() = Some(rx);
3843        ctx.schedule_semantic_cold_seed_gate_for_configure();
3844        drop(tx);
3845
3846        crate::runtime_drain::drain_semantic_index_events(&ctx);
3847
3848        assert!(
3849            !ctx.semantic_cold_seed_active(),
3850            "semantic worker disconnect must clear the scheduled cold gate"
3851        );
3852        assert!(
3853            ctx.tier2_pull_demand_pending(),
3854            "semantic worker disconnect must resume deferred Tier-2 work"
3855        );
3856    }
3857
3858    #[test]
3859    fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
3860        let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3861        let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3862        let base = Instant::now();
3863        ctx_a.reset_tier2_refresh_scheduler_at(base);
3864        ctx_b.reset_tier2_refresh_scheduler_at(base);
3865        ctx_a.set_semantic_cold_seed_active_for_test(true);
3866
3867        assert_eq!(
3868            ctx_a.tick_tier2_refresh_scheduler_at(
3869                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3870                0,
3871            ),
3872            None,
3873            "root A should defer Tier-2 while its semantic cold seed is active"
3874        );
3875        assert_eq!(
3876            ctx_b.tick_tier2_refresh_scheduler_at(
3877                base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
3878                0,
3879            ),
3880            Some(Tier2TriggerReason::ConfigureWarm),
3881            "root B must not inherit root A's semantic cold gate"
3882        );
3883    }
3884
3885    #[test]
3886    fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
3887        let _env_guard = force_async_callgraph_builds();
3888        CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
3889
3890        let project = TempDir::new().expect("project tempdir");
3891        let storage = TempDir::new().expect("storage tempdir");
3892        let source_dir = project.path().join("src");
3893        std::fs::create_dir_all(&source_dir).expect("source dir");
3894        std::fs::write(
3895            source_dir.join("lib.rs"),
3896            "pub fn caller() { callee(); }\npub fn callee() {}\n",
3897        )
3898        .expect("source file");
3899
3900        let ctx = Arc::new(AppContext::new(
3901            Box::new(TreeSitterProvider::new()),
3902            Config {
3903                project_root: Some(project.path().to_path_buf()),
3904                storage_dir: Some(storage.path().to_path_buf()),
3905                callgraph_chunk_size: 1,
3906                ..Config::default()
3907            },
3908        ));
3909
3910        let barrier = Arc::new(Barrier::new(3));
3911        let handles = (0..2)
3912            .map(|_| {
3913                let ctx = Arc::clone(&ctx);
3914                let barrier = Arc::clone(&barrier);
3915                std::thread::spawn(move || {
3916                    barrier.wait();
3917                    matches!(
3918                        ctx.callgraph_store_for_ops(),
3919                        CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
3920                    )
3921                })
3922            })
3923            .collect::<Vec<_>>();
3924
3925        barrier.wait();
3926        for handle in handles {
3927            assert!(
3928                handle.join().expect("callgraph caller thread"),
3929                "cold callgraph ops should report Building or observe the installed store"
3930            );
3931        }
3932
3933        assert_eq!(
3934            CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
3935            1,
3936            "concurrent cold callers must share one background build"
3937        );
3938
3939        let rx = ctx
3940            .callgraph_store_rx
3941            .lock()
3942            .as_ref()
3943            .cloned()
3944            .expect("in-flight receiver installed before spawn");
3945        rx.recv_timeout(Duration::from_secs(30))
3946            .expect("background cold build should complete");
3947        *ctx.callgraph_store_rx.lock() = None;
3948    }
3949}
3950
3951#[cfg(test)]
3952mod status_emitter_tests {
3953    use super::*;
3954    use crate::parser::TreeSitterProvider;
3955
3956    fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
3957        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
3958        let (tx, rx) = mpsc::channel();
3959        ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
3960            let _ = tx.send(frame);
3961        }))));
3962        (ctx, rx)
3963    }
3964
3965    #[test]
3966    fn status_emitter_signal_triggers_push() {
3967        let (ctx, rx) = ctx_with_frame_rx();
3968        ctx.status_emitter().signal(ctx.build_status_snapshot());
3969        let frame = rx
3970            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3971            .expect("status_changed push");
3972        assert!(matches!(frame, PushFrame::StatusChanged(_)));
3973    }
3974
3975    #[test]
3976    fn status_emitter_debounces_burst() {
3977        let (ctx, rx) = ctx_with_frame_rx();
3978        for _ in 0..10 {
3979            ctx.status_emitter().signal(ctx.build_status_snapshot());
3980        }
3981        let frame = rx
3982            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3983            .expect("status_changed push");
3984        assert!(matches!(frame, PushFrame::StatusChanged(_)));
3985        assert!(rx.try_recv().is_err());
3986    }
3987
3988    #[test]
3989    fn status_emitter_separate_windows_separate_pushes() {
3990        let (ctx, rx) = ctx_with_frame_rx();
3991        ctx.status_emitter().signal(ctx.build_status_snapshot());
3992        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3993            .expect("first push");
3994        ctx.status_emitter().signal(ctx.build_status_snapshot());
3995        rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
3996            .expect("second push");
3997    }
3998
3999    #[test]
4000    fn status_emitter_no_signal_no_push() {
4001        let (_ctx, rx) = ctx_with_frame_rx();
4002        assert!(rx
4003            .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
4004            .is_err());
4005    }
4006
4007    #[test]
4008    fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
4009        let (ctx, rx) = ctx_with_frame_rx();
4010        drop(ctx);
4011        assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
4012    }
4013
4014    #[test]
4015    fn progress_sender_slot_is_per_context_for_shared_app() {
4016        let app = App::default_shared();
4017        let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
4018        let ctx_b = AppContext::from_app(app, Config::default());
4019        let (tx_a, rx_a) = mpsc::channel();
4020        let (tx_b, rx_b) = mpsc::channel();
4021
4022        ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
4023            let _ = tx_a.send(frame);
4024        }))));
4025        ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
4026            let _ = tx_b.send(frame);
4027        }))));
4028
4029        ctx_a.emit_progress(ProgressFrame {
4030            frame_type: "progress",
4031            request_id: "ctx-a".to_string(),
4032            kind: crate::protocol::ProgressKind::Stdout,
4033            chunk: "a".to_string(),
4034        });
4035        ctx_b.emit_progress(ProgressFrame {
4036            frame_type: "progress",
4037            request_id: "ctx-b".to_string(),
4038            kind: crate::protocol::ProgressKind::Stdout,
4039            chunk: "b".to_string(),
4040        });
4041
4042        match rx_a
4043            .recv_timeout(Duration::from_millis(50))
4044            .expect("ctx A progress frame")
4045        {
4046            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
4047            other => panic!("unexpected frame for ctx A: {other:?}"),
4048        }
4049        assert!(rx_a.try_recv().is_err());
4050
4051        match rx_b
4052            .recv_timeout(Duration::from_millis(50))
4053            .expect("ctx B progress frame")
4054        {
4055            PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
4056            other => panic!("unexpected frame for ctx B: {other:?}"),
4057        }
4058        assert!(rx_b.try_recv().is_err());
4059    }
4060}
4061
4062#[cfg(test)]
4063mod status_bar_tests {
4064    use super::*;
4065    use crate::parser::TreeSitterProvider;
4066
4067    fn ctx() -> AppContext {
4068        AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
4069    }
4070
4071    #[test]
4072    fn status_bar_counts_none_until_tier2_populated() {
4073        let ctx = ctx();
4074        // No scan has run yet — never surface a bar claiming "0 dead code".
4075        assert!(ctx.status_bar_counts().is_none());
4076
4077        ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
4078        let counts = ctx.status_bar_counts().expect("populated");
4079        assert_eq!(counts.dead_code, 5);
4080        assert_eq!(counts.unused_exports, 3);
4081        assert_eq!(counts.duplicates, 7);
4082        assert_eq!(counts.todos, 2);
4083        assert!(!counts.tier2_stale);
4084        // Errors/warnings are read live from an empty LSP store → 0.
4085        assert_eq!(counts.errors, 0);
4086        assert_eq!(counts.warnings, 0);
4087    }
4088
4089    #[test]
4090    fn partial_tier2_does_not_fabricate_zeros() {
4091        let ctx = ctx();
4092        // Only dead_code has completed (the slow first serial category); the
4093        // other two are still in flight. The bar must stay suppressed rather
4094        // than render `D5 U0 C0` with fabricated zeros (#1).
4095        ctx.update_status_bar_tier2(Some(5), None, None, None, true);
4096        assert!(
4097            ctx.status_bar_counts().is_none(),
4098            "bar must not surface until all three Tier-2 categories are real"
4099        );
4100
4101        // Second category completes — still incomplete, still suppressed.
4102        ctx.update_status_bar_tier2(None, Some(3), None, None, true);
4103        assert!(ctx.status_bar_counts().is_none());
4104
4105        // Final category completes → bar surfaces with all real counts, and
4106        // none of them were ever fabricated.
4107        ctx.update_status_bar_tier2(None, None, Some(7), None, false);
4108        let counts = ctx.status_bar_counts().expect("all three real now");
4109        assert_eq!(counts.dead_code, 5);
4110        assert_eq!(counts.unused_exports, 3);
4111        assert_eq!(counts.duplicates, 7);
4112    }
4113
4114    #[test]
4115    fn update_with_none_todos_preserves_last_known_todos() {
4116        let ctx = ctx();
4117        ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
4118        // A background-scan refresh passes todos=None → todo count preserved.
4119        ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
4120        let counts = ctx.status_bar_counts().expect("populated");
4121        assert_eq!(counts.todos, 9);
4122        assert_eq!(counts.dead_code, 2);
4123    }
4124
4125    #[test]
4126    fn update_with_none_count_preserves_last_known_count() {
4127        let ctx = ctx();
4128        ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
4129        // A refresh that only recomputed dead_code preserves the other two
4130        // real counts rather than overwriting them with a fabricated 0.
4131        ctx.update_status_bar_tier2(Some(11), None, None, None, false);
4132        let counts = ctx.status_bar_counts().expect("populated");
4133        assert_eq!(counts.dead_code, 11);
4134        assert_eq!(counts.unused_exports, 20);
4135        assert_eq!(counts.duplicates, 30);
4136    }
4137
4138    #[test]
4139    fn mark_stale_sets_flag_only_after_populate() {
4140        let ctx = ctx();
4141        // No-op before first populate.
4142        ctx.mark_status_bar_tier2_stale();
4143        assert!(ctx.status_bar_counts().is_none());
4144
4145        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
4146        ctx.mark_status_bar_tier2_stale();
4147        assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
4148
4149        // A completed scan clears stale.
4150        ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
4151        assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
4152    }
4153
4154    // End-to-end wiring: a diagnostic for a file inflates the status-bar `E`
4155    // count (read live from the warm LSP set); clearing that file's diagnostics
4156    // (the deleted-file path) drops it back. This is the AppContext glue between
4157    // the watcher-drain clear and the agent-visible bar.
4158    #[test]
4159    fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
4160        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
4161        use crate::lsp::registry::ServerKind;
4162        use crate::lsp::roots::ServerKey;
4163
4164        let ctx = ctx();
4165        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); // populate so the bar surfaces
4166
4167        let file = std::path::PathBuf::from("/proj/gone.ts");
4168        {
4169            let mut lsp = ctx.lsp();
4170            lsp.diagnostics_store_mut_for_test().publish(
4171                ServerKey {
4172                    kind: ServerKind::TypeScript,
4173                    root: std::path::PathBuf::from("/proj"),
4174                },
4175                file.clone(),
4176                vec![StoredDiagnostic {
4177                    file: file.clone(),
4178                    line: 1,
4179                    column: 1,
4180                    end_line: 1,
4181                    end_column: 2,
4182                    severity: DiagnosticSeverity::Error,
4183                    message: "boom".into(),
4184                    code: None,
4185                    source: None,
4186                }],
4187            );
4188        }
4189
4190        // Bar reflects the live warm-set error.
4191        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
4192
4193        // Clearing the (now-deleted) file's diagnostics drops the count.
4194        let removed = ctx.lsp_clear_diagnostics_for_file(&file);
4195        assert!(removed);
4196        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
4197    }
4198
4199    #[test]
4200    fn status_bar_filtered_counts_ignore_environmental_flap() {
4201        use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
4202        use crate::lsp::registry::ServerKind;
4203        use crate::lsp::roots::ServerKey;
4204
4205        let ctx = ctx();
4206        let root = if cfg!(windows) {
4207            std::path::PathBuf::from(r"C:\proj")
4208        } else {
4209            std::path::PathBuf::from("/proj")
4210        };
4211        ctx.set_canonical_cache_root(root.clone());
4212        ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
4213
4214        let file = root.join("aft.jsonc");
4215        let key = ServerKey {
4216            kind: ServerKind::TypeScript,
4217            root: root.clone(),
4218        };
4219        let env = StoredDiagnostic {
4220            file: file.clone(),
4221            line: 1,
4222            column: 1,
4223            end_line: 1,
4224            end_column: 2,
4225            severity: DiagnosticSeverity::Error,
4226            message: "Failed to load schema from https://example.com/schema.json".into(),
4227            code: None,
4228            source: Some("json".into()),
4229        };
4230
4231        assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
4232
4233        {
4234            let mut lsp = ctx.lsp();
4235            lsp.diagnostics_store_mut_for_test()
4236                .publish(key.clone(), file.clone(), vec![env]);
4237        }
4238        assert_eq!(
4239            ctx.status_bar_counts().expect("populated").errors,
4240            0,
4241            "environmental publish must not change status-bar E"
4242        );
4243
4244        {
4245            let mut lsp = ctx.lsp();
4246            lsp.diagnostics_store_mut_for_test()
4247                .publish(key, file, vec![]);
4248        }
4249        assert_eq!(
4250            ctx.status_bar_counts().expect("populated").errors,
4251            0,
4252            "environmental clear must not change status-bar E"
4253        );
4254    }
4255}
4256
4257#[cfg(test)]
4258mod harness_path_tests {
4259    use super::*;
4260    use crate::harness::Harness;
4261    use crate::parser::TreeSitterProvider;
4262
4263    fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
4264        let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
4265        ctx.update_config(|config| {
4266            config.storage_dir = Some(storage_dir);
4267        });
4268        ctx.set_harness(harness);
4269        ctx
4270    }
4271
4272    #[test]
4273    fn harness_dir_resolves_correctly() {
4274        let storage = PathBuf::from("/tmp/cortexkit/aft");
4275        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
4276
4277        assert_eq!(ctx.harness_dir(), storage.join("pi"));
4278    }
4279
4280    #[test]
4281    fn bash_tasks_dir_uses_hash_session() {
4282        let storage = PathBuf::from("/tmp/cortexkit/aft");
4283        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
4284
4285        assert_eq!(
4286            ctx.bash_tasks_dir("ses_abc"),
4287            storage
4288                .join("opencode")
4289                .join("bash-tasks")
4290                .join(hash_session("ses_abc"))
4291        );
4292    }
4293
4294    #[test]
4295    fn backups_dir_includes_path_hash() {
4296        let storage = PathBuf::from("/tmp/cortexkit/aft");
4297        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
4298
4299        assert_eq!(
4300            ctx.backups_dir("ses_abc", "pathhash"),
4301            storage
4302                .join("pi")
4303                .join("backups")
4304                .join(hash_session("ses_abc"))
4305                .join("pathhash")
4306        );
4307    }
4308
4309    #[test]
4310    fn filters_dir_under_harness() {
4311        let storage = PathBuf::from("/tmp/cortexkit/aft");
4312        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
4313
4314        assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
4315    }
4316
4317    #[test]
4318    fn trust_file_is_host_global() {
4319        let storage = PathBuf::from("/tmp/cortexkit/aft");
4320        let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
4321
4322        assert_eq!(
4323            ctx.trust_file(),
4324            storage.join("trusted-filter-projects.json")
4325        );
4326    }
4327
4328    #[test]
4329    fn same_session_different_harness_resolve_different_paths() {
4330        let storage = PathBuf::from("/tmp/cortexkit/aft");
4331        let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
4332        let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
4333
4334        assert_ne!(
4335            opencode.bash_tasks_dir("ses_same"),
4336            pi.bash_tasks_dir("ses_same")
4337        );
4338    }
4339}
4340
4341#[cfg(test)]
4342mod gitignore_tests {
4343    use super::*;
4344    use std::fs;
4345    use std::path::Path;
4346    use tempfile::TempDir;
4347
4348    fn make_ctx_with_root(root: &Path) -> AppContext {
4349        let provider = Box::new(crate::parser::TreeSitterProvider::new());
4350        let config = Config {
4351            project_root: Some(root.to_path_buf()),
4352            ..Config::default()
4353        };
4354        AppContext::new(provider, config)
4355    }
4356
4357    /// Helper: returns true when the matcher would skip `path` (as if it
4358    /// arrived via a watcher event for this project root). Canonicalizes
4359    /// the query path so symlink prefixes (e.g. macOS `/var` → `/private/var`)
4360    /// don't trip the `ignore` crate's "path is expected to be under the
4361    /// root" panic — production code does the same guard via
4362    /// `path.starts_with(matcher.path())` in `drain_watcher_events`.
4363    fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
4364        let Some(matcher) = ctx.gitignore() else {
4365            return false;
4366        };
4367        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
4368        if !canonical.starts_with(matcher.path()) {
4369            return false;
4370        }
4371        let is_dir = canonical.is_dir();
4372        matcher
4373            .matched_path_or_any_parents(&canonical, is_dir)
4374            .is_ignore()
4375    }
4376
4377    /// Run `f` with global git-ignore discovery neutralized.
4378    ///
4379    /// `rebuild_gitignore` loads git's global excludes via the `ignore`
4380    /// crate, which discovers them from TWO places: `core.excludesfile` in
4381    /// `$HOME/.gitconfig` (or `$XDG_CONFIG_HOME/git/config`), and the default
4382    /// `$XDG_CONFIG_HOME/git/ignore` / `$HOME/.config/git/ignore` locations.
4383    /// A developer machine commonly has one of these, so a "no project ignore
4384    /// → None" assertion is only deterministic when BOTH discovery roots point
4385    /// at an empty directory — neutralizing only `XDG_CONFIG_HOME` still finds
4386    /// a `~/.gitconfig` `core.excludesfile`. Serialized on the process-wide
4387    /// env lock shared with every other HOME-mutating test; env is restored
4388    /// before the closure result is used.
4389    fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
4390        let _guard = crate::test_env::process_env_lock();
4391        let tmp = TempDir::new().unwrap();
4392        let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
4393        let prev_home = std::env::var_os("HOME");
4394        let prev_userprofile = std::env::var_os("USERPROFILE");
4395        // SAFETY: serialized by the process env lock; restored immediately
4396        // after `f`.
4397        unsafe {
4398            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
4399            std::env::set_var("HOME", tmp.path());
4400            std::env::set_var("USERPROFILE", tmp.path());
4401        }
4402        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
4403        unsafe {
4404            match prev_xdg {
4405                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
4406                None => std::env::remove_var("XDG_CONFIG_HOME"),
4407            }
4408            match prev_home {
4409                Some(v) => std::env::set_var("HOME", v),
4410                None => std::env::remove_var("HOME"),
4411            }
4412            match prev_userprofile {
4413                Some(v) => std::env::set_var("USERPROFILE", v),
4414                None => std::env::remove_var("USERPROFILE"),
4415            }
4416        }
4417        match result {
4418            Ok(r) => r,
4419            Err(p) => std::panic::resume_unwind(p),
4420        }
4421    }
4422
4423    #[test]
4424    fn rebuild_gitignore_returns_none_without_project_root() {
4425        let provider = Box::new(crate::parser::TreeSitterProvider::new());
4426        let ctx = AppContext::new(provider, Config::default());
4427        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
4428        assert!(ctx.gitignore().is_none());
4429    }
4430
4431    #[test]
4432    fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
4433        let tmp = TempDir::new().unwrap();
4434        let ctx = make_ctx_with_root(tmp.path());
4435        with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
4436        assert!(ctx.gitignore().is_none());
4437    }
4438
4439    #[test]
4440    fn matcher_filters_files_in_ignored_dist_dir() {
4441        let tmp = TempDir::new().unwrap();
4442        fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
4443        fs::create_dir_all(tmp.path().join("dist")).unwrap();
4444        fs::create_dir_all(tmp.path().join("src")).unwrap();
4445        let dist_file = tmp.path().join("dist").join("bundle.js");
4446        let src_file = tmp.path().join("src").join("app.ts");
4447        fs::write(&dist_file, "x").unwrap();
4448        fs::write(&src_file, "y").unwrap();
4449
4450        let ctx = make_ctx_with_root(tmp.path());
4451        ctx.rebuild_gitignore();
4452
4453        assert!(ctx.gitignore().is_some());
4454        assert!(
4455            is_ignored(&ctx, &dist_file),
4456            "dist/bundle.js should be ignored"
4457        );
4458        assert!(
4459            !is_ignored(&ctx, &src_file),
4460            "src/app.ts should NOT be ignored"
4461        );
4462    }
4463
4464    #[test]
4465    fn matcher_handles_node_modules_and_target() {
4466        let tmp = TempDir::new().unwrap();
4467        fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
4468        fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
4469        fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
4470        let nm_file = tmp.path().join("node_modules/foo/index.js");
4471        let target_file = tmp.path().join("target/debug/aft");
4472        fs::write(&nm_file, "x").unwrap();
4473        fs::write(&target_file, "x").unwrap();
4474
4475        let ctx = make_ctx_with_root(tmp.path());
4476        ctx.rebuild_gitignore();
4477
4478        assert!(is_ignored(&ctx, &nm_file));
4479        assert!(is_ignored(&ctx, &target_file));
4480    }
4481
4482    #[test]
4483    fn matcher_honors_negation_pattern() {
4484        // .gitignore: ignore all *.log files EXCEPT important.log
4485        let tmp = TempDir::new().unwrap();
4486        fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
4487        let random_log = tmp.path().join("random.log");
4488        let important_log = tmp.path().join("important.log");
4489        fs::write(&random_log, "x").unwrap();
4490        fs::write(&important_log, "y").unwrap();
4491
4492        let ctx = make_ctx_with_root(tmp.path());
4493        ctx.rebuild_gitignore();
4494
4495        assert!(is_ignored(&ctx, &random_log));
4496        assert!(
4497            !is_ignored(&ctx, &important_log),
4498            "negation pattern should un-ignore important.log"
4499        );
4500    }
4501
4502    #[test]
4503    fn rebuild_picks_up_gitignore_changes() {
4504        let tmp = TempDir::new().unwrap();
4505        let ignore_path = tmp.path().join(".gitignore");
4506        fs::write(&ignore_path, "foo.txt\n").unwrap();
4507        let foo = tmp.path().join("foo.txt");
4508        let bar = tmp.path().join("bar.txt");
4509        fs::write(&foo, "").unwrap();
4510        fs::write(&bar, "").unwrap();
4511
4512        let ctx = make_ctx_with_root(tmp.path());
4513        ctx.rebuild_gitignore();
4514        assert!(is_ignored(&ctx, &foo));
4515        assert!(!is_ignored(&ctx, &bar));
4516
4517        // Now flip the rules: ignore bar.txt instead of foo.txt
4518        fs::write(&ignore_path, "bar.txt\n").unwrap();
4519        ctx.rebuild_gitignore();
4520        assert!(!is_ignored(&ctx, &foo));
4521        assert!(is_ignored(&ctx, &bar));
4522    }
4523
4524    #[test]
4525    fn gitignore_loads_info_exclude_when_present() {
4526        let tmp = TempDir::new().unwrap();
4527        let info_dir = tmp.path().join(".git/info");
4528        fs::create_dir_all(&info_dir).unwrap();
4529        fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
4530        let secrets = tmp.path().join("secrets.txt");
4531        let public = tmp.path().join("public.txt");
4532        fs::write(&secrets, "token").unwrap();
4533        fs::write(&public, "ok").unwrap();
4534
4535        let ctx = make_ctx_with_root(tmp.path());
4536        ctx.rebuild_gitignore();
4537
4538        assert!(is_ignored(&ctx, &secrets));
4539        assert!(!is_ignored(&ctx, &public));
4540    }
4541
4542    #[test]
4543    fn matcher_picks_up_nested_gitignore() {
4544        let tmp = TempDir::new().unwrap();
4545        // Root .gitignore is intentionally empty — only the nested one ignores
4546        fs::write(tmp.path().join(".gitignore"), "").unwrap();
4547        let sub = tmp.path().join("packages/foo");
4548        fs::create_dir_all(&sub).unwrap();
4549        fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
4550        let generated_file = sub.join("generated").join("out.js");
4551        fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
4552        fs::write(&generated_file, "x").unwrap();
4553
4554        let ctx = make_ctx_with_root(tmp.path());
4555        ctx.rebuild_gitignore();
4556
4557        assert!(
4558            is_ignored(&ctx, &generated_file),
4559            "nested gitignore in packages/foo/.gitignore should ignore generated/"
4560        );
4561    }
4562}