Skip to main content

aft/
runtime_drain.rs

1use crate as aft;
2use crate::context::{
3    AppContext, SemanticIndexEvent, SemanticIndexStatus, SemanticRefreshEvent,
4    SemanticRefreshRequest,
5};
6use crate::log_ctx;
7use crate::lsp::client::LspEvent;
8use crate::protocol::PushFrame;
9use crate::watcher_filter::{watcher_path_is_infra_skip, WatcherDispatchEvent};
10use std::collections::HashSet;
11use std::path::Path;
12use std::sync::Arc;
13use std::thread;
14use std::time::Duration;
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub struct DrainBatchOutcome {
18    pub processed: usize,
19    pub has_more: bool,
20}
21
22pub const WATCHER_EVENT_DRAIN_BATCH_CAP: usize = 256;
23pub const LSP_EVENT_DRAIN_BATCH_CAP: usize = 256;
24
25pub fn drain_configure_warning_events(ctx: &AppContext) {
26    crate::commands::configure::drain_deferred_configure_maintenance(ctx);
27    for (generation, frame) in ctx.drain_configure_warnings() {
28        if ctx.configure_generation() != generation {
29            aft::slog_info!(
30                "dropping stale configure_warnings for generation {} (current {})",
31                generation,
32                ctx.configure_generation()
33            );
34            continue;
35        }
36
37        if let Some(sender) = ctx.progress_sender_handle() {
38            sender(PushFrame::ConfigureWarnings(frame));
39        }
40    }
41}
42
43pub fn drain_inspect_events(ctx: &AppContext) {
44    let drained = ctx.inspect_manager().drain_completions();
45    // Watcher-driven Tier-2 scans complete via the reuse path, which bypasses
46    // `result_rx`/`drain_completions`. Poll the manager's reuse counter so a
47    // background scan still refreshes the bar (#3) — otherwise the counts and
48    // `~` marker would only update on a manual `aft_inspect`.
49    let reuse_completed = ctx.take_new_reuse_completions();
50    // A completed background Tier-2 scan refreshes the agent status-bar counts
51    // to the freshly-persisted aggregate, and clears the stale marker — so the
52    // bar reflects the new numbers on the next tool result without waiting for
53    // an explicit aft_inspect call.
54    if drained > 0 || reuse_completed {
55        if let Some(project_root) = ctx.config().project_root.clone() {
56            let (dead_code, unused_exports, duplicates) = ctx
57                .inspect_manager()
58                .latest_tier2_counts(ctx.inspect_dir(), project_root);
59            // Don't clear the `~` stale marker until the whole serial Tier-2
60            // cycle has drained — while any category is still in flight the
61            // already-persisted categories may predate the latest edit, so
62            // claiming fresh would be premature (#20). `None` counts preserve
63            // the last-known value rather than fabricating a `0` (#1).
64            let stale = ctx.inspect_manager().tier2_any_in_flight();
65            ctx.update_status_bar_tier2(dead_code, unused_exports, duplicates, None, stale);
66            // Push the refreshed snapshot so the sidebar reflects the new Tier-2
67            // counts immediately. `update_status_bar_tier2` only mutates the
68            // in-memory counts (which the agent status bar reads live on each
69            // tool result); the push-driven sidebar would otherwise keep showing
70            // the pre-population snapshot — where `status_bar` was null and the
71            // Code Health section stayed hidden — until some unrelated event
72            // happened to emit a status frame.
73            ctx.status_emitter().signal(ctx.build_status_snapshot());
74        }
75    }
76}
77
78/// Drain all background build-completion receivers in standalone order.
79///
80/// Search installs first so watcher/pending updates apply to the freshest index,
81/// followed by callgraph store and semantic index completion.
82pub fn drain_build_completions(ctx: &AppContext) {
83    drain_search_index_events(ctx);
84    drain_callgraph_store_events(ctx);
85    drain_semantic_index_events(ctx);
86}
87
88/// Return true when any background build-completion receiver is currently set.
89///
90/// Each receiver is checked under its own short lock; no lock is held while
91/// checking the next subsystem.
92pub fn any_build_in_flight(ctx: &AppContext) -> bool {
93    {
94        let rx = ctx
95            .search_index_rx()
96            .read()
97            .unwrap_or_else(std::sync::PoisonError::into_inner);
98        if rx.is_some() {
99            return true;
100        }
101    }
102
103    {
104        let rx = ctx.callgraph_store_rx().lock();
105        if rx.is_some() {
106            return true;
107        }
108    }
109
110    {
111        let rx = ctx.semantic_index_rx().lock();
112        rx.is_some()
113    }
114}
115
116pub fn watcher_path_is_ignored_by_current_matcher(ctx: &AppContext, path: &Path) -> bool {
117    if watcher_path_is_infra_skip(path) {
118        return true;
119    }
120
121    if let Some(matcher) = ctx.gitignore() {
122        if path.starts_with(matcher.path()) {
123            let is_dir = path.is_dir();
124            return matcher
125                .matched_path_or_any_parents(path, is_dir)
126                .is_ignore();
127        }
128    }
129
130    false
131}
132
133fn replay_search_index_pending_updates(
134    ctx: &AppContext,
135    index: &mut crate::search_index::SearchIndex,
136    pending_paths: Vec<std::path::PathBuf>,
137) {
138    for path in pending_paths {
139        if path.exists() {
140            if watcher_path_is_ignored_by_current_matcher(ctx, &path) {
141                index.remove_file(&path);
142            } else {
143                index.update_file(&path);
144            }
145        } else {
146            index.remove_file(&path);
147        }
148    }
149}
150
151pub fn watcher_path_is_semantic_source(path: &Path) -> bool {
152    crate::semantic_index::is_semantic_indexed_extension(path)
153}
154
155pub fn mark_semantic_corpus_refresh_success(ctx: &AppContext) {
156    ctx.clear_all_semantic_refresh_retry_attempts();
157    ctx.reset_semantic_refresh_circuit_after_success();
158}
159
160pub fn drain_search_index_events(ctx: &AppContext) {
161    let (latest, disconnected) = {
162        let rx_ref = ctx
163            .search_index_rx()
164            .read()
165            .unwrap_or_else(std::sync::PoisonError::into_inner);
166        let Some(rx) = rx_ref.as_ref() else {
167            return;
168        };
169
170        let mut latest = None;
171        let mut disconnected = false;
172        loop {
173            match rx.try_recv() {
174                Ok(index) => latest = Some(index),
175                Err(crossbeam_channel::TryRecvError::Empty) => break,
176                Err(crossbeam_channel::TryRecvError::Disconnected) => {
177                    disconnected = true;
178                    break;
179                }
180            }
181        }
182        (latest, disconnected)
183    };
184
185    let mut status_changed = false;
186    let mut installed_index = false;
187    if let Some(mut index) = latest {
188        let pending_paths = ctx.take_pending_search_index_paths();
189        if !pending_paths.is_empty() {
190            replay_search_index_pending_updates(ctx, &mut index, pending_paths);
191        }
192        *ctx.search_index()
193            .write()
194            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
195        installed_index = true;
196        status_changed = true;
197    }
198
199    if disconnected || installed_index {
200        *ctx.search_index_rx()
201            .write()
202            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
203        if disconnected && !installed_index {
204            let _ = ctx.take_pending_search_index_paths();
205        }
206        status_changed = true;
207    }
208
209    if status_changed {
210        ctx.status_emitter().signal(ctx.build_status_snapshot());
211    }
212}
213
214/// Install a background-built callgraph store once its cold build completes.
215/// Mirrors `drain_search_index_events`: drains the receiver, installs the
216/// freshest store, replays paths that changed during the build, and clears the
217/// receiver. On build failure (channel disconnected with nothing installed) the
218/// receiver is cleared so a later op can retry the cold build.
219pub fn drain_callgraph_store_events(ctx: &AppContext) {
220    let (latest, disconnected) = {
221        let rx_ref = ctx.callgraph_store_rx().lock();
222        let Some(rx) = rx_ref.as_ref() else {
223            return;
224        };
225
226        let mut latest = None;
227        let mut disconnected = false;
228        loop {
229            match rx.try_recv() {
230                Ok(store) => latest = Some(store),
231                Err(crossbeam_channel::TryRecvError::Empty) => break,
232                Err(crossbeam_channel::TryRecvError::Disconnected) => {
233                    disconnected = true;
234                    break;
235                }
236            }
237        }
238        (latest, disconnected)
239    };
240
241    let mut status_changed = false;
242    let mut installed = false;
243    if let Some(store) = latest {
244        // Replay source files that changed while the cold build was running so
245        // the freshly-installed store reflects mid-build edits.
246        let pending = ctx.take_pending_callgraph_store_paths();
247        if !pending.is_empty() {
248            if let Err(error) = store.refresh_files(&pending) {
249                crate::slog_warn!(
250                    "callgraph store post-build pending refresh failed: {}",
251                    error
252                );
253                if let Err(mark_error) = store.mark_files_stale(&pending) {
254                    crate::slog_warn!(
255                        "failed to mark callgraph store files stale after post-build refresh failure: {}",
256                        mark_error
257                    );
258                }
259            }
260        }
261        *ctx.callgraph_store()
262            .write()
263            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(store));
264        installed = true;
265        status_changed = true;
266    }
267
268    if disconnected || installed {
269        *ctx.callgraph_store_rx().lock() = None;
270        if disconnected && !installed {
271            // Build failed: discard pending paths (no store to apply them to);
272            // a later op restarts the build and re-walks the project.
273            let _ = ctx.take_pending_callgraph_store_paths();
274        }
275        status_changed = true;
276    }
277
278    if status_changed {
279        ctx.status_emitter().signal(ctx.build_status_snapshot());
280    }
281}
282
283pub fn drain_semantic_index_events(ctx: &AppContext) {
284    let (events, disconnected) = {
285        let rx_ref = ctx.semantic_index_rx().lock();
286        let Some(rx) = rx_ref.as_ref() else {
287            return;
288        };
289
290        let mut events = Vec::new();
291        let mut disconnected = false;
292        loop {
293            match rx.try_recv() {
294                Ok(event) => events.push(event),
295                Err(crossbeam_channel::TryRecvError::Empty) => break,
296                Err(crossbeam_channel::TryRecvError::Disconnected) => {
297                    disconnected = true;
298                    break;
299                }
300            }
301        }
302        (events, disconnected)
303    };
304
305    if events.is_empty() && !disconnected {
306        return;
307    }
308
309    let mut keep_receiver = true;
310    let mut status_changed = false;
311    let mut replay_refresh_paths = Vec::new();
312    let mut replay_corpus_refresh = false;
313    for event in events {
314        match event {
315            SemanticIndexEvent::Progress {
316                stage,
317                files,
318                entries_done,
319                entries_total,
320            } => {
321                *ctx.semantic_index_status()
322                    .write()
323                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
324                    SemanticIndexStatus::Building {
325                        stage,
326                        files,
327                        entries_done,
328                        entries_total,
329                    };
330                // Push progress to the sidebar. Without this, a long rebuild
331                // (e.g. a slow local embedding backend re-indexing after a prior
332                // failure) leaves the sidebar showing the stale prior state —
333                // "failed" with an old error — for the entire build, even though
334                // it is actively embedding. Progress transitions are exactly
335                // when the user needs to see "building".
336                status_changed = true;
337            }
338            SemanticIndexEvent::ColdSeedGateCleared => {
339                ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
340            }
341            SemanticIndexEvent::Ready(mut index) => {
342                mark_semantic_corpus_refresh_success(ctx);
343                let pending_paths = ctx.take_pending_semantic_index_paths();
344                for path in pending_paths {
345                    if watcher_path_is_semantic_source(&path) {
346                        index.invalidate_file(&path);
347                        replay_refresh_paths.push(path);
348                    }
349                }
350                replay_corpus_refresh = ctx.take_pending_semantic_corpus_refresh();
351                *ctx.semantic_index()
352                    .write()
353                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
354                *ctx.semantic_index_status()
355                    .write()
356                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
357                    SemanticIndexStatus::ready();
358                keep_receiver = false;
359                status_changed = true;
360                ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
361            }
362            SemanticIndexEvent::Failed(error) => {
363                let _ = ctx.take_pending_semantic_index_paths();
364                let _ = ctx.take_pending_semantic_corpus_refresh();
365                *ctx.semantic_index()
366                    .write()
367                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
368                ctx.clear_semantic_refresh_worker();
369                *ctx.semantic_index_status()
370                    .write()
371                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
372                    SemanticIndexStatus::Failed(error);
373                keep_receiver = false;
374                status_changed = true;
375                ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
376            }
377        }
378    }
379
380    if disconnected && keep_receiver {
381        let _ = ctx.take_pending_semantic_index_paths();
382        let _ = ctx.take_pending_semantic_corpus_refresh();
383        *ctx.semantic_index()
384            .write()
385            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
386        ctx.clear_semantic_refresh_worker();
387        *ctx.semantic_index_status()
388            .write()
389            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Failed(
390            "semantic index build worker disconnected before reporting completion".to_string(),
391        );
392        keep_receiver = false;
393        status_changed = true;
394        ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
395    }
396
397    if !keep_receiver {
398        *ctx.semantic_index_rx().lock() = None;
399    }
400
401    if replay_corpus_refresh {
402        if ctx.canonical_cache_root_opt().is_some() {
403            *ctx.semantic_index_status()
404                .write()
405                .unwrap_or_else(std::sync::PoisonError::into_inner) =
406                SemanticIndexStatus::Building {
407                    stage: "refreshing_corpus".to_string(),
408                    files: None,
409                    entries_done: None,
410                    entries_total: None,
411                };
412            let sent = ctx
413                .semantic_refresh_sender()
414                .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
415            if !sent {
416                *ctx.semantic_index_status()
417                    .write()
418                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
419                    SemanticIndexStatus::Failed(
420                        "semantic corpus refresh worker unavailable".to_string(),
421                    );
422            }
423            status_changed = true;
424        }
425    } else if !replay_refresh_paths.is_empty() {
426        {
427            let mut status = ctx
428                .semantic_index_status()
429                .write()
430                .unwrap_or_else(std::sync::PoisonError::into_inner);
431            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
432                for path in &replay_refresh_paths {
433                    status.add_refreshing_file(path.clone());
434                }
435                status_changed = true;
436            }
437        }
438        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
439            sender
440                .send(SemanticRefreshRequest::Files {
441                    paths: replay_refresh_paths.clone(),
442                })
443                .is_ok()
444        });
445        if !sent {
446            crate::slog_warn!(
447                "semantic refresh worker unavailable; dropping {} replayed file(s)",
448                replay_refresh_paths.len()
449            );
450            let mut status = ctx
451                .semantic_index_status()
452                .write()
453                .unwrap_or_else(std::sync::PoisonError::into_inner);
454            for path in &replay_refresh_paths {
455                status.cancel_refreshing_file(path);
456            }
457            status_changed = true;
458        }
459    }
460
461    if status_changed {
462        ctx.status_emitter().signal(ctx.build_status_snapshot());
463    }
464}
465
466pub const MAX_RETRY_ATTEMPTS: usize = 6;
467pub const BREAKER_TRIP_THRESHOLD: usize = 3;
468
469/// Backoff for live semantic refresh retries after a transient embedding backend
470/// failure. Mirrors the cold-build retry cadence (15s -> 30s -> 60s capped) so
471/// a down backend cannot spin the watcher/refresh loop hot while still
472/// self-healing once the backend returns.
473fn semantic_refresh_retry_backoff(attempt: usize) -> Duration {
474    // Test seam, intentionally matching the build-level retry override.
475    if let Ok(raw) = std::env::var("AFT_SEMANTIC_RETRY_BACKOFF_MS") {
476        if let Ok(ms) = raw.parse::<u64>() {
477            return Duration::from_millis(ms);
478        }
479    }
480    const SCHEDULE_SECS: [u64; 3] = [15, 30, 60];
481    let secs = SCHEDULE_SECS
482        .get(attempt)
483        .copied()
484        .unwrap_or(*SCHEDULE_SECS.last().unwrap());
485    Duration::from_secs(secs)
486}
487
488struct SemanticRefreshRetryPlan {
489    retry_paths: Vec<std::path::PathBuf>,
490    capped_paths: Vec<std::path::PathBuf>,
491    delay: Option<Duration>,
492}
493
494fn next_semantic_refresh_retry_plan(
495    ctx: &AppContext,
496    paths: Vec<std::path::PathBuf>,
497) -> SemanticRefreshRetryPlan {
498    let mut retry_paths = Vec::new();
499    let mut capped_paths = Vec::new();
500    let mut max_attempt = 0usize;
501
502    ctx.with_semantic_refresh_retry_attempts_mut(|attempts| {
503        for path in paths {
504            let attempt = attempts.get(&path).copied().unwrap_or(0);
505            if attempt >= MAX_RETRY_ATTEMPTS {
506                capped_paths.push(path);
507                continue;
508            }
509            max_attempt = max_attempt.max(attempt);
510            attempts.insert(path.clone(), attempt.saturating_add(1));
511            retry_paths.push(path);
512        }
513    });
514
515    let delay = if retry_paths.is_empty() {
516        None
517    } else {
518        Some(semantic_refresh_retry_backoff(max_attempt))
519    };
520
521    SemanticRefreshRetryPlan {
522        retry_paths,
523        capped_paths,
524        delay,
525    }
526}
527
528fn clear_semantic_refresh_retry_attempts(ctx: &AppContext, paths: &[std::path::PathBuf]) {
529    ctx.clear_semantic_refresh_retry_attempts(paths);
530}
531
532fn clear_completed_pending_semantic_index_paths(
533    ctx: &AppContext,
534    completed_paths: &[std::path::PathBuf],
535) {
536    if completed_paths.is_empty() {
537        return;
538    }
539
540    let completed = completed_paths.iter().cloned().collect::<HashSet<_>>();
541    let remaining = ctx
542        .take_pending_semantic_index_paths()
543        .into_iter()
544        .filter(|path| !completed.contains(path))
545        .collect::<Vec<_>>();
546    if !remaining.is_empty() {
547        ctx.add_pending_semantic_index_paths(remaining);
548    }
549}
550
551fn semantic_refresh_probe_delay() -> Duration {
552    semantic_refresh_retry_backoff(usize::MAX)
553}
554
555pub fn semantic_refresh_circuit_is_open(ctx: &AppContext) -> bool {
556    ctx.semantic_refresh_circuit_is_open()
557}
558
559pub fn record_semantic_refresh_transient_failure(ctx: &AppContext) -> bool {
560    ctx.record_semantic_refresh_transient_failure(BREAKER_TRIP_THRESHOLD)
561}
562
563fn reset_semantic_refresh_transient_failure_count(ctx: &AppContext) {
564    ctx.reset_semantic_refresh_transient_failure_count();
565}
566
567fn reset_semantic_refresh_circuit_after_success(ctx: &AppContext) {
568    ctx.reset_semantic_refresh_circuit_after_success();
569}
570
571fn mark_semantic_refresh_success(ctx: &AppContext, completed_paths: &[std::path::PathBuf]) {
572    clear_semantic_refresh_retry_attempts(ctx, completed_paths);
573    clear_completed_pending_semantic_index_paths(ctx, completed_paths);
574    reset_semantic_refresh_circuit_after_success(ctx);
575}
576
577#[doc(hidden)]
578pub fn semantic_refresh_transient_failure_count_for_test(ctx: &AppContext) -> usize {
579    ctx.semantic_refresh_transient_failure_count()
580}
581
582#[doc(hidden)]
583pub fn semantic_refresh_probe_is_scheduled_for_test(ctx: &AppContext) -> bool {
584    ctx.semantic_refresh_probe_is_scheduled()
585}
586
587fn ensure_semantic_refresh_probe_scheduled(ctx: &AppContext) {
588    ctx.ensure_semantic_refresh_probe_scheduled(semantic_refresh_probe_delay());
589}
590
591fn maybe_fire_semantic_refresh_probe(ctx: &AppContext) {
592    if !ctx.take_semantic_refresh_probe_ready() {
593        return;
594    }
595    if !semantic_refresh_circuit_is_open(ctx) {
596        return;
597    }
598
599    let pending_paths = ctx.take_pending_semantic_index_paths();
600    if pending_paths.is_empty() {
601        return;
602    }
603
604    let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
605        sender
606            .send(SemanticRefreshRequest::Files {
607                paths: pending_paths.clone(),
608            })
609            .is_ok()
610    });
611    if !sent {
612        ctx.add_pending_semantic_index_paths(pending_paths);
613    }
614}
615
616pub fn schedule_semantic_refresh_retry(
617    ctx: &AppContext,
618    paths: Vec<std::path::PathBuf>,
619    error: &str,
620) -> bool {
621    if paths.is_empty() {
622        return false;
623    }
624    let Some(sender) = ctx.semantic_refresh_sender() else {
625        return false;
626    };
627
628    let SemanticRefreshRetryPlan {
629        retry_paths,
630        capped_paths,
631        delay,
632    } = next_semantic_refresh_retry_plan(ctx, paths);
633
634    if !capped_paths.is_empty() {
635        aft::slog_warn!(
636            "semantic refresh retry limit reached for {} file(s); preserving for next watcher/configure refresh",
637            capped_paths.len(),
638        );
639        ctx.add_pending_semantic_index_paths(capped_paths);
640    }
641
642    let Some(delay) = delay else {
643        return true;
644    };
645
646    let clean = aft::semantic_index::strip_transient_embedding_marker(error);
647    aft::slog_warn!(
648        "semantic refresh hit a transient backend error ({}); retrying {} file(s) in {}ms",
649        clean,
650        retry_paths.len(),
651        delay.as_millis(),
652    );
653
654    let session_id = log_ctx::current_session();
655    thread::spawn(move || {
656        log_ctx::with_session(session_id, || {
657            thread::sleep(delay);
658            let _ = sender.send(SemanticRefreshRequest::Files { paths: retry_paths });
659        });
660    });
661    true
662}
663
664pub fn drain_semantic_refresh_events(ctx: &AppContext) {
665    let (events, disconnected) = {
666        let rx_ref = ctx.semantic_refresh_event_rx().lock();
667        let Some(rx) = rx_ref.as_ref() else {
668            return;
669        };
670
671        let mut events = Vec::new();
672        let mut disconnected = false;
673        loop {
674            match rx.try_recv() {
675                Ok(event) => events.push(event),
676                Err(crossbeam_channel::TryRecvError::Empty) => break,
677                Err(crossbeam_channel::TryRecvError::Disconnected) => {
678                    disconnected = true;
679                    break;
680                }
681            }
682        }
683        (events, disconnected)
684    };
685
686    if events.is_empty() && !disconnected {
687        maybe_fire_semantic_refresh_probe(ctx);
688        return;
689    }
690
691    let had_events = !events.is_empty();
692    let mut status_changed = false;
693    let mut replay_refresh_paths = Vec::new();
694    for event in events {
695        match event {
696            SemanticRefreshEvent::Started { paths } => {
697                let mut status = ctx
698                    .semantic_index_status()
699                    .write()
700                    .unwrap_or_else(std::sync::PoisonError::into_inner);
701                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
702                    for path in paths {
703                        status.start_refreshing_file(path);
704                    }
705                    status_changed = true;
706                }
707            }
708            SemanticRefreshEvent::CorpusStarted { files } => {
709                *ctx.semantic_index_status()
710                    .write()
711                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
712                    SemanticIndexStatus::Building {
713                        stage: "refreshing_corpus".to_string(),
714                        files: Some(files),
715                        entries_done: None,
716                        entries_total: None,
717                    };
718                status_changed = true;
719            }
720            SemanticRefreshEvent::Completed {
721                added_entries,
722                updated_metadata,
723                completed_paths,
724            } => {
725                if let Some(index) = ctx
726                    .semantic_index()
727                    .write()
728                    .unwrap_or_else(std::sync::PoisonError::into_inner)
729                    .as_mut()
730                {
731                    index.apply_refresh_update(added_entries, updated_metadata, &completed_paths);
732                }
733                mark_semantic_refresh_success(ctx, &completed_paths);
734                let mut status = ctx
735                    .semantic_index_status()
736                    .write()
737                    .unwrap_or_else(std::sync::PoisonError::into_inner);
738                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
739                    for path in &completed_paths {
740                        status.complete_refreshing_file(path);
741                    }
742                    status_changed = true;
743                }
744            }
745            SemanticRefreshEvent::CorpusCompleted {
746                mut index,
747                changed,
748                added,
749                deleted,
750                total_processed,
751            } => {
752                aft::runtime_drain::mark_semantic_corpus_refresh_success(ctx);
753                if changed > 0 || added > 0 || deleted > 0 {
754                    aft::slog_info!(
755                        "semantic corpus refresh completed: {} changed, {} new, {} deleted, {} total processed",
756                        changed,
757                        added,
758                        deleted,
759                        total_processed
760                    );
761                }
762                let pending_paths = ctx.take_pending_semantic_index_paths();
763                for path in pending_paths {
764                    if !aft::runtime_drain::watcher_path_is_semantic_source(&path) {
765                        continue;
766                    }
767                    index.invalidate_file(&path);
768                    if !aft::runtime_drain::watcher_path_is_ignored_by_current_matcher(ctx, &path) {
769                        replay_refresh_paths.push(path);
770                    }
771                }
772                *ctx.semantic_index()
773                    .write()
774                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
775                *ctx.semantic_index_status()
776                    .write()
777                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
778                    SemanticIndexStatus::ready();
779                status_changed = true;
780            }
781            SemanticRefreshEvent::Failed { paths, error } => {
782                if aft::semantic_index::embedding_failure_is_transient(&error) {
783                    if record_semantic_refresh_transient_failure(ctx) {
784                        ctx.add_pending_semantic_index_paths(paths);
785                        ensure_semantic_refresh_probe_scheduled(ctx);
786                    } else if !schedule_semantic_refresh_retry(ctx, paths.clone(), &error) {
787                        aft::slog_warn!(
788                            "semantic refresh worker unavailable; preserving {} transiently failed file(s) for retry",
789                            paths.len(),
790                        );
791                        ctx.add_pending_semantic_index_paths(paths);
792                    }
793                } else {
794                    aft::slog_warn!("semantic refresh failed: {}", error);
795                    reset_semantic_refresh_transient_failure_count(ctx);
796                    clear_semantic_refresh_retry_attempts(ctx, &paths);
797                    let mut status = ctx
798                        .semantic_index_status()
799                        .write()
800                        .unwrap_or_else(std::sync::PoisonError::into_inner);
801                    if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
802                        for path in &paths {
803                            status.complete_refreshing_file(path);
804                        }
805                        status_changed = true;
806                    }
807                }
808            }
809            SemanticRefreshEvent::CorpusFailed { error } => {
810                // A transient backend blip during a corpus refresh must NOT
811                // destroy the working index — the prior index is still valid and
812                // serving. Keep it Ready and let the next watcher/ignore change
813                // re-trigger the refresh, rather than nuking everything to
814                // `Failed` over a connection hiccup (the same park-forever trap
815                // the initial build now rides out). Permanent errors (dimension
816                // mismatch, too-many-files) still drop the index and surface the
817                // real failure.
818                if aft::semantic_index::embedding_failure_is_transient(&error) {
819                    let clean = aft::semantic_index::strip_transient_embedding_marker(&error);
820                    let has_index = ctx
821                        .semantic_index()
822                        .read()
823                        .unwrap_or_else(std::sync::PoisonError::into_inner)
824                        .is_some();
825                    if has_index {
826                        aft::slog_warn!(
827                            "semantic corpus refresh hit a transient backend error ({}); keeping the existing index",
828                            clean,
829                        );
830                        *ctx.semantic_index_status()
831                            .write()
832                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
833                            SemanticIndexStatus::ready();
834                    } else {
835                        // No index to fall back on — surface the clean message.
836                        aft::slog_warn!("semantic corpus refresh failed: {}", clean);
837                        *ctx.semantic_index_status()
838                            .write()
839                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
840                            SemanticIndexStatus::Failed(clean);
841                    }
842                    status_changed = true;
843                } else {
844                    aft::slog_warn!("semantic corpus refresh failed: {}", error);
845                    let _ = ctx.take_pending_semantic_index_paths();
846                    *ctx.semantic_index()
847                        .write()
848                        .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
849                    *ctx.semantic_index_status()
850                        .write()
851                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
852                        SemanticIndexStatus::Failed(error);
853                    status_changed = true;
854                }
855            }
856        }
857    }
858
859    if disconnected {
860        ctx.clear_semantic_refresh_worker();
861        let refreshing_paths = {
862            let status = ctx
863                .semantic_index_status()
864                .read()
865                .unwrap_or_else(std::sync::PoisonError::into_inner);
866            match &*status {
867                SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
868                _ => Vec::new(),
869            }
870        };
871        if !refreshing_paths.is_empty() {
872            let mut status = ctx
873                .semantic_index_status()
874                .write()
875                .unwrap_or_else(std::sync::PoisonError::into_inner);
876            for path in &refreshing_paths {
877                status.cancel_refreshing_file(path);
878            }
879        }
880        if !refreshing_paths.is_empty() || had_events {
881            status_changed = true;
882        }
883    }
884
885    if !replay_refresh_paths.is_empty() {
886        {
887            let mut status = ctx
888                .semantic_index_status()
889                .write()
890                .unwrap_or_else(std::sync::PoisonError::into_inner);
891            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
892                for path in &replay_refresh_paths {
893                    status.add_refreshing_file(path.clone());
894                }
895                status_changed = true;
896            }
897        }
898        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
899            sender
900                .send(SemanticRefreshRequest::Files {
901                    paths: replay_refresh_paths.clone(),
902                })
903                .is_ok()
904        });
905        if !sent {
906            aft::slog_warn!(
907                "semantic refresh worker unavailable; dropping {} replayed corpus file(s)",
908                replay_refresh_paths.len()
909            );
910            let mut status = ctx
911                .semantic_index_status()
912                .write()
913                .unwrap_or_else(std::sync::PoisonError::into_inner);
914            for path in &replay_refresh_paths {
915                status.cancel_refreshing_file(path);
916            }
917            status_changed = true;
918        }
919    }
920
921    maybe_fire_semantic_refresh_probe(ctx);
922
923    if status_changed {
924        ctx.status_emitter().signal(ctx.build_status_snapshot());
925    }
926}
927
928/// Source file extensions that the call graph supports.
929const SOURCE_EXTENSIONS: &[&str] = &[
930    "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "pyi", "rs", "go",
931];
932
933pub const WATCHER_BATCH_INLINE_CAP: usize = 256;
934
935/// A `tsconfig.json` / `jsconfig.json` (including variant names like
936/// `tsconfig.base.json`). A change to any of these can shift TypeScript build
937/// membership (which files `tsc` checks), so the status-bar membership cache
938/// must be invalidated. Deliberately broad on the variant suffix and ignorant
939/// of `extends` graphs: the cache is cleared wholesale on a match, and base
940/// configs almost always follow the `tsconfig*.json` naming. Non-standard base
941/// names are covered on the next `tsconfig.json` change or `configure`.
942pub fn watcher_path_is_tsconfig(path: &std::path::Path) -> bool {
943    path.file_name()
944        .and_then(|n| n.to_str())
945        .map(|n| {
946            n == "tsconfig.json"
947                || n == "jsconfig.json"
948                || ((n.starts_with("tsconfig.") || n.starts_with("jsconfig."))
949                    && n.ends_with(".json"))
950        })
951        .unwrap_or(false)
952}
953
954pub fn watcher_path_is_source(path: &std::path::Path) -> bool {
955    path.extension()
956        .and_then(|ext| ext.to_str())
957        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
958}
959
960/// A file the callgraph STORE would have indexed at cold-build time. The store
961/// indexes every file `walk_project_files` yields (i.e. any detected language),
962/// not just the trigram `SOURCE_EXTENSIONS` set. Gating the store's watcher
963/// refresh on the narrower trigram set left edits to Java/C/C++/C#/Kotlin/Ruby/
964/// PHP/… (all of which the store extracts calls for) serving stale results until
965/// a full rebuild. Mirror cold-build exactly so refresh coverage == index
966/// coverage.
967pub fn watcher_path_is_callgraph_indexed(path: &std::path::Path) -> bool {
968    aft::parser::detect_language(path).is_some()
969}
970
971pub fn semantic_corpus_refresh_in_progress(ctx: &AppContext) -> bool {
972    let status = ctx
973        .semantic_index_status()
974        .read()
975        .unwrap_or_else(std::sync::PoisonError::into_inner);
976    matches!(
977        &*status,
978        SemanticIndexStatus::Building { stage, .. } if stage == "refreshing_corpus"
979    )
980}
981
982#[cfg(debug_assertions)]
983pub fn delay_search_rebuild_publish_for_debug() {
984    let Some(delay_ms) = std::env::var("AFT_TEST_SEARCH_REBUILD_PUBLISH_DELAY_MS")
985        .ok()
986        .and_then(|raw| raw.parse::<u64>().ok())
987    else {
988        return;
989    };
990    thread::sleep(Duration::from_millis(delay_ms));
991}
992
993#[cfg(not(debug_assertions))]
994pub fn delay_search_rebuild_publish_for_debug() {}
995
996pub fn spawn_search_corpus_refresh(
997    ctx: &AppContext,
998    root: std::path::PathBuf,
999    config: Arc<aft::config::Config>,
1000) {
1001    {
1002        let mut search_index = ctx
1003            .search_index()
1004            .write()
1005            .unwrap_or_else(std::sync::PoisonError::into_inner);
1006        if let Some(index) = search_index.as_mut() {
1007            index.ready = false;
1008        }
1009    }
1010
1011    let (tx, rx): (
1012        crossbeam_channel::Sender<aft::search_index::SearchIndex>,
1013        crossbeam_channel::Receiver<aft::search_index::SearchIndex>,
1014    ) = crossbeam_channel::unbounded();
1015    *ctx.search_index_rx()
1016        .write()
1017        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(rx);
1018    ctx.reset_symbol_cache();
1019
1020    let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
1021    let session_id = log_ctx::current_session();
1022    thread::spawn(move || {
1023        log_ctx::with_session(session_id, || {
1024            let cache_dir =
1025                aft::search_index::resolve_cache_dir(&root, config.storage_dir.as_deref());
1026            let _cache_lock = if shared_artifacts_read_only {
1027                None
1028            } else {
1029                match aft::search_index::CacheLock::acquire(&cache_dir) {
1030                    Ok(lock) => Some(lock),
1031                    Err(error) => {
1032                        aft::slog_warn!(
1033                            "failed to acquire search cache lock for ignore refresh: {}",
1034                            error
1035                        );
1036                        None
1037                    }
1038                }
1039            };
1040            let mut index = aft::search_index::SearchIndex::build_with_limit_to_cache_dir(
1041                &root,
1042                config.search_index_max_file_size,
1043                &cache_dir,
1044            );
1045            delay_search_rebuild_publish_for_debug();
1046            if !shared_artifacts_read_only {
1047                let head = index.stored_git_head().map(str::to_owned);
1048                index.write_to_disk(&cache_dir, head.as_deref());
1049            }
1050            let _ = tx.send(index);
1051        });
1052    });
1053}
1054
1055pub fn refresh_project_corpus(
1056    ctx: &AppContext,
1057    reason: &str,
1058    _invalidate_ignore_paths: bool,
1059) -> bool {
1060    let Some(root) = ctx.canonical_cache_root_opt() else {
1061        return false;
1062    };
1063    if !ctx.heavy_root_work_allowed() {
1064        return false;
1065    }
1066    let config = ctx.config();
1067    let mut status_changed = false;
1068
1069    if !ctx.is_worktree_bridge() {
1070        // Do NOT cold-build the callgraph store synchronously here. This function
1071        // runs on the single-threaded dispatch loop from `drain_watcher_events`,
1072        // which fires before EVERY request (and on idle ticks). A full O(repo)
1073        // `refresh_corpus` (= `cold_build`: parse all files + resolve refs +
1074        // rewrite SQLite) blocks ALL queued requests — including `configure` and
1075        // `bash` — for its entire duration, which exceeds the 30s transport
1076        // timeout on a large repo. On a long-lived bridge (OpenCode Desktop) an
1077        // FSEvents overflow triggers this drain, so the user sees configure/bash
1078        // time out (regression: the watcher-overflow path that calls this is new
1079        // in 0.39.1; the ignore-rule path that also calls this had the same
1080        // latent inline block, just rarely triggered).
1081        //
1082        // Instead, drop the resident store and force a BACKGROUND rebuild: the
1083        // next `callgraph_store_for_ops()` spawns the cold build off-thread and
1084        // returns `Building` (callgraph ops + dead_code projection already handle
1085        // `Building`/unavailable gracefully). This mirrors the search/semantic
1086        // refreshes below, which are already async. A build already in flight
1087        // keeps running; the resident drop + force flag make the next op converge
1088        // to a fresh full rebuild.
1089        // Mirror the original "act only when the callgraph is actually loaded or
1090        // building" guard, but reschedule instead of inline-building.
1091        let callgraph_store_resident = {
1092            let guard = ctx
1093                .callgraph_store()
1094                .read()
1095                .unwrap_or_else(std::sync::PoisonError::into_inner);
1096            guard.is_some()
1097        };
1098        if callgraph_store_resident || ctx.callgraph_store_rx().lock().is_some() {
1099            *ctx.callgraph_store()
1100                .write()
1101                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1102            ctx.mark_callgraph_store_force_rebuild();
1103            status_changed = true;
1104            aft::slog_info!(
1105                "callgraph store scheduled for background rebuild after {}",
1106                reason
1107            );
1108        }
1109    }
1110
1111    if config.search_index && !ctx.shared_artifacts_read_only() {
1112        spawn_search_corpus_refresh(ctx, root.clone(), config.clone());
1113        status_changed = true;
1114        aft::slog_info!("started search index refresh after {}", reason);
1115    }
1116
1117    if config.semantic_search && !ctx.shared_artifacts_read_only() {
1118        if let Some(sender) = ctx.semantic_refresh_sender() {
1119            *ctx.semantic_index_status()
1120                .write()
1121                .unwrap_or_else(std::sync::PoisonError::into_inner) =
1122                SemanticIndexStatus::Building {
1123                    stage: "refreshing_corpus".to_string(),
1124                    files: None,
1125                    entries_done: None,
1126                    entries_total: None,
1127                };
1128            match sender.send(SemanticRefreshRequest::Corpus) {
1129                Ok(()) => {
1130                    status_changed = true;
1131                }
1132                Err(error) => {
1133                    *ctx.semantic_index_status()
1134                        .write()
1135                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
1136                        SemanticIndexStatus::Failed(format!(
1137                            "semantic corpus refresh worker unavailable: {error}"
1138                        ));
1139                    status_changed = true;
1140                }
1141            }
1142        } else if ctx.semantic_index_rx().lock().is_some() {
1143            ctx.mark_pending_semantic_corpus_refresh();
1144        }
1145    }
1146
1147    status_changed
1148}
1149
1150pub fn refresh_corpus_after_ignore_change(ctx: &AppContext) -> bool {
1151    refresh_project_corpus(ctx, "ignore-rule change", true)
1152}
1153
1154pub fn refresh_project_after_watcher_rescan(ctx: &AppContext) -> bool {
1155    if ctx.canonical_cache_root_opt().is_none() {
1156        return false;
1157    }
1158    ctx.clear_pending_index_updates();
1159    ctx.reset_symbol_cache();
1160    let _ = ctx.mark_status_bar_tier2_stale();
1161    ctx.clear_tsconfig_membership_cache();
1162    let mut status_changed = true;
1163
1164    status_changed |= refresh_project_corpus(ctx, "watcher overflow", false);
1165    status_changed
1166}
1167
1168pub fn refresh_callgraph_store_for_watcher(
1169    ctx: &AppContext,
1170    changed: &HashSet<std::path::PathBuf>,
1171) {
1172    if ctx.is_worktree_bridge() || !ctx.heavy_root_work_allowed() {
1173        return;
1174    }
1175    let source_paths = changed
1176        .iter()
1177        .filter(|path| watcher_path_is_callgraph_indexed(path))
1178        .cloned()
1179        .collect::<Vec<_>>();
1180    if source_paths.is_empty() {
1181        return;
1182    }
1183    // Converge to the current generation before writing: if another process
1184    // published a newer one, drop our stale store so the changed paths get
1185    // recorded as pending and replayed against the fresh store (rather than
1186    // incrementally written into a superseded generation).
1187    ctx.revalidate_callgraph_store_generation();
1188    let store = {
1189        let guard = ctx
1190            .callgraph_store()
1191            .read()
1192            .unwrap_or_else(std::sync::PoisonError::into_inner);
1193        guard.as_ref().map(Arc::clone)
1194    };
1195    let Some(store) = store else {
1196        // Store not resident yet. If a cold build is in flight, record the
1197        // changed paths so they're replayed once the freshly-built store lands
1198        // (otherwise mid-build edits would be silently lost). If no build is
1199        // running, there's nothing to refresh.
1200        if ctx.callgraph_store_rx().lock().is_some() {
1201            ctx.add_pending_callgraph_store_paths(source_paths);
1202        }
1203        return;
1204    };
1205    if let Err(error) = store.refresh_files(&source_paths) {
1206        aft::slog_warn!("callgraph store refresh failed: {}", error);
1207        match store.mark_files_stale(&source_paths) {
1208            Ok(marked) => aft::slog_warn!(
1209                "marked {} callgraph store file(s) stale after refresh failure",
1210                marked.len()
1211            ),
1212            Err(mark_error) => aft::slog_warn!(
1213                "failed to mark callgraph store files stale after refresh failure: {}",
1214                mark_error
1215            ),
1216        }
1217    }
1218}
1219
1220/// Drain pre-filtered watcher events and apply cache invalidations on the
1221/// dispatch thread. The watcher filter thread owns notify receive/decode,
1222/// metadata filtering, ignore matching, root-deleted detection, and path
1223/// coalescing; this drain only reacts to compact control events and surviving
1224/// paths because the cache/index state below is not Send.
1225pub fn drain_watcher_events(ctx: &AppContext) {
1226    let _ = drain_watcher_events_bounded(ctx, usize::MAX);
1227}
1228
1229pub fn drain_watcher_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
1230    let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
1231    let mut ignore_file_changed = false;
1232    let mut rescan_required = false;
1233    let mut watcher_failed = None;
1234    let mut root_deleted = false;
1235    let mut outcome = DrainBatchOutcome::default();
1236
1237    {
1238        let rx_ref = ctx.watcher_rx().lock();
1239        let rx = match rx_ref.as_ref() {
1240            Some(rx) => rx,
1241            None => {
1242                ctx.tick_tier2_refresh_scheduler(0);
1243                return outcome; // No watcher configured
1244            }
1245        };
1246
1247        loop {
1248            if outcome.processed >= max_events {
1249                outcome.has_more = !rx.is_empty();
1250                break;
1251            }
1252            match rx.try_recv() {
1253                Ok(WatcherDispatchEvent::Paths(paths)) => {
1254                    outcome.processed += 1;
1255                    if !rescan_required {
1256                        changed.extend(paths);
1257                    }
1258                }
1259                Ok(WatcherDispatchEvent::RescanRequired) => {
1260                    outcome.processed += 1;
1261                    rescan_required = true;
1262                    changed.clear();
1263                }
1264                Ok(WatcherDispatchEvent::IgnoreRulesChanged { path }) => {
1265                    outcome.processed += 1;
1266                    ignore_file_changed = true;
1267                    log::debug!(
1268                        "watcher: ignore rules changed at {}, rebuilding matcher",
1269                        path.display()
1270                    );
1271                    if !rescan_required {
1272                        if ctx.heavy_root_work_allowed() {
1273                            ctx.rebuild_gitignore();
1274                        } else {
1275                            ctx.clear_gitignore();
1276                        }
1277                    }
1278                }
1279                Ok(WatcherDispatchEvent::RootDeleted) => {
1280                    outcome.processed += 1;
1281                    root_deleted = true;
1282                    break;
1283                }
1284                Ok(WatcherDispatchEvent::Error(error)) => {
1285                    outcome.processed += 1;
1286                    watcher_failed = Some(error);
1287                    break;
1288                }
1289                Err(crossbeam_channel::TryRecvError::Empty) => break,
1290                Err(crossbeam_channel::TryRecvError::Disconnected) => {
1291                    watcher_failed = Some("watcher channel disconnected".to_string());
1292                    break;
1293                }
1294            }
1295        }
1296    }
1297
1298    let mut watcher_status_changed = false;
1299    if root_deleted {
1300        ctx.stop_watcher_runtime();
1301        let _ = ctx.add_degraded_reason("project_root_deleted".to_string());
1302        aft::slog_warn!(
1303            "project root deleted; dropping watcher to avoid delete-storm: {:?}",
1304            ctx.canonical_cache_root_opt()
1305        );
1306        watcher_status_changed = true;
1307        changed.clear();
1308        rescan_required = false;
1309    } else if let Some(error) = watcher_failed {
1310        ctx.stop_watcher_runtime();
1311        let _ = ctx.add_degraded_reason("watcher_unavailable".to_string());
1312        aft::slog_warn!(
1313            "file watcher unavailable; continuing without live external-change invalidation: {}",
1314            error
1315        );
1316        watcher_status_changed = true;
1317        rescan_required = false;
1318    }
1319
1320    let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
1321    let mut status_changed = watcher_status_changed;
1322    let mut project_corpus_refresh_requested = false;
1323    if rescan_required {
1324        aft::slog_warn!("watcher overflow: forcing project rescan");
1325        if heavy_root_work_allowed {
1326            ctx.rebuild_gitignore();
1327        } else {
1328            ctx.clear_gitignore();
1329        }
1330        status_changed |= refresh_project_after_watcher_rescan(ctx);
1331        project_corpus_refresh_requested = true;
1332        changed.clear();
1333    } else if ignore_file_changed {
1334        status_changed |= refresh_corpus_after_ignore_change(ctx);
1335        project_corpus_refresh_requested = true;
1336    }
1337
1338    let scheduler_changed_path_count = if rescan_required {
1339        aft::inspect::tier2_scheduler::TIER2_REFRESH_STORM_PATH_THRESHOLD + 1
1340    } else if ignore_file_changed {
1341        changed.len().max(1)
1342    } else {
1343        changed.len()
1344    };
1345    if changed.is_empty() {
1346        if status_changed {
1347            ctx.status_emitter().signal(ctx.build_status_snapshot());
1348        }
1349        ctx.tick_tier2_refresh_scheduler(scheduler_changed_path_count);
1350        return outcome;
1351    }
1352
1353    if heavy_root_work_allowed {
1354        ctx.add_pending_tier2_paths(changed.iter().cloned());
1355    }
1356
1357    // A real source change makes the last-known Tier-2 counts stale until the
1358    // next background scan reconciles them — surface that in the status bar
1359    // immediately (the `~` marker) so the agent never reads them as live.
1360    if ctx.mark_status_bar_tier2_stale() {
1361        status_changed = true;
1362    }
1363
1364    // A tsconfig change can shift which files `tsc` checks, which is the policy
1365    // the status-bar E/W count filters on. Clear the membership cache wholesale
1366    // so the next bar count re-resolves from disk (handles new nested configs,
1367    // edited `extends` parents, and deletions without per-key bookkeeping).
1368    if changed.iter().any(|path| watcher_path_is_tsconfig(path)) {
1369        ctx.clear_tsconfig_membership_cache();
1370        status_changed = true;
1371    }
1372
1373    let oversized_inline_batch = changed.len() > WATCHER_BATCH_INLINE_CAP;
1374    if oversized_inline_batch {
1375        aft::slog_warn!(
1376            "watcher batch of {} paths exceeds inline cap {}; scheduling corpus refresh",
1377            changed.len(),
1378            WATCHER_BATCH_INLINE_CAP
1379        );
1380        if !project_corpus_refresh_requested {
1381            status_changed |= refresh_project_corpus(ctx, "oversized watcher batch", false);
1382        }
1383    }
1384
1385    let search_build_in_progress = {
1386        let search_index_rx = ctx
1387            .search_index_rx()
1388            .read()
1389            .unwrap_or_else(std::sync::PoisonError::into_inner);
1390        search_index_rx.is_some()
1391    };
1392    if heavy_root_work_allowed
1393        && !ctx.shared_artifacts_read_only()
1394        && !oversized_inline_batch
1395        && search_build_in_progress
1396    {
1397        ctx.add_pending_search_index_paths(changed.iter().cloned());
1398    }
1399    let semantic_source_paths = changed
1400        .iter()
1401        .filter(|path| aft::runtime_drain::watcher_path_is_semantic_source(path))
1402        .cloned()
1403        .collect::<Vec<_>>();
1404    let semantic_build_in_progress = ctx.semantic_index_rx().lock().is_some();
1405    let semantic_corpus_refresh_in_progress = semantic_corpus_refresh_in_progress(ctx);
1406    if heavy_root_work_allowed
1407        && !ctx.shared_artifacts_read_only()
1408        && !oversized_inline_batch
1409        && (semantic_build_in_progress || semantic_corpus_refresh_in_progress)
1410        && !semantic_source_paths.is_empty()
1411    {
1412        ctx.add_pending_semantic_index_paths(semantic_source_paths.clone());
1413    }
1414
1415    if !ctx.shared_artifacts_read_only() {
1416        if let Ok(mut symbol_cache) = ctx.symbol_cache().write() {
1417            for path in &changed {
1418                symbol_cache.invalidate(path);
1419            }
1420        }
1421    }
1422
1423    let mut semantic_refresh_paths = Vec::new();
1424    if heavy_root_work_allowed && !oversized_inline_batch {
1425        refresh_callgraph_store_for_watcher(ctx, &changed);
1426
1427        if !ctx.shared_artifacts_read_only() {
1428            let mut index_ref = ctx
1429                .search_index()
1430                .write()
1431                .unwrap_or_else(std::sync::PoisonError::into_inner);
1432            if let Some(index) = index_ref.as_mut() {
1433                for path in &changed {
1434                    if path.exists() {
1435                        index.update_file(path);
1436                    } else {
1437                        index.remove_file(path);
1438                    }
1439                }
1440            }
1441        }
1442
1443        let stale_paths = if ctx.shared_artifacts_read_only() {
1444            Vec::new()
1445        } else {
1446            let mut semantic_index_ref = ctx
1447                .semantic_index()
1448                .write()
1449                .unwrap_or_else(std::sync::PoisonError::into_inner);
1450            let mut stale_paths = Vec::new();
1451            if let Some(index) = semantic_index_ref.as_mut() {
1452                for path in &semantic_source_paths {
1453                    index.invalidate_file(path);
1454                    stale_paths.push(path.clone());
1455                }
1456            }
1457            stale_paths
1458        };
1459        if !stale_paths.is_empty() {
1460            let mut status = ctx
1461                .semantic_index_status()
1462                .write()
1463                .unwrap_or_else(std::sync::PoisonError::into_inner);
1464            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1465                for path in &stale_paths {
1466                    status.add_refreshing_file(path.clone());
1467                }
1468                semantic_refresh_paths = stale_paths;
1469                status_changed = true;
1470            }
1471        }
1472    }
1473
1474    // Keep LSP diagnostics honest for filesystem changes that bypass AFT's
1475    // write/edit tools. Deleted files cannot be republished by a server, so we
1476    // still evict them. Existing files with warm diagnostics are marked stale:
1477    // stale entries stay in the store for pull resultIds/server coverage, but
1478    // status-bar and inspect warm reads hide them until a publish or pull proves
1479    // they match the current file contents.
1480    //
1481    // Not gated on the trigram `SOURCE_EXTENSIONS` set: any registered LSP
1482    // server (Bash, YAML, Solidity, Vue, C/C++, custom servers, …) can publish
1483    // diagnostics for files outside that set. The store checks are cheap no-ops
1484    // for paths with no diagnostics.
1485    let mut lsp_resync_paths = Vec::new();
1486    for path in &changed {
1487        if !path.exists() {
1488            if ctx.lsp_clear_diagnostics_for_file(path) {
1489                status_changed = true;
1490            }
1491            continue;
1492        }
1493
1494        let stale = ctx.lsp_mark_diagnostics_stale_for_file(path);
1495        if stale.changed {
1496            status_changed = true;
1497        }
1498        if stale.had_entries {
1499            lsp_resync_paths.push(path.clone());
1500        }
1501    }
1502
1503    for path in &lsp_resync_paths {
1504        ctx.lsp_resync_changed_file_for_diagnostics(path);
1505    }
1506
1507    if !semantic_refresh_paths.is_empty() {
1508        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1509            sender
1510                .send(SemanticRefreshRequest::Files {
1511                    paths: semantic_refresh_paths.clone(),
1512                })
1513                .is_ok()
1514        });
1515        if !sent {
1516            aft::slog_warn!(
1517                "semantic refresh worker unavailable; dropping {} refreshing file(s)",
1518                semantic_refresh_paths.len()
1519            );
1520            let mut status = ctx
1521                .semantic_index_status()
1522                .write()
1523                .unwrap_or_else(std::sync::PoisonError::into_inner);
1524            for path in &semantic_refresh_paths {
1525                status.cancel_refreshing_file(path);
1526            }
1527            status_changed = true;
1528        }
1529    }
1530
1531    aft::slog_info!("invalidated {} files", changed.len());
1532    if status_changed {
1533        ctx.status_emitter().signal(ctx.build_status_snapshot());
1534    }
1535    ctx.tick_tier2_refresh_scheduler(scheduler_changed_path_count);
1536    outcome
1537}
1538
1539pub fn drain_lsp_events(ctx: &AppContext) {
1540    let _ = drain_lsp_events_bounded(ctx, usize::MAX);
1541}
1542
1543pub fn drain_lsp_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
1544    let drained = {
1545        let mut lsp = ctx.lsp();
1546        lsp.drain_events_bounded(max_events)
1547    };
1548    let outcome = DrainBatchOutcome {
1549        processed: drained.events.len(),
1550        has_more: drained.has_more,
1551    };
1552    let mut status_changed = drained.diagnostics_changed;
1553    for event in drained.events {
1554        match event {
1555            LspEvent::Notification {
1556                server_kind,
1557                root,
1558                method,
1559                params,
1560            } => {
1561                log::debug!(
1562                    "[aft-lsp] notification {:?} {} {} {}",
1563                    server_kind,
1564                    root.display(),
1565                    method,
1566                    params.unwrap_or(serde_json::Value::Null)
1567                );
1568            }
1569            LspEvent::ServerRequest {
1570                server_kind,
1571                root,
1572                id,
1573                method,
1574                params,
1575            } => {
1576                log::debug!(
1577                    "[aft-lsp] request {:?} {} {:?} {} {}",
1578                    server_kind,
1579                    root.display(),
1580                    id,
1581                    method,
1582                    params.unwrap_or(serde_json::Value::Null)
1583                );
1584            }
1585            LspEvent::ServerExited { server_kind, root } => {
1586                aft::slog_info!("exited {:?} {}", server_kind, root.display());
1587                status_changed = true;
1588            }
1589        }
1590    }
1591    if status_changed {
1592        ctx.status_emitter().signal(ctx.build_status_snapshot());
1593    }
1594    outcome
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599    use super::*;
1600    use crate::config::Config;
1601    use crate::context::{default_language_provider_factory, AppContext};
1602
1603    fn watcher_context(
1604        root: &Path,
1605    ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
1606        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
1607        ctx.update_config(|config| {
1608            config.project_root = Some(root.to_path_buf());
1609        });
1610        ctx.set_canonical_cache_root(root.to_path_buf());
1611        let (tx, rx) = crossbeam_channel::unbounded();
1612        *ctx.watcher_rx().lock() = Some(rx);
1613        (ctx, tx)
1614    }
1615
1616    #[test]
1617    fn watcher_drain_batch_cap_yields_with_events_remaining() {
1618        let temp = tempfile::tempdir().unwrap();
1619        let (ctx, tx) = watcher_context(temp.path());
1620        let cap = 3;
1621        for index in 0..(cap * 2 + 1) {
1622            tx.send(WatcherDispatchEvent::Paths(vec![temp
1623                .path()
1624                .join(format!("file-{index}.rs"))]))
1625                .unwrap();
1626        }
1627
1628        let first = drain_watcher_events_bounded(&ctx, cap);
1629
1630        assert_eq!(first.processed, cap);
1631        assert!(first.has_more);
1632        assert_eq!(ctx.pending_tier2_paths().len(), cap);
1633    }
1634
1635    #[test]
1636    fn watcher_drain_requeues_until_all_events_are_applied() {
1637        let temp = tempfile::tempdir().unwrap();
1638        let (ctx, tx) = watcher_context(temp.path());
1639        let cap = 4;
1640        let total = cap * 2 + 3;
1641        for index in 0..total {
1642            tx.send(WatcherDispatchEvent::Paths(vec![temp
1643                .path()
1644                .join(format!("file-{index}.rs"))]))
1645                .unwrap();
1646        }
1647
1648        let mut processed = 0;
1649        loop {
1650            let outcome = drain_watcher_events_bounded(&ctx, cap);
1651            assert!(outcome.processed <= cap);
1652            processed += outcome.processed;
1653            if !outcome.has_more {
1654                break;
1655            }
1656        }
1657
1658        assert_eq!(processed, total);
1659        assert_eq!(ctx.pending_tier2_paths().len(), total);
1660    }
1661}