1use 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
11const 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
24pub 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, ¤t) {
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}