Skip to main content

agentmux_srv/backend/
tower_sampler.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tower: CPU and memory per task, for the read-only task manager pane
5//! (SPEC_TOWER_TASK_MANAGER_PANE_2026_10_08.md).
6//!
7//! A **task** is a pane and every process it started, plus one more for
8//! AgentMux itself. Each sample takes one process snapshot
9//! (`agentmux_procstats`) and groups it:
10//!
11//! 1. **The pane's tracker** (`process_tracker`): a Job Object or cgroup on
12//!    an agent, which nothing it starts can leave; a best-effort scan on
13//!    macOS.
14//! 2. **The pane's process tree** where there is no tracker membership (a
15//!    terminal on Linux or macOS): descendants of its shell.
16//! 3. **Sticky membership**: a process seen in a task stays in it until it
17//!    exits, even after it is reparented away from the tree.
18//! 4. **AgentMux**: the backend's own ancestry while it is AgentMux's
19//!    (launcher, window host) and everything under it no task claimed.
20//!
21//! Sampling is driven by the pane (`tower.sample`): nothing runs while no
22//! Tower is open, and two panes asking at once share a sample.
23
24use 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
35/// `TowerTask::id` of AgentMux's own processes.
36pub const AGENTMUX_TASK_ID: &str = "agentmux";
37
38/// How often the pane asks while it is visible.
39pub const INTERVAL: Duration = Duration::from_secs(2);
40
41/// A request this soon after the last sample gets that sample: two Tower panes
42/// (or a re-render) don't double the cost or shrink the CPU window.
43const REUSE_WITHIN: Duration = Duration::from_millis(900);
44
45/// A gap longer than this since the last sample starts the CPU rates over, so
46/// a pane that was hidden for minutes doesn't show minutes-long averages.
47const STALE_AFTER: Duration = Duration::from_secs(10);
48
49/// Most processes one task's tree walk takes (a fork bomb shouldn't stall a
50/// sample).
51const MAX_TREE: usize = 2048;
52
53/// How long a task's exited process stays listed (dimmed, with what it
54/// used): long enough to see a build's compilers that a 2 s sample catches
55/// once or never. SPEC_TOWER_AGENT_CENTRIC_VIEWS_2026_10_08.md §5.4.
56const EXITED_LINGER: Duration = Duration::from_secs(60);
57
58/// At most this many exited processes are kept, the newest: a big build
59/// runs thousands of compilers a minute.
60const MAX_EXITED: usize = 500;
61
62/// A task's process that has exited: what it was when last seen.
63#[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/// What the sampler is told about the panes.
74#[derive(Debug, Default)]
75pub struct Inputs {
76    /// Every block with a tracker.
77    pub blocks: Vec<BlockMembers>,
78    /// `(block id, its process)` for blocks registered for per-pane stats
79    /// (terminal shells), the tree walk's starting points.
80    pub roots: Vec<(String, u32)>,
81    /// This backend's PID: where the AgentMux task is found from.
82    pub own_pid: u32,
83}
84
85/// How a block is named and what kind of task it is.
86#[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    /// Indices into the snapshot, with each one's role.
98    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
119/// Group a snapshot into tasks (module doc). Pure: everything it needs is in
120/// its arguments.
121fn 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    // Parent → children, trusting a parent only if it really is one (a
124    // Windows parent PID can name a newer process that reused it).
125    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    // Snapshot index → its group's index in `groups`.
152    let mut claimed: HashMap<usize, usize> = HashMap::new();
153
154    // 1. Trackers.
155    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        // A tree whose processes all came and went between two samples still
165        // has its CPU account: keep it, so that work is counted. It is shown
166        // only while that account moved (`render`).
167        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    // 2. Process trees, for blocks the trackers didn't cover.
208    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    // 3. Sticky: still alive, not claimed this round, its task still here.
235    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    // 4. AgentMux: up from this backend while the parent is AgentMux's, then
249    //    everything under that no task claimed.
250    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
269/// Rows a snapshot sent to another machine carries per list (busiest,
270/// largest), as a remote frame does (`agentmux_remote::procs`).
271pub const SHARED_TOP: usize = 100;
272
273/// Make a snapshot fit to send to a paired device: drop the tasks `hidden`
274/// names (an agent hidden from paired devices) and their processes, keep the
275/// host rows matching every word of `filter`, then only the `top` busiest
276/// and `top` largest of those. Totals keep counting every process.
277pub fn share(snap: &mut TowerSnapshot, top: usize, filter: &str, hidden: &dyn Fn(&str) -> bool) {
278    snap.remote = true;
279    // Asked once per task (`hidden` reads the store), then used for every row.
280    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    // A task's own list is capped like the host's, so one process-heavy agent
288    // can't make every frame megabytes (its totals still count every process).
289    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    // Name, PID and the task's label, as the pane's own filter matches.
295    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
307/// Keep the `top` busiest by CPU and the `top` largest by memory (their
308/// union, in their original order).
309fn 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    /// What each of AgentMux's own processes is (`tower_agentmux`).
335    describer: Describer,
336    /// The last measurement: what a request within [`REUSE_WITHIN`] renders
337    /// from, so it neither costs a second read nor shrinks the CPU window to
338    /// milliseconds (one coarse clock tick over that would read as a spike).
339    last: Option<Sampled>,
340    /// Each live process's most private memory seen.
341    peaks: HashMap<ProcKey, u64>,
342    /// Tasks' processes that exited within [`EXITED_LINGER`], oldest first.
343    exited: VecDeque<Exited>,
344}
345
346/// One measurement: the process table, every process's CPU rate, the grouping
347/// and each task's CPU account rate. [`render`] turns it into an answer.
348struct Sampled {
349    at: Instant,
350    /// When the process-table read began: a process started after this may
351    /// be missing from `procs` though it is running.
352    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    /// AgentMux's own processes: index → what it is (`TowerProcess::detail`).
359    details: HashMap<usize, String>,
360    /// Each process's peak private memory, by index.
361    peaks: Vec<Option<u64>>,
362    /// Tasks' processes that exited within [`EXITED_LINGER`].
363    exited: Vec<Exited>,
364}
365
366/// The process-wide sampler. Its state is the previous sample (for CPU
367/// rates) and sticky membership.
368pub 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    /// The PIDs in the process table of the last sample, if that sample's read
399    /// began at or after `since`: what is missing from it had exited by then.
400    /// `None` for an older sample (one reused within [`REUSE_WITHIN`], the last
401    /// good one before a failed read, or one whose read was already under way),
402    /// which can't speak for a process that started after it.
403    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    /// A sample (module doc). BLOCKING: reads the process table, and `labels`
410    /// and `renderer` may read the store; call it off the async workers.
411    /// `renderer` says what a renderer PID serves ("window Main"), for the
412    /// AgentMux task's rows (`tower_agentmux::renderer_serves`).
413    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    /// [`sample`](Self::sample) with the process table read by `snapshot`.
425    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
453/// A pane's name in Tower and on its stats badge: the agent's name, else what
454/// the terminal runs. `None` for a block that no longer exists.
455pub 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
478/// Measure: group the table, take every process's and task account's rate
479/// since the previous measurement, and remember sticky membership.
480/// `cmdline` reads a process's command line, for labelling AgentMux's own
481/// processes only (`tower_agentmux`).
482fn 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    // Every process's rate, every round, so the Host view has rates the
493    // moment it is opened.
494    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
525/// What a renderer row adds after its type: the window or pane it serves.
526fn renderer_suffix(detail: &str, pid: u32, renderer: &dyn Fn(u32) -> Option<String>) -> Option<String> {
527    (detail == "Renderer").then(|| renderer(pid)).flatten()
528}
529
530/// Update each process's peak memory, and note the tasks' members that were
531/// in the previous measurement and are gone from this one. Returns the
532/// peaks by index and the exited processes still within [`EXITED_LINGER`].
533fn 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    // Only a recent measurement says what exited just now. The sampler runs
536    // while a Tower pane is visible; after a gap (a hidden pane) what is gone
537    // went at any time in it, so nothing is listed as just exited.
538    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    // Gone past the linger, or (a PID reused with the same key is impossible,
553    // but a process seen gone in one read can reappear in the next) back.
554    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
573/// A sum of rates, or `None` when none of them is known yet (a first
574/// sample), never a made-up 0.
575fn 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
579/// The answer from a measurement. Pure apart from `labels` and `renderer`.
580fn 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        // Nothing running, nothing used since the last sample and nothing
641        // that just exited: no row.
642        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        // Already measured for every process each round (`advance`), so the
707        // totals cost nothing even when the full list isn't asked for.
708        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    /// A sample reused from before a moment, or none at all, can't say a
721    /// process that started after it has exited.
722    #[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    /// A read already under way when a shell registered finishes after that
737    /// moment but can still miss the shell: it doesn't count either.
738    #[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        // As `Tower::sample_from` does: the next measurement compares to it.
765        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    /// launcher → srv → claude (job) → mcp, bash → node; launcher → cef host
816    /// → renderer. A terminal: srv → pwsh → vim (no tracker).
817    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        // AgentMux: the launcher (srv's AgentMux parent) and everything under
856        // it that no task claimed; not init, not explorer.
857        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        // The terminal has a Job Object too (Windows): its members win.
863        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        // vim's shell exited; vim was reparented to init.
877        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        // No root, no task — unless the task is still around through another
882        // member; here it isn't, so vim belongs to nobody.
883        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        // With the shell alive (a sibling of vim was reparented), sticky
887        // membership keeps the reparented one in the task.
888        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        // 301's recorded parent (300) exited and a NEWER process got PID 300.
900        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        // Run straight from a terminal (task dev): srv's parent is a shell.
913        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    /// A build's compiler that exits between samples stays in its task's
923    /// `exited` list for a minute, with all the CPU it used and its peak
924    /// memory, then goes; it never counts as running.
925    #[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    /// After a gap (no Tower pane visible), what went missing in it isn't
964    /// listed as just exited.
965    #[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        // One second later: node used 0.5 s; vim 0.25 s; the agent's job
993        // account grew by 2 s (a build that already exited used most of it).
994        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        // The machine's totals come with every sample, the full list or not,
1016        // and are the host list's own.
1017        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        // (`build` measures again: a sample at the same instant has no CPU
1022        // interval, so only what doesn't depend on one is compared.)
1023        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    /// A build that started and finished between two samples: the tree has
1031    /// no live process, but its Job account grew, and that CPU is reported.
1032    #[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        // Idle afterwards: the row goes away again.
1046        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    /// What a paired device gets: no hidden agent, no row of its processes,
1051    /// the filter applied, only the busiest and largest.
1052    #[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        // By the task's label, as the pane's filter matches.
1078        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    /// Opening the Host view right after a Tasks sample renders the same
1094    /// measurement: no second read, so no CPU window of a few milliseconds.
1095    #[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    /// The real sampler on this machine finds this test process as part of
1113    /// "AgentMux"-less grouping without error, and a second request within
1114    /// the reuse window is the same sample.
1115    #[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}