Skip to main content

aft/
runtime_drain.rs

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