Skip to main content

aft/
logging.rs

1//! Durable process logging and low-cost periodic performance summaries.
2//!
3//! Rust module processes use one file per PID. That avoids cross-process rename
4//! races while preserving a single greppable directory for all AFT activity.
5
6use crate::bash_background::process::is_process_alive;
7use crate::executor::Executor;
8use crate::run_tool_call::{ToolCallPhaseDurations, WaitingOn};
9use std::cell::RefCell;
10use std::collections::{BTreeMap, HashMap, VecDeque};
11use std::fs::{self, File, OpenOptions};
12use std::io::{self, BufWriter, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::mpsc::{self, SyncSender, TrySendError};
16use std::sync::{LazyLock, Mutex, OnceLock};
17use std::thread;
18use std::time::{Duration, Instant, SystemTime};
19
20/// Maximum size of an active Rust or plugin log before its single backup rotates in.
21const LOG_FILE_BYTES: u64 = 32 * 1024 * 1024;
22/// Keep one backup generation; retention is hygiene rather than a user setting.
23const LOG_GENERATIONS: usize = 1;
24/// Check the active file on every write so the cap is not exceeded by a burst.
25const ROTATION_CHECK_EVERY: u64 = 1;
26const LOG_CHANNEL_CAPACITY: usize = 4096;
27/// Do not reap a dead PID's file until it has been quiet for at least one day.
28const DEAD_PROCESS_LOG_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
29/// Limit the total regular-file footprint left in the log directory.
30const LOG_DIRECTORY_BUDGET_BYTES: u64 = 200 * 1024 * 1024;
31/// Maintenance ticks may call the sweep, but actual directory work is hourly.
32const LOG_SWEEP_INTERVAL: Duration = Duration::from_secs(60 * 60);
33const DEFAULT_PERF_TICK_INTERVAL: Duration = Duration::from_secs(60);
34const PERF_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
35const SLOW_TOOL_CALL_THRESHOLD: Duration = Duration::from_millis(50);
36const TOOL_CALL_SAMPLE_CAPACITY: usize = 256;
37/// Census lines stay greppable and short; extra fields are dropped past this.
38const INDEX_EVENT_MAX_BYTES: usize = 300;
39
40/// Standing-index plane recorded on every `index_event` line.
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
42pub(crate) enum IndexPlane {
43    Callgraph,
44    Search,
45    Semantic,
46    Tier2,
47}
48
49impl IndexPlane {
50    pub(crate) fn as_str(self) -> &'static str {
51        match self {
52            Self::Callgraph => "callgraph",
53            Self::Search => "search",
54            Self::Semantic => "semantic",
55            Self::Tier2 => "tier2",
56        }
57    }
58}
59
60/// Lifecycle kind recorded on every `index_event` line.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub(crate) enum IndexEventKind {
63    BuildStarted,
64    BuildProgress,
65    BuildReady,
66    BuildSuperseded,
67    #[allow(dead_code)]
68    BuildCancelled,
69    BuildFailed,
70    BuildSuspended,
71    BreakerAdmitted,
72    BreakerReset,
73    ArtifactLoaded,
74    FirstQuery,
75}
76
77impl IndexEventKind {
78    pub(crate) fn as_str(self) -> &'static str {
79        match self {
80            Self::BuildStarted => "build_started",
81            Self::BuildProgress => "build_progress",
82            Self::BuildReady => "build_ready",
83            Self::BuildSuperseded => "build_superseded",
84            Self::BuildCancelled => "build_cancelled",
85            Self::BuildFailed => "build_failed",
86            Self::BuildSuspended => "build_suspended",
87            Self::BreakerAdmitted => "breaker_admitted",
88            Self::BreakerReset => "breaker_reset",
89            Self::ArtifactLoaded => "artifact_loaded",
90            Self::FirstQuery => "first_query",
91        }
92    }
93
94    fn is_terminal(self) -> bool {
95        matches!(
96            self,
97            Self::BuildReady
98                | Self::BuildSuperseded
99                | Self::BuildCancelled
100                | Self::BuildFailed
101                | Self::BuildSuspended
102        )
103    }
104}
105
106/// One structured standing-index lifecycle record.
107#[derive(Clone, Debug)]
108pub(crate) struct IndexEvent {
109    pub kind: IndexEventKind,
110    pub plane: IndexPlane,
111    pub build_id: String,
112    pub root: PathBuf,
113    pub key: String,
114    extra: Vec<(&'static str, String)>,
115}
116
117impl IndexEvent {
118    pub(crate) fn new(
119        kind: IndexEventKind,
120        plane: IndexPlane,
121        build_id: impl Into<String>,
122        root: impl AsRef<Path>,
123        key: impl Into<String>,
124    ) -> Self {
125        Self {
126            kind,
127            plane,
128            build_id: build_id.into(),
129            root: root.as_ref().to_path_buf(),
130            key: key.into(),
131            extra: Vec::new(),
132        }
133    }
134
135    pub(crate) fn from_scope(kind: IndexEventKind, scope: &IndexBuildScope) -> Self {
136        Self::new(
137            kind,
138            scope.plane,
139            scope.build_id.clone(),
140            &scope.root,
141            scope.key.clone(),
142        )
143    }
144
145    pub(crate) fn field(mut self, key: &'static str, value: impl ToString) -> Self {
146        self.extra.push((key, value.to_string()));
147        self
148    }
149}
150
151/// Identity of one in-flight build attempt, carried on a thread-local so nested
152/// helpers such as `ensure_cold_build_current` can emit without extra params.
153#[derive(Clone, Debug)]
154pub(crate) struct IndexBuildScope {
155    pub plane: IndexPlane,
156    pub build_id: String,
157    pub root: PathBuf,
158    pub key: String,
159    pub started_at: Instant,
160}
161
162impl IndexBuildScope {
163    pub(crate) fn new(plane: IndexPlane, root: impl AsRef<Path>, key: impl Into<String>) -> Self {
164        Self {
165            plane,
166            build_id: mint_index_build_id(),
167            root: root.as_ref().to_path_buf(),
168            key: key.into(),
169            started_at: Instant::now(),
170        }
171    }
172
173    pub(crate) fn elapsed_ms(&self) -> u64 {
174        self.started_at.elapsed().as_millis().min(u64::MAX as u128) as u64
175    }
176}
177
178struct InFlightBuild {
179    build_id: String,
180}
181
182struct UnclaimedReady {
183    build_id: String,
184    key: String,
185    ready_at: Instant,
186}
187
188#[derive(Default)]
189struct IndexLifecycle {
190    in_flight: Mutex<HashMap<(String, IndexPlane), InFlightBuild>>,
191    unclaimed: Mutex<HashMap<(String, IndexPlane), UnclaimedReady>>,
192}
193
194#[derive(Default)]
195struct IndexBuildStartSignals {
196    sequence: u64,
197    last_by_root_plane: HashMap<(String, IndexPlane), u64>,
198    in_flight_by_root_plane: HashMap<(String, IndexPlane), u64>,
199    waiters: HashMap<(String, IndexPlane), Vec<(u64, crossbeam_channel::Sender<()>)>>,
200}
201
202struct ToolCallWaitState {
203    waiting_on: WaitingOn,
204    waiting_on_build_id: Option<String>,
205    wait_ms: u64,
206    queue_ms: u64,
207}
208
209impl Default for ToolCallWaitState {
210    fn default() -> Self {
211        Self {
212            waiting_on: WaitingOn::None,
213            waiting_on_build_id: None,
214            wait_ms: 0,
215            queue_ms: 0,
216        }
217    }
218}
219
220static INDEX_BUILD_COUNTER: AtomicU64 = AtomicU64::new(1);
221static INDEX_LIFECYCLE: LazyLock<IndexLifecycle> = LazyLock::new(IndexLifecycle::default);
222static INDEX_BUILD_START_SIGNALS: LazyLock<Mutex<IndexBuildStartSignals>> =
223    LazyLock::new(|| Mutex::new(IndexBuildStartSignals::default()));
224
225thread_local! {
226    static CURRENT_INDEX_BUILD: RefCell<Option<IndexBuildScope>> = const { RefCell::new(None) };
227    static TOOL_CALL_WAIT: RefCell<ToolCallWaitState> = const {
228        RefCell::new(ToolCallWaitState {
229            waiting_on: WaitingOn::None,
230            waiting_on_build_id: None,
231            wait_ms: 0,
232            queue_ms: 0,
233        })
234    };
235}
236
237#[cfg(test)]
238static INDEX_EVENT_CAPTURE: LazyLock<Mutex<Option<Vec<String>>>> =
239    LazyLock::new(|| Mutex::new(None));
240#[cfg(test)]
241static INDEX_EVENT_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
242
243/// Mint a stable per-attempt id (`b-<pid>-<n>`) at `build_started`.
244pub(crate) fn mint_index_build_id() -> String {
245    let n = INDEX_BUILD_COUNTER.fetch_add(1, Ordering::Relaxed);
246    format!("b-{}-{n}", std::process::id())
247}
248
249/// RAII install of the current index-build attempt on this thread.
250pub(crate) struct IndexBuildGuard {
251    previous: Option<IndexBuildScope>,
252}
253
254impl Drop for IndexBuildGuard {
255    fn drop(&mut self) {
256        CURRENT_INDEX_BUILD.with(|slot| {
257            *slot.borrow_mut() = self.previous.take();
258        });
259    }
260}
261
262/// Install `scope` as the current index-build attempt until the guard drops.
263pub(crate) fn install_index_build(scope: IndexBuildScope) -> IndexBuildGuard {
264    let previous = CURRENT_INDEX_BUILD.with(|slot| slot.replace(Some(scope)));
265    IndexBuildGuard { previous }
266}
267
268/// Run `f` with `scope` as the current index-build attempt on this thread.
269#[allow(dead_code)]
270pub(crate) fn with_index_build<R>(scope: IndexBuildScope, f: impl FnOnce() -> R) -> R {
271    let _guard = install_index_build(scope);
272    f()
273}
274
275/// Emits `build_failed` if the attempt returns without a terminal event.
276pub(crate) struct IndexBuildFailureGuard {
277    armed: bool,
278}
279
280impl IndexBuildFailureGuard {
281    pub(crate) fn new() -> Self {
282        Self { armed: true }
283    }
284
285    pub(crate) fn disarm(&mut self) {
286        self.armed = false;
287    }
288}
289
290impl Drop for IndexBuildFailureGuard {
291    fn drop(&mut self) {
292        if !self.armed {
293            return;
294        }
295        let Some(scope) = current_index_build() else {
296            return;
297        };
298        // A prior terminal event already cleared the in-flight slot.
299        if in_flight_build_id(scope.plane, &scope.root).as_deref() != Some(scope.build_id.as_str())
300        {
301            return;
302        }
303        log_current_index_event(
304            IndexEventKind::BuildFailed,
305            &[("reason", "error".to_string())],
306        );
307    }
308}
309
310pub(crate) fn current_index_build() -> Option<IndexBuildScope> {
311    CURRENT_INDEX_BUILD.with(|slot| slot.borrow().clone())
312}
313
314/// Normalize a project root for `index_event` (`\` → `/`).
315pub(crate) fn normalize_index_root(root: &Path) -> String {
316    root.to_string_lossy().replace('\\', "/")
317}
318
319fn sanitize_index_value(value: &str) -> String {
320    let mut out = String::with_capacity(value.len().max(1));
321    for ch in value.chars() {
322        match ch {
323            ' ' | '\t' | '\n' | '\r' | '=' => out.push('_'),
324            '\\' => out.push('/'),
325            c if c.is_ascii_graphic() || c == '/' => out.push(c),
326            _ => out.push('_'),
327        }
328    }
329    if out.is_empty() {
330        out.push('-');
331    }
332    out
333}
334
335fn push_index_field(line: &mut String, key: &str, value: &str) {
336    let sanitized = sanitize_index_value(value);
337    let addition = format!(" {key}={sanitized}");
338    if line.len() + addition.len() > INDEX_EVENT_MAX_BYTES {
339        return;
340    }
341    line.push_str(&addition);
342}
343
344/// Keep the distinctive suffix of a long root so the five required fields still fit.
345fn left_truncate_root(root: &str, max_bytes: usize) -> String {
346    const MARKER: &str = "...";
347    if root.len() <= max_bytes {
348        return root.to_string();
349    }
350    let max_bytes = max_bytes.max(MARKER.len());
351    let keep = max_bytes.saturating_sub(MARKER.len());
352    let mut start = root.len().saturating_sub(keep);
353    while start < root.len() && !root.is_char_boundary(start) {
354        start += 1;
355    }
356    format!("{MARKER}{}", &root[start..])
357}
358
359fn format_index_event_line(event: &IndexEvent) -> String {
360    let kind = sanitize_index_value(event.kind.as_str());
361    let plane = sanitize_index_value(event.plane.as_str());
362    let build_id = sanitize_index_value(&event.build_id);
363    let key = sanitize_index_value(&event.key);
364    let mut root = sanitize_index_value(&normalize_index_root(&event.root));
365    let prefix = format!("index_event kind={kind} plane={plane} build_id={build_id} root=");
366    let key_field = format!(" key={key}");
367    let root_budget = INDEX_EVENT_MAX_BYTES
368        .saturating_sub(prefix.len())
369        .saturating_sub(key_field.len());
370    if root.len() > root_budget {
371        root = left_truncate_root(&root, root_budget);
372    }
373    let mut line = format!("{prefix}{root}{key_field}");
374    for (key, value) in &event.extra {
375        push_index_field(&mut line, key, value);
376    }
377    line
378}
379
380fn root_plane_key(root: &Path, plane: IndexPlane) -> (String, IndexPlane) {
381    (normalize_index_root(root), plane)
382}
383
384fn remember_in_flight(event: &IndexEvent) {
385    let key = root_plane_key(&event.root, event.plane);
386    if event.kind == IndexEventKind::BuildStarted {
387        if let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() {
388            signals.sequence = signals.sequence.wrapping_add(1);
389            let sequence = signals.sequence;
390            signals.last_by_root_plane.insert(key.clone(), sequence);
391            signals
392                .in_flight_by_root_plane
393                .insert(key.clone(), sequence);
394            if let Some(waiters) = signals.waiters.remove(&key) {
395                for (baseline, sender) in waiters {
396                    if sequence > baseline {
397                        let _ = sender.send(());
398                    }
399                }
400            }
401        }
402        if let Ok(mut in_flight) = INDEX_LIFECYCLE.in_flight.lock() {
403            in_flight.insert(
404                key,
405                InFlightBuild {
406                    build_id: event.build_id.clone(),
407                },
408            );
409        }
410        return;
411    }
412    if event.kind.is_terminal() {
413        if let Ok(mut in_flight) = INDEX_LIFECYCLE.in_flight.lock() {
414            in_flight.remove(&key);
415        }
416        if let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() {
417            signals.in_flight_by_root_plane.remove(&key);
418        }
419        release_index_build_start_waiters(event.plane, &event.root);
420    }
421    if event.kind == IndexEventKind::BuildReady {
422        if let Ok(mut unclaimed) = INDEX_LIFECYCLE.unclaimed.lock() {
423            unclaimed.insert(
424                key,
425                UnclaimedReady {
426                    build_id: event.build_id.clone(),
427                    key: event.key.clone(),
428                    ready_at: Instant::now(),
429                },
430            );
431        }
432    }
433}
434
435fn emit_index_event_line(line: String) {
436    #[cfg(test)]
437    if let Ok(mut slot) = INDEX_EVENT_CAPTURE.lock() {
438        if let Some(events) = slot.as_mut() {
439            events.push(line.clone());
440        }
441    }
442    crate::slog_info!("{}", line);
443}
444
445/// Write one greppable `index_event` info line through the house slog path.
446pub(crate) fn log_index_event(event: IndexEvent) {
447    remember_in_flight(&event);
448    emit_index_event_line(format_index_event_line(&event));
449}
450
451pub(crate) fn log_watcher_rescan(
452    root: &Path,
453    reason: crate::watcher_filter::RescanReason,
454    cost_ms: u64,
455    rss_delta_bytes: Option<i64>,
456    raw_events_since_last: u64,
457) {
458    let reason = sanitize_index_value(reason.as_str());
459    let rss_delta = rss_delta_bytes
460        .map(|delta| delta.to_string())
461        .unwrap_or_else(|| "unavailable".to_string());
462    let prefix = "index_event kind=watcher_rescan plane=watcher root=";
463    let suffix = format!(
464        " reason={reason} cost_ms={cost_ms} rss_delta_bytes={rss_delta} raw_events_since_last={raw_events_since_last}"
465    );
466    let mut root = sanitize_index_value(&normalize_index_root(root));
467    let root_budget = INDEX_EVENT_MAX_BYTES
468        .saturating_sub(prefix.len())
469        .saturating_sub(suffix.len());
470    if root.len() > root_budget {
471        root = left_truncate_root(&root, root_budget);
472    }
473    emit_index_event_line(format!("{prefix}{root}{suffix}"));
474}
475
476/// Emit an event for the current thread's index-build scope, if any.
477pub(crate) fn log_current_index_event(kind: IndexEventKind, extra: &[(&'static str, String)]) {
478    let Some(scope) = current_index_build() else {
479        return;
480    };
481    let mut event = IndexEvent::from_scope(kind, &scope);
482    for (key, value) in extra {
483        event.extra.push((key, value.clone()));
484    }
485    log_index_event(event);
486}
487
488/// Most recent build-start sequence observed for one root and index plane.
489pub(crate) fn index_build_start_sequence(plane: IndexPlane, root: &Path) -> u64 {
490    INDEX_BUILD_START_SIGNALS
491        .lock()
492        .ok()
493        .and_then(|signals| {
494            signals
495                .last_by_root_plane
496                .get(&root_plane_key(root, plane))
497                .copied()
498        })
499        .unwrap_or(0)
500}
501
502/// Send a one-shot signal once the current or next build starts for this root and plane.
503pub(crate) fn signal_after_index_build_start(
504    plane: IndexPlane,
505    root: &Path,
506    baseline: u64,
507    sender: crossbeam_channel::Sender<()>,
508) {
509    let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() else {
510        let _ = sender.send(());
511        return;
512    };
513    let key = root_plane_key(root, plane);
514    if signals.in_flight_by_root_plane.contains_key(&key)
515        || signals
516            .last_by_root_plane
517            .get(&key)
518            .is_some_and(|sequence| *sequence > baseline)
519    {
520        let _ = sender.send(());
521    } else {
522        signals
523            .waiters
524            .entry(key)
525            .or_default()
526            .push((baseline, sender));
527    }
528}
529
530/// Release start waiters when a build attempt ends before emitting `build_started`.
531pub(crate) fn release_index_build_start_waiters(plane: IndexPlane, root: &Path) {
532    let waiters = INDEX_BUILD_START_SIGNALS
533        .lock()
534        .ok()
535        .and_then(|mut signals| signals.waiters.remove(&root_plane_key(root, plane)))
536        .unwrap_or_default();
537    for (_, sender) in waiters {
538        let _ = sender.send(());
539    }
540}
541
542/// In-flight `build_id` for a root/plane, if a cold build has started and not yet terminated.
543pub(crate) fn in_flight_build_id(plane: IndexPlane, root: &Path) -> Option<String> {
544    INDEX_LIFECYCLE
545        .in_flight
546        .lock()
547        .ok()?
548        .get(&root_plane_key(root, plane))
549        .map(|build| build.build_id.clone())
550}
551
552/// Consume the per-root unclaimed ready slot for `plane` and emit `first_query` once.
553pub(crate) fn claim_first_query(
554    plane: IndexPlane,
555    root: &Path,
556    tool: &str,
557    queue_ms: u64,
558    service_ms: u64,
559    status: &str,
560) -> bool {
561    let slot = {
562        let Ok(mut unclaimed) = INDEX_LIFECYCLE.unclaimed.lock() else {
563            return false;
564        };
565        unclaimed.remove(&root_plane_key(root, plane))
566    };
567    let Some(slot) = slot else {
568        return false;
569    };
570    let ready_to_first_query_ms = slot.ready_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
571    log_index_event(
572        IndexEvent::new(
573            IndexEventKind::FirstQuery,
574            plane,
575            slot.build_id,
576            root,
577            slot.key,
578        )
579        .field("tool", tool)
580        .field("queue_ms", queue_ms)
581        .field("service_ms", service_ms)
582        .field("status", status)
583        .field("ready_to_first_query_ms", ready_to_first_query_ms),
584    );
585    true
586}
587
588/// Query-path helper: claim `first_query` on a ready plane, or attribute a Building wait.
589pub(crate) fn note_index_query(
590    plane: IndexPlane,
591    root: &Path,
592    tool: &str,
593    service_ms: u64,
594    status: &str,
595) {
596    match status {
597        "building" | "rebuilding" => {
598            let build_id = in_flight_build_id(plane, root);
599            note_tool_call_wait(WaitingOn::Build, build_id.as_deref(), 0);
600        }
601        _ => {
602            let queue_ms = TOOL_CALL_WAIT.with(|slot| slot.borrow().queue_ms);
603            claim_first_query(plane, root, tool, queue_ms, service_ms, status);
604        }
605    }
606}
607
608/// Reset causal-wait state on the executor worker at job admission.
609pub(crate) fn reset_tool_call_wait() {
610    TOOL_CALL_WAIT.with(|slot| *slot.borrow_mut() = ToolCallWaitState::default());
611}
612
613/// Record what the current tool-call thread waited on.
614pub(crate) fn note_tool_call_wait(waiting_on: WaitingOn, build_id: Option<&str>, wait_ms: u64) {
615    TOOL_CALL_WAIT.with(|slot| {
616        let mut state = slot.borrow_mut();
617        state.waiting_on = waiting_on;
618        state.waiting_on_build_id = build_id.map(str::to_string);
619        state.wait_ms = state.wait_ms.saturating_add(wait_ms);
620    });
621}
622
623pub(crate) fn note_tool_call_queue_ms(queue_ms: u64) {
624    TOOL_CALL_WAIT.with(|slot| slot.borrow_mut().queue_ms = queue_ms);
625}
626
627pub(crate) fn take_tool_call_wait() -> (WaitingOn, Option<String>, u64) {
628    TOOL_CALL_WAIT.with(|slot| {
629        let state = std::mem::take(&mut *slot.borrow_mut());
630        (state.waiting_on, state.waiting_on_build_id, state.wait_ms)
631    })
632}
633
634#[cfg(test)]
635pub(crate) fn capture_index_events<R>(f: impl FnOnce() -> R) -> (R, Vec<String>) {
636    let _serial = INDEX_EVENT_CAPTURE_LOCK
637        .lock()
638        .unwrap_or_else(std::sync::PoisonError::into_inner);
639    if let Ok(mut slot) = INDEX_EVENT_CAPTURE.lock() {
640        *slot = Some(Vec::new());
641    }
642    let result = f();
643    let events = INDEX_EVENT_CAPTURE
644        .lock()
645        .ok()
646        .and_then(|mut slot| slot.take())
647        .unwrap_or_default();
648    (result, events)
649}
650
651/// Initialize the `RUST_LOG`-filtered stderr logger and its additive file sink.
652pub fn init() {
653    let storage_root = crate::bash_background::storage_dir(None);
654    let logs_dir = storage_root.join("logs");
655    let file_name = format!("aft-{}.log", std::process::id());
656    let file_path = logs_dir.join(file_name);
657    let mut startup_sweep = None;
658
659    let file_tx = match prepare_file_sink(&logs_dir, &file_path) {
660        Ok((sink, summary)) => {
661            startup_sweep = Some(summary);
662            let (tx, rx) = mpsc::sync_channel(LOG_CHANNEL_CAPACITY);
663            thread::Builder::new()
664                .name("aft-log-writer".to_string())
665                .spawn(move || run_file_writer(sink, rx))
666                .map(|_| {
667                    if let Ok(mut control) = FILE_CONTROL.lock() {
668                        control.tx = Some(tx.clone());
669                        control.storage_root = Some(storage_root.clone());
670                    }
671                    Some(tx)
672                })
673                .unwrap_or_else(|error| {
674                    write_stderr_once(&format!(
675                        "[aft] durable log disabled: cannot start writer thread: {error}\n"
676                    ));
677                    None
678                })
679        }
680        Err(error) => {
681            write_stderr_once(&format!(
682                "[aft] durable log disabled for {}: {error}\n",
683                file_path.display()
684            ));
685            None
686        }
687    };
688
689    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
690        .target(env_logger::Target::Pipe(Box::new(TeeWriter { file_tx })))
691        .format(|buf, record| {
692            let prefix = if record.target().starts_with("aft::lsp")
693                || record.target().starts_with("aft_lsp")
694            {
695                "[aft-lsp]"
696            } else {
697                "[aft]"
698            };
699            // Wall-clock stamp so post-hoc log forensics can correlate
700            // lines with external events (health probes, module bounces).
701            // Seconds precision is enough; chrono is avoided on purpose —
702            // this hand-rolls UTC from the epoch to keep deps flat.
703            writeln!(
704                buf,
705                "{} {} {}",
706                format_utc_timestamp(),
707                prefix,
708                record.args()
709            )
710        })
711        .init();
712
713    if let Some(summary) = startup_sweep {
714        log_sweep_summary(summary);
715    }
716}
717
718/// Render `now` as `YYYY-MM-DDTHH:MM:SSZ` without a date-time dependency.
719///
720/// Civil-date math uses the days-from-epoch algorithm (Howard Hinnant's
721/// `civil_from_days`); u64 seconds keep it valid far past 2100.
722fn format_utc_timestamp() -> String {
723    let secs = SystemTime::now()
724        .duration_since(SystemTime::UNIX_EPOCH)
725        .map(|d| d.as_secs())
726        .unwrap_or(0);
727    format_epoch_secs(secs)
728}
729
730fn format_epoch_secs(secs: u64) -> String {
731    let (days, rem) = (secs / 86_400, secs % 86_400);
732    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
733    // Howard Hinnant's civil_from_days: adding 719,468 shifts Unix epoch day 0
734    // into the algorithm's era, which begins on 0000-03-01 (putting the leap
735    // day last in each year simplifies the month/day arithmetic below).
736    let z = days as i64 + 719_468;
737    let era = z.div_euclid(146_097);
738    let doe = z.rem_euclid(146_097);
739    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
740    let y = yoe + era * 400;
741    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
742    let mp = (5 * doy + 2) / 153;
743    let d = doy - (153 * mp + 2) / 5 + 1;
744    let m = if mp < 10 { mp + 3 } else { mp - 9 };
745    let y = if m <= 2 { y + 1 } else { y };
746    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
747}
748
749fn prepare_file_sink(
750    logs_dir: &Path,
751    file_path: &Path,
752) -> io::Result<(RotatingFile, SweepSummary)> {
753    fs::create_dir_all(logs_dir)?;
754    let summary = sweep_logs(
755        logs_dir,
756        SystemTime::now(),
757        DEAD_PROCESS_LOG_MAX_AGE,
758        LOG_DIRECTORY_BUDGET_BYTES,
759    )?;
760    mark_log_sweep_ran();
761    let sink = RotatingFile::open(
762        file_path.to_path_buf(),
763        LOG_FILE_BYTES,
764        LOG_GENERATIONS,
765        ROTATION_CHECK_EVERY,
766    )?;
767    Ok((sink, summary))
768}
769
770enum LogMessage {
771    Write(Vec<u8>),
772    Reconfigure(PathBuf),
773}
774
775#[derive(Default)]
776struct FileControl {
777    tx: Option<SyncSender<LogMessage>>,
778    storage_root: Option<PathBuf>,
779}
780
781static FILE_CONTROL: LazyLock<Mutex<FileControl>> =
782    LazyLock::new(|| Mutex::new(FileControl::default()));
783
784struct TeeWriter {
785    file_tx: Option<SyncSender<LogMessage>>,
786}
787
788impl Write for TeeWriter {
789    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
790        io::stderr().write_all(buf)?;
791        if let Some(tx) = self.file_tx.as_ref() {
792            match tx.try_send(LogMessage::Write(buf.to_vec())) {
793                Ok(()) => {}
794                Err(TrySendError::Full(_)) => {
795                    PERF.file_lines_dropped.fetch_add(1, Ordering::Relaxed);
796                }
797                Err(TrySendError::Disconnected(_)) => self.file_tx = None,
798            }
799        }
800        Ok(buf.len())
801    }
802
803    fn flush(&mut self) -> io::Result<()> {
804        io::stderr().flush()
805    }
806}
807
808fn run_file_writer(mut sink: RotatingFile, rx: mpsc::Receiver<LogMessage>) {
809    while let Ok(message) = rx.recv() {
810        let mut lines = Vec::new();
811        let mut reconfigure = None;
812        match message {
813            LogMessage::Write(line) => {
814                lines.push(line);
815                while lines.len() < 256 {
816                    match rx.try_recv() {
817                        Ok(LogMessage::Write(line)) => lines.push(line),
818                        Ok(LogMessage::Reconfigure(storage_root)) => {
819                            reconfigure = Some(storage_root);
820                            break;
821                        }
822                        Err(_) => break,
823                    }
824                }
825            }
826            LogMessage::Reconfigure(storage_root) => reconfigure = Some(storage_root),
827        }
828        if !lines.is_empty() {
829            if let Err(error) = sink.write_batch(&lines) {
830                write_stderr_once(&format!(
831                    "[aft] durable log disabled after write failure for {}: {error}\n",
832                    sink.path.display()
833                ));
834                break;
835            }
836        }
837        if let Some(storage_root) = reconfigure {
838            let logs_dir = storage_root.join("logs");
839            let path = logs_dir.join(format!("aft-{}.log", std::process::id()));
840            match prepare_file_sink(&logs_dir, &path) {
841                Ok((new_sink, summary)) => {
842                    sink = new_sink;
843                    log_sweep_summary(summary);
844                }
845                Err(error) => write_stderr_once(&format!(
846                    "[aft] durable log could not switch to {}: {error}\n",
847                    path.display()
848                )),
849            }
850        }
851    }
852}
853
854fn write_stderr_once(message: &str) {
855    let _ = io::stderr().write_all(message.as_bytes());
856}
857
858struct RotatingFile {
859    path: PathBuf,
860    writer: Option<BufWriter<File>>,
861    size: u64,
862    threshold: u64,
863    generations: usize,
864    check_every: u64,
865    writes_since_check: u64,
866}
867
868impl RotatingFile {
869    fn open(
870        path: PathBuf,
871        threshold: u64,
872        generations: usize,
873        check_every: u64,
874    ) -> io::Result<Self> {
875        let file = OpenOptions::new().create(true).append(true).open(&path)?;
876        let size = file.metadata()?.len();
877        let mut sink = Self {
878            path,
879            writer: Some(BufWriter::new(file)),
880            size,
881            threshold,
882            generations,
883            check_every: check_every.max(1),
884            writes_since_check: 0,
885        };
886        if size > threshold {
887            sink.rotate()?;
888        }
889        Ok(sink)
890    }
891
892    fn write_batch(&mut self, lines: &[Vec<u8>]) -> io::Result<()> {
893        let batch_bytes = lines.iter().map(Vec::len).sum::<usize>() as u64;
894        self.writes_since_check = self.writes_since_check.saturating_add(lines.len() as u64);
895        if self.writes_since_check >= self.check_every
896            && self.size > 0
897            && self.size.saturating_add(batch_bytes) > self.threshold
898        {
899            self.rotate()?;
900        }
901        let writer = self
902            .writer
903            .as_mut()
904            .ok_or_else(|| io::Error::other("log writer unavailable"))?;
905        for line in lines {
906            writer.write_all(line)?;
907        }
908        // The worker batches channel messages before this flush. File I/O never
909        // runs on request, watcher, executor, or transport threads.
910        writer.flush()?;
911        self.size = self.size.saturating_add(batch_bytes);
912        if self.writes_since_check >= self.check_every {
913            self.writes_since_check = 0;
914        }
915        Ok(())
916    }
917
918    fn rotate(&mut self) -> io::Result<()> {
919        if let Some(mut writer) = self.writer.take() {
920            writer.flush()?;
921        }
922        if self.generations > 0 {
923            let oldest = rotated_path(&self.path, self.generations);
924            remove_file_if_present(&oldest)?;
925            for generation in (1..self.generations).rev() {
926                let from = rotated_path(&self.path, generation);
927                let to = rotated_path(&self.path, generation + 1);
928                rename_if_present(&from, &to)?;
929            }
930            rename_if_present(&self.path, &rotated_path(&self.path, 1))?;
931        } else {
932            remove_file_if_present(&self.path)?;
933        }
934        let file = OpenOptions::new()
935            .create(true)
936            .write(true)
937            .truncate(true)
938            .open(&self.path)?;
939        self.writer = Some(BufWriter::new(file));
940        self.size = 0;
941        self.writes_since_check = 0;
942        Ok(())
943    }
944}
945
946fn rotated_path(base: &Path, generation: usize) -> PathBuf {
947    let mut path = base.as_os_str().to_os_string();
948    path.push(format!(".{generation}"));
949    PathBuf::from(path)
950}
951
952fn remove_file_if_present(path: &Path) -> io::Result<()> {
953    match fs::remove_file(path) {
954        Ok(()) => Ok(()),
955        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
956        Err(error) => Err(error),
957    }
958}
959
960fn rename_if_present(from: &Path, to: &Path) -> io::Result<()> {
961    match fs::rename(from, to) {
962        Ok(()) => Ok(()),
963        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
964        Err(error) => Err(error),
965    }
966}
967
968#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
969struct SweepSummary {
970    removed_files: usize,
971    bytes_freed: u64,
972}
973
974struct ProcessLogFile {
975    path: PathBuf,
976    modified: Option<SystemTime>,
977    bytes: u64,
978    dead: bool,
979    old_enough: bool,
980    removed: bool,
981}
982
983fn log_sweep_summary(summary: SweepSummary) {
984    crate::slog_info!(
985        "log retention sweep: removed_files={} bytes_freed={}",
986        summary.removed_files,
987        summary.bytes_freed
988    );
989}
990
991/// Sweep dead Rust process logs, then enforce the directory budget without ever
992/// deleting a live PID's file or the plugin logger's pid-less file.
993fn sweep_logs(
994    dir: &Path,
995    now: SystemTime,
996    max_age: Duration,
997    budget_bytes: u64,
998) -> io::Result<SweepSummary> {
999    let mut total_bytes = 0_u64;
1000    let mut process_logs = Vec::new();
1001    let mut live_pids = BTreeMap::new();
1002    let own_pid = std::process::id();
1003
1004    for entry in fs::read_dir(dir)? {
1005        let entry = match entry {
1006            Ok(entry) => entry,
1007            Err(_) => continue,
1008        };
1009        let metadata = match entry.metadata() {
1010            Ok(metadata) if metadata.is_file() => metadata,
1011            Ok(_) | Err(_) => continue,
1012        };
1013        let bytes = metadata.len();
1014        total_bytes = total_bytes.saturating_add(bytes);
1015        let name = entry.file_name();
1016        let name = name.to_string_lossy();
1017        let pid = match name.as_ref() {
1018            // This shared TypeScript-owned file has no PID. Keep this explicit
1019            // so a future default branch cannot accidentally make it reaped.
1020            "aft-plugin.log" => continue,
1021            _ => process_log_pid(&name),
1022        };
1023        let Some(pid) = pid else {
1024            continue;
1025        };
1026        let modified = metadata.modified().ok();
1027        let old_enough = modified
1028            .and_then(|modified| now.duration_since(modified).ok())
1029            .is_some_and(|age| age >= max_age);
1030        let alive = *live_pids
1031            .entry(pid)
1032            .or_insert_with(|| is_process_alive(pid));
1033        process_logs.push(ProcessLogFile {
1034            path: entry.path(),
1035            modified,
1036            bytes,
1037            dead: pid != own_pid && !alive,
1038            old_enough,
1039            removed: false,
1040        });
1041    }
1042
1043    let mut summary = SweepSummary::default();
1044    for file in &mut process_logs {
1045        if file.dead && file.old_enough && remove_sweep_candidate(&file.path) {
1046            file.removed = true;
1047            total_bytes = total_bytes.saturating_sub(file.bytes);
1048            summary.removed_files += 1;
1049            summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1050        }
1051    }
1052
1053    // The budget backstop is deliberately separate from the age-gated reap:
1054    // once liveness says a PID is dead, budget pressure may remove even a fresh
1055    // dead file so the directory can actually converge under its hard limit.
1056    // Live files remain ineligible regardless of age or budget pressure.
1057    process_logs.sort_by_key(|file| file.modified);
1058    for file in process_logs
1059        .iter_mut()
1060        .filter(|file| file.dead && !file.removed)
1061    {
1062        if total_bytes <= budget_bytes {
1063            break;
1064        }
1065        if remove_sweep_candidate(&file.path) {
1066            file.removed = true;
1067            total_bytes = total_bytes.saturating_sub(file.bytes);
1068            summary.removed_files += 1;
1069            summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1070        }
1071    }
1072
1073    Ok(summary)
1074}
1075
1076fn remove_sweep_candidate(path: &Path) -> bool {
1077    // A sharing violation means another process has the file pinned (notably on
1078    // Windows). Leave it for a later sweep instead of failing the maintenance pass.
1079    fs::remove_file(path).is_ok()
1080}
1081
1082fn process_log_pid(name: &str) -> Option<u32> {
1083    let rest = name.strip_prefix("aft-")?;
1084    let (pid, suffix) = rest.split_once(".log")?;
1085    if !suffix.is_empty()
1086        && !(suffix.starts_with('.') && suffix[1..].chars().all(|ch| ch.is_ascii_digit()))
1087    {
1088        return None;
1089    }
1090    pid.parse().ok()
1091}
1092
1093static LAST_LOG_SWEEP: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(|| Mutex::new(None));
1094
1095fn mark_log_sweep_ran() {
1096    if let Ok(mut last_run) = LAST_LOG_SWEEP.lock() {
1097        *last_run = Some(Instant::now());
1098    }
1099}
1100
1101/// Run log maintenance from an existing idle/maintenance tick at most hourly.
1102pub fn maybe_sweep_logs() {
1103    let now = Instant::now();
1104    let should_run = LAST_LOG_SWEEP
1105        .lock()
1106        .map(|mut last_run| {
1107            if last_run.is_some_and(|last| now.duration_since(last) < LOG_SWEEP_INTERVAL) {
1108                false
1109            } else {
1110                *last_run = Some(now);
1111                true
1112            }
1113        })
1114        .unwrap_or(false);
1115    if !should_run {
1116        return;
1117    }
1118
1119    let storage_root = FILE_CONTROL
1120        .lock()
1121        .ok()
1122        .and_then(|control| control.storage_root.clone())
1123        .unwrap_or_else(|| crate::bash_background::storage_dir(None));
1124    let logs_dir = storage_root.join("logs");
1125    match sweep_logs(
1126        &logs_dir,
1127        SystemTime::now(),
1128        DEAD_PROCESS_LOG_MAX_AGE,
1129        LOG_DIRECTORY_BUDGET_BYTES,
1130    ) {
1131        Ok(summary) => log_sweep_summary(summary),
1132        Err(error) => crate::slog_warn!(
1133            "log retention sweep failed for {}: {}",
1134            logs_dir.display(),
1135            error
1136        ),
1137    }
1138}
1139
1140#[derive(Default)]
1141struct PerfMetrics {
1142    watcher_ingested: AtomicU64,
1143    watcher_paths: AtomicU64,
1144    watcher_dropped: AtomicU64,
1145    drain_slices: AtomicU64,
1146    semantic_collects: AtomicU64,
1147    semantic_files: AtomicU64,
1148    semantic_chunks: AtomicU64,
1149    semantic_ms: AtomicU64,
1150    callgraph_invalidations: AtomicU64,
1151    file_lines_dropped: AtomicU64,
1152    tool_call_count: AtomicU64,
1153    tool_calls: Mutex<VecDeque<ToolCallPerfSample>>,
1154    tier2: Mutex<BTreeMap<String, (u64, u64)>>,
1155    next_sample_ns: AtomicU64,
1156    reporter: Mutex<PerfReporter>,
1157}
1158
1159struct PerfReporter {
1160    last_report: Instant,
1161    last_completed_interactive: u64,
1162    last_completed_maintenance: u64,
1163    last_tool_call_count: u64,
1164}
1165
1166impl Default for PerfReporter {
1167    fn default() -> Self {
1168        Self {
1169            last_report: Instant::now(),
1170            last_completed_interactive: 0,
1171            last_completed_maintenance: 0,
1172            last_tool_call_count: 0,
1173        }
1174    }
1175}
1176
1177#[derive(Clone, Copy)]
1178struct ToolCallPerfSample {
1179    total_ms: u64,
1180    queue_ms: u64,
1181}
1182
1183#[derive(Clone, Copy, Default)]
1184struct ToolCallPerfSummary {
1185    window: usize,
1186    p50_total_ms: u64,
1187    max_total_ms: u64,
1188    p50_queue_ms: u64,
1189    max_queue_ms: u64,
1190}
1191
1192#[derive(Clone, Copy, Default)]
1193struct ExecutorSample {
1194    interactive_running: usize,
1195    maintenance_running: usize,
1196    interactive_queued: usize,
1197    maintenance_queued: usize,
1198    interactive_oldest_ms: Option<u64>,
1199    maintenance_oldest_ms: Option<u64>,
1200}
1201
1202static PERF: LazyLock<PerfMetrics> = LazyLock::new(PerfMetrics::default);
1203
1204/// Move subsequent file log writes to a newly configured storage root.
1205///
1206/// Reconfiguration is queued behind existing writes and is a no-op when the
1207/// root has not changed. Initialization and explicit configure changes call
1208/// this directly, avoiding storage-root polling on transport drain turns.
1209pub fn sync_storage_root(storage_root: PathBuf) {
1210    let Ok(mut control) = FILE_CONTROL.lock() else {
1211        return;
1212    };
1213    if control.storage_root.as_ref() == Some(&storage_root) {
1214        return;
1215    }
1216    let Some(tx) = control.tx.as_ref() else {
1217        return;
1218    };
1219    if tx
1220        .try_send(LogMessage::Reconfigure(storage_root.clone()))
1221        .is_ok()
1222    {
1223        control.storage_root = Some(storage_root);
1224    }
1225}
1226
1227/// Called by `drain_watcher_events_bounded` for dispatch events actually received.
1228pub fn note_watcher_events(count: usize) {
1229    PERF.watcher_ingested
1230        .fetch_add(count as u64, Ordering::Relaxed);
1231}
1232
1233/// Called when a watcher drain slice takes paths from dispatch continuation state.
1234pub fn note_drain_paths(count: usize) {
1235    PERF.watcher_paths
1236        .fetch_add(count as u64, Ordering::Relaxed);
1237}
1238
1239/// Called when `drain_watcher_events_bounded` receives a rescan-required overflow signal.
1240pub fn note_watcher_overflow() {
1241    PERF.watcher_dropped.fetch_add(1, Ordering::Relaxed);
1242}
1243
1244/// Called by the standalone request loop before a request-triggered runtime drain.
1245pub fn note_drain_slice() {
1246    PERF.drain_slices.fetch_add(1, Ordering::Relaxed);
1247}
1248
1249/// Called after `SemanticIndex::collect_chunks` has collected one real file batch.
1250pub fn note_semantic_collect(chunks: usize, files: usize, elapsed_ms: u64) {
1251    PERF.semantic_collects.fetch_add(1, Ordering::Relaxed);
1252    PERF.semantic_chunks
1253        .fetch_add(chunks as u64, Ordering::Relaxed);
1254    PERF.semantic_files
1255        .fetch_add(files as u64, Ordering::Relaxed);
1256    PERF.semantic_ms.fetch_add(elapsed_ms, Ordering::Relaxed);
1257}
1258
1259/// Called by `Tier2PhaseTimings::log` after a Tier-2 scan performs measurable work.
1260pub fn note_tier2_scan(category: String, elapsed_ms: u64) {
1261    if let Ok(mut tier2) = PERF.tier2.lock() {
1262        let entry = tier2.entry(category).or_default();
1263        entry.0 = entry.0.saturating_add(1);
1264        entry.1 = entry.1.saturating_add(elapsed_ms);
1265    }
1266}
1267
1268/// Called after watcher-driven callgraph `refresh_files` succeeds for concrete paths.
1269pub fn note_callgraph_invalidations(files: usize) {
1270    PERF.callgraph_invalidations
1271        .fetch_add(files as u64, Ordering::Relaxed);
1272}
1273
1274/// Record a completed subc tool call for slow-call diagnostics and the standing
1275/// perf-tick window. The writer calls this only after `write_all` has handed the
1276/// complete response frame to the transport.
1277pub fn note_tool_call_trace(
1278    name: &str,
1279    root: &Path,
1280    channel: u16,
1281    corr: u64,
1282    phases: ToolCallPhaseDurations,
1283) {
1284    let sample = ToolCallPerfSample {
1285        total_ms: duration_millis_u64(phases.total),
1286        queue_ms: duration_millis_u64(phases.queue),
1287    };
1288    if let Ok(mut samples) = PERF.tool_calls.lock() {
1289        if samples.len() == TOOL_CALL_SAMPLE_CAPACITY {
1290            samples.pop_front();
1291        }
1292        samples.push_back(sample);
1293        PERF.tool_call_count.fetch_add(1, Ordering::Relaxed);
1294    }
1295
1296    let waiting_on_build_id = phases.waiting_on_build_id.as_deref().unwrap_or("-");
1297    crate::slog_debug!(
1298        "tool_call phase name={} channel={} corr={} total_ms={:.3} queue_ms={:.3} translate_ms={:.3} exec_ms={:.3} format_ms={:.3} finalize_ms={:.3} egress_ms={:.3} egress_enqueue_ms={:.3} egress_queue_ms={:.3} egress_prepare_ms={:.3} egress_write_ms={:.3} frame_bytes={} writer_queue_depth={} writer_active={} writer_queue_full={} reserve_timeouts={} waiting_on={} waiting_on_build_id={} wait_ms={} root={}",
1299        name,
1300        channel,
1301        corr,
1302        duration_millis_f64(phases.total),
1303        duration_millis_f64(phases.queue),
1304        duration_millis_f64(phases.translate),
1305        duration_millis_f64(phases.execute),
1306        duration_millis_f64(phases.format),
1307        duration_millis_f64(phases.finalize),
1308        duration_millis_f64(phases.egress),
1309        duration_millis_f64(phases.egress_enqueue),
1310        duration_millis_f64(phases.egress_queue),
1311        duration_millis_f64(phases.egress_prepare),
1312        duration_millis_f64(phases.egress_write),
1313        phases.frame_bytes,
1314        phases.writer_queue_depth,
1315        phases.writer_active_at_enqueue,
1316        phases.writer_queue_was_full,
1317        phases.writer_reserve_timeouts,
1318        phases.waiting_on.as_str(),
1319        waiting_on_build_id,
1320        phases.wait_ms,
1321        root.display(),
1322    );
1323
1324    if phases.total > SLOW_TOOL_CALL_THRESHOLD {
1325        crate::slog_warn!(
1326            "slow tool_call name={} channel={} corr={} total={}ms queue={} translate={} exec={} format={} finalize={} egress={} egress_enqueue={} egress_queue={} egress_prepare={} egress_write={} frame_bytes={} writer_queue_depth={} writer_active={} writer_queue_full={} reserve_timeouts={} waiting_on={} waiting_on_build_id={} wait_ms={} root={}",
1327            name,
1328            channel,
1329            corr,
1330            duration_millis_u64(phases.total),
1331            duration_millis_u64(phases.queue),
1332            duration_millis_u64(phases.translate),
1333            duration_millis_u64(phases.execute),
1334            duration_millis_u64(phases.format),
1335            duration_millis_u64(phases.finalize),
1336            duration_millis_u64(phases.egress),
1337            duration_millis_u64(phases.egress_enqueue),
1338            duration_millis_u64(phases.egress_queue),
1339            duration_millis_u64(phases.egress_prepare),
1340            duration_millis_u64(phases.egress_write),
1341            phases.frame_bytes,
1342            phases.writer_queue_depth,
1343            phases.writer_active_at_enqueue,
1344            phases.writer_queue_was_full,
1345            phases.writer_reserve_timeouts,
1346            phases.waiting_on.as_str(),
1347            waiting_on_build_id,
1348            phases.wait_ms,
1349            root.display(),
1350        );
1351    }
1352}
1353
1354/// Sample executor liveness and emit one busy-only aggregate at the configured cadence.
1355///
1356/// The transport may call this every loop turn; an atomic deadline keeps all
1357/// executor sampling and reporter locking off that path between drain ticks.
1358pub fn perf_tick(executor: Option<&Executor>) {
1359    if !perf_sample_due() {
1360        return;
1361    }
1362
1363    let sample = executor.and_then(|executor| {
1364        executor
1365            .try_dispatch_liveness_snapshot()
1366            .map(|snapshot| ExecutorSample {
1367                interactive_running: snapshot.running.interactive,
1368                maintenance_running: snapshot.running.maintenance,
1369                interactive_queued: snapshot.interactive.queued,
1370                maintenance_queued: snapshot.maintenance.queued,
1371                interactive_oldest_ms: snapshot.interactive.oldest_age_ms,
1372                maintenance_oldest_ms: snapshot.maintenance.oldest_age_ms,
1373            })
1374    });
1375
1376    let completion_counts = executor.map_or((0, 0), Executor::completion_counts);
1377    let tool_call_count = PERF.tool_call_count.load(Ordering::Relaxed);
1378    let (completed_interactive, completed_maintenance, new_tool_calls) = {
1379        let Ok(mut reporter) = PERF.reporter.lock() else {
1380            return;
1381        };
1382        if reporter.last_report.elapsed() < perf_tick_interval() {
1383            return;
1384        }
1385        reporter.last_report = Instant::now();
1386        let completed = (
1387            completion_counts
1388                .0
1389                .saturating_sub(reporter.last_completed_interactive),
1390            completion_counts
1391                .1
1392                .saturating_sub(reporter.last_completed_maintenance),
1393            tool_call_count.saturating_sub(reporter.last_tool_call_count),
1394        );
1395        reporter.last_completed_interactive = completion_counts.0;
1396        reporter.last_completed_maintenance = completion_counts.1;
1397        reporter.last_tool_call_count = tool_call_count;
1398        completed
1399    };
1400
1401    let watcher_ingested = PERF.watcher_ingested.swap(0, Ordering::Relaxed);
1402    let watcher_paths = PERF.watcher_paths.swap(0, Ordering::Relaxed);
1403    let watcher_dropped = PERF.watcher_dropped.swap(0, Ordering::Relaxed);
1404    let drain_slices = PERF.drain_slices.swap(0, Ordering::Relaxed);
1405    let semantic_collects = PERF.semantic_collects.swap(0, Ordering::Relaxed);
1406    let semantic_files = PERF.semantic_files.swap(0, Ordering::Relaxed);
1407    let semantic_chunks = PERF.semantic_chunks.swap(0, Ordering::Relaxed);
1408    let semantic_ms = PERF.semantic_ms.swap(0, Ordering::Relaxed);
1409    let callgraph_invalidations = PERF.callgraph_invalidations.swap(0, Ordering::Relaxed);
1410    let file_lines_dropped = PERF.file_lines_dropped.swap(0, Ordering::Relaxed);
1411    let tier2 = PERF
1412        .tier2
1413        .lock()
1414        .map(|mut tier2| std::mem::take(&mut *tier2))
1415        .unwrap_or_default();
1416    let tool_calls = PERF
1417        .tool_calls
1418        .lock()
1419        .map(|samples| summarize_tool_calls(&samples))
1420        .unwrap_or_default();
1421
1422    let executor_busy = sample.is_some_and(|sample| {
1423        sample.interactive_running > 0
1424            || sample.maintenance_running > 0
1425            || sample.interactive_queued > 0
1426            || sample.maintenance_queued > 0
1427    });
1428    let active = watcher_ingested > 0
1429        || watcher_paths > 0
1430        || watcher_dropped > 0
1431        || drain_slices > 0
1432        || semantic_collects > 0
1433        || callgraph_invalidations > 0
1434        || completed_interactive > 0
1435        || completed_maintenance > 0
1436        || new_tool_calls > 0
1437        || file_lines_dropped > 0
1438        || !tier2.is_empty()
1439        || executor_busy;
1440    if !active {
1441        return;
1442    }
1443
1444    let tier2_summary = if tier2.is_empty() {
1445        "none".to_string()
1446    } else {
1447        tier2
1448            .into_iter()
1449            .map(|(category, (count, ms))| format!("{category}:{count}/{ms}ms"))
1450            .collect::<Vec<_>>()
1451            .join(",")
1452    };
1453    let sample = sample.unwrap_or_default();
1454    crate::slog_info!(
1455        "perf tick: watcher={{ingested:{},paths:{},dropped:{}}} drains={} tier2=[{}] semantic={{collects:{},files:{},chunks:{},ms:{}}} callgraph_invalidations={} executor_completed={{interactive:{},maintenance:{}}} oldest_queued_ms={{interactive:{},maintenance:{}}} {} file_log_dropped={}",
1456        watcher_ingested,
1457        watcher_paths,
1458        watcher_dropped,
1459        drain_slices,
1460        tier2_summary,
1461        semantic_collects,
1462        semantic_files,
1463        semantic_chunks,
1464        semantic_ms,
1465        callgraph_invalidations,
1466        completed_interactive,
1467        completed_maintenance,
1468        format_optional_ms(sample.interactive_oldest_ms),
1469        format_optional_ms(sample.maintenance_oldest_ms),
1470        format_tool_call_summary(new_tool_calls, tool_calls),
1471        file_lines_dropped,
1472    );
1473}
1474
1475fn duration_millis_f64(duration: Duration) -> f64 {
1476    duration.as_secs_f64() * 1_000.0
1477}
1478
1479fn duration_millis_u64(duration: Duration) -> u64 {
1480    duration.as_millis().min(u64::MAX as u128) as u64
1481}
1482
1483fn summarize_tool_calls(samples: &VecDeque<ToolCallPerfSample>) -> ToolCallPerfSummary {
1484    if samples.is_empty() {
1485        return ToolCallPerfSummary::default();
1486    }
1487    let mut totals = samples
1488        .iter()
1489        .map(|sample| sample.total_ms)
1490        .collect::<Vec<_>>();
1491    let mut queues = samples
1492        .iter()
1493        .map(|sample| sample.queue_ms)
1494        .collect::<Vec<_>>();
1495    totals.sort_unstable();
1496    queues.sort_unstable();
1497    let median_index = (samples.len() - 1) / 2;
1498    ToolCallPerfSummary {
1499        window: samples.len(),
1500        p50_total_ms: totals[median_index],
1501        max_total_ms: totals[totals.len() - 1],
1502        p50_queue_ms: queues[median_index],
1503        max_queue_ms: queues[queues.len() - 1],
1504    }
1505}
1506
1507fn format_tool_call_summary(new_tool_calls: u64, summary: ToolCallPerfSummary) -> String {
1508    format!(
1509        "toolcall={{new:{new_tool_calls},window:{},p50_total_ms:{},max_total_ms:{},p50_queue_ms:{},max_queue_ms:{}}}",
1510        summary.window,
1511        summary.p50_total_ms,
1512        summary.max_total_ms,
1513        summary.p50_queue_ms,
1514        summary.max_queue_ms,
1515    )
1516}
1517
1518fn format_optional_ms(value: Option<u64>) -> String {
1519    value
1520        .map(|value| value.to_string())
1521        .unwrap_or_else(|| "none".to_string())
1522}
1523
1524fn perf_sample_due() -> bool {
1525    static ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
1526    let now_ns = ORIGIN.elapsed().as_nanos().min(u64::MAX as u128) as u64;
1527    let mut deadline = PERF.next_sample_ns.load(Ordering::Relaxed);
1528    loop {
1529        if now_ns < deadline {
1530            return false;
1531        }
1532        let next = now_ns.saturating_add(PERF_SAMPLE_INTERVAL.as_nanos() as u64);
1533        match PERF.next_sample_ns.compare_exchange_weak(
1534            deadline,
1535            next,
1536            Ordering::Relaxed,
1537            Ordering::Relaxed,
1538        ) {
1539            Ok(_) => return true,
1540            Err(observed) => deadline = observed,
1541        }
1542    }
1543}
1544
1545fn perf_tick_interval() -> Duration {
1546    static INTERVAL: OnceLock<Duration> = OnceLock::new();
1547    *INTERVAL.get_or_init(|| {
1548        std::env::var("AFT_PERF_TICK_INTERVAL_MS")
1549            .ok()
1550            .and_then(|value| value.parse::<u64>().ok())
1551            .filter(|value| *value > 0)
1552            .map(Duration::from_millis)
1553            .unwrap_or(DEFAULT_PERF_TICK_INTERVAL)
1554    })
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559    use super::*;
1560    use filetime::{set_file_mtime, FileTime};
1561    use tempfile::TempDir;
1562
1563    fn line(value: &str) -> Vec<Vec<u8>> {
1564        vec![format!("{value}\n").into_bytes()]
1565    }
1566
1567    #[test]
1568    fn epoch_timestamp_renders_known_dates() {
1569        // Epoch start, a modern date, a post-2038 date (u64 range), and the
1570        // 2100 non-leap century boundary that naive leap logic gets wrong.
1571        assert_eq!(format_epoch_secs(0), "1970-01-01T00:00:00Z");
1572        assert_eq!(format_epoch_secs(1_704_067_200), "2024-01-01T00:00:00Z");
1573        assert_eq!(format_epoch_secs(1_709_251_199), "2024-02-29T23:59:59Z");
1574        assert_eq!(format_epoch_secs(4_102_444_800), "2100-01-01T00:00:00Z");
1575        assert_eq!(format_epoch_secs(4_107_542_399), "2100-02-28T23:59:59Z");
1576    }
1577
1578    #[test]
1579    fn rotation_rolls_once_and_replaces_the_single_backup_generation() {
1580        let temp = TempDir::new().unwrap();
1581        let path = temp.path().join("aft-123.log");
1582        fs::write(rotated_path(&path, 1), "stale backup\n").unwrap();
1583        let mut sink = RotatingFile::open(path.clone(), 10, 1, 1).unwrap();
1584        sink.write_batch(&line("aaaa")).unwrap();
1585        sink.write_batch(&line("bbbb")).unwrap();
1586        sink.write_batch(&line("cccc")).unwrap();
1587        sink.write_batch(&line("dddd")).unwrap();
1588        sink.write_batch(&line("eeee")).unwrap();
1589
1590        assert_eq!(fs::read_to_string(&path).unwrap(), "eeee\n");
1591        assert_eq!(
1592            fs::read_to_string(rotated_path(&path, 1)).unwrap(),
1593            "cccc\ndddd\n"
1594        );
1595        assert!(!rotated_path(&path, 2).exists());
1596    }
1597
1598    #[test]
1599    fn dead_pid_sweep_respects_age_liveness_and_explicit_plugin_exclusion() {
1600        let temp = TempDir::new().unwrap();
1601        let dead = temp.path().join("aft-4294967294.log");
1602        let dead_rotated = temp.path().join("aft-4294967294.log.1");
1603        let fresh_dead = temp.path().join("aft-4294967293.log");
1604        let own = temp.path().join(format!("aft-{}.log", std::process::id()));
1605        let live_rotated = rotated_path(&own, 1);
1606        let plugin = temp.path().join("aft-plugin.log");
1607        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1608        for path in [&dead, &dead_rotated, &own, &live_rotated, &plugin] {
1609            fs::write(path, "log").unwrap();
1610            set_file_mtime(path, FileTime::from_unix_time(1, 0)).unwrap();
1611        }
1612        fs::write(&fresh_dead, "fresh").unwrap();
1613        set_file_mtime(
1614            &fresh_dead,
1615            FileTime::from_unix_time(
1616                (now - DEAD_PROCESS_LOG_MAX_AGE + Duration::from_secs(1))
1617                    .duration_since(SystemTime::UNIX_EPOCH)
1618                    .unwrap()
1619                    .as_secs() as i64,
1620                0,
1621            ),
1622        )
1623        .unwrap();
1624
1625        let summary = sweep_logs(temp.path(), now, DEAD_PROCESS_LOG_MAX_AGE, u64::MAX).unwrap();
1626
1627        assert_eq!(summary.removed_files, 2);
1628        assert!(!dead.exists());
1629        assert!(!dead_rotated.exists());
1630        assert!(fresh_dead.exists());
1631        assert!(own.exists());
1632        assert!(live_rotated.exists());
1633        assert!(plugin.exists());
1634    }
1635
1636    #[test]
1637    fn budget_backstop_deletes_oldest_dead_files_but_not_live_files() {
1638        let temp = TempDir::new().unwrap();
1639        let oldest = temp.path().join("aft-4294967294.log");
1640        let newest = temp.path().join("aft-4294967293.log");
1641        let live = temp.path().join(format!("aft-{}.log", std::process::id()));
1642        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1643        fs::write(&oldest, "oldest").unwrap();
1644        fs::write(&newest, "newest").unwrap();
1645        fs::write(&live, "live-live").unwrap();
1646        set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
1647        set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
1648        set_file_mtime(&live, FileTime::from_unix_time(1, 0)).unwrap();
1649
1650        let summary = sweep_logs(
1651            temp.path(),
1652            now,
1653            Duration::from_secs(365 * 24 * 60 * 60),
1654            15,
1655        )
1656        .unwrap();
1657
1658        assert_eq!(summary.removed_files, 1);
1659        assert!(!oldest.exists());
1660        assert!(newest.exists());
1661        assert!(live.exists());
1662    }
1663
1664    #[test]
1665    fn tool_call_summary_uses_bounded_window_median_and_maxima() {
1666        let samples = VecDeque::from([
1667            ToolCallPerfSample {
1668                total_ms: 9,
1669                queue_ms: 5,
1670            },
1671            ToolCallPerfSample {
1672                total_ms: 3,
1673                queue_ms: 1,
1674            },
1675            ToolCallPerfSample {
1676                total_ms: 7,
1677                queue_ms: 2,
1678            },
1679            ToolCallPerfSample {
1680                total_ms: 5,
1681                queue_ms: 4,
1682            },
1683        ]);
1684
1685        let summary = summarize_tool_calls(&samples);
1686
1687        assert_eq!(summary.window, 4);
1688        assert_eq!(summary.p50_total_ms, 5);
1689        assert_eq!(summary.max_total_ms, 9);
1690        assert_eq!(summary.p50_queue_ms, 2);
1691        assert_eq!(summary.max_queue_ms, 5);
1692    }
1693
1694    #[test]
1695    fn tool_call_tick_labels_interval_count_and_rolling_window() {
1696        let samples = VecDeque::from([ToolCallPerfSample {
1697            total_ms: 3_000,
1698            queue_ms: 2_900,
1699        }]);
1700        let summary = summarize_tool_calls(&samples);
1701
1702        assert_eq!(
1703            format_tool_call_summary(1, summary),
1704            "toolcall={new:1,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1705        );
1706        assert_eq!(
1707            format_tool_call_summary(0, summary),
1708            "toolcall={new:0,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1709        );
1710    }
1711
1712    fn index_event_matches_grammar(line: &str) -> bool {
1713        let Some(rest) = line.strip_prefix("index_event ") else {
1714            return false;
1715        };
1716        if rest.is_empty() {
1717            return false;
1718        }
1719        rest.split(' ').all(|token| {
1720            let Some((key, value)) = token.split_once('=') else {
1721                return false;
1722            };
1723            !key.is_empty()
1724                && key.chars().all(|c| c.is_ascii_lowercase() || c == '_')
1725                && !value.is_empty()
1726                && !value.contains(' ')
1727                && !value.contains('=')
1728        })
1729    }
1730
1731    fn index_event_fields(line: &str) -> BTreeMap<String, String> {
1732        let mut fields = BTreeMap::new();
1733        for token in line.split_whitespace().skip(1) {
1734            let Some((key, value)) = token.split_once('=') else {
1735                continue;
1736            };
1737            fields.insert(key.to_string(), value.to_string());
1738        }
1739        fields
1740    }
1741
1742    fn assert_index_event_grammar(lines: &[String]) {
1743        for line in lines {
1744            assert!(
1745                index_event_matches_grammar(line),
1746                "index_event line failed grammar: {line}"
1747            );
1748        }
1749    }
1750
1751    fn event_matches(line: &str, plane: &str, root: &str, key: &str) -> bool {
1752        let fields = index_event_fields(line);
1753        fields.get("plane").map(String::as_str) == Some(plane)
1754            && fields.get("root").map(String::as_str) == Some(root)
1755            && fields.get("key").map(String::as_str) == Some(key)
1756    }
1757
1758    fn assert_lifecycle_sequence(
1759        lines: &[String],
1760        plane: &str,
1761        root: &str,
1762        key: &str,
1763        require_progress: bool,
1764    ) {
1765        let events: Vec<_> = lines
1766            .iter()
1767            .filter(|line| event_matches(line, plane, root, key))
1768            .cloned()
1769            .collect();
1770        assert_index_event_grammar(&events);
1771        let kinds: Vec<_> = events
1772            .iter()
1773            .filter_map(|line| index_event_fields(line).get("kind").cloned())
1774            .filter(|kind| {
1775                matches!(
1776                    kind.as_str(),
1777                    "build_started" | "build_progress" | "build_ready"
1778                )
1779            })
1780            .collect();
1781        assert!(
1782            kinds.first().is_some_and(|kind| kind == "build_started"),
1783            "expected build_started first, got {kinds:?} from {events:?}"
1784        );
1785        if require_progress {
1786            assert!(
1787                kinds.iter().any(|kind| kind == "build_progress"),
1788                "expected build_progress in {kinds:?} from {events:?}"
1789            );
1790        }
1791        assert!(
1792            kinds.last().is_some_and(|kind| kind == "build_ready"),
1793            "expected build_ready last, got {kinds:?} from {events:?}"
1794        );
1795        let build_ids: Vec<_> = events
1796            .iter()
1797            .filter_map(|line| index_event_fields(line).get("build_id").cloned())
1798            .collect();
1799        assert!(!build_ids.is_empty());
1800        assert!(
1801            build_ids.iter().all(|id| id == &build_ids[0]),
1802            "build_id drifted across events: {build_ids:?}"
1803        );
1804        for line in &events {
1805            let fields = index_event_fields(line);
1806            assert_eq!(fields.get("root").map(String::as_str), Some(root), "{line}");
1807            assert_eq!(fields.get("key").map(String::as_str), Some(key), "{line}");
1808        }
1809    }
1810
1811    fn tiny_project(name: &str, file_name: &str, contents: &str) -> (tempfile::TempDir, PathBuf) {
1812        let temp = tempfile::tempdir().expect("tempdir");
1813        let root = temp.path().join(name);
1814        std::fs::create_dir_all(&root).expect("create project");
1815        std::fs::write(root.join(file_name), contents).expect("write fixture");
1816        let root = std::fs::canonicalize(&root).unwrap_or(root);
1817        (temp, root)
1818    }
1819
1820    #[test]
1821    fn watcher_rescan_event_reports_reason_cost_rss_and_interval_count() {
1822        let (_, lines) = capture_index_events(|| {
1823            log_watcher_rescan(
1824                Path::new("/tmp/watcher root"),
1825                crate::watcher_filter::RescanReason::KernelDropped,
1826                42,
1827                Some(-4096),
1828                137,
1829            );
1830        });
1831        assert_index_event_grammar(&lines);
1832        assert_eq!(lines.len(), 1);
1833        let fields = index_event_fields(&lines[0]);
1834        assert_eq!(
1835            fields.get("kind").map(String::as_str),
1836            Some("watcher_rescan")
1837        );
1838        assert_eq!(fields.get("plane").map(String::as_str), Some("watcher"));
1839        assert_eq!(
1840            fields.get("root").map(String::as_str),
1841            Some("/tmp/watcher_root")
1842        );
1843        assert_eq!(
1844            fields.get("reason").map(String::as_str),
1845            Some("kernel_dropped")
1846        );
1847        assert_eq!(fields.get("cost_ms").map(String::as_str), Some("42"));
1848        assert_eq!(
1849            fields.get("rss_delta_bytes").map(String::as_str),
1850            Some("-4096")
1851        );
1852        assert_eq!(
1853            fields.get("raw_events_since_last").map(String::as_str),
1854            Some("137")
1855        );
1856    }
1857
1858    #[test]
1859    fn index_event_grammar_rejects_spaces_and_equals_in_values() {
1860        let (result, lines) = capture_index_events(|| {
1861            log_index_event(
1862                IndexEvent::new(
1863                    IndexEventKind::BuildStarted,
1864                    IndexPlane::Search,
1865                    "b-1-1",
1866                    "/tmp/root",
1867                    "abc123",
1868                )
1869                .field("stage", "streaming"),
1870            );
1871        });
1872        let _ = result;
1873        assert_index_event_grammar(&lines);
1874        // The capture slot is process-wide: a parallel lib test that builds an
1875        // index can emit into the same window. Only this test's own event
1876        // (unique build_id) is the subject here.
1877        let own: Vec<&String> = lines
1878            .iter()
1879            .filter(|line| {
1880                index_event_fields(line).get("build_id").map(String::as_str) == Some("b-1-1")
1881            })
1882            .collect();
1883        assert_eq!(own.len(), 1, "{lines:?}");
1884    }
1885
1886    #[test]
1887    fn index_event_keeps_required_fields_for_long_root() {
1888        let root = format!("/{}", "a".repeat(399));
1889        let (_, lines) = capture_index_events(|| {
1890            log_index_event(IndexEvent::new(
1891                IndexEventKind::BuildStarted,
1892                IndexPlane::Search,
1893                "b-9-9",
1894                &root,
1895                "keepkey",
1896            ));
1897        });
1898        assert_eq!(lines.len(), 1, "{lines:?}");
1899        let fields = index_event_fields(&lines[0]);
1900        assert_eq!(fields.get("build_id").map(String::as_str), Some("b-9-9"));
1901        assert_eq!(fields.get("key").map(String::as_str), Some("keepkey"));
1902        assert!(fields.get("root").is_some(), "{lines:?}");
1903        assert_index_event_grammar(&lines);
1904    }
1905
1906    #[test]
1907    fn first_query_fires_once_per_build_id() {
1908        let root = PathBuf::from("/tmp/first-query-root");
1909        let key = "firstquerykey";
1910        let build_id = "b-1-99";
1911        let (_, lines) = capture_index_events(|| {
1912            log_index_event(IndexEvent::new(
1913                IndexEventKind::BuildReady,
1914                IndexPlane::Search,
1915                build_id,
1916                &root,
1917                key,
1918            ));
1919            assert!(claim_first_query(
1920                IndexPlane::Search,
1921                &root,
1922                "grep",
1923                1,
1924                2,
1925                "ok"
1926            ));
1927            assert!(!claim_first_query(
1928                IndexPlane::Search,
1929                &root,
1930                "grep",
1931                1,
1932                2,
1933                "ok"
1934            ));
1935        });
1936        let first_query: Vec<_> = lines
1937            .iter()
1938            .filter(|line| line.contains("kind=first_query"))
1939            .collect();
1940        assert_eq!(first_query.len(), 1, "{lines:?}");
1941        let fields = index_event_fields(first_query[0]);
1942        assert_eq!(fields.get("build_id").map(String::as_str), Some(build_id));
1943        assert_eq!(fields.get("tool").map(String::as_str), Some("grep"));
1944    }
1945
1946    #[test]
1947    fn callgraph_cold_build_emits_started_progress_ready() {
1948        let (_temp, root) = tiny_project("cg", "lib.rs", "pub fn marker() {}\n");
1949        let key = crate::search_index::artifact_cache_key(&root);
1950        let callgraph_dir = _temp.path().join("callgraph").join(&key);
1951        crate::root_cache::configure_artifact_access(&root, &key, false);
1952        let source = root.join("lib.rs");
1953        let (built, lines) = capture_index_events(|| {
1954            crate::callgraph_store::CallGraphStore::cold_build_with_lease(
1955                callgraph_dir,
1956                root.clone(),
1957                std::slice::from_ref(&source),
1958            )
1959        });
1960        built.expect("callgraph cold build");
1961        assert_lifecycle_sequence(
1962            &lines,
1963            "callgraph",
1964            &normalize_index_root(&root),
1965            &key,
1966            true,
1967        );
1968    }
1969
1970    #[test]
1971    fn search_cold_build_emits_started_progress_ready() {
1972        let (_temp, root) = tiny_project("search", "file.txt", "alpha token\n");
1973        let key = crate::search_index::artifact_cache_key(&root);
1974        let (index, lines) = capture_index_events(|| {
1975            crate::search_index::SearchIndex::build_with_limit(&root, 1_000_000)
1976        });
1977        assert!(index.ready);
1978        assert_lifecycle_sequence(&lines, "search", &normalize_index_root(&root), &key, true);
1979    }
1980
1981    #[test]
1982    fn semantic_cold_build_emits_started_progress_ready() {
1983        let (_temp, root) = tiny_project("sem", "lib.rs", "pub fn hello() {}\n");
1984        let key = crate::search_index::artifact_cache_key(&root);
1985        let files = vec![root.join("lib.rs")];
1986        let mut embed = |texts: Vec<String>| {
1987            Ok(texts
1988                .into_iter()
1989                .map(|_| vec![0.01_f32; 384])
1990                .collect::<Vec<_>>())
1991        };
1992        let (built, lines) = capture_index_events(|| {
1993            crate::semantic_index::SemanticIndex::build(&root, &files, &mut embed, 8)
1994        });
1995        built.expect("semantic build");
1996        assert_lifecycle_sequence(&lines, "semantic", &normalize_index_root(&root), &key, true);
1997    }
1998
1999    #[test]
2000    fn tier2_category_emits_started_ready() {
2001        let (_temp, root) = tiny_project("t2", "mod0.ts", "export function f0() { return 0; }\n");
2002        let key = crate::search_index::artifact_cache_key(&root);
2003        crate::root_cache::configure_artifact_access(&root, &key, false);
2004        let inspect_dir = _temp.path().join("inspect");
2005        let manager = std::sync::Arc::new(crate::inspect::InspectManager::new());
2006        let snapshot = crate::inspect::InspectSnapshot::new(
2007            root.clone(),
2008            inspect_dir,
2009            std::sync::Arc::new(crate::config::Config {
2010                project_root: Some(root.clone()),
2011                ..crate::config::Config::default()
2012            }),
2013            std::sync::Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new())),
2014        );
2015        let (outcome, lines) = capture_index_events(|| {
2016            manager.tier2_run_with_reuse_blocking_fresh(
2017                snapshot,
2018                crate::inspect::InspectCategory::Complexity,
2019                crate::inspect::JobScope::for_project(root.clone()),
2020            )
2021        });
2022        assert!(outcome.payload().is_some(), "{outcome:?}");
2023        assert_lifecycle_sequence(&lines, "tier2", &normalize_index_root(&root), &key, false);
2024        assert!(
2025            !lines.iter().any(|line| {
2026                event_matches(line, "tier2", &normalize_index_root(&root), &key)
2027                    && line.contains("kind=build_progress")
2028            }),
2029            "tier2 must not emit synthetic build_progress: {lines:?}"
2030        );
2031    }
2032
2033    #[test]
2034    fn superseded_cold_build_keeps_build_id_and_skips_ready() {
2035        let (_temp, root) = tiny_project("sup", "lib.rs", "pub fn marker() {}\n");
2036        let key = crate::search_index::artifact_cache_key(&root);
2037        let callgraph_dir = _temp.path().join("callgraph").join(&key);
2038        crate::root_cache::configure_artifact_access(&root, &key, false);
2039        let source = root.join("lib.rs");
2040        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
2041        let stale_epoch = epoch.current();
2042        epoch.next();
2043        let (failed, lines) = capture_index_events(|| {
2044            crate::callgraph_store::with_publish_epoch(epoch, stale_epoch, || {
2045                crate::callgraph_store::CallGraphStore::cold_build_with_lease(
2046                    callgraph_dir,
2047                    root.clone(),
2048                    std::slice::from_ref(&source),
2049                )
2050            })
2051        });
2052        assert!(matches!(
2053            failed,
2054            Err(crate::callgraph_store::CallGraphStoreError::Superseded)
2055        ));
2056        let root_s = normalize_index_root(&root);
2057        let events: Vec<_> = lines
2058            .iter()
2059            .filter(|line| event_matches(line, "callgraph", &root_s, &key))
2060            .cloned()
2061            .collect();
2062        assert_index_event_grammar(&events);
2063        let kinds: Vec<_> = events
2064            .iter()
2065            .filter_map(|line| index_event_fields(line).get("kind").cloned())
2066            .collect();
2067        assert!(
2068            kinds.contains(&"build_started".to_string()),
2069            "{kinds:?} {events:?}"
2070        );
2071        assert!(
2072            kinds.contains(&"build_superseded".to_string()),
2073            "{kinds:?} {events:?}"
2074        );
2075        assert!(
2076            !kinds.contains(&"build_ready".to_string()),
2077            "superseded build must not emit build_ready: {kinds:?}"
2078        );
2079        let started_id = events
2080            .iter()
2081            .find(|line| line.contains("kind=build_started"))
2082            .and_then(|line| index_event_fields(line).get("build_id").cloned());
2083        let superseded_id = events
2084            .iter()
2085            .find(|line| line.contains("kind=build_superseded"))
2086            .and_then(|line| index_event_fields(line).get("build_id").cloned());
2087        assert_eq!(started_id, superseded_id);
2088        for line in &events {
2089            let fields = index_event_fields(line);
2090            assert_eq!(
2091                fields.get("root").map(String::as_str),
2092                Some(root_s.as_str())
2093            );
2094            assert_eq!(fields.get("key").map(String::as_str), Some(key.as_str()));
2095        }
2096    }
2097}