Skip to main content

aft/
runtime_drain.rs

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