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    /// Files whose PID number is live but belongs to a process started after
973    /// the file's last write. Reported separately so a sweep that removes
974    /// nothing can be read from the log as "nothing was dead" versus "the
975    /// recycled-PID verdict never fired".
976    recycled_pids: usize,
977}
978
979struct ProcessLogFile {
980    path: PathBuf,
981    modified: Option<SystemTime>,
982    bytes: u64,
983    dead: bool,
984    old_enough: bool,
985    removed: bool,
986}
987
988fn log_sweep_summary(summary: SweepSummary) {
989    crate::slog_info!(
990        "log retention sweep: removed_files={} bytes_freed={} recycled_pids={}",
991        summary.removed_files,
992        summary.bytes_freed,
993        summary.recycled_pids
994    );
995}
996
997/// Sweep dead Rust process logs, then enforce the directory budget without ever
998/// deleting a live PID's file or the plugin logger's pid-less file.
999fn sweep_logs(
1000    dir: &Path,
1001    now: SystemTime,
1002    max_age: Duration,
1003    budget_bytes: u64,
1004) -> io::Result<SweepSummary> {
1005    let mut total_bytes = 0_u64;
1006    let mut process_logs = Vec::new();
1007    let mut live_pids = BTreeMap::new();
1008    let mut recycled_count = 0_usize;
1009    let own_pid = std::process::id();
1010
1011    for entry in fs::read_dir(dir)? {
1012        let entry = match entry {
1013            Ok(entry) => entry,
1014            Err(_) => continue,
1015        };
1016        let metadata = match entry.metadata() {
1017            Ok(metadata) if metadata.is_file() => metadata,
1018            Ok(_) | Err(_) => continue,
1019        };
1020        let bytes = metadata.len();
1021        total_bytes = total_bytes.saturating_add(bytes);
1022        let name = entry.file_name();
1023        let name = name.to_string_lossy();
1024        let pid = match name.as_ref() {
1025            // This shared TypeScript-owned file has no PID. Keep this explicit
1026            // so a future default branch cannot accidentally make it reaped.
1027            "aft-plugin.log" => continue,
1028            _ => process_log_pid(&name),
1029        };
1030        let Some(pid) = pid else {
1031            continue;
1032        };
1033        let modified = metadata.modified().ok();
1034        let old_enough = modified
1035            .and_then(|modified| now.duration_since(modified).ok())
1036            .is_some_and(|age| age >= max_age);
1037        let alive = *live_pids.entry(pid).or_insert_with(|| {
1038            if !is_process_alive(pid) {
1039                return false;
1040            }
1041            let recycled = pid_started_after_last_write(pid, modified);
1042            if recycled {
1043                recycled_count += 1;
1044            }
1045            !recycled
1046        });
1047        process_logs.push(ProcessLogFile {
1048            path: entry.path(),
1049            modified,
1050            bytes,
1051            dead: pid != own_pid && !alive,
1052            old_enough,
1053            removed: false,
1054        });
1055    }
1056
1057    let mut summary = SweepSummary {
1058        recycled_pids: recycled_count,
1059        ..SweepSummary::default()
1060    };
1061    for file in &mut process_logs {
1062        if file.dead && file.old_enough && remove_sweep_candidate(&file.path) {
1063            file.removed = true;
1064            total_bytes = total_bytes.saturating_sub(file.bytes);
1065            summary.removed_files += 1;
1066            summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1067        }
1068    }
1069
1070    // The budget backstop is deliberately separate from the age-gated reap:
1071    // once liveness says a PID is dead, budget pressure may remove even a fresh
1072    // dead file so the directory can actually converge under its hard limit.
1073    // Live files remain ineligible regardless of age or budget pressure.
1074    process_logs.sort_by_key(|file| file.modified);
1075    for file in process_logs
1076        .iter_mut()
1077        .filter(|file| file.dead && !file.removed)
1078    {
1079        if total_bytes <= budget_bytes {
1080            break;
1081        }
1082        if remove_sweep_candidate(&file.path) {
1083            file.removed = true;
1084            total_bytes = total_bytes.saturating_sub(file.bytes);
1085            summary.removed_files += 1;
1086            summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1087        }
1088    }
1089
1090    Ok(summary)
1091}
1092
1093/// Slack for clock and filesystem timestamp granularity when comparing a
1094/// process start time against a log file's last write.
1095const PID_REUSE_TOLERANCE_MS: u64 = 2_000;
1096
1097/// The PID number outlives the process that wrote the log. After a reboot the
1098/// kernel hands low numbers to system daemons, so a liveness check by number
1099/// alone pinned sixteen-day-old files under the age reap and the budget
1100/// backstop alike. The real owner started before it wrote the file, so a start
1101/// time later than the file's last write proves the number was recycled.
1102/// Platforms without a start-time source keep the number-only verdict.
1103fn pid_started_after_last_write(pid: u32, modified: Option<SystemTime>) -> bool {
1104    let Some(modified_ms) = modified
1105        .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
1106        .map(|since_epoch| since_epoch.as_millis() as u64)
1107    else {
1108        return false;
1109    };
1110    crate::root_cache::process_start_time_ms(pid)
1111        .is_some_and(|started_ms| started_ms > modified_ms.saturating_add(PID_REUSE_TOLERANCE_MS))
1112}
1113
1114fn remove_sweep_candidate(path: &Path) -> bool {
1115    // A sharing violation means another process has the file pinned (notably on
1116    // Windows). Leave it for a later sweep instead of failing the maintenance pass.
1117    fs::remove_file(path).is_ok()
1118}
1119
1120fn process_log_pid(name: &str) -> Option<u32> {
1121    let rest = name.strip_prefix("aft-")?;
1122    let (pid, suffix) = rest.split_once(".log")?;
1123    if !suffix.is_empty()
1124        && !(suffix.starts_with('.') && suffix[1..].chars().all(|ch| ch.is_ascii_digit()))
1125    {
1126        return None;
1127    }
1128    pid.parse().ok()
1129}
1130
1131static LAST_LOG_SWEEP: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(|| Mutex::new(None));
1132
1133fn mark_log_sweep_ran() {
1134    if let Ok(mut last_run) = LAST_LOG_SWEEP.lock() {
1135        *last_run = Some(Instant::now());
1136    }
1137}
1138
1139/// Run log maintenance from an existing idle/maintenance tick at most hourly.
1140pub fn maybe_sweep_logs() {
1141    let now = Instant::now();
1142    let should_run = LAST_LOG_SWEEP
1143        .lock()
1144        .map(|mut last_run| {
1145            if last_run.is_some_and(|last| now.duration_since(last) < LOG_SWEEP_INTERVAL) {
1146                false
1147            } else {
1148                *last_run = Some(now);
1149                true
1150            }
1151        })
1152        .unwrap_or(false);
1153    if !should_run {
1154        return;
1155    }
1156
1157    let storage_root = FILE_CONTROL
1158        .lock()
1159        .ok()
1160        .and_then(|control| control.storage_root.clone())
1161        .unwrap_or_else(|| crate::bash_background::storage_dir(None));
1162    let logs_dir = storage_root.join("logs");
1163    match sweep_logs(
1164        &logs_dir,
1165        SystemTime::now(),
1166        DEAD_PROCESS_LOG_MAX_AGE,
1167        LOG_DIRECTORY_BUDGET_BYTES,
1168    ) {
1169        Ok(summary) => log_sweep_summary(summary),
1170        Err(error) => crate::slog_warn!(
1171            "log retention sweep failed for {}: {}",
1172            logs_dir.display(),
1173            error
1174        ),
1175    }
1176}
1177
1178#[derive(Default)]
1179struct PerfMetrics {
1180    watcher_ingested: AtomicU64,
1181    watcher_paths: AtomicU64,
1182    watcher_dropped: AtomicU64,
1183    drain_slices: AtomicU64,
1184    semantic_collects: AtomicU64,
1185    semantic_files: AtomicU64,
1186    semantic_chunks: AtomicU64,
1187    semantic_ms: AtomicU64,
1188    callgraph_invalidations: AtomicU64,
1189    file_lines_dropped: AtomicU64,
1190    tool_call_count: AtomicU64,
1191    tool_calls: Mutex<VecDeque<ToolCallPerfSample>>,
1192    tier2: Mutex<BTreeMap<String, (u64, u64)>>,
1193    next_sample_ns: AtomicU64,
1194    reporter: Mutex<PerfReporter>,
1195}
1196
1197struct PerfReporter {
1198    last_report: Instant,
1199    last_completed_interactive: u64,
1200    last_completed_maintenance: u64,
1201    last_tool_call_count: u64,
1202}
1203
1204impl Default for PerfReporter {
1205    fn default() -> Self {
1206        Self {
1207            last_report: Instant::now(),
1208            last_completed_interactive: 0,
1209            last_completed_maintenance: 0,
1210            last_tool_call_count: 0,
1211        }
1212    }
1213}
1214
1215#[derive(Clone, Copy)]
1216struct ToolCallPerfSample {
1217    total_ms: u64,
1218    queue_ms: u64,
1219}
1220
1221#[derive(Clone, Copy, Default)]
1222struct ToolCallPerfSummary {
1223    window: usize,
1224    p50_total_ms: u64,
1225    max_total_ms: u64,
1226    p50_queue_ms: u64,
1227    max_queue_ms: u64,
1228}
1229
1230#[derive(Clone, Copy, Default)]
1231struct ExecutorSample {
1232    interactive_running: usize,
1233    maintenance_running: usize,
1234    interactive_queued: usize,
1235    maintenance_queued: usize,
1236    interactive_oldest_ms: Option<u64>,
1237    maintenance_oldest_ms: Option<u64>,
1238}
1239
1240static PERF: LazyLock<PerfMetrics> = LazyLock::new(PerfMetrics::default);
1241
1242/// Move subsequent file log writes to a newly configured storage root.
1243///
1244/// Reconfiguration is queued behind existing writes and is a no-op when the
1245/// root has not changed. Initialization and explicit configure changes call
1246/// this directly, avoiding storage-root polling on transport drain turns.
1247pub fn sync_storage_root(storage_root: PathBuf) {
1248    let Ok(mut control) = FILE_CONTROL.lock() else {
1249        return;
1250    };
1251    if control.storage_root.as_ref() == Some(&storage_root) {
1252        return;
1253    }
1254    let Some(tx) = control.tx.as_ref() else {
1255        return;
1256    };
1257    if tx
1258        .try_send(LogMessage::Reconfigure(storage_root.clone()))
1259        .is_ok()
1260    {
1261        control.storage_root = Some(storage_root);
1262    }
1263}
1264
1265/// Called by `drain_watcher_events_bounded` for dispatch events actually received.
1266pub fn note_watcher_events(count: usize) {
1267    PERF.watcher_ingested
1268        .fetch_add(count as u64, Ordering::Relaxed);
1269}
1270
1271/// Called when a watcher drain slice takes paths from dispatch continuation state.
1272pub fn note_drain_paths(count: usize) {
1273    PERF.watcher_paths
1274        .fetch_add(count as u64, Ordering::Relaxed);
1275}
1276
1277/// Called when `drain_watcher_events_bounded` receives a rescan-required overflow signal.
1278pub fn note_watcher_overflow() {
1279    PERF.watcher_dropped.fetch_add(1, Ordering::Relaxed);
1280}
1281
1282/// Called by the standalone request loop before a request-triggered runtime drain.
1283pub fn note_drain_slice() {
1284    PERF.drain_slices.fetch_add(1, Ordering::Relaxed);
1285}
1286
1287/// Called after `SemanticIndex::collect_chunks` has collected one real file batch.
1288pub fn note_semantic_collect(chunks: usize, files: usize, elapsed_ms: u64) {
1289    PERF.semantic_collects.fetch_add(1, Ordering::Relaxed);
1290    PERF.semantic_chunks
1291        .fetch_add(chunks as u64, Ordering::Relaxed);
1292    PERF.semantic_files
1293        .fetch_add(files as u64, Ordering::Relaxed);
1294    PERF.semantic_ms.fetch_add(elapsed_ms, Ordering::Relaxed);
1295}
1296
1297/// Called by `Tier2PhaseTimings::log` after a Tier-2 scan performs measurable work.
1298pub fn note_tier2_scan(category: String, elapsed_ms: u64) {
1299    if let Ok(mut tier2) = PERF.tier2.lock() {
1300        let entry = tier2.entry(category).or_default();
1301        entry.0 = entry.0.saturating_add(1);
1302        entry.1 = entry.1.saturating_add(elapsed_ms);
1303    }
1304}
1305
1306/// Called after watcher-driven callgraph `refresh_files` succeeds for concrete paths.
1307pub fn note_callgraph_invalidations(files: usize) {
1308    PERF.callgraph_invalidations
1309        .fetch_add(files as u64, Ordering::Relaxed);
1310}
1311
1312/// Record a completed subc tool call for slow-call diagnostics and the standing
1313/// perf-tick window. The writer calls this only after `write_all` has handed the
1314/// complete response frame to the transport.
1315pub fn note_tool_call_trace(
1316    name: &str,
1317    root: &Path,
1318    channel: u16,
1319    corr: u64,
1320    phases: ToolCallPhaseDurations,
1321) {
1322    let sample = ToolCallPerfSample {
1323        total_ms: duration_millis_u64(phases.total),
1324        queue_ms: duration_millis_u64(phases.queue),
1325    };
1326    if let Ok(mut samples) = PERF.tool_calls.lock() {
1327        if samples.len() == TOOL_CALL_SAMPLE_CAPACITY {
1328            samples.pop_front();
1329        }
1330        samples.push_back(sample);
1331        PERF.tool_call_count.fetch_add(1, Ordering::Relaxed);
1332    }
1333
1334    let waiting_on_build_id = phases.waiting_on_build_id.as_deref().unwrap_or("-");
1335    crate::slog_debug!(
1336        "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={}",
1337        name,
1338        channel,
1339        corr,
1340        duration_millis_f64(phases.total),
1341        duration_millis_f64(phases.queue),
1342        duration_millis_f64(phases.translate),
1343        duration_millis_f64(phases.execute),
1344        duration_millis_f64(phases.format),
1345        duration_millis_f64(phases.finalize),
1346        duration_millis_f64(phases.egress),
1347        duration_millis_f64(phases.egress_enqueue),
1348        duration_millis_f64(phases.egress_queue),
1349        duration_millis_f64(phases.egress_prepare),
1350        duration_millis_f64(phases.egress_write),
1351        phases.frame_bytes,
1352        phases.writer_queue_depth,
1353        phases.writer_active_at_enqueue,
1354        phases.writer_queue_was_full,
1355        phases.writer_reserve_timeouts,
1356        phases.waiting_on.as_str(),
1357        waiting_on_build_id,
1358        phases.wait_ms,
1359        root.display(),
1360    );
1361
1362    if phases.total > SLOW_TOOL_CALL_THRESHOLD {
1363        crate::slog_warn!(
1364            "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={}",
1365            name,
1366            channel,
1367            corr,
1368            duration_millis_u64(phases.total),
1369            duration_millis_u64(phases.queue),
1370            duration_millis_u64(phases.translate),
1371            duration_millis_u64(phases.execute),
1372            duration_millis_u64(phases.format),
1373            duration_millis_u64(phases.finalize),
1374            duration_millis_u64(phases.egress),
1375            duration_millis_u64(phases.egress_enqueue),
1376            duration_millis_u64(phases.egress_queue),
1377            duration_millis_u64(phases.egress_prepare),
1378            duration_millis_u64(phases.egress_write),
1379            phases.frame_bytes,
1380            phases.writer_queue_depth,
1381            phases.writer_active_at_enqueue,
1382            phases.writer_queue_was_full,
1383            phases.writer_reserve_timeouts,
1384            phases.waiting_on.as_str(),
1385            waiting_on_build_id,
1386            phases.wait_ms,
1387            root.display(),
1388        );
1389    }
1390}
1391
1392/// Sample executor liveness and emit one busy-only aggregate at the configured cadence.
1393///
1394/// The transport may call this every loop turn; an atomic deadline keeps all
1395/// executor sampling and reporter locking off that path between drain ticks.
1396pub fn perf_tick(executor: Option<&Executor>) {
1397    if !perf_sample_due() {
1398        return;
1399    }
1400
1401    let sample = executor.and_then(|executor| {
1402        executor
1403            .try_dispatch_liveness_snapshot()
1404            .map(|snapshot| ExecutorSample {
1405                interactive_running: snapshot.running.interactive,
1406                maintenance_running: snapshot.running.maintenance,
1407                interactive_queued: snapshot.interactive.queued,
1408                maintenance_queued: snapshot.maintenance.queued,
1409                interactive_oldest_ms: snapshot.interactive.oldest_age_ms,
1410                maintenance_oldest_ms: snapshot.maintenance.oldest_age_ms,
1411            })
1412    });
1413
1414    let completion_counts = executor.map_or((0, 0), Executor::completion_counts);
1415    let tool_call_count = PERF.tool_call_count.load(Ordering::Relaxed);
1416    let (completed_interactive, completed_maintenance, new_tool_calls) = {
1417        let Ok(mut reporter) = PERF.reporter.lock() else {
1418            return;
1419        };
1420        if reporter.last_report.elapsed() < perf_tick_interval() {
1421            return;
1422        }
1423        reporter.last_report = Instant::now();
1424        let completed = (
1425            completion_counts
1426                .0
1427                .saturating_sub(reporter.last_completed_interactive),
1428            completion_counts
1429                .1
1430                .saturating_sub(reporter.last_completed_maintenance),
1431            tool_call_count.saturating_sub(reporter.last_tool_call_count),
1432        );
1433        reporter.last_completed_interactive = completion_counts.0;
1434        reporter.last_completed_maintenance = completion_counts.1;
1435        reporter.last_tool_call_count = tool_call_count;
1436        completed
1437    };
1438
1439    let watcher_ingested = PERF.watcher_ingested.swap(0, Ordering::Relaxed);
1440    let watcher_paths = PERF.watcher_paths.swap(0, Ordering::Relaxed);
1441    let watcher_dropped = PERF.watcher_dropped.swap(0, Ordering::Relaxed);
1442    let drain_slices = PERF.drain_slices.swap(0, Ordering::Relaxed);
1443    let semantic_collects = PERF.semantic_collects.swap(0, Ordering::Relaxed);
1444    let semantic_files = PERF.semantic_files.swap(0, Ordering::Relaxed);
1445    let semantic_chunks = PERF.semantic_chunks.swap(0, Ordering::Relaxed);
1446    let semantic_ms = PERF.semantic_ms.swap(0, Ordering::Relaxed);
1447    let callgraph_invalidations = PERF.callgraph_invalidations.swap(0, Ordering::Relaxed);
1448    let file_lines_dropped = PERF.file_lines_dropped.swap(0, Ordering::Relaxed);
1449    let tier2 = PERF
1450        .tier2
1451        .lock()
1452        .map(|mut tier2| std::mem::take(&mut *tier2))
1453        .unwrap_or_default();
1454    let tool_calls = PERF
1455        .tool_calls
1456        .lock()
1457        .map(|samples| summarize_tool_calls(&samples))
1458        .unwrap_or_default();
1459
1460    let executor_busy = sample.is_some_and(|sample| {
1461        sample.interactive_running > 0
1462            || sample.maintenance_running > 0
1463            || sample.interactive_queued > 0
1464            || sample.maintenance_queued > 0
1465    });
1466    let active = watcher_ingested > 0
1467        || watcher_paths > 0
1468        || watcher_dropped > 0
1469        || drain_slices > 0
1470        || semantic_collects > 0
1471        || callgraph_invalidations > 0
1472        || completed_interactive > 0
1473        || completed_maintenance > 0
1474        || new_tool_calls > 0
1475        || file_lines_dropped > 0
1476        || !tier2.is_empty()
1477        || executor_busy;
1478    if !active {
1479        return;
1480    }
1481
1482    let tier2_summary = if tier2.is_empty() {
1483        "none".to_string()
1484    } else {
1485        tier2
1486            .into_iter()
1487            .map(|(category, (count, ms))| format!("{category}:{count}/{ms}ms"))
1488            .collect::<Vec<_>>()
1489            .join(",")
1490    };
1491    let sample = sample.unwrap_or_default();
1492    crate::slog_info!(
1493        "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={}",
1494        watcher_ingested,
1495        watcher_paths,
1496        watcher_dropped,
1497        drain_slices,
1498        tier2_summary,
1499        semantic_collects,
1500        semantic_files,
1501        semantic_chunks,
1502        semantic_ms,
1503        callgraph_invalidations,
1504        completed_interactive,
1505        completed_maintenance,
1506        format_optional_ms(sample.interactive_oldest_ms),
1507        format_optional_ms(sample.maintenance_oldest_ms),
1508        format_tool_call_summary(new_tool_calls, tool_calls),
1509        file_lines_dropped,
1510    );
1511}
1512
1513fn duration_millis_f64(duration: Duration) -> f64 {
1514    duration.as_secs_f64() * 1_000.0
1515}
1516
1517fn duration_millis_u64(duration: Duration) -> u64 {
1518    duration.as_millis().min(u64::MAX as u128) as u64
1519}
1520
1521fn summarize_tool_calls(samples: &VecDeque<ToolCallPerfSample>) -> ToolCallPerfSummary {
1522    if samples.is_empty() {
1523        return ToolCallPerfSummary::default();
1524    }
1525    let mut totals = samples
1526        .iter()
1527        .map(|sample| sample.total_ms)
1528        .collect::<Vec<_>>();
1529    let mut queues = samples
1530        .iter()
1531        .map(|sample| sample.queue_ms)
1532        .collect::<Vec<_>>();
1533    totals.sort_unstable();
1534    queues.sort_unstable();
1535    let median_index = (samples.len() - 1) / 2;
1536    ToolCallPerfSummary {
1537        window: samples.len(),
1538        p50_total_ms: totals[median_index],
1539        max_total_ms: totals[totals.len() - 1],
1540        p50_queue_ms: queues[median_index],
1541        max_queue_ms: queues[queues.len() - 1],
1542    }
1543}
1544
1545fn format_tool_call_summary(new_tool_calls: u64, summary: ToolCallPerfSummary) -> String {
1546    format!(
1547        "toolcall={{new:{new_tool_calls},window:{},p50_total_ms:{},max_total_ms:{},p50_queue_ms:{},max_queue_ms:{}}}",
1548        summary.window,
1549        summary.p50_total_ms,
1550        summary.max_total_ms,
1551        summary.p50_queue_ms,
1552        summary.max_queue_ms,
1553    )
1554}
1555
1556fn format_optional_ms(value: Option<u64>) -> String {
1557    value
1558        .map(|value| value.to_string())
1559        .unwrap_or_else(|| "none".to_string())
1560}
1561
1562fn perf_sample_due() -> bool {
1563    static ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
1564    let now_ns = ORIGIN.elapsed().as_nanos().min(u64::MAX as u128) as u64;
1565    let mut deadline = PERF.next_sample_ns.load(Ordering::Relaxed);
1566    loop {
1567        if now_ns < deadline {
1568            return false;
1569        }
1570        let next = now_ns.saturating_add(PERF_SAMPLE_INTERVAL.as_nanos() as u64);
1571        match PERF.next_sample_ns.compare_exchange_weak(
1572            deadline,
1573            next,
1574            Ordering::Relaxed,
1575            Ordering::Relaxed,
1576        ) {
1577            Ok(_) => return true,
1578            Err(observed) => deadline = observed,
1579        }
1580    }
1581}
1582
1583fn perf_tick_interval() -> Duration {
1584    static INTERVAL: OnceLock<Duration> = OnceLock::new();
1585    *INTERVAL.get_or_init(|| {
1586        std::env::var("AFT_PERF_TICK_INTERVAL_MS")
1587            .ok()
1588            .and_then(|value| value.parse::<u64>().ok())
1589            .filter(|value| *value > 0)
1590            .map(Duration::from_millis)
1591            .unwrap_or(DEFAULT_PERF_TICK_INTERVAL)
1592    })
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597    use super::*;
1598    use filetime::{set_file_mtime, FileTime};
1599    use tempfile::TempDir;
1600
1601    fn line(value: &str) -> Vec<Vec<u8>> {
1602        vec![format!("{value}\n").into_bytes()]
1603    }
1604
1605    #[test]
1606    fn epoch_timestamp_renders_known_dates() {
1607        // Epoch start, a modern date, a post-2038 date (u64 range), and the
1608        // 2100 non-leap century boundary that naive leap logic gets wrong.
1609        assert_eq!(format_epoch_secs(0), "1970-01-01T00:00:00Z");
1610        assert_eq!(format_epoch_secs(1_704_067_200), "2024-01-01T00:00:00Z");
1611        assert_eq!(format_epoch_secs(1_709_251_199), "2024-02-29T23:59:59Z");
1612        assert_eq!(format_epoch_secs(4_102_444_800), "2100-01-01T00:00:00Z");
1613        assert_eq!(format_epoch_secs(4_107_542_399), "2100-02-28T23:59:59Z");
1614    }
1615
1616    #[test]
1617    fn rotation_rolls_once_and_replaces_the_single_backup_generation() {
1618        let temp = TempDir::new().unwrap();
1619        let path = temp.path().join("aft-123.log");
1620        fs::write(rotated_path(&path, 1), "stale backup\n").unwrap();
1621        let mut sink = RotatingFile::open(path.clone(), 10, 1, 1).unwrap();
1622        sink.write_batch(&line("aaaa")).unwrap();
1623        sink.write_batch(&line("bbbb")).unwrap();
1624        sink.write_batch(&line("cccc")).unwrap();
1625        sink.write_batch(&line("dddd")).unwrap();
1626        sink.write_batch(&line("eeee")).unwrap();
1627
1628        assert_eq!(fs::read_to_string(&path).unwrap(), "eeee\n");
1629        assert_eq!(
1630            fs::read_to_string(rotated_path(&path, 1)).unwrap(),
1631            "cccc\ndddd\n"
1632        );
1633        assert!(!rotated_path(&path, 2).exists());
1634    }
1635
1636    #[test]
1637    fn dead_pid_sweep_respects_age_liveness_and_explicit_plugin_exclusion() {
1638        let temp = TempDir::new().unwrap();
1639        let dead = temp.path().join("aft-4294967294.log");
1640        let dead_rotated = temp.path().join("aft-4294967294.log.1");
1641        let fresh_dead = temp.path().join("aft-4294967293.log");
1642        let own = temp.path().join(format!("aft-{}.log", std::process::id()));
1643        let live_rotated = rotated_path(&own, 1);
1644        let plugin = temp.path().join("aft-plugin.log");
1645        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1646        for path in [&dead, &dead_rotated, &own, &live_rotated, &plugin] {
1647            fs::write(path, "log").unwrap();
1648            set_file_mtime(path, FileTime::from_unix_time(1, 0)).unwrap();
1649        }
1650        fs::write(&fresh_dead, "fresh").unwrap();
1651        set_file_mtime(
1652            &fresh_dead,
1653            FileTime::from_unix_time(
1654                (now - DEAD_PROCESS_LOG_MAX_AGE + Duration::from_secs(1))
1655                    .duration_since(SystemTime::UNIX_EPOCH)
1656                    .unwrap()
1657                    .as_secs() as i64,
1658                0,
1659            ),
1660        )
1661        .unwrap();
1662
1663        let summary = sweep_logs(temp.path(), now, DEAD_PROCESS_LOG_MAX_AGE, u64::MAX).unwrap();
1664
1665        assert_eq!(summary.removed_files, 2);
1666        assert!(!dead.exists());
1667        assert!(!dead_rotated.exists());
1668        assert!(fresh_dead.exists());
1669        assert!(own.exists());
1670        assert!(live_rotated.exists());
1671        assert!(plugin.exists());
1672    }
1673
1674    /// A live process's own start time always precedes its writes, so its file
1675    /// with a current mtime stays; the same live number on a file last written
1676    /// before that process started is a recycled PID and is reaped.
1677    #[cfg(any(target_os = "linux", target_os = "macos"))]
1678    #[test]
1679    fn recycled_pid_log_is_reaped_while_the_live_owner_is_kept() {
1680        let temp = TempDir::new().unwrap();
1681        let live_pid = unsafe { libc::getppid() } as u32;
1682        assert!(is_process_alive(live_pid));
1683        let recycled = temp.path().join(format!("aft-{live_pid}.log"));
1684        fs::write(&recycled, "written long before this process existed").unwrap();
1685        set_file_mtime(&recycled, FileTime::from_unix_time(1, 0)).unwrap();
1686
1687        let summary = sweep_logs(
1688            temp.path(),
1689            SystemTime::now(),
1690            DEAD_PROCESS_LOG_MAX_AGE,
1691            u64::MAX,
1692        )
1693        .unwrap();
1694        assert_eq!(summary.removed_files, 1);
1695        assert_eq!(summary.recycled_pids, 1);
1696        assert!(!recycled.exists());
1697
1698        let owned = temp.path().join(format!("aft-{live_pid}.log"));
1699        fs::write(&owned, "written by the live owner").unwrap();
1700        let summary = sweep_logs(
1701            temp.path(),
1702            SystemTime::now() + DEAD_PROCESS_LOG_MAX_AGE * 2,
1703            DEAD_PROCESS_LOG_MAX_AGE,
1704            0,
1705        )
1706        .unwrap();
1707        assert_eq!(summary.removed_files, 0);
1708        assert!(owned.exists());
1709    }
1710
1711    /// After a reboot the kernel hands low numbers to system daemons owned by
1712    /// other users. PID 1 is always such a process, and the first cut of the
1713    /// recycled-PID check could not read its start time on macOS, so the file
1714    /// stayed pinned as if the daemon had written it. The verdict must not
1715    /// depend on the new owner's uid.
1716    #[cfg(any(target_os = "linux", target_os = "macos"))]
1717    #[test]
1718    fn recycled_pid_now_owned_by_another_user_is_reaped() {
1719        let temp = TempDir::new().unwrap();
1720        assert!(is_process_alive(1));
1721        assert!(
1722            crate::root_cache::process_start_time_ms(1).is_some(),
1723            "pid 1's start time must be readable from an unprivileged process"
1724        );
1725        let recycled = temp.path().join("aft-1.log");
1726        fs::write(&recycled, "written before the current pid 1 booted").unwrap();
1727        set_file_mtime(&recycled, FileTime::from_unix_time(1, 0)).unwrap();
1728
1729        let summary = sweep_logs(
1730            temp.path(),
1731            SystemTime::now(),
1732            DEAD_PROCESS_LOG_MAX_AGE,
1733            u64::MAX,
1734        )
1735        .unwrap();
1736        assert_eq!(summary.removed_files, 1);
1737        assert_eq!(summary.recycled_pids, 1);
1738        assert!(!recycled.exists());
1739    }
1740
1741    #[test]
1742    fn budget_backstop_deletes_oldest_dead_files_but_not_live_files() {
1743        let temp = TempDir::new().unwrap();
1744        let oldest = temp.path().join("aft-4294967294.log");
1745        let newest = temp.path().join("aft-4294967293.log");
1746        let live = temp.path().join(format!("aft-{}.log", std::process::id()));
1747        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1748        fs::write(&oldest, "oldest").unwrap();
1749        fs::write(&newest, "newest").unwrap();
1750        fs::write(&live, "live-live").unwrap();
1751        set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
1752        set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
1753        set_file_mtime(&live, FileTime::from_unix_time(1, 0)).unwrap();
1754
1755        let summary = sweep_logs(
1756            temp.path(),
1757            now,
1758            Duration::from_secs(365 * 24 * 60 * 60),
1759            15,
1760        )
1761        .unwrap();
1762
1763        assert_eq!(summary.removed_files, 1);
1764        assert!(!oldest.exists());
1765        assert!(newest.exists());
1766        assert!(live.exists());
1767    }
1768
1769    #[test]
1770    fn tool_call_summary_uses_bounded_window_median_and_maxima() {
1771        let samples = VecDeque::from([
1772            ToolCallPerfSample {
1773                total_ms: 9,
1774                queue_ms: 5,
1775            },
1776            ToolCallPerfSample {
1777                total_ms: 3,
1778                queue_ms: 1,
1779            },
1780            ToolCallPerfSample {
1781                total_ms: 7,
1782                queue_ms: 2,
1783            },
1784            ToolCallPerfSample {
1785                total_ms: 5,
1786                queue_ms: 4,
1787            },
1788        ]);
1789
1790        let summary = summarize_tool_calls(&samples);
1791
1792        assert_eq!(summary.window, 4);
1793        assert_eq!(summary.p50_total_ms, 5);
1794        assert_eq!(summary.max_total_ms, 9);
1795        assert_eq!(summary.p50_queue_ms, 2);
1796        assert_eq!(summary.max_queue_ms, 5);
1797    }
1798
1799    #[test]
1800    fn tool_call_tick_labels_interval_count_and_rolling_window() {
1801        let samples = VecDeque::from([ToolCallPerfSample {
1802            total_ms: 3_000,
1803            queue_ms: 2_900,
1804        }]);
1805        let summary = summarize_tool_calls(&samples);
1806
1807        assert_eq!(
1808            format_tool_call_summary(1, summary),
1809            "toolcall={new:1,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1810        );
1811        assert_eq!(
1812            format_tool_call_summary(0, summary),
1813            "toolcall={new:0,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1814        );
1815    }
1816
1817    fn index_event_matches_grammar(line: &str) -> bool {
1818        let Some(rest) = line.strip_prefix("index_event ") else {
1819            return false;
1820        };
1821        if rest.is_empty() {
1822            return false;
1823        }
1824        rest.split(' ').all(|token| {
1825            let Some((key, value)) = token.split_once('=') else {
1826                return false;
1827            };
1828            !key.is_empty()
1829                && key.chars().all(|c| c.is_ascii_lowercase() || c == '_')
1830                && !value.is_empty()
1831                && !value.contains(' ')
1832                && !value.contains('=')
1833        })
1834    }
1835
1836    fn index_event_fields(line: &str) -> BTreeMap<String, String> {
1837        let mut fields = BTreeMap::new();
1838        for token in line.split_whitespace().skip(1) {
1839            let Some((key, value)) = token.split_once('=') else {
1840                continue;
1841            };
1842            fields.insert(key.to_string(), value.to_string());
1843        }
1844        fields
1845    }
1846
1847    fn assert_index_event_grammar(lines: &[String]) {
1848        for line in lines {
1849            assert!(
1850                index_event_matches_grammar(line),
1851                "index_event line failed grammar: {line}"
1852            );
1853        }
1854    }
1855
1856    fn event_matches(line: &str, plane: &str, root: &str, key: &str) -> bool {
1857        let fields = index_event_fields(line);
1858        fields.get("plane").map(String::as_str) == Some(plane)
1859            && fields.get("root").map(String::as_str) == Some(root)
1860            && fields.get("key").map(String::as_str) == Some(key)
1861    }
1862
1863    fn assert_lifecycle_sequence(
1864        lines: &[String],
1865        plane: &str,
1866        root: &str,
1867        key: &str,
1868        require_progress: bool,
1869    ) {
1870        let events: Vec<_> = lines
1871            .iter()
1872            .filter(|line| event_matches(line, plane, root, key))
1873            .cloned()
1874            .collect();
1875        assert_index_event_grammar(&events);
1876        let kinds: Vec<_> = events
1877            .iter()
1878            .filter_map(|line| index_event_fields(line).get("kind").cloned())
1879            .filter(|kind| {
1880                matches!(
1881                    kind.as_str(),
1882                    "build_started" | "build_progress" | "build_ready"
1883                )
1884            })
1885            .collect();
1886        assert!(
1887            kinds.first().is_some_and(|kind| kind == "build_started"),
1888            "expected build_started first, got {kinds:?} from {events:?}"
1889        );
1890        if require_progress {
1891            assert!(
1892                kinds.iter().any(|kind| kind == "build_progress"),
1893                "expected build_progress in {kinds:?} from {events:?}"
1894            );
1895        }
1896        assert!(
1897            kinds.last().is_some_and(|kind| kind == "build_ready"),
1898            "expected build_ready last, got {kinds:?} from {events:?}"
1899        );
1900        let build_ids: Vec<_> = events
1901            .iter()
1902            .filter_map(|line| index_event_fields(line).get("build_id").cloned())
1903            .collect();
1904        assert!(!build_ids.is_empty());
1905        assert!(
1906            build_ids.iter().all(|id| id == &build_ids[0]),
1907            "build_id drifted across events: {build_ids:?}"
1908        );
1909        for line in &events {
1910            let fields = index_event_fields(line);
1911            assert_eq!(fields.get("root").map(String::as_str), Some(root), "{line}");
1912            assert_eq!(fields.get("key").map(String::as_str), Some(key), "{line}");
1913        }
1914    }
1915
1916    fn tiny_project(name: &str, file_name: &str, contents: &str) -> (tempfile::TempDir, PathBuf) {
1917        let temp = tempfile::tempdir().expect("tempdir");
1918        let root = temp.path().join(name);
1919        std::fs::create_dir_all(&root).expect("create project");
1920        std::fs::write(root.join(file_name), contents).expect("write fixture");
1921        let root = std::fs::canonicalize(&root).unwrap_or(root);
1922        (temp, root)
1923    }
1924
1925    #[test]
1926    fn watcher_rescan_event_reports_reason_cost_rss_and_interval_count() {
1927        let (_, lines) = capture_index_events(|| {
1928            log_watcher_rescan(
1929                Path::new("/tmp/watcher root"),
1930                crate::watcher_filter::RescanReason::KernelDropped,
1931                42,
1932                Some(-4096),
1933                137,
1934            );
1935        });
1936        assert_index_event_grammar(&lines);
1937        assert_eq!(lines.len(), 1);
1938        let fields = index_event_fields(&lines[0]);
1939        assert_eq!(
1940            fields.get("kind").map(String::as_str),
1941            Some("watcher_rescan")
1942        );
1943        assert_eq!(fields.get("plane").map(String::as_str), Some("watcher"));
1944        assert_eq!(
1945            fields.get("root").map(String::as_str),
1946            Some("/tmp/watcher_root")
1947        );
1948        assert_eq!(
1949            fields.get("reason").map(String::as_str),
1950            Some("kernel_dropped")
1951        );
1952        assert_eq!(fields.get("cost_ms").map(String::as_str), Some("42"));
1953        assert_eq!(
1954            fields.get("rss_delta_bytes").map(String::as_str),
1955            Some("-4096")
1956        );
1957        assert_eq!(
1958            fields.get("raw_events_since_last").map(String::as_str),
1959            Some("137")
1960        );
1961    }
1962
1963    #[test]
1964    fn index_event_grammar_rejects_spaces_and_equals_in_values() {
1965        let (result, lines) = capture_index_events(|| {
1966            log_index_event(
1967                IndexEvent::new(
1968                    IndexEventKind::BuildStarted,
1969                    IndexPlane::Search,
1970                    "b-1-1",
1971                    "/tmp/root",
1972                    "abc123",
1973                )
1974                .field("stage", "streaming"),
1975            );
1976        });
1977        let _ = result;
1978        assert_index_event_grammar(&lines);
1979        // The capture slot is process-wide: a parallel lib test that builds an
1980        // index can emit into the same window. Only this test's own event
1981        // (unique build_id) is the subject here.
1982        let own: Vec<&String> = lines
1983            .iter()
1984            .filter(|line| {
1985                index_event_fields(line).get("build_id").map(String::as_str) == Some("b-1-1")
1986            })
1987            .collect();
1988        assert_eq!(own.len(), 1, "{lines:?}");
1989    }
1990
1991    #[test]
1992    fn index_event_keeps_required_fields_for_long_root() {
1993        let root = format!("/{}", "a".repeat(399));
1994        let (_, lines) = capture_index_events(|| {
1995            log_index_event(IndexEvent::new(
1996                IndexEventKind::BuildStarted,
1997                IndexPlane::Search,
1998                "b-9-9",
1999                &root,
2000                "keepkey",
2001            ));
2002        });
2003        assert_eq!(lines.len(), 1, "{lines:?}");
2004        let fields = index_event_fields(&lines[0]);
2005        assert_eq!(fields.get("build_id").map(String::as_str), Some("b-9-9"));
2006        assert_eq!(fields.get("key").map(String::as_str), Some("keepkey"));
2007        assert!(fields.get("root").is_some(), "{lines:?}");
2008        assert_index_event_grammar(&lines);
2009    }
2010
2011    #[test]
2012    fn first_query_fires_once_per_build_id() {
2013        let root = PathBuf::from("/tmp/first-query-root");
2014        let key = "firstquerykey";
2015        let build_id = "b-1-99";
2016        let (_, lines) = capture_index_events(|| {
2017            log_index_event(IndexEvent::new(
2018                IndexEventKind::BuildReady,
2019                IndexPlane::Search,
2020                build_id,
2021                &root,
2022                key,
2023            ));
2024            assert!(claim_first_query(
2025                IndexPlane::Search,
2026                &root,
2027                "grep",
2028                1,
2029                2,
2030                "ok"
2031            ));
2032            assert!(!claim_first_query(
2033                IndexPlane::Search,
2034                &root,
2035                "grep",
2036                1,
2037                2,
2038                "ok"
2039            ));
2040        });
2041        let first_query: Vec<_> = lines
2042            .iter()
2043            .filter(|line| line.contains("kind=first_query"))
2044            .collect();
2045        assert_eq!(first_query.len(), 1, "{lines:?}");
2046        let fields = index_event_fields(first_query[0]);
2047        assert_eq!(fields.get("build_id").map(String::as_str), Some(build_id));
2048        assert_eq!(fields.get("tool").map(String::as_str), Some("grep"));
2049    }
2050
2051    #[test]
2052    fn callgraph_cold_build_emits_started_progress_ready() {
2053        let (_temp, root) = tiny_project("cg", "lib.rs", "pub fn marker() {}\n");
2054        let key = crate::search_index::artifact_cache_key(&root);
2055        let callgraph_dir = _temp.path().join("callgraph").join(&key);
2056        crate::root_cache::configure_artifact_access(&root, &key, false);
2057        let source = root.join("lib.rs");
2058        let (built, lines) = capture_index_events(|| {
2059            crate::callgraph_store::CallGraphStore::cold_build_with_lease(
2060                callgraph_dir,
2061                root.clone(),
2062                std::slice::from_ref(&source),
2063            )
2064        });
2065        built.expect("callgraph cold build");
2066        assert_lifecycle_sequence(
2067            &lines,
2068            "callgraph",
2069            &normalize_index_root(&root),
2070            &key,
2071            true,
2072        );
2073    }
2074
2075    #[test]
2076    fn search_cold_build_emits_started_progress_ready() {
2077        let (_temp, root) = tiny_project("search", "file.txt", "alpha token\n");
2078        let key = crate::search_index::artifact_cache_key(&root);
2079        let (index, lines) = capture_index_events(|| {
2080            crate::search_index::SearchIndex::build_with_limit(&root, 1_000_000)
2081        });
2082        assert!(index.ready);
2083        assert_lifecycle_sequence(&lines, "search", &normalize_index_root(&root), &key, true);
2084    }
2085
2086    #[test]
2087    fn semantic_cold_build_emits_started_progress_ready() {
2088        let (_temp, root) = tiny_project("sem", "lib.rs", "pub fn hello() {}\n");
2089        let key = crate::search_index::artifact_cache_key(&root);
2090        let files = vec![root.join("lib.rs")];
2091        let mut embed = |texts: Vec<String>| {
2092            Ok(texts
2093                .into_iter()
2094                .map(|_| vec![0.01_f32; 384])
2095                .collect::<Vec<_>>())
2096        };
2097        let (built, lines) = capture_index_events(|| {
2098            crate::semantic_index::SemanticIndex::build(&root, &files, &mut embed, 8)
2099        });
2100        built.expect("semantic build");
2101        assert_lifecycle_sequence(&lines, "semantic", &normalize_index_root(&root), &key, true);
2102    }
2103
2104    #[test]
2105    fn tier2_category_emits_started_ready() {
2106        let (_temp, root) = tiny_project("t2", "mod0.ts", "export function f0() { return 0; }\n");
2107        let key = crate::search_index::artifact_cache_key(&root);
2108        crate::root_cache::configure_artifact_access(&root, &key, false);
2109        let inspect_dir = _temp.path().join("inspect");
2110        let manager = std::sync::Arc::new(crate::inspect::InspectManager::new());
2111        let snapshot = crate::inspect::InspectSnapshot::new(
2112            root.clone(),
2113            inspect_dir,
2114            std::sync::Arc::new(crate::config::Config {
2115                project_root: Some(root.clone()),
2116                ..crate::config::Config::default()
2117            }),
2118            std::sync::Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new())),
2119        );
2120        let (outcome, lines) = capture_index_events(|| {
2121            manager.tier2_run_with_reuse_blocking_fresh(
2122                snapshot,
2123                crate::inspect::InspectCategory::Complexity,
2124                crate::inspect::JobScope::for_project(root.clone()),
2125            )
2126        });
2127        assert!(outcome.payload().is_some(), "{outcome:?}");
2128        assert_lifecycle_sequence(&lines, "tier2", &normalize_index_root(&root), &key, false);
2129        assert!(
2130            !lines.iter().any(|line| {
2131                event_matches(line, "tier2", &normalize_index_root(&root), &key)
2132                    && line.contains("kind=build_progress")
2133            }),
2134            "tier2 must not emit synthetic build_progress: {lines:?}"
2135        );
2136    }
2137
2138    #[test]
2139    fn superseded_cold_build_keeps_build_id_and_skips_ready() {
2140        let (_temp, root) = tiny_project("sup", "lib.rs", "pub fn marker() {}\n");
2141        let key = crate::search_index::artifact_cache_key(&root);
2142        let callgraph_dir = _temp.path().join("callgraph").join(&key);
2143        crate::root_cache::configure_artifact_access(&root, &key, false);
2144        let source = root.join("lib.rs");
2145        let epoch = crate::root_cache::ArtifactPublishEpoch::default();
2146        let stale_epoch = epoch.current();
2147        epoch.next();
2148        let (failed, lines) = capture_index_events(|| {
2149            crate::callgraph_store::with_publish_epoch(epoch, stale_epoch, || {
2150                crate::callgraph_store::CallGraphStore::cold_build_with_lease(
2151                    callgraph_dir,
2152                    root.clone(),
2153                    std::slice::from_ref(&source),
2154                )
2155            })
2156        });
2157        assert!(matches!(
2158            failed,
2159            Err(crate::callgraph_store::CallGraphStoreError::Superseded)
2160        ));
2161        let root_s = normalize_index_root(&root);
2162        let events: Vec<_> = lines
2163            .iter()
2164            .filter(|line| event_matches(line, "callgraph", &root_s, &key))
2165            .cloned()
2166            .collect();
2167        assert_index_event_grammar(&events);
2168        let kinds: Vec<_> = events
2169            .iter()
2170            .filter_map(|line| index_event_fields(line).get("kind").cloned())
2171            .collect();
2172        assert!(
2173            kinds.contains(&"build_started".to_string()),
2174            "{kinds:?} {events:?}"
2175        );
2176        assert!(
2177            kinds.contains(&"build_superseded".to_string()),
2178            "{kinds:?} {events:?}"
2179        );
2180        assert!(
2181            !kinds.contains(&"build_ready".to_string()),
2182            "superseded build must not emit build_ready: {kinds:?}"
2183        );
2184        let started_id = events
2185            .iter()
2186            .find(|line| line.contains("kind=build_started"))
2187            .and_then(|line| index_event_fields(line).get("build_id").cloned());
2188        let superseded_id = events
2189            .iter()
2190            .find(|line| line.contains("kind=build_superseded"))
2191            .and_then(|line| index_event_fields(line).get("build_id").cloned());
2192        assert_eq!(started_id, superseded_id);
2193        for line in &events {
2194            let fields = index_event_fields(line);
2195            assert_eq!(
2196                fields.get("root").map(String::as_str),
2197                Some(root_s.as_str())
2198            );
2199            assert_eq!(fields.get("key").map(String::as_str), Some(key.as_str()));
2200        }
2201    }
2202}