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