Skip to main content

aft/
runtime_drain.rs

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