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