Skip to main content

batty_cli/team/
load.rs

1//! Team load monitoring and historical load graphing.
2
3use std::path::Path;
4
5use anyhow::{Result, bail};
6use tracing::warn;
7
8use super::{config, events, hierarchy, now_unix, status, team_config_path, team_events_path};
9use crate::tmux;
10
11/// Default duration window for load graph rendering, in seconds (1 hour).
12const LOAD_GRAPH_WINDOW_SECONDS: u64 = 3_600;
13const LOAD_GRAPH_WIDTH: usize = 30;
14
15#[derive(Debug, Clone, Copy)]
16pub struct TeamLoadSnapshot {
17    pub timestamp: u64,
18    pub total_members: usize,
19    pub working_members: usize,
20    pub load: f64,
21    pub session_running: bool,
22}
23
24/// Show an estimated team load value from live state, store it, and show recent load trends.
25pub fn show_load(project_root: &Path) -> Result<()> {
26    let current = capture_team_load(project_root)?;
27    if let Err(error) = log_team_load_snapshot(project_root, &current) {
28        warn!(error = %error, "failed to append load snapshot to team event log");
29    }
30
31    let mut history = read_team_load_history(project_root)?;
32    history.push(current);
33    history.sort_by_key(|snapshot| snapshot.timestamp);
34
35    println!(
36        "Current load: {:.1}% ({} / {} members working)",
37        current.load * 100.0,
38        current.working_members,
39        current.total_members.max(1)
40    );
41    println!(
42        "Session: {}",
43        if current.session_running {
44            "running"
45        } else {
46            "stopped"
47        }
48    );
49
50    if let Some(avg) = average_load(&history, current.timestamp, 10 * 60) {
51        println!("10m avg: {:.1}%", avg * 100.0);
52    } else {
53        println!("10m avg: n/a");
54    }
55    println!(
56        "30m avg: {}",
57        average_load(&history, current.timestamp, 30 * 60)
58            .map(|avg| format!("{:.1}%", avg * 100.0))
59            .unwrap_or_else(|| "n/a".to_string())
60    );
61    println!(
62        "60m avg: {}",
63        average_load(&history, current.timestamp, 60 * 60)
64            .map(|avg| format!("{:.1}%", avg * 100.0))
65            .unwrap_or_else(|| "n/a".to_string())
66    );
67
68    println!("Load graph (1h):");
69    println!("{}", render_load_graph(&history, current.timestamp));
70    Ok(())
71}
72
73fn capture_team_load(project_root: &Path) -> Result<TeamLoadSnapshot> {
74    let config_path = team_config_path(project_root);
75    if !config_path.exists() {
76        bail!("no team config found at {}", config_path.display());
77    }
78
79    let team_config = config::TeamConfig::load(&config_path)?;
80    let members = hierarchy::resolve_hierarchy(&team_config)?;
81    let session = format!("batty-{}", team_config.name);
82    let session_running = tmux::session_exists(&session);
83    let runtime_statuses = if session_running {
84        match status::list_runtime_member_statuses(&session) {
85            Ok(statuses) => statuses,
86            Err(error) => {
87                warn!(session = %session, error = %error, "failed to read runtime statuses for load sampling");
88                std::collections::HashMap::new()
89            }
90        }
91    } else {
92        std::collections::HashMap::new()
93    };
94
95    let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
96    let owned_task_buckets = status::owned_task_buckets(project_root, &members);
97    let rows = status::build_team_status_rows(
98        &members,
99        session_running,
100        &runtime_statuses,
101        &Default::default(),
102        &triage_backlog_counts,
103        &owned_task_buckets,
104        &Default::default(),
105    );
106    let mut total_members = 0usize;
107    let mut working_members = 0usize;
108
109    for row in &rows {
110        if row.role_type == "User" {
111            continue;
112        }
113        total_members += 1;
114        if counts_as_active_load(row) {
115            working_members += 1;
116        }
117    }
118
119    let load = if total_members == 0 {
120        0.0
121    } else {
122        working_members as f64 / total_members as f64
123    };
124
125    Ok(TeamLoadSnapshot {
126        timestamp: now_unix(),
127        total_members,
128        working_members: working_members.min(total_members),
129        load,
130        session_running,
131    })
132}
133
134fn counts_as_active_load(row: &status::TeamStatusRow) -> bool {
135    matches!(row.state.as_str(), "working" | "triaging" | "reviewing")
136}
137
138fn log_team_load_snapshot(project_root: &Path, snapshot: &TeamLoadSnapshot) -> Result<()> {
139    let events_path = team_events_path(project_root);
140    let mut sink = events::EventSink::new(&events_path)?;
141    let event = events::TeamEvent::load_snapshot(
142        snapshot.working_members as u32,
143        snapshot.total_members as u32,
144        snapshot.session_running,
145    );
146    sink.emit(event)?;
147    Ok(())
148}
149
150fn read_team_load_history(project_root: &Path) -> Result<Vec<TeamLoadSnapshot>> {
151    let events_path = team_events_path(project_root);
152    let events = events::read_events(&events_path)?;
153    let mut history = Vec::new();
154    for event in events {
155        if event.event != "load_snapshot" {
156            continue;
157        }
158        let Some(load) = event.load else {
159            continue;
160        };
161        let Some(working_members) = event.working_members else {
162            continue;
163        };
164        let Some(total_members) = event.total_members else {
165            continue;
166        };
167
168        history.push(TeamLoadSnapshot {
169            timestamp: event.ts,
170            total_members: total_members as usize,
171            working_members: working_members as usize,
172            load,
173            session_running: event.session_running.unwrap_or(false),
174        });
175    }
176    Ok(history)
177}
178
179fn average_load(samples: &[TeamLoadSnapshot], now: u64, window_seconds: u64) -> Option<f64> {
180    let cutoff = now.saturating_sub(window_seconds);
181    let mut values = Vec::new();
182    for sample in samples {
183        if sample.timestamp >= cutoff && sample.timestamp <= now {
184            values.push(sample.load);
185        }
186    }
187    if values.is_empty() {
188        return None;
189    }
190    let sum: f64 = values.iter().copied().sum();
191    Some(sum / values.len() as f64)
192}
193
194fn render_load_graph(samples: &[TeamLoadSnapshot], now: u64) -> String {
195    if samples.is_empty() {
196        return "(no historical load data yet)".to_string();
197    }
198
199    let bucket_size = (LOAD_GRAPH_WINDOW_SECONDS / LOAD_GRAPH_WIDTH as u64).max(1);
200    let window_start = now.saturating_sub(LOAD_GRAPH_WINDOW_SECONDS);
201    let mut history = String::new();
202    let mut previous = 0.0;
203    for index in 0..LOAD_GRAPH_WIDTH {
204        let bucket_start = window_start + (index as u64 * bucket_size);
205        let bucket_end = if index + 1 == LOAD_GRAPH_WIDTH {
206            now + 1
207        } else {
208            bucket_start + bucket_size
209        };
210
211        let mut sum = 0.0;
212        let mut count = 0usize;
213        for sample in samples {
214            if sample.timestamp >= bucket_start && sample.timestamp < bucket_end {
215                sum += sample.load;
216                count += 1;
217            }
218        }
219
220        let value = if count == 0 {
221            previous
222        } else {
223            sum / count as f64
224        };
225        previous = value;
226        history.push(load_point_char(value));
227    }
228
229    history
230}
231
232fn load_point_char(value: f64) -> char {
233    let clamped = value.clamp(0.0, 1.0);
234    match (clamped * 5.0).round() as usize {
235        0 => ' ',
236        1 => '.',
237        2 => ':',
238        3 => '=',
239        4 => '#',
240        _ => '@',
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn counts_as_active_load_treats_triaging_as_working() {
250        let triaging = status::TeamStatusRow {
251            name: "lead".to_string(),
252            role: "lead".to_string(),
253            role_type: "Manager".to_string(),
254            agent: Some("codex".to_string()),
255            reports_to: Some("architect".to_string()),
256            state: "triaging".to_string(),
257            pending_inbox: 0,
258            triage_backlog: 2,
259            active_owned_tasks: vec![191],
260            review_owned_tasks: vec![193],
261            signal: Some("needs triage (2)".to_string()),
262            runtime_label: Some("idle".to_string()),
263            health: status::AgentHealthSummary::default(),
264            health_summary: "-".to_string(),
265            eta: "-".to_string(),
266        };
267        let reviewing = status::TeamStatusRow {
268            state: "reviewing".to_string(),
269            triage_backlog: 0,
270            signal: Some("needs review (1)".to_string()),
271            runtime_label: Some("idle".to_string()),
272            ..triaging.clone()
273        };
274        let idle = status::TeamStatusRow {
275            state: "idle".to_string(),
276            triage_backlog: 0,
277            signal: None,
278            runtime_label: Some("idle".to_string()),
279            ..triaging.clone()
280        };
281
282        assert!(counts_as_active_load(&triaging));
283        assert!(counts_as_active_load(&reviewing));
284        assert!(!counts_as_active_load(&idle));
285    }
286
287    #[test]
288    fn average_load_ignores_points_older_than_window() {
289        let now = 10_000u64;
290        let samples = vec![
291            TeamLoadSnapshot {
292                timestamp: now - 3_000,
293                total_members: 10,
294                working_members: 0,
295                load: 0.8,
296                session_running: true,
297            },
298            TeamLoadSnapshot {
299                timestamp: now - 10,
300                total_members: 10,
301                working_members: 0,
302                load: 0.4,
303                session_running: true,
304            },
305            TeamLoadSnapshot {
306                timestamp: now - 20,
307                total_members: 10,
308                working_members: 0,
309                load: 0.6,
310                session_running: true,
311            },
312        ];
313
314        let avg_60s = average_load(&samples, now, 60).unwrap();
315        assert!((avg_60s - 0.5).abs() < 0.0001);
316        assert!(average_load(&samples, now, 5).is_none());
317    }
318
319    #[test]
320    fn render_load_graph_returns_expected_width() {
321        let now = 10_000u64;
322        let samples = vec![
323            TeamLoadSnapshot {
324                timestamp: now - 3_600,
325                total_members: 10,
326                working_members: 2,
327                load: 0.2,
328                session_running: true,
329            },
330            TeamLoadSnapshot {
331                timestamp: now - 1_800,
332                total_members: 10,
333                working_members: 5,
334                load: 0.5,
335                session_running: true,
336            },
337            TeamLoadSnapshot {
338                timestamp: now - 900,
339                total_members: 10,
340                working_members: 10,
341                load: 1.0,
342                session_running: true,
343            },
344            TeamLoadSnapshot {
345                timestamp: now - 600,
346                total_members: 10,
347                working_members: 0,
348                load: 0.0,
349                session_running: true,
350            },
351        ];
352
353        let graph = render_load_graph(&samples, now);
354        assert_eq!(graph.len(), LOAD_GRAPH_WIDTH);
355        assert!(graph.chars().all(|c| " .:=#@".contains(c)));
356    }
357}