1use 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
20const LOG_FILE_BYTES: u64 = 32 * 1024 * 1024;
22const LOG_GENERATIONS: usize = 1;
24const ROTATION_CHECK_EVERY: u64 = 1;
26const LOG_CHANNEL_CAPACITY: usize = 4096;
27const DEAD_PROCESS_LOG_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
29const LOG_DIRECTORY_BUDGET_BYTES: u64 = 200 * 1024 * 1024;
31const 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;
37const INDEX_EVENT_MAX_BYTES: usize = 300;
39
40#[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#[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#[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#[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
233pub(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
239pub(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
252pub(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#[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
265pub(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 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
304pub(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
334fn 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
406pub(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
419pub(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
431pub(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
441pub(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
477pub(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
497pub(crate) fn reset_tool_call_wait() {
499 TOOL_CALL_WAIT.with(|slot| *slot.borrow_mut() = ToolCallWaitState::default());
500}
501
502pub(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
540pub 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 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
607fn 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 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 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
880fn 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 "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 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 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
990pub 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
1093pub 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
1116pub fn note_watcher_events(count: usize) {
1118 PERF.watcher_ingested
1119 .fetch_add(count as u64, Ordering::Relaxed);
1120}
1121
1122pub fn note_drain_paths(count: usize) {
1124 PERF.watcher_paths
1125 .fetch_add(count as u64, Ordering::Relaxed);
1126}
1127
1128pub fn note_watcher_overflow() {
1130 PERF.watcher_dropped.fetch_add(1, Ordering::Relaxed);
1131}
1132
1133pub fn note_drain_slice() {
1135 PERF.drain_slices.fetch_add(1, Ordering::Relaxed);
1136}
1137
1138pub 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
1148pub 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
1157pub fn note_callgraph_invalidations(files: usize) {
1159 PERF.callgraph_invalidations
1160 .fetch_add(files as u64, Ordering::Relaxed);
1161}
1162
1163pub 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
1243pub 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 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}