1use std::collections::{HashMap, HashSet, VecDeque};
25use std::sync::{Mutex, OnceLock};
26use std::time::{Duration, Instant};
27
28use agentmux_procstats::{ProcInfo, ProcKey, RateMeter};
29
30use crate::backend::process_tracker::registry::BlockMembers;
31use crate::backend::tower_agentmux::Describer;
32use crate::backend::process_tracker::{agent_started, TrackedProcess, TrackingConfidence};
33use crate::backend::rpc_types::{TowerHost, TowerMachine, TowerProcess, TowerProcessRole, TowerSnapshot, TowerTask, TowerTaskKind};
34
35pub const AGENTMUX_TASK_ID: &str = "agentmux";
37
38pub const INTERVAL: Duration = Duration::from_secs(2);
40
41const REUSE_WITHIN: Duration = Duration::from_millis(900);
44
45const STALE_AFTER: Duration = Duration::from_secs(10);
48
49const MAX_TREE: usize = 2048;
52
53const EXITED_LINGER: Duration = Duration::from_secs(60);
57
58const MAX_EXITED: usize = 500;
61
62#[derive(Clone)]
64struct Exited {
65 info: ProcInfo,
66 task: String,
67 role: TowerProcessRole,
68 peak: Option<u64>,
69 gone: Instant,
70 gone_ms: u64,
71}
72
73#[derive(Debug, Default)]
75pub struct Inputs {
76 pub blocks: Vec<BlockMembers>,
78 pub roots: Vec<(String, u32)>,
81 pub own_pid: u32,
83}
84
85#[derive(Debug, Clone)]
87pub struct BlockLabel {
88 pub label: String,
89 pub agent: bool,
90}
91
92#[derive(Debug, Clone, PartialEq)]
93struct Group {
94 id: String,
95 tracking: &'static str,
96 cpu_time_ns: Option<u64>,
97 members: Vec<(usize, TowerProcessRole)>,
99}
100
101#[derive(Debug, Default, PartialEq)]
102struct Grouping {
103 tasks: Vec<Group>,
104 agentmux: Vec<usize>,
105}
106
107fn is_agentmux_process(name: &str) -> bool {
108 name.to_ascii_lowercase().starts_with("agentmux")
109}
110
111fn tracking_label(c: TrackingConfidence) -> &'static str {
112 match c {
113 TrackingConfidence::High => "high",
114 TrackingConfidence::BestEffort => "best_effort",
115 TrackingConfidence::None => "tree",
116 }
117}
118
119fn group(snap: &[ProcInfo], inputs: &Inputs, sticky: &HashMap<ProcKey, String>) -> Grouping {
122 let by_pid: HashMap<u32, usize> = snap.iter().enumerate().map(|(i, p)| (p.pid, i)).collect();
123 let mut children: HashMap<usize, Vec<usize>> = HashMap::new();
126 for (i, p) in snap.iter().enumerate() {
127 if let Some(&parent) = p.ppid.and_then(|pp| by_pid.get(&pp)) {
128 if p.is_child_of(&snap[parent]) {
129 children.entry(parent).or_default().push(i);
130 }
131 }
132 }
133 let descendants = |root: usize, claimed: &HashMap<usize, usize>| -> Vec<usize> {
134 let mut out = vec![root];
135 let mut queue = VecDeque::from([root]);
136 while let Some(i) = queue.pop_front() {
137 for &c in children.get(&i).into_iter().flatten() {
138 if out.len() >= MAX_TREE {
139 return out;
140 }
141 if !claimed.contains_key(&c) && !out.contains(&c) {
142 out.push(c);
143 queue.push_back(c);
144 }
145 }
146 }
147 out
148 };
149
150 let mut groups: Vec<Group> = Vec::new();
151 let mut claimed: HashMap<usize, usize> = HashMap::new();
153
154 let mut blocks: Vec<&BlockMembers> = inputs.blocks.iter().collect();
156 blocks.sort_by(|a, b| a.block_id.cmp(&b.block_id));
157 for b in blocks {
158 let members: Vec<usize> = b
159 .pids
160 .iter()
161 .filter_map(|pid| by_pid.get(pid).copied())
162 .filter(|i| !claimed.contains_key(i))
163 .collect();
164 if members.is_empty() && b.cpu_time_ns.is_none() {
168 continue;
169 }
170 let tracked: Vec<TrackedProcess> = members
171 .iter()
172 .map(|&i| TrackedProcess {
173 pid: snap[i].pid,
174 command: snap[i].name.clone(),
175 rss_bytes: 0,
176 started_at_ms: 0,
177 parent_pid: snap[i].ppid,
178 exe: String::new(),
179 })
180 .collect();
181 let started: HashSet<u32> = agent_started(tracked, &b.roots).into_iter().map(|p| p.pid).collect();
182 let gi = groups.len();
183 groups.push(Group {
184 id: b.block_id.clone(),
185 tracking: tracking_label(b.confidence),
186 cpu_time_ns: b.cpu_time_ns,
187 members: members
188 .iter()
189 .map(|&i| {
190 let pid = snap[i].pid;
191 let role = if b.roots.contains(&pid) {
192 TowerProcessRole::Main
193 } else if started.contains(&pid) {
194 TowerProcessRole::Started
195 } else {
196 TowerProcessRole::Support
197 };
198 (i, role)
199 })
200 .collect(),
201 });
202 for &i in &members {
203 claimed.insert(i, gi);
204 }
205 }
206
207 let mut roots: Vec<&(String, u32)> = inputs.roots.iter().collect();
209 roots.sort();
210 for (block_id, pid) in roots {
211 if groups.iter().any(|g| &g.id == block_id) {
212 continue;
213 }
214 let Some(&root) = by_pid.get(pid) else { continue };
215 if claimed.contains_key(&root) {
216 continue;
217 }
218 let tree = descendants(root, &claimed);
219 let gi = groups.len();
220 groups.push(Group {
221 id: block_id.clone(),
222 tracking: tracking_label(TrackingConfidence::None),
223 cpu_time_ns: None,
224 members: tree
225 .iter()
226 .map(|&i| (i, if i == root { TowerProcessRole::Main } else { TowerProcessRole::Started }))
227 .collect(),
228 });
229 for i in tree {
230 claimed.insert(i, gi);
231 }
232 }
233
234 let by_key: HashMap<ProcKey, usize> = snap.iter().enumerate().map(|(i, p)| (p.key(), i)).collect();
236 let mut sticky: Vec<(&ProcKey, &String)> = sticky.iter().collect();
237 sticky.sort();
238 for (key, block_id) in sticky {
239 let Some(&i) = by_key.get(key) else { continue };
240 if claimed.contains_key(&i) {
241 continue;
242 }
243 let Some(gi) = groups.iter().position(|g| &g.id == block_id) else { continue };
244 groups[gi].members.push((i, TowerProcessRole::Started));
245 claimed.insert(i, gi);
246 }
247
248 let mut agentmux = Vec::new();
251 if let Some(&me) = by_pid.get(&inputs.own_pid) {
252 let mut top = me;
253 for _ in 0..16 {
254 let p = &snap[top];
255 let Some(&parent) = p.ppid.and_then(|pp| by_pid.get(&pp)) else { break };
256 if !p.is_child_of(&snap[parent]) || !is_agentmux_process(&snap[parent].name) {
257 break;
258 }
259 top = parent;
260 }
261 if !claimed.contains_key(&top) {
262 agentmux = descendants(top, &claimed);
263 }
264 }
265
266 Grouping { tasks: groups, agentmux }
267}
268
269pub const SHARED_TOP: usize = 100;
272
273pub fn share(snap: &mut TowerSnapshot, top: usize, filter: &str, hidden: &dyn Fn(&str) -> bool) {
278 snap.remote = true;
279 let hidden: HashSet<String> = snap
281 .tasks
282 .iter()
283 .filter(|t| t.kind != TowerTaskKind::Agentmux && hidden(&t.id))
284 .map(|t| t.id.clone())
285 .collect();
286 snap.tasks.retain(|t| !hidden.contains(&t.id));
287 for t in &mut snap.tasks {
290 keep_top(&mut t.processes, top);
291 }
292 let Some(host) = snap.host.as_mut() else { return };
293 host.processes.retain(|p| p.task.as_ref().is_none_or(|t| !hidden.contains(t)));
294 let labels: HashMap<&str, String> = snap.tasks.iter().map(|t| (t.id.as_str(), t.label.to_lowercase())).collect();
296 let words: Vec<String> = filter.to_lowercase().split_whitespace().map(str::to_string).collect();
297 host.processes.retain(|p| {
298 let task = p.task.as_deref().and_then(|t| labels.get(t)).map_or("", String::as_str);
299 let detail = p.detail.as_deref().unwrap_or("").to_lowercase();
300 let hay = format!("{} {} {task} {detail}", p.name.to_lowercase(), p.pid);
301 words.iter().all(|w| hay.contains(w.as_str()))
302 });
303 host.matched = host.processes.len() as u32;
304 keep_top(&mut host.processes, top);
305}
306
307fn keep_top(processes: &mut Vec<TowerProcess>, top: usize) {
310 if processes.len() <= top {
311 return;
312 }
313 let mut by_cpu: Vec<usize> = (0..processes.len()).collect();
314 by_cpu.sort_by(|&a, &b| processes[b].cpu.unwrap_or(-1.0).total_cmp(&processes[a].cpu.unwrap_or(-1.0)));
315 let mut by_mem: Vec<usize> = (0..processes.len()).collect();
316 by_mem.sort_by_key(|&i| std::cmp::Reverse(processes[i].mem.map_or(-1, |m| m as i128)));
317 let keep: HashSet<usize> = by_cpu.into_iter().take(top).chain(by_mem.into_iter().take(top)).collect();
318 let mut i = 0;
319 processes.retain(|_| {
320 i += 1;
321 keep.contains(&(i - 1))
322 });
323}
324
325fn proc_id(p: &ProcInfo) -> String {
326 format!("{}:{}", p.pid, p.start_key)
327}
328
329
330struct State {
331 procs: RateMeter<ProcKey>,
332 tasks: RateMeter<String>,
333 sticky: HashMap<ProcKey, String>,
334 describer: Describer,
336 last: Option<Sampled>,
340 peaks: HashMap<ProcKey, u64>,
342 exited: VecDeque<Exited>,
344}
345
346struct Sampled {
349 at: Instant,
350 read_started: Instant,
353 ts_ms: u64,
354 procs: Vec<ProcInfo>,
355 rates: Vec<Option<f64>>,
356 grouping: Grouping,
357 account_rates: HashMap<String, Option<f64>>,
358 details: HashMap<usize, String>,
360 peaks: Vec<Option<u64>>,
362 exited: Vec<Exited>,
364}
365
366pub struct Tower {
369 state: Mutex<State>,
370}
371
372impl Default for Tower {
373 fn default() -> Self {
374 Self::new()
375 }
376}
377
378impl Tower {
379 pub fn new() -> Self {
380 Self {
381 state: Mutex::new(State {
382 procs: RateMeter::new(),
383 tasks: RateMeter::new(),
384 sticky: HashMap::new(),
385 describer: Describer::default(),
386 last: None,
387 peaks: HashMap::new(),
388 exited: VecDeque::new(),
389 }),
390 }
391 }
392
393 pub fn global() -> &'static Tower {
394 static GLOBAL: OnceLock<Tower> = OnceLock::new();
395 GLOBAL.get_or_init(Tower::new)
396 }
397
398 pub fn live_pids_since(&self, since: Instant) -> Option<HashSet<u32>> {
404 let st = self.state.lock().unwrap_or_else(|e| e.into_inner());
405 let last = st.last.as_ref().filter(|l| l.read_started >= since)?;
406 Some(last.procs.iter().map(|p| p.pid).collect())
407 }
408
409 pub fn sample(
414 &self,
415 want_host: bool,
416 hostname: &str,
417 inputs: impl FnOnce() -> Inputs,
418 labels: impl Fn(&str) -> Option<BlockLabel>,
419 renderer: impl Fn(u32) -> Option<String>,
420 ) -> std::io::Result<TowerSnapshot> {
421 self.sample_from(agentmux_procstats::snapshot, want_host, hostname, inputs, labels, renderer)
422 }
423
424 fn sample_from(
426 &self,
427 snapshot: impl FnOnce() -> std::io::Result<Vec<ProcInfo>>,
428 want_host: bool,
429 hostname: &str,
430 inputs: impl FnOnce() -> Inputs,
431 labels: impl Fn(&str) -> Option<BlockLabel>,
432 renderer: impl Fn(u32) -> Option<String>,
433 ) -> std::io::Result<TowerSnapshot> {
434 let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
435 let fresh = st.last.as_ref().is_some_and(|l| l.at.elapsed() < REUSE_WITHIN);
436 if !fresh {
437 let read_started = Instant::now();
438 let procs = snapshot()?;
439 let now = Instant::now();
440 if st.last.as_ref().is_some_and(|l| now.duration_since(l.at) > STALE_AFTER) {
441 st.procs = RateMeter::new();
442 st.tasks = RateMeter::new();
443 }
444 let mut sampled = advance(&mut st, procs, &inputs(), now, &agentmux_procstats::command_line_of);
445 sampled.read_started = read_started;
446 st.last = Some(sampled);
447 }
448 let last = st.last.as_ref().expect("measured above");
449 Ok(render(last, want_host, hostname, &labels, &renderer))
450 }
451}
452
453pub fn block_label(mstore: &crate::backend::storage::store::Store, block_id: &str) -> Option<BlockLabel> {
456 use crate::backend::{blockcontroller, obj};
457 let block = mstore.get::<obj::Block>(block_id).ok().flatten()?;
458 let meta = |key: &str| obj::meta_get_string(&block.meta, key, "");
459 let agent_name = [meta("agentName"), meta("agentId")].into_iter().find(|s| !s.is_empty());
460 let agent = agent_name.is_some()
461 || blockcontroller::get_controller(block_id).is_some_and(|c| c.get_runtime_status().is_agent_pane);
462 let label = agent_name.unwrap_or_else(|| {
463 let title = meta("frame:title");
464 let connection = meta("connection");
465 if !title.is_empty() {
466 title
467 } else if !connection.is_empty() && connection != "local" {
468 format!("Terminal ({connection})")
469 } else if agent {
470 "Agent".to_string()
471 } else {
472 "Terminal".to_string()
473 }
474 });
475 Some(BlockLabel { label, agent })
476}
477
478fn advance(
483 st: &mut State,
484 procs: Vec<ProcInfo>,
485 inputs: &Inputs,
486 now: Instant,
487 cmdline: &dyn Fn(ProcKey) -> Option<String>,
488) -> Sampled {
489 let grouping = group(&procs, inputs, &st.sticky);
490 let details = st.describer.describe(&procs, &grouping.agentmux, inputs.own_pid, cmdline);
491 let (peaks, exited) = track_peaks_and_exits(st, &procs, now);
492 let rates: Vec<Option<f64>> = procs
495 .iter()
496 .map(|p| p.cpu_ns.and_then(|ns| st.procs.sample(p.key(), ns, now)))
497 .collect();
498 st.procs.finish();
499 let mut account_rates = HashMap::new();
500 let mut sticky = HashMap::new();
501 for g in &grouping.tasks {
502 if let Some(ns) = g.cpu_time_ns {
503 account_rates.insert(g.id.clone(), st.tasks.sample(g.id.clone(), ns, now));
504 }
505 for &(i, _) in &g.members {
506 sticky.insert(procs[i].key(), g.id.clone());
507 }
508 }
509 st.tasks.finish();
510 st.sticky = sticky;
511 Sampled {
512 at: now,
513 read_started: now,
514 ts_ms: agentmux_common::time::now_ms() as u64,
515 procs,
516 rates,
517 grouping,
518 account_rates,
519 details,
520 peaks,
521 exited,
522 }
523}
524
525fn renderer_suffix(detail: &str, pid: u32, renderer: &dyn Fn(u32) -> Option<String>) -> Option<String> {
527 (detail == "Renderer").then(|| renderer(pid)).flatten()
528}
529
530fn track_peaks_and_exits(st: &mut State, procs: &[ProcInfo], now: Instant) -> (Vec<Option<u64>>, Vec<Exited>) {
534 let live: HashSet<ProcKey> = procs.iter().map(|p| p.key()).collect();
535 let recent = st.last.as_ref().filter(|prev| now.duration_since(prev.at) <= STALE_AFTER);
539 if let Some(prev) = recent {
540 let gone_ms = agentmux_common::time::now_ms() as u64;
541 for g in &prev.grouping.tasks {
542 for &(i, role) in &g.members {
543 let p = &prev.procs[i];
544 if live.contains(&p.key()) {
545 continue;
546 }
547 let peak = st.peaks.get(&p.key()).copied().or(p.mem_private);
548 st.exited.push_back(Exited { info: p.clone(), task: g.id.clone(), role, peak, gone: now, gone_ms });
549 }
550 }
551 }
552 st.exited.retain(|e| now.duration_since(e.gone) < EXITED_LINGER && !live.contains(&e.info.key()));
555 while st.exited.len() > MAX_EXITED {
556 st.exited.pop_front();
557 }
558 let mut peaks = HashMap::with_capacity(procs.len());
559 for p in procs {
560 let peak = match (st.peaks.get(&p.key()).copied(), p.mem_private) {
561 (Some(a), Some(b)) => Some(a.max(b)),
562 (a, b) => a.or(b),
563 };
564 if let Some(v) = peak {
565 peaks.insert(p.key(), v);
566 }
567 }
568 st.peaks = peaks;
569 let by_index = procs.iter().map(|p| st.peaks.get(&p.key()).copied()).collect();
570 (by_index, st.exited.iter().cloned().collect())
571}
572
573fn sum_known(rates: impl Iterator<Item = Option<f64>>) -> Option<f64> {
576 rates.flatten().fold(None, |acc, r| Some(acc.unwrap_or(0.0) + r))
577}
578
579fn render(
581 s: &Sampled,
582 want_host: bool,
583 hostname: &str,
584 labels: &dyn Fn(&str) -> Option<BlockLabel>,
585 renderer: &dyn Fn(u32) -> Option<String>,
586) -> TowerSnapshot {
587 let (procs, rates, grouping) = (&s.procs, &s.rates, &s.grouping);
588 let row = |i: usize, role: Option<TowerProcessRole>, task: Option<String>| {
589 let p = &procs[i];
590 TowerProcess {
591 id: proc_id(p),
592 pid: p.pid,
593 ppid: p.ppid,
594 name: p.name.clone(),
595 started_at_ms: p.started_at_ms,
596 cpu: rates[i],
597 mem: p.mem_private,
598 mem_resident: p.mem_resident,
599 mem_commit: p.mem_commit,
600 role,
601 task,
602 detail: s.details.get(&i).map(|d| match renderer_suffix(d, p.pid, renderer) {
603 Some(serves) => format!("{d} · {serves}"),
604 None => d.clone(),
605 }),
606 cpu_time_ns: p.cpu_ns,
607 peak_mem: s.peaks.get(i).copied().flatten(),
608 exited_ms: None,
609 started_by: crate::backend::tool_calls::lookup(p.pid, p.started_at_ms).map(|c| c.description),
610 }
611 };
612 let exited_of = |task: &str| -> Vec<TowerProcess> {
613 s.exited
614 .iter()
615 .filter(|e| e.task == task)
616 .map(|e| TowerProcess {
617 id: proc_id(&e.info),
618 pid: e.info.pid,
619 ppid: e.info.ppid,
620 name: e.info.name.clone(),
621 started_at_ms: e.info.started_at_ms,
622 role: Some(e.role),
623 cpu_time_ns: e.info.cpu_ns,
624 peak_mem: e.peak,
625 exited_ms: Some(e.gone_ms),
626 started_by: crate::backend::tool_calls::lookup(e.info.pid, e.info.started_at_ms).map(|c| c.description),
627 ..Default::default()
628 })
629 .collect()
630 };
631
632 let mut tasks = Vec::new();
633 for g in &grouping.tasks {
634 let cpu = if g.cpu_time_ns.is_some() {
635 s.account_rates.get(&g.id).copied().flatten()
636 } else {
637 sum_known(g.members.iter().map(|m| rates[m.0]))
638 };
639 let exited = exited_of(&g.id);
640 if g.members.is_empty() && cpu.unwrap_or(0.0) <= 0.0 && exited.is_empty() {
643 continue;
644 }
645 let Some(label) = labels(&g.id) else { continue };
646 tasks.push(TowerTask {
647 id: g.id.clone(),
648 kind: if label.agent { TowerTaskKind::Agent } else { TowerTaskKind::Terminal },
649 label: label.label,
650 tracking: g.tracking.to_string(),
651 cpu,
652 cpu_account: g.cpu_time_ns.is_some(),
653 mem: g.members.iter().filter_map(|&(i, _)| procs[i].mem_private).sum(),
654 processes: g.members.iter().map(|&(i, role)| row(i, Some(role), None)).collect(),
655 exited: (!exited.is_empty()).then_some(exited),
656 });
657 }
658
659 if !grouping.agentmux.is_empty() {
660 tasks.push(TowerTask {
661 id: AGENTMUX_TASK_ID.to_string(),
662 kind: TowerTaskKind::Agentmux,
663 label: "AgentMux".to_string(),
664 tracking: tracking_label(TrackingConfidence::None).to_string(),
665 cpu: sum_known(grouping.agentmux.iter().map(|&i| rates[i])),
666 cpu_account: false,
667 mem: grouping.agentmux.iter().filter_map(|&i| procs[i].mem_private).sum(),
668 processes: grouping
669 .agentmux
670 .iter()
671 .map(|&i| row(i, Some(TowerProcessRole::Main), None))
672 .collect(),
673 exited: None,
674 });
675 }
676
677 let host = want_host.then(|| {
678 let mut owner: HashMap<usize, String> = HashMap::new();
679 for t in &grouping.tasks {
680 for &(i, _) in &t.members {
681 owner.insert(i, t.id.clone());
682 }
683 }
684 for &i in &grouping.agentmux {
685 owner.insert(i, AGENTMUX_TASK_ID.to_string());
686 }
687 TowerHost {
688 processes: (0..procs.len()).map(|i| row(i, None, owner.get(&i).cloned())).collect(),
689 unmeasured: procs.iter().filter(|p| !p.measured()).count() as u32,
690 cpu: sum_known(rates.iter().copied()),
691 mem: procs.iter().filter_map(|p| p.mem_private).sum(),
692 total: procs.len() as u32,
693 matched: procs.len() as u32,
694 }
695 });
696
697 TowerSnapshot {
698 ts_ms: s.ts_ms,
699 hostname: hostname.to_string(),
700 os: std::env::consts::OS.to_string(),
701 cpu_count: agentmux_procstats::cpu_count() as u32,
702 memory_metric: agentmux_procstats::MEMORY_METRIC.to_string(),
703 interval_ms: INTERVAL.as_millis() as u32,
704 remote: false,
705 tasks,
706 machine: Some(TowerMachine {
709 cpu: sum_known(rates.iter().copied()),
710 mem: procs.iter().filter_map(|p| p.mem_private).sum(),
711 processes: procs.len() as u32,
712 }),
713 host,
714 }
715}
716
717#[cfg(test)]
718mod tests {
719
720 #[test]
723 fn live_pids_answer_only_for_a_sample_read_at_or_after_the_moment_asked() {
724 let tower = Tower::new();
725 let before = Instant::now();
726 assert_eq!(tower.live_pids_since(before), None, "no sample yet");
727 let one = || {
728 Ok(vec![p(42, 1, "sh")])
729 };
730 tower.sample_from(one, false, "", Inputs::default, |_| None, |_| None).unwrap();
731 assert_eq!(tower.live_pids_since(before), Some(HashSet::from([42])));
732 let later = Instant::now() + Duration::from_millis(1);
733 assert_eq!(tower.live_pids_since(later), None, "that sample was read before `later`");
734 }
735
736 #[test]
739 fn a_read_that_began_before_the_moment_asked_does_not_count_though_it_ended_after() {
740 let tower = Tower::new();
741 let mid = std::cell::Cell::new(None);
742 let slow = || {
743 std::thread::sleep(Duration::from_millis(3));
744 mid.set(Some(Instant::now()));
745 std::thread::sleep(Duration::from_millis(3));
746 Ok(vec![p(42, 1, "sh")])
747 };
748 tower.sample_from(slow, false, "", Inputs::default, |_| None, |_| None).unwrap();
749 assert_eq!(tower.live_pids_since(mid.get().unwrap()), None);
750 }
751 use super::*;
752
753 fn build(
754 st: &mut State,
755 procs: &[ProcInfo],
756 inputs: &Inputs,
757 now: Instant,
758 want_host: bool,
759 hostname: &str,
760 labels: &dyn Fn(&str) -> Option<BlockLabel>,
761 ) -> TowerSnapshot {
762 let sampled = advance(st, procs.to_vec(), inputs, now, &|_| None);
763 let snap = render(&sampled, want_host, hostname, labels, &|_| None);
764 st.last = Some(sampled);
766 snap
767 }
768
769 fn state() -> State {
770 State {
771 procs: RateMeter::new(),
772 tasks: RateMeter::new(),
773 sticky: HashMap::new(),
774 describer: Describer::default(),
775 last: None,
776 peaks: HashMap::new(),
777 exited: VecDeque::new(),
778 }
779 }
780
781 fn p(pid: u32, ppid: u32, name: &str) -> ProcInfo {
782 ProcInfo {
783 pid,
784 ppid: Some(ppid).filter(|&x| x != 0),
785 start_key: pid as u64 * 10,
786 started_at_ms: Some(pid as u64 * 10),
787 name: name.to_string(),
788 cpu_ns: Some(0),
789 mem_private: Some(1000),
790 mem_resident: Some(2000),
791 mem_commit: None,
792 }
793 }
794
795 fn tracked(block: &str, pids: &[u32], roots: &[u32]) -> BlockMembers {
796 BlockMembers {
797 block_id: block.to_string(),
798 pids: pids.to_vec(),
799 roots: roots.iter().copied().collect(),
800 confidence: TrackingConfidence::High,
801 cpu_time_ns: Some(0),
802 }
803 }
804
805 fn pids_of(snap: &[ProcInfo], idx: &[usize]) -> Vec<u32> {
806 let mut v: Vec<u32> = idx.iter().map(|&i| snap[i].pid).collect();
807 v.sort();
808 v
809 }
810
811 fn task<'a>(g: &'a Grouping, id: &str) -> &'a Group {
812 g.tasks.iter().find(|t| t.id == id).unwrap_or_else(|| panic!("no task {id}: {g:?}"))
813 }
814
815 fn machine() -> Vec<ProcInfo> {
818 vec![
819 p(1, 0, "init"),
820 p(100, 1, "agentmux-launcher.exe"),
821 p(110, 100, "agentmux-srv.exe"),
822 p(120, 100, "agentmux-cef.exe"),
823 p(121, 120, "agentmux-cef.exe"),
824 p(200, 110, "claude.exe"),
825 p(201, 200, "agentmux-mcp.exe"),
826 p(202, 200, "bash.exe"),
827 p(203, 202, "node.exe"),
828 p(300, 110, "pwsh.exe"),
829 p(301, 300, "vim.exe"),
830 p(900, 1, "explorer.exe"),
831 ]
832 }
833
834 #[test]
835 fn tracker_members_tree_and_agentmux_are_grouped_with_roles() {
836 let snap = machine();
837 let inputs = Inputs {
838 blocks: vec![tracked("agent-a", &[200, 201, 202, 203], &[200])],
839 roots: vec![("term-b".into(), 300)],
840 own_pid: 110,
841 };
842 let g = group(&snap, &inputs, &HashMap::new());
843 let a = task(&g, "agent-a");
844 let role = |pid: u32| a.members.iter().find(|(i, _)| snap[*i].pid == pid).unwrap().1;
845 assert_eq!(role(200), TowerProcessRole::Main);
846 assert_eq!(role(201), TowerProcessRole::Support, "the MCP server is the agent's plumbing");
847 assert_eq!(role(202), TowerProcessRole::Started);
848 assert_eq!(role(203), TowerProcessRole::Started);
849 assert_eq!(a.tracking, "high");
850
851 let b = task(&g, "term-b");
852 assert_eq!(pids_of(&snap, &b.members.iter().map(|m| m.0).collect::<Vec<_>>()), vec![300, 301]);
853 assert_eq!(b.tracking, "tree");
854
855 assert_eq!(pids_of(&snap, &g.agentmux), vec![100, 110, 120, 121]);
858 }
859
860 #[test]
861 fn a_tracker_covers_a_block_its_tree_would_have() {
862 let snap = machine();
864 let inputs = Inputs {
865 blocks: vec![tracked("term-b", &[300, 301], &[300])],
866 roots: vec![("term-b".into(), 300)],
867 own_pid: 110,
868 };
869 let g = group(&snap, &inputs, &HashMap::new());
870 assert_eq!(g.tasks.len(), 1);
871 assert_eq!(task(&g, "term-b").tracking, "high");
872 }
873
874 #[test]
875 fn a_reparented_process_stays_in_its_task() {
876 let mut snap = machine();
878 snap.retain(|x| x.pid != 300);
879 snap.iter_mut().find(|x| x.pid == 301).unwrap().ppid = Some(1);
880 let inputs = Inputs { blocks: vec![], roots: vec![("term-b".into(), 300)], own_pid: 110 };
881 let g = group(&snap, &inputs, &HashMap::from([(snap.iter().find(|x| x.pid == 301).unwrap().key(), "term-b".to_string())]));
884 assert!(g.tasks.is_empty());
885
886 let mut snap = machine();
889 snap.push(p(302, 1, "server.exe"));
890 let sticky = HashMap::from([(snap.last().unwrap().key(), "term-b".to_string())]);
891 let inputs = Inputs { blocks: vec![], roots: vec![("term-b".into(), 300)], own_pid: 110 };
892 let g = group(&snap, &inputs, &sticky);
893 let members: Vec<usize> = task(&g, "term-b").members.iter().map(|m| m.0).collect();
894 assert_eq!(pids_of(&snap, &members), vec![300, 301, 302]);
895 }
896
897 #[test]
898 fn a_reused_parent_pid_is_not_trusted() {
899 let mut snap = machine();
901 let newer = snap.iter_mut().find(|x| x.pid == 300).unwrap();
902 newer.started_at_ms = Some(999_999);
903 newer.start_key = 999_999;
904 let inputs = Inputs { blocks: vec![], roots: vec![("term-b".into(), 300)], own_pid: 110 };
905 let g = group(&snap, &inputs, &HashMap::new());
906 let members: Vec<usize> = task(&g, "term-b").members.iter().map(|m| m.0).collect();
907 assert_eq!(pids_of(&snap, &members), vec![300], "vim is not the newer process's child");
908 }
909
910 #[test]
911 fn agentmux_stops_at_a_parent_that_is_not_agentmux() {
912 let snap = vec![p(1, 0, "init"), p(50, 1, "bash"), p(110, 50, "agentmux-srv"), p(111, 110, "agentmux-srv")];
914 let g = group(&snap, &Inputs { own_pid: 110, ..Default::default() }, &HashMap::new());
915 assert_eq!(pids_of(&snap, &g.agentmux), vec![110, 111]);
916 }
917
918 fn label(id: &str) -> Option<BlockLabel> {
919 Some(BlockLabel { label: format!("label {id}"), agent: id.starts_with("agent") })
920 }
921
922 #[test]
926 fn a_task_process_that_exits_lingers_a_minute_with_what_it_used() {
927 let mut st = state();
928 let t0 = Instant::now();
929 let mut snap = machine();
930 let inputs = Inputs { blocks: vec![tracked("agent-a", &[200, 201, 202, 203], &[200])], roots: vec![], own_pid: 110 };
931 let node = |snap: &mut Vec<ProcInfo>| snap.iter_mut().find(|x| x.pid == 203).unwrap().clone();
932 let set = |snap: &mut Vec<ProcInfo>, mem: u64, cpu_ns: u64| {
933 let n = snap.iter_mut().find(|x| x.pid == 203).unwrap();
934 n.mem_private = Some(mem);
935 n.cpu_ns = Some(cpu_ns);
936 };
937 set(&mut snap, 400, 1_000_000_000);
938 build(&mut st, &snap, &inputs, t0, false, "host", &label);
939 set(&mut snap, 100, 2_000_000_000);
940 let second = build(&mut st, &snap, &inputs, t0 + Duration::from_secs(2), false, "host", &label);
941 let task = |s: &TowerSnapshot| s.tasks.iter().find(|t| t.id == "agent-a").cloned().unwrap();
942 let live = task(&second).processes.into_iter().find(|p| p.pid == 203).unwrap();
943 assert_eq!((live.peak_mem, live.cpu_time_ns), (Some(400), Some(2_000_000_000)), "the peak, not the current");
944 assert!(task(&second).exited.is_none());
945
946 let gone_key = node(&mut snap).key();
947 snap.retain(|x| x.pid != 203);
948 let third = build(&mut st, &snap, &inputs, t0 + Duration::from_secs(4), false, "host", &label);
949 let t = task(&third);
950 assert!(t.processes.iter().all(|p| p.pid != 203), "an exited process isn't running");
951 let exited = t.exited.unwrap();
952 assert_eq!(exited.len(), 1);
953 let e = &exited[0];
954 assert_eq!(e.id, format!("{}:{}", gone_key.pid, gone_key.start_key));
955 assert_eq!((e.peak_mem, e.cpu_time_ns, e.cpu, e.mem), (Some(400), Some(2_000_000_000), None, None));
956 assert_eq!(e.role, Some(TowerProcessRole::Started));
957 assert!(e.exited_ms.is_some());
958
959 let later = build(&mut st, &snap, &inputs, t0 + Duration::from_secs(4) + EXITED_LINGER, false, "host", &label);
960 assert!(task(&later).exited.is_none(), "gone after the linger");
961 }
962
963 #[test]
966 fn processes_gone_during_a_gap_are_not_listed_as_just_exited() {
967 let mut st = state();
968 let t0 = Instant::now();
969 let mut snap = machine();
970 let inputs = Inputs { blocks: vec![tracked("agent-a", &[200, 201, 202, 203], &[200])], roots: vec![], own_pid: 110 };
971 build(&mut st, &snap, &inputs, t0, false, "host", &label);
972 snap.retain(|x| x.pid != 203);
973 let after_gap = build(&mut st, &snap, &inputs, t0 + STALE_AFTER + Duration::from_secs(1), false, "host", &label);
974 let task = after_gap.tasks.iter().find(|t| t.id == "agent-a").unwrap();
975 assert!(task.exited.is_none(), "nothing just exited after a gap");
976 }
977
978 #[test]
979 fn rates_come_from_the_job_account_and_the_process_deltas() {
980 let mut st = state();
981 let t0 = Instant::now();
982 let mut snap = machine();
983 let mut inputs = Inputs {
984 blocks: vec![tracked("agent-a", &[200, 201, 202, 203], &[200])],
985 roots: vec![("term-b".into(), 300)],
986 own_pid: 110,
987 };
988 let first = build(&mut st, &snap, &inputs, t0, true, "host", &label);
989 assert!(first.tasks.iter().all(|t| t.cpu.is_none()), "no rates on the first sample");
990 assert_eq!(first.host.as_ref().unwrap().cpu, None, "nor a made-up 0% for the machine");
991
992 snap.iter_mut().find(|x| x.pid == 203).unwrap().cpu_ns = Some(500_000_000);
995 snap.iter_mut().find(|x| x.pid == 301).unwrap().cpu_ns = Some(250_000_000);
996 inputs.blocks[0].cpu_time_ns = Some(2_000_000_000);
997 let second = build(&mut st, &snap, &inputs, t0 + Duration::from_secs(1), true, "host", &label);
998 let t = |id: &str| second.tasks.iter().find(|t| t.id == id).unwrap();
999 assert!((t("agent-a").cpu.unwrap() - 2.0).abs() < 1e-9, "the job account, exited members included");
1000 assert!(t("agent-a").cpu_account);
1001 assert!((t("term-b").cpu.unwrap() - 0.25).abs() < 1e-9, "the tree's per-process sum");
1002 assert_eq!(t("agent-a").kind, TowerTaskKind::Agent);
1003 assert_eq!(t("term-b").kind, TowerTaskKind::Terminal);
1004 assert_eq!(t("agent-a").mem, 4000);
1005 assert_eq!(t(AGENTMUX_TASK_ID).kind, TowerTaskKind::Agentmux);
1006
1007 let host = second.host.unwrap();
1008 assert_eq!(host.processes.len(), snap.len());
1009 assert!((host.cpu.unwrap() - 0.75).abs() < 1e-9);
1010 let node = host.processes.iter().find(|x| x.pid == 203).unwrap();
1011 assert_eq!(node.task.as_deref(), Some("agent-a"));
1012 assert!(host.processes.iter().find(|x| x.pid == 900).unwrap().task.is_none());
1013 assert_eq!(node.id, "203:2030");
1014
1015 let machine = second.machine.clone().unwrap();
1018 assert_eq!(machine.cpu, host.cpu);
1019 assert_eq!(machine.mem, host.mem);
1020 assert_eq!(machine.processes as usize, snap.len());
1021 let without_list = build(&mut st, &snap, &inputs, t0 + Duration::from_secs(2), false, "host", &label);
1024 assert!(without_list.host.is_none());
1025 let again = without_list.machine.unwrap();
1026 assert_eq!((again.mem, again.processes), (machine.mem, machine.processes));
1027 assert_eq!(first.machine.unwrap().cpu, None, "no made-up 0% on the first sample");
1028 }
1029
1030 #[test]
1033 fn a_tree_whose_processes_all_exited_still_reports_their_cpu() {
1034 let mut st = state();
1035 let t0 = Instant::now();
1036 let mut inputs = Inputs { blocks: vec![tracked("agent-a", &[], &[200])], roots: vec![], own_pid: 110 };
1037 inputs.blocks[0].cpu_time_ns = Some(1_000_000_000);
1038 let first = build(&mut st, &machine(), &inputs, t0, false, "h", &label);
1039 assert!(first.tasks.iter().all(|t| t.id != "agent-a"), "nothing measured yet, nothing running: no row");
1040 inputs.blocks[0].cpu_time_ns = Some(3_000_000_000);
1041 let second = build(&mut st, &machine(), &inputs, t0 + Duration::from_secs(1), false, "h", &label);
1042 let a = second.tasks.iter().find(|t| t.id == "agent-a").expect("its CPU is reported");
1043 assert!((a.cpu.unwrap() - 2.0).abs() < 1e-9);
1044 assert!(a.processes.is_empty());
1045 let third = build(&mut st, &machine(), &inputs, t0 + Duration::from_secs(2), false, "h", &label);
1047 assert!(third.tasks.iter().all(|t| t.id != "agent-a"));
1048 }
1049
1050 #[test]
1053 fn a_shared_snapshot_hides_hidden_agents_and_keeps_the_top() {
1054 let mut st = state();
1055 let inputs = Inputs {
1056 blocks: vec![tracked("agent-a", &[200, 201, 202, 203], &[200]), tracked("agent-b", &[300, 301], &[300])],
1057 roots: vec![],
1058 own_pid: 110,
1059 };
1060 let mut snap = build(&mut st, &machine(), &inputs, Instant::now(), true, "h", &label);
1061 share(&mut snap, 2, "", &|id| id == "agent-b");
1062 assert!(snap.remote);
1063 assert!(snap.tasks.iter().all(|t| t.id != "agent-b"));
1064 let host = snap.host.as_ref().unwrap();
1065 assert!(host.processes.iter().all(|p| p.task.as_deref() != Some("agent-b")));
1066 assert!(host.processes.len() <= 4, "two lists of two");
1067 assert_eq!(host.total, machine().len() as u32, "totals still count everything");
1068 let a = snap.tasks.iter().find(|t| t.id == "agent-a").unwrap();
1069 assert!(a.processes.len() <= 4, "a task's own list is capped too");
1070 assert_eq!(a.mem, 4000, "but its totals count every process");
1071
1072 let mut snap = build(&mut st, &machine(), &inputs, Instant::now(), true, "h", &label);
1073 share(&mut snap, 100, "node", &|_| false);
1074 assert_eq!(snap.host.as_ref().unwrap().matched, 1);
1075 assert_eq!(snap.host.unwrap().processes[0].name, "node.exe");
1076
1077 let mut snap = build(&mut st, &machine(), &inputs, Instant::now(), true, "h", &label);
1079 share(&mut snap, 100, "agent-b", &|_| false);
1080 let names: Vec<String> = snap.host.unwrap().processes.into_iter().map(|p| p.name).collect();
1081 assert_eq!(names.len(), 2, "{names:?}");
1082 }
1083
1084 #[test]
1085 fn a_block_without_a_label_is_left_out() {
1086 let mut st = state();
1087 let inputs = Inputs { blocks: vec![tracked("gone", &[200], &[200])], roots: vec![], own_pid: 110 };
1088 let s = build(&mut st, &machine(), &inputs, Instant::now(), false, "h", &|_| None);
1089 assert!(s.tasks.iter().all(|t| t.id != "gone"));
1090 assert!(s.host.is_none());
1091 }
1092
1093 #[test]
1096 fn host_rows_added_within_the_reuse_window_come_from_the_same_measurement() {
1097 let tower = Tower::new();
1098 let reads = std::cell::Cell::new(0);
1099 let table = || {
1100 reads.set(reads.get() + 1);
1101 Ok(machine())
1102 };
1103 let inputs = || Inputs { own_pid: 110, ..Default::default() };
1104 let tasks_only = tower.sample_from(table, false, "h", inputs, label, |_| None).unwrap();
1105 assert!(tasks_only.host.is_none());
1106 let with_host = tower.sample_from(table, true, "h", inputs, label, |_| None).unwrap();
1107 assert_eq!(reads.get(), 1, "one read for both");
1108 assert_eq!(with_host.ts_ms, tasks_only.ts_ms);
1109 assert_eq!(with_host.host.unwrap().processes.len(), machine().len());
1110 }
1111
1112 #[test]
1116 fn a_real_sample_and_its_reuse() {
1117 let tower = Tower::new();
1118 let inputs = || Inputs { own_pid: std::process::id(), ..Default::default() };
1119 let a = tower.sample(true, "h", inputs, |_| None, |_| None).unwrap();
1120 let b = tower.sample(false, "h", inputs, |_| None, |_| None).unwrap();
1121 assert_eq!(a.ts_ms, b.ts_ms, "reused");
1122 assert!(a.host.as_ref().unwrap().processes.iter().any(|x| x.pid == std::process::id()));
1123 assert!(a.cpu_count >= 1);
1124 }
1125}