Skip to main content

aft/
runtime_drain.rs

1use crate as aft;
2use crate::callgraph_store::{invalidates_workspace_crate_prefix_cache, CallGraphStore};
3use crate::context::{
4    AppContext, CallGraphStoreBuildEvent, SemanticIndexEvent, SemanticIndexStatus,
5    SemanticRefreshEvent, SemanticRefreshRequest, WatcherDrainApplyPhase, WatcherDrainPhase,
6    WatcherDrainSliceState,
7};
8use crate::log_ctx;
9use crate::lsp::client::LspEvent;
10use crate::protocol::PushFrame;
11#[cfg(test)]
12use crate::watcher_filter::RescanReason;
13use crate::watcher_filter::{watcher_path_is_infra_skip, WatcherDispatchEvent};
14use std::collections::{BTreeSet, HashSet, VecDeque};
15use std::path::{Path, PathBuf};
16#[cfg(test)]
17use std::sync::atomic::AtomicU64;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Mutex, OnceLock};
20use std::thread;
21use std::time::{Duration, Instant};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct DrainBatchOutcome {
25    pub processed: usize,
26    pub has_more: bool,
27}
28
29pub const WATCHER_PATH_DRAIN_BATCH_CAP: usize = 2_048;
30pub const WATCHER_DRAIN_SLICE_BUDGET: Duration = Duration::from_millis(250);
31// View assembly may waste one superseded build, but it must not inherit the
32// semantic embedder's human-edit debounce before publishing callgraph data.
33const VIEW_PUBLICATION_QUIET_WINDOW: Duration = Duration::from_secs(1);
34const WATCHER_DRAIN_UNIT_WARN_AFTER: Duration = Duration::from_secs(5);
35const WATCHER_DRAIN_UNIT_FINAL_AFTER: Duration = Duration::from_secs(30);
36pub const LSP_EVENT_DRAIN_BATCH_CAP: usize = 256;
37
38#[cfg(test)]
39thread_local! {
40    static LEGACY_WATCHER_REFRESHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
41}
42
43#[cfg(test)]
44pub(crate) fn reset_legacy_watcher_refreshes_for_test() {
45    LEGACY_WATCHER_REFRESHES.with(|count| count.set(0));
46}
47
48#[cfg(test)]
49pub(crate) fn legacy_watcher_refreshes_for_test() -> usize {
50    LEGACY_WATCHER_REFRESHES.with(std::cell::Cell::get)
51}
52
53#[cfg(test)]
54fn note_legacy_watcher_refresh_for_test() {
55    LEGACY_WATCHER_REFRESHES.with(|count| count.set(count.get() + 1));
56}
57
58#[cfg(not(test))]
59fn note_legacy_watcher_refresh_for_test() {}
60
61#[cfg(test)]
62struct ArtifactDrainCommitGate {
63    context_id: usize,
64    reached_tx: crossbeam_channel::Sender<()>,
65    release_rx: crossbeam_channel::Receiver<()>,
66}
67
68#[cfg(test)]
69static ARTIFACT_DRAIN_COMMIT_GATE: OnceLock<Mutex<Option<ArtifactDrainCommitGate>>> =
70    OnceLock::new();
71#[cfg(test)]
72static ARTIFACT_DRAIN_TEST_MUTEX: Mutex<()> = Mutex::new(());
73
74#[cfg(test)]
75struct SemanticRefreshRecoveryGate {
76    context_id: usize,
77    reached_tx: crossbeam_channel::Sender<()>,
78    release_rx: crossbeam_channel::Receiver<()>,
79}
80
81#[cfg(test)]
82static SEMANTIC_REFRESH_RECOVERY_GATE: OnceLock<Mutex<Option<SemanticRefreshRecoveryGate>>> =
83    OnceLock::new();
84
85#[cfg(test)]
86struct WatcherPhaseCommitGate {
87    target: PathBuf,
88    reached_tx: crossbeam_channel::Sender<()>,
89    release_rx: crossbeam_channel::Receiver<()>,
90}
91
92#[cfg(test)]
93static WATCHER_PHASE_COMMIT_GATE: std::sync::OnceLock<Mutex<Option<WatcherPhaseCommitGate>>> =
94    std::sync::OnceLock::new();
95
96#[cfg(test)]
97struct WatcherRescanGate {
98    context_id: usize,
99    reached_tx: crossbeam_channel::Sender<()>,
100    release_rx: crossbeam_channel::Receiver<()>,
101}
102
103#[cfg(test)]
104static WATCHER_RESCAN_GATE: OnceLock<Mutex<Option<WatcherRescanGate>>> = OnceLock::new();
105
106#[cfg(test)]
107fn install_watcher_rescan_gate_for_test(
108    ctx: &AppContext,
109) -> (
110    crossbeam_channel::Receiver<()>,
111    crossbeam_channel::Sender<()>,
112) {
113    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
114    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
115    *WATCHER_RESCAN_GATE
116        .get_or_init(|| Mutex::new(None))
117        .lock()
118        .expect("watcher rescan gate mutex poisoned") = Some(WatcherRescanGate {
119        context_id: ctx as *const AppContext as usize,
120        reached_tx,
121        release_rx,
122    });
123    (reached_rx, release_tx)
124}
125
126#[cfg(test)]
127fn wait_on_watcher_rescan_gate_for_test(ctx: &AppContext) {
128    let mut slot = WATCHER_RESCAN_GATE
129        .get_or_init(|| Mutex::new(None))
130        .lock()
131        .expect("watcher rescan gate mutex poisoned");
132    if !slot
133        .as_ref()
134        .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
135    {
136        return;
137    }
138    let gate = slot.take();
139    drop(slot);
140    if let Some(gate) = gate {
141        let _ = gate.reached_tx.send(());
142        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
143    }
144}
145
146#[cfg(not(test))]
147fn wait_on_watcher_rescan_gate_for_test(_ctx: &AppContext) {}
148
149#[cfg(test)]
150fn install_watcher_phase_commit_gate_for_test(
151    target: PathBuf,
152) -> (
153    crossbeam_channel::Receiver<()>,
154    crossbeam_channel::Sender<()>,
155) {
156    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
157    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
158    *WATCHER_PHASE_COMMIT_GATE
159        .get_or_init(|| Mutex::new(None))
160        .lock()
161        .expect("watcher phase commit gate mutex poisoned") = Some(WatcherPhaseCommitGate {
162        target,
163        reached_tx,
164        release_rx,
165    });
166    (reached_rx, release_tx)
167}
168
169#[cfg(test)]
170fn wait_on_watcher_phase_commit_gate_for_test(path: &Path) {
171    let mut slot = WATCHER_PHASE_COMMIT_GATE
172        .get_or_init(|| Mutex::new(None))
173        .lock()
174        .expect("watcher phase commit gate mutex poisoned");
175    if !slot.as_ref().is_some_and(|gate| gate.target == path) {
176        return;
177    }
178    let gate = slot.take();
179    drop(slot);
180    if let Some(gate) = gate {
181        let _ = gate.reached_tx.send(());
182        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
183    }
184}
185
186#[cfg(not(test))]
187fn wait_on_watcher_phase_commit_gate_for_test(_path: &Path) {}
188
189#[cfg(test)]
190fn install_artifact_drain_commit_gate_for_test(
191    ctx: &AppContext,
192) -> (
193    crossbeam_channel::Receiver<()>,
194    crossbeam_channel::Sender<()>,
195) {
196    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
197    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
198    *ARTIFACT_DRAIN_COMMIT_GATE
199        .get_or_init(|| Mutex::new(None))
200        .lock()
201        .expect("artifact drain commit gate mutex poisoned") = Some(ArtifactDrainCommitGate {
202        context_id: ctx as *const AppContext as usize,
203        reached_tx,
204        release_rx,
205    });
206    (reached_rx, release_tx)
207}
208
209#[cfg(test)]
210fn wait_on_artifact_drain_commit_gate_for_test(ctx: &AppContext) {
211    let mut slot = ARTIFACT_DRAIN_COMMIT_GATE
212        .get_or_init(|| Mutex::new(None))
213        .lock()
214        .expect("artifact drain commit gate mutex poisoned");
215    if !slot
216        .as_ref()
217        .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
218    {
219        return;
220    }
221    let gate = slot.take();
222    drop(slot);
223    if let Some(gate) = gate {
224        let _ = gate.reached_tx.send(());
225        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
226    }
227}
228
229#[cfg(not(test))]
230fn wait_on_artifact_drain_commit_gate_for_test(_ctx: &AppContext) {}
231
232#[cfg(test)]
233fn install_semantic_refresh_recovery_gate_for_test(
234    ctx: &AppContext,
235) -> (
236    crossbeam_channel::Receiver<()>,
237    crossbeam_channel::Sender<()>,
238) {
239    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
240    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
241    *SEMANTIC_REFRESH_RECOVERY_GATE
242        .get_or_init(|| Mutex::new(None))
243        .lock()
244        .expect("semantic refresh recovery gate mutex poisoned") =
245        Some(SemanticRefreshRecoveryGate {
246            context_id: ctx as *const AppContext as usize,
247            reached_tx,
248            release_rx,
249        });
250    (reached_rx, release_tx)
251}
252
253#[cfg(test)]
254fn wait_on_semantic_refresh_recovery_gate_for_test(ctx: &AppContext) {
255    let mut slot = SEMANTIC_REFRESH_RECOVERY_GATE
256        .get_or_init(|| Mutex::new(None))
257        .lock()
258        .expect("semantic refresh recovery gate mutex poisoned");
259    if !slot
260        .as_ref()
261        .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
262    {
263        return;
264    }
265    let gate = slot.take();
266    drop(slot);
267    if let Some(gate) = gate {
268        let _ = gate.reached_tx.send(());
269        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
270    }
271}
272
273#[cfg(not(test))]
274fn wait_on_semantic_refresh_recovery_gate_for_test(_ctx: &AppContext) {}
275
276struct WatcherDrainUnitGuard<'a> {
277    phase: &'static str,
278    path: &'a Path,
279    batch_len: usize,
280    started: Instant,
281}
282
283impl<'a> WatcherDrainUnitGuard<'a> {
284    fn start(phase: WatcherDrainApplyPhase, path: &'a Path) -> Self {
285        Self {
286            phase: watcher_drain_phase_name(phase),
287            path,
288            batch_len: 1,
289            started: Instant::now(),
290        }
291    }
292
293    fn start_batch(phase: WatcherDrainApplyPhase, path: &'a Path, batch_len: usize) -> Self {
294        Self {
295            phase: watcher_drain_phase_name(phase),
296            path,
297            batch_len,
298            started: Instant::now(),
299        }
300    }
301}
302
303impl Drop for WatcherDrainUnitGuard<'_> {
304    fn drop(&mut self) {
305        let elapsed = self.started.elapsed();
306        let (warn_after, final_after) = watcher_drain_unit_thresholds();
307        if elapsed < warn_after {
308            return;
309        }
310        let path = if self.batch_len == 1 {
311            self.path.display().to_string()
312        } else {
313            format!("{} (+{} paths)", self.path.display(), self.batch_len - 1)
314        };
315        emit_watcher_drain_unit_log(format!(
316            "watcher drain unit exceeded 5s: phase={} path={} elapsed={}ms",
317            self.phase,
318            path,
319            elapsed.as_millis()
320        ));
321        if elapsed >= final_after {
322            emit_watcher_drain_unit_log(format!(
323                "watcher drain unit completed after 30s: phase={} path={} elapsed={}ms",
324                self.phase,
325                path,
326                elapsed.as_millis()
327            ));
328        }
329    }
330}
331
332fn watcher_drain_unit_thresholds() -> (Duration, Duration) {
333    #[cfg(test)]
334    if let Some(thresholds) = WATCHER_UNIT_TEST_THRESHOLDS.with(std::cell::Cell::get) {
335        return thresholds;
336    }
337    (
338        WATCHER_DRAIN_UNIT_WARN_AFTER,
339        WATCHER_DRAIN_UNIT_FINAL_AFTER,
340    )
341}
342
343fn emit_watcher_drain_unit_log(line: String) {
344    log::warn!("{line}");
345    #[cfg(test)]
346    WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow_mut().push(line));
347}
348
349#[cfg(test)]
350thread_local! {
351    static WATCHER_UNIT_TEST_DELAY: std::cell::Cell<Duration> = const { std::cell::Cell::new(Duration::ZERO) };
352    static WATCHER_UNIT_TEST_THRESHOLDS: std::cell::Cell<Option<(Duration, Duration)>> = const { std::cell::Cell::new(None) };
353    static WATCHER_UNIT_TEST_LOGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
354}
355
356#[cfg(test)]
357fn delay_watcher_unit_for_test() {
358    let delay = WATCHER_UNIT_TEST_DELAY.with(std::cell::Cell::get);
359    if !delay.is_zero() {
360        thread::sleep(delay);
361    }
362}
363
364#[cfg(not(test))]
365fn delay_watcher_unit_for_test() {}
366
367pub fn drain_deferred_configure_maintenance(ctx: &AppContext) {
368    crate::commands::configure::drain_deferred_configure_maintenance(ctx);
369}
370
371/// Tracks deferred configure work for the standalone NDJSON loop. The attached-
372/// daemon (`subc`) path schedules this work through its executor instead.
373#[derive(Debug)]
374pub struct StandaloneConfigureMaintenance {
375    inner: crate::commands::configure::ConfigureMaintenanceState,
376}
377
378impl Default for StandaloneConfigureMaintenance {
379    fn default() -> Self {
380        Self {
381            inner: crate::commands::configure::ConfigureMaintenanceState::standalone(),
382        }
383    }
384}
385
386impl StandaloneConfigureMaintenance {
387    pub fn has_pending(&mut self, ctx: &AppContext) -> bool {
388        crate::commands::configure::standalone_configure_maintenance_pending(ctx, &mut self.inner)
389    }
390
391    pub fn drain_prefix(&mut self, ctx: &AppContext) -> bool {
392        crate::commands::configure::drain_standalone_configure_prefix(ctx, &mut self.inner)
393    }
394
395    pub fn drain_one(&mut self, ctx: &AppContext) -> bool {
396        crate::commands::configure::drain_deferred_configure_maintenance_unit(ctx, &mut self.inner)
397    }
398}
399
400const STANDALONE_LOG_SWEEP_ATTEMPT_INTERVAL: Duration = Duration::from_secs(60);
401const CONFIGURE_MAINTENANCE_YIELD_LOG_INTERVAL: Duration = Duration::from_secs(5);
402static STANDALONE_LOG_SWEEP_LAST_ATTEMPT: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
403static STANDALONE_LOG_SWEEP_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
404static CONFIGURE_MAINTENANCE_YIELD_LAST_LOG: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
405
406/// Keep shared log-retention I/O off the standalone request loop. The logging
407/// module retains its hourly sweep cadence; this coarser gate avoids spawning a
408/// no-op thread on every 250 ms idle tick.
409pub fn spawn_standalone_log_maintenance() {
410    let now = Instant::now();
411    let should_attempt = STANDALONE_LOG_SWEEP_LAST_ATTEMPT
412        .get_or_init(|| Mutex::new(None))
413        .lock()
414        .map(|mut last_attempt| {
415            if last_attempt.is_some_and(|last| {
416                now.duration_since(last) < STANDALONE_LOG_SWEEP_ATTEMPT_INTERVAL
417            }) {
418                false
419            } else {
420                *last_attempt = Some(now);
421                true
422            }
423        })
424        .unwrap_or(false);
425    if !should_attempt || STANDALONE_LOG_SWEEP_IN_FLIGHT.swap(true, Ordering::AcqRel) {
426        return;
427    }
428
429    if let Err(error) = thread::Builder::new()
430        .name("aft-log-retention-sweep".to_string())
431        .spawn(|| {
432            crate::logging::maybe_sweep_logs();
433            STANDALONE_LOG_SWEEP_IN_FLIGHT.store(false, Ordering::Release);
434        })
435    {
436        STANDALONE_LOG_SWEEP_IN_FLIGHT.store(false, Ordering::Release);
437        crate::slog_warn!("failed to spawn log retention maintenance thread: {error}");
438    }
439}
440
441/// Report cooperative configure work displaced by already-buffered requests.
442/// Bursty clients may trigger many unit boundaries, so emit at most once per
443/// five seconds while preserving the queued count from the observed boundary.
444pub fn note_configure_maintenance_yield(queued: usize) {
445    if queued == 0 {
446        return;
447    }
448    let now = Instant::now();
449    let should_log = CONFIGURE_MAINTENANCE_YIELD_LAST_LOG
450        .get_or_init(|| Mutex::new(None))
451        .lock()
452        .map(|mut last_log| {
453            if last_log.is_some_and(|last| {
454                now.duration_since(last) < CONFIGURE_MAINTENANCE_YIELD_LOG_INTERVAL
455            }) {
456                false
457            } else {
458                *last_log = Some(now);
459                true
460            }
461        })
462        .unwrap_or(false);
463    if should_log {
464        crate::slog_info!(
465            "configure maintenance yielded to {} queued request(s)",
466            queued
467        );
468    }
469}
470
471pub fn drain_configure_warning_events(ctx: &AppContext) {
472    for (generation, frame) in ctx.drain_configure_warnings() {
473        if ctx.configure_generation() != generation {
474            aft::slog_info!(
475                "dropping stale configure_warnings for generation {} (current {})",
476                generation,
477                ctx.configure_generation()
478            );
479            continue;
480        }
481
482        if let Some(sender) = ctx.progress_sender_handle() {
483            sender(PushFrame::ConfigureWarnings(frame));
484        }
485    }
486}
487
488pub fn drain_inspect_events(ctx: &AppContext) {
489    drain_inspect_events_for_generation(ctx, ctx.configure_generation());
490}
491
492pub(crate) fn drain_inspect_events_for_generation(ctx: &AppContext, generation: u64) {
493    let Some((drained, reuse_completed)) = ctx.run_if_subc_bound_generation(generation, || {
494        let drained = ctx.inspect_manager().drain_completions();
495        // Watcher-driven Tier-2 scans complete via the reuse path, which bypasses
496        // `result_rx`/`drain_completions`. Poll the manager's reuse counter so a
497        // background scan still refreshes the bar (#3), otherwise the counts and
498        // `~` marker would only update on a manual `aft_inspect`.
499        (drained, ctx.take_new_reuse_completions())
500    }) else {
501        return;
502    };
503    // A completed background Tier-2 scan refreshes the agent status-bar counts
504    // to the freshly-persisted aggregate, and clears the stale marker, so the
505    // bar reflects the new numbers on the next tool result without waiting for
506    // an explicit aft_inspect call.
507    if drained > 0 || reuse_completed {
508        if let Some(project_root) = ctx.config().project_root.clone() {
509            let inspect_dir = ctx.inspect_dir();
510            let (dead_code, unused_exports, duplicates) = ctx
511                .inspect_manager()
512                .latest_tier2_counts(inspect_dir.clone(), project_root.clone());
513            // Don't clear the `~` stale marker until the whole serial Tier-2
514            // cycle has drained. While any category is still in flight the
515            // already-persisted categories may predate the latest edit, so
516            // claiming fresh would be premature. `None` counts preserve the
517            // last-known value rather than fabricating a `0`.
518            let stale = ctx.inspect_manager().tier2_any_in_flight();
519            ctx.update_status_bar_tier2(dead_code, unused_exports, duplicates, None, stale);
520            // Health must distinguish "tier2 still building" from "tier2 complete
521            // except dead_code, which is blocked on the callgraph store". Refresh
522            // the flag from the same latest aggregate the counts came from so the
523            // two never disagree.
524            let blocked = ctx
525                .inspect_manager()
526                .dead_code_blocked_on_callgraph(inspect_dir, project_root);
527            ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(blocked);
528            ctx.status_emitter().signal(ctx.build_status_snapshot());
529        }
530    }
531}
532
533/// Drain all background build-completion receivers in standalone order.
534///
535/// Search installs first so watcher/pending updates apply to the freshest index,
536/// followed by callgraph store and semantic index completion.
537pub fn drain_build_completions(ctx: &AppContext) {
538    drain_search_index_events(ctx);
539    drain_callgraph_store_events(ctx);
540    drain_semantic_index_events(ctx);
541}
542
543/// Return true when any background build-completion receiver is currently set.
544///
545/// Each receiver is checked under its own short lock; no lock is held while
546/// checking the next subsystem.
547pub fn any_build_in_flight(ctx: &AppContext) -> bool {
548    {
549        let rx = ctx
550            .search_index_rx()
551            .read()
552            .unwrap_or_else(std::sync::PoisonError::into_inner);
553        if rx.is_some() {
554            return true;
555        }
556    }
557
558    {
559        let rx = ctx.callgraph_store_rx().lock();
560        if rx.is_some() {
561            return true;
562        }
563    }
564
565    {
566        let rx = ctx.semantic_index_rx().lock();
567        rx.is_some()
568    }
569}
570
571pub fn watcher_path_is_ignored_by_current_matcher(ctx: &AppContext, path: &Path) -> bool {
572    if watcher_path_is_infra_skip(path) {
573        return true;
574    }
575
576    if let Some(matcher) = ctx.gitignore() {
577        if path.starts_with(matcher.path()) {
578            let is_dir = path.is_dir();
579            return matcher
580                .matched_path_or_any_parents(path, is_dir)
581                .is_ignore();
582        }
583    }
584
585    false
586}
587
588fn replay_search_index_pending_updates(
589    ctx: &AppContext,
590    index: &mut crate::search_index::SearchIndex,
591    pending_paths: Vec<std::path::PathBuf>,
592) {
593    for path in pending_paths {
594        if path.exists() {
595            if watcher_path_is_ignored_by_current_matcher(ctx, &path) {
596                index.remove_file(&path);
597            } else {
598                index.update_file(&path);
599            }
600        } else {
601            index.remove_file(&path);
602        }
603    }
604}
605
606pub fn watcher_path_is_semantic_source(path: &Path) -> bool {
607    crate::semantic_index::is_semantic_indexed_extension(path)
608}
609
610pub fn mark_semantic_corpus_refresh_success(ctx: &AppContext) {
611    ctx.clear_all_semantic_refresh_retry_attempts();
612    ctx.reset_semantic_refresh_circuit_after_success();
613}
614
615pub fn drain_search_index_events(ctx: &AppContext) {
616    let (latest, disconnected, receiver_generation, receiver_epoch) = {
617        let rx_ref = ctx
618            .search_index_rx()
619            .read()
620            .unwrap_or_else(std::sync::PoisonError::into_inner);
621        let Some(rx) = rx_ref.as_ref() else {
622            return;
623        };
624
625        let mut latest = None;
626        let mut disconnected = false;
627        loop {
628            match rx.try_recv() {
629                Ok(index) => latest = Some(index),
630                Err(crossbeam_channel::TryRecvError::Empty) => break,
631                Err(crossbeam_channel::TryRecvError::Disconnected) => {
632                    disconnected = true;
633                    break;
634                }
635            }
636        }
637        (
638            latest,
639            disconnected,
640            ctx.search_index_rx_generation(),
641            ctx.search_index_rx_epoch(),
642        )
643    };
644
645    let mut installed_index = false;
646    if let Some(mut index) = latest {
647        wait_on_artifact_drain_commit_gate_for_test(ctx);
648        installed_index = ctx
649            .with_current_search_index_rx(receiver_generation, receiver_epoch, |receiver| {
650                let pending_paths = ctx.take_pending_search_index_paths();
651                if !pending_paths.is_empty() {
652                    replay_search_index_pending_updates(ctx, &mut index, pending_paths);
653                }
654                *ctx.search_index()
655                    .write()
656                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
657                *receiver = None;
658                true
659            })
660            .unwrap_or(false);
661        if !installed_index {
662            return;
663        }
664        ctx.note_search_index_load_succeeded();
665    } else if disconnected {
666        let cleared = ctx
667            .with_current_search_index_rx(receiver_generation, receiver_epoch, |receiver| {
668                *receiver = None;
669                let mut search_index = ctx
670                    .search_index()
671                    .write()
672                    .unwrap_or_else(std::sync::PoisonError::into_inner);
673                // A build-denied index is a terminal settled state (write access
674                // refused), not an in-progress build, so keep it: clearing it
675                // would flip health back to "building" for a root that can never
676                // produce a real index here.
677                if search_index
678                    .as_ref()
679                    .is_some_and(|index| !index.ready && !index.build_denied)
680                {
681                    *search_index = None;
682                }
683                true
684            })
685            .unwrap_or(false);
686        if !cleared {
687            return;
688        }
689        // The worker dropped its sender without delivering an index (a lost
690        // load). Left alone, the root now has no index and no receiver, so health
691        // would report "building" forever — nothing reschedules the load until a
692        // later search query happens to run. Schedule one automatic replacement
693        // load (capped per configure generation) so the root recovers on its own.
694        crate::commands::configure::restart_search_index_after_load_disconnect(ctx);
695    }
696
697    if installed_index || disconnected {
698        ctx.status_emitter().signal(ctx.build_status_snapshot());
699    }
700}
701
702pub fn drain_callgraph_store_events(ctx: &AppContext) {
703    let (
704        latest,
705        denied,
706        suspended,
707        settled,
708        disconnected,
709        fulfilled_force_token,
710        receiver_generation,
711        receiver_epoch,
712    ) = {
713        let rx_ref = ctx.callgraph_store_rx().lock();
714        let Some(rx) = rx_ref.as_ref() else {
715            return;
716        };
717
718        let mut latest = None;
719        let mut denied = None;
720        let mut suspended = None;
721        let mut settled = false;
722        let mut fulfilled_force_token = None;
723        let mut disconnected = false;
724        loop {
725            match rx.try_recv() {
726                Ok(CallGraphStoreBuildEvent::Ready {
727                    store,
728                    fulfilled_force_token: token,
729                    publication_epoch,
730                }) => {
731                    if ctx.callgraph_persist_epoch_flag().current() == publication_epoch {
732                        latest = Some(store);
733                        fulfilled_force_token = token;
734                    } else {
735                        // A newer configure advanced the persist epoch after this
736                        // build published; its generation is already superseded on
737                        // disk, so treat the event as settled instead of installing
738                        // the stale handle in RAM.
739                        drop(store);
740                        settled = true;
741                    }
742                }
743                Ok(CallGraphStoreBuildEvent::Denied { reason }) => denied = Some(reason),
744                Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
745                    suspended = Some(suspension)
746                }
747                Ok(CallGraphStoreBuildEvent::Settled) => settled = true,
748                Err(crossbeam_channel::TryRecvError::Empty) => break,
749                Err(crossbeam_channel::TryRecvError::Disconnected) => {
750                    disconnected = true;
751                    break;
752                }
753            }
754        }
755        (
756            latest,
757            denied,
758            suspended,
759            settled,
760            disconnected,
761            fulfilled_force_token,
762            ctx.callgraph_store_rx_generation(),
763            ctx.callgraph_store_rx_epoch(),
764        )
765    };
766
767    let ready_received = latest.is_some();
768    let terminal =
769        ready_received || denied.is_some() || suspended.is_some() || settled || disconnected;
770    if !terminal {
771        return;
772    }
773    if let Some(project_root) = ctx.callgraph_project_root() {
774        crate::logging::release_index_build_start_waiters(
775            crate::logging::IndexPlane::Callgraph,
776            &project_root,
777        );
778    }
779    wait_on_artifact_drain_commit_gate_for_test(ctx);
780
781    let mut reopened = None;
782    if let Some(store) = latest {
783        // Release the cold-build writer lease before opening the published
784        // generation through its read-only pointer.
785        drop(store);
786        if let Some(project_root) = ctx.callgraph_project_root() {
787            match CallGraphStore::open_readonly(ctx.callgraph_store_dir(), project_root) {
788                Ok(Some(store)) => reopened = Some(Arc::new(store)),
789                Ok(None) => {
790                    crate::slog_warn!(
791                        "callgraph store build completed without a readable published generation"
792                    );
793                }
794                Err(error) => {
795                    crate::slog_warn!("failed to install read-only callgraph store: {}", error);
796                }
797            }
798        }
799    }
800
801    let mut pending = Vec::new();
802    let installed =
803        ctx.with_current_callgraph_store_rx(receiver_generation, receiver_epoch, |receiver| {
804            let installed = if let Some(store) = reopened {
805                ctx.clear_callgraph_store_build_denied();
806                *ctx.callgraph_store()
807                    .write()
808                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
809                // This take and the refresh worker's post-defer re-check form a
810                // check-then-act handoff: one site sees parked paths with a
811                // ready current store, so neither site needs to poll alone.
812                pending = ctx
813                    .take_pending_callgraph_store_paths()
814                    .into_iter()
815                    .filter(|path| {
816                        invalidates_workspace_crate_prefix_cache(path)
817                            || !watcher_path_is_generated_for_callgraph(ctx, path)
818                    })
819                    .collect();
820                true
821            } else {
822                false
823            };
824            if let Some(reason) = denied {
825                ctx.record_callgraph_store_build_denied(receiver_generation, reason);
826            }
827            if let Some(suspension) = suspended {
828                ctx.record_callgraph_store_build_suspension(receiver_generation, suspension);
829            }
830            if terminal {
831                *receiver = None;
832            }
833            if installed {
834                if let Some(force_token) = fulfilled_force_token {
835                    ctx.fulfill_callgraph_store_force_token(force_token);
836                }
837            }
838            installed
839        });
840    let Some(installed) = installed else {
841        return;
842    };
843
844    if installed {
845        if !pending.is_empty() {
846            let _ = ctx.enqueue_callgraph_store_refresh(pending);
847        }
848        let _ = ctx.request_tier2_refresh_pull();
849    }
850    if terminal {
851        ctx.status_emitter().signal(ctx.build_status_snapshot());
852    }
853}
854
855pub fn drain_semantic_index_events(ctx: &AppContext) {
856    let (events, disconnected, receiver_generation, receiver_epoch) = {
857        let rx_ref = ctx.semantic_index_rx().lock();
858        let Some(rx) = rx_ref.as_ref() else {
859            return;
860        };
861
862        let mut events = Vec::new();
863        let mut disconnected = false;
864        loop {
865            match rx.try_recv() {
866                Ok(event) => events.push(event),
867                Err(crossbeam_channel::TryRecvError::Empty) => break,
868                Err(crossbeam_channel::TryRecvError::Disconnected) => {
869                    disconnected = true;
870                    break;
871                }
872            }
873        }
874        (
875            events,
876            disconnected,
877            ctx.semantic_index_rx_generation(),
878            ctx.semantic_index_rx_epoch(),
879        )
880    };
881
882    if events.is_empty() && !disconnected {
883        return;
884    }
885
886    wait_on_artifact_drain_commit_gate_for_test(ctx);
887    let mut terminal = false;
888    let mut status_changed = false;
889    let mut replay_refresh_paths = Vec::new();
890    let mut replay_corpus_refresh = false;
891    let mut cold_seed_resumes = Vec::new();
892
893    for event in events {
894        match event {
895            SemanticIndexEvent::Progress {
896                stage,
897                files,
898                entries_done,
899                entries_total,
900            } => {
901                let committed = ctx
902                    .with_current_semantic_index_rx(
903                        receiver_generation,
904                        receiver_epoch,
905                        |_receiver| {
906                            *ctx.semantic_index_status()
907                                .write()
908                                .unwrap_or_else(std::sync::PoisonError::into_inner) =
909                                SemanticIndexStatus::Building {
910                                    stage,
911                                    files,
912                                    entries_done,
913                                    entries_total,
914                                };
915                            true
916                        },
917                    )
918                    .unwrap_or(false);
919                if !committed {
920                    return;
921                }
922                status_changed = true;
923            }
924            SemanticIndexEvent::ColdSeedGateCleared => {
925                let resume = ctx.with_current_semantic_index_rx(
926                    receiver_generation,
927                    receiver_epoch,
928                    |_receiver| ctx.take_semantic_cold_seed_resume(true),
929                );
930                let Some(resume) = resume else {
931                    return;
932                };
933                cold_seed_resumes.push(resume);
934            }
935            SemanticIndexEvent::Ready(mut index) => {
936                let committed = ctx.with_current_semantic_index_rx(
937                    receiver_generation,
938                    receiver_epoch,
939                    |receiver| {
940                        mark_semantic_corpus_refresh_success(ctx);
941                        let refresh_paths = ctx
942                            .take_pending_semantic_index_paths()
943                            .into_iter()
944                            .filter(|path| watcher_path_is_semantic_source(path))
945                            .collect::<Vec<_>>();
946                        index.invalidate_files(&refresh_paths);
947                        let corpus_refresh = ctx.take_pending_semantic_corpus_refresh()
948                            && !ctx.shared_artifacts_read_only();
949                        if let Some(root) = ctx.canonical_cache_root_opt() {
950                            let semantic_config = ctx.config().semantic.clone();
951                            let _ = index.adopt_frozen_base_for_root(&root, &semantic_config);
952                        }
953                        *ctx.semantic_index()
954                            .write()
955                            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
956                        *ctx.semantic_index_status()
957                            .write()
958                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
959                            SemanticIndexStatus::ready();
960                        *receiver = None;
961                        (
962                            ctx.take_semantic_cold_seed_resume(false),
963                            refresh_paths,
964                            corpus_refresh,
965                        )
966                    },
967                );
968                let Some((resume, refresh_paths, corpus_refresh)) = committed else {
969                    return;
970                };
971                cold_seed_resumes.push(resume);
972                replay_refresh_paths.extend(refresh_paths);
973                replay_corpus_refresh = corpus_refresh;
974                terminal = true;
975                status_changed = true;
976            }
977            SemanticIndexEvent::Failed(error) => {
978                let committed = ctx.with_current_semantic_index_rx(
979                    receiver_generation,
980                    receiver_epoch,
981                    |receiver| {
982                        let _ = ctx.take_pending_semantic_index_paths();
983                        let _ = ctx.take_pending_semantic_corpus_refresh();
984                        *ctx.semantic_index()
985                            .write()
986                            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
987                        ctx.clear_semantic_refresh_worker();
988                        *ctx.semantic_index_status()
989                            .write()
990                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
991                            SemanticIndexStatus::Failed(error);
992                        *receiver = None;
993                        ctx.take_semantic_cold_seed_resume(false)
994                    },
995                );
996                let Some(resume) = committed else {
997                    return;
998                };
999                cold_seed_resumes.push(resume);
1000                terminal = true;
1001                status_changed = true;
1002            }
1003        }
1004    }
1005
1006    if terminal
1007        && matches!(
1008            &*ctx
1009                .semantic_index_status()
1010                .read()
1011                .unwrap_or_else(std::sync::PoisonError::into_inner),
1012            SemanticIndexStatus::Ready { .. }
1013        )
1014    {
1015        let _ = crate::commands::configure::ensure_ready_semantic_refresh_worker(ctx);
1016    }
1017
1018    if terminal
1019        && ctx.config().views.enabled
1020        && matches!(
1021            &*ctx
1022                .semantic_index_status()
1023                .read()
1024                .unwrap_or_else(std::sync::PoisonError::into_inner),
1025            SemanticIndexStatus::Ready { .. }
1026        )
1027    {
1028        // A refresh completing only matters to the view for the paths whose
1029        // semantic plane was still pending; republishing the whole checkout
1030        // on every refresh re-derived every entry and rescanned the live index
1031        // for nothing (soak finding, 2026-09-12).
1032        if let Some(pending) = semantic_ready_view_publication_paths(
1033            ctx.view_runtime_snapshot()
1034                .map(|snapshot| snapshot.pending_paths),
1035        ) {
1036            if let Err(error) = crate::executor::view_publication::schedule(ctx, pending, true) {
1037                aft::slog_warn!("semantic-ready view publication failed: {}", error);
1038            }
1039        }
1040    }
1041
1042    if disconnected && !terminal {
1043        let committed =
1044            ctx.with_current_semantic_index_rx(receiver_generation, receiver_epoch, |receiver| {
1045                let _ = ctx.take_pending_semantic_index_paths();
1046                let _ = ctx.take_pending_semantic_corpus_refresh();
1047                *ctx.semantic_index()
1048                    .write()
1049                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1050                ctx.clear_semantic_refresh_worker();
1051                *ctx.semantic_index_status()
1052                    .write()
1053                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1054                    SemanticIndexStatus::Failed(
1055                        "semantic index build worker disconnected before reporting completion"
1056                            .to_string(),
1057                    );
1058                *receiver = None;
1059                ctx.take_semantic_cold_seed_resume(false)
1060            });
1061        let Some(resume) = committed else {
1062            return;
1063        };
1064        cold_seed_resumes.push(resume);
1065        status_changed = true;
1066    }
1067
1068    if terminal || disconnected {
1069        ctx.set_semantic_build_progress(None);
1070    }
1071
1072    for resume in cold_seed_resumes {
1073        ctx.apply_semantic_cold_seed_resume(resume);
1074    }
1075
1076    if replay_corpus_refresh {
1077        let replayed = ctx.run_if_subc_bound_generation(receiver_generation, || {
1078            if ctx.semantic_index_rx_epoch() != receiver_epoch
1079                || ctx.canonical_cache_root_opt().is_none()
1080            {
1081                return false;
1082            }
1083            *ctx.semantic_index_status()
1084                .write()
1085                .unwrap_or_else(std::sync::PoisonError::into_inner) =
1086                SemanticIndexStatus::Building {
1087                    stage: "refreshing_corpus".to_string(),
1088                    files: None,
1089                    entries_done: None,
1090                    entries_total: None,
1091                };
1092            let sent = ctx
1093                .semantic_refresh_sender()
1094                .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
1095            if !sent {
1096                *ctx.semantic_index_status()
1097                    .write()
1098                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1099                    SemanticIndexStatus::Failed(
1100                        "semantic corpus refresh worker unavailable".to_string(),
1101                    );
1102            }
1103            true
1104        });
1105        if replayed != Some(true) {
1106            return;
1107        }
1108        status_changed = true;
1109    } else if !replay_refresh_paths.is_empty() {
1110        let replayed = ctx.run_if_subc_bound_generation(receiver_generation, || {
1111            if ctx.semantic_index_rx_epoch() != receiver_epoch {
1112                return false;
1113            }
1114            {
1115                let mut status = ctx
1116                    .semantic_index_status()
1117                    .write()
1118                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1119                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1120                    for path in &replay_refresh_paths {
1121                        status.add_refreshing_file(path.clone());
1122                    }
1123                }
1124            }
1125            let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1126                sender
1127                    .send(SemanticRefreshRequest::Files {
1128                        paths: replay_refresh_paths.clone(),
1129                    })
1130                    .is_ok()
1131            });
1132            if !sent {
1133                crate::slog_warn!(
1134                    "semantic refresh worker unavailable; dropping {} replayed file(s)",
1135                    replay_refresh_paths.len()
1136                );
1137                let mut status = ctx
1138                    .semantic_index_status()
1139                    .write()
1140                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1141                for path in &replay_refresh_paths {
1142                    status.cancel_refreshing_file(path);
1143                }
1144            }
1145            true
1146        });
1147        if replayed != Some(true) {
1148            return;
1149        }
1150        status_changed = true;
1151    }
1152
1153    if status_changed {
1154        ctx.status_emitter().signal(ctx.build_status_snapshot());
1155    }
1156}
1157
1158pub const MAX_RETRY_ATTEMPTS: usize = 6;
1159pub const BREAKER_TRIP_THRESHOLD: usize = 3;
1160
1161#[cfg(test)]
1162static SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
1163
1164/// Backoff for live semantic refresh retries after a transient embedding backend
1165/// failure. Mirrors the cold-build retry cadence (15s -> 30s -> 60s capped) so
1166/// a down backend cannot spin the watcher/refresh loop hot while still
1167/// self-healing once the backend returns.
1168fn semantic_refresh_retry_backoff(attempt: usize) -> Duration {
1169    #[cfg(test)]
1170    {
1171        let override_ms = SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.load(Ordering::SeqCst);
1172        if override_ms != u64::MAX {
1173            return Duration::from_millis(override_ms);
1174        }
1175    }
1176    // Test seam, intentionally matching the build-level retry override.
1177    if let Ok(raw) = std::env::var("AFT_SEMANTIC_RETRY_BACKOFF_MS") {
1178        if let Ok(ms) = raw.parse::<u64>() {
1179            return Duration::from_millis(ms);
1180        }
1181    }
1182    const SCHEDULE_SECS: [u64; 3] = [15, 30, 60];
1183    let secs = SCHEDULE_SECS
1184        .get(attempt)
1185        .copied()
1186        .unwrap_or(*SCHEDULE_SECS.last().unwrap());
1187    Duration::from_secs(secs)
1188}
1189
1190struct SemanticRefreshRetryPlan {
1191    retry_paths: Vec<std::path::PathBuf>,
1192    capped_paths: Vec<std::path::PathBuf>,
1193    delay: Option<Duration>,
1194}
1195
1196fn next_semantic_refresh_retry_plan(
1197    ctx: &AppContext,
1198    paths: Vec<std::path::PathBuf>,
1199) -> SemanticRefreshRetryPlan {
1200    let mut retry_paths = Vec::new();
1201    let mut capped_paths = Vec::new();
1202    let mut max_attempt = 0usize;
1203
1204    ctx.with_semantic_refresh_retry_attempts_mut(|attempts| {
1205        for path in paths {
1206            let attempt = attempts.get(&path).copied().unwrap_or(0);
1207            if attempt >= MAX_RETRY_ATTEMPTS {
1208                capped_paths.push(path);
1209                continue;
1210            }
1211            max_attempt = max_attempt.max(attempt);
1212            attempts.insert(path.clone(), attempt.saturating_add(1));
1213            retry_paths.push(path);
1214        }
1215    });
1216
1217    let delay = if retry_paths.is_empty() {
1218        None
1219    } else {
1220        Some(semantic_refresh_retry_backoff(max_attempt))
1221    };
1222
1223    SemanticRefreshRetryPlan {
1224        retry_paths,
1225        capped_paths,
1226        delay,
1227    }
1228}
1229
1230fn clear_semantic_refresh_retry_attempts(ctx: &AppContext, paths: &[std::path::PathBuf]) {
1231    ctx.clear_semantic_refresh_retry_attempts(paths);
1232}
1233
1234fn clear_completed_pending_semantic_index_paths(
1235    ctx: &AppContext,
1236    completed_paths: &[std::path::PathBuf],
1237) {
1238    if completed_paths.is_empty() {
1239        return;
1240    }
1241
1242    let completed = completed_paths.iter().cloned().collect::<HashSet<_>>();
1243    let remaining = ctx
1244        .take_pending_semantic_index_paths()
1245        .into_iter()
1246        .filter(|path| !completed.contains(path))
1247        .collect::<Vec<_>>();
1248    if !remaining.is_empty() {
1249        ctx.add_pending_semantic_index_paths(remaining);
1250    }
1251}
1252
1253fn semantic_refresh_probe_delay() -> Duration {
1254    semantic_refresh_retry_backoff(usize::MAX)
1255}
1256
1257pub fn semantic_refresh_circuit_is_open(ctx: &AppContext) -> bool {
1258    ctx.semantic_refresh_circuit_is_open()
1259}
1260
1261pub fn record_semantic_refresh_transient_failure(ctx: &AppContext, error: &str) -> bool {
1262    let reason = aft::semantic_index::strip_transient_embedding_marker(error);
1263    ctx.record_semantic_refresh_transient_failure(BREAKER_TRIP_THRESHOLD, &reason)
1264}
1265
1266fn reset_semantic_refresh_transient_failure_count(ctx: &AppContext) {
1267    ctx.reset_semantic_refresh_transient_failure_count();
1268}
1269
1270fn reset_semantic_refresh_circuit_after_success(ctx: &AppContext) {
1271    ctx.reset_semantic_refresh_circuit_after_success();
1272}
1273
1274fn mark_semantic_refresh_success(ctx: &AppContext, completed_paths: &[std::path::PathBuf]) {
1275    clear_semantic_refresh_retry_attempts(ctx, completed_paths);
1276    clear_completed_pending_semantic_index_paths(ctx, completed_paths);
1277    reset_semantic_refresh_circuit_after_success(ctx);
1278}
1279
1280#[doc(hidden)]
1281pub fn semantic_refresh_transient_failure_count_for_test(ctx: &AppContext) -> usize {
1282    ctx.semantic_refresh_transient_failure_count()
1283}
1284
1285#[doc(hidden)]
1286pub fn semantic_refresh_probe_is_scheduled_for_test(ctx: &AppContext) -> bool {
1287    ctx.semantic_refresh_probe_is_scheduled()
1288}
1289
1290fn ensure_semantic_refresh_probe_scheduled(ctx: &AppContext) {
1291    ctx.ensure_semantic_refresh_probe_scheduled(semantic_refresh_probe_delay());
1292}
1293
1294fn maybe_fire_semantic_refresh_probe(ctx: &AppContext) {
1295    let generation = ctx.semantic_refresh_generation();
1296    let _ = ctx.run_if_subc_bound_generation(generation, || {
1297        if !ctx.take_semantic_refresh_probe_ready() {
1298            return;
1299        }
1300        if !semantic_refresh_circuit_is_open(ctx) {
1301            return;
1302        }
1303
1304        if ctx.take_pending_semantic_corpus_refresh() && !ctx.shared_artifacts_read_only() {
1305            // Stamp the status BEFORE sending: the worker emits CorpusStarted
1306            // only after walking the project, and an unbind cancellation in
1307            // that window preserves corpus intent by reading
1308            // corpus_refresh_in_flight() from the status. A send without the
1309            // stamp would lose the intent entirely.
1310            let previous_status = {
1311                let mut status = ctx
1312                    .semantic_index_status()
1313                    .write()
1314                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1315                let previous = status.clone();
1316                *status = SemanticIndexStatus::Building {
1317                    stage: "refreshing_corpus".to_string(),
1318                    files: None,
1319                    entries_done: None,
1320                    entries_total: None,
1321                };
1322                previous
1323            };
1324            let sent = ctx
1325                .semantic_refresh_sender()
1326                .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
1327            if !sent {
1328                *ctx.semantic_index_status()
1329                    .write()
1330                    .unwrap_or_else(std::sync::PoisonError::into_inner) = previous_status;
1331                ctx.mark_pending_semantic_corpus_refresh();
1332            }
1333            return;
1334        }
1335
1336        let pending_paths = ctx.take_pending_semantic_index_paths();
1337        if pending_paths.is_empty() {
1338            return;
1339        }
1340
1341        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1342            sender
1343                .send(SemanticRefreshRequest::Files {
1344                    paths: pending_paths.clone(),
1345                })
1346                .is_ok()
1347        });
1348        if !sent {
1349            ctx.add_pending_semantic_index_paths(pending_paths);
1350        }
1351    });
1352}
1353
1354pub fn schedule_semantic_refresh_retry(
1355    ctx: &AppContext,
1356    paths: Vec<std::path::PathBuf>,
1357    error: &str,
1358) -> bool {
1359    if paths.is_empty() {
1360        return false;
1361    }
1362    if ctx.semantic_refresh_sender().is_none() {
1363        return false;
1364    };
1365
1366    let SemanticRefreshRetryPlan {
1367        retry_paths,
1368        capped_paths,
1369        delay,
1370    } = next_semantic_refresh_retry_plan(ctx, paths);
1371
1372    if !capped_paths.is_empty() {
1373        aft::slog_warn!(
1374            "semantic refresh retry limit reached for {} file(s); preserving for next watcher/configure refresh",
1375            capped_paths.len(),
1376        );
1377        ctx.add_pending_semantic_index_paths(capped_paths);
1378    }
1379
1380    let Some(delay) = delay else {
1381        return true;
1382    };
1383
1384    let clean = aft::semantic_index::strip_transient_embedding_marker(error);
1385    aft::slog_warn!(
1386        "semantic refresh hit a transient backend error ({}); retrying {} file(s) in {}ms",
1387        clean,
1388        retry_paths.len(),
1389        delay.as_millis(),
1390    );
1391
1392    let session_id = log_ctx::current_session();
1393    let generation = ctx.semantic_refresh_generation();
1394    let generation_flag = ctx.configure_generation_flag();
1395    let lifecycle = ctx.subc_lifecycle_admission();
1396    let (sender_slot, pending_paths_slot) = ctx.semantic_refresh_retry_slots();
1397    thread::spawn(move || {
1398        log_ctx::with_session(session_id, || {
1399            thread::sleep(delay);
1400            let _ = lifecycle.run_if_current(&generation_flag, generation, || {
1401                let sent = sender_slot.lock().as_ref().is_some_and(|sender| {
1402                    sender
1403                        .send(SemanticRefreshRequest::Files {
1404                            paths: retry_paths.clone(),
1405                        })
1406                        .is_ok()
1407                });
1408                if !sent {
1409                    pending_paths_slot.lock().extend(retry_paths);
1410                }
1411            });
1412        });
1413    });
1414    true
1415}
1416
1417pub fn drain_semantic_refresh_events(ctx: &AppContext) {
1418    let (events, disconnected, receiver_generation, receiver_epoch) = {
1419        let rx_ref = ctx.semantic_refresh_event_rx().lock();
1420        let Some(rx) = rx_ref.as_ref() else {
1421            return;
1422        };
1423
1424        let mut events = Vec::new();
1425        let mut disconnected = false;
1426        loop {
1427            match rx.try_recv() {
1428                Ok(event) => events.push(event),
1429                Err(crossbeam_channel::TryRecvError::Empty) => break,
1430                Err(crossbeam_channel::TryRecvError::Disconnected) => {
1431                    disconnected = true;
1432                    break;
1433                }
1434            }
1435        }
1436        (
1437            events,
1438            disconnected,
1439            ctx.semantic_refresh_generation(),
1440            ctx.semantic_refresh_epoch(),
1441        )
1442    };
1443
1444    if events.is_empty() && !disconnected {
1445        maybe_fire_semantic_refresh_probe(ctx);
1446        return;
1447    }
1448
1449    wait_on_artifact_drain_commit_gate_for_test(ctx);
1450    let committed = ctx.with_current_semantic_refresh_rx(
1451        receiver_generation,
1452        receiver_epoch,
1453        || {
1454        let had_events = !events.is_empty();
1455        let mut status_changed = false;
1456        let mut replay_refresh_paths = Vec::new();
1457        let mut schedule_breaker_probe = false;
1458        let mut view_refresh_completed = false;
1459        for event in events {
1460        match event {
1461            SemanticRefreshEvent::Started { paths } => {
1462                let mut status = ctx
1463                    .semantic_index_status()
1464                    .write()
1465                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1466                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1467                    for path in paths {
1468                        status.start_refreshing_file(path);
1469                    }
1470                    status_changed = true;
1471                }
1472            }
1473            SemanticRefreshEvent::CorpusStarted { files } => {
1474                *ctx.semantic_index_status()
1475                    .write()
1476                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1477                    SemanticIndexStatus::Building {
1478                        stage: "refreshing_corpus".to_string(),
1479                        files: Some(files),
1480                        entries_done: None,
1481                        entries_total: None,
1482                    };
1483                status_changed = true;
1484            }
1485            SemanticRefreshEvent::Completed {
1486                added_entries,
1487                updated_metadata,
1488                completed_paths,
1489            } => {
1490                if let Some(index) = ctx
1491                    .semantic_index()
1492                    .write()
1493                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1494                    .as_mut()
1495                {
1496                    index.apply_refresh_update(added_entries, updated_metadata, &completed_paths);
1497                }
1498                mark_semantic_refresh_success(ctx, &completed_paths);
1499                view_refresh_completed = true;
1500                let mut status = ctx
1501                    .semantic_index_status()
1502                    .write()
1503                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1504                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1505                    for path in &completed_paths {
1506                        status.complete_refreshing_file(path);
1507                    }
1508                    status_changed = true;
1509                }
1510            }
1511            SemanticRefreshEvent::CorpusCompleted {
1512                mut index,
1513                changed,
1514                added,
1515                deleted,
1516                total_processed,
1517            } => {
1518                aft::runtime_drain::mark_semantic_corpus_refresh_success(ctx);
1519                if changed > 0 || added > 0 || deleted > 0 {
1520                    aft::slog_info!(
1521                        "semantic corpus refresh completed: {} changed, {} new, {} deleted, {} total processed",
1522                        changed,
1523                        added,
1524                        deleted,
1525                        total_processed
1526                    );
1527                }
1528                let pending_paths = ctx.take_pending_semantic_index_paths();
1529                let mut invalidated_paths = Vec::new();
1530                for path in pending_paths {
1531                    if !aft::runtime_drain::watcher_path_is_semantic_source(&path) {
1532                        continue;
1533                    }
1534                    if !aft::runtime_drain::watcher_path_is_ignored_by_current_matcher(ctx, &path) {
1535                        replay_refresh_paths.push(path.clone());
1536                    }
1537                    invalidated_paths.push(path);
1538                }
1539                index.invalidate_files(&invalidated_paths);
1540                *ctx.semantic_index()
1541                    .write()
1542                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
1543                *ctx.semantic_index_status()
1544                    .write()
1545                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1546                    SemanticIndexStatus::ready();
1547                view_refresh_completed = true;
1548                status_changed = true;
1549            }
1550            SemanticRefreshEvent::Failed { paths, error } => {
1551                if aft::semantic_index::embedding_failure_is_transient(&error) {
1552                    if record_semantic_refresh_transient_failure(ctx, &error) {
1553                        ctx.add_pending_semantic_index_paths(paths);
1554                        schedule_breaker_probe = true;
1555                    } else if !schedule_semantic_refresh_retry(ctx, paths.clone(), &error) {
1556                        aft::slog_warn!(
1557                            "semantic refresh worker unavailable; preserving {} transiently failed file(s) for retry",
1558                            paths.len(),
1559                        );
1560                        ctx.add_pending_semantic_index_paths(paths);
1561                    }
1562                } else {
1563                    aft::slog_warn!("semantic refresh failed: {}", error);
1564                    reset_semantic_refresh_transient_failure_count(ctx);
1565                    clear_semantic_refresh_retry_attempts(ctx, &paths);
1566                    let mut status = ctx
1567                        .semantic_index_status()
1568                        .write()
1569                        .unwrap_or_else(std::sync::PoisonError::into_inner);
1570                    if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1571                        for path in &paths {
1572                            status.complete_refreshing_file(path);
1573                        }
1574                        status_changed = true;
1575                    }
1576                }
1577            }
1578            SemanticRefreshEvent::CorpusFailed { paths, error } => {
1579                // A transient backend blip during a corpus refresh must NOT
1580                // destroy the working index — the prior index is still valid and
1581                // serving. Keep it Ready and let the next watcher/ignore change
1582                // re-trigger the refresh, rather than nuking everything to
1583                // `Failed` over a connection hiccup (the same park-forever trap
1584                // the initial build now rides out). Permanent errors (dimension
1585                // mismatch, too-many-files) still drop the index and surface the
1586                // real failure.
1587                if aft::semantic_index::embedding_failure_is_transient(&error) {
1588                    let clean = aft::semantic_index::strip_transient_embedding_marker(&error);
1589                    let has_index = ctx
1590                        .semantic_index()
1591                        .read()
1592                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1593                        .is_some();
1594                    // The corpus worker already paid for the project walk and
1595                    // identified the exact changed/new/deleted file set. Retain
1596                    // only those paths so the probe re-extracts them through the
1597                    // file refresh lane instead of collecting the corpus again.
1598                    ctx.add_pending_semantic_index_paths(paths);
1599                    ctx.trip_semantic_refresh_circuit(BREAKER_TRIP_THRESHOLD, &clean);
1600                    schedule_breaker_probe = true;
1601                    if has_index {
1602                        aft::slog_warn!(
1603                            "semantic corpus refresh hit a transient backend error ({}); keeping the existing index",
1604                            clean,
1605                        );
1606                        *ctx.semantic_index_status()
1607                            .write()
1608                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1609                            SemanticIndexStatus::ready();
1610                    } else {
1611                        // No index to fall back on — surface the clean message.
1612                        aft::slog_warn!("semantic corpus refresh failed: {}", clean);
1613                        *ctx.semantic_index_status()
1614                            .write()
1615                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1616                            SemanticIndexStatus::Failed(clean);
1617                    }
1618                    status_changed = true;
1619                } else {
1620                    aft::slog_warn!("semantic corpus refresh failed: {}", error);
1621                    let _ = ctx.take_pending_semantic_index_paths();
1622                    *ctx.semantic_index()
1623                        .write()
1624                        .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1625                    *ctx.semantic_index_status()
1626                        .write()
1627                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
1628                        SemanticIndexStatus::Failed(error);
1629                    status_changed = true;
1630                }
1631            }
1632            }
1633        }
1634
1635        if disconnected {
1636        let refreshing_paths = {
1637            let status = ctx
1638                .semantic_index_status()
1639                .read()
1640                .unwrap_or_else(std::sync::PoisonError::into_inner);
1641            match &*status {
1642                SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
1643                _ => Vec::new(),
1644            }
1645        };
1646        if !refreshing_paths.is_empty() {
1647            let mut status = ctx
1648                .semantic_index_status()
1649                .write()
1650                .unwrap_or_else(std::sync::PoisonError::into_inner);
1651            for path in &refreshing_paths {
1652                status.cancel_refreshing_file(path);
1653            }
1654        }
1655        if !refreshing_paths.is_empty() || had_events {
1656            status_changed = true;
1657        }
1658    }
1659
1660    if !replay_refresh_paths.is_empty() {
1661        {
1662            let mut status = ctx
1663                .semantic_index_status()
1664                .write()
1665                .unwrap_or_else(std::sync::PoisonError::into_inner);
1666            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1667                for path in &replay_refresh_paths {
1668                    status.add_refreshing_file(path.clone());
1669                }
1670                status_changed = true;
1671            }
1672        }
1673        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1674            sender
1675                .send(SemanticRefreshRequest::Files {
1676                    paths: replay_refresh_paths.clone(),
1677                })
1678                .is_ok()
1679        });
1680        if !sent {
1681            aft::slog_warn!(
1682                "semantic refresh worker unavailable; dropping {} replayed corpus file(s)",
1683                replay_refresh_paths.len()
1684            );
1685            let mut status = ctx
1686                .semantic_index_status()
1687                .write()
1688                .unwrap_or_else(std::sync::PoisonError::into_inner);
1689            for path in &replay_refresh_paths {
1690                status.cancel_refreshing_file(path);
1691            }
1692            status_changed = true;
1693        }
1694        }
1695
1696        (status_changed, schedule_breaker_probe, view_refresh_completed)
1697    },
1698    );
1699    let Some((mut status_changed, schedule_breaker_probe, view_refresh_completed)) = committed
1700    else {
1701        return;
1702    };
1703    if schedule_breaker_probe && semantic_refresh_circuit_is_open(ctx) {
1704        ensure_semantic_refresh_probe_scheduled(ctx);
1705    }
1706    if disconnected {
1707        if let Some(disconnected_build_epoch) =
1708            ctx.clear_semantic_refresh_worker_if_current(receiver_generation, receiver_epoch)
1709        {
1710            wait_on_semantic_refresh_recovery_gate_for_test(ctx);
1711            let _ = crate::commands::configure::restart_semantic_artifacts_after_refresh_disconnect(
1712                ctx,
1713                disconnected_build_epoch,
1714            );
1715            status_changed = true;
1716        }
1717    }
1718
1719    maybe_fire_semantic_refresh_probe(ctx);
1720
1721    if view_refresh_completed {
1722        publish_semantic_ready_view(ctx);
1723    }
1724
1725    if status_changed {
1726        ctx.status_emitter().signal(ctx.build_status_snapshot());
1727    }
1728}
1729
1730fn publish_semantic_ready_view(ctx: &AppContext) {
1731    let Some(paths) = semantic_ready_view_publication_paths(
1732        ctx.view_runtime_snapshot()
1733            .map(|snapshot| snapshot.pending_paths),
1734    ) else {
1735        return;
1736    };
1737    let Some(root) = ctx.canonical_cache_root_opt() else {
1738        return;
1739    };
1740    match crate::executor::view_publication::schedule(ctx, paths, !ctx.shared_artifacts_read_only())
1741    {
1742        Ok(()) => crate::slog_info!(
1743            "content-addressed view publication scheduled after semantic refresh root={}",
1744            root.display()
1745        ),
1746        Err(error) => crate::slog_warn!(
1747            "content-addressed view publication after semantic refresh failed: {}",
1748            error
1749        ),
1750    }
1751}
1752
1753/// Source file extensions that the call graph supports.
1754const SOURCE_EXTENSIONS: &[&str] = &[
1755    "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "pyi", "rs", "go",
1756];
1757
1758pub const WATCHER_BATCH_INLINE_CAP: usize = 256;
1759
1760/// A `tsconfig.json` / `jsconfig.json` (including variant names like
1761/// `tsconfig.base.json`). A change to any of these can shift TypeScript build
1762/// membership (which files `tsc` checks), so the status-bar membership cache
1763/// must be invalidated. Deliberately broad on the variant suffix and ignorant
1764/// of `extends` graphs: the cache is cleared wholesale on a match, and base
1765/// configs almost always follow the `tsconfig*.json` naming. Non-standard base
1766/// names are covered on the next `tsconfig.json` change or `configure`.
1767pub fn watcher_path_is_tsconfig(path: &std::path::Path) -> bool {
1768    path.file_name()
1769        .and_then(|n| n.to_str())
1770        .map(|n| {
1771            n == "tsconfig.json"
1772                || n == "jsconfig.json"
1773                || ((n.starts_with("tsconfig.") || n.starts_with("jsconfig."))
1774                    && n.ends_with(".json"))
1775        })
1776        .unwrap_or(false)
1777}
1778
1779pub fn watcher_path_is_source(path: &std::path::Path) -> bool {
1780    path.extension()
1781        .and_then(|ext| ext.to_str())
1782        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1783}
1784
1785/// A file the callgraph STORE would have indexed at cold-build time. The store
1786/// indexes every file `walk_project_files` yields (i.e. any detected language),
1787/// not just the trigram `SOURCE_EXTENSIONS` set. Gating the store's watcher
1788/// refresh on the narrower trigram set left edits to Java/C/C++/C#/Kotlin/Ruby/
1789/// PHP/… (all of which the store extracts calls for) serving stale results until
1790/// a full rebuild. Mirror cold-build exactly so refresh coverage == index
1791/// coverage.
1792pub fn watcher_path_is_callgraph_indexed(path: &std::path::Path) -> bool {
1793    aft::parser::detect_language(path).is_some()
1794}
1795
1796pub fn semantic_corpus_refresh_in_progress(ctx: &AppContext) -> bool {
1797    let status = ctx
1798        .semantic_index_status()
1799        .read()
1800        .unwrap_or_else(std::sync::PoisonError::into_inner);
1801    matches!(
1802        &*status,
1803        SemanticIndexStatus::Building { stage, .. } if stage == "refreshing_corpus"
1804    )
1805}
1806
1807struct SearchRebuildPublishGate {
1808    reached_tx: crossbeam_channel::Sender<()>,
1809    release_rx: crossbeam_channel::Receiver<()>,
1810}
1811
1812static SEARCH_REBUILD_PUBLISH_GATE: OnceLock<Mutex<Option<SearchRebuildPublishGate>>> =
1813    OnceLock::new();
1814static SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL: OnceLock<Mutex<Option<crossbeam_channel::Sender<()>>>> =
1815    OnceLock::new();
1816
1817#[doc(hidden)]
1818pub fn install_search_rebuild_publish_gate_for_test() -> (
1819    crossbeam_channel::Receiver<()>,
1820    crossbeam_channel::Receiver<()>,
1821    crossbeam_channel::Sender<()>,
1822) {
1823    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1824    let (shutdown_waiting_tx, shutdown_waiting_rx) = crossbeam_channel::bounded(1);
1825    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1826    *SEARCH_REBUILD_PUBLISH_GATE
1827        .get_or_init(|| Mutex::new(None))
1828        .lock()
1829        .expect("search rebuild publish gate mutex poisoned") = Some(SearchRebuildPublishGate {
1830        reached_tx,
1831        release_rx,
1832    });
1833    *SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL
1834        .get_or_init(|| Mutex::new(None))
1835        .lock()
1836        .expect("search rebuild shutdown wait signal mutex poisoned") = Some(shutdown_waiting_tx);
1837    (reached_rx, shutdown_waiting_rx, release_tx)
1838}
1839
1840pub(crate) fn note_search_rebuild_shutdown_wait_for_test() {
1841    let signal = SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL
1842        .get_or_init(|| Mutex::new(None))
1843        .lock()
1844        .expect("search rebuild shutdown wait signal mutex poisoned")
1845        .take();
1846    if let Some(signal) = signal {
1847        let _ = signal.send(());
1848    }
1849}
1850
1851fn wait_on_search_rebuild_publish_gate_for_test() {
1852    let gate = SEARCH_REBUILD_PUBLISH_GATE
1853        .get_or_init(|| Mutex::new(None))
1854        .lock()
1855        .expect("search rebuild publish gate mutex poisoned")
1856        .take();
1857    if let Some(gate) = gate {
1858        let _ = gate.reached_tx.send(());
1859        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1860    }
1861}
1862
1863pub fn spawn_search_corpus_refresh(
1864    ctx: &AppContext,
1865    root: std::path::PathBuf,
1866    config: Arc<aft::config::Config>,
1867) {
1868    let generation = ctx.configure_generation();
1869    let _ = ctx.run_if_subc_bound_generation(generation, || {
1870        spawn_search_corpus_refresh_admitted(ctx, root, config, generation);
1871    });
1872}
1873
1874fn spawn_search_corpus_refresh_admitted(
1875    ctx: &AppContext,
1876    root: std::path::PathBuf,
1877    config: Arc<aft::config::Config>,
1878    generation: u64,
1879) {
1880    {
1881        let mut search_index = ctx
1882            .search_index()
1883            .write()
1884            .unwrap_or_else(std::sync::PoisonError::into_inner);
1885        if let Some(index) = search_index.as_mut() {
1886            index.ready = false;
1887        }
1888    }
1889
1890    let (tx, rx): (
1891        crossbeam_channel::Sender<aft::search_index::SearchIndex>,
1892        crossbeam_channel::Receiver<aft::search_index::SearchIndex>,
1893    ) = crossbeam_channel::unbounded();
1894    let receiver_epoch = ctx.install_search_index_rx(rx, generation);
1895    let receiver_terminal_guard = ctx.search_index_rx_terminal_guard(receiver_epoch);
1896    ctx.reset_symbol_cache();
1897
1898    let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
1899    let project_key = ctx.memoized_artifact_cache_key(&root);
1900    let session_id = log_ctx::current_session();
1901    let generation_flag = ctx.configure_generation_flag();
1902    let content_generation = ctx.configure_content_generation();
1903    let content_generation_flag = ctx.configure_content_generation_flag();
1904    let persist_epoch_flag = ctx.search_persist_epoch_flag();
1905    let persist_epoch = ctx.next_search_persist_epoch();
1906    let lifecycle = ctx.subc_lifecycle_admission();
1907    let cold_build_limiter = ctx.cold_build_limiter();
1908    thread::spawn(move || {
1909        let _terminal_guard = receiver_terminal_guard;
1910        log_ctx::with_session(session_id, || {
1911            let Some(_permit) = crate::cold_build_limiter::acquire_blocking_while_with_limiter(
1912                &cold_build_limiter,
1913                "search corpus refresh",
1914                || lifecycle.is_current(&generation_flag, generation),
1915            ) else {
1916                return;
1917            };
1918            if !lifecycle.is_current(&generation_flag, generation)
1919                || persist_epoch_flag.current() != persist_epoch
1920            {
1921                return;
1922            }
1923            let cache_dir = aft::search_index::resolve_cache_dir_with_key(
1924                &project_key,
1925                config.storage_dir.as_deref(),
1926            );
1927            let cache_lock = if shared_artifacts_read_only {
1928                None
1929            } else {
1930                match aft::search_index::CacheLock::acquire(&cache_dir, &root) {
1931                    Ok(lock) => Some(lock),
1932                    Err(error) => {
1933                        aft::slog_warn!(
1934                            "failed to acquire search cache lock for ignore refresh: {}",
1935                            error
1936                        );
1937                        None
1938                    }
1939                }
1940            };
1941            let mut index = aft::search_index::SearchIndex::build_with_limit_to_cache_dir(
1942                &root,
1943                config.search_index_max_file_size,
1944                &cache_dir,
1945            );
1946            wait_on_search_rebuild_publish_gate_for_test();
1947            // cache_lock is None for borrow-only roots, so ignore-refresh
1948            // never writes cache.bin even when ram_overlay is on.
1949            if cache_lock.is_some()
1950                && content_generation_flag.load(std::sync::atomic::Ordering::SeqCst)
1951                    == content_generation
1952            {
1953                let _ = persist_epoch_flag.run_if_current(persist_epoch, || {
1954                    let head = index.stored_git_head().map(str::to_owned);
1955                    index.write_to_disk(&cache_dir, head.as_deref());
1956                });
1957            }
1958            let _ = lifecycle.run_if_current(&generation_flag, generation, || {
1959                let _ = tx.send(index);
1960            });
1961        });
1962    });
1963}
1964
1965pub fn refresh_project_corpus(
1966    ctx: &AppContext,
1967    reason: &str,
1968    _invalidate_ignore_paths: bool,
1969) -> bool {
1970    let generation = ctx.configure_generation();
1971    ctx.run_if_subc_bound_generation(generation, || {
1972        let Some(root) = ctx.canonical_cache_root_opt() else {
1973            return false;
1974        };
1975        let config = ctx.config();
1976        let mut status_changed = false;
1977
1978        if ctx.callgraph_writer() {
1979            // Do NOT cold-build the callgraph store synchronously here. This function
1980            // runs on the single-threaded dispatch loop from `drain_watcher_events`,
1981            // which fires before EVERY request (and on idle ticks). A full O(repo)
1982            // `refresh_corpus` (= `cold_build`: parse all files + resolve refs +
1983            // rewrite SQLite) blocks ALL queued requests — including `configure` and
1984            // `bash` — for its entire duration, which exceeds the 30s transport
1985            // timeout on a large repo. On a long-lived bridge (OpenCode Desktop) an
1986            // FSEvents overflow triggers this drain, so the user sees configure/bash
1987            // time out (regression: the watcher-overflow path that calls this is new
1988            // in 0.39.1; the ignore-rule path that also calls this had the same
1989            // latent inline block, just rarely triggered).
1990            //
1991            // Instead, drop the resident store and force a BACKGROUND rebuild: the
1992            // next `callgraph_store_for_ops()` spawns the cold build off-thread and
1993            // returns `Building` (callgraph ops + dead_code projection already handle
1994            // `Building`/unavailable gracefully). This mirrors the search/semantic
1995            // refreshes below, which are already async. A build already in flight
1996            // keeps running; the resident drop + force flag make the next op converge
1997            // to a fresh full rebuild.
1998            // Mirror the original "act only when the callgraph is actually loaded or
1999            // building" guard, but reschedule instead of inline-building.
2000            let callgraph_store_resident = {
2001                let guard = ctx
2002                    .callgraph_store()
2003                    .read()
2004                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2005                guard.is_some()
2006            };
2007            if callgraph_store_resident || ctx.callgraph_store_rx().lock().is_some() {
2008                *ctx.callgraph_store()
2009                    .write()
2010                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2011                ctx.mark_callgraph_store_force_rebuild();
2012                status_changed = true;
2013                aft::slog_info!(
2014                    "callgraph store scheduled for background rebuild after {}",
2015                    reason
2016                );
2017            }
2018        }
2019
2020        if config.search_index && !ctx.shared_artifacts_read_only() {
2021            spawn_search_corpus_refresh_admitted(ctx, root.clone(), config.clone(), generation);
2022            status_changed = true;
2023            aft::slog_info!("started search index refresh after {}", reason);
2024        }
2025
2026        if config.semantic_search && !ctx.shared_artifacts_read_only() {
2027            if let Some(sender) = ctx.semantic_refresh_sender() {
2028                *ctx.semantic_index_status()
2029                    .write()
2030                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
2031                    SemanticIndexStatus::Building {
2032                        stage: "refreshing_corpus".to_string(),
2033                        files: None,
2034                        entries_done: None,
2035                        entries_total: None,
2036                    };
2037                match sender.send(SemanticRefreshRequest::Corpus) {
2038                    Ok(()) => {
2039                        status_changed = true;
2040                    }
2041                    Err(error) => {
2042                        *ctx.semantic_index_status()
2043                            .write()
2044                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
2045                            SemanticIndexStatus::Failed(format!(
2046                                "semantic corpus refresh worker unavailable: {error}"
2047                            ));
2048                        status_changed = true;
2049                    }
2050                }
2051            } else if ctx.semantic_index_rx().lock().is_some() {
2052                ctx.mark_pending_semantic_corpus_refresh();
2053            }
2054        }
2055
2056        status_changed
2057    })
2058    .unwrap_or(false)
2059}
2060
2061pub fn refresh_corpus_after_ignore_change(ctx: &AppContext) -> bool {
2062    refresh_project_corpus(ctx, "ignore-rule change", true)
2063}
2064
2065fn watcher_rescan_rss_bytes() -> Option<u64> {
2066    crate::memory::rss_bytes()
2067}
2068
2069fn watcher_rescan_rss_delta(before: Option<u64>, after: Option<u64>) -> Option<i64> {
2070    let delta = i128::from(after?) - i128::from(before?);
2071    Some(delta.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64)
2072}
2073
2074pub fn refresh_project_after_watcher_rescan(ctx: &AppContext) -> bool {
2075    if ctx.canonical_cache_root_opt().is_none() {
2076        return false;
2077    }
2078    let generation = ctx.configure_generation();
2079    let Some(mut status_changed) = ctx.run_if_subc_bound_generation(generation, || {
2080        if let Some(root) = ctx.canonical_cache_root_opt() {
2081            // A rescan means watcher events were LOST. Same-size,
2082            // preserved-mtime edits are exactly what stat-first verification
2083            // misses, so the memo must downgrade to strict content
2084            // verification, not just to stat-first.
2085            crate::cache_freshness::invalidate_verify_memo_strict(&root);
2086        }
2087        ctx.clear_pending_index_updates();
2088        ctx.reset_symbol_cache();
2089        let _ = ctx.mark_status_bar_tier2_stale();
2090        ctx.clear_tsconfig_membership_cache();
2091        true
2092    }) else {
2093        return false;
2094    };
2095
2096    status_changed |= refresh_project_corpus(ctx, "watcher overflow", false);
2097
2098    // The shared corpus refresh only reconciles what is resident or has a live
2099    // worker. After lost events that is not enough: nothing may be resident
2100    // (evicted root), no refresh worker may exist yet, and read-only roots
2101    // skip watcher path application entirely. Force the reconciliation for
2102    // each lane regardless of residency.
2103    let hardened = ctx.run_if_subc_bound_generation(generation, || {
2104        let config = ctx.config();
2105        if ctx.callgraph_writer()
2106            && config.callgraph_store
2107            && ctx.pending_callgraph_store_force_token().is_none()
2108        {
2109            // The corpus refresh above forces a rebuild only when a store was
2110            // resident or building; lost events invalidate the disk
2111            // generation either way.
2112            ctx.mark_callgraph_store_force_rebuild();
2113        }
2114        if ctx.shared_artifacts_read_only() {
2115            // Read-only roots reconcile by re-opening the shared artifacts:
2116            // drop the resident snapshots so the evicted-reload path fires on
2117            // the next query.
2118            ctx.search_index()
2119                .write()
2120                .unwrap_or_else(std::sync::PoisonError::into_inner)
2121                .take();
2122            if config.semantic_search {
2123                ctx.semantic_index()
2124                    .write()
2125                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2126                    .take();
2127                ctx.clear_semantic_refresh_worker();
2128            }
2129        } else if config.semantic_search
2130            && ctx.semantic_refresh_sender().is_none()
2131            && ctx.semantic_index_rx().lock().is_none()
2132        {
2133            // No worker exists to receive the corpus request and none is
2134            // building; retain the intent so the next worker replays it.
2135            ctx.mark_pending_semantic_corpus_refresh();
2136        }
2137    });
2138    status_changed |= hardened.is_some();
2139    status_changed
2140}
2141
2142fn watcher_path_is_generated_for_callgraph(ctx: &AppContext, path: &Path) -> bool {
2143    ctx.callgraph_project_root()
2144        .is_some_and(|project_root| crate::inspect::is_generated_file(&project_root, path))
2145}
2146
2147fn published_view_owns_callgraph_writes(ctx: &AppContext) -> bool {
2148    ctx.config().views.enabled
2149        && ctx
2150            .view_runtime_snapshot()
2151            .is_some_and(|view| view.generation.is_some() && view.manifest.is_some())
2152}
2153
2154fn enqueue_legacy_callgraph_refresh_for_watcher(
2155    ctx: &AppContext,
2156    paths: impl IntoIterator<Item = PathBuf>,
2157    generation: u64,
2158) -> bool {
2159    if published_view_owns_callgraph_writes(ctx) {
2160        let root = ctx
2161            .canonical_cache_root_opt()
2162            .or_else(|| ctx.config().project_root.clone())
2163            .unwrap_or_default();
2164        log::debug!(
2165            "legacy callgraph refresh skipped root={} reason=views_owner",
2166            root.display()
2167        );
2168        return true;
2169    }
2170    note_legacy_watcher_refresh_for_test();
2171    ctx.enqueue_callgraph_store_refresh_for_generation(paths, generation)
2172}
2173
2174pub fn refresh_callgraph_store_for_watcher(
2175    ctx: &AppContext,
2176    changed: &HashSet<std::path::PathBuf>,
2177) {
2178    if !ctx.heavy_root_work_allowed() {
2179        return;
2180    }
2181    let refresh_paths = changed
2182        .iter()
2183        .filter(|path| {
2184            invalidates_workspace_crate_prefix_cache(path)
2185                || (watcher_path_is_callgraph_indexed(path)
2186                    && !watcher_path_is_generated_for_callgraph(ctx, path))
2187        })
2188        .cloned()
2189        .collect::<Vec<_>>();
2190    if refresh_paths.is_empty() {
2191        return;
2192    }
2193    // This is intentionally the only watcher call-site action. Opening and
2194    // mutating SQLite belongs to the process-wide store worker, outside every
2195    // executor lane and its epoch gate.
2196    let _ = enqueue_legacy_callgraph_refresh_for_watcher(
2197        ctx,
2198        refresh_paths,
2199        ctx.configure_generation(),
2200    );
2201}
2202
2203/// Drain pre-filtered watcher events and apply cache invalidations on the
2204/// dispatch thread. The watcher filter thread owns notify receive/decode,
2205/// metadata filtering, ignore matching, root-deleted detection, and path
2206/// coalescing; this drain only reacts to compact control events and surviving
2207/// paths because the cache/index state below is not Send.
2208pub fn drain_watcher_events(ctx: &AppContext) {
2209    loop {
2210        let outcome = drain_watcher_events_bounded(ctx, WATCHER_PATH_DRAIN_BATCH_CAP);
2211        if !outcome.has_more {
2212            break;
2213        }
2214    }
2215}
2216
2217fn watcher_drain_phase_name(stage: WatcherDrainApplyPhase) -> &'static str {
2218    match stage {
2219        WatcherDrainApplyPhase::PendingTier2 => "pending_tier2",
2220        WatcherDrainApplyPhase::PendingIndexes => "pending_indexes",
2221        WatcherDrainApplyPhase::SymbolCache => "symbol_cache",
2222        WatcherDrainApplyPhase::Callgraph => "callgraph",
2223        WatcherDrainApplyPhase::SearchIndex => "search_index",
2224        WatcherDrainApplyPhase::SemanticIndex => "semantic_index",
2225        WatcherDrainApplyPhase::LspDiagnostics => "lsp_diagnostics",
2226        WatcherDrainApplyPhase::Complete => "complete",
2227    }
2228}
2229
2230fn apply_watcher_path_phase(
2231    stage: WatcherDrainApplyPhase,
2232    paths: &mut VecDeque<PathBuf>,
2233    remaining: &mut usize,
2234    started: Instant,
2235    budget: Duration,
2236    mut apply: impl FnMut(&Path),
2237) -> bool {
2238    while *remaining > 0 {
2239        let path = paths
2240            .pop_front()
2241            .expect("watcher apply phase tracks its remaining paths");
2242        {
2243            let _watchdog = WatcherDrainUnitGuard::start(stage, &path);
2244            delay_watcher_unit_for_test();
2245            wait_on_watcher_phase_commit_gate_for_test(&path);
2246            apply(&path);
2247        }
2248        paths.push_back(path);
2249        *remaining -= 1;
2250        if started.elapsed() >= budget {
2251            return false;
2252        }
2253    }
2254    true
2255}
2256
2257fn apply_callgraph_watcher_phase(
2258    ctx: &AppContext,
2259    paths: &mut VecDeque<PathBuf>,
2260    remaining: &mut usize,
2261    started: Instant,
2262    budget: Duration,
2263    enabled: bool,
2264    mut refresh: impl FnMut(&AppContext, &HashSet<PathBuf>),
2265) -> bool {
2266    let mut changed = HashSet::new();
2267    if enabled {
2268        // Include manifest invalidation before the budgeted path loop. If this
2269        // phase yields before reaching Cargo.toml, an earlier source sub-batch
2270        // must still discard the root's old workspace map before resolving refs.
2271        changed.extend(
2272            paths
2273                .iter()
2274                .filter(|path| invalidates_workspace_crate_prefix_cache(path))
2275                .cloned(),
2276        );
2277    }
2278    let mut generated_skipped = 0usize;
2279    let completed = apply_watcher_path_phase(
2280        WatcherDrainApplyPhase::Callgraph,
2281        paths,
2282        remaining,
2283        started,
2284        budget,
2285        |path| {
2286            if !enabled {
2287                return;
2288            }
2289            if invalidates_workspace_crate_prefix_cache(path) {
2290                changed.insert(path.to_path_buf());
2291            } else if watcher_path_is_callgraph_indexed(path) {
2292                if watcher_path_is_generated_for_callgraph(ctx, path) {
2293                    generated_skipped += 1;
2294                } else {
2295                    changed.insert(path.to_path_buf());
2296                }
2297            }
2298        },
2299    );
2300    if generated_skipped > 0 {
2301        log::debug!(
2302            "callgraph refresh skipped {} generated file(s)",
2303            generated_skipped
2304        );
2305    }
2306    if !changed.is_empty() {
2307        let first = changed
2308            .iter()
2309            .min()
2310            .expect("non-empty callgraph watcher batch has a first path");
2311        let _watchdog = WatcherDrainUnitGuard::start_batch(
2312            WatcherDrainApplyPhase::Callgraph,
2313            first,
2314            changed.len(),
2315        );
2316        delay_watcher_unit_for_test();
2317        refresh(ctx, &changed);
2318    }
2319    completed
2320}
2321
2322fn next_watcher_apply_phase(stage: WatcherDrainApplyPhase) -> WatcherDrainApplyPhase {
2323    match stage {
2324        WatcherDrainApplyPhase::PendingTier2 => WatcherDrainApplyPhase::PendingIndexes,
2325        WatcherDrainApplyPhase::PendingIndexes => WatcherDrainApplyPhase::SymbolCache,
2326        WatcherDrainApplyPhase::SymbolCache => WatcherDrainApplyPhase::Callgraph,
2327        WatcherDrainApplyPhase::Callgraph => WatcherDrainApplyPhase::SearchIndex,
2328        WatcherDrainApplyPhase::SearchIndex => WatcherDrainApplyPhase::SemanticIndex,
2329        WatcherDrainApplyPhase::SemanticIndex => WatcherDrainApplyPhase::LspDiagnostics,
2330        WatcherDrainApplyPhase::LspDiagnostics => WatcherDrainApplyPhase::Complete,
2331        WatcherDrainApplyPhase::Complete => WatcherDrainApplyPhase::Complete,
2332    }
2333}
2334
2335fn apply_watcher_slice(ctx: &AppContext, state: &mut WatcherDrainSliceState, started: Instant) {
2336    let WatcherDrainPhase::Apply {
2337        mut stage,
2338        mut paths,
2339        mut remaining,
2340        oversized_inline_batch,
2341    } = std::mem::take(&mut state.phase)
2342    else {
2343        return;
2344    };
2345    let lifecycle_generation = ctx.configure_generation();
2346    // Mid-slice unbind: every per-path mutation below is lifecycle-gated, so
2347    // continuing would burn the whole batch as no-ops and DROP the paths at
2348    // Complete. Abort with the phase intact instead — the continuation is
2349    // retained across lifecycle-only generation changes and the rebind
2350    // rebases and replays it.
2351    if ctx
2352        .run_if_subc_bound_generation(lifecycle_generation, || ())
2353        .is_none()
2354    {
2355        state.phase = WatcherDrainPhase::Apply {
2356            stage,
2357            paths,
2358            remaining,
2359            oversized_inline_batch,
2360        };
2361        return;
2362    }
2363    if !paths.is_empty() || remaining > 0 {
2364        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2365            ctx.invalidate_warm_verify_memo();
2366        });
2367    }
2368    let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
2369    let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
2370    let apply_ram_search_updates = !shared_artifacts_read_only || ctx.ram_overlay_active();
2371    let mut semantic_refresh_paths = std::mem::take(&mut state.semantic_refresh_paths);
2372    let mut status_changed = state.status_changed;
2373
2374    loop {
2375        let completed = match stage {
2376            WatcherDrainApplyPhase::PendingTier2 => apply_watcher_path_phase(
2377                WatcherDrainApplyPhase::PendingTier2,
2378                &mut paths,
2379                &mut remaining,
2380                started,
2381                WATCHER_DRAIN_SLICE_BUDGET,
2382                |path| {
2383                    if heavy_root_work_allowed && ctx.inspect_writer() {
2384                        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2385                            ctx.add_pending_tier2_paths([path.to_path_buf()]);
2386                        });
2387                    }
2388                },
2389            ),
2390            WatcherDrainApplyPhase::PendingIndexes => {
2391                let search_build_in_progress = ctx
2392                    .search_index_rx()
2393                    .read()
2394                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2395                    .is_some();
2396                let semantic_build_in_progress = ctx.semantic_index_rx().lock().is_some();
2397                let semantic_corpus_refresh_in_progress = semantic_corpus_refresh_in_progress(ctx);
2398                apply_watcher_path_phase(
2399                    WatcherDrainApplyPhase::PendingIndexes,
2400                    &mut paths,
2401                    &mut remaining,
2402                    started,
2403                    WATCHER_DRAIN_SLICE_BUDGET,
2404                    |path| {
2405                        if heavy_root_work_allowed
2406                            && !shared_artifacts_read_only
2407                            && search_build_in_progress
2408                        {
2409                            let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2410                                ctx.add_pending_search_index_paths([path.to_path_buf()])
2411                            });
2412                        }
2413                        if heavy_root_work_allowed
2414                            && (!shared_artifacts_read_only || ctx.ram_overlay_active())
2415                            && (semantic_build_in_progress || semantic_corpus_refresh_in_progress)
2416                            && watcher_path_is_semantic_source(path)
2417                        {
2418                            let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2419                                ctx.add_pending_semantic_index_paths([path.to_path_buf()])
2420                            });
2421                        }
2422                    },
2423                )
2424            }
2425            WatcherDrainApplyPhase::SymbolCache => apply_watcher_path_phase(
2426                WatcherDrainApplyPhase::SymbolCache,
2427                &mut paths,
2428                &mut remaining,
2429                started,
2430                WATCHER_DRAIN_SLICE_BUDGET,
2431                |path| {
2432                    if apply_ram_search_updates {
2433                        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2434                            if let Ok(mut symbol_cache) = ctx.symbol_cache().write() {
2435                                symbol_cache.invalidate(path);
2436                            }
2437                        });
2438                    }
2439                },
2440            ),
2441            WatcherDrainApplyPhase::Callgraph => apply_callgraph_watcher_phase(
2442                ctx,
2443                &mut paths,
2444                &mut remaining,
2445                started,
2446                WATCHER_DRAIN_SLICE_BUDGET,
2447                heavy_root_work_allowed,
2448                |ctx, changed| {
2449                    let _ = enqueue_legacy_callgraph_refresh_for_watcher(
2450                        ctx,
2451                        changed.iter().cloned(),
2452                        lifecycle_generation,
2453                    );
2454                },
2455            ),
2456            WatcherDrainApplyPhase::SearchIndex => apply_watcher_path_phase(
2457                WatcherDrainApplyPhase::SearchIndex,
2458                &mut paths,
2459                &mut remaining,
2460                started,
2461                WATCHER_DRAIN_SLICE_BUDGET,
2462                |path| {
2463                    if heavy_root_work_allowed && apply_ram_search_updates {
2464                        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2465                            let mut index_ref = ctx
2466                                .search_index()
2467                                .write()
2468                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2469                            if let Some(index) = index_ref.as_mut() {
2470                                if path.exists() {
2471                                    index.update_file(path);
2472                                } else {
2473                                    index.remove_file(path);
2474                                }
2475                            }
2476                        });
2477                    }
2478                },
2479            ),
2480            WatcherDrainApplyPhase::SemanticIndex => {
2481                // Every root masks stale shared vectors immediately. A writer or
2482                // an enabled RAM overlay also queues a private replacement.
2483                let mut invalidated_paths = Vec::new();
2484                let completed = apply_watcher_path_phase(
2485                    WatcherDrainApplyPhase::SemanticIndex,
2486                    &mut paths,
2487                    &mut remaining,
2488                    started,
2489                    WATCHER_DRAIN_SLICE_BUDGET,
2490                    |path| {
2491                        if heavy_root_work_allowed && watcher_path_is_semantic_source(path) {
2492                            invalidated_paths.push(path.to_path_buf());
2493                        }
2494                    },
2495                );
2496
2497                if !invalidated_paths.is_empty() {
2498                    // Invalidate all semantic paths processed in this slice under
2499                    // one write lock so a multi-file edit scans the index once.
2500                    let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2501                        let invalidated = {
2502                            let mut semantic_index_ref = ctx
2503                                .semantic_index()
2504                                .write()
2505                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2506                            semantic_index_ref.as_mut().is_some_and(|index| {
2507                                index.invalidate_files(&invalidated_paths);
2508                                true
2509                            })
2510                        };
2511                        if invalidated && ctx.semantic_refresh_sender().is_some() {
2512                            let mut status = ctx
2513                                .semantic_index_status()
2514                                .write()
2515                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2516                            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
2517                                for path in invalidated_paths {
2518                                    status.add_refreshing_file(path.clone());
2519                                    semantic_refresh_paths.push(path);
2520                                }
2521                                status_changed = true;
2522                            }
2523                        }
2524                    });
2525                }
2526                completed
2527            }
2528            WatcherDrainApplyPhase::LspDiagnostics => apply_watcher_path_phase(
2529                WatcherDrainApplyPhase::LspDiagnostics,
2530                &mut paths,
2531                &mut remaining,
2532                started,
2533                WATCHER_DRAIN_SLICE_BUDGET,
2534                |path| {
2535                    let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2536                        if !path.exists() {
2537                            status_changed |= ctx.lsp_clear_diagnostics_for_file(path);
2538                            return;
2539                        }
2540                        let stale = ctx.lsp_mark_diagnostics_stale_for_file(path);
2541                        status_changed |= stale.changed;
2542                        if stale.had_entries {
2543                            ctx.lsp_resync_changed_file_for_diagnostics(path);
2544                        }
2545                    });
2546                },
2547            ),
2548            WatcherDrainApplyPhase::Complete => true,
2549        };
2550
2551        // Per-path mutations are lifecycle-gated no-ops once the root unbinds,
2552        // so a stage that overlapped an unbind may have skipped paths (their
2553        // `remaining` was still decremented). Rewind the CURRENT stage (every
2554        // stage action is idempotent) and park the continuation; the rebased
2555        // replay after rebind re-runs it in full. This check must cover the
2556        // budget-exhausted park as well: a mid-stage park that kept the
2557        // decremented `remaining` would permanently skip the gated paths.
2558        if ctx
2559            .run_if_subc_bound_generation(lifecycle_generation, || ())
2560            .is_none()
2561        {
2562            state.status_changed = status_changed;
2563            state.semantic_refresh_paths = semantic_refresh_paths;
2564            remaining = paths.len();
2565            state.phase = WatcherDrainPhase::Apply {
2566                stage,
2567                paths,
2568                remaining,
2569                oversized_inline_batch,
2570            };
2571            return;
2572        }
2573
2574        if !completed {
2575            state.status_changed = status_changed;
2576            state.semantic_refresh_paths = semantic_refresh_paths;
2577            state.phase = WatcherDrainPhase::Apply {
2578                stage,
2579                paths,
2580                remaining,
2581                oversized_inline_batch,
2582            };
2583            return;
2584        }
2585
2586        if stage == WatcherDrainApplyPhase::Complete {
2587            break;
2588        }
2589        stage = next_watcher_apply_phase(stage);
2590        remaining = paths.len();
2591        if started.elapsed() >= WATCHER_DRAIN_SLICE_BUDGET {
2592            state.status_changed = status_changed;
2593            state.semantic_refresh_paths = semantic_refresh_paths;
2594            state.phase = WatcherDrainPhase::Apply {
2595                stage,
2596                paths,
2597                remaining,
2598                oversized_inline_batch,
2599            };
2600            return;
2601        }
2602    }
2603
2604    if !semantic_refresh_paths.is_empty() {
2605        // Distinguish "root unbound" (None) from "worker unavailable"
2606        // (Some(false)): losing admission here must PARK the collected paths
2607        // for the post-rebind replay, not drop them — the per-path
2608        // invalidation already ran, so these paths are the only record that
2609        // the semantic index is stale for them.
2610        match ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2611            ctx.semantic_refresh_sender().is_some_and(|sender| {
2612                sender
2613                    .send(SemanticRefreshRequest::Files {
2614                        paths: semantic_refresh_paths.clone(),
2615                    })
2616                    .is_ok()
2617            })
2618        }) {
2619            Some(true) => {}
2620            Some(false) => {
2621                aft::slog_warn!(
2622                    "semantic refresh worker unavailable; dropping {} refreshing file(s)",
2623                    semantic_refresh_paths.len()
2624                );
2625                let mut status = ctx
2626                    .semantic_index_status()
2627                    .write()
2628                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2629                for path in &semantic_refresh_paths {
2630                    status.cancel_refreshing_file(path);
2631                }
2632                status_changed = true;
2633            }
2634            None => {
2635                state.status_changed = status_changed;
2636                state.semantic_refresh_paths = semantic_refresh_paths;
2637                state.phase = WatcherDrainPhase::Apply {
2638                    stage: WatcherDrainApplyPhase::Complete,
2639                    paths,
2640                    remaining: 0,
2641                    oversized_inline_batch,
2642                };
2643                return;
2644            }
2645        }
2646    }
2647
2648    aft::slog_info!("invalidated {} files", paths.len());
2649    if status_changed {
2650        ctx.status_emitter().signal(ctx.build_status_snapshot());
2651    }
2652    ctx.tick_tier2_refresh_scheduler(state.scheduler_changed_path_count);
2653    state.phase = WatcherDrainPhase::Collect;
2654    state.status_changed = false;
2655    state.scheduler_changed_path_count = 0;
2656    state.semantic_refresh_paths.clear();
2657}
2658
2659/// Paths the view still owes a semantic plane, or `None` when a completed
2660/// refresh has nothing to publish. An absent view runtime (views off, or the
2661/// load failed) and an empty pending set both mean "do nothing": the current
2662/// generation already covers HEAD, and re-deriving it would only mint a
2663/// duplicate.
2664fn semantic_ready_view_publication_paths(
2665    pending: Option<BTreeSet<Vec<u8>>>,
2666) -> Option<BTreeSet<Vec<u8>>> {
2667    pending.filter(|paths| !paths.is_empty())
2668}
2669
2670fn publish_view_if_quiet(ctx: &AppContext, state: &mut WatcherDrainSliceState) {
2671    if !ctx.config().views.enabled
2672        || !matches!(state.phase, WatcherDrainPhase::Collect)
2673        || state
2674            .view_publication_due
2675            .is_none_or(|due| Instant::now() < due)
2676    {
2677        return;
2678    }
2679    let Some(root) = ctx.canonical_cache_root_opt() else {
2680        return;
2681    };
2682    let changed = state
2683        .view_publication_paths
2684        .iter()
2685        .filter_map(|path| path.strip_prefix(&root).ok())
2686        .filter_map(|path| aft::views::RelPath::from_os_path(path).ok())
2687        .map(|path| path.as_bytes().to_vec())
2688        .collect::<BTreeSet<_>>();
2689    match crate::executor::view_publication::schedule(
2690        ctx,
2691        changed,
2692        !ctx.shared_artifacts_read_only(),
2693    ) {
2694        Ok(()) => {
2695            aft::slog_info!(
2696                "content-addressed view publication scheduled root={}",
2697                root.display()
2698            );
2699            state.view_publication_paths.clear();
2700            state.view_publication_due = None;
2701        }
2702        Err(error) => {
2703            aft::slog_warn!(
2704                "content-addressed view publication failed root={} error={}",
2705                root.display(),
2706                error
2707            );
2708            state.view_publication_due = Some(Instant::now() + Duration::from_secs(1));
2709        }
2710    }
2711}
2712
2713pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> DrainBatchOutcome {
2714    let started = Instant::now();
2715    let configure_generation = ctx.configure_generation();
2716    let content_generation = ctx.configure_content_generation();
2717    let mut outcome = DrainBatchOutcome::default();
2718    // Admission before touching the continuation: an unbound invocation must
2719    // leave the retained state in place for the rebind to rebase, not take
2720    // and drop it.
2721    if ctx
2722        .run_if_subc_bound_generation(configure_generation, || ())
2723        .is_none()
2724    {
2725        return outcome;
2726    }
2727    let mut state = match ctx.watcher_drain_slice().lock().take() {
2728        Some(state) if state.configure_generation == configure_generation => state,
2729        // Lifecycle-only generation change (transient unbind + equivalent
2730        // rebind): the retained paths are still valid for this configuration.
2731        // Rebase onto the current generation and replay — every apply phase
2732        // is idempotent (re-indexing an unchanged file is a no-op).
2733        Some(mut state) if state.configure_content_generation == content_generation => {
2734            state.configure_generation = configure_generation;
2735            state
2736        }
2737        _ => WatcherDrainSliceState::new(configure_generation, content_generation),
2738    };
2739    let mut dispatch_events_received = 0usize;
2740    let mut watcher_failed = None;
2741    let mut root_deleted = false;
2742
2743    {
2744        let rx_ref = ctx.watcher_rx().lock();
2745        let Some(rx) = rx_ref.as_ref() else {
2746            ctx.tick_tier2_refresh_scheduler(0);
2747            return outcome;
2748        };
2749
2750        loop {
2751            match rx.try_recv() {
2752                Ok(WatcherDispatchEvent::Paths(paths)) => {
2753                    dispatch_events_received += 1;
2754                    if !state.rescan_required {
2755                        state.pending_paths.extend(paths);
2756                    }
2757                }
2758                Ok(WatcherDispatchEvent::RescanRequired(reason)) => {
2759                    dispatch_events_received += 1;
2760                    state.rescan_required = true;
2761                    state.rescan_reason = reason;
2762                    state.pending_paths.clear();
2763                    state.phase = WatcherDrainPhase::Collect;
2764                    state.semantic_refresh_paths.clear();
2765                    state.scheduler_changed_path_count = 0;
2766                }
2767                Ok(WatcherDispatchEvent::IgnoreRulesChanged { path }) => {
2768                    dispatch_events_received += 1;
2769                    state.ignore_changed = true;
2770                    log::debug!(
2771                        "watcher: ignore rules changed at {}, rebuilding matcher",
2772                        path.display()
2773                    );
2774                    if !state.rescan_required {
2775                        let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
2776                        let _ = ctx.run_if_subc_bound_generation(configure_generation, || {
2777                            if heavy_root_work_allowed {
2778                                ctx.rebuild_gitignore();
2779                            } else {
2780                                ctx.clear_gitignore();
2781                            }
2782                        });
2783                    }
2784                }
2785                Ok(WatcherDispatchEvent::RootDeleted) => {
2786                    dispatch_events_received += 1;
2787                    root_deleted = true;
2788                    break;
2789                }
2790                Ok(WatcherDispatchEvent::Error(error)) => {
2791                    dispatch_events_received += 1;
2792                    watcher_failed = Some(error);
2793                    break;
2794                }
2795                Err(crossbeam_channel::TryRecvError::Empty) => break,
2796                Err(crossbeam_channel::TryRecvError::Disconnected) => {
2797                    watcher_failed = Some("watcher channel disconnected".to_string());
2798                    break;
2799                }
2800            }
2801            if started.elapsed() >= WATCHER_DRAIN_SLICE_BUDGET {
2802                break;
2803            }
2804        }
2805    }
2806
2807    crate::logging::note_watcher_events(dispatch_events_received);
2808    let receiver_has_more_after_receive = ctx
2809        .watcher_rx()
2810        .lock()
2811        .as_ref()
2812        .is_some_and(|rx| !rx.is_empty());
2813
2814    if root_deleted {
2815        ctx.stop_watcher_runtime_in_background();
2816        let _ = ctx.add_degraded_reason("project_root_deleted".to_string());
2817        aft::slog_warn!(
2818            "project root deleted; dropping watcher to avoid delete-storm: {:?}",
2819            ctx.canonical_cache_root_opt()
2820        );
2821        ctx.status_emitter().signal(ctx.build_status_snapshot());
2822        return outcome;
2823    }
2824    if let Some(error) = watcher_failed {
2825        ctx.stop_watcher_runtime_in_background();
2826        let _ = ctx.add_degraded_reason("watcher_unavailable".to_string());
2827        aft::slog_warn!(
2828            "file watcher unavailable; continuing without live external-change invalidation: {}",
2829            error
2830        );
2831        ctx.status_emitter().signal(ctx.build_status_snapshot());
2832        return outcome;
2833    }
2834
2835    if state.rescan_required && receiver_has_more_after_receive {
2836        outcome.has_more = true;
2837        *ctx.watcher_drain_slice().lock() = Some(state);
2838        return outcome;
2839    }
2840
2841    if state.rescan_required {
2842        crate::logging::note_watcher_overflow();
2843        let root = ctx
2844            .canonical_cache_root_opt()
2845            .or_else(|| ctx.config().project_root.clone())
2846            .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
2847        let watcher_counters = ctx.watcher_counters();
2848        watcher_counters.start_rescan();
2849        let mut rescan_reason = state.rescan_reason;
2850        loop {
2851            let interval = watcher_counters.begin_rescan(rescan_reason);
2852            wait_on_watcher_rescan_gate_for_test(ctx);
2853            let app = ctx.app();
2854            let db = app.db();
2855            crate::watcher_filter::persist_watcher_observations(
2856                &root,
2857                &watcher_counters,
2858                db.as_ref(),
2859            );
2860            if ctx.heavy_root_work_allowed() {
2861                ctx.rebuild_gitignore();
2862            } else {
2863                ctx.clear_gitignore();
2864            }
2865            let rss_before = watcher_rescan_rss_bytes();
2866            let rescan_started = Instant::now();
2867            state.status_changed |= refresh_project_after_watcher_rescan(ctx);
2868            let cost_ms = rescan_started.elapsed().as_millis().min(u64::MAX as u128) as u64;
2869            let rss_delta_bytes = watcher_rescan_rss_delta(rss_before, watcher_rescan_rss_bytes());
2870            watcher_counters.finish_rescan(cost_ms, rss_delta_bytes);
2871            crate::logging::log_watcher_rescan(
2872                &root,
2873                rescan_reason,
2874                cost_ms,
2875                rss_delta_bytes,
2876                interval.raw_events,
2877            );
2878            let Some(next_reason) = watcher_counters.finish_rescan_walk() else {
2879                break;
2880            };
2881            rescan_reason = next_reason;
2882        }
2883        state.scheduler_changed_path_count =
2884            aft::inspect::tier2_scheduler::TIER2_REFRESH_STORM_PATH_THRESHOLD + 1;
2885        if state.status_changed {
2886            ctx.status_emitter().signal(ctx.build_status_snapshot());
2887        }
2888        ctx.tick_tier2_refresh_scheduler(state.scheduler_changed_path_count);
2889        // Acknowledge the rescan only if the whole refresh sequence ran under
2890        // the original lifecycle generation: an unbind advances the
2891        // generation, and the internal admission gates then made parts of the
2892        // sequence no-ops. Keeping the flag parks the rescan for the
2893        // post-rebind replay instead of acknowledging a partial one.
2894        if ctx
2895            .run_if_subc_bound_generation(configure_generation, || ())
2896            .is_some()
2897        {
2898            state.rescan_required = false;
2899            state.ignore_changed = false;
2900            if ctx.config().views.enabled {
2901                state.view_publication_paths.clear();
2902                state.view_publication_due = Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2903            }
2904        }
2905        state.status_changed = false;
2906        state.scheduler_changed_path_count = 0;
2907    } else if matches!(state.phase, WatcherDrainPhase::Collect) {
2908        let ignore_changed = state.ignore_changed;
2909        if ignore_changed {
2910            state.status_changed |= refresh_corpus_after_ignore_change(ctx);
2911            // Same partial-sequence rule as the rescan path: acknowledge only
2912            // when the refresh ran fully under this lifecycle generation.
2913            if ctx
2914                .run_if_subc_bound_generation(configure_generation, || ())
2915                .is_some()
2916            {
2917                state.ignore_changed = false;
2918                if ctx.config().views.enabled {
2919                    state.view_publication_paths.clear();
2920                    state.view_publication_due =
2921                        Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2922                }
2923            }
2924        }
2925
2926        if max_paths > 0 && !state.pending_paths.is_empty() {
2927            let mut unique = HashSet::new();
2928            let mut paths = VecDeque::new();
2929            while outcome.processed < max_paths {
2930                let Some(path) = state.pending_paths.pop_front() else {
2931                    break;
2932                };
2933                outcome.processed += 1;
2934                if unique.insert(path.clone()) {
2935                    paths.push_back(path);
2936                }
2937            }
2938            crate::logging::note_drain_paths(outcome.processed);
2939
2940            if paths.is_empty() {
2941                if state.status_changed {
2942                    ctx.status_emitter().signal(ctx.build_status_snapshot());
2943                }
2944                ctx.tick_tier2_refresh_scheduler(usize::from(ignore_changed));
2945                state.status_changed = false;
2946            } else {
2947                let git_head_paths = if ctx.config().views.enabled {
2948                    ctx.refresh_view_head_for_watcher(paths.make_contiguous())
2949                } else {
2950                    BTreeSet::new()
2951                };
2952                paths.retain(|path| !git_head_paths.contains(path));
2953                if ctx.config().views.enabled {
2954                    state.view_publication_paths.extend(paths.iter().cloned());
2955                    state.view_publication_due =
2956                        Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2957                }
2958                state.path_slice_count += 1;
2959                state.scheduler_changed_path_count = if ignore_changed {
2960                    paths.len().max(1)
2961                } else {
2962                    paths.len()
2963                };
2964                if ctx
2965                    .run_if_subc_bound_generation(configure_generation, || {
2966                        ctx.mark_status_bar_tier2_stale()
2967                    })
2968                    .unwrap_or(false)
2969                {
2970                    state.status_changed = true;
2971                }
2972                if paths.iter().any(|path| watcher_path_is_tsconfig(path))
2973                    && ctx
2974                        .run_if_subc_bound_generation(configure_generation, || {
2975                            ctx.clear_tsconfig_membership_cache();
2976                        })
2977                        .is_some()
2978                {
2979                    state.status_changed = true;
2980                }
2981
2982                let oversized_inline_batch = paths.len() > WATCHER_BATCH_INLINE_CAP;
2983                if oversized_inline_batch {
2984                    aft::slog_warn!(
2985                        "watcher batch of {} paths exceeds inline cap {}; applying bounded incremental refresh",
2986                        paths.len(),
2987                        WATCHER_BATCH_INLINE_CAP
2988                    );
2989                }
2990                let remaining = paths.len();
2991                state.phase = WatcherDrainPhase::Apply {
2992                    stage: WatcherDrainApplyPhase::PendingTier2,
2993                    paths,
2994                    remaining,
2995                    oversized_inline_batch,
2996                };
2997            }
2998        } else if ignore_changed {
2999            if state.status_changed {
3000                ctx.status_emitter().signal(ctx.build_status_snapshot());
3001            }
3002            ctx.tick_tier2_refresh_scheduler(1);
3003            state.status_changed = false;
3004        }
3005    }
3006
3007    if matches!(state.phase, WatcherDrainPhase::Apply { .. })
3008        && started.elapsed() < WATCHER_DRAIN_SLICE_BUDGET
3009    {
3010        apply_watcher_slice(ctx, &mut state, started);
3011    }
3012
3013    publish_view_if_quiet(ctx, &mut state);
3014
3015    let receiver_has_more = ctx
3016        .watcher_rx()
3017        .lock()
3018        .as_ref()
3019        .is_some_and(|rx| !rx.is_empty());
3020    outcome.has_more = state.has_pending_work() || receiver_has_more;
3021    // Retain the continuation across lifecycle-only generation changes (a
3022    // mid-drain unbind advances the generation; the rebind rebases). Only a
3023    // content change — a real reconfigure — discards it.
3024    if state.configure_content_generation == ctx.configure_content_generation() {
3025        *ctx.watcher_drain_slice().lock() = Some(state);
3026    }
3027    outcome
3028}
3029
3030pub fn drain_lsp_events(ctx: &AppContext) {
3031    let _ = drain_lsp_events_bounded(ctx, usize::MAX);
3032}
3033
3034/// Shut down language servers for a standalone runtime whose last request is
3035/// older than `idle.lsp_ttl_minutes`. Subc uses the same helper with its own
3036/// per-root activity stamp.
3037pub fn shutdown_idle_lsp(ctx: &AppContext) {
3038    shutdown_idle_lsp_at(ctx, Instant::now(), ctx.last_request_at());
3039}
3040
3041pub fn shutdown_idle_lsp_at(ctx: &AppContext, now: Instant, last_activity: Instant) {
3042    let ttl = ctx.config().idle.lsp_ttl();
3043    let idle = now.saturating_duration_since(last_activity);
3044    if idle < ttl {
3045        return;
3046    }
3047    let clients = {
3048        let mut lsp = ctx.lsp();
3049        if lsp.server_count() == 0 {
3050            return;
3051        }
3052        lsp.take_all_clients()
3053    };
3054    let n = clients.len();
3055    if n == 0 {
3056        return;
3057    }
3058    let root = ctx
3059        .config()
3060        .project_root
3061        .as_ref()
3062        .map(|path| path.display().to_string())
3063        .unwrap_or_else(|| "standalone".to_string());
3064    aft::slog_info!(
3065        "idle lsp reap {root}: shut down {n} server(s) after {}m (ttl {}m)",
3066        idle.as_secs() / 60,
3067        ctx.config().idle.lsp_ttl_minutes
3068    );
3069    crate::lsp::manager::LspManager::spawn_idle_lsp_reap(clients);
3070}
3071
3072pub fn drain_lsp_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
3073    let drained = {
3074        let mut lsp = ctx.lsp();
3075        lsp.drain_events_bounded(max_events)
3076    };
3077    let outcome = DrainBatchOutcome {
3078        processed: drained.events.len(),
3079        has_more: drained.has_more,
3080    };
3081    let mut status_changed = drained.diagnostics_changed;
3082    for event in drained.events {
3083        match event {
3084            LspEvent::Notification {
3085                server_kind,
3086                root,
3087                method,
3088                params,
3089            } => {
3090                log::debug!(
3091                    "[aft-lsp] notification {:?} {} {} {}",
3092                    server_kind,
3093                    root.display(),
3094                    method,
3095                    params.unwrap_or(serde_json::Value::Null)
3096                );
3097            }
3098            LspEvent::ServerRequest {
3099                server_kind,
3100                root,
3101                id,
3102                method,
3103                params,
3104            } => {
3105                log::debug!(
3106                    "[aft-lsp] request {:?} {} {:?} {} {}",
3107                    server_kind,
3108                    root.display(),
3109                    id,
3110                    method,
3111                    params.unwrap_or(serde_json::Value::Null)
3112                );
3113            }
3114            LspEvent::ServerExited {
3115                server_kind,
3116                root,
3117                reason,
3118            } => {
3119                aft::slog_info!("exited {:?} {} ({reason})", server_kind, root.display());
3120                status_changed = true;
3121            }
3122        }
3123    }
3124    if status_changed {
3125        ctx.status_emitter().signal(ctx.build_status_snapshot());
3126    }
3127    outcome
3128}
3129
3130#[cfg(test)]
3131pub(crate) fn configure_search_order_context_for_test(
3132    root: &Path,
3133    storage: &Path,
3134) -> (AppContext, std::path::PathBuf) {
3135    std::fs::write(root.join(".gitignore"), "ignored.rs\n").unwrap();
3136    std::fs::write(root.join("ignored.rs"), "fn ignored_marker() {}\n").unwrap();
3137
3138    let ctx = AppContext::new(
3139        crate::context::default_language_provider_factory(),
3140        crate::config::Config {
3141            project_root: Some(root.to_path_buf()),
3142            storage_dir: Some(storage.to_path_buf()),
3143            ..crate::config::Config::default()
3144        },
3145    );
3146    let canonical_root = std::fs::canonicalize(root).unwrap();
3147    let ignored_path = canonical_root.join("ignored.rs");
3148    ctx.set_canonical_cache_root(canonical_root.clone());
3149    ctx.set_harness(crate::harness::Harness::Opencode);
3150    ctx.enqueue_configure_maintenance(crate::context::ConfigureMaintenanceJob {
3151        generation: ctx.configure_generation(),
3152        root_path: root.to_path_buf(),
3153        canonical_cache_root: canonical_root,
3154        harness: crate::harness::Harness::Opencode,
3155        storage_root: storage.to_path_buf(),
3156        harness_dir: storage.join("opencode"),
3157        session_id: "order-test".to_string(),
3158        home_match: false,
3159        format_tool_cache_clear_needed: false,
3160        run_bash_replay: false,
3161        refresh_project_runtime: true,
3162        sync_bash_compress_flag: false,
3163        reset_filter_registry: false,
3164        clear_failed_spawns: false,
3165        warm_callgraph_store: false,
3166        supersede_search_artifact_persistence: false,
3167        supersede_callgraph_artifact_persistence: false,
3168        supersede_semantic_artifact_persistence: false,
3169        search_artifact_load_start: None,
3170        semantic_artifact_load_start: None,
3171    })
3172    .expect("test configure maintenance queue has capacity");
3173
3174    let (search_tx, search_rx) = crossbeam_channel::unbounded();
3175    search_tx
3176        .send(crate::search_index::SearchIndex::new())
3177        .unwrap();
3178    drop(search_tx);
3179    *ctx.search_index_rx()
3180        .write()
3181        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(search_rx);
3182    ctx.add_pending_search_index_paths([ignored_path.clone()]);
3183    (ctx, ignored_path)
3184}
3185
3186#[cfg(test)]
3187mod tests {
3188    use super::*;
3189    use crate::config::Config;
3190    use crate::context::{default_language_provider_factory, AppContext};
3191
3192    #[test]
3193    fn semantic_ready_publication_is_skipped_unless_paths_are_pending() {
3194        assert_eq!(semantic_ready_view_publication_paths(None), None);
3195        assert_eq!(
3196            semantic_ready_view_publication_paths(Some(BTreeSet::new())),
3197            None,
3198            "an up-to-date view must not republish when a refresh completes"
3199        );
3200        let pending = BTreeSet::from([b"src/lib.rs".to_vec()]);
3201        assert_eq!(
3202            semantic_ready_view_publication_paths(Some(pending.clone())),
3203            Some(pending),
3204            "only the paths still owed a semantic plane are published"
3205        );
3206    }
3207
3208    fn watcher_context(
3209        root: &Path,
3210    ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
3211        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
3212        ctx.update_config(|config| {
3213            config.project_root = Some(root.to_path_buf());
3214        });
3215        ctx.set_canonical_cache_root(root.to_path_buf());
3216        let (tx, rx) = crossbeam_channel::unbounded();
3217        *ctx.watcher_rx().lock() = Some(rx);
3218        (ctx, tx)
3219    }
3220
3221    #[test]
3222    fn watcher_semantic_phase_batches_invalidation_into_one_retain_pass() {
3223        let root = tempfile::tempdir().unwrap();
3224        let root_path = root.path().canonicalize().unwrap();
3225        let files = (0..4)
3226            .map(|ordinal| {
3227                let file = root_path.join(format!("source_{ordinal}.rs"));
3228                std::fs::write(&file, format!("pub fn source_{ordinal}() {{}}\n")).unwrap();
3229                file
3230            })
3231            .collect::<Vec<_>>();
3232        let mut embed = |texts: Vec<String>| {
3233            Ok::<_, String>(texts.into_iter().map(|_| vec![1.0, 0.5]).collect())
3234        };
3235        let index = crate::semantic_index::SemanticIndex::build(
3236            &root_path,
3237            &files,
3238            &mut embed,
3239            files.len(),
3240        )
3241        .unwrap();
3242        assert!(index.entry_count() >= files.len());
3243
3244        let (ctx, watcher_tx) = watcher_context(&root_path);
3245        ctx.mark_subc_bound();
3246        ctx.set_heavy_root_work_allowed(true);
3247        ctx.set_cache_writer_capabilities(true, true);
3248        *ctx.semantic_index()
3249            .write()
3250            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
3251        *ctx.semantic_index_status()
3252            .write()
3253            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
3254        watcher_tx
3255            .send(WatcherDispatchEvent::Paths(files.clone()))
3256            .unwrap();
3257
3258        let outcome = drain_watcher_events_bounded(&ctx, files.len());
3259
3260        assert_eq!(outcome.processed, files.len());
3261        assert!(!outcome.has_more);
3262        let index = ctx
3263            .semantic_index()
3264            .read()
3265            .unwrap_or_else(std::sync::PoisonError::into_inner);
3266        let index = index.as_ref().unwrap();
3267        assert_eq!(index.entry_count(), 0);
3268        assert_eq!(index.removal_retain_passes_for_test(), 1);
3269    }
3270
3271    #[test]
3272    fn newer_watcher_refresh_prevents_older_configure_build_from_overwriting_disk() {
3273        let root = tempfile::tempdir().unwrap();
3274        let storage = tempfile::tempdir().unwrap();
3275        let root_path = root.path().canonicalize().unwrap();
3276        let source = root_path.join("marker.rs");
3277        std::fs::write(&source, "fn old_generation_marker() {}\n").unwrap();
3278
3279        let ctx = AppContext::new(
3280            default_language_provider_factory(),
3281            Config {
3282                project_root: Some(root_path.clone()),
3283                storage_dir: Some(storage.path().to_path_buf()),
3284                ..Config::default()
3285            },
3286        );
3287        ctx.set_canonical_cache_root(root_path.clone());
3288        ctx.set_harness(crate::harness::Harness::Opencode);
3289
3290        let project_key = ctx.memoized_artifact_cache_key(&root_path);
3291        let cache_dir =
3292            crate::search_index::resolve_cache_dir_with_key(&project_key, Some(storage.path()));
3293        let mut older_index = crate::search_index::SearchIndex::build(&root_path);
3294        let older_epoch = ctx.next_search_persist_epoch();
3295        let persist_epoch = ctx.search_persist_epoch_flag();
3296        let (older_reached_tx, older_reached_rx) = std::sync::mpsc::channel();
3297        let (older_release_tx, older_release_rx) = std::sync::mpsc::channel();
3298        let older_root = root_path.clone();
3299        let older_cache = cache_dir.clone();
3300        let older_writer = std::thread::spawn(move || {
3301            older_reached_tx.send(()).unwrap();
3302            older_release_rx.recv().unwrap();
3303            let _lock = crate::search_index::CacheLock::acquire(&older_cache, &older_root)
3304                .expect("older build should acquire the persistence lock");
3305            let _ = persist_epoch.run_if_current(older_epoch, || {
3306                older_index.write_to_disk(&older_cache, None);
3307            });
3308        });
3309        older_reached_rx
3310            .recv_timeout(Duration::from_secs(2))
3311            .expect("older configure build did not reach its persistence barrier");
3312
3313        std::fs::write(&source, "fn new_watcher_marker() {}\n").unwrap();
3314        spawn_search_corpus_refresh(&ctx, root_path.clone(), ctx.config());
3315        let refresh_rx = ctx
3316            .search_index_rx()
3317            .read()
3318            .unwrap_or_else(std::sync::PoisonError::into_inner)
3319            .as_ref()
3320            .expect("watcher refresh receiver")
3321            .clone();
3322        refresh_rx
3323            .recv_timeout(Duration::from_secs(12))
3324            .expect("watcher refresh did not complete");
3325
3326        older_release_tx.send(()).unwrap();
3327        older_writer.join().unwrap();
3328
3329        let disk = crate::search_index::SearchIndex::read_from_disk(&cache_dir, &root_path)
3330            .expect("persisted search index");
3331        assert_eq!(
3332            disk.grep("new_watcher_marker", true, &[], &[], &root_path, 10)
3333                .matches
3334                .len(),
3335            1,
3336            "newer watcher refresh must remain on disk"
3337        );
3338        assert!(
3339            disk.grep("old_generation_marker", true, &[], &[], &root_path, 10)
3340                .matches
3341                .is_empty(),
3342            "older configure build must not overwrite the newer watcher refresh"
3343        );
3344    }
3345
3346    #[test]
3347    fn watcher_phase_dequeued_before_unbind_cannot_index_after_teardown() {
3348        let temp = tempfile::tempdir().unwrap();
3349        let root = std::fs::canonicalize(temp.path()).unwrap();
3350        let root = root.as_path();
3351        let source = root.join("changed.rs");
3352        std::fs::write(&source, "fn watcher_marker() {}\n").unwrap();
3353        let (ctx, watcher_tx) = watcher_context(root);
3354        *ctx.search_index()
3355            .write()
3356            .unwrap_or_else(std::sync::PoisonError::into_inner) =
3357            Some(crate::search_index::SearchIndex::new());
3358        watcher_tx
3359            .send(WatcherDispatchEvent::Paths(vec![source.clone()]))
3360            .unwrap();
3361
3362        let ctx = Arc::new(ctx);
3363        let (reached_rx, release_tx) = install_watcher_phase_commit_gate_for_test(source.clone());
3364        let drain_ctx = Arc::clone(&ctx);
3365        let drain = std::thread::spawn(move || {
3366            while drain_watcher_events_bounded(&drain_ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {}
3367        });
3368        reached_rx
3369            .recv_timeout(Duration::from_secs(2))
3370            .expect("watcher phase did not reach its commit barrier");
3371        ctx.mark_subc_unbound();
3372        release_tx.send(()).unwrap();
3373        drain.join().unwrap();
3374
3375        {
3376            let search = ctx
3377                .search_index()
3378                .read()
3379                .unwrap_or_else(std::sync::PoisonError::into_inner);
3380            assert!(
3381                search
3382                    .as_ref()
3383                    .expect("search index")
3384                    .grep("watcher_marker", true, &[], &[], root, 10)
3385                    .matches
3386                    .is_empty(),
3387                "watcher work dequeued before teardown must not mutate the index after unbind"
3388            );
3389        }
3390
3391        // The unapplied path survives the unbound window in the retained
3392        // continuation; an equivalent rebind rebases and replays it.
3393        ctx.mark_subc_bound();
3394        let mut guard = 0;
3395        while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3396            guard += 1;
3397            assert!(guard < 16, "rebased replay must finish");
3398        }
3399        let search = ctx
3400            .search_index()
3401            .read()
3402            .unwrap_or_else(std::sync::PoisonError::into_inner);
3403        assert_eq!(
3404            search
3405                .as_ref()
3406                .expect("search index")
3407                .grep("watcher_marker", true, &[], &[], root, 10)
3408                .matches
3409                .len(),
3410            1,
3411            "post-rebind replay must apply the retained watcher path"
3412        );
3413    }
3414
3415    #[test]
3416    fn standalone_configure_tail_precedes_completed_search_install() {
3417        let root = tempfile::tempdir().unwrap();
3418        let storage = tempfile::tempdir().unwrap();
3419        let (ctx, ignored_path) =
3420            configure_search_order_context_for_test(root.path(), storage.path());
3421        assert!(!watcher_path_is_ignored_by_current_matcher(
3422            &ctx,
3423            &ignored_path
3424        ));
3425
3426        drain_deferred_configure_maintenance(&ctx);
3427        drain_configure_warning_events(&ctx);
3428        drain_search_index_events(&ctx);
3429
3430        assert!(watcher_path_is_ignored_by_current_matcher(
3431            &ctx,
3432            &ignored_path
3433        ));
3434        assert_eq!(
3435            ctx.search_index()
3436                .read()
3437                .unwrap_or_else(std::sync::PoisonError::into_inner)
3438                .as_ref()
3439                .expect("completed search index installed")
3440                .file_count(),
3441            0,
3442            "configure must install the ignore matcher before pending paths replay"
3443        );
3444        ctx.stop_watcher_runtime();
3445    }
3446
3447    #[test]
3448    fn post_ack_semantic_ready_transition_pushes_status_changed() {
3449        let root = tempfile::tempdir().unwrap();
3450        let config = Config {
3451            project_root: Some(root.path().to_path_buf()),
3452            semantic_search: true,
3453            ..Config::default()
3454        };
3455        let ctx = AppContext::new(default_language_provider_factory(), config);
3456        ctx.set_canonical_cache_root(root.path().to_path_buf());
3457        *ctx.semantic_index_status()
3458            .write()
3459            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
3460            stage: "loading_artifacts".to_string(),
3461            files: None,
3462            entries_done: None,
3463            entries_total: None,
3464        };
3465        let (event_tx, event_rx) = crossbeam_channel::unbounded();
3466        *ctx.semantic_index_rx().lock() = Some(event_rx);
3467        let (push_tx, push_rx) = std::sync::mpsc::channel();
3468        ctx.set_progress_sender(Some(std::sync::Arc::new(Box::new(move |frame| {
3469            let _ = push_tx.send(frame);
3470        }))));
3471
3472        event_tx
3473            .send(SemanticIndexEvent::Ready(
3474                crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
3475            ))
3476            .unwrap();
3477        drain_semantic_index_events(&ctx);
3478
3479        assert!(matches!(
3480            &*ctx
3481                .semantic_index_status()
3482                .read()
3483                .unwrap_or_else(std::sync::PoisonError::into_inner),
3484            SemanticIndexStatus::Ready { .. }
3485        ));
3486        let pushed = push_rx
3487            .recv_timeout(Duration::from_secs(2))
3488            .expect("ready transition should push status_changed");
3489        assert!(matches!(
3490            pushed,
3491            crate::protocol::PushFrame::StatusChanged(_)
3492        ));
3493    }
3494
3495    #[test]
3496    fn watcher_overflow_invalidates_artifact_freshness_memo() {
3497        let root = tempfile::tempdir().unwrap();
3498        let artifact = root.path().join("semantic.bin");
3499        std::fs::write(&artifact, b"artifact").unwrap();
3500        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
3501        let generation = crate::cache_freshness::artifact_generation(&artifact);
3502        let ticket = crate::cache_freshness::capture_verify_ticket(&canonical_root);
3503        assert!(
3504            crate::cache_freshness::record_verify_completed_if_unchanged(
3505                &canonical_root,
3506                crate::cache_freshness::VerifyArtifact::Semantic,
3507                generation,
3508                ticket,
3509            )
3510        );
3511        assert_eq!(
3512            crate::cache_freshness::warm_verify_plan(
3513                &canonical_root,
3514                crate::cache_freshness::VerifyArtifact::Semantic,
3515                generation,
3516            ),
3517            crate::cache_freshness::WarmVerifyPlan::Skip
3518        );
3519
3520        let ctx = AppContext::new(
3521            default_language_provider_factory(),
3522            Config {
3523                project_root: Some(canonical_root.clone()),
3524                ..Config::default()
3525            },
3526        );
3527        ctx.set_canonical_cache_root(canonical_root.clone());
3528        refresh_project_after_watcher_rescan(&ctx);
3529
3530        assert_eq!(
3531            crate::cache_freshness::warm_verify_plan(
3532                &canonical_root,
3533                crate::cache_freshness::VerifyArtifact::Semantic,
3534                generation,
3535            ),
3536            crate::cache_freshness::WarmVerifyPlan::Strict,
3537            "lost watcher events force STRICT verification: stat-first would \
3538             miss same-size, preserved-mtime edits made during the gap"
3539        );
3540    }
3541
3542    #[test]
3543    fn superseded_callgraph_worker_settles_receiver_and_allows_retry() {
3544        let root = tempfile::tempdir().unwrap();
3545        let storage = tempfile::tempdir().unwrap();
3546        std::fs::write(root.path().join("lib.rs"), "pub fn marker() {}\n").unwrap();
3547        let ctx = AppContext::new(
3548            default_language_provider_factory(),
3549            Config {
3550                project_root: Some(root.path().to_path_buf()),
3551                storage_dir: Some(storage.path().to_path_buf()),
3552                callgraph_chunk_size: 1,
3553                ..Config::default()
3554            },
3555        );
3556        let generation = ctx.configure_generation();
3557        let (worker_tx, worker_rx) = crossbeam_channel::unbounded();
3558        ctx.note_callgraph_store_rx_generation(generation);
3559        ctx.next_callgraph_store_rx_epoch();
3560        *ctx.callgraph_store_rx().lock() = Some(worker_rx);
3561
3562        drain_callgraph_store_events(&ctx);
3563        assert!(
3564            ctx.callgraph_store_rx().lock().is_some(),
3565            "an empty running receiver remains in flight"
3566        );
3567        ctx.next_callgraph_persist_epoch();
3568        worker_tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3569        drain_callgraph_store_events(&ctx);
3570        assert!(
3571            ctx.callgraph_store_rx().lock().is_none(),
3572            "a superseded worker must explicitly retire its receiver"
3573        );
3574
3575        assert!(matches!(
3576            ctx.callgraph_store_for_ops(),
3577            crate::context::CallgraphStoreAccess::Building
3578                | crate::context::CallgraphStoreAccess::Ready(_)
3579        ));
3580        assert!(
3581            ctx.callgraph_store_rx().lock().is_some()
3582                || ctx
3583                    .callgraph_store()
3584                    .read()
3585                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3586                    .is_some(),
3587            "a later operation must be able to retry the callgraph build"
3588        );
3589    }
3590
3591    #[test]
3592    fn failed_forced_callgraph_build_preserves_durable_demand() {
3593        let root = tempfile::tempdir().unwrap();
3594        let ctx = AppContext::new(
3595            default_language_provider_factory(),
3596            Config {
3597                project_root: Some(root.path().to_path_buf()),
3598                ..Config::default()
3599            },
3600        );
3601        let force_token = ctx.mark_callgraph_store_force_rebuild();
3602        assert_eq!(ctx.pending_callgraph_store_force_token(), Some(force_token));
3603
3604        let generation = ctx.configure_generation();
3605        let (tx, rx) = crossbeam_channel::unbounded();
3606        ctx.note_callgraph_store_rx_generation(generation);
3607        ctx.next_callgraph_store_rx_epoch();
3608        *ctx.callgraph_store_rx().lock() = Some(rx);
3609        tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3610        drain_callgraph_store_events(&ctx);
3611
3612        assert!(
3613            ctx.pending_callgraph_store_force_token().is_some(),
3614            "the current failed forced build must preserve retry demand"
3615        );
3616        assert!(ctx.callgraph_store_rx().lock().is_none());
3617    }
3618
3619    #[test]
3620    fn newer_forced_callgraph_demand_survives_older_publication() {
3621        let root = tempfile::tempdir().unwrap();
3622        let storage = tempfile::tempdir().unwrap();
3623        let source = root.path().join("lib.rs");
3624        std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3625        let project_root = std::fs::canonicalize(root.path()).unwrap();
3626        let ctx = AppContext::new(
3627            default_language_provider_factory(),
3628            Config {
3629                project_root: Some(project_root.clone()),
3630                storage_dir: Some(storage.path().to_path_buf()),
3631                ..Config::default()
3632            },
3633        );
3634        ctx.set_canonical_cache_root(project_root.clone());
3635        let project_key = crate::search_index::artifact_cache_key(&project_root);
3636        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3637        let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3638            ctx.callgraph_store_dir(),
3639            project_root,
3640            &[source],
3641            1,
3642        )
3643        .unwrap();
3644        let older = ctx.mark_callgraph_store_force_rebuild();
3645        let generation = ctx.configure_generation();
3646        let (tx, rx) = crossbeam_channel::unbounded();
3647        ctx.note_callgraph_store_rx_generation(generation);
3648        ctx.next_callgraph_store_rx_epoch();
3649        *ctx.callgraph_store_rx().lock() = Some(rx);
3650        tx.send(CallGraphStoreBuildEvent::Ready {
3651            store,
3652            fulfilled_force_token: Some(older),
3653            publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3654        })
3655        .unwrap();
3656        let newer = ctx.mark_callgraph_store_force_rebuild();
3657
3658        drain_callgraph_store_events(&ctx);
3659
3660        assert!(ctx.callgraph_store().read().unwrap().is_some());
3661        assert_eq!(ctx.pending_callgraph_store_force_token(), Some(newer));
3662        assert!(matches!(
3663            ctx.callgraph_store_for_ops(),
3664            crate::context::CallgraphStoreAccess::Building
3665        ));
3666        assert!(ctx.callgraph_store_rx().lock().is_some());
3667
3668        let deadline = Instant::now() + Duration::from_secs(10);
3669        while ctx.pending_callgraph_store_force_token().is_some() {
3670            drain_callgraph_store_events(&ctx);
3671            assert!(
3672                Instant::now() < deadline,
3673                "newer forced callgraph rebuild did not publish"
3674            );
3675            std::thread::sleep(Duration::from_millis(5));
3676        }
3677        assert!(ctx.callgraph_store().read().unwrap().is_some());
3678    }
3679
3680    #[test]
3681    fn callgraph_ready_without_published_pointer_settles_and_preserves_pending_paths() {
3682        let root = tempfile::tempdir().unwrap();
3683        let storage = tempfile::tempdir().unwrap();
3684        let source = root.path().join("lib.rs");
3685        std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3686        let project_root = std::fs::canonicalize(root.path()).unwrap();
3687        let ctx = AppContext::new(
3688            default_language_provider_factory(),
3689            Config {
3690                project_root: Some(project_root.clone()),
3691                storage_dir: Some(storage.path().to_path_buf()),
3692                callgraph_chunk_size: 1,
3693                ..Config::default()
3694            },
3695        );
3696        ctx.set_canonical_cache_root(project_root.clone());
3697        let project_key = crate::search_index::artifact_cache_key(&project_root);
3698        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3699        let callgraph_dir = ctx.callgraph_store_dir();
3700        let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3701            callgraph_dir.clone(),
3702            project_root,
3703            &[source],
3704            1,
3705        )
3706        .unwrap();
3707        let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
3708        std::fs::remove_file(pointer).unwrap();
3709
3710        let pending = root.path().join("pending.rs");
3711        ctx.add_pending_callgraph_store_paths([pending.clone()]);
3712        let generation = ctx.configure_generation();
3713        let (tx, rx) = crossbeam_channel::unbounded();
3714        {
3715            let mut receiver = ctx.callgraph_store_rx().lock();
3716            ctx.note_callgraph_store_rx_generation(generation);
3717            ctx.next_callgraph_store_rx_epoch();
3718            *receiver = Some(rx);
3719        }
3720        tx.send(CallGraphStoreBuildEvent::Ready {
3721            store,
3722            fulfilled_force_token: None,
3723            publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3724        })
3725        .unwrap();
3726        drop(tx);
3727
3728        drain_callgraph_store_events(&ctx);
3729
3730        assert!(
3731            ctx.callgraph_store_rx().lock().is_none(),
3732            "Ready is terminal even when reopening the pointer fails"
3733        );
3734        assert_eq!(
3735            ctx.take_pending_callgraph_store_paths(),
3736            vec![pending],
3737            "failed reopen must preserve pending watcher paths for the retry"
3738        );
3739    }
3740
3741    #[test]
3742    fn callgraph_ready_transition_schedules_tier2_dead_code_rescan() {
3743        // When the callgraph store transitions to ready, the drain must request a
3744        // tier2 refresh pull. dead_code is suppressed (callgraph_available:false)
3745        // while no store is ready; this pull is what eventually re-runs dead_code
3746        // against the now-ready store and replaces that aggregate, flipping the
3747        // root to genuinely complete instead of "building" forever.
3748        let root = tempfile::tempdir().unwrap();
3749        let storage = tempfile::tempdir().unwrap();
3750        let source = root.path().join("lib.rs");
3751        std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3752        let project_root = std::fs::canonicalize(root.path()).unwrap();
3753        let ctx = AppContext::new(
3754            default_language_provider_factory(),
3755            Config {
3756                project_root: Some(project_root.clone()),
3757                storage_dir: Some(storage.path().to_path_buf()),
3758                callgraph_chunk_size: 1,
3759                ..Config::default()
3760            },
3761        );
3762        ctx.set_canonical_cache_root(project_root.clone());
3763        let project_key = crate::search_index::artifact_cache_key(&project_root);
3764        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3765        let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3766            ctx.callgraph_store_dir(),
3767            project_root,
3768            &[source],
3769            1,
3770        )
3771        .unwrap();
3772
3773        assert!(
3774            !ctx.tier2_pull_demand_pending(),
3775            "no tier2 pull demand before the callgraph store is ready"
3776        );
3777
3778        let generation = ctx.configure_generation();
3779        let (tx, rx) = crossbeam_channel::unbounded();
3780        ctx.note_callgraph_store_rx_generation(generation);
3781        ctx.next_callgraph_store_rx_epoch();
3782        *ctx.callgraph_store_rx().lock() = Some(rx);
3783        tx.send(CallGraphStoreBuildEvent::Ready {
3784            store,
3785            fulfilled_force_token: None,
3786            publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3787        })
3788        .unwrap();
3789        drop(tx);
3790
3791        drain_callgraph_store_events(&ctx);
3792
3793        assert!(
3794            ctx.callgraph_store().read().unwrap().is_some(),
3795            "the ready callgraph store must install"
3796        );
3797        assert!(
3798            ctx.tier2_pull_demand_pending(),
3799            "the callgraph-ready transition must schedule a tier2 refresh pull so dead_code is rescanned against the ready store"
3800        );
3801    }
3802
3803    #[test]
3804    fn stale_callgraph_receiver_cannot_clear_newer_same_generation_receiver() {
3805        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
3806        let root = tempfile::tempdir().unwrap();
3807        let ctx = Arc::new(AppContext::new(
3808            default_language_provider_factory(),
3809            Config {
3810                project_root: Some(root.path().to_path_buf()),
3811                ..Config::default()
3812            },
3813        ));
3814        let generation = ctx.configure_generation();
3815        let (old_tx, old_rx) = crossbeam_channel::unbounded();
3816        ctx.note_callgraph_store_rx_generation(generation);
3817        ctx.next_callgraph_store_rx_epoch();
3818        *ctx.callgraph_store_rx().lock() = Some(old_rx);
3819        old_tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3820        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
3821
3822        let drain_ctx = Arc::clone(&ctx);
3823        let drain = std::thread::spawn(move || drain_callgraph_store_events(&drain_ctx));
3824        reached
3825            .recv_timeout(Duration::from_secs(2))
3826            .expect("stale callgraph receiver was not dequeued");
3827
3828        let (_new_tx, new_rx) = crossbeam_channel::unbounded();
3829        ctx.note_callgraph_store_rx_generation(generation);
3830        ctx.next_callgraph_store_rx_epoch();
3831        *ctx.callgraph_store_rx().lock() = Some(new_rx);
3832        release.send(()).unwrap();
3833        drain.join().unwrap();
3834
3835        assert!(
3836            ctx.callgraph_store_rx().lock().is_some(),
3837            "a stale callgraph drain must not clear the replacement receiver"
3838        );
3839        assert!(
3840            ctx.pending_callgraph_store_force_token().is_none(),
3841            "a stale terminal event must not create force demand for its replacement"
3842        );
3843    }
3844
3845    #[test]
3846    fn dequeued_search_completion_cannot_clear_newer_same_generation_receiver() {
3847        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
3848        let root = tempfile::tempdir().unwrap();
3849        let ctx = Arc::new(AppContext::new(
3850            default_language_provider_factory(),
3851            Config {
3852                project_root: Some(root.path().to_path_buf()),
3853                ..Config::default()
3854            },
3855        ));
3856        let generation = ctx.configure_generation();
3857        let (old_tx, old_rx) = crossbeam_channel::unbounded();
3858        old_tx
3859            .send(crate::search_index::SearchIndex::new())
3860            .unwrap();
3861        ctx.note_search_index_rx_generation(generation);
3862        ctx.next_search_index_rx_epoch();
3863        *ctx.search_index_rx()
3864            .write()
3865            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(old_rx);
3866        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
3867
3868        let drain_ctx = Arc::clone(&ctx);
3869        let drain = std::thread::spawn(move || drain_search_index_events(&drain_ctx));
3870        reached
3871            .recv_timeout(Duration::from_secs(2))
3872            .expect("old search completion was not dequeued");
3873
3874        let (_new_tx, new_rx) = crossbeam_channel::unbounded();
3875        ctx.note_search_index_rx_generation(generation);
3876        ctx.next_search_index_rx_epoch();
3877        *ctx.search_index_rx()
3878            .write()
3879            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(new_rx);
3880        release.send(()).unwrap();
3881        drain.join().unwrap();
3882
3883        assert!(
3884            ctx.search_index()
3885                .read()
3886                .unwrap_or_else(std::sync::PoisonError::into_inner)
3887                .is_none(),
3888            "an older same-generation receiver must not publish after replacement"
3889        );
3890        assert!(
3891            ctx.search_index_rx()
3892                .read()
3893                .unwrap_or_else(std::sync::PoisonError::into_inner)
3894                .is_some(),
3895            "an older same-generation drain must not clear the newer receiver"
3896        );
3897    }
3898
3899    #[test]
3900    fn rescan_arriving_while_unbound_executes_fully_after_rebind() {
3901        let temp = tempfile::tempdir().unwrap();
3902        let root = std::fs::canonicalize(temp.path()).unwrap();
3903        let (ctx, watcher_tx) = watcher_context(&root);
3904        // Warm Skip memo: the rescan's strict invalidation is its observable
3905        // effect, so the memo downgrade proves the refresh actually ran.
3906        let artifact = root.join("artifact.bin");
3907        std::fs::write(&artifact, b"artifact").unwrap();
3908        let generation = crate::cache_freshness::artifact_generation(&artifact);
3909        let ticket = crate::cache_freshness::capture_verify_ticket(&root);
3910        assert!(
3911            crate::cache_freshness::record_verify_completed_if_unchanged(
3912                &root,
3913                crate::cache_freshness::VerifyArtifact::Search,
3914                generation,
3915                ticket,
3916            )
3917        );
3918        watcher_tx
3919            .send(WatcherDispatchEvent::RescanRequired(RescanReason::Unknown))
3920            .unwrap();
3921
3922        // While unbound the drain must not consume (and then lose) the
3923        // rescan: nothing may execute, so the memo stays warm.
3924        ctx.mark_subc_unbound();
3925        drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP);
3926        assert_eq!(
3927            crate::cache_freshness::warm_verify_plan(
3928                &root,
3929                crate::cache_freshness::VerifyArtifact::Search,
3930                generation,
3931            ),
3932            crate::cache_freshness::WarmVerifyPlan::Skip,
3933            "an unbound drain must not run (or half-run) the rescan"
3934        );
3935
3936        // After rebind the retained rescan executes in full: strict memo and
3937        // acknowledged flag.
3938        ctx.mark_subc_bound();
3939        let mut guard = 0;
3940        while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3941            guard += 1;
3942            assert!(guard < 16, "rescan replay must finish");
3943        }
3944        assert_eq!(
3945            crate::cache_freshness::warm_verify_plan(
3946                &root,
3947                crate::cache_freshness::VerifyArtifact::Search,
3948                generation,
3949            ),
3950            crate::cache_freshness::WarmVerifyPlan::Strict,
3951            "the post-rebind drain must execute the retained rescan strictly"
3952        );
3953        assert!(
3954            !ctx.watcher_drain_slice()
3955                .lock()
3956                .as_ref()
3957                .is_some_and(|state| state.rescan_required),
3958            "a fully-bound rescan must be acknowledged"
3959        );
3960    }
3961
3962    #[test]
3963    fn budget_interrupted_stage_rewinds_when_unbind_lands_mid_stage() {
3964        // An unbind mid-stage makes the remaining per-path actions gated
3965        // no-ops while `remaining` still decrements. The park must rewind the
3966        // stage so the post-rebind replay re-runs it in full — for BOTH park
3967        // shapes (budget-exhausted and stage-complete).
3968        let temp = tempfile::tempdir().unwrap();
3969        let root = std::fs::canonicalize(temp.path()).unwrap();
3970        let first = root.join("first.rs");
3971        let second = root.join("second.rs");
3972        std::fs::write(&first, "fn first_marker() {}\n").unwrap();
3973        std::fs::write(&second, "fn second_marker() {}\n").unwrap();
3974        let (ctx, watcher_tx) = watcher_context(&root);
3975        *ctx.search_index()
3976            .write()
3977            .unwrap_or_else(std::sync::PoisonError::into_inner) =
3978            Some(crate::search_index::SearchIndex::new());
3979        watcher_tx
3980            .send(WatcherDispatchEvent::Paths(vec![
3981                first.clone(),
3982                second.clone(),
3983            ]))
3984            .unwrap();
3985
3986        // Gate on the SECOND path so the unbind lands after `first` was
3987        // already applied within the same stage pass.
3988        let ctx = Arc::new(ctx);
3989        let (reached_rx, release_tx) = install_watcher_phase_commit_gate_for_test(second.clone());
3990        let drain_ctx = Arc::clone(&ctx);
3991        let drain = std::thread::spawn(move || {
3992            while drain_watcher_events_bounded(&drain_ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {}
3993        });
3994        reached_rx
3995            .recv_timeout(Duration::from_secs(2))
3996            .expect("watcher phase did not reach the second path");
3997        ctx.mark_subc_unbound();
3998        release_tx.send(()).unwrap();
3999        drain.join().unwrap();
4000
4001        ctx.mark_subc_bound();
4002        let mut guard = 0;
4003        while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
4004            guard += 1;
4005            assert!(guard < 32, "rebased replay must finish");
4006        }
4007        let search = ctx
4008            .search_index()
4009            .read()
4010            .unwrap_or_else(std::sync::PoisonError::into_inner);
4011        let index = search.as_ref().expect("search index");
4012        for (marker, path) in [("first_marker", &first), ("second_marker", &second)] {
4013            assert_eq!(
4014                index.grep(marker, true, &[], &[], &root, 10).matches.len(),
4015                1,
4016                "post-rebind replay must apply {} ({})",
4017                marker,
4018                path.display()
4019            );
4020        }
4021    }
4022
4023    #[test]
4024    fn pending_paths_retained_across_transient_unbind_repair_next_installed_index() {
4025        let temp = tempfile::tempdir().unwrap();
4026        let root = std::fs::canonicalize(temp.path()).unwrap();
4027        let source = root.join("edited-during-unbind.rs");
4028        std::fs::write(&source, "fn repaired_marker() {}\n").unwrap();
4029        let ctx = AppContext::new(
4030            default_language_provider_factory(),
4031            Config {
4032                project_root: Some(root.clone()),
4033                ..Config::default()
4034            },
4035        );
4036        ctx.set_canonical_cache_root(root.clone());
4037
4038        // Watcher recorded the edit while a build was in flight, then the
4039        // route unbound. The transient-unbind cleanup retires the receiver but
4040        // must keep the pending path: it is the only record that any artifact
4041        // a pre-unbind worker persisted is content-stale.
4042        ctx.add_pending_search_index_paths([source.clone()]);
4043        ctx.mark_subc_unbound();
4044        ctx.cancel_unbound_artifact_work();
4045        assert!(ctx.search_index_rx().read().unwrap().is_none());
4046
4047        // Equivalent rebind starts a replacement build; its install must
4048        // replay the retained path into the fresh index.
4049        ctx.mark_subc_bound();
4050        let (tx, rx) = crossbeam_channel::unbounded();
4051        let mut stale_index = crate::search_index::SearchIndex::build(&root);
4052        // Simulate the pre-unbind worker's artifact predating the edit.
4053        stale_index.remove_file(&source);
4054        tx.send(stale_index).unwrap();
4055        ctx.install_search_index_rx(rx, ctx.configure_generation());
4056
4057        drain_search_index_events(&ctx);
4058
4059        let search = ctx
4060            .search_index()
4061            .read()
4062            .unwrap_or_else(std::sync::PoisonError::into_inner);
4063        assert_eq!(
4064            search
4065                .as_ref()
4066                .expect("installed search index")
4067                .grep("repaired_marker", true, &[], &[], &root, 10)
4068                .matches
4069                .len(),
4070            1,
4071            "retained pending path must repair the stale artifact on install"
4072        );
4073    }
4074
4075    #[test]
4076    fn disconnected_search_refresh_clears_nonready_index_and_preserves_pending_paths() {
4077        let root = tempfile::tempdir().unwrap();
4078        let ctx = AppContext::new(
4079            default_language_provider_factory(),
4080            Config {
4081                project_root: Some(root.path().to_path_buf()),
4082                ..Config::default()
4083            },
4084        );
4085        let mut index = crate::search_index::SearchIndex::new();
4086        index.ready = false;
4087        *ctx.search_index()
4088            .write()
4089            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
4090        let pending = root.path().join("pending.rs");
4091        ctx.add_pending_search_index_paths([pending.clone()]);
4092        let generation = ctx.configure_generation();
4093        let (tx, rx) = crossbeam_channel::unbounded();
4094        drop(tx);
4095        ctx.install_search_index_rx(rx, generation);
4096
4097        drain_search_index_events(&ctx);
4098
4099        assert!(
4100            ctx.search_index()
4101                .read()
4102                .unwrap_or_else(std::sync::PoisonError::into_inner)
4103                .is_none(),
4104            "a disconnected refresh must not leave a permanently non-ready index"
4105        );
4106        assert!(ctx.search_index_rx().read().unwrap().is_none());
4107        assert_eq!(ctx.take_pending_search_index_paths(), vec![pending]);
4108    }
4109
4110    #[test]
4111    fn search_index_disconnect_reschedule_caps_at_one_per_generation() {
4112        // The drain path replaces a lost search-index load at most once per
4113        // configure generation; after that the query-triggered reload is the
4114        // recovery path, so a persistently failing worker cannot be relaunched in
4115        // a loop on the drain thread.
4116        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
4117        assert!(
4118            ctx.allow_search_index_disconnect_reschedule(),
4119            "the first automatic replacement in a generation must be allowed"
4120        );
4121        assert!(
4122            !ctx.allow_search_index_disconnect_reschedule(),
4123            "a second automatic replacement in the same generation must be denied"
4124        );
4125        assert!(
4126            !ctx.search_index_query_reload_allowed(),
4127            "queued queries must use fallback during the retry cooldown"
4128        );
4129        ctx.advance_configure_generation();
4130        assert!(
4131            ctx.search_index_query_reload_allowed(),
4132            "a new configure generation must clear the retry cooldown"
4133        );
4134        assert!(
4135            ctx.allow_search_index_disconnect_reschedule(),
4136            "advancing the configure generation must reset the replacement cap"
4137        );
4138    }
4139
4140    #[test]
4141    fn lost_search_load_disconnect_schedules_one_replacement_that_installs() {
4142        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4143        let temp = tempfile::tempdir().unwrap();
4144        let root = std::fs::canonicalize(temp.path()).unwrap();
4145        let storage = temp.path().join("storage");
4146        std::fs::create_dir_all(&storage).unwrap();
4147        std::fs::write(
4148            root.join("lib.rs"),
4149            "pub fn LostLoadNeedle() -> bool { true }\n",
4150        )
4151        .unwrap();
4152        let ctx = AppContext::new(
4153            default_language_provider_factory(),
4154            Config {
4155                project_root: Some(root.clone()),
4156                storage_dir: Some(storage),
4157                search_index: true,
4158                semantic_search: false,
4159                callgraph_store: false,
4160                ..Config::default()
4161            },
4162        );
4163        ctx.set_canonical_cache_root(root.clone());
4164
4165        // Simulate a lost post-configure load: the build worker dropped its
4166        // sender without delivering an index, leaving a not-ready index and a
4167        // disconnected receiver. Before the fix nothing rescheduled this, so
4168        // health reported "building" forever.
4169        let mut stranded = crate::search_index::SearchIndex::new();
4170        stranded.ready = false;
4171        *ctx.search_index()
4172            .write()
4173            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stranded);
4174        let generation = ctx.configure_generation();
4175        let (tx, rx) = crossbeam_channel::unbounded::<crate::search_index::SearchIndex>();
4176        drop(tx); // disconnect without sending
4177        ctx.install_search_index_rx(rx, generation);
4178
4179        drain_search_index_events(&ctx);
4180
4181        assert!(
4182            ctx.search_index_rx().read().unwrap().is_some(),
4183            "a lost load must schedule a replacement search-index load (fresh receiver installed)"
4184        );
4185
4186        // The replacement worker builds and publishes; draining installs it.
4187        // Poll until ready (bounded so a regression fails instead of hanging).
4188        let deadline = std::time::Instant::now() + Duration::from_secs(20);
4189        loop {
4190            drain_search_index_events(&ctx);
4191            let ready = ctx
4192                .search_index()
4193                .read()
4194                .unwrap_or_else(std::sync::PoisonError::into_inner)
4195                .as_ref()
4196                .is_some_and(|index| index.ready);
4197            if ready {
4198                break;
4199            }
4200            assert!(
4201                std::time::Instant::now() < deadline,
4202                "replacement search-index load did not install before the deadline"
4203            );
4204            std::thread::sleep(Duration::from_millis(20));
4205        }
4206
4207        let matches = ctx
4208            .search_index()
4209            .read()
4210            .unwrap_or_else(std::sync::PoisonError::into_inner)
4211            .as_ref()
4212            .expect("installed replacement index")
4213            .grep("LostLoadNeedle", true, &[], &[], &root, 10)
4214            .matches
4215            .len();
4216        assert_eq!(
4217            matches, 1,
4218            "the replacement index must actually serve queries"
4219        );
4220
4221        // The one-per-generation cap is now consumed. A second lost load in the
4222        // same generation must NOT schedule another replacement (no loop); the
4223        // query-triggered reload remains the recovery path.
4224        let mut stranded_again = crate::search_index::SearchIndex::new();
4225        stranded_again.ready = false;
4226        *ctx.search_index()
4227            .write()
4228            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stranded_again);
4229        let (tx2, rx2) = crossbeam_channel::unbounded::<crate::search_index::SearchIndex>();
4230        drop(tx2);
4231        ctx.install_search_index_rx(rx2, ctx.configure_generation());
4232
4233        drain_search_index_events(&ctx);
4234
4235        assert!(
4236            ctx.search_index_rx().read().unwrap().is_none(),
4237            "the cap must prevent a second automatic replacement in the same generation"
4238        );
4239    }
4240
4241    #[test]
4242    fn dequeued_search_completion_cannot_publish_after_unbind() {
4243        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4244        let root = tempfile::tempdir().unwrap();
4245        let ctx = Arc::new(AppContext::new(
4246            default_language_provider_factory(),
4247            Config {
4248                project_root: Some(root.path().to_path_buf()),
4249                ..Config::default()
4250            },
4251        ));
4252        ctx.set_canonical_cache_root(root.path().to_path_buf());
4253        let generation = ctx.configure_generation();
4254        let (tx, rx) = crossbeam_channel::unbounded();
4255        tx.send(crate::search_index::SearchIndex::new()).unwrap();
4256        ctx.note_search_index_rx_generation(generation);
4257        *ctx.search_index_rx()
4258            .write()
4259            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(rx);
4260        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4261
4262        let drain_ctx = Arc::clone(&ctx);
4263        let drain = std::thread::spawn(move || drain_search_index_events(&drain_ctx));
4264        reached
4265            .recv_timeout(Duration::from_secs(2))
4266            .expect("search completion was not dequeued");
4267        ctx.mark_subc_unbound();
4268        release.send(()).unwrap();
4269        drain.join().unwrap();
4270
4271        assert!(
4272            ctx.search_index()
4273                .read()
4274                .unwrap_or_else(std::sync::PoisonError::into_inner)
4275                .is_none(),
4276            "a dequeued completion must re-check lifecycle admission at commit"
4277        );
4278    }
4279
4280    #[test]
4281    fn dequeued_semantic_completion_cannot_publish_after_unbind() {
4282        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4283        let root = tempfile::tempdir().unwrap();
4284        let ctx = Arc::new(AppContext::new(
4285            default_language_provider_factory(),
4286            Config {
4287                project_root: Some(root.path().to_path_buf()),
4288                semantic_search: true,
4289                ..Config::default()
4290            },
4291        ));
4292        ctx.set_canonical_cache_root(root.path().to_path_buf());
4293        let generation = ctx.configure_generation();
4294        let (tx, rx) = crossbeam_channel::unbounded();
4295        tx.send(SemanticIndexEvent::Ready(
4296            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4297        ))
4298        .unwrap();
4299        ctx.note_semantic_index_rx_generation(generation);
4300        *ctx.semantic_index_rx().lock() = Some(rx);
4301        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4302
4303        let drain_ctx = Arc::clone(&ctx);
4304        let drain = std::thread::spawn(move || drain_semantic_index_events(&drain_ctx));
4305        reached
4306            .recv_timeout(Duration::from_secs(2))
4307            .expect("semantic completion was not dequeued");
4308        ctx.mark_subc_unbound();
4309        release.send(()).unwrap();
4310        drain.join().unwrap();
4311
4312        assert!(
4313            ctx.semantic_index()
4314                .read()
4315                .unwrap_or_else(std::sync::PoisonError::into_inner)
4316                .is_none(),
4317            "a dequeued completion must re-check lifecycle admission at commit"
4318        );
4319    }
4320
4321    #[test]
4322    fn dequeued_semantic_refresh_cannot_publish_after_unbind() {
4323        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4324        let root = tempfile::tempdir().unwrap();
4325        let ctx = Arc::new(AppContext::new(
4326            default_language_provider_factory(),
4327            Config {
4328                project_root: Some(root.path().to_path_buf()),
4329                semantic_search: true,
4330                ..Config::default()
4331            },
4332        ));
4333        ctx.set_canonical_cache_root(root.path().to_path_buf());
4334        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4335        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4336        ctx.install_semantic_refresh_worker_for_build_epoch(
4337            request_tx,
4338            event_rx,
4339            Arc::new(Mutex::new(None)),
4340            ctx.semantic_index_rx_epoch(),
4341        );
4342        event_tx
4343            .send(SemanticRefreshEvent::CorpusCompleted {
4344                index: crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4345                changed: 0,
4346                added: 0,
4347                deleted: 0,
4348                total_processed: 0,
4349            })
4350            .unwrap();
4351        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4352
4353        let drain_ctx = Arc::clone(&ctx);
4354        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4355        reached
4356            .recv_timeout(Duration::from_secs(2))
4357            .expect("semantic refresh completion was not dequeued");
4358        ctx.mark_subc_unbound();
4359        release.send(()).unwrap();
4360        drain.join().unwrap();
4361
4362        assert!(
4363            ctx.semantic_index()
4364                .read()
4365                .unwrap_or_else(std::sync::PoisonError::into_inner)
4366                .is_none(),
4367            "a dequeued refresh must re-check lifecycle admission at commit"
4368        );
4369    }
4370
4371    #[test]
4372    fn dequeued_semantic_refresh_cannot_publish_after_bound_replacement() {
4373        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4374        let root = tempfile::tempdir().unwrap();
4375        let ctx = Arc::new(AppContext::new(
4376            default_language_provider_factory(),
4377            Config {
4378                project_root: Some(root.path().to_path_buf()),
4379                semantic_search: true,
4380                ..Config::default()
4381            },
4382        ));
4383        ctx.set_canonical_cache_root(root.path().to_path_buf());
4384        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4385        let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4386        ctx.install_semantic_refresh_worker_for_build_epoch(
4387            old_request_tx,
4388            old_event_rx,
4389            Arc::new(Mutex::new(None)),
4390            ctx.semantic_index_rx_epoch(),
4391        );
4392        old_event_tx
4393            .send(SemanticRefreshEvent::CorpusCompleted {
4394                index: crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4395                changed: 0,
4396                added: 0,
4397                deleted: 0,
4398                total_processed: 0,
4399            })
4400            .unwrap();
4401        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4402
4403        let drain_ctx = Arc::clone(&ctx);
4404        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4405        reached
4406            .recv_timeout(Duration::from_secs(2))
4407            .expect("old semantic refresh completion was not dequeued");
4408
4409        let (new_request_tx, _new_request_rx) = crossbeam_channel::unbounded();
4410        let (_new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4411        ctx.install_semantic_refresh_worker_for_build_epoch(
4412            new_request_tx,
4413            new_event_rx,
4414            Arc::new(Mutex::new(None)),
4415            ctx.semantic_index_rx_epoch(),
4416        );
4417        release.send(()).unwrap();
4418        drain.join().unwrap();
4419
4420        assert!(
4421            ctx.semantic_index()
4422                .read()
4423                .unwrap_or_else(std::sync::PoisonError::into_inner)
4424                .is_none(),
4425            "an old refresh event must not be relabeled as the replacement worker"
4426        );
4427        assert!(
4428            ctx.semantic_refresh_event_rx().lock().is_some(),
4429            "the stale drain must not clear the replacement refresh receiver"
4430        );
4431    }
4432
4433    #[test]
4434    fn current_semantic_refresh_disconnect_requests_full_reload() {
4435        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4436        crate::commands::configure::set_semantic_refresh_restart_result_for_test(Some(true));
4437        struct RestartOverrideReset;
4438        impl Drop for RestartOverrideReset {
4439            fn drop(&mut self) {
4440                crate::commands::configure::set_semantic_refresh_restart_result_for_test(None);
4441            }
4442        }
4443        let _reset = RestartOverrideReset;
4444
4445        let root = tempfile::tempdir().unwrap();
4446        let ctx = AppContext::new(
4447            default_language_provider_factory(),
4448            Config {
4449                project_root: Some(root.path().to_path_buf()),
4450                semantic_search: true,
4451                ..Config::default()
4452            },
4453        );
4454        ctx.set_canonical_cache_root(root.path().to_path_buf());
4455        *ctx.semantic_index()
4456            .write()
4457            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4458            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4459        );
4460        *ctx.semantic_index_status()
4461            .write()
4462            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4463        let (_build_tx, build_rx) = crossbeam_channel::unbounded();
4464        let disconnected_build_epoch =
4465            ctx.install_semantic_index_rx(build_rx, ctx.configure_generation());
4466        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4467        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4468        ctx.install_semantic_refresh_worker_for_build_epoch(
4469            request_tx,
4470            event_rx,
4471            Arc::new(Mutex::new(None)),
4472            disconnected_build_epoch,
4473        );
4474        drop(event_tx);
4475
4476        drain_semantic_refresh_events(&ctx);
4477
4478        assert_eq!(
4479            crate::commands::configure::semantic_refresh_restart_attempts_for_test(),
4480            1
4481        );
4482        assert!(
4483            ctx.semantic_index()
4484                .read()
4485                .unwrap_or_else(std::sync::PoisonError::into_inner)
4486                .is_none(),
4487            "recovery must force a full reload rather than retain an index without a refresh worker"
4488        );
4489        assert!(ctx.semantic_refresh_event_rx().lock().is_none());
4490        assert!(
4491            ctx.semantic_index_rx().lock().is_none(),
4492            "a build receiver from the disconnected refresh generation must not be adopted"
4493        );
4494        assert!(matches!(
4495            &*ctx
4496                .semantic_index_status()
4497                .read()
4498                .unwrap_or_else(std::sync::PoisonError::into_inner),
4499            SemanticIndexStatus::Building { stage, .. } if stage == "restarting_refresh_worker"
4500        ));
4501    }
4502
4503    #[test]
4504    fn finished_refresh_worker_wakes_maintenance_after_last_event_is_drained() {
4505        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4506        crate::commands::configure::set_semantic_refresh_restart_result_for_test(Some(true));
4507        struct RestartOverrideReset;
4508        impl Drop for RestartOverrideReset {
4509            fn drop(&mut self) {
4510                crate::commands::configure::set_semantic_refresh_restart_result_for_test(None);
4511            }
4512        }
4513        let _reset = RestartOverrideReset;
4514
4515        let root = tempfile::tempdir().unwrap();
4516        let ctx = AppContext::new(
4517            default_language_provider_factory(),
4518            Config {
4519                project_root: Some(root.path().to_path_buf()),
4520                semantic_search: true,
4521                ..Config::default()
4522            },
4523        );
4524        ctx.set_canonical_cache_root(root.path().to_path_buf());
4525        *ctx.semantic_index()
4526            .write()
4527            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4528            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4529        );
4530        *ctx.semantic_index_status()
4531            .write()
4532            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4533
4534        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4535        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4536        let (event_sent_tx, event_sent_rx) = crossbeam_channel::bounded(1);
4537        let (finish_tx, finish_rx) = crossbeam_channel::bounded(1);
4538        let worker = std::thread::spawn(move || {
4539            event_tx
4540                .send(SemanticRefreshEvent::Started { paths: Vec::new() })
4541                .unwrap();
4542            event_sent_tx.send(()).unwrap();
4543            finish_rx.recv().unwrap();
4544        });
4545        let worker_slot = Arc::new(Mutex::new(Some(worker)));
4546        ctx.install_semantic_refresh_worker_for_build_epoch(
4547            request_tx,
4548            event_rx,
4549            Arc::clone(&worker_slot),
4550            ctx.semantic_index_rx_epoch(),
4551        );
4552        event_sent_rx.recv_timeout(Duration::from_secs(2)).unwrap();
4553        drain_semantic_refresh_events(&ctx);
4554        assert!(
4555            !ctx.completion_drains_have_work(),
4556            "a live worker with an empty event queue should not cause maintenance churn"
4557        );
4558
4559        finish_tx.send(()).unwrap();
4560        let deadline = Instant::now() + Duration::from_secs(2);
4561        while !ctx.completion_drains_have_work() {
4562            assert!(
4563                Instant::now() < deadline,
4564                "finished refresh worker did not wake maintenance"
4565            );
4566            std::thread::yield_now();
4567        }
4568        drain_semantic_refresh_events(&ctx);
4569
4570        assert_eq!(
4571            crate::commands::configure::semantic_refresh_restart_attempts_for_test(),
4572            1
4573        );
4574        assert!(ctx.semantic_refresh_event_rx().lock().is_none());
4575    }
4576
4577    #[test]
4578    fn semantic_disconnect_does_not_overwrite_replacement_loader_state() {
4579        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4580        let root = tempfile::tempdir().unwrap();
4581        let ctx = Arc::new(AppContext::new(
4582            default_language_provider_factory(),
4583            Config {
4584                project_root: Some(root.path().to_path_buf()),
4585                semantic_search: true,
4586                ..Config::default()
4587            },
4588        ));
4589        *ctx.semantic_index()
4590            .write()
4591            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4592            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4593        );
4594        *ctx.semantic_index_status()
4595            .write()
4596            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4597        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4598        let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4599        ctx.install_semantic_refresh_worker_for_build_epoch(
4600            old_request_tx,
4601            old_event_rx,
4602            Arc::new(Mutex::new(None)),
4603            ctx.semantic_index_rx_epoch(),
4604        );
4605        drop(old_event_tx);
4606        let (reached, release) = install_semantic_refresh_recovery_gate_for_test(&ctx);
4607
4608        let drain_ctx = Arc::clone(&ctx);
4609        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4610        reached
4611            .recv_timeout(Duration::from_secs(2))
4612            .expect("old worker was not cleared before recovery");
4613
4614        let (build_tx, build_rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
4615        ctx.install_semantic_index_rx(build_rx, ctx.configure_generation());
4616        let (new_request_tx, _new_request_rx) = crossbeam_channel::unbounded();
4617        let (new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4618        ctx.install_semantic_refresh_worker_for_build_epoch(
4619            new_request_tx,
4620            new_event_rx,
4621            Arc::new(Mutex::new(None)),
4622            ctx.semantic_index_rx_epoch(),
4623        );
4624        *ctx.semantic_index_status()
4625            .write()
4626            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
4627            stage: "replacement_loader".to_string(),
4628            files: None,
4629            entries_done: None,
4630            entries_total: None,
4631        };
4632        release.send(()).unwrap();
4633        drain.join().unwrap();
4634
4635        assert!(ctx.semantic_index_rx().lock().is_some());
4636        assert!(ctx.semantic_refresh_event_rx().lock().is_some());
4637        assert!(matches!(
4638            &*ctx
4639                .semantic_index_status()
4640                .read()
4641                .unwrap_or_else(std::sync::PoisonError::into_inner),
4642            SemanticIndexStatus::Building { stage, .. } if stage == "replacement_loader"
4643        ));
4644        drop(build_tx);
4645        drop(new_event_tx);
4646    }
4647
4648    #[test]
4649    fn semantic_disconnect_preserves_newer_build_receiver_before_refresh_install() {
4650        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4651        let root = tempfile::tempdir().unwrap();
4652        let ctx = Arc::new(AppContext::new(
4653            default_language_provider_factory(),
4654            Config {
4655                project_root: Some(root.path().to_path_buf()),
4656                semantic_search: true,
4657                ..Config::default()
4658            },
4659        ));
4660        ctx.set_canonical_cache_root(root.path().to_path_buf());
4661        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4662        let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4663        ctx.install_semantic_refresh_worker_for_build_epoch(
4664            old_request_tx,
4665            old_event_rx,
4666            Arc::new(Mutex::new(None)),
4667            ctx.semantic_index_rx_epoch(),
4668        );
4669        drop(old_event_tx);
4670        let (reached, release) = install_semantic_refresh_recovery_gate_for_test(&ctx);
4671
4672        let drain_ctx = Arc::clone(&ctx);
4673        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4674        reached
4675            .recv_timeout(Duration::from_secs(2))
4676            .expect("semantic refresh recovery did not reach the post-clear gate");
4677
4678        let (_replacement_tx, replacement_rx) = crossbeam_channel::unbounded();
4679        ctx.install_semantic_index_rx(replacement_rx, ctx.configure_generation());
4680        *ctx.semantic_index_status()
4681            .write()
4682            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
4683            stage: "replacement_loader".to_string(),
4684            files: None,
4685            entries_done: None,
4686            entries_total: None,
4687        };
4688        release.send(()).unwrap();
4689        drain.join().unwrap();
4690
4691        assert!(
4692            ctx.semantic_index_rx().lock().is_some(),
4693            "the old disconnect must not retire a newer build receiver while its refresh worker is being installed"
4694        );
4695        assert!(matches!(
4696            &*ctx
4697                .semantic_index_status()
4698                .read()
4699                .unwrap_or_else(std::sync::PoisonError::into_inner),
4700            SemanticIndexStatus::Building { stage, .. } if stage == "replacement_loader"
4701        ));
4702    }
4703
4704    #[test]
4705    fn delayed_semantic_retry_targets_same_generation_replacement_worker() {
4706        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4707        SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.store(20, Ordering::SeqCst);
4708        struct RetryDelayReset;
4709        impl Drop for RetryDelayReset {
4710            fn drop(&mut self) {
4711                SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.store(u64::MAX, Ordering::SeqCst);
4712            }
4713        }
4714        let _delay_reset = RetryDelayReset;
4715
4716        let root = tempfile::tempdir().unwrap();
4717        let ctx = AppContext::new(
4718            default_language_provider_factory(),
4719            Config {
4720                project_root: Some(root.path().to_path_buf()),
4721                semantic_search: true,
4722                ..Config::default()
4723            },
4724        );
4725        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4726        let (_old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4727        ctx.install_semantic_refresh_worker_for_build_epoch(
4728            old_request_tx,
4729            old_event_rx,
4730            Arc::new(Mutex::new(None)),
4731            ctx.semantic_index_rx_epoch(),
4732        );
4733        let retry_path = root.path().join("retry.rs");
4734        assert!(schedule_semantic_refresh_retry(
4735            &ctx,
4736            vec![retry_path.clone()],
4737            "transient embedding failure",
4738        ));
4739
4740        let (new_request_tx, new_request_rx) = crossbeam_channel::unbounded();
4741        let (_new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4742        ctx.install_semantic_refresh_worker_for_build_epoch(
4743            new_request_tx,
4744            new_event_rx,
4745            Arc::new(Mutex::new(None)),
4746            ctx.semantic_index_rx_epoch(),
4747        );
4748
4749        let request = new_request_rx
4750            .recv_timeout(Duration::from_secs(2))
4751            .expect("retry should resolve the replacement sender when it fires");
4752        assert!(matches!(
4753            request,
4754            SemanticRefreshRequest::Files { paths } if paths == vec![retry_path]
4755        ));
4756    }
4757
4758    #[test]
4759    fn watcher_drain_batch_cap_yields_with_events_remaining() {
4760        let temp = tempfile::tempdir().unwrap();
4761        let (ctx, tx) = watcher_context(temp.path());
4762        let cap = 3;
4763        for index in 0..(cap * 2 + 1) {
4764            tx.send(WatcherDispatchEvent::Paths(vec![temp
4765                .path()
4766                .join(format!("file-{index}.rs"))]))
4767                .unwrap();
4768        }
4769
4770        let first = drain_watcher_events_bounded(&ctx, cap);
4771
4772        assert_eq!(first.processed, cap);
4773        assert!(first.has_more);
4774        assert_eq!(ctx.pending_tier2_paths().len(), cap);
4775    }
4776
4777    #[test]
4778    fn watcher_drain_requeues_until_all_events_are_applied() {
4779        let temp = tempfile::tempdir().unwrap();
4780        let (ctx, tx) = watcher_context(temp.path());
4781        let cap = 4;
4782        let total = cap * 2 + 3;
4783        for index in 0..total {
4784            tx.send(WatcherDispatchEvent::Paths(vec![temp
4785                .path()
4786                .join(format!("file-{index}.rs"))]))
4787                .unwrap();
4788        }
4789
4790        let mut processed = 0;
4791        loop {
4792            let outcome = drain_watcher_events_bounded(&ctx, cap);
4793            assert!(outcome.processed <= cap);
4794            processed += outcome.processed;
4795            if !outcome.has_more {
4796                break;
4797            }
4798        }
4799
4800        assert_eq!(processed, total);
4801        assert_eq!(ctx.pending_tier2_paths().len(), total);
4802    }
4803}
4804
4805#[cfg(test)]
4806mod watcher_slice_tests {
4807    use super::*;
4808    use crate::config::Config;
4809    use crate::context::{default_language_provider_factory, AppContext, ViewRuntimeSnapshot};
4810
4811    fn context_with_watcher(
4812        root: &Path,
4813    ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
4814        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
4815        ctx.update_config(|config| config.project_root = Some(root.to_path_buf()));
4816        ctx.set_canonical_cache_root(root.to_path_buf());
4817        let (tx, rx) = crossbeam_channel::unbounded();
4818        *ctx.watcher_rx().lock() = Some(rx);
4819        (ctx, tx)
4820    }
4821
4822    #[test]
4823    fn watcher_batch_schedules_view_publication_within_its_own_quiet_window() {
4824        let temp = tempfile::tempdir().unwrap();
4825        let (ctx, tx) = context_with_watcher(temp.path());
4826        ctx.update_config(|config| config.views.enabled = true);
4827        tx.send(WatcherDispatchEvent::Paths(vec![temp
4828            .path()
4829            .join("changed.rs")]))
4830            .unwrap();
4831        let applied_at = Instant::now();
4832
4833        let outcome = drain_watcher_events_bounded(&ctx, 1);
4834
4835        assert_eq!(outcome.processed, 1);
4836        let due = ctx
4837            .watcher_drain_slice()
4838            .lock()
4839            .as_ref()
4840            .and_then(|state| state.view_publication_due)
4841            .expect("watcher batch should schedule view publication");
4842        assert!(due > applied_at);
4843        assert!(
4844            due <= applied_at + VIEW_PUBLICATION_QUIET_WINDOW + Duration::from_millis(250),
4845            "view publication due {due:?} exceeded its own quiet window from {applied_at:?}"
4846        );
4847        assert!(
4848            due < applied_at + crate::commands::configure::semantic_refresh_quiet_window(),
4849            "view publication inherited the semantic refresh quiet window"
4850        );
4851    }
4852
4853    #[test]
4854    fn views_published_watcher_batch_skips_legacy_refresh_and_still_schedules_publication() {
4855        let root = tempfile::tempdir().unwrap();
4856        let storage = tempfile::tempdir().unwrap();
4857        let (ctx, tx) = context_with_watcher(root.path());
4858        ctx.update_config(|config| {
4859            config.callgraph_store = true;
4860            config.views.enabled = true;
4861        });
4862        ctx.set_cache_writer_capabilities(false, false);
4863        let family = crate::search_index::artifact_cache_key(root.path());
4864        let scope = crate::path_identity::project_scope_key(root.path());
4865        let view = crate::views::ViewStore::open(storage.path(), &scope).unwrap();
4866        ctx.install_view_runtime(
4867            ViewRuntimeSnapshot {
4868                query_pin: None,
4869                storage: storage.path().to_path_buf(),
4870                family,
4871                scope,
4872                view_dir: view.view_dir().to_path_buf(),
4873                generation: Some("1-published".to_owned()),
4874                manifest: Some(
4875                    crate::views::Manifest::new(std::iter::empty::<(
4876                        crate::views::RelPath,
4877                        crate::views::ManifestEntry,
4878                    )>())
4879                    .unwrap(),
4880                ),
4881                head_fingerprint: "published".to_owned(),
4882                head_metadata: crate::alias::capture_git_head_metadata(root.path(), None)
4883                    .unwrap_or_else(|_| crate::alias::GitHeadMetadata {
4884                        head_path: root.path().join(".git/HEAD"),
4885                        head_mtime: None,
4886                        resolved_ref_path: None,
4887                        resolved_ref_mtime: None,
4888                    }),
4889                pending_paths: BTreeSet::new(),
4890            },
4891            None,
4892        );
4893        reset_legacy_watcher_refreshes_for_test();
4894        tx.send(WatcherDispatchEvent::Paths(vec![root
4895            .path()
4896            .join("changed.rs")]))
4897            .unwrap();
4898
4899        let outcome = drain_watcher_events_bounded(&ctx, 1);
4900
4901        assert_eq!(outcome.processed, 1);
4902        assert_eq!(
4903            legacy_watcher_refreshes_for_test(),
4904            0,
4905            "a published view must own watcher callgraph writes"
4906        );
4907        assert!(
4908            ctx.watcher_drain_slice()
4909                .lock()
4910                .as_ref()
4911                .is_some_and(|state| state.view_publication_due.is_some()),
4912            "view publication must remain scheduled"
4913        );
4914
4915        ctx.update_config(|config| config.views.enabled = false);
4916        tx.send(WatcherDispatchEvent::Paths(vec![root
4917            .path()
4918            .join("legacy.rs")]))
4919            .unwrap();
4920        let outcome = drain_watcher_events_bounded(&ctx, 1);
4921        assert_eq!(outcome.processed, 1);
4922        assert_eq!(
4923            legacy_watcher_refreshes_for_test(),
4924            1,
4925            "disabling views must resume legacy refresh on the next watcher batch"
4926        );
4927    }
4928
4929    fn set_watcher_unit_test_seam(delay: Duration, thresholds: Option<(Duration, Duration)>) {
4930        WATCHER_UNIT_TEST_DELAY.with(|value| value.set(delay));
4931        WATCHER_UNIT_TEST_THRESHOLDS.with(|value| value.set(thresholds));
4932        WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow_mut().clear());
4933    }
4934
4935    fn clear_watcher_unit_test_seam() {
4936        set_watcher_unit_test_seam(Duration::ZERO, None);
4937    }
4938
4939    #[test]
4940    fn callgraph_phase_batches_all_indexed_paths_into_one_refresh() {
4941        let temp = tempfile::tempdir().unwrap();
4942        let (ctx, _) = context_with_watcher(temp.path());
4943        let generated = temp.path().join("compiled.ts");
4944        std::fs::write(&generated, "// @generated\nexport const compiled = true;\n").unwrap();
4945        let mut paths = VecDeque::from([
4946            temp.path().join("a.rs"),
4947            temp.path().join("b.ts"),
4948            generated,
4949            temp.path().join("ignored.txt"),
4950            temp.path().join("Cargo.toml"),
4951        ]);
4952        let mut remaining = paths.len();
4953        let mut refreshed = Vec::new();
4954
4955        let completed = apply_callgraph_watcher_phase(
4956            &ctx,
4957            &mut paths,
4958            &mut remaining,
4959            Instant::now(),
4960            WATCHER_DRAIN_SLICE_BUDGET,
4961            true,
4962            |_, changed| refreshed.push(changed.clone()),
4963        );
4964
4965        assert!(completed);
4966        assert_eq!(remaining, 0);
4967        assert_eq!(refreshed.len(), 1);
4968        assert_eq!(refreshed[0].len(), 3);
4969        assert!(refreshed[0].contains(&temp.path().join("Cargo.toml")));
4970    }
4971
4972    #[test]
4973    fn callgraph_phase_includes_manifest_before_budget_yield() {
4974        let temp = tempfile::tempdir().unwrap();
4975        let (ctx, _) = context_with_watcher(temp.path());
4976        let source = temp.path().join("first.rs");
4977        let manifest = temp.path().join("Cargo.toml");
4978        let mut paths = VecDeque::from([source.clone(), manifest.clone()]);
4979        let mut remaining = paths.len();
4980        let mut refreshed = Vec::new();
4981        set_watcher_unit_test_seam(Duration::from_millis(2), None);
4982
4983        let completed = apply_callgraph_watcher_phase(
4984            &ctx,
4985            &mut paths,
4986            &mut remaining,
4987            Instant::now(),
4988            Duration::from_millis(1),
4989            true,
4990            |_, changed| refreshed.push(changed.clone()),
4991        );
4992        clear_watcher_unit_test_seam();
4993
4994        assert!(!completed);
4995        assert_eq!(remaining, 1);
4996        assert_eq!(refreshed.len(), 1);
4997        assert_eq!(refreshed[0], HashSet::from([source, manifest]));
4998    }
4999
5000    #[test]
5001    fn callgraph_phase_flushes_once_per_slice_before_requeue() {
5002        let temp = tempfile::tempdir().unwrap();
5003        let (ctx, _) = context_with_watcher(temp.path());
5004        let mut paths =
5005            VecDeque::from([temp.path().join("first.rs"), temp.path().join("second.rs")]);
5006        let mut remaining = paths.len();
5007        let mut refreshed = Vec::new();
5008        set_watcher_unit_test_seam(Duration::from_millis(2), None);
5009
5010        let first_completed = apply_callgraph_watcher_phase(
5011            &ctx,
5012            &mut paths,
5013            &mut remaining,
5014            Instant::now(),
5015            Duration::from_millis(1),
5016            true,
5017            |_, changed| refreshed.push(changed.clone()),
5018        );
5019        assert!(!first_completed);
5020        assert_eq!(remaining, 1);
5021        assert_eq!(refreshed.len(), 1, "the yielded slice must flush its batch");
5022
5023        let second_completed = apply_callgraph_watcher_phase(
5024            &ctx,
5025            &mut paths,
5026            &mut remaining,
5027            Instant::now(),
5028            Duration::from_millis(1),
5029            true,
5030            |_, changed| refreshed.push(changed.clone()),
5031        );
5032        clear_watcher_unit_test_seam();
5033
5034        assert!(!second_completed);
5035        assert_eq!(remaining, 0);
5036        assert_eq!(refreshed.len(), 2);
5037        assert!(refreshed.iter().all(|batch| batch.len() == 1));
5038    }
5039
5040    #[test]
5041    fn watcher_unit_watchdog_names_slow_phase_and_path() {
5042        let temp = tempfile::tempdir().unwrap();
5043        let slow_path = temp.path().join("slow.rs");
5044        let mut paths = VecDeque::from([slow_path.clone()]);
5045        let mut remaining = 1;
5046        set_watcher_unit_test_seam(
5047            Duration::from_millis(5),
5048            Some((Duration::from_millis(1), Duration::from_secs(1))),
5049        );
5050
5051        let completed = apply_watcher_path_phase(
5052            WatcherDrainApplyPhase::SemanticIndex,
5053            &mut paths,
5054            &mut remaining,
5055            Instant::now(),
5056            WATCHER_DRAIN_SLICE_BUDGET,
5057            |_| {},
5058        );
5059        let logs = WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow().clone());
5060        clear_watcher_unit_test_seam();
5061
5062        assert!(completed);
5063        assert_eq!(logs.len(), 1);
5064        assert!(logs[0].contains("watcher drain unit exceeded 5s"));
5065        assert!(logs[0].contains("phase=semantic_index"));
5066        assert!(logs[0].contains(&format!("path={}", slow_path.display())));
5067    }
5068
5069    #[test]
5070    fn watcher_callgraph_refresh_defers_when_ready_store_is_unavailable() {
5071        let temp = tempfile::tempdir().unwrap();
5072        let (ctx, _) = context_with_watcher(temp.path());
5073        ctx.update_config(|config| config.callgraph_store = true);
5074        ctx.set_cache_role(false, None);
5075        let source = temp.path().join("pending.rs");
5076        let generated = temp.path().join("compiled.ts");
5077        std::fs::write(&generated, "// @generated\nexport const compiled = true;\n").unwrap();
5078
5079        refresh_callgraph_store_for_watcher(&ctx, &HashSet::from([source.clone(), generated]));
5080
5081        let deadline = Instant::now() + Duration::from_secs(12);
5082        loop {
5083            let pending = ctx.take_pending_callgraph_store_paths();
5084            if !pending.is_empty() {
5085                assert_eq!(pending, vec![source]);
5086                break;
5087            }
5088            assert!(
5089                Instant::now() < deadline,
5090                "refresh worker did not defer the unavailable store batch"
5091            );
5092            std::thread::sleep(Duration::from_millis(5));
5093        }
5094    }
5095
5096    #[test]
5097    fn watcher_callgraph_refresh_keeps_worktree_paths_pending() {
5098        let temp = tempfile::tempdir().unwrap();
5099        let (ctx, _) = context_with_watcher(temp.path());
5100        ctx.update_config(|config| config.callgraph_store = true);
5101        ctx.set_cache_role(true, None);
5102        let source = temp.path().join("worktree.rs");
5103
5104        refresh_callgraph_store_for_watcher(&ctx, &HashSet::from([source.clone()]));
5105
5106        assert_eq!(ctx.take_pending_callgraph_store_paths(), vec![source]);
5107    }
5108
5109    #[test]
5110    fn watcher_single_dispatch_event_is_sliced_by_path_count() {
5111        let temp = tempfile::tempdir().unwrap();
5112        let (ctx, tx) = context_with_watcher(temp.path());
5113        let path_count = 1_024;
5114        let path_cap = 256;
5115        tx.send(WatcherDispatchEvent::Paths(
5116            (0..path_count)
5117                .map(|index| temp.path().join(format!("single-event-{index}.txt")))
5118                .collect(),
5119        ))
5120        .unwrap();
5121
5122        let mut slices = 0;
5123        let mut processed = 0;
5124        loop {
5125            let outcome = drain_watcher_events_bounded(&ctx, path_cap);
5126            slices += 1;
5127            processed += outcome.processed;
5128            assert!(outcome.processed <= path_cap);
5129            if !outcome.has_more {
5130                break;
5131            }
5132            // Convergence guard only: the 250 ms time budget can end a slice
5133            // after a handful of paths on a loaded box, so the slice count is
5134            // bounded by the mechanism (every slice makes progress), not by a
5135            // number that depends on the host's speed.
5136            assert!(
5137                slices <= path_count,
5138                "single dispatch event did not converge"
5139            );
5140        }
5141
5142        assert_eq!(processed, path_count);
5143        // At least ceil(1024/256) slices from the path budget; the time budget
5144        // may only add slices, never remove them.
5145        assert!(
5146            slices >= 4,
5147            "expected at least 4 path-budgeted slices, got {slices}"
5148        );
5149        assert_eq!(ctx.pending_tier2_paths().len(), path_count);
5150    }
5151
5152    #[test]
5153    fn watcher_overflows_during_rescan_coalesce_one_followup_without_filter_stall() {
5154        let temp = tempfile::tempdir().unwrap();
5155        let root = std::fs::canonicalize(temp.path()).unwrap();
5156        let ctx = Arc::new(AppContext::new(
5157            default_language_provider_factory(),
5158            Config::default(),
5159        ));
5160        ctx.update_config(|config| config.project_root = Some(root.clone()));
5161        ctx.set_canonical_cache_root(root.clone());
5162        let counters = ctx.watcher_counters();
5163        let matcher = Arc::new(std::sync::RwLock::new(None));
5164        let matcher_generation = Arc::new(AtomicU64::new(0));
5165        let shutdown = Arc::new(AtomicBool::new(false));
5166        let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(8);
5167        *ctx.watcher_rx().lock() = Some(dispatch_rx);
5168        let (raw_sender_tx, raw_sender_rx) = crossbeam_channel::bounded(1);
5169        let filter_shutdown = Arc::clone(&shutdown);
5170        let filter_root = root.clone();
5171        let filter = std::thread::spawn(move || {
5172            crate::watcher_filter::run_watcher_thread(
5173                crate::watcher_filter::WatcherFilterConfig::new(filter_root, None),
5174                Vec::new(),
5175                matcher,
5176                matcher_generation,
5177                dispatch_tx,
5178                filter_shutdown,
5179                move |_root, _extra, raw_tx| {
5180                    raw_sender_tx.send(raw_tx).unwrap();
5181                    Ok::<(), std::io::Error>(())
5182                },
5183            );
5184        });
5185        let raw_tx = raw_sender_rx.recv_timeout(Duration::from_secs(2)).unwrap();
5186        let overflow =
5187            || notify::Event::new(notify::EventKind::Other).set_flag(notify::event::Flag::Rescan);
5188        raw_tx.send(Ok(overflow())).unwrap();
5189        let deadline = Instant::now() + Duration::from_secs(2);
5190        while (counters.snapshot().overflows_total != 1
5191            || ctx
5192                .watcher_rx()
5193                .lock()
5194                .as_ref()
5195                .is_none_or(crossbeam_channel::Receiver::is_empty))
5196            && Instant::now() < deadline
5197        {
5198            std::thread::yield_now();
5199        }
5200        assert_eq!(counters.snapshot().overflows_total, 1);
5201        assert!(ctx
5202            .watcher_rx()
5203            .lock()
5204            .as_ref()
5205            .is_some_and(|receiver| !receiver.is_empty()));
5206        let _ = crate::watcher_filter::take_watcher_overflow_logs_for_test();
5207
5208        let (rescan_reached, release_rescan) = install_watcher_rescan_gate_for_test(&ctx);
5209        let drain_ctx = Arc::clone(&ctx);
5210        let drain = std::thread::spawn(move || drain_watcher_events(&drain_ctx));
5211        rescan_reached
5212            .recv_timeout(Duration::from_secs(2))
5213            .expect("first rescan did not reach the test gate");
5214
5215        raw_tx.send(Ok(overflow())).unwrap();
5216        raw_tx.send(Ok(overflow())).unwrap();
5217        let callback_deadline = Instant::now() + Duration::from_millis(50);
5218        let mut overflow_lines = Vec::new();
5219        while overflow_lines
5220            .iter()
5221            .filter(|line: &&String| {
5222                line.contains(&format!("root={}", root.display()))
5223                    && line.contains("rescan_in_progress=true")
5224            })
5225            .count()
5226            != 2
5227            && Instant::now() < callback_deadline
5228        {
5229            overflow_lines.extend(crate::watcher_filter::take_watcher_overflow_logs_for_test());
5230            std::thread::yield_now();
5231        }
5232        assert_eq!(
5233            overflow_lines
5234                .iter()
5235                .filter(|line| {
5236                    line.contains(&format!("root={}", root.display()))
5237                        && line.contains("rescan_in_progress=true")
5238                })
5239                .count(),
5240            2,
5241            "the filter must finish attributing drops without waiting for the rescan: {overflow_lines:?}"
5242        );
5243        assert_eq!(counters.snapshot().overflows_total, 3);
5244        assert!(ctx
5245            .watcher_rx()
5246            .lock()
5247            .as_ref()
5248            .is_some_and(crossbeam_channel::Receiver::is_empty));
5249        release_rescan.send(()).unwrap();
5250        drain.join().unwrap();
5251
5252        let snapshot = counters.snapshot();
5253        assert_eq!(snapshot.overflows_during_rescan, 2);
5254        assert_eq!(snapshot.rescans_unknown_total, 2);
5255        assert!(!counters.rescan_in_progress());
5256        assert!(ctx
5257            .watcher_rx()
5258            .lock()
5259            .as_ref()
5260            .is_some_and(crossbeam_channel::Receiver::is_empty));
5261
5262        shutdown.store(true, Ordering::SeqCst);
5263        drop(raw_tx);
5264        filter.join().unwrap();
5265    }
5266
5267    #[test]
5268    fn watcher_rescan_supersedes_pending_paths() {
5269        let temp = tempfile::tempdir().unwrap();
5270        let (ctx, tx) = context_with_watcher(temp.path());
5271        tx.send(WatcherDispatchEvent::Paths(
5272            (0..5)
5273                .map(|index| temp.path().join(format!("before-rescan-{index}.txt")))
5274                .collect(),
5275        ))
5276        .unwrap();
5277        let first = drain_watcher_events_bounded(&ctx, 2);
5278        assert_eq!(first.processed, 2);
5279        assert!(first.has_more);
5280        assert_eq!(ctx.watcher_drain_pending_path_count(), 3);
5281
5282        let counters = ctx.watcher_counters();
5283        counters.note_raw_event();
5284        counters.note_raw_event();
5285        tx.send(WatcherDispatchEvent::RescanRequired(
5286            RescanReason::KernelDropped,
5287        ))
5288        .unwrap();
5289        let (second, lines) =
5290            crate::logging::capture_index_events(|| drain_watcher_events_bounded(&ctx, 2));
5291
5292        assert_eq!(second.processed, 0);
5293        assert!(!second.has_more);
5294        assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5295        let snapshot = counters.snapshot();
5296        assert_eq!(snapshot.raw_events_total, 2);
5297        assert_eq!(snapshot.raw_events_since_last_rescan, 0);
5298        assert_eq!(snapshot.rescans_kernel_dropped_total, 1);
5299        assert_eq!(snapshot.rescans_user_dropped_total, 0);
5300        assert_eq!(snapshot.rescans_unknown_total, 0);
5301        assert!(snapshot.last_rescan_at_ms.is_some());
5302        assert!(snapshot.last_rescan_cost_ms.is_some());
5303        // Other tests on this thread pool emit index_event lines too; the
5304        // capture is process-wide, so select this test's line by content —
5305        // kind and this test's own root (matched by the temp dir's unique
5306        // name, since the line may spell the root canonically or not).
5307        let root_name = temp
5308            .path()
5309            .file_name()
5310            .unwrap()
5311            .to_string_lossy()
5312            .into_owned();
5313        let rescan_lines = lines
5314            .iter()
5315            .filter(|line| {
5316                line.contains("kind=watcher_rescan plane=watcher") && line.contains(&root_name)
5317            })
5318            .collect::<Vec<_>>();
5319        assert_eq!(rescan_lines.len(), 1, "lines: {lines:?}");
5320        assert!(rescan_lines[0].contains("reason=kernel_dropped"));
5321        assert!(rescan_lines[0].contains("raw_events_since_last=2"));
5322    }
5323
5324    #[test]
5325    fn watcher_lifecycle_generation_change_rebases_continuation() {
5326        let temp = tempfile::tempdir().unwrap();
5327        let (ctx, tx) = context_with_watcher(temp.path());
5328        tx.send(WatcherDispatchEvent::Paths(
5329            (0..5)
5330                .map(|index| temp.path().join(format!("old-generation-{index}.txt")))
5331                .collect(),
5332        ))
5333        .unwrap();
5334        let first = drain_watcher_events_bounded(&ctx, 2);
5335        assert_eq!(first.processed, 2);
5336        assert!(first.has_more);
5337
5338        // A lifecycle-only generation change (transient unbind + equivalent
5339        // rebind) must NOT lose the in-flight paths: the continuation rebases
5340        // onto the new generation and keeps draining.
5341        ctx.advance_configure_generation();
5342        let second = drain_watcher_events_bounded(&ctx, 2);
5343        assert_eq!(second.processed, 2);
5344        assert!(second.has_more);
5345
5346        let mut guard = 0;
5347        while drain_watcher_events_bounded(&ctx, 2).has_more {
5348            guard += 1;
5349            assert!(guard < 16, "rebased continuation must finish draining");
5350        }
5351        assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5352        assert_eq!(
5353            ctx.pending_tier2_paths().len(),
5354            5,
5355            "every path survives the lifecycle-only generation change"
5356        );
5357    }
5358
5359    #[test]
5360    fn watcher_content_generation_change_discards_continuation() {
5361        let temp = tempfile::tempdir().unwrap();
5362        let (ctx, tx) = context_with_watcher(temp.path());
5363        tx.send(WatcherDispatchEvent::Paths(
5364            (0..5)
5365                .map(|index| temp.path().join(format!("old-content-{index}.txt")))
5366                .collect(),
5367        ))
5368        .unwrap();
5369        let first = drain_watcher_events_bounded(&ctx, 2);
5370        assert_eq!(first.processed, 2);
5371        assert!(first.has_more);
5372
5373        // A real reconfigure (content change) rebuilds artifacts wholesale;
5374        // the stale continuation is discarded, not replayed.
5375        ctx.configure_content_generation_flag()
5376            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5377        ctx.advance_configure_generation();
5378        let second = drain_watcher_events_bounded(&ctx, 2);
5379
5380        assert_eq!(second.processed, 0);
5381        assert!(!second.has_more);
5382        assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5383        assert_eq!(ctx.pending_tier2_paths().len(), 2);
5384    }
5385
5386    fn install_search_index(
5387        ctx: &AppContext,
5388        root: &Path,
5389        storage: &Path,
5390        file: &Path,
5391        contents: &[u8],
5392    ) -> (PathBuf, PathBuf) {
5393        let canonical = std::fs::canonicalize(root).expect("canonical root");
5394        ctx.update_config(|config| {
5395            config.storage_dir = Some(storage.to_path_buf());
5396            config.search_index = true;
5397        });
5398        ctx.set_canonical_cache_root(canonical.clone());
5399        let cache_dir = crate::search_index::resolve_cache_dir(&canonical, Some(storage));
5400        let mut index = crate::search_index::SearchIndex::build(&canonical);
5401        index.index_file(file, contents);
5402        let git_head = index.stored_git_head().map(str::to_owned);
5403        assert!(
5404            index.write_to_disk(&cache_dir, git_head.as_deref()),
5405            "owner write of cache.bin must succeed before borrow-only setup"
5406        );
5407        *ctx.search_index()
5408            .write()
5409            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
5410        (canonical, cache_dir)
5411    }
5412
5413    fn grep_count(ctx: &AppContext, pattern: &str, root: &Path) -> usize {
5414        let index = ctx
5415            .search_index()
5416            .read()
5417            .unwrap_or_else(std::sync::PoisonError::into_inner);
5418        index
5419            .as_ref()
5420            .expect("resident search index")
5421            .grep(pattern, true, &[], &[], root, 10)
5422            .matches
5423            .len()
5424    }
5425
5426    fn cache_bin_hash(path: &Path) -> blake3::Hash {
5427        blake3::hash(&std::fs::read(path).expect("read cache.bin"))
5428    }
5429
5430    fn search_index_has_pending_disk_changes(ctx: &AppContext) -> bool {
5431        ctx.search_index()
5432            .read()
5433            .unwrap_or_else(std::sync::PoisonError::into_inner)
5434            .as_ref()
5435            .is_some_and(|index| index.has_pending_disk_changes())
5436    }
5437
5438    fn mark_borrow_only(ctx: &AppContext, canonical: &Path, cache_dir: &Path) {
5439        let project_key = cache_dir
5440            .file_name()
5441            .and_then(|name| name.to_str())
5442            .expect("cache key")
5443            .to_string();
5444        crate::root_cache::configure_artifact_access(canonical, &project_key, true);
5445        ctx.set_cache_role(true, None);
5446    }
5447
5448    #[test]
5449    fn ram_overlay_is_off_by_default_and_transport_independent() {
5450        let temp = tempfile::tempdir().unwrap();
5451        let (ctx, _) = context_with_watcher(temp.path());
5452        ctx.set_cache_role(true, None);
5453        assert!(
5454            ctx.shared_artifacts_read_only(),
5455            "worktree role is borrow-only"
5456        );
5457        assert!(
5458            !ctx.ram_overlay_active(),
5459            "worktree.ram_overlay defaults off"
5460        );
5461
5462        ctx.update_config(|config| config.worktree.ram_overlay = true);
5463        assert!(
5464            ctx.ram_overlay_active(),
5465            "overlay follows config + borrow-only, not bind/transport identity"
5466        );
5467
5468        ctx.set_cache_role(false, None);
5469        assert!(
5470            !ctx.ram_overlay_active(),
5471            "owner roots stay on the normal writer path even when the gate is on"
5472        );
5473    }
5474
5475    #[test]
5476    fn borrow_only_watcher_arms_are_noops_until_overlay_flips_on() {
5477        let root = tempfile::tempdir().unwrap();
5478        let storage = tempfile::tempdir().unwrap();
5479        let (ctx, tx) = context_with_watcher(root.path());
5480        let file = std::fs::canonicalize(root.path())
5481            .expect("canonical root")
5482            .join("overlay.rs");
5483        std::fs::write(&file, "old overlay token\n").expect("write source");
5484        let (canonical, cache_dir) = install_search_index(
5485            &ctx,
5486            root.path(),
5487            storage.path(),
5488            &file,
5489            b"old overlay token\n",
5490        );
5491        mark_borrow_only(&ctx, &canonical, &cache_dir);
5492        assert!(ctx.shared_artifacts_read_only());
5493        assert!(!ctx.ram_overlay_active());
5494        assert!(
5495            !search_index_has_pending_disk_changes(&ctx),
5496            "precondition: compacted owner write leaves no RAM delta"
5497        );
5498
5499        std::fs::write(&file, "new overlay token\n").expect("edit source");
5500        tx.send(WatcherDispatchEvent::Paths(vec![file.clone()]))
5501            .unwrap();
5502        drain_watcher_events(&ctx);
5503        assert!(
5504            !search_index_has_pending_disk_changes(&ctx),
5505            "gate off: borrow-only watcher must not mutate the RAM delta"
5506        );
5507
5508        ctx.update_config(|config| config.worktree.ram_overlay = true);
5509        assert!(ctx.ram_overlay_active());
5510        tx.send(WatcherDispatchEvent::Paths(vec![file])).unwrap();
5511        drain_watcher_events(&ctx);
5512        assert!(
5513            search_index_has_pending_disk_changes(&ctx),
5514            "flipping the predicate on must let the same watcher arm apply"
5515        );
5516        assert_eq!(
5517            grep_count(&ctx, "new overlay token", &canonical),
5518            1,
5519            "overlay-on drain must make the disk edit searchable from RAM"
5520        );
5521    }
5522
5523    #[test]
5524    fn ram_overlay_search_reflects_edits_without_writing_cache_bin() {
5525        let root = tempfile::tempdir().unwrap();
5526        let storage = tempfile::tempdir().unwrap();
5527        let (ctx, tx) = context_with_watcher(root.path());
5528        let file = std::fs::canonicalize(root.path())
5529            .expect("canonical root")
5530            .join("overlay.rs");
5531        std::fs::write(&file, "old overlay token\n").expect("write source");
5532        let (canonical, cache_dir) = install_search_index(
5533            &ctx,
5534            root.path(),
5535            storage.path(),
5536            &file,
5537            b"old overlay token\n",
5538        );
5539        mark_borrow_only(&ctx, &canonical, &cache_dir);
5540        ctx.update_config(|config| config.worktree.ram_overlay = true);
5541
5542        *ctx.semantic_index()
5543            .write()
5544            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
5545            crate::semantic_index::SemanticIndex::new(canonical.clone(), 8),
5546        );
5547        *ctx.semantic_index_status()
5548            .write()
5549            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
5550
5551        let cache_path = cache_dir.join("cache.bin");
5552        let before = cache_bin_hash(&cache_path);
5553
5554        std::fs::write(&file, "new overlay token\n").expect("edit source");
5555        tx.send(WatcherDispatchEvent::Paths(vec![file.clone()]))
5556            .unwrap();
5557        drain_watcher_events(&ctx);
5558
5559        assert_eq!(
5560            grep_count(&ctx, "new overlay token", &canonical),
5561            1,
5562            "overlay must serve the edit from the RAM delta"
5563        );
5564        assert_eq!(grep_count(&ctx, "old overlay token", &canonical), 0);
5565        assert_eq!(
5566            cache_bin_hash(&cache_path),
5567            before,
5568            "overlay must not change on-disk cache.bin bytes"
5569        );
5570
5571        {
5572            // Family key != this checkout's path identity, matching a linked
5573            // worktree that borrows the owner's shared artifact key.
5574            let family_key = "shared-family-overlay-key";
5575            crate::root_cache::configure_artifact_access(&canonical, family_key, true);
5576            let family_dir = storage.path().join(family_key);
5577            let mut index = ctx
5578                .search_index()
5579                .write()
5580                .unwrap_or_else(std::sync::PoisonError::into_inner);
5581            assert!(
5582                !index
5583                    .as_mut()
5584                    .expect("resident search index")
5585                    .write_to_disk(&family_dir, None),
5586                "direct persist must fail-closed for a borrow-only shared key"
5587            );
5588        }
5589        assert!(
5590            !ctx.flush_search_index_on_graceful_shutdown(),
5591            "shutdown must not flush a borrow-only overlay root"
5592        );
5593        assert_eq!(
5594            cache_bin_hash(&cache_path),
5595            before,
5596            "shutdown and persist must leave cache.bin byte-identical"
5597        );
5598
5599        let refreshing = match &*ctx
5600            .semantic_index_status()
5601            .read()
5602            .unwrap_or_else(std::sync::PoisonError::into_inner)
5603        {
5604            SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
5605            other => panic!("expected Ready semantic status, got {other:?}"),
5606        };
5607        assert!(
5608            refreshing.is_empty(),
5609            "a context without a semantic refresh worker must not report refresh work"
5610        );
5611    }
5612
5613    #[test]
5614    fn owner_root_still_uses_normal_path_when_overlay_gate_is_on() {
5615        let root = tempfile::tempdir().unwrap();
5616        let storage = tempfile::tempdir().unwrap();
5617        let (ctx, tx) = context_with_watcher(root.path());
5618        let file = std::fs::canonicalize(root.path())
5619            .expect("canonical root")
5620            .join("owner.rs");
5621        std::fs::write(&file, "old owner token\n").expect("write source");
5622        let (canonical, cache_dir) = install_search_index(
5623            &ctx,
5624            root.path(),
5625            storage.path(),
5626            &file,
5627            b"old owner token\n",
5628        );
5629        ctx.update_config(|config| config.worktree.ram_overlay = true);
5630        assert!(!ctx.shared_artifacts_read_only(), "owner root is a writer");
5631        assert!(!ctx.ram_overlay_active());
5632
5633        std::fs::write(&file, "new owner token\n").expect("edit source");
5634        tx.send(WatcherDispatchEvent::Paths(vec![file])).unwrap();
5635        drain_watcher_events(&ctx);
5636        assert_eq!(grep_count(&ctx, "new owner token", &canonical), 1);
5637        assert_eq!(grep_count(&ctx, "old owner token", &canonical), 0);
5638
5639        assert!(
5640            ctx.flush_search_index_on_graceful_shutdown(),
5641            "owner persist path must still flush when the overlay gate is on"
5642        );
5643        let mut restored = crate::search_index::SearchIndex::read_from_disk(&cache_dir, &canonical)
5644            .expect("reload owner cache.bin");
5645        restored.set_ready(true);
5646        assert_eq!(
5647            restored
5648                .grep("new owner token", true, &[], &[], &canonical, 10)
5649                .matches
5650                .len(),
5651            1,
5652            "owner shutdown flush must persist the RAM delta"
5653        );
5654    }
5655}
5656
5657// Every test here spawns a real child process, so the module is Unix-only.
5658#[cfg(all(test, unix))]
5659mod idle_lsp_tests {
5660    use super::shutdown_idle_lsp_at;
5661    use crate::config::Config;
5662    use crate::context::AppContext;
5663    use crate::lsp::child_registry::LspChildRegistry;
5664    use crate::lsp::client::LspClient;
5665    use crate::lsp::registry::ServerKind;
5666    use std::collections::HashMap;
5667    use std::path::Path;
5668    use std::time::{Duration, Instant};
5669
5670    #[cfg(unix)]
5671    fn spawn_sleep_client(ctx: &AppContext, root: std::path::PathBuf) -> u32 {
5672        let registry = LspChildRegistry::new();
5673        ctx.lsp().set_child_registry(registry.clone());
5674        let client = LspClient::spawn(
5675            ServerKind::TypeScript,
5676            root,
5677            Path::new("sh"),
5678            &["-c".to_string(), "exec sleep 60".to_string()],
5679            &HashMap::new(),
5680            ctx.lsp().event_sender_for_test(),
5681            registry,
5682        )
5683        .expect("spawn idle-lsp stand-in");
5684        let pid = client.child_pid();
5685        ctx.lsp().insert_client_for_test(client);
5686        pid
5687    }
5688
5689    #[cfg(unix)]
5690    fn wait_until_dead(pid: u32, timeout: Duration) -> bool {
5691        let started = Instant::now();
5692        while started.elapsed() < timeout {
5693            if !crate::bash_background::process::is_process_alive(pid) {
5694                return true;
5695            }
5696            std::thread::sleep(Duration::from_millis(20));
5697        }
5698        false
5699    }
5700
5701    #[cfg(unix)]
5702    #[test]
5703    fn idle_lsp_ttl_shuts_down_stale_client_and_keeps_recent() {
5704        let tmp = tempfile::tempdir().unwrap();
5705        let mut stale_config = Config::default();
5706        stale_config.idle.lsp_ttl_minutes = 1;
5707        let stale = AppContext::new(
5708            crate::context::default_language_provider_factory(),
5709            stale_config,
5710        );
5711        let pid = spawn_sleep_client(&stale, tmp.path().to_path_buf());
5712        assert_eq!(stale.lsp().server_count(), 1);
5713        let now = Instant::now();
5714        stale.set_last_request_at_for_test(now - Duration::from_secs(61));
5715        let started = Instant::now();
5716        shutdown_idle_lsp_at(&stale, now, stale.last_request_at());
5717        assert!(
5718            started.elapsed() < Duration::from_millis(250),
5719            "idle lsp reap must return without waiting on Shutdown, took {:?}",
5720            started.elapsed()
5721        );
5722        assert_eq!(
5723            stale.lsp().server_count(),
5724            0,
5725            "a root idle longer than lsp_ttl_minutes must shut down its language servers"
5726        );
5727        assert!(
5728            wait_until_dead(pid, Duration::from_secs(6)),
5729            "idle-reaped child must die within SHUTDOWN_TIMEOUT + 1s"
5730        );
5731
5732        let mut fresh_config = Config::default();
5733        fresh_config.idle.lsp_ttl_minutes = 1;
5734        let fresh = AppContext::new(
5735            crate::context::default_language_provider_factory(),
5736            fresh_config,
5737        );
5738        spawn_sleep_client(&fresh, tmp.path().to_path_buf());
5739        assert_eq!(fresh.lsp().server_count(), 1);
5740        shutdown_idle_lsp_at(&fresh, Instant::now(), fresh.last_request_at());
5741        assert_eq!(
5742            fresh.lsp().server_count(),
5743            1,
5744            "a root with recent activity must keep its language servers"
5745        );
5746    }
5747}