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