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 supervisory_pressures = status::supervisory_status_pressure(
98 project_root,
99 &members,
100 session_running,
101 &runtime_statuses,
102 );
103 let branch_mismatches = status::branch_mismatch_by_member(project_root, &members);
104 let rows = status::build_team_status_rows(
105 &members,
106 session_running,
107 &runtime_statuses,
108 &Default::default(),
109 &triage_backlog_counts,
110 &owned_task_buckets,
111 &supervisory_pressures,
112 &branch_mismatches,
113 &Default::default(),
114 &Default::default(),
115 );
116 let mut total_members = 0usize;
117 let mut working_members = 0usize;
118
119 for row in &rows {
120 if row.role_type == "User" {
121 continue;
122 }
123 total_members += 1;
124 if counts_as_active_load(row) {
125 working_members += 1;
126 }
127 }
128
129 let load = if total_members == 0 {
130 0.0
131 } else {
132 working_members as f64 / total_members as f64
133 };
134
135 Ok(TeamLoadSnapshot {
136 timestamp: now_unix(),
137 total_members,
138 working_members: working_members.min(total_members),
139 load,
140 session_running,
141 })
142}
143
144fn counts_as_active_load(row: &status::TeamStatusRow) -> bool {
145 matches!(row.state.as_str(), "working" | "triaging" | "reviewing")
146}
147
148fn log_team_load_snapshot(project_root: &Path, snapshot: &TeamLoadSnapshot) -> Result<()> {
149 let events_path = team_events_path(project_root);
150 let mut sink = events::EventSink::new(&events_path)?;
151 let event = events::TeamEvent::load_snapshot(
152 snapshot.working_members as u32,
153 snapshot.total_members as u32,
154 snapshot.session_running,
155 );
156 sink.emit(event)?;
157 Ok(())
158}
159
160fn read_team_load_history(project_root: &Path) -> Result<Vec<TeamLoadSnapshot>> {
161 let events_path = team_events_path(project_root);
162 let events = events::read_events(&events_path)?;
163 let mut history = Vec::new();
164 for event in events {
165 if event.event != "load_snapshot" {
166 continue;
167 }
168 let Some(load) = event.load else {
169 continue;
170 };
171 let Some(working_members) = event.working_members else {
172 continue;
173 };
174 let Some(total_members) = event.total_members else {
175 continue;
176 };
177
178 history.push(TeamLoadSnapshot {
179 timestamp: event.ts,
180 total_members: total_members as usize,
181 working_members: working_members as usize,
182 load,
183 session_running: event.session_running.unwrap_or(false),
184 });
185 }
186 Ok(history)
187}
188
189fn average_load(samples: &[TeamLoadSnapshot], now: u64, window_seconds: u64) -> Option<f64> {
190 let cutoff = now.saturating_sub(window_seconds);
191 let mut values = Vec::new();
192 for sample in samples {
193 if sample.timestamp >= cutoff && sample.timestamp <= now {
194 values.push(sample.load);
195 }
196 }
197 if values.is_empty() {
198 return None;
199 }
200 let sum: f64 = values.iter().copied().sum();
201 Some(sum / values.len() as f64)
202}
203
204fn render_load_graph(samples: &[TeamLoadSnapshot], now: u64) -> String {
205 if samples.is_empty() {
206 return "(no historical load data yet)".to_string();
207 }
208
209 let bucket_size = (LOAD_GRAPH_WINDOW_SECONDS / LOAD_GRAPH_WIDTH as u64).max(1);
210 let window_start = now.saturating_sub(LOAD_GRAPH_WINDOW_SECONDS);
211 let mut history = String::new();
212 let mut previous = 0.0;
213 for index in 0..LOAD_GRAPH_WIDTH {
214 let bucket_start = window_start + (index as u64 * bucket_size);
215 let bucket_end = if index + 1 == LOAD_GRAPH_WIDTH {
216 now + 1
217 } else {
218 bucket_start + bucket_size
219 };
220
221 let mut sum = 0.0;
222 let mut count = 0usize;
223 for sample in samples {
224 if sample.timestamp >= bucket_start && sample.timestamp < bucket_end {
225 sum += sample.load;
226 count += 1;
227 }
228 }
229
230 let value = if count == 0 {
231 previous
232 } else {
233 sum / count as f64
234 };
235 previous = value;
236 history.push(load_point_char(value));
237 }
238
239 history
240}
241
242fn load_point_char(value: f64) -> char {
243 let clamped = value.clamp(0.0, 1.0);
244 match (clamped * 5.0).round() as usize {
245 0 => ' ',
246 1 => '.',
247 2 => ':',
248 3 => '=',
249 4 => '#',
250 _ => '@',
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn counts_as_active_load_treats_triaging_as_working() {
260 let triaging = status::TeamStatusRow {
261 name: "lead".to_string(),
262 role: "lead".to_string(),
263 role_type: "Manager".to_string(),
264 agent: Some("codex".to_string()),
265 reports_to: Some("architect".to_string()),
266 state: "triaging".to_string(),
267 pending_inbox: 0,
268 triage_backlog: 2,
269 active_owned_tasks: vec![191],
270 review_owned_tasks: vec![193],
271 signal: Some("needs triage (2)".to_string()),
272 runtime_label: Some("idle".to_string()),
273 worktree_staleness: None,
274 health: status::AgentHealthSummary::default(),
275 health_summary: "-".to_string(),
276 eta: "-".to_string(),
277 };
278 let reviewing = status::TeamStatusRow {
279 state: "reviewing".to_string(),
280 triage_backlog: 0,
281 signal: Some("needs review (1)".to_string()),
282 runtime_label: Some("idle".to_string()),
283 ..triaging.clone()
284 };
285 let idle = status::TeamStatusRow {
286 state: "idle".to_string(),
287 triage_backlog: 0,
288 signal: None,
289 runtime_label: Some("idle".to_string()),
290 ..triaging.clone()
291 };
292
293 assert!(counts_as_active_load(&triaging));
294 assert!(counts_as_active_load(&reviewing));
295 assert!(!counts_as_active_load(&idle));
296 }
297
298 #[test]
299 fn average_load_ignores_points_older_than_window() {
300 let now = 10_000u64;
301 let samples = vec![
302 TeamLoadSnapshot {
303 timestamp: now - 3_000,
304 total_members: 10,
305 working_members: 0,
306 load: 0.8,
307 session_running: true,
308 },
309 TeamLoadSnapshot {
310 timestamp: now - 10,
311 total_members: 10,
312 working_members: 0,
313 load: 0.4,
314 session_running: true,
315 },
316 TeamLoadSnapshot {
317 timestamp: now - 20,
318 total_members: 10,
319 working_members: 0,
320 load: 0.6,
321 session_running: true,
322 },
323 ];
324
325 let avg_60s = average_load(&samples, now, 60).unwrap();
326 assert!((avg_60s - 0.5).abs() < 0.0001);
327 assert!(average_load(&samples, now, 5).is_none());
328 }
329
330 #[test]
331 fn render_load_graph_returns_expected_width() {
332 let now = 10_000u64;
333 let samples = vec![
334 TeamLoadSnapshot {
335 timestamp: now - 3_600,
336 total_members: 10,
337 working_members: 2,
338 load: 0.2,
339 session_running: true,
340 },
341 TeamLoadSnapshot {
342 timestamp: now - 1_800,
343 total_members: 10,
344 working_members: 5,
345 load: 0.5,
346 session_running: true,
347 },
348 TeamLoadSnapshot {
349 timestamp: now - 900,
350 total_members: 10,
351 working_members: 10,
352 load: 1.0,
353 session_running: true,
354 },
355 TeamLoadSnapshot {
356 timestamp: now - 600,
357 total_members: 10,
358 working_members: 0,
359 load: 0.0,
360 session_running: true,
361 },
362 ];
363
364 let graph = render_load_graph(&samples, now);
365 assert_eq!(graph.len(), LOAD_GRAPH_WIDTH);
366 assert!(graph.chars().all(|c| " .:=#@".contains(c)));
367 }
368}