Skip to main content

aft/
runtime_drain.rs

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