Skip to main content

aft/
runtime_drain.rs

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