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