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
194#[derive(Default)]
195struct IndexBuildStartSignals {
196 sequence: u64,
197 last_by_root_plane: HashMap<(String, IndexPlane), u64>,
198 in_flight_by_root_plane: HashMap<(String, IndexPlane), u64>,
199 waiters: HashMap<(String, IndexPlane), Vec<(u64, crossbeam_channel::Sender<()>)>>,
200}
201
202struct ToolCallWaitState {
203 waiting_on: WaitingOn,
204 waiting_on_build_id: Option<String>,
205 wait_ms: u64,
206 queue_ms: u64,
207}
208
209impl Default for ToolCallWaitState {
210 fn default() -> Self {
211 Self {
212 waiting_on: WaitingOn::None,
213 waiting_on_build_id: None,
214 wait_ms: 0,
215 queue_ms: 0,
216 }
217 }
218}
219
220static INDEX_BUILD_COUNTER: AtomicU64 = AtomicU64::new(1);
221static INDEX_LIFECYCLE: LazyLock<IndexLifecycle> = LazyLock::new(IndexLifecycle::default);
222static INDEX_BUILD_START_SIGNALS: LazyLock<Mutex<IndexBuildStartSignals>> =
223 LazyLock::new(|| Mutex::new(IndexBuildStartSignals::default()));
224
225thread_local! {
226 static CURRENT_INDEX_BUILD: RefCell<Option<IndexBuildScope>> = const { RefCell::new(None) };
227 static TOOL_CALL_WAIT: RefCell<ToolCallWaitState> = const {
228 RefCell::new(ToolCallWaitState {
229 waiting_on: WaitingOn::None,
230 waiting_on_build_id: None,
231 wait_ms: 0,
232 queue_ms: 0,
233 })
234 };
235}
236
237#[cfg(test)]
238static INDEX_EVENT_CAPTURE: LazyLock<Mutex<Option<Vec<String>>>> =
239 LazyLock::new(|| Mutex::new(None));
240#[cfg(test)]
241static INDEX_EVENT_CAPTURE_LOCK: Mutex<()> = Mutex::new(());
242
243pub(crate) fn mint_index_build_id() -> String {
245 let n = INDEX_BUILD_COUNTER.fetch_add(1, Ordering::Relaxed);
246 format!("b-{}-{n}", std::process::id())
247}
248
249pub(crate) struct IndexBuildGuard {
251 previous: Option<IndexBuildScope>,
252}
253
254impl Drop for IndexBuildGuard {
255 fn drop(&mut self) {
256 CURRENT_INDEX_BUILD.with(|slot| {
257 *slot.borrow_mut() = self.previous.take();
258 });
259 }
260}
261
262pub(crate) fn install_index_build(scope: IndexBuildScope) -> IndexBuildGuard {
264 let previous = CURRENT_INDEX_BUILD.with(|slot| slot.replace(Some(scope)));
265 IndexBuildGuard { previous }
266}
267
268#[allow(dead_code)]
270pub(crate) fn with_index_build<R>(scope: IndexBuildScope, f: impl FnOnce() -> R) -> R {
271 let _guard = install_index_build(scope);
272 f()
273}
274
275pub(crate) struct IndexBuildFailureGuard {
277 armed: bool,
278}
279
280impl IndexBuildFailureGuard {
281 pub(crate) fn new() -> Self {
282 Self { armed: true }
283 }
284
285 pub(crate) fn disarm(&mut self) {
286 self.armed = false;
287 }
288}
289
290impl Drop for IndexBuildFailureGuard {
291 fn drop(&mut self) {
292 if !self.armed {
293 return;
294 }
295 let Some(scope) = current_index_build() else {
296 return;
297 };
298 if in_flight_build_id(scope.plane, &scope.root).as_deref() != Some(scope.build_id.as_str())
300 {
301 return;
302 }
303 log_current_index_event(
304 IndexEventKind::BuildFailed,
305 &[("reason", "error".to_string())],
306 );
307 }
308}
309
310pub(crate) fn current_index_build() -> Option<IndexBuildScope> {
311 CURRENT_INDEX_BUILD.with(|slot| slot.borrow().clone())
312}
313
314pub(crate) fn normalize_index_root(root: &Path) -> String {
316 root.to_string_lossy().replace('\\', "/")
317}
318
319fn sanitize_index_value(value: &str) -> String {
320 let mut out = String::with_capacity(value.len().max(1));
321 for ch in value.chars() {
322 match ch {
323 ' ' | '\t' | '\n' | '\r' | '=' => out.push('_'),
324 '\\' => out.push('/'),
325 c if c.is_ascii_graphic() || c == '/' => out.push(c),
326 _ => out.push('_'),
327 }
328 }
329 if out.is_empty() {
330 out.push('-');
331 }
332 out
333}
334
335fn push_index_field(line: &mut String, key: &str, value: &str) {
336 let sanitized = sanitize_index_value(value);
337 let addition = format!(" {key}={sanitized}");
338 if line.len() + addition.len() > INDEX_EVENT_MAX_BYTES {
339 return;
340 }
341 line.push_str(&addition);
342}
343
344fn left_truncate_root(root: &str, max_bytes: usize) -> String {
346 const MARKER: &str = "...";
347 if root.len() <= max_bytes {
348 return root.to_string();
349 }
350 let max_bytes = max_bytes.max(MARKER.len());
351 let keep = max_bytes.saturating_sub(MARKER.len());
352 let mut start = root.len().saturating_sub(keep);
353 while start < root.len() && !root.is_char_boundary(start) {
354 start += 1;
355 }
356 format!("{MARKER}{}", &root[start..])
357}
358
359fn format_index_event_line(event: &IndexEvent) -> String {
360 let kind = sanitize_index_value(event.kind.as_str());
361 let plane = sanitize_index_value(event.plane.as_str());
362 let build_id = sanitize_index_value(&event.build_id);
363 let key = sanitize_index_value(&event.key);
364 let mut root = sanitize_index_value(&normalize_index_root(&event.root));
365 let prefix = format!("index_event kind={kind} plane={plane} build_id={build_id} root=");
366 let key_field = format!(" key={key}");
367 let root_budget = INDEX_EVENT_MAX_BYTES
368 .saturating_sub(prefix.len())
369 .saturating_sub(key_field.len());
370 if root.len() > root_budget {
371 root = left_truncate_root(&root, root_budget);
372 }
373 let mut line = format!("{prefix}{root}{key_field}");
374 for (key, value) in &event.extra {
375 push_index_field(&mut line, key, value);
376 }
377 line
378}
379
380fn root_plane_key(root: &Path, plane: IndexPlane) -> (String, IndexPlane) {
381 (normalize_index_root(root), plane)
382}
383
384fn remember_in_flight(event: &IndexEvent) {
385 let key = root_plane_key(&event.root, event.plane);
386 if event.kind == IndexEventKind::BuildStarted {
387 if let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() {
388 signals.sequence = signals.sequence.wrapping_add(1);
389 let sequence = signals.sequence;
390 signals.last_by_root_plane.insert(key.clone(), sequence);
391 signals
392 .in_flight_by_root_plane
393 .insert(key.clone(), sequence);
394 if let Some(waiters) = signals.waiters.remove(&key) {
395 for (baseline, sender) in waiters {
396 if sequence > baseline {
397 let _ = sender.send(());
398 }
399 }
400 }
401 }
402 if let Ok(mut in_flight) = INDEX_LIFECYCLE.in_flight.lock() {
403 in_flight.insert(
404 key,
405 InFlightBuild {
406 build_id: event.build_id.clone(),
407 },
408 );
409 }
410 return;
411 }
412 if event.kind.is_terminal() {
413 if let Ok(mut in_flight) = INDEX_LIFECYCLE.in_flight.lock() {
414 in_flight.remove(&key);
415 }
416 if let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() {
417 signals.in_flight_by_root_plane.remove(&key);
418 }
419 release_index_build_start_waiters(event.plane, &event.root);
420 }
421 if event.kind == IndexEventKind::BuildReady {
422 if let Ok(mut unclaimed) = INDEX_LIFECYCLE.unclaimed.lock() {
423 unclaimed.insert(
424 key,
425 UnclaimedReady {
426 build_id: event.build_id.clone(),
427 key: event.key.clone(),
428 ready_at: Instant::now(),
429 },
430 );
431 }
432 }
433}
434
435fn emit_index_event_line(line: String) {
436 #[cfg(test)]
437 if let Ok(mut slot) = INDEX_EVENT_CAPTURE.lock() {
438 if let Some(events) = slot.as_mut() {
439 events.push(line.clone());
440 }
441 }
442 crate::slog_info!("{}", line);
443}
444
445pub(crate) fn log_index_event(event: IndexEvent) {
447 remember_in_flight(&event);
448 emit_index_event_line(format_index_event_line(&event));
449}
450
451pub(crate) fn log_watcher_rescan(
452 root: &Path,
453 reason: crate::watcher_filter::RescanReason,
454 cost_ms: u64,
455 rss_delta_bytes: Option<i64>,
456 raw_events_since_last: u64,
457) {
458 let reason = sanitize_index_value(reason.as_str());
459 let rss_delta = rss_delta_bytes
460 .map(|delta| delta.to_string())
461 .unwrap_or_else(|| "unavailable".to_string());
462 let prefix = "index_event kind=watcher_rescan plane=watcher root=";
463 let suffix = format!(
464 " reason={reason} cost_ms={cost_ms} rss_delta_bytes={rss_delta} raw_events_since_last={raw_events_since_last}"
465 );
466 let mut root = sanitize_index_value(&normalize_index_root(root));
467 let root_budget = INDEX_EVENT_MAX_BYTES
468 .saturating_sub(prefix.len())
469 .saturating_sub(suffix.len());
470 if root.len() > root_budget {
471 root = left_truncate_root(&root, root_budget);
472 }
473 emit_index_event_line(format!("{prefix}{root}{suffix}"));
474}
475
476pub(crate) fn log_current_index_event(kind: IndexEventKind, extra: &[(&'static str, String)]) {
478 let Some(scope) = current_index_build() else {
479 return;
480 };
481 let mut event = IndexEvent::from_scope(kind, &scope);
482 for (key, value) in extra {
483 event.extra.push((key, value.clone()));
484 }
485 log_index_event(event);
486}
487
488pub(crate) fn index_build_start_sequence(plane: IndexPlane, root: &Path) -> u64 {
490 INDEX_BUILD_START_SIGNALS
491 .lock()
492 .ok()
493 .and_then(|signals| {
494 signals
495 .last_by_root_plane
496 .get(&root_plane_key(root, plane))
497 .copied()
498 })
499 .unwrap_or(0)
500}
501
502pub(crate) fn signal_after_index_build_start(
504 plane: IndexPlane,
505 root: &Path,
506 baseline: u64,
507 sender: crossbeam_channel::Sender<()>,
508) {
509 let Ok(mut signals) = INDEX_BUILD_START_SIGNALS.lock() else {
510 let _ = sender.send(());
511 return;
512 };
513 let key = root_plane_key(root, plane);
514 if signals.in_flight_by_root_plane.contains_key(&key)
515 || signals
516 .last_by_root_plane
517 .get(&key)
518 .is_some_and(|sequence| *sequence > baseline)
519 {
520 let _ = sender.send(());
521 } else {
522 signals
523 .waiters
524 .entry(key)
525 .or_default()
526 .push((baseline, sender));
527 }
528}
529
530pub(crate) fn release_index_build_start_waiters(plane: IndexPlane, root: &Path) {
532 let waiters = INDEX_BUILD_START_SIGNALS
533 .lock()
534 .ok()
535 .and_then(|mut signals| signals.waiters.remove(&root_plane_key(root, plane)))
536 .unwrap_or_default();
537 for (_, sender) in waiters {
538 let _ = sender.send(());
539 }
540}
541
542pub(crate) fn in_flight_build_id(plane: IndexPlane, root: &Path) -> Option<String> {
544 INDEX_LIFECYCLE
545 .in_flight
546 .lock()
547 .ok()?
548 .get(&root_plane_key(root, plane))
549 .map(|build| build.build_id.clone())
550}
551
552pub(crate) fn claim_first_query(
554 plane: IndexPlane,
555 root: &Path,
556 tool: &str,
557 queue_ms: u64,
558 service_ms: u64,
559 status: &str,
560) -> bool {
561 let slot = {
562 let Ok(mut unclaimed) = INDEX_LIFECYCLE.unclaimed.lock() else {
563 return false;
564 };
565 unclaimed.remove(&root_plane_key(root, plane))
566 };
567 let Some(slot) = slot else {
568 return false;
569 };
570 let ready_to_first_query_ms = slot.ready_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
571 log_index_event(
572 IndexEvent::new(
573 IndexEventKind::FirstQuery,
574 plane,
575 slot.build_id,
576 root,
577 slot.key,
578 )
579 .field("tool", tool)
580 .field("queue_ms", queue_ms)
581 .field("service_ms", service_ms)
582 .field("status", status)
583 .field("ready_to_first_query_ms", ready_to_first_query_ms),
584 );
585 true
586}
587
588pub(crate) fn note_index_query(
590 plane: IndexPlane,
591 root: &Path,
592 tool: &str,
593 service_ms: u64,
594 status: &str,
595) {
596 match status {
597 "building" | "rebuilding" => {
598 let build_id = in_flight_build_id(plane, root);
599 note_tool_call_wait(WaitingOn::Build, build_id.as_deref(), 0);
600 }
601 _ => {
602 let queue_ms = TOOL_CALL_WAIT.with(|slot| slot.borrow().queue_ms);
603 claim_first_query(plane, root, tool, queue_ms, service_ms, status);
604 }
605 }
606}
607
608pub(crate) fn reset_tool_call_wait() {
610 TOOL_CALL_WAIT.with(|slot| *slot.borrow_mut() = ToolCallWaitState::default());
611}
612
613pub(crate) fn note_tool_call_wait(waiting_on: WaitingOn, build_id: Option<&str>, wait_ms: u64) {
615 TOOL_CALL_WAIT.with(|slot| {
616 let mut state = slot.borrow_mut();
617 state.waiting_on = waiting_on;
618 state.waiting_on_build_id = build_id.map(str::to_string);
619 state.wait_ms = state.wait_ms.saturating_add(wait_ms);
620 });
621}
622
623pub(crate) fn note_tool_call_queue_ms(queue_ms: u64) {
624 TOOL_CALL_WAIT.with(|slot| slot.borrow_mut().queue_ms = queue_ms);
625}
626
627pub(crate) fn take_tool_call_wait() -> (WaitingOn, Option<String>, u64) {
628 TOOL_CALL_WAIT.with(|slot| {
629 let state = std::mem::take(&mut *slot.borrow_mut());
630 (state.waiting_on, state.waiting_on_build_id, state.wait_ms)
631 })
632}
633
634#[cfg(test)]
635pub(crate) fn capture_index_events<R>(f: impl FnOnce() -> R) -> (R, Vec<String>) {
636 let _serial = INDEX_EVENT_CAPTURE_LOCK
637 .lock()
638 .unwrap_or_else(std::sync::PoisonError::into_inner);
639 if let Ok(mut slot) = INDEX_EVENT_CAPTURE.lock() {
640 *slot = Some(Vec::new());
641 }
642 let result = f();
643 let events = INDEX_EVENT_CAPTURE
644 .lock()
645 .ok()
646 .and_then(|mut slot| slot.take())
647 .unwrap_or_default();
648 (result, events)
649}
650
651pub fn init() {
653 let storage_root = crate::bash_background::storage_dir(None);
654 let logs_dir = storage_root.join("logs");
655 let file_name = format!("aft-{}.log", std::process::id());
656 let file_path = logs_dir.join(file_name);
657 let mut startup_sweep = None;
658
659 let file_tx = match prepare_file_sink(&logs_dir, &file_path) {
660 Ok((sink, summary)) => {
661 startup_sweep = Some(summary);
662 let (tx, rx) = mpsc::sync_channel(LOG_CHANNEL_CAPACITY);
663 thread::Builder::new()
664 .name("aft-log-writer".to_string())
665 .spawn(move || run_file_writer(sink, rx))
666 .map(|_| {
667 if let Ok(mut control) = FILE_CONTROL.lock() {
668 control.tx = Some(tx.clone());
669 control.storage_root = Some(storage_root.clone());
670 }
671 Some(tx)
672 })
673 .unwrap_or_else(|error| {
674 write_stderr_once(&format!(
675 "[aft] durable log disabled: cannot start writer thread: {error}\n"
676 ));
677 None
678 })
679 }
680 Err(error) => {
681 write_stderr_once(&format!(
682 "[aft] durable log disabled for {}: {error}\n",
683 file_path.display()
684 ));
685 None
686 }
687 };
688
689 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
690 .target(env_logger::Target::Pipe(Box::new(TeeWriter { file_tx })))
691 .format(|buf, record| {
692 let prefix = if record.target().starts_with("aft::lsp")
693 || record.target().starts_with("aft_lsp")
694 {
695 "[aft-lsp]"
696 } else {
697 "[aft]"
698 };
699 writeln!(
704 buf,
705 "{} {} {}",
706 format_utc_timestamp(),
707 prefix,
708 record.args()
709 )
710 })
711 .init();
712
713 if let Some(summary) = startup_sweep {
714 log_sweep_summary(summary);
715 }
716}
717
718fn format_utc_timestamp() -> String {
723 let secs = SystemTime::now()
724 .duration_since(SystemTime::UNIX_EPOCH)
725 .map(|d| d.as_secs())
726 .unwrap_or(0);
727 format_epoch_secs(secs)
728}
729
730fn format_epoch_secs(secs: u64) -> String {
731 let (days, rem) = (secs / 86_400, secs % 86_400);
732 let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
733 let z = days as i64 + 719_468;
737 let era = z.div_euclid(146_097);
738 let doe = z.rem_euclid(146_097);
739 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
740 let y = yoe + era * 400;
741 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
742 let mp = (5 * doy + 2) / 153;
743 let d = doy - (153 * mp + 2) / 5 + 1;
744 let m = if mp < 10 { mp + 3 } else { mp - 9 };
745 let y = if m <= 2 { y + 1 } else { y };
746 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
747}
748
749fn prepare_file_sink(
750 logs_dir: &Path,
751 file_path: &Path,
752) -> io::Result<(RotatingFile, SweepSummary)> {
753 fs::create_dir_all(logs_dir)?;
754 let summary = sweep_logs(
755 logs_dir,
756 SystemTime::now(),
757 DEAD_PROCESS_LOG_MAX_AGE,
758 LOG_DIRECTORY_BUDGET_BYTES,
759 )?;
760 mark_log_sweep_ran();
761 let sink = RotatingFile::open(
762 file_path.to_path_buf(),
763 LOG_FILE_BYTES,
764 LOG_GENERATIONS,
765 ROTATION_CHECK_EVERY,
766 )?;
767 Ok((sink, summary))
768}
769
770enum LogMessage {
771 Write(Vec<u8>),
772 Reconfigure(PathBuf),
773}
774
775#[derive(Default)]
776struct FileControl {
777 tx: Option<SyncSender<LogMessage>>,
778 storage_root: Option<PathBuf>,
779}
780
781static FILE_CONTROL: LazyLock<Mutex<FileControl>> =
782 LazyLock::new(|| Mutex::new(FileControl::default()));
783
784struct TeeWriter {
785 file_tx: Option<SyncSender<LogMessage>>,
786}
787
788impl Write for TeeWriter {
789 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
790 io::stderr().write_all(buf)?;
791 if let Some(tx) = self.file_tx.as_ref() {
792 match tx.try_send(LogMessage::Write(buf.to_vec())) {
793 Ok(()) => {}
794 Err(TrySendError::Full(_)) => {
795 PERF.file_lines_dropped.fetch_add(1, Ordering::Relaxed);
796 }
797 Err(TrySendError::Disconnected(_)) => self.file_tx = None,
798 }
799 }
800 Ok(buf.len())
801 }
802
803 fn flush(&mut self) -> io::Result<()> {
804 io::stderr().flush()
805 }
806}
807
808fn run_file_writer(mut sink: RotatingFile, rx: mpsc::Receiver<LogMessage>) {
809 while let Ok(message) = rx.recv() {
810 let mut lines = Vec::new();
811 let mut reconfigure = None;
812 match message {
813 LogMessage::Write(line) => {
814 lines.push(line);
815 while lines.len() < 256 {
816 match rx.try_recv() {
817 Ok(LogMessage::Write(line)) => lines.push(line),
818 Ok(LogMessage::Reconfigure(storage_root)) => {
819 reconfigure = Some(storage_root);
820 break;
821 }
822 Err(_) => break,
823 }
824 }
825 }
826 LogMessage::Reconfigure(storage_root) => reconfigure = Some(storage_root),
827 }
828 if !lines.is_empty() {
829 if let Err(error) = sink.write_batch(&lines) {
830 write_stderr_once(&format!(
831 "[aft] durable log disabled after write failure for {}: {error}\n",
832 sink.path.display()
833 ));
834 break;
835 }
836 }
837 if let Some(storage_root) = reconfigure {
838 let logs_dir = storage_root.join("logs");
839 let path = logs_dir.join(format!("aft-{}.log", std::process::id()));
840 match prepare_file_sink(&logs_dir, &path) {
841 Ok((new_sink, summary)) => {
842 sink = new_sink;
843 log_sweep_summary(summary);
844 }
845 Err(error) => write_stderr_once(&format!(
846 "[aft] durable log could not switch to {}: {error}\n",
847 path.display()
848 )),
849 }
850 }
851 }
852}
853
854fn write_stderr_once(message: &str) {
855 let _ = io::stderr().write_all(message.as_bytes());
856}
857
858struct RotatingFile {
859 path: PathBuf,
860 writer: Option<BufWriter<File>>,
861 size: u64,
862 threshold: u64,
863 generations: usize,
864 check_every: u64,
865 writes_since_check: u64,
866}
867
868impl RotatingFile {
869 fn open(
870 path: PathBuf,
871 threshold: u64,
872 generations: usize,
873 check_every: u64,
874 ) -> io::Result<Self> {
875 let file = OpenOptions::new().create(true).append(true).open(&path)?;
876 let size = file.metadata()?.len();
877 let mut sink = Self {
878 path,
879 writer: Some(BufWriter::new(file)),
880 size,
881 threshold,
882 generations,
883 check_every: check_every.max(1),
884 writes_since_check: 0,
885 };
886 if size > threshold {
887 sink.rotate()?;
888 }
889 Ok(sink)
890 }
891
892 fn write_batch(&mut self, lines: &[Vec<u8>]) -> io::Result<()> {
893 let batch_bytes = lines.iter().map(Vec::len).sum::<usize>() as u64;
894 self.writes_since_check = self.writes_since_check.saturating_add(lines.len() as u64);
895 if self.writes_since_check >= self.check_every
896 && self.size > 0
897 && self.size.saturating_add(batch_bytes) > self.threshold
898 {
899 self.rotate()?;
900 }
901 let writer = self
902 .writer
903 .as_mut()
904 .ok_or_else(|| io::Error::other("log writer unavailable"))?;
905 for line in lines {
906 writer.write_all(line)?;
907 }
908 writer.flush()?;
911 self.size = self.size.saturating_add(batch_bytes);
912 if self.writes_since_check >= self.check_every {
913 self.writes_since_check = 0;
914 }
915 Ok(())
916 }
917
918 fn rotate(&mut self) -> io::Result<()> {
919 if let Some(mut writer) = self.writer.take() {
920 writer.flush()?;
921 }
922 if self.generations > 0 {
923 let oldest = rotated_path(&self.path, self.generations);
924 remove_file_if_present(&oldest)?;
925 for generation in (1..self.generations).rev() {
926 let from = rotated_path(&self.path, generation);
927 let to = rotated_path(&self.path, generation + 1);
928 rename_if_present(&from, &to)?;
929 }
930 rename_if_present(&self.path, &rotated_path(&self.path, 1))?;
931 } else {
932 remove_file_if_present(&self.path)?;
933 }
934 let file = OpenOptions::new()
935 .create(true)
936 .write(true)
937 .truncate(true)
938 .open(&self.path)?;
939 self.writer = Some(BufWriter::new(file));
940 self.size = 0;
941 self.writes_since_check = 0;
942 Ok(())
943 }
944}
945
946fn rotated_path(base: &Path, generation: usize) -> PathBuf {
947 let mut path = base.as_os_str().to_os_string();
948 path.push(format!(".{generation}"));
949 PathBuf::from(path)
950}
951
952fn remove_file_if_present(path: &Path) -> io::Result<()> {
953 match fs::remove_file(path) {
954 Ok(()) => Ok(()),
955 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
956 Err(error) => Err(error),
957 }
958}
959
960fn rename_if_present(from: &Path, to: &Path) -> io::Result<()> {
961 match fs::rename(from, to) {
962 Ok(()) => Ok(()),
963 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
964 Err(error) => Err(error),
965 }
966}
967
968#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
969struct SweepSummary {
970 removed_files: usize,
971 bytes_freed: u64,
972 recycled_pids: usize,
977}
978
979struct ProcessLogFile {
980 path: PathBuf,
981 modified: Option<SystemTime>,
982 bytes: u64,
983 dead: bool,
984 old_enough: bool,
985 removed: bool,
986}
987
988fn log_sweep_summary(summary: SweepSummary) {
989 crate::slog_info!(
990 "log retention sweep: removed_files={} bytes_freed={} recycled_pids={}",
991 summary.removed_files,
992 summary.bytes_freed,
993 summary.recycled_pids
994 );
995}
996
997fn sweep_logs(
1000 dir: &Path,
1001 now: SystemTime,
1002 max_age: Duration,
1003 budget_bytes: u64,
1004) -> io::Result<SweepSummary> {
1005 let mut total_bytes = 0_u64;
1006 let mut process_logs = Vec::new();
1007 let mut live_pids = BTreeMap::new();
1008 let mut recycled_count = 0_usize;
1009 let own_pid = std::process::id();
1010
1011 for entry in fs::read_dir(dir)? {
1012 let entry = match entry {
1013 Ok(entry) => entry,
1014 Err(_) => continue,
1015 };
1016 let metadata = match entry.metadata() {
1017 Ok(metadata) if metadata.is_file() => metadata,
1018 Ok(_) | Err(_) => continue,
1019 };
1020 let bytes = metadata.len();
1021 total_bytes = total_bytes.saturating_add(bytes);
1022 let name = entry.file_name();
1023 let name = name.to_string_lossy();
1024 let pid = match name.as_ref() {
1025 "aft-plugin.log" => continue,
1028 _ => process_log_pid(&name),
1029 };
1030 let Some(pid) = pid else {
1031 continue;
1032 };
1033 let modified = metadata.modified().ok();
1034 let old_enough = modified
1035 .and_then(|modified| now.duration_since(modified).ok())
1036 .is_some_and(|age| age >= max_age);
1037 let alive = *live_pids.entry(pid).or_insert_with(|| {
1038 if !is_process_alive(pid) {
1039 return false;
1040 }
1041 let recycled = pid_started_after_last_write(pid, modified);
1042 if recycled {
1043 recycled_count += 1;
1044 }
1045 !recycled
1046 });
1047 process_logs.push(ProcessLogFile {
1048 path: entry.path(),
1049 modified,
1050 bytes,
1051 dead: pid != own_pid && !alive,
1052 old_enough,
1053 removed: false,
1054 });
1055 }
1056
1057 let mut summary = SweepSummary {
1058 recycled_pids: recycled_count,
1059 ..SweepSummary::default()
1060 };
1061 for file in &mut process_logs {
1062 if file.dead && file.old_enough && remove_sweep_candidate(&file.path) {
1063 file.removed = true;
1064 total_bytes = total_bytes.saturating_sub(file.bytes);
1065 summary.removed_files += 1;
1066 summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1067 }
1068 }
1069
1070 process_logs.sort_by_key(|file| file.modified);
1075 for file in process_logs
1076 .iter_mut()
1077 .filter(|file| file.dead && !file.removed)
1078 {
1079 if total_bytes <= budget_bytes {
1080 break;
1081 }
1082 if remove_sweep_candidate(&file.path) {
1083 file.removed = true;
1084 total_bytes = total_bytes.saturating_sub(file.bytes);
1085 summary.removed_files += 1;
1086 summary.bytes_freed = summary.bytes_freed.saturating_add(file.bytes);
1087 }
1088 }
1089
1090 Ok(summary)
1091}
1092
1093const PID_REUSE_TOLERANCE_MS: u64 = 2_000;
1096
1097fn pid_started_after_last_write(pid: u32, modified: Option<SystemTime>) -> bool {
1104 let Some(modified_ms) = modified
1105 .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
1106 .map(|since_epoch| since_epoch.as_millis() as u64)
1107 else {
1108 return false;
1109 };
1110 crate::root_cache::process_start_time_ms(pid)
1111 .is_some_and(|started_ms| started_ms > modified_ms.saturating_add(PID_REUSE_TOLERANCE_MS))
1112}
1113
1114fn remove_sweep_candidate(path: &Path) -> bool {
1115 fs::remove_file(path).is_ok()
1118}
1119
1120fn process_log_pid(name: &str) -> Option<u32> {
1121 let rest = name.strip_prefix("aft-")?;
1122 let (pid, suffix) = rest.split_once(".log")?;
1123 if !suffix.is_empty()
1124 && !(suffix.starts_with('.') && suffix[1..].chars().all(|ch| ch.is_ascii_digit()))
1125 {
1126 return None;
1127 }
1128 pid.parse().ok()
1129}
1130
1131static LAST_LOG_SWEEP: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(|| Mutex::new(None));
1132
1133fn mark_log_sweep_ran() {
1134 if let Ok(mut last_run) = LAST_LOG_SWEEP.lock() {
1135 *last_run = Some(Instant::now());
1136 }
1137}
1138
1139pub fn maybe_sweep_logs() {
1141 let now = Instant::now();
1142 let should_run = LAST_LOG_SWEEP
1143 .lock()
1144 .map(|mut last_run| {
1145 if last_run.is_some_and(|last| now.duration_since(last) < LOG_SWEEP_INTERVAL) {
1146 false
1147 } else {
1148 *last_run = Some(now);
1149 true
1150 }
1151 })
1152 .unwrap_or(false);
1153 if !should_run {
1154 return;
1155 }
1156
1157 let storage_root = FILE_CONTROL
1158 .lock()
1159 .ok()
1160 .and_then(|control| control.storage_root.clone())
1161 .unwrap_or_else(|| crate::bash_background::storage_dir(None));
1162 let logs_dir = storage_root.join("logs");
1163 match sweep_logs(
1164 &logs_dir,
1165 SystemTime::now(),
1166 DEAD_PROCESS_LOG_MAX_AGE,
1167 LOG_DIRECTORY_BUDGET_BYTES,
1168 ) {
1169 Ok(summary) => log_sweep_summary(summary),
1170 Err(error) => crate::slog_warn!(
1171 "log retention sweep failed for {}: {}",
1172 logs_dir.display(),
1173 error
1174 ),
1175 }
1176}
1177
1178#[derive(Default)]
1179struct PerfMetrics {
1180 watcher_ingested: AtomicU64,
1181 watcher_paths: AtomicU64,
1182 watcher_dropped: AtomicU64,
1183 drain_slices: AtomicU64,
1184 semantic_collects: AtomicU64,
1185 semantic_files: AtomicU64,
1186 semantic_chunks: AtomicU64,
1187 semantic_ms: AtomicU64,
1188 callgraph_invalidations: AtomicU64,
1189 file_lines_dropped: AtomicU64,
1190 tool_call_count: AtomicU64,
1191 tool_calls: Mutex<VecDeque<ToolCallPerfSample>>,
1192 tier2: Mutex<BTreeMap<String, (u64, u64)>>,
1193 next_sample_ns: AtomicU64,
1194 reporter: Mutex<PerfReporter>,
1195}
1196
1197struct PerfReporter {
1198 last_report: Instant,
1199 last_completed_interactive: u64,
1200 last_completed_maintenance: u64,
1201 last_tool_call_count: u64,
1202}
1203
1204impl Default for PerfReporter {
1205 fn default() -> Self {
1206 Self {
1207 last_report: Instant::now(),
1208 last_completed_interactive: 0,
1209 last_completed_maintenance: 0,
1210 last_tool_call_count: 0,
1211 }
1212 }
1213}
1214
1215#[derive(Clone, Copy)]
1216struct ToolCallPerfSample {
1217 total_ms: u64,
1218 queue_ms: u64,
1219}
1220
1221#[derive(Clone, Copy, Default)]
1222struct ToolCallPerfSummary {
1223 window: usize,
1224 p50_total_ms: u64,
1225 max_total_ms: u64,
1226 p50_queue_ms: u64,
1227 max_queue_ms: u64,
1228}
1229
1230#[derive(Clone, Copy, Default)]
1231struct ExecutorSample {
1232 interactive_running: usize,
1233 maintenance_running: usize,
1234 interactive_queued: usize,
1235 maintenance_queued: usize,
1236 interactive_oldest_ms: Option<u64>,
1237 maintenance_oldest_ms: Option<u64>,
1238}
1239
1240static PERF: LazyLock<PerfMetrics> = LazyLock::new(PerfMetrics::default);
1241
1242pub fn sync_storage_root(storage_root: PathBuf) {
1248 let Ok(mut control) = FILE_CONTROL.lock() else {
1249 return;
1250 };
1251 if control.storage_root.as_ref() == Some(&storage_root) {
1252 return;
1253 }
1254 let Some(tx) = control.tx.as_ref() else {
1255 return;
1256 };
1257 if tx
1258 .try_send(LogMessage::Reconfigure(storage_root.clone()))
1259 .is_ok()
1260 {
1261 control.storage_root = Some(storage_root);
1262 }
1263}
1264
1265pub fn note_watcher_events(count: usize) {
1267 PERF.watcher_ingested
1268 .fetch_add(count as u64, Ordering::Relaxed);
1269}
1270
1271pub fn note_drain_paths(count: usize) {
1273 PERF.watcher_paths
1274 .fetch_add(count as u64, Ordering::Relaxed);
1275}
1276
1277pub fn note_watcher_overflow() {
1279 PERF.watcher_dropped.fetch_add(1, Ordering::Relaxed);
1280}
1281
1282pub fn note_drain_slice() {
1284 PERF.drain_slices.fetch_add(1, Ordering::Relaxed);
1285}
1286
1287pub fn note_semantic_collect(chunks: usize, files: usize, elapsed_ms: u64) {
1289 PERF.semantic_collects.fetch_add(1, Ordering::Relaxed);
1290 PERF.semantic_chunks
1291 .fetch_add(chunks as u64, Ordering::Relaxed);
1292 PERF.semantic_files
1293 .fetch_add(files as u64, Ordering::Relaxed);
1294 PERF.semantic_ms.fetch_add(elapsed_ms, Ordering::Relaxed);
1295}
1296
1297pub fn note_tier2_scan(category: String, elapsed_ms: u64) {
1299 if let Ok(mut tier2) = PERF.tier2.lock() {
1300 let entry = tier2.entry(category).or_default();
1301 entry.0 = entry.0.saturating_add(1);
1302 entry.1 = entry.1.saturating_add(elapsed_ms);
1303 }
1304}
1305
1306pub fn note_callgraph_invalidations(files: usize) {
1308 PERF.callgraph_invalidations
1309 .fetch_add(files as u64, Ordering::Relaxed);
1310}
1311
1312pub fn note_tool_call_trace(
1316 name: &str,
1317 root: &Path,
1318 channel: u16,
1319 corr: u64,
1320 phases: ToolCallPhaseDurations,
1321) {
1322 let sample = ToolCallPerfSample {
1323 total_ms: duration_millis_u64(phases.total),
1324 queue_ms: duration_millis_u64(phases.queue),
1325 };
1326 if let Ok(mut samples) = PERF.tool_calls.lock() {
1327 if samples.len() == TOOL_CALL_SAMPLE_CAPACITY {
1328 samples.pop_front();
1329 }
1330 samples.push_back(sample);
1331 PERF.tool_call_count.fetch_add(1, Ordering::Relaxed);
1332 }
1333
1334 let waiting_on_build_id = phases.waiting_on_build_id.as_deref().unwrap_or("-");
1335 crate::slog_debug!(
1336 "tool_call phase name={} channel={} corr={} total_ms={:.3} queue_ms={:.3} translate_ms={:.3} exec_ms={:.3} format_ms={:.3} finalize_ms={:.3} egress_ms={:.3} egress_enqueue_ms={:.3} egress_queue_ms={:.3} egress_prepare_ms={:.3} egress_write_ms={:.3} frame_bytes={} writer_queue_depth={} writer_active={} writer_queue_full={} reserve_timeouts={} waiting_on={} waiting_on_build_id={} wait_ms={} root={}",
1337 name,
1338 channel,
1339 corr,
1340 duration_millis_f64(phases.total),
1341 duration_millis_f64(phases.queue),
1342 duration_millis_f64(phases.translate),
1343 duration_millis_f64(phases.execute),
1344 duration_millis_f64(phases.format),
1345 duration_millis_f64(phases.finalize),
1346 duration_millis_f64(phases.egress),
1347 duration_millis_f64(phases.egress_enqueue),
1348 duration_millis_f64(phases.egress_queue),
1349 duration_millis_f64(phases.egress_prepare),
1350 duration_millis_f64(phases.egress_write),
1351 phases.frame_bytes,
1352 phases.writer_queue_depth,
1353 phases.writer_active_at_enqueue,
1354 phases.writer_queue_was_full,
1355 phases.writer_reserve_timeouts,
1356 phases.waiting_on.as_str(),
1357 waiting_on_build_id,
1358 phases.wait_ms,
1359 root.display(),
1360 );
1361
1362 if phases.total > SLOW_TOOL_CALL_THRESHOLD {
1363 crate::slog_warn!(
1364 "slow tool_call name={} channel={} corr={} total={}ms queue={} translate={} exec={} format={} finalize={} egress={} egress_enqueue={} egress_queue={} egress_prepare={} egress_write={} frame_bytes={} writer_queue_depth={} writer_active={} writer_queue_full={} reserve_timeouts={} waiting_on={} waiting_on_build_id={} wait_ms={} root={}",
1365 name,
1366 channel,
1367 corr,
1368 duration_millis_u64(phases.total),
1369 duration_millis_u64(phases.queue),
1370 duration_millis_u64(phases.translate),
1371 duration_millis_u64(phases.execute),
1372 duration_millis_u64(phases.format),
1373 duration_millis_u64(phases.finalize),
1374 duration_millis_u64(phases.egress),
1375 duration_millis_u64(phases.egress_enqueue),
1376 duration_millis_u64(phases.egress_queue),
1377 duration_millis_u64(phases.egress_prepare),
1378 duration_millis_u64(phases.egress_write),
1379 phases.frame_bytes,
1380 phases.writer_queue_depth,
1381 phases.writer_active_at_enqueue,
1382 phases.writer_queue_was_full,
1383 phases.writer_reserve_timeouts,
1384 phases.waiting_on.as_str(),
1385 waiting_on_build_id,
1386 phases.wait_ms,
1387 root.display(),
1388 );
1389 }
1390}
1391
1392pub fn perf_tick(executor: Option<&Executor>) {
1397 if !perf_sample_due() {
1398 return;
1399 }
1400
1401 let sample = executor.and_then(|executor| {
1402 executor
1403 .try_dispatch_liveness_snapshot()
1404 .map(|snapshot| ExecutorSample {
1405 interactive_running: snapshot.running.interactive,
1406 maintenance_running: snapshot.running.maintenance,
1407 interactive_queued: snapshot.interactive.queued,
1408 maintenance_queued: snapshot.maintenance.queued,
1409 interactive_oldest_ms: snapshot.interactive.oldest_age_ms,
1410 maintenance_oldest_ms: snapshot.maintenance.oldest_age_ms,
1411 })
1412 });
1413
1414 let completion_counts = executor.map_or((0, 0), Executor::completion_counts);
1415 let tool_call_count = PERF.tool_call_count.load(Ordering::Relaxed);
1416 let (completed_interactive, completed_maintenance, new_tool_calls) = {
1417 let Ok(mut reporter) = PERF.reporter.lock() else {
1418 return;
1419 };
1420 if reporter.last_report.elapsed() < perf_tick_interval() {
1421 return;
1422 }
1423 reporter.last_report = Instant::now();
1424 let completed = (
1425 completion_counts
1426 .0
1427 .saturating_sub(reporter.last_completed_interactive),
1428 completion_counts
1429 .1
1430 .saturating_sub(reporter.last_completed_maintenance),
1431 tool_call_count.saturating_sub(reporter.last_tool_call_count),
1432 );
1433 reporter.last_completed_interactive = completion_counts.0;
1434 reporter.last_completed_maintenance = completion_counts.1;
1435 reporter.last_tool_call_count = tool_call_count;
1436 completed
1437 };
1438
1439 let watcher_ingested = PERF.watcher_ingested.swap(0, Ordering::Relaxed);
1440 let watcher_paths = PERF.watcher_paths.swap(0, Ordering::Relaxed);
1441 let watcher_dropped = PERF.watcher_dropped.swap(0, Ordering::Relaxed);
1442 let drain_slices = PERF.drain_slices.swap(0, Ordering::Relaxed);
1443 let semantic_collects = PERF.semantic_collects.swap(0, Ordering::Relaxed);
1444 let semantic_files = PERF.semantic_files.swap(0, Ordering::Relaxed);
1445 let semantic_chunks = PERF.semantic_chunks.swap(0, Ordering::Relaxed);
1446 let semantic_ms = PERF.semantic_ms.swap(0, Ordering::Relaxed);
1447 let callgraph_invalidations = PERF.callgraph_invalidations.swap(0, Ordering::Relaxed);
1448 let file_lines_dropped = PERF.file_lines_dropped.swap(0, Ordering::Relaxed);
1449 let tier2 = PERF
1450 .tier2
1451 .lock()
1452 .map(|mut tier2| std::mem::take(&mut *tier2))
1453 .unwrap_or_default();
1454 let tool_calls = PERF
1455 .tool_calls
1456 .lock()
1457 .map(|samples| summarize_tool_calls(&samples))
1458 .unwrap_or_default();
1459
1460 let executor_busy = sample.is_some_and(|sample| {
1461 sample.interactive_running > 0
1462 || sample.maintenance_running > 0
1463 || sample.interactive_queued > 0
1464 || sample.maintenance_queued > 0
1465 });
1466 let active = watcher_ingested > 0
1467 || watcher_paths > 0
1468 || watcher_dropped > 0
1469 || drain_slices > 0
1470 || semantic_collects > 0
1471 || callgraph_invalidations > 0
1472 || completed_interactive > 0
1473 || completed_maintenance > 0
1474 || new_tool_calls > 0
1475 || file_lines_dropped > 0
1476 || !tier2.is_empty()
1477 || executor_busy;
1478 if !active {
1479 return;
1480 }
1481
1482 let tier2_summary = if tier2.is_empty() {
1483 "none".to_string()
1484 } else {
1485 tier2
1486 .into_iter()
1487 .map(|(category, (count, ms))| format!("{category}:{count}/{ms}ms"))
1488 .collect::<Vec<_>>()
1489 .join(",")
1490 };
1491 let sample = sample.unwrap_or_default();
1492 crate::slog_info!(
1493 "perf tick: watcher={{ingested:{},paths:{},dropped:{}}} drains={} tier2=[{}] semantic={{collects:{},files:{},chunks:{},ms:{}}} callgraph_invalidations={} executor_completed={{interactive:{},maintenance:{}}} oldest_queued_ms={{interactive:{},maintenance:{}}} {} file_log_dropped={}",
1494 watcher_ingested,
1495 watcher_paths,
1496 watcher_dropped,
1497 drain_slices,
1498 tier2_summary,
1499 semantic_collects,
1500 semantic_files,
1501 semantic_chunks,
1502 semantic_ms,
1503 callgraph_invalidations,
1504 completed_interactive,
1505 completed_maintenance,
1506 format_optional_ms(sample.interactive_oldest_ms),
1507 format_optional_ms(sample.maintenance_oldest_ms),
1508 format_tool_call_summary(new_tool_calls, tool_calls),
1509 file_lines_dropped,
1510 );
1511}
1512
1513fn duration_millis_f64(duration: Duration) -> f64 {
1514 duration.as_secs_f64() * 1_000.0
1515}
1516
1517fn duration_millis_u64(duration: Duration) -> u64 {
1518 duration.as_millis().min(u64::MAX as u128) as u64
1519}
1520
1521fn summarize_tool_calls(samples: &VecDeque<ToolCallPerfSample>) -> ToolCallPerfSummary {
1522 if samples.is_empty() {
1523 return ToolCallPerfSummary::default();
1524 }
1525 let mut totals = samples
1526 .iter()
1527 .map(|sample| sample.total_ms)
1528 .collect::<Vec<_>>();
1529 let mut queues = samples
1530 .iter()
1531 .map(|sample| sample.queue_ms)
1532 .collect::<Vec<_>>();
1533 totals.sort_unstable();
1534 queues.sort_unstable();
1535 let median_index = (samples.len() - 1) / 2;
1536 ToolCallPerfSummary {
1537 window: samples.len(),
1538 p50_total_ms: totals[median_index],
1539 max_total_ms: totals[totals.len() - 1],
1540 p50_queue_ms: queues[median_index],
1541 max_queue_ms: queues[queues.len() - 1],
1542 }
1543}
1544
1545fn format_tool_call_summary(new_tool_calls: u64, summary: ToolCallPerfSummary) -> String {
1546 format!(
1547 "toolcall={{new:{new_tool_calls},window:{},p50_total_ms:{},max_total_ms:{},p50_queue_ms:{},max_queue_ms:{}}}",
1548 summary.window,
1549 summary.p50_total_ms,
1550 summary.max_total_ms,
1551 summary.p50_queue_ms,
1552 summary.max_queue_ms,
1553 )
1554}
1555
1556fn format_optional_ms(value: Option<u64>) -> String {
1557 value
1558 .map(|value| value.to_string())
1559 .unwrap_or_else(|| "none".to_string())
1560}
1561
1562fn perf_sample_due() -> bool {
1563 static ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
1564 let now_ns = ORIGIN.elapsed().as_nanos().min(u64::MAX as u128) as u64;
1565 let mut deadline = PERF.next_sample_ns.load(Ordering::Relaxed);
1566 loop {
1567 if now_ns < deadline {
1568 return false;
1569 }
1570 let next = now_ns.saturating_add(PERF_SAMPLE_INTERVAL.as_nanos() as u64);
1571 match PERF.next_sample_ns.compare_exchange_weak(
1572 deadline,
1573 next,
1574 Ordering::Relaxed,
1575 Ordering::Relaxed,
1576 ) {
1577 Ok(_) => return true,
1578 Err(observed) => deadline = observed,
1579 }
1580 }
1581}
1582
1583fn perf_tick_interval() -> Duration {
1584 static INTERVAL: OnceLock<Duration> = OnceLock::new();
1585 *INTERVAL.get_or_init(|| {
1586 std::env::var("AFT_PERF_TICK_INTERVAL_MS")
1587 .ok()
1588 .and_then(|value| value.parse::<u64>().ok())
1589 .filter(|value| *value > 0)
1590 .map(Duration::from_millis)
1591 .unwrap_or(DEFAULT_PERF_TICK_INTERVAL)
1592 })
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 use super::*;
1598 use filetime::{set_file_mtime, FileTime};
1599 use tempfile::TempDir;
1600
1601 fn line(value: &str) -> Vec<Vec<u8>> {
1602 vec![format!("{value}\n").into_bytes()]
1603 }
1604
1605 #[test]
1606 fn epoch_timestamp_renders_known_dates() {
1607 assert_eq!(format_epoch_secs(0), "1970-01-01T00:00:00Z");
1610 assert_eq!(format_epoch_secs(1_704_067_200), "2024-01-01T00:00:00Z");
1611 assert_eq!(format_epoch_secs(1_709_251_199), "2024-02-29T23:59:59Z");
1612 assert_eq!(format_epoch_secs(4_102_444_800), "2100-01-01T00:00:00Z");
1613 assert_eq!(format_epoch_secs(4_107_542_399), "2100-02-28T23:59:59Z");
1614 }
1615
1616 #[test]
1617 fn rotation_rolls_once_and_replaces_the_single_backup_generation() {
1618 let temp = TempDir::new().unwrap();
1619 let path = temp.path().join("aft-123.log");
1620 fs::write(rotated_path(&path, 1), "stale backup\n").unwrap();
1621 let mut sink = RotatingFile::open(path.clone(), 10, 1, 1).unwrap();
1622 sink.write_batch(&line("aaaa")).unwrap();
1623 sink.write_batch(&line("bbbb")).unwrap();
1624 sink.write_batch(&line("cccc")).unwrap();
1625 sink.write_batch(&line("dddd")).unwrap();
1626 sink.write_batch(&line("eeee")).unwrap();
1627
1628 assert_eq!(fs::read_to_string(&path).unwrap(), "eeee\n");
1629 assert_eq!(
1630 fs::read_to_string(rotated_path(&path, 1)).unwrap(),
1631 "cccc\ndddd\n"
1632 );
1633 assert!(!rotated_path(&path, 2).exists());
1634 }
1635
1636 #[test]
1637 fn dead_pid_sweep_respects_age_liveness_and_explicit_plugin_exclusion() {
1638 let temp = TempDir::new().unwrap();
1639 let dead = temp.path().join("aft-4294967294.log");
1640 let dead_rotated = temp.path().join("aft-4294967294.log.1");
1641 let fresh_dead = temp.path().join("aft-4294967293.log");
1642 let own = temp.path().join(format!("aft-{}.log", std::process::id()));
1643 let live_rotated = rotated_path(&own, 1);
1644 let plugin = temp.path().join("aft-plugin.log");
1645 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1646 for path in [&dead, &dead_rotated, &own, &live_rotated, &plugin] {
1647 fs::write(path, "log").unwrap();
1648 set_file_mtime(path, FileTime::from_unix_time(1, 0)).unwrap();
1649 }
1650 fs::write(&fresh_dead, "fresh").unwrap();
1651 set_file_mtime(
1652 &fresh_dead,
1653 FileTime::from_unix_time(
1654 (now - DEAD_PROCESS_LOG_MAX_AGE + Duration::from_secs(1))
1655 .duration_since(SystemTime::UNIX_EPOCH)
1656 .unwrap()
1657 .as_secs() as i64,
1658 0,
1659 ),
1660 )
1661 .unwrap();
1662
1663 let summary = sweep_logs(temp.path(), now, DEAD_PROCESS_LOG_MAX_AGE, u64::MAX).unwrap();
1664
1665 assert_eq!(summary.removed_files, 2);
1666 assert!(!dead.exists());
1667 assert!(!dead_rotated.exists());
1668 assert!(fresh_dead.exists());
1669 assert!(own.exists());
1670 assert!(live_rotated.exists());
1671 assert!(plugin.exists());
1672 }
1673
1674 #[cfg(any(target_os = "linux", target_os = "macos"))]
1678 #[test]
1679 fn recycled_pid_log_is_reaped_while_the_live_owner_is_kept() {
1680 let temp = TempDir::new().unwrap();
1681 let live_pid = unsafe { libc::getppid() } as u32;
1682 assert!(is_process_alive(live_pid));
1683 let recycled = temp.path().join(format!("aft-{live_pid}.log"));
1684 fs::write(&recycled, "written long before this process existed").unwrap();
1685 set_file_mtime(&recycled, FileTime::from_unix_time(1, 0)).unwrap();
1686
1687 let summary = sweep_logs(
1688 temp.path(),
1689 SystemTime::now(),
1690 DEAD_PROCESS_LOG_MAX_AGE,
1691 u64::MAX,
1692 )
1693 .unwrap();
1694 assert_eq!(summary.removed_files, 1);
1695 assert_eq!(summary.recycled_pids, 1);
1696 assert!(!recycled.exists());
1697
1698 let owned = temp.path().join(format!("aft-{live_pid}.log"));
1699 fs::write(&owned, "written by the live owner").unwrap();
1700 let summary = sweep_logs(
1701 temp.path(),
1702 SystemTime::now() + DEAD_PROCESS_LOG_MAX_AGE * 2,
1703 DEAD_PROCESS_LOG_MAX_AGE,
1704 0,
1705 )
1706 .unwrap();
1707 assert_eq!(summary.removed_files, 0);
1708 assert!(owned.exists());
1709 }
1710
1711 #[cfg(any(target_os = "linux", target_os = "macos"))]
1717 #[test]
1718 fn recycled_pid_now_owned_by_another_user_is_reaped() {
1719 let temp = TempDir::new().unwrap();
1720 assert!(is_process_alive(1));
1721 assert!(
1722 crate::root_cache::process_start_time_ms(1).is_some(),
1723 "pid 1's start time must be readable from an unprivileged process"
1724 );
1725 let recycled = temp.path().join("aft-1.log");
1726 fs::write(&recycled, "written before the current pid 1 booted").unwrap();
1727 set_file_mtime(&recycled, FileTime::from_unix_time(1, 0)).unwrap();
1728
1729 let summary = sweep_logs(
1730 temp.path(),
1731 SystemTime::now(),
1732 DEAD_PROCESS_LOG_MAX_AGE,
1733 u64::MAX,
1734 )
1735 .unwrap();
1736 assert_eq!(summary.removed_files, 1);
1737 assert_eq!(summary.recycled_pids, 1);
1738 assert!(!recycled.exists());
1739 }
1740
1741 #[test]
1742 fn budget_backstop_deletes_oldest_dead_files_but_not_live_files() {
1743 let temp = TempDir::new().unwrap();
1744 let oldest = temp.path().join("aft-4294967294.log");
1745 let newest = temp.path().join("aft-4294967293.log");
1746 let live = temp.path().join(format!("aft-{}.log", std::process::id()));
1747 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60);
1748 fs::write(&oldest, "oldest").unwrap();
1749 fs::write(&newest, "newest").unwrap();
1750 fs::write(&live, "live-live").unwrap();
1751 set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
1752 set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
1753 set_file_mtime(&live, FileTime::from_unix_time(1, 0)).unwrap();
1754
1755 let summary = sweep_logs(
1756 temp.path(),
1757 now,
1758 Duration::from_secs(365 * 24 * 60 * 60),
1759 15,
1760 )
1761 .unwrap();
1762
1763 assert_eq!(summary.removed_files, 1);
1764 assert!(!oldest.exists());
1765 assert!(newest.exists());
1766 assert!(live.exists());
1767 }
1768
1769 #[test]
1770 fn tool_call_summary_uses_bounded_window_median_and_maxima() {
1771 let samples = VecDeque::from([
1772 ToolCallPerfSample {
1773 total_ms: 9,
1774 queue_ms: 5,
1775 },
1776 ToolCallPerfSample {
1777 total_ms: 3,
1778 queue_ms: 1,
1779 },
1780 ToolCallPerfSample {
1781 total_ms: 7,
1782 queue_ms: 2,
1783 },
1784 ToolCallPerfSample {
1785 total_ms: 5,
1786 queue_ms: 4,
1787 },
1788 ]);
1789
1790 let summary = summarize_tool_calls(&samples);
1791
1792 assert_eq!(summary.window, 4);
1793 assert_eq!(summary.p50_total_ms, 5);
1794 assert_eq!(summary.max_total_ms, 9);
1795 assert_eq!(summary.p50_queue_ms, 2);
1796 assert_eq!(summary.max_queue_ms, 5);
1797 }
1798
1799 #[test]
1800 fn tool_call_tick_labels_interval_count_and_rolling_window() {
1801 let samples = VecDeque::from([ToolCallPerfSample {
1802 total_ms: 3_000,
1803 queue_ms: 2_900,
1804 }]);
1805 let summary = summarize_tool_calls(&samples);
1806
1807 assert_eq!(
1808 format_tool_call_summary(1, summary),
1809 "toolcall={new:1,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1810 );
1811 assert_eq!(
1812 format_tool_call_summary(0, summary),
1813 "toolcall={new:0,window:1,p50_total_ms:3000,max_total_ms:3000,p50_queue_ms:2900,max_queue_ms:2900}"
1814 );
1815 }
1816
1817 fn index_event_matches_grammar(line: &str) -> bool {
1818 let Some(rest) = line.strip_prefix("index_event ") else {
1819 return false;
1820 };
1821 if rest.is_empty() {
1822 return false;
1823 }
1824 rest.split(' ').all(|token| {
1825 let Some((key, value)) = token.split_once('=') else {
1826 return false;
1827 };
1828 !key.is_empty()
1829 && key.chars().all(|c| c.is_ascii_lowercase() || c == '_')
1830 && !value.is_empty()
1831 && !value.contains(' ')
1832 && !value.contains('=')
1833 })
1834 }
1835
1836 fn index_event_fields(line: &str) -> BTreeMap<String, String> {
1837 let mut fields = BTreeMap::new();
1838 for token in line.split_whitespace().skip(1) {
1839 let Some((key, value)) = token.split_once('=') else {
1840 continue;
1841 };
1842 fields.insert(key.to_string(), value.to_string());
1843 }
1844 fields
1845 }
1846
1847 fn assert_index_event_grammar(lines: &[String]) {
1848 for line in lines {
1849 assert!(
1850 index_event_matches_grammar(line),
1851 "index_event line failed grammar: {line}"
1852 );
1853 }
1854 }
1855
1856 fn event_matches(line: &str, plane: &str, root: &str, key: &str) -> bool {
1857 let fields = index_event_fields(line);
1858 fields.get("plane").map(String::as_str) == Some(plane)
1859 && fields.get("root").map(String::as_str) == Some(root)
1860 && fields.get("key").map(String::as_str) == Some(key)
1861 }
1862
1863 fn assert_lifecycle_sequence(
1864 lines: &[String],
1865 plane: &str,
1866 root: &str,
1867 key: &str,
1868 require_progress: bool,
1869 ) {
1870 let events: Vec<_> = lines
1871 .iter()
1872 .filter(|line| event_matches(line, plane, root, key))
1873 .cloned()
1874 .collect();
1875 assert_index_event_grammar(&events);
1876 let kinds: Vec<_> = events
1877 .iter()
1878 .filter_map(|line| index_event_fields(line).get("kind").cloned())
1879 .filter(|kind| {
1880 matches!(
1881 kind.as_str(),
1882 "build_started" | "build_progress" | "build_ready"
1883 )
1884 })
1885 .collect();
1886 assert!(
1887 kinds.first().is_some_and(|kind| kind == "build_started"),
1888 "expected build_started first, got {kinds:?} from {events:?}"
1889 );
1890 if require_progress {
1891 assert!(
1892 kinds.iter().any(|kind| kind == "build_progress"),
1893 "expected build_progress in {kinds:?} from {events:?}"
1894 );
1895 }
1896 assert!(
1897 kinds.last().is_some_and(|kind| kind == "build_ready"),
1898 "expected build_ready last, got {kinds:?} from {events:?}"
1899 );
1900 let build_ids: Vec<_> = events
1901 .iter()
1902 .filter_map(|line| index_event_fields(line).get("build_id").cloned())
1903 .collect();
1904 assert!(!build_ids.is_empty());
1905 assert!(
1906 build_ids.iter().all(|id| id == &build_ids[0]),
1907 "build_id drifted across events: {build_ids:?}"
1908 );
1909 for line in &events {
1910 let fields = index_event_fields(line);
1911 assert_eq!(fields.get("root").map(String::as_str), Some(root), "{line}");
1912 assert_eq!(fields.get("key").map(String::as_str), Some(key), "{line}");
1913 }
1914 }
1915
1916 fn tiny_project(name: &str, file_name: &str, contents: &str) -> (tempfile::TempDir, PathBuf) {
1917 let temp = tempfile::tempdir().expect("tempdir");
1918 let root = temp.path().join(name);
1919 std::fs::create_dir_all(&root).expect("create project");
1920 std::fs::write(root.join(file_name), contents).expect("write fixture");
1921 let root = std::fs::canonicalize(&root).unwrap_or(root);
1922 (temp, root)
1923 }
1924
1925 #[test]
1926 fn watcher_rescan_event_reports_reason_cost_rss_and_interval_count() {
1927 let (_, lines) = capture_index_events(|| {
1928 log_watcher_rescan(
1929 Path::new("/tmp/watcher root"),
1930 crate::watcher_filter::RescanReason::KernelDropped,
1931 42,
1932 Some(-4096),
1933 137,
1934 );
1935 });
1936 assert_index_event_grammar(&lines);
1937 assert_eq!(lines.len(), 1);
1938 let fields = index_event_fields(&lines[0]);
1939 assert_eq!(
1940 fields.get("kind").map(String::as_str),
1941 Some("watcher_rescan")
1942 );
1943 assert_eq!(fields.get("plane").map(String::as_str), Some("watcher"));
1944 assert_eq!(
1945 fields.get("root").map(String::as_str),
1946 Some("/tmp/watcher_root")
1947 );
1948 assert_eq!(
1949 fields.get("reason").map(String::as_str),
1950 Some("kernel_dropped")
1951 );
1952 assert_eq!(fields.get("cost_ms").map(String::as_str), Some("42"));
1953 assert_eq!(
1954 fields.get("rss_delta_bytes").map(String::as_str),
1955 Some("-4096")
1956 );
1957 assert_eq!(
1958 fields.get("raw_events_since_last").map(String::as_str),
1959 Some("137")
1960 );
1961 }
1962
1963 #[test]
1964 fn index_event_grammar_rejects_spaces_and_equals_in_values() {
1965 let (result, lines) = capture_index_events(|| {
1966 log_index_event(
1967 IndexEvent::new(
1968 IndexEventKind::BuildStarted,
1969 IndexPlane::Search,
1970 "b-1-1",
1971 "/tmp/root",
1972 "abc123",
1973 )
1974 .field("stage", "streaming"),
1975 );
1976 });
1977 let _ = result;
1978 assert_index_event_grammar(&lines);
1979 let own: Vec<&String> = lines
1983 .iter()
1984 .filter(|line| {
1985 index_event_fields(line).get("build_id").map(String::as_str) == Some("b-1-1")
1986 })
1987 .collect();
1988 assert_eq!(own.len(), 1, "{lines:?}");
1989 }
1990
1991 #[test]
1992 fn index_event_keeps_required_fields_for_long_root() {
1993 let root = format!("/{}", "a".repeat(399));
1994 let (_, lines) = capture_index_events(|| {
1995 log_index_event(IndexEvent::new(
1996 IndexEventKind::BuildStarted,
1997 IndexPlane::Search,
1998 "b-9-9",
1999 &root,
2000 "keepkey",
2001 ));
2002 });
2003 assert_eq!(lines.len(), 1, "{lines:?}");
2004 let fields = index_event_fields(&lines[0]);
2005 assert_eq!(fields.get("build_id").map(String::as_str), Some("b-9-9"));
2006 assert_eq!(fields.get("key").map(String::as_str), Some("keepkey"));
2007 assert!(fields.get("root").is_some(), "{lines:?}");
2008 assert_index_event_grammar(&lines);
2009 }
2010
2011 #[test]
2012 fn first_query_fires_once_per_build_id() {
2013 let root = PathBuf::from("/tmp/first-query-root");
2014 let key = "firstquerykey";
2015 let build_id = "b-1-99";
2016 let (_, lines) = capture_index_events(|| {
2017 log_index_event(IndexEvent::new(
2018 IndexEventKind::BuildReady,
2019 IndexPlane::Search,
2020 build_id,
2021 &root,
2022 key,
2023 ));
2024 assert!(claim_first_query(
2025 IndexPlane::Search,
2026 &root,
2027 "grep",
2028 1,
2029 2,
2030 "ok"
2031 ));
2032 assert!(!claim_first_query(
2033 IndexPlane::Search,
2034 &root,
2035 "grep",
2036 1,
2037 2,
2038 "ok"
2039 ));
2040 });
2041 let first_query: Vec<_> = lines
2042 .iter()
2043 .filter(|line| line.contains("kind=first_query"))
2044 .collect();
2045 assert_eq!(first_query.len(), 1, "{lines:?}");
2046 let fields = index_event_fields(first_query[0]);
2047 assert_eq!(fields.get("build_id").map(String::as_str), Some(build_id));
2048 assert_eq!(fields.get("tool").map(String::as_str), Some("grep"));
2049 }
2050
2051 #[test]
2052 fn callgraph_cold_build_emits_started_progress_ready() {
2053 let (_temp, root) = tiny_project("cg", "lib.rs", "pub fn marker() {}\n");
2054 let key = crate::search_index::artifact_cache_key(&root);
2055 let callgraph_dir = _temp.path().join("callgraph").join(&key);
2056 crate::root_cache::configure_artifact_access(&root, &key, false);
2057 let source = root.join("lib.rs");
2058 let (built, lines) = capture_index_events(|| {
2059 crate::callgraph_store::CallGraphStore::cold_build_with_lease(
2060 callgraph_dir,
2061 root.clone(),
2062 std::slice::from_ref(&source),
2063 )
2064 });
2065 built.expect("callgraph cold build");
2066 assert_lifecycle_sequence(
2067 &lines,
2068 "callgraph",
2069 &normalize_index_root(&root),
2070 &key,
2071 true,
2072 );
2073 }
2074
2075 #[test]
2076 fn search_cold_build_emits_started_progress_ready() {
2077 let (_temp, root) = tiny_project("search", "file.txt", "alpha token\n");
2078 let key = crate::search_index::artifact_cache_key(&root);
2079 let (index, lines) = capture_index_events(|| {
2080 crate::search_index::SearchIndex::build_with_limit(&root, 1_000_000)
2081 });
2082 assert!(index.ready);
2083 assert_lifecycle_sequence(&lines, "search", &normalize_index_root(&root), &key, true);
2084 }
2085
2086 #[test]
2087 fn semantic_cold_build_emits_started_progress_ready() {
2088 let (_temp, root) = tiny_project("sem", "lib.rs", "pub fn hello() {}\n");
2089 let key = crate::search_index::artifact_cache_key(&root);
2090 let files = vec![root.join("lib.rs")];
2091 let mut embed = |texts: Vec<String>| {
2092 Ok(texts
2093 .into_iter()
2094 .map(|_| vec![0.01_f32; 384])
2095 .collect::<Vec<_>>())
2096 };
2097 let (built, lines) = capture_index_events(|| {
2098 crate::semantic_index::SemanticIndex::build(&root, &files, &mut embed, 8)
2099 });
2100 built.expect("semantic build");
2101 assert_lifecycle_sequence(&lines, "semantic", &normalize_index_root(&root), &key, true);
2102 }
2103
2104 #[test]
2105 fn tier2_category_emits_started_ready() {
2106 let (_temp, root) = tiny_project("t2", "mod0.ts", "export function f0() { return 0; }\n");
2107 let key = crate::search_index::artifact_cache_key(&root);
2108 crate::root_cache::configure_artifact_access(&root, &key, false);
2109 let inspect_dir = _temp.path().join("inspect");
2110 let manager = std::sync::Arc::new(crate::inspect::InspectManager::new());
2111 let snapshot = crate::inspect::InspectSnapshot::new(
2112 root.clone(),
2113 inspect_dir,
2114 std::sync::Arc::new(crate::config::Config {
2115 project_root: Some(root.clone()),
2116 ..crate::config::Config::default()
2117 }),
2118 std::sync::Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new())),
2119 );
2120 let (outcome, lines) = capture_index_events(|| {
2121 manager.tier2_run_with_reuse_blocking_fresh(
2122 snapshot,
2123 crate::inspect::InspectCategory::Complexity,
2124 crate::inspect::JobScope::for_project(root.clone()),
2125 )
2126 });
2127 assert!(outcome.payload().is_some(), "{outcome:?}");
2128 assert_lifecycle_sequence(&lines, "tier2", &normalize_index_root(&root), &key, false);
2129 assert!(
2130 !lines.iter().any(|line| {
2131 event_matches(line, "tier2", &normalize_index_root(&root), &key)
2132 && line.contains("kind=build_progress")
2133 }),
2134 "tier2 must not emit synthetic build_progress: {lines:?}"
2135 );
2136 }
2137
2138 #[test]
2139 fn superseded_cold_build_keeps_build_id_and_skips_ready() {
2140 let (_temp, root) = tiny_project("sup", "lib.rs", "pub fn marker() {}\n");
2141 let key = crate::search_index::artifact_cache_key(&root);
2142 let callgraph_dir = _temp.path().join("callgraph").join(&key);
2143 crate::root_cache::configure_artifact_access(&root, &key, false);
2144 let source = root.join("lib.rs");
2145 let epoch = crate::root_cache::ArtifactPublishEpoch::default();
2146 let stale_epoch = epoch.current();
2147 epoch.next();
2148 let (failed, lines) = capture_index_events(|| {
2149 crate::callgraph_store::with_publish_epoch(epoch, stale_epoch, || {
2150 crate::callgraph_store::CallGraphStore::cold_build_with_lease(
2151 callgraph_dir,
2152 root.clone(),
2153 std::slice::from_ref(&source),
2154 )
2155 })
2156 });
2157 assert!(matches!(
2158 failed,
2159 Err(crate::callgraph_store::CallGraphStoreError::Superseded)
2160 ));
2161 let root_s = normalize_index_root(&root);
2162 let events: Vec<_> = lines
2163 .iter()
2164 .filter(|line| event_matches(line, "callgraph", &root_s, &key))
2165 .cloned()
2166 .collect();
2167 assert_index_event_grammar(&events);
2168 let kinds: Vec<_> = events
2169 .iter()
2170 .filter_map(|line| index_event_fields(line).get("kind").cloned())
2171 .collect();
2172 assert!(
2173 kinds.contains(&"build_started".to_string()),
2174 "{kinds:?} {events:?}"
2175 );
2176 assert!(
2177 kinds.contains(&"build_superseded".to_string()),
2178 "{kinds:?} {events:?}"
2179 );
2180 assert!(
2181 !kinds.contains(&"build_ready".to_string()),
2182 "superseded build must not emit build_ready: {kinds:?}"
2183 );
2184 let started_id = events
2185 .iter()
2186 .find(|line| line.contains("kind=build_started"))
2187 .and_then(|line| index_event_fields(line).get("build_id").cloned());
2188 let superseded_id = events
2189 .iter()
2190 .find(|line| line.contains("kind=build_superseded"))
2191 .and_then(|line| index_event_fields(line).get("build_id").cloned());
2192 assert_eq!(started_id, superseded_id);
2193 for line in &events {
2194 let fields = index_event_fields(line);
2195 assert_eq!(
2196 fields.get("root").map(String::as_str),
2197 Some(root_s.as_str())
2198 );
2199 assert_eq!(fields.get("key").map(String::as_str), Some(key.as_str()));
2200 }
2201 }
2202}