Skip to main content

agentmux_srv/backend/sampler/
panes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Each pane's CPU and memory for its stats badge, taken from Tower's sample
5//! (`backend::tower_sampler`), so the badge and Tower count the same processes
6//! the same way: a pane's Job Object or cgroup, which nothing it starts can
7//! leave, else its shell's process tree. A Tower pane open at the same time
8//! shares the process snapshot (Tower reuses one taken within the last second).
9//! The badge's tooltip detail (`blockstats_detail`) still walks the shell's
10//! tree; moving it onto the same grouping is a follow-up.
11//! docs/reports/REPORT_SYSINFO_REWRITE_AND_MODERN_MONITOR_2026_10_13.md section 5.5.
12
13use std::sync::Arc;
14
15use crate::backend::blockcontroller::pidregistry;
16use crate::backend::blockstats_detail::Shell;
17use crate::backend::rpc_types::{TowerProcessRole, TowerSnapshot, TowerTaskKind};
18use crate::backend::tower_sampler::{BlockLabel, Inputs, Tower};
19
20/// What one pane's badge shows.
21#[derive(Debug, Clone, PartialEq)]
22pub struct PaneStat {
23    pub block_id: String,
24    /// Percent of one core; `None` on the first sample, before there is a rate.
25    pub cpu_pct: Option<f64>,
26    /// Resident memory of its processes, bytes: what the badge calls Memory ("in
27    /// RAM now, including pages shared"). Its tooltip shows private memory
28    /// separately, from the detail.
29    pub mem: u64,
30    pub pids: u32,
31    /// Its main process, so a restarted shell starts a new peak.
32    pub shell: Shell,
33}
34
35/// Measures every pane's stats. Blocking: reads the process table.
36pub type PaneSampler = Arc<dyn Fn() -> std::io::Result<Vec<PaneStat>> + Send + Sync>;
37
38/// The panes' stats in a Tower snapshot: one per pane task, AgentMux's own
39/// task left out.
40pub fn from_snapshot(snap: &TowerSnapshot) -> Vec<PaneStat> {
41    snap.tasks
42        .iter()
43        .filter(|t| t.kind != TowerTaskKind::Agentmux)
44        .map(|t| {
45            let main = t.processes.iter().find(|p| p.role == Some(TowerProcessRole::Main));
46            PaneStat {
47                block_id: t.id.clone(),
48                cpu_pct: t.cpu.map(|c| c * 100.0),
49                mem: t.processes.iter().filter_map(|p| p.mem_resident).sum(),
50                pids: t.processes.len() as u32,
51                shell: main.map_or((0, 0), |p| (p.pid, p.started_at_ms.unwrap_or(0))),
52            }
53        })
54        .collect()
55}
56
57/// The live sampler: Tower's sample of this machine, with each pane named by
58/// `labels` (a pane `labels` doesn't know, such as one that was closed, is left out).
59pub fn tower_sampler(labels: impl Fn(&str) -> Option<BlockLabel> + Send + Sync + 'static) -> PaneSampler {
60    Arc::new(move || {
61        let inputs = || Inputs {
62            blocks: crate::backend::process_tracker::registry::global()
63                .map(|t| t.members_by_block())
64                .unwrap_or_default(),
65            roots: pidregistry::get_all(),
66            own_pid: std::process::id(),
67        };
68        let snap = Tower::global().sample(false, "", inputs, &labels, |_| None)?;
69        Ok(from_snapshot(&snap))
70    })
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::backend::rpc_types::{TowerProcess, TowerTask};
77
78    fn process(pid: u32, role: TowerProcessRole, started: u64) -> TowerProcess {
79        TowerProcess {
80            pid,
81            role: Some(role),
82            started_at_ms: Some(started),
83            mem: Some(1),
84            mem_resident: Some(1000 + u64::from(pid)),
85            ..Default::default()
86        }
87    }
88
89    fn snapshot(tasks: Vec<TowerTask>) -> TowerSnapshot {
90        TowerSnapshot {
91            ts_ms: 0,
92            hostname: String::new(),
93            os: String::new(),
94            cpu_count: 1,
95            memory_metric: String::new(),
96            interval_ms: 2000,
97            remote: false,
98            tasks,
99            machine: None,
100            host: None,
101        }
102    }
103
104    fn task(id: &str, kind: TowerTaskKind, cpu: Option<f64>, mem: u64, processes: Vec<TowerProcess>) -> TowerTask {
105        TowerTask {
106            id: id.to_string(),
107            kind,
108            label: id.to_string(),
109            tracking: "tree".to_string(),
110            cpu,
111            cpu_account: false,
112            mem,
113            processes,
114            exited: None,
115        }
116    }
117
118    #[test]
119    fn each_pane_task_becomes_a_badge_and_agentmux_itself_does_not() {
120        let snap = snapshot(vec![
121                task(
122                    "term-1",
123                    TowerTaskKind::Terminal,
124                    Some(0.25),
125                    4096,
126                    vec![process(10, TowerProcessRole::Main, 111), process(11, TowerProcessRole::Started, 112)],
127                ),
128                task("agent-1", TowerTaskKind::Agent, Some(1.5), 8192, vec![process(20, TowerProcessRole::Main, 222)]),
129                task(crate::backend::tower_sampler::AGENTMUX_TASK_ID, TowerTaskKind::Agentmux, Some(0.1), 1, vec![]),
130        ]);
131        let stats = from_snapshot(&snap);
132        assert_eq!(
133            stats,
134            vec![
135                // Resident memory summed over the processes, not the task's private total.
136                PaneStat { block_id: "term-1".into(), cpu_pct: Some(25.0), mem: 1010 + 1011, pids: 2, shell: (10, 111) },
137                PaneStat { block_id: "agent-1".into(), cpu_pct: Some(150.0), mem: 1020, pids: 1, shell: (20, 222) },
138            ]
139        );
140    }
141
142    #[test]
143    fn a_first_sample_has_no_rate_yet_and_a_task_without_a_main_process_has_no_shell() {
144        let snap = snapshot(vec![task("agent-2", TowerTaskKind::Agent, None, 10, vec![process(30, TowerProcessRole::Support, 5)])]);
145        let s = &from_snapshot(&snap)[0];
146        assert_eq!((s.cpu_pct, s.shell), (None, (0, 0)));
147    }
148}