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