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