1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::process::Command;
4use std::time::{Duration, Instant, SystemTime};
5
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8use tracing::warn;
9
10use crate::task;
11
12use super::config::{self, RoleType};
13use super::daemon::NudgeSchedule;
14use super::events;
15use super::hierarchy::MemberInstance;
16use super::inbox;
17use super::standup::MemberState;
18use super::{
19 TRIAGE_RESULT_FRESHNESS_SECONDS, daemon_state_path, now_unix, pause_marker_path,
20 team_config_dir, team_config_path, team_events_path,
21};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub(crate) struct RuntimeMemberStatus {
25 pub(crate) state: String,
26 pub(crate) signal: Option<String>,
27 pub(crate) label: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub(crate) struct TeamStatusRow {
32 pub(crate) name: String,
33 pub(crate) role: String,
34 pub(crate) role_type: String,
35 pub(crate) agent: Option<String>,
36 pub(crate) reports_to: Option<String>,
37 pub(crate) state: String,
38 pub(crate) pending_inbox: usize,
39 pub(crate) triage_backlog: usize,
40 pub(crate) active_owned_tasks: Vec<u32>,
41 pub(crate) review_owned_tasks: Vec<u32>,
42 pub(crate) signal: Option<String>,
43 pub(crate) runtime_label: Option<String>,
44 pub(crate) health: AgentHealthSummary,
45 pub(crate) health_summary: String,
46 pub(crate) eta: String,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(crate) struct TriageBacklogState {
51 pub(crate) count: usize,
52 pub(crate) newest_result_ts: u64,
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub(crate) struct OwnedTaskBuckets {
57 pub(crate) active: Vec<u32>,
58 pub(crate) review: Vec<u32>,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
62pub(crate) struct AgentHealthSummary {
63 pub(crate) restart_count: u32,
64 pub(crate) context_exhaustion_count: u32,
65 pub(crate) delivery_failure_count: u32,
66 pub(crate) task_elapsed_secs: Option<u64>,
67 pub(crate) backend_health: crate::agent::BackendHealth,
68}
69
70#[derive(Debug, Clone, Default, Deserialize)]
71struct PersistedDaemonHealthState {
72 #[serde(default)]
73 active_tasks: HashMap<String, u32>,
74 #[serde(default)]
75 retry_counts: HashMap<String, u32>,
76}
77
78#[derive(Debug, Clone, PartialEq, Default, Serialize)]
79pub struct WorkflowMetrics {
80 pub runnable_count: u32,
81 pub blocked_count: u32,
82 pub in_review_count: u32,
83 pub in_progress_count: u32,
84 pub idle_with_runnable: Vec<String>,
85 pub oldest_review_age_secs: Option<u64>,
86 pub oldest_assignment_age_secs: Option<u64>,
87 pub auto_merge_count: u32,
89 pub manual_merge_count: u32,
90 pub auto_merge_rate: Option<f64>,
91 pub rework_count: u32,
92 pub rework_rate: Option<f64>,
93 pub review_nudge_count: u32,
94 pub review_escalation_count: u32,
95 pub avg_review_latency_secs: Option<f64>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub(crate) struct StatusTaskEntry {
100 pub(crate) id: u32,
101 pub(crate) title: String,
102 pub(crate) status: String,
103 pub(crate) priority: String,
104 pub(crate) claimed_by: Option<String>,
105 pub(crate) review_owner: Option<String>,
106 pub(crate) blocked_on: Option<String>,
107 pub(crate) branch: Option<String>,
108 pub(crate) worktree_path: Option<String>,
109 pub(crate) commit: Option<String>,
110 pub(crate) next_action: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub(crate) struct TeamStatusHealth {
115 pub(crate) session_running: bool,
116 pub(crate) paused: bool,
117 pub(crate) member_count: usize,
118 pub(crate) active_member_count: usize,
119 pub(crate) pending_inbox_count: usize,
120 pub(crate) triage_backlog_count: usize,
121 pub(crate) unhealthy_members: Vec<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize)]
125pub(crate) struct TeamStatusJsonReport {
126 pub(crate) team: String,
127 pub(crate) session: String,
128 pub(crate) running: bool,
129 pub(crate) paused: bool,
130 pub(crate) health: TeamStatusHealth,
131 pub(crate) workflow_metrics: Option<WorkflowMetrics>,
132 pub(crate) active_tasks: Vec<StatusTaskEntry>,
133 pub(crate) review_queue: Vec<StatusTaskEntry>,
134 pub(crate) members: Vec<TeamStatusRow>,
135}
136
137pub(crate) fn list_runtime_member_statuses(
138 session: &str,
139) -> Result<HashMap<String, RuntimeMemberStatus>> {
140 let output = Command::new("tmux")
141 .args([
142 "list-panes",
143 "-t",
144 session,
145 "-F",
146 "#{pane_id}\t#{@batty_role}\t#{@batty_status}\t#{pane_dead}",
147 ])
148 .output()
149 .with_context(|| format!("failed to list panes for session '{session}'"))?;
150
151 if !output.status.success() {
152 let stderr = String::from_utf8_lossy(&output.stderr);
153 bail!("tmux list-panes runtime status failed: {stderr}");
154 }
155
156 let mut statuses = HashMap::new();
157 for line in String::from_utf8_lossy(&output.stdout).lines() {
158 let mut parts = line.splitn(4, '\t');
159 let Some(_pane_id) = parts.next() else {
160 continue;
161 };
162 let Some(member_name) = parts.next() else {
163 continue;
164 };
165 let Some(raw_status) = parts.next() else {
166 continue;
167 };
168 let Some(pane_dead) = parts.next() else {
169 continue;
170 };
171 if member_name.trim().is_empty() {
172 continue;
173 }
174
175 statuses.insert(
176 member_name.to_string(),
177 summarize_runtime_member_status(raw_status, pane_dead == "1"),
178 );
179 }
180
181 Ok(statuses)
182}
183
184pub(crate) fn summarize_runtime_member_status(
185 raw_status: &str,
186 pane_dead: bool,
187) -> RuntimeMemberStatus {
188 if pane_dead {
189 return RuntimeMemberStatus {
190 state: "crashed".to_string(),
191 signal: None,
192 label: Some("crashed".to_string()),
193 };
194 }
195
196 let label = strip_tmux_style(raw_status);
197 let normalized = label.to_ascii_lowercase();
198 let has_paused_nudge = normalized.contains("nudge paused");
199 let has_nudge_sent = normalized.contains("nudge sent");
200 let has_waiting_nudge = normalized.contains("nudge") && !has_nudge_sent && !has_paused_nudge;
201 let has_paused_standup = normalized.contains("standup paused");
202 let has_standup = normalized.contains("standup") && !has_paused_standup;
203
204 let state = if normalized.contains("crashed") {
205 "crashed"
206 } else if normalized.contains("working") {
207 "working"
208 } else if normalized.contains("done") || normalized.contains("completed") {
209 "done"
210 } else if normalized.contains("idle") {
211 "idle"
212 } else if label.is_empty() {
213 "starting"
214 } else {
215 "unknown"
216 };
217
218 let mut signals = Vec::new();
219 if has_paused_nudge {
220 signals.push("nudge paused");
221 } else if has_nudge_sent {
222 signals.push("nudged");
223 } else if has_waiting_nudge {
224 signals.push("waiting for nudge");
225 }
226 if has_paused_standup {
227 signals.push("standup paused");
228 } else if has_standup {
229 signals.push("standup");
230 }
231 let signal = (!signals.is_empty()).then(|| signals.join(", "));
232
233 RuntimeMemberStatus {
234 state: state.to_string(),
235 signal,
236 label: (!label.is_empty()).then_some(label),
237 }
238}
239
240pub(crate) fn strip_tmux_style(input: &str) -> String {
241 let mut output = String::new();
242 let mut chars = input.chars().peekable();
243
244 while let Some(ch) = chars.next() {
245 if ch == '#' && chars.peek() == Some(&'[') {
246 chars.next();
247 for next in chars.by_ref() {
248 if next == ']' {
249 break;
250 }
251 }
252 continue;
253 }
254 output.push(ch);
255 }
256
257 output.split_whitespace().collect::<Vec<_>>().join(" ")
258}
259
260pub(crate) fn build_team_status_rows(
261 members: &[MemberInstance],
262 session_running: bool,
263 runtime_statuses: &HashMap<String, RuntimeMemberStatus>,
264 pending_inbox_counts: &HashMap<String, usize>,
265 triage_backlog_counts: &HashMap<String, usize>,
266 owned_task_buckets: &HashMap<String, OwnedTaskBuckets>,
267 agent_health: &HashMap<String, AgentHealthSummary>,
268) -> Vec<TeamStatusRow> {
269 members
270 .iter()
271 .map(|member| {
272 let runtime = runtime_statuses.get(&member.name);
273 let pending_inbox = pending_inbox_counts.get(&member.name).copied().unwrap_or(0);
274 let triage_backlog = triage_backlog_counts
275 .get(&member.name)
276 .copied()
277 .unwrap_or(0);
278 let owned_tasks = owned_task_buckets
279 .get(&member.name)
280 .cloned()
281 .unwrap_or_default();
282 let (state, signal, runtime_label) = if member.role_type == config::RoleType::User {
283 ("user".to_string(), None, None)
284 } else if !session_running {
285 ("stopped".to_string(), None, None)
286 } else if let Some(runtime) = runtime {
287 (
288 runtime.state.clone(),
289 runtime.signal.clone(),
290 runtime.label.clone(),
291 )
292 } else {
293 ("starting".to_string(), None, None)
294 };
295
296 let review_backlog = owned_tasks.review.len();
297 let state = if session_running && state == "idle" && review_backlog > 0 {
298 "reviewing".to_string()
299 } else if session_running && state == "idle" && triage_backlog > 0 {
300 "triaging".to_string()
301 } else {
302 state
303 };
304
305 let signal = merge_status_signal(signal, triage_backlog, review_backlog);
306 let health = agent_health.get(&member.name).cloned().unwrap_or_default();
307 let health_summary = format_agent_health_summary(&health);
308
309 TeamStatusRow {
310 name: member.name.clone(),
311 role: member.role_name.clone(),
312 role_type: format!("{:?}", member.role_type),
313 agent: member.agent.clone(),
314 reports_to: member.reports_to.clone(),
315 state,
316 pending_inbox,
317 triage_backlog,
318 active_owned_tasks: owned_tasks.active,
319 review_owned_tasks: owned_tasks.review,
320 signal,
321 runtime_label,
322 health,
323 health_summary,
324 eta: "-".to_string(),
325 }
326 })
327 .collect()
328}
329
330pub(crate) fn agent_health_by_member(
331 project_root: &Path,
332 members: &[MemberInstance],
333) -> HashMap<String, AgentHealthSummary> {
334 let mut health_by_member = members
335 .iter()
336 .map(|member| (member.name.clone(), AgentHealthSummary::default()))
337 .collect::<HashMap<_, _>>();
338
339 let daemon_state = match load_persisted_daemon_health_state(&daemon_state_path(project_root)) {
340 Ok(state) => state.unwrap_or_default(),
341 Err(error) => {
342 warn!(error = %error, "failed to load daemon health state");
343 PersistedDaemonHealthState::default()
344 }
345 };
346
347 for (member, retry_count) in &daemon_state.retry_counts {
348 health_by_member
349 .entry(member.clone())
350 .or_default()
351 .restart_count = health_by_member
352 .get(member)
353 .map(|health| health.restart_count.max(*retry_count))
354 .unwrap_or(*retry_count);
355 }
356
357 let mut restart_events = HashMap::<String, u32>::new();
358 let mut latest_assignment_ts = HashMap::<String, u64>::new();
359 let mut latest_assignment_ts_by_task = HashMap::<(String, u32), u64>::new();
360 match events::read_events(&team_events_path(project_root)) {
361 Ok(events) => {
362 for event in events {
363 let Some(role) = event.role.as_deref() else {
364 continue;
365 };
366
367 match event.event.as_str() {
368 "agent_restarted" => {
369 *restart_events.entry(role.to_string()).or_insert(0) += 1;
370 if let Some(restart_count) = event.restart_count {
371 health_by_member
372 .entry(role.to_string())
373 .or_default()
374 .restart_count = health_by_member
375 .get(role)
376 .map(|health| health.restart_count.max(restart_count))
377 .unwrap_or(restart_count);
378 }
379 }
380 "context_exhausted" => {
381 health_by_member
382 .entry(role.to_string())
383 .or_default()
384 .context_exhaustion_count += 1;
385 }
386 "delivery_failed" => {
387 health_by_member
388 .entry(role.to_string())
389 .or_default()
390 .delivery_failure_count += 1;
391 }
392 "task_assigned" => {
393 latest_assignment_ts.insert(role.to_string(), event.ts);
394 if let Some(task_id) =
395 event.task.as_deref().and_then(parse_assigned_task_id)
396 {
397 latest_assignment_ts_by_task
398 .insert((role.to_string(), task_id), event.ts);
399 }
400 }
401 "health_changed" => {
402 if let Some(reason) = event.reason.as_deref() {
405 let new_state = reason.split('→').next_back().unwrap_or("healthy");
406 let health_val = match new_state {
407 "degraded" => crate::agent::BackendHealth::Degraded,
408 "unreachable" => crate::agent::BackendHealth::Unreachable,
409 _ => crate::agent::BackendHealth::Healthy,
410 };
411 health_by_member
412 .entry(role.to_string())
413 .or_default()
414 .backend_health = health_val;
415 }
416 }
417 _ => {}
418 }
419 }
420 }
421 Err(error) => {
422 warn!(error = %error, "failed to read team events for status health summary");
423 }
424 }
425
426 for (member, event_count) in restart_events {
427 let health = health_by_member.entry(member).or_default();
428 health.restart_count = health.restart_count.max(event_count);
429 }
430
431 let now = now_unix();
432 for (member, task_id) in daemon_state.active_tasks {
433 let assigned_ts = latest_assignment_ts_by_task
434 .get(&(member.clone(), task_id))
435 .copied()
436 .or_else(|| latest_assignment_ts.get(&member).copied());
437 if let Some(assigned_ts) = assigned_ts {
438 health_by_member
439 .entry(member)
440 .or_default()
441 .task_elapsed_secs = Some(now.saturating_sub(assigned_ts));
442 }
443 }
444
445 health_by_member
446}
447
448fn load_persisted_daemon_health_state(path: &Path) -> Result<Option<PersistedDaemonHealthState>> {
449 if !path.exists() {
450 return Ok(None);
451 }
452
453 let content = std::fs::read_to_string(path)
454 .with_context(|| format!("failed to read {}", path.display()))?;
455 let state = serde_json::from_str::<PersistedDaemonHealthState>(&content)
456 .with_context(|| format!("failed to parse {}", path.display()))?;
457 Ok(Some(state))
458}
459
460fn parse_assigned_task_id(task: &str) -> Option<u32> {
461 let trimmed = task.trim();
462 trimmed
463 .parse::<u32>()
464 .ok()
465 .or_else(|| {
466 trimmed
467 .split("Task #")
468 .nth(1)
469 .and_then(parse_leading_task_id)
470 })
471 .or_else(|| {
472 trimmed
473 .find('#')
474 .and_then(|idx| parse_leading_task_id(&trimmed[idx + 1..]))
475 })
476}
477
478fn parse_leading_task_id(value: &str) -> Option<u32> {
479 let digits = value
480 .trim_start()
481 .chars()
482 .take_while(|ch| ch.is_ascii_digit())
483 .collect::<String>();
484 (!digits.is_empty()).then(|| digits.parse().ok()).flatten()
485}
486
487pub(crate) fn format_agent_health_summary(health: &AgentHealthSummary) -> String {
488 let mut parts = Vec::new();
489 if !health.backend_health.is_healthy() {
490 parts.push(format!("B:{}", health.backend_health.as_str()));
491 }
492 if health.restart_count > 0 {
493 parts.push(format!("r{}", health.restart_count));
494 }
495 if health.context_exhaustion_count > 0 {
496 parts.push(format!("c{}", health.context_exhaustion_count));
497 }
498 if health.delivery_failure_count > 0 {
499 parts.push(format!("d{}", health.delivery_failure_count));
500 }
501 if let Some(task_elapsed_secs) = health.task_elapsed_secs {
502 parts.push(format!("t{}", format_health_duration(task_elapsed_secs)));
503 }
504
505 if parts.is_empty() {
506 "-".to_string()
507 } else {
508 parts.join(" ")
509 }
510}
511
512fn format_health_duration(task_elapsed_secs: u64) -> String {
513 if task_elapsed_secs < 60 {
514 format!("{task_elapsed_secs}s")
515 } else if task_elapsed_secs < 3_600 {
516 format!("{}m", task_elapsed_secs / 60)
517 } else if task_elapsed_secs < 86_400 {
518 format!("{}h", task_elapsed_secs / 3_600)
519 } else {
520 format!("{}d", task_elapsed_secs / 86_400)
521 }
522}
523
524fn merge_status_signal(
525 signal: Option<String>,
526 triage_backlog: usize,
527 review_backlog: usize,
528) -> Option<String> {
529 let triage_signal = (triage_backlog > 0).then(|| format!("needs triage ({triage_backlog})"));
530 let review_signal = (review_backlog > 0).then(|| format!("needs review ({review_backlog})"));
531 let mut signals = Vec::new();
532 if let Some(existing) = signal {
533 signals.push(existing);
534 }
535 if let Some(triage) = triage_signal {
536 signals.push(triage);
537 }
538 if let Some(review) = review_signal {
539 signals.push(review);
540 }
541 if signals.is_empty() {
542 None
543 } else {
544 Some(signals.join(", "))
545 }
546}
547
548pub(crate) fn triage_backlog_counts(
549 project_root: &Path,
550 members: &[MemberInstance],
551) -> HashMap<String, usize> {
552 let root = inbox::inboxes_root(project_root);
553 let direct_reports = direct_reports_by_member(members);
554 direct_reports
555 .into_iter()
556 .filter_map(|(member_name, reports)| {
557 match delivered_direct_report_triage_state(&root, &member_name, &reports) {
558 Ok(state) => Some((member_name, state.count)),
559 Err(error) => {
560 warn!(member = %member_name, error = %error, "failed to compute lead triage backlog");
561 None
562 }
563 }
564 })
565 .collect()
566}
567
568pub(crate) fn direct_reports_by_member(members: &[MemberInstance]) -> HashMap<String, Vec<String>> {
569 let mut direct_reports: HashMap<String, Vec<String>> = HashMap::new();
570 for member in members {
571 if let Some(parent) = &member.reports_to {
572 direct_reports
573 .entry(parent.clone())
574 .or_default()
575 .push(member.name.clone());
576 }
577 }
578 direct_reports
579}
580
581pub(crate) fn delivered_direct_report_triage_count(
582 inbox_root: &Path,
583 member_name: &str,
584 direct_reports: &[String],
585) -> Result<usize> {
586 Ok(delivered_direct_report_triage_state(inbox_root, member_name, direct_reports)?.count)
587}
588
589pub(crate) fn delivered_direct_report_triage_state(
590 inbox_root: &Path,
591 member_name: &str,
592 direct_reports: &[String],
593) -> Result<TriageBacklogState> {
594 delivered_direct_report_triage_state_at(inbox_root, member_name, direct_reports, now_unix())
595}
596
597pub(crate) fn delivered_direct_report_triage_state_at(
598 inbox_root: &Path,
599 member_name: &str,
600 direct_reports: &[String],
601 now_ts: u64,
602) -> Result<TriageBacklogState> {
603 if direct_reports.is_empty() {
604 return Ok(TriageBacklogState {
605 count: 0,
606 newest_result_ts: 0,
607 });
608 }
609
610 let mut latest_outbound_by_report = HashMap::new();
611 for report in direct_reports {
612 let report_messages = inbox::all_messages(inbox_root, report)?;
613 let latest_outbound = report_messages
614 .iter()
615 .filter_map(|(msg, _)| (msg.from == member_name).then_some(msg.timestamp))
616 .max()
617 .unwrap_or(0);
618 latest_outbound_by_report.insert(report.as_str(), latest_outbound);
619 }
620
621 let member_messages = inbox::all_messages(inbox_root, member_name)?;
622 let mut count = 0usize;
623 let mut newest_result_ts = 0u64;
624 for (msg, delivered) in &member_messages {
625 let is_fresh = now_ts.saturating_sub(msg.timestamp) <= TRIAGE_RESULT_FRESHNESS_SECONDS;
626 let needs_triage = *delivered
627 && is_fresh
628 && direct_reports.iter().any(|report| report == &msg.from)
629 && msg.timestamp
630 > *latest_outbound_by_report
631 .get(msg.from.as_str())
632 .unwrap_or(&0);
633 if needs_triage {
634 count += 1;
635 newest_result_ts = newest_result_ts.max(msg.timestamp);
636 }
637 }
638
639 Ok(TriageBacklogState {
640 count,
641 newest_result_ts,
642 })
643}
644
645pub(crate) fn pending_inbox_counts(
646 project_root: &Path,
647 members: &[MemberInstance],
648) -> HashMap<String, usize> {
649 let root = inbox::inboxes_root(project_root);
650 members
651 .iter()
652 .filter_map(|member| match inbox::pending_message_count(&root, &member.name) {
653 Ok(count) => Some((member.name.clone(), count)),
654 Err(error) => {
655 warn!(member = %member.name, error = %error, "failed to count pending inbox messages");
656 None
657 }
658 })
659 .collect()
660}
661
662fn classify_owned_task_status(status: &str) -> Option<bool> {
663 match status {
664 "done" | "archived" => None,
665 "review" => Some(false),
666 _ => Some(true),
667 }
668}
669
670pub(crate) fn owned_task_buckets(
671 project_root: &Path,
672 members: &[MemberInstance],
673) -> HashMap<String, OwnedTaskBuckets> {
674 let tasks_dir = project_root
675 .join(".batty")
676 .join("team_config")
677 .join("board")
678 .join("tasks");
679 if !tasks_dir.is_dir() {
680 return HashMap::new();
681 }
682
683 let member_names: HashSet<&str> = members.iter().map(|member| member.name.as_str()).collect();
684 let mut owned = HashMap::<String, OwnedTaskBuckets>::new();
685 let tasks = match crate::task::load_tasks_from_dir(&tasks_dir) {
686 Ok(tasks) => tasks,
687 Err(error) => {
688 warn!(path = %tasks_dir.display(), error = %error, "failed to load board tasks for status");
689 return HashMap::new();
690 }
691 };
692
693 for task in tasks {
694 let Some(claimed_by) = task.claimed_by else {
695 continue;
696 };
697 if !member_names.contains(claimed_by.as_str()) {
698 continue;
699 }
700 let Some(is_active) = classify_owned_task_status(task.status.as_str()) else {
701 continue;
702 };
703 let owner = if is_active {
704 claimed_by
705 } else {
706 members
707 .iter()
708 .find(|member| member.name == claimed_by)
709 .and_then(|member| member.reports_to.as_deref())
710 .unwrap_or(claimed_by.as_str())
711 .to_string()
712 };
713 let entry = owned.entry(owner).or_default();
714 if is_active {
715 entry.active.push(task.id);
716 } else {
717 entry.review.push(task.id);
718 }
719 }
720
721 for buckets in owned.values_mut() {
722 buckets.active.sort_unstable();
723 buckets.review.sort_unstable();
724 }
725
726 owned
727}
728
729pub(crate) fn format_owned_tasks_summary(task_ids: &[u32]) -> String {
730 match task_ids {
731 [] => "-".to_string(),
732 [task_id] => format!("#{task_id}"),
733 [first, second] => format!("#{first},#{second}"),
734 [first, second, rest @ ..] => format!("#{first},#{second},+{}", rest.len()),
735 }
736}
737
738pub(crate) fn board_status_task_queues(
739 project_root: &Path,
740) -> Result<(Vec<StatusTaskEntry>, Vec<StatusTaskEntry>)> {
741 let tasks_dir = project_root
742 .join(".batty")
743 .join("team_config")
744 .join("board")
745 .join("tasks");
746 if !tasks_dir.is_dir() {
747 return Ok((Vec::new(), Vec::new()));
748 }
749
750 let mut active_tasks = Vec::new();
751 let mut review_queue = Vec::new();
752 for task in task::load_tasks_from_dir(&tasks_dir)? {
753 let entry = StatusTaskEntry {
754 id: task.id,
755 title: task.title,
756 status: task.status.clone(),
757 priority: task.priority,
758 claimed_by: task.claimed_by,
759 review_owner: task.review_owner,
760 blocked_on: task.blocked_on,
761 branch: task.branch,
762 worktree_path: task.worktree_path,
763 commit: task.commit,
764 next_action: task.next_action,
765 };
766
767 match task.status.as_str() {
768 "in-progress" | "in_progress" => active_tasks.push(entry),
769 "review" => review_queue.push(entry),
770 _ => {}
771 }
772 }
773
774 Ok((active_tasks, review_queue))
775}
776
777pub(crate) fn build_team_status_health(
778 rows: &[TeamStatusRow],
779 session_running: bool,
780 paused: bool,
781) -> TeamStatusHealth {
782 let member_rows: Vec<&TeamStatusRow> =
783 rows.iter().filter(|row| row.role_type != "User").collect();
784 let mut unhealthy_members = member_rows
785 .iter()
786 .filter(|row| {
787 row.health.restart_count > 0
788 || row.health.context_exhaustion_count > 0
789 || row.health.delivery_failure_count > 0
790 || !row.health.backend_health.is_healthy()
791 })
792 .map(|row| row.name.clone())
793 .collect::<Vec<_>>();
794 unhealthy_members.sort();
795
796 TeamStatusHealth {
797 session_running,
798 paused,
799 member_count: member_rows.len(),
800 active_member_count: member_rows
801 .iter()
802 .filter(|row| matches!(row.state.as_str(), "working" | "triaging" | "reviewing"))
803 .count(),
804 pending_inbox_count: member_rows.iter().map(|row| row.pending_inbox).sum(),
805 triage_backlog_count: member_rows.iter().map(|row| row.triage_backlog).sum(),
806 unhealthy_members,
807 }
808}
809
810pub(crate) struct TeamStatusJsonReportInput {
811 pub(crate) team: String,
812 pub(crate) session: String,
813 pub(crate) session_running: bool,
814 pub(crate) paused: bool,
815 pub(crate) workflow_metrics: Option<WorkflowMetrics>,
816 pub(crate) active_tasks: Vec<StatusTaskEntry>,
817 pub(crate) review_queue: Vec<StatusTaskEntry>,
818 pub(crate) members: Vec<TeamStatusRow>,
819}
820
821pub(crate) fn build_team_status_json_report(
822 input: TeamStatusJsonReportInput,
823) -> TeamStatusJsonReport {
824 let TeamStatusJsonReportInput {
825 team,
826 session,
827 session_running,
828 paused,
829 workflow_metrics,
830 active_tasks,
831 review_queue,
832 members,
833 } = input;
834 let health = build_team_status_health(&members, session_running, paused);
835 TeamStatusJsonReport {
836 team,
837 session,
838 running: session_running,
839 paused,
840 health,
841 workflow_metrics,
842 active_tasks,
843 review_queue,
844 members,
845 }
846}
847
848pub fn compute_metrics(board_dir: &Path, members: &[MemberInstance]) -> Result<WorkflowMetrics> {
849 compute_metrics_with_telemetry(board_dir, members, None, None)
850}
851
852pub fn compute_metrics_with_telemetry(
857 board_dir: &Path,
858 members: &[MemberInstance],
859 db: Option<&rusqlite::Connection>,
860 events_path: Option<&Path>,
861) -> Result<WorkflowMetrics> {
862 let board_metrics = compute_board_metrics(board_dir, members)?;
863
864 let review = if let Some(conn) = db {
865 compute_review_metrics_from_db(conn)
866 } else {
867 compute_review_metrics(events_path)
868 };
869
870 Ok(WorkflowMetrics {
871 runnable_count: board_metrics.runnable_count,
872 blocked_count: board_metrics.blocked_count,
873 in_review_count: board_metrics.in_review_count,
874 in_progress_count: board_metrics.in_progress_count,
875 idle_with_runnable: board_metrics.idle_with_runnable,
876 oldest_review_age_secs: board_metrics.oldest_review_age_secs,
877 oldest_assignment_age_secs: board_metrics.oldest_assignment_age_secs,
878 auto_merge_count: review.auto_merge_count,
879 manual_merge_count: review.manual_merge_count,
880 auto_merge_rate: review.auto_merge_rate,
881 rework_count: review.rework_count,
882 rework_rate: review.rework_rate,
883 review_nudge_count: review.review_nudge_count,
884 review_escalation_count: review.review_escalation_count,
885 avg_review_latency_secs: review.avg_review_latency_secs,
886 })
887}
888
889pub fn compute_metrics_with_events(
890 board_dir: &Path,
891 members: &[MemberInstance],
892 events_path: Option<&Path>,
893) -> Result<WorkflowMetrics> {
894 compute_metrics_with_telemetry(board_dir, members, None, events_path)
895}
896
897struct BoardMetrics {
899 runnable_count: u32,
900 blocked_count: u32,
901 in_review_count: u32,
902 in_progress_count: u32,
903 idle_with_runnable: Vec<String>,
904 oldest_review_age_secs: Option<u64>,
905 oldest_assignment_age_secs: Option<u64>,
906}
907
908fn compute_board_metrics(board_dir: &Path, members: &[MemberInstance]) -> Result<BoardMetrics> {
909 let tasks_dir = board_dir.join("tasks");
910 if !tasks_dir.is_dir() {
911 return Ok(BoardMetrics {
912 runnable_count: 0,
913 blocked_count: 0,
914 in_review_count: 0,
915 in_progress_count: 0,
916 idle_with_runnable: Vec::new(),
917 oldest_review_age_secs: None,
918 oldest_assignment_age_secs: None,
919 });
920 }
921
922 let tasks = task::load_tasks_from_dir(&tasks_dir)?;
923 if tasks.is_empty() {
924 return Ok(BoardMetrics {
925 runnable_count: 0,
926 blocked_count: 0,
927 in_review_count: 0,
928 in_progress_count: 0,
929 idle_with_runnable: Vec::new(),
930 oldest_review_age_secs: None,
931 oldest_assignment_age_secs: None,
932 });
933 }
934
935 let task_status_by_id: HashMap<u32, String> = tasks
936 .iter()
937 .map(|task| (task.id, task.status.clone()))
938 .collect();
939
940 let now = SystemTime::now();
941 let runnable_count = tasks
942 .iter()
943 .filter(|task| matches!(task.status.as_str(), "backlog" | "todo"))
944 .filter(|task| task.claimed_by.is_none())
945 .filter(|task| task.blocked.is_none())
946 .filter(|task| {
947 task.depends_on.iter().all(|dep_id| {
948 task_status_by_id
949 .get(dep_id)
950 .is_none_or(|status| status == "done")
951 })
952 })
953 .count() as u32;
954
955 let blocked_count = tasks
956 .iter()
957 .filter(|task| task.status == "blocked" || task.blocked.is_some())
958 .count() as u32;
959 let in_review_count = tasks.iter().filter(|task| task.status == "review").count() as u32;
960 let in_progress_count = tasks
961 .iter()
962 .filter(|task| matches!(task.status.as_str(), "in-progress" | "in_progress"))
963 .count() as u32;
964
965 let oldest_review_age_secs = tasks
966 .iter()
967 .filter(|task| task.status == "review")
968 .filter_map(|task| file_age_secs(&task.source_path, now))
969 .max();
970 let oldest_assignment_age_secs = tasks
971 .iter()
972 .filter(|task| task.claimed_by.is_some())
973 .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
974 .filter_map(|task| file_age_secs(&task.source_path, now))
975 .max();
976
977 let idle_with_runnable = compute_idle_with_runnable(board_dir, members, &tasks, runnable_count);
978
979 Ok(BoardMetrics {
980 runnable_count,
981 blocked_count,
982 in_review_count,
983 in_progress_count,
984 idle_with_runnable,
985 oldest_review_age_secs,
986 oldest_assignment_age_secs,
987 })
988}
989
990#[derive(Default)]
991struct ReviewMetrics {
992 auto_merge_count: u32,
993 manual_merge_count: u32,
994 auto_merge_rate: Option<f64>,
995 rework_count: u32,
996 rework_rate: Option<f64>,
997 review_nudge_count: u32,
998 review_escalation_count: u32,
999 avg_review_latency_secs: Option<f64>,
1000}
1001
1002fn compute_review_metrics(events_path: Option<&Path>) -> ReviewMetrics {
1003 let events = events_path
1004 .and_then(|path| events::read_events(path).ok())
1005 .unwrap_or_default();
1006
1007 let mut auto_merge_count: u32 = 0;
1008 let mut manual_merge_count: u32 = 0;
1009 let mut rework_count: u32 = 0;
1010 let mut review_nudge_count: u32 = 0;
1011 let mut review_escalation_count: u32 = 0;
1012
1013 let mut review_enter_ts: HashMap<String, u64> = HashMap::new();
1016 let mut review_latencies: Vec<f64> = Vec::new();
1017
1018 for event in &events {
1019 match event.event.as_str() {
1020 "task_auto_merged" => {
1021 auto_merge_count += 1;
1022 if let Some(task_id) = &event.task {
1023 if let Some(enter_ts) = review_enter_ts.remove(task_id) {
1024 review_latencies.push((event.ts - enter_ts) as f64);
1025 }
1026 }
1027 }
1028 "task_manual_merged" => {
1029 manual_merge_count += 1;
1030 if let Some(task_id) = &event.task {
1031 if let Some(enter_ts) = review_enter_ts.remove(task_id) {
1032 review_latencies.push((event.ts - enter_ts) as f64);
1033 }
1034 }
1035 }
1036 "task_reworked" => {
1037 rework_count += 1;
1038 }
1039 "review_nudge_sent" => {
1040 review_nudge_count += 1;
1041 }
1042 "review_escalated" => {
1043 review_escalation_count += 1;
1044 }
1045 "task_completed" => {
1046 if let Some(task_id) = &event.task {
1047 review_enter_ts.insert(task_id.clone(), event.ts);
1048 }
1049 }
1050 _ => {}
1051 }
1052 }
1053
1054 let total_merges = auto_merge_count + manual_merge_count;
1055 let auto_merge_rate = if total_merges > 0 {
1056 Some(auto_merge_count as f64 / total_merges as f64)
1057 } else {
1058 None
1059 };
1060 let total_reviewed = total_merges + rework_count;
1061 let rework_rate = if total_reviewed > 0 {
1062 Some(rework_count as f64 / total_reviewed as f64)
1063 } else {
1064 None
1065 };
1066 let avg_review_latency_secs = if review_latencies.is_empty() {
1067 None
1068 } else {
1069 Some(review_latencies.iter().sum::<f64>() / review_latencies.len() as f64)
1070 };
1071
1072 ReviewMetrics {
1073 auto_merge_count,
1074 manual_merge_count,
1075 auto_merge_rate,
1076 rework_count,
1077 rework_rate,
1078 review_nudge_count,
1079 review_escalation_count,
1080 avg_review_latency_secs,
1081 }
1082}
1083
1084fn compute_review_metrics_from_db(conn: &rusqlite::Connection) -> ReviewMetrics {
1086 let row = match crate::team::telemetry_db::query_review_metrics(conn) {
1087 Ok(row) => row,
1088 Err(error) => {
1089 warn!(error = %error, "failed to query review metrics from telemetry DB; returning zeros");
1090 return ReviewMetrics::default();
1091 }
1092 };
1093
1094 let auto_merge_count = row.auto_merge_count as u32;
1095 let manual_merge_count = row.manual_merge_count as u32;
1096 let rework_count = row.rework_count as u32;
1097 let total_merges = auto_merge_count + manual_merge_count;
1098 let auto_merge_rate = if total_merges > 0 {
1099 Some(auto_merge_count as f64 / total_merges as f64)
1100 } else {
1101 None
1102 };
1103 let total_reviewed = total_merges + rework_count;
1104 let rework_rate = if total_reviewed > 0 {
1105 Some(rework_count as f64 / total_reviewed as f64)
1106 } else {
1107 None
1108 };
1109
1110 ReviewMetrics {
1111 auto_merge_count,
1112 manual_merge_count,
1113 auto_merge_rate,
1114 rework_count,
1115 rework_rate,
1116 review_nudge_count: row.review_nudge_count as u32,
1117 review_escalation_count: row.review_escalation_count as u32,
1118 avg_review_latency_secs: row.avg_review_latency_secs,
1119 }
1120}
1121
1122pub fn format_metrics(metrics: &WorkflowMetrics) -> String {
1123 let idle = if metrics.idle_with_runnable.is_empty() {
1124 "-".to_string()
1125 } else {
1126 metrics.idle_with_runnable.join(", ")
1127 };
1128
1129 let auto_merge_rate_str = metrics
1130 .auto_merge_rate
1131 .map(|r| format!("{:.0}%", r * 100.0))
1132 .unwrap_or_else(|| "-".to_string());
1133 let rework_rate_str = metrics
1134 .rework_rate
1135 .map(|r| format!("{:.0}%", r * 100.0))
1136 .unwrap_or_else(|| "-".to_string());
1137 let avg_latency_str = metrics
1138 .avg_review_latency_secs
1139 .map(|secs| format_age(Some(secs as u64)))
1140 .unwrap_or_else(|| "-".to_string());
1141
1142 format!(
1143 "Workflow Metrics\n\
1144Runnable: {}\n\
1145Blocked: {}\n\
1146In Review: {}\n\
1147In Progress: {}\n\
1148Idle With Runnable: {}\n\
1149Oldest Review Age: {}\n\
1150Oldest Assignment Age: {}\n\n\
1151Review Pipeline\n\
1152Queue: {} | Avg Latency: {} | Auto-merge Rate: {} | Rework Rate: {}\n\
1153Auto: {} | Manual: {} | Rework: {} | Nudges: {} | Escalations: {}",
1154 metrics.runnable_count,
1155 metrics.blocked_count,
1156 metrics.in_review_count,
1157 metrics.in_progress_count,
1158 idle,
1159 format_age(metrics.oldest_review_age_secs),
1160 format_age(metrics.oldest_assignment_age_secs),
1161 metrics.in_review_count,
1162 avg_latency_str,
1163 auto_merge_rate_str,
1164 rework_rate_str,
1165 metrics.auto_merge_count,
1166 metrics.manual_merge_count,
1167 metrics.rework_count,
1168 metrics.review_nudge_count,
1169 metrics.review_escalation_count,
1170 )
1171}
1172
1173fn compute_idle_with_runnable(
1174 board_dir: &Path,
1175 members: &[MemberInstance],
1176 tasks: &[task::Task],
1177 runnable_count: u32,
1178) -> Vec<String> {
1179 if runnable_count == 0 {
1180 return Vec::new();
1181 }
1182
1183 let busy_engineers: HashSet<&str> = tasks
1184 .iter()
1185 .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
1186 .filter_map(|task| task.claimed_by.as_deref())
1187 .collect();
1188
1189 let pending_root = project_root_from_board_dir(board_dir).map(inbox::inboxes_root);
1190 let mut idle = members
1191 .iter()
1192 .filter(|member| member.role_type == RoleType::Engineer)
1193 .filter(|member| !busy_engineers.contains(member.name.as_str()))
1194 .filter(|member| {
1195 pending_root
1196 .as_ref()
1197 .and_then(|root| inbox::pending_message_count(root, &member.name).ok())
1198 .unwrap_or(0)
1199 == 0
1200 })
1201 .map(|member| member.name.clone())
1202 .collect::<Vec<_>>();
1203 idle.sort();
1204 idle
1205}
1206
1207fn project_root_from_board_dir(board_dir: &Path) -> Option<&Path> {
1208 board_dir.parent()?.parent()?.parent()
1209}
1210
1211fn file_age_secs(path: &Path, now: SystemTime) -> Option<u64> {
1212 let modified = std::fs::metadata(path).ok()?.modified().ok()?;
1213 now.duration_since(modified)
1214 .ok()
1215 .map(|duration| duration.as_secs())
1216}
1217
1218fn format_age(age_secs: Option<u64>) -> String {
1219 age_secs
1220 .map(|secs| format!("{secs}s"))
1221 .unwrap_or_else(|| "n/a".to_string())
1222}
1223
1224pub(crate) fn workflow_metrics_section(
1225 project_root: &Path,
1226 members: &[MemberInstance],
1227) -> Option<(String, WorkflowMetrics)> {
1228 let config_path = team_config_path(project_root);
1229 if !workflow_metrics_enabled(&config_path) {
1230 return None;
1231 }
1232
1233 let board_dir = team_config_dir(project_root).join("board");
1234 let events_path = team_events_path(project_root);
1235
1236 let db = crate::team::telemetry_db::open(project_root).ok();
1238 let events_fallback = if db.is_none() && events_path.is_file() {
1239 Some(events_path.as_path())
1240 } else {
1241 None
1242 };
1243
1244 match compute_metrics_with_telemetry(&board_dir, members, db.as_ref(), events_fallback) {
1245 Ok(metrics) => {
1246 let formatted = format_metrics(&metrics);
1247 Some((formatted, metrics))
1248 }
1249 Err(error) => {
1250 warn!(path = %board_dir.display(), error = %error, "failed to compute workflow metrics");
1251 None
1252 }
1253 }
1254}
1255
1256pub(crate) fn workflow_metrics_enabled(config_path: &Path) -> bool {
1257 let Ok(content) = std::fs::read_to_string(config_path) else {
1258 return false;
1259 };
1260
1261 content.lines().any(|line| {
1262 let line = line.trim();
1263 matches!(
1264 line,
1265 "workflow_mode: hybrid" | "workflow_mode: workflow_first"
1266 )
1267 })
1268}
1269
1270pub(crate) struct PaneStatusLabelUpdateContext<'a, F>
1271where
1272 F: Fn(&str) -> Option<Duration>,
1273{
1274 pub(crate) project_root: &'a Path,
1275 pub(crate) members: &'a [MemberInstance],
1276 pub(crate) pane_map: &'a HashMap<String, String>,
1277 pub(crate) states: &'a HashMap<String, MemberState>,
1278 pub(crate) nudges: &'a HashMap<String, NudgeSchedule>,
1279 pub(crate) last_standup: &'a HashMap<String, Instant>,
1280 pub(crate) paused_standups: &'a HashSet<String>,
1281 pub(crate) standup_interval_for_member: F,
1282}
1283
1284pub(crate) fn update_pane_status_labels<F>(context: PaneStatusLabelUpdateContext<'_, F>)
1285where
1286 F: Fn(&str) -> Option<Duration>,
1287{
1288 let PaneStatusLabelUpdateContext {
1289 project_root,
1290 members,
1291 pane_map,
1292 states,
1293 nudges,
1294 last_standup,
1295 paused_standups,
1296 standup_interval_for_member,
1297 } = context;
1298 let globally_paused = pause_marker_path(project_root).exists();
1299 let inbox_root = inbox::inboxes_root(project_root);
1300 let direct_reports = direct_reports_by_member(members);
1301 let owned_task_buckets = owned_task_buckets(project_root, members);
1302
1303 for member in members {
1304 if member.role_type == RoleType::User {
1305 continue;
1306 }
1307 let Some(pane_id) = pane_map.get(&member.name) else {
1308 continue;
1309 };
1310
1311 let state = states
1312 .get(&member.name)
1313 .copied()
1314 .unwrap_or(MemberState::Idle);
1315
1316 let pending_inbox = match inbox::pending_message_count(&inbox_root, &member.name) {
1317 Ok(count) => count,
1318 Err(error) => {
1319 warn!(member = %member.name, error = %error, "failed to count pending inbox messages");
1320 0
1321 }
1322 };
1323 let triage_backlog = match direct_reports.get(&member.name) {
1324 Some(reports) => {
1325 match delivered_direct_report_triage_count(&inbox_root, &member.name, reports) {
1326 Ok(count) => count,
1327 Err(error) => {
1328 warn!(member = %member.name, error = %error, "failed to compute triage backlog");
1329 0
1330 }
1331 }
1332 }
1333 None => 0,
1334 };
1335 let member_owned_tasks = owned_task_buckets
1336 .get(&member.name)
1337 .cloned()
1338 .unwrap_or_default();
1339
1340 let label = if globally_paused {
1341 compose_pane_status_label(PaneStatusLabelArgs {
1342 state,
1343 pending_inbox,
1344 triage_backlog,
1345 active_task_ids: &member_owned_tasks.active,
1346 review_task_ids: &member_owned_tasks.review,
1347 globally_paused: true,
1348 nudge_status: "",
1349 standup_status: "",
1350 })
1351 } else {
1352 let nudge_str = format_nudge_status(nudges.get(&member.name));
1353 let standup_str = standup_interval_for_member(&member.name)
1354 .map(|standup_interval| {
1355 format_standup_status(
1356 last_standup.get(&member.name).copied(),
1357 standup_interval,
1358 paused_standups.contains(&member.name),
1359 )
1360 })
1361 .unwrap_or_default();
1362 compose_pane_status_label(PaneStatusLabelArgs {
1363 state,
1364 pending_inbox,
1365 triage_backlog,
1366 active_task_ids: &member_owned_tasks.active,
1367 review_task_ids: &member_owned_tasks.review,
1368 globally_paused: false,
1369 nudge_status: &nudge_str,
1370 standup_status: &standup_str,
1371 })
1372 };
1373
1374 let _ = Command::new("tmux")
1375 .args(["set-option", "-p", "-t", pane_id, "@batty_status", &label])
1376 .output();
1377 }
1378}
1379
1380pub(crate) fn format_nudge_status(schedule: Option<&NudgeSchedule>) -> String {
1381 let Some(schedule) = schedule else {
1382 return String::new();
1383 };
1384
1385 if schedule.fired_this_idle {
1386 return " #[fg=magenta]nudge sent#[default]".to_string();
1387 }
1388
1389 if schedule.paused {
1390 return " #[fg=244]nudge paused#[default]".to_string();
1391 }
1392
1393 let Some(idle_since) = schedule.idle_since else {
1394 return String::new();
1395 };
1396
1397 let elapsed = idle_since.elapsed();
1398 if elapsed < schedule.interval {
1399 let remaining = schedule.interval - elapsed;
1400 let mins = remaining.as_secs() / 60;
1401 let secs = remaining.as_secs() % 60;
1402 format!(" #[fg=magenta]nudge {mins}:{secs:02}#[default]")
1403 } else {
1404 " #[fg=magenta]nudge now#[default]".to_string()
1405 }
1406}
1407
1408fn format_inbox_status(pending_count: usize) -> String {
1409 if pending_count == 0 {
1410 " #[fg=244]inbox 0#[default]".to_string()
1411 } else {
1412 format!(" #[fg=colour214,bold]inbox {pending_count}#[default]")
1413 }
1414}
1415
1416fn format_active_task_status(active_task_ids: &[u32]) -> String {
1417 match active_task_ids {
1418 [] => String::new(),
1419 [task_id] => format!(" #[fg=green,bold]task {task_id}#[default]"),
1420 _ => format!(" #[fg=green,bold]tasks {}#[default]", active_task_ids.len()),
1421 }
1422}
1423
1424fn format_review_task_status(review_task_ids: &[u32]) -> String {
1425 match review_task_ids {
1426 [] => String::new(),
1427 [task_id] => format!(" #[fg=blue,bold]review {task_id}#[default]"),
1428 _ => format!(" #[fg=blue,bold]review {}#[default]", review_task_ids.len()),
1429 }
1430}
1431
1432pub(crate) struct PaneStatusLabelArgs<'a> {
1433 pub(crate) state: MemberState,
1434 pub(crate) pending_inbox: usize,
1435 pub(crate) triage_backlog: usize,
1436 pub(crate) active_task_ids: &'a [u32],
1437 pub(crate) review_task_ids: &'a [u32],
1438 pub(crate) globally_paused: bool,
1439 pub(crate) nudge_status: &'a str,
1440 pub(crate) standup_status: &'a str,
1441}
1442
1443pub(crate) fn compose_pane_status_label(args: PaneStatusLabelArgs<'_>) -> String {
1444 let PaneStatusLabelArgs {
1445 state,
1446 pending_inbox,
1447 triage_backlog,
1448 active_task_ids,
1449 review_task_ids,
1450 globally_paused,
1451 nudge_status,
1452 standup_status,
1453 } = args;
1454 let state_str = match state {
1455 MemberState::Idle => "#[fg=yellow]idle#[default]",
1456 MemberState::Working => "#[fg=cyan]working#[default]",
1457 };
1458 let inbox_str = format_inbox_status(pending_inbox);
1459 let triage_str = if triage_backlog > 0 {
1460 format!(" #[fg=red,bold]triage {triage_backlog}#[default]")
1461 } else {
1462 String::new()
1463 };
1464 let active_task_str = format_active_task_status(active_task_ids);
1465 let review_task_str = format_review_task_status(review_task_ids);
1466
1467 if globally_paused {
1468 return format!(
1469 "{state_str}{inbox_str}{triage_str}{active_task_str}{review_task_str} #[fg=red]PAUSED#[default]"
1470 );
1471 }
1472
1473 format!(
1474 "{state_str}{inbox_str}{triage_str}{active_task_str}{review_task_str}{nudge_status}{standup_status}"
1475 )
1476}
1477
1478pub(crate) fn format_standup_status(
1479 last_standup: Option<Instant>,
1480 interval: Duration,
1481 paused: bool,
1482) -> String {
1483 if paused {
1484 return " #[fg=244]standup paused#[default]".to_string();
1485 }
1486
1487 let Some(last_standup) = last_standup else {
1488 return String::new();
1489 };
1490
1491 let elapsed = last_standup.elapsed();
1492 if elapsed < interval {
1493 let remaining = interval - elapsed;
1494 let mins = remaining.as_secs() / 60;
1495 let secs = remaining.as_secs() % 60;
1496 format!(" #[fg=blue]standup {mins}:{secs:02}#[default]")
1497 } else {
1498 " #[fg=blue]standup now#[default]".to_string()
1499 }
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504 use super::*;
1505 use std::fs;
1506
1507 use crate::team::config::RoleType;
1508 use crate::team::events::{EventSink, TeamEvent};
1509 use crate::team::hierarchy::MemberInstance;
1510 use crate::team::inbox::InboxMessage;
1511
1512 fn engineer(name: &str) -> MemberInstance {
1513 MemberInstance {
1514 name: name.to_string(),
1515 role_name: name.to_string(),
1516 role_type: RoleType::Engineer,
1517 agent: Some("codex".to_string()),
1518 prompt: None,
1519 reports_to: Some("manager".to_string()),
1520 use_worktrees: false,
1521 }
1522 }
1523
1524 fn manager(name: &str) -> MemberInstance {
1525 MemberInstance {
1526 name: name.to_string(),
1527 role_name: name.to_string(),
1528 role_type: RoleType::Manager,
1529 agent: Some("codex".to_string()),
1530 prompt: None,
1531 reports_to: Some("architect".to_string()),
1532 use_worktrees: false,
1533 }
1534 }
1535
1536 fn user_member(name: &str) -> MemberInstance {
1537 MemberInstance {
1538 name: name.to_string(),
1539 role_name: name.to_string(),
1540 role_type: RoleType::User,
1541 agent: None,
1542 prompt: None,
1543 reports_to: None,
1544 use_worktrees: false,
1545 }
1546 }
1547
1548 fn board_dir(project_root: &Path) -> std::path::PathBuf {
1549 project_root
1550 .join(".batty")
1551 .join("team_config")
1552 .join("board")
1553 }
1554
1555 fn write_board_task(project_root: &Path, filename: &str, frontmatter: &str) {
1556 let tasks_dir = board_dir(project_root).join("tasks");
1557 fs::create_dir_all(&tasks_dir).unwrap();
1558 fs::write(
1559 tasks_dir.join(filename),
1560 format!("---\n{frontmatter}class: standard\n---\n"),
1561 )
1562 .unwrap();
1563 }
1564
1565 #[test]
1566 fn build_team_status_rows_marks_user_and_stopped_session() {
1567 let members = vec![engineer("eng-1"), user_member("human")];
1568 let rows = build_team_status_rows(
1569 &members,
1570 false,
1571 &HashMap::new(),
1572 &HashMap::new(),
1573 &HashMap::new(),
1574 &HashMap::new(),
1575 &HashMap::new(),
1576 );
1577
1578 assert_eq!(rows[0].state, "stopped");
1579 assert_eq!(rows[0].runtime_label, None);
1580 assert_eq!(rows[1].state, "user");
1581 assert_eq!(rows[1].role_type, "User");
1582 assert_eq!(rows[1].agent, None);
1583 }
1584
1585 #[test]
1586 fn build_team_status_rows_promotes_idle_member_with_triage_backlog() {
1587 let members = vec![manager("manager")];
1588 let runtime_statuses = HashMap::from([(
1589 "manager".to_string(),
1590 RuntimeMemberStatus {
1591 state: "idle".to_string(),
1592 signal: None,
1593 label: Some("idle".to_string()),
1594 },
1595 )]);
1596 let triage_backlog_counts = HashMap::from([("manager".to_string(), 2usize)]);
1597
1598 let rows = build_team_status_rows(
1599 &members,
1600 true,
1601 &runtime_statuses,
1602 &HashMap::new(),
1603 &triage_backlog_counts,
1604 &HashMap::new(),
1605 &HashMap::new(),
1606 );
1607
1608 assert_eq!(rows[0].state, "triaging");
1609 assert_eq!(rows[0].signal.as_deref(), Some("needs triage (2)"));
1610 }
1611
1612 #[test]
1613 fn build_team_status_rows_promotes_idle_member_with_review_backlog() {
1614 let members = vec![manager("manager")];
1615 let runtime_statuses = HashMap::from([(
1616 "manager".to_string(),
1617 RuntimeMemberStatus {
1618 state: "idle".to_string(),
1619 signal: Some("nudge paused".to_string()),
1620 label: Some("idle".to_string()),
1621 },
1622 )]);
1623 let owned_task_buckets = HashMap::from([(
1624 "manager".to_string(),
1625 OwnedTaskBuckets {
1626 active: Vec::new(),
1627 review: vec![41, 42],
1628 },
1629 )]);
1630
1631 let rows = build_team_status_rows(
1632 &members,
1633 true,
1634 &runtime_statuses,
1635 &HashMap::new(),
1636 &HashMap::new(),
1637 &owned_task_buckets,
1638 &HashMap::new(),
1639 );
1640
1641 assert_eq!(rows[0].state, "reviewing");
1642 assert_eq!(
1643 rows[0].signal.as_deref(),
1644 Some("nudge paused, needs review (2)")
1645 );
1646 }
1647
1648 #[test]
1649 fn build_team_status_rows_defaults_to_starting_when_runtime_missing() {
1650 let members = vec![engineer("eng-1")];
1651 let rows = build_team_status_rows(
1652 &members,
1653 true,
1654 &HashMap::new(),
1655 &HashMap::new(),
1656 &HashMap::new(),
1657 &HashMap::new(),
1658 &HashMap::new(),
1659 );
1660
1661 assert_eq!(rows[0].state, "starting");
1662 assert_eq!(rows[0].runtime_label, None);
1663 }
1664
1665 #[test]
1666 fn build_team_status_health_counts_non_user_members_and_sorts_unhealthy() {
1667 let rows = vec![
1668 TeamStatusRow {
1669 name: "human".to_string(),
1670 role: "user".to_string(),
1671 role_type: "User".to_string(),
1672 agent: None,
1673 reports_to: None,
1674 state: "user".to_string(),
1675 pending_inbox: 9,
1676 triage_backlog: 9,
1677 active_owned_tasks: Vec::new(),
1678 review_owned_tasks: Vec::new(),
1679 signal: None,
1680 runtime_label: None,
1681 health: AgentHealthSummary::default(),
1682 health_summary: "-".to_string(),
1683 eta: "-".to_string(),
1684 },
1685 TeamStatusRow {
1686 name: "eng-2".to_string(),
1687 role: "engineer".to_string(),
1688 role_type: "Engineer".to_string(),
1689 agent: Some("codex".to_string()),
1690 reports_to: Some("manager".to_string()),
1691 state: "working".to_string(),
1692 pending_inbox: 1,
1693 triage_backlog: 2,
1694 active_owned_tasks: vec![2],
1695 review_owned_tasks: Vec::new(),
1696 signal: None,
1697 runtime_label: Some("working".to_string()),
1698 health: AgentHealthSummary {
1699 restart_count: 1,
1700 context_exhaustion_count: 0,
1701 delivery_failure_count: 0,
1702 task_elapsed_secs: None,
1703 backend_health: crate::agent::BackendHealth::default(),
1704 },
1705 health_summary: "r1".to_string(),
1706 eta: "-".to_string(),
1707 },
1708 TeamStatusRow {
1709 name: "eng-1".to_string(),
1710 role: "engineer".to_string(),
1711 role_type: "Engineer".to_string(),
1712 agent: Some("codex".to_string()),
1713 reports_to: Some("manager".to_string()),
1714 state: "reviewing".to_string(),
1715 pending_inbox: 3,
1716 triage_backlog: 1,
1717 active_owned_tasks: Vec::new(),
1718 review_owned_tasks: vec![1],
1719 signal: None,
1720 runtime_label: Some("idle".to_string()),
1721 health: AgentHealthSummary {
1722 restart_count: 0,
1723 context_exhaustion_count: 1,
1724 delivery_failure_count: 1,
1725 task_elapsed_secs: None,
1726 backend_health: crate::agent::BackendHealth::default(),
1727 },
1728 health_summary: "c1 d1".to_string(),
1729 eta: "-".to_string(),
1730 },
1731 ];
1732
1733 let health = build_team_status_health(&rows, true, false);
1734 assert_eq!(health.member_count, 2);
1735 assert_eq!(health.active_member_count, 2);
1736 assert_eq!(health.pending_inbox_count, 4);
1737 assert_eq!(health.triage_backlog_count, 3);
1738 assert_eq!(
1739 health.unhealthy_members,
1740 vec!["eng-1".to_string(), "eng-2".to_string()]
1741 );
1742 }
1743
1744 #[test]
1745 fn board_status_task_queues_returns_empty_when_board_is_missing() {
1746 let tmp = tempfile::tempdir().unwrap();
1747 let (active_tasks, review_queue) = board_status_task_queues(tmp.path()).unwrap();
1748
1749 assert!(active_tasks.is_empty());
1750 assert!(review_queue.is_empty());
1751 }
1752
1753 #[test]
1754 fn owned_task_buckets_routes_review_items_to_manager() {
1755 let tmp = tempfile::tempdir().unwrap();
1756 write_board_task(
1757 tmp.path(),
1758 "003-review.md",
1759 "id: 3\ntitle: Review one\nstatus: review\npriority: high\nclaimed_by: eng-2\n",
1760 );
1761 write_board_task(
1762 tmp.path(),
1763 "004-review.md",
1764 "id: 4\ntitle: Review two\nstatus: review\npriority: high\nclaimed_by: eng-1\n",
1765 );
1766 write_board_task(
1767 tmp.path(),
1768 "005-active.md",
1769 "id: 5\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-2\n",
1770 );
1771
1772 let buckets = owned_task_buckets(
1773 tmp.path(),
1774 &[manager("manager"), engineer("eng-1"), engineer("eng-2")],
1775 );
1776
1777 assert_eq!(
1778 buckets.get("manager"),
1779 Some(&OwnedTaskBuckets {
1780 active: Vec::new(),
1781 review: vec![3, 4],
1782 })
1783 );
1784 assert_eq!(
1785 buckets.get("eng-2"),
1786 Some(&OwnedTaskBuckets {
1787 active: vec![5],
1788 review: Vec::new(),
1789 })
1790 );
1791 }
1792
1793 #[test]
1794 fn compute_metrics_returns_default_when_board_is_missing() {
1795 let metrics =
1796 compute_metrics(&tempfile::tempdir().unwrap().path().join("board"), &[]).unwrap();
1797
1798 assert_eq!(metrics, WorkflowMetrics::default());
1799 }
1800
1801 #[test]
1802 fn compute_metrics_returns_default_when_board_is_empty() {
1803 let tmp = tempfile::tempdir().unwrap();
1804 fs::create_dir_all(board_dir(tmp.path()).join("tasks")).unwrap();
1805
1806 let metrics = compute_metrics(&board_dir(tmp.path()), &[]).unwrap();
1807 assert_eq!(metrics, WorkflowMetrics::default());
1808 }
1809
1810 #[test]
1811 fn compute_metrics_counts_workflow_states_and_idle_runnable() {
1812 let tmp = tempfile::tempdir().unwrap();
1813 write_board_task(
1814 tmp.path(),
1815 "001-runnable.md",
1816 "id: 1\ntitle: Runnable\nstatus: todo\npriority: high\n",
1817 );
1818 write_board_task(
1819 tmp.path(),
1820 "002-blocked.md",
1821 "id: 2\ntitle: Blocked\nstatus: blocked\npriority: medium\n",
1822 );
1823 write_board_task(
1824 tmp.path(),
1825 "003-review.md",
1826 "id: 3\ntitle: Review\nstatus: review\npriority: medium\nclaimed_by: eng-1\n",
1827 );
1828 write_board_task(
1829 tmp.path(),
1830 "004-in-progress.md",
1831 "id: 4\ntitle: In progress\nstatus: in-progress\npriority: medium\nclaimed_by: eng-1\n",
1832 );
1833 write_board_task(
1834 tmp.path(),
1835 "005-claimed.md",
1836 "id: 5\ntitle: Claimed todo\nstatus: todo\npriority: low\nclaimed_by: eng-3\n",
1837 );
1838 write_board_task(
1839 tmp.path(),
1840 "006-waiting.md",
1841 "id: 6\ntitle: Waiting\nstatus: todo\npriority: low\ndepends_on:\n - 7\n",
1842 );
1843 write_board_task(
1844 tmp.path(),
1845 "007-parent.md",
1846 "id: 7\ntitle: Parent\nstatus: in-progress\npriority: low\nclaimed_by: eng-3\n",
1847 );
1848
1849 let inbox_root = crate::team::inbox::inboxes_root(tmp.path());
1850 crate::team::inbox::deliver_to_inbox(
1851 &inbox_root,
1852 &InboxMessage::new_send("manager", "eng-2", "please pick this up"),
1853 )
1854 .unwrap();
1855
1856 let metrics = compute_metrics(
1857 &board_dir(tmp.path()),
1858 &[
1859 engineer("eng-1"),
1860 engineer("eng-2"),
1861 engineer("eng-3"),
1862 engineer("eng-4"),
1863 ],
1864 )
1865 .unwrap();
1866
1867 assert_eq!(metrics.runnable_count, 1);
1868 assert_eq!(metrics.blocked_count, 1);
1869 assert_eq!(metrics.in_review_count, 1);
1870 assert_eq!(metrics.in_progress_count, 2);
1871 assert_eq!(metrics.idle_with_runnable, vec!["eng-4".to_string()]);
1872 assert!(metrics.oldest_review_age_secs.is_some());
1873 assert!(metrics.oldest_assignment_age_secs.is_some());
1874 }
1875
1876 #[test]
1877 fn workflow_metrics_section_returns_none_when_mode_disabled() {
1878 let tmp = tempfile::tempdir().unwrap();
1879 let team_config_dir = tmp.path().join(".batty").join("team_config");
1880 fs::create_dir_all(&team_config_dir).unwrap();
1881 fs::write(team_config_dir.join("team.yaml"), "team: test\n").unwrap();
1882
1883 assert!(workflow_metrics_section(tmp.path(), &[engineer("eng-1")]).is_none());
1884 }
1885
1886 #[test]
1887 fn workflow_metrics_section_returns_formatted_metrics_when_enabled() {
1888 let tmp = tempfile::tempdir().unwrap();
1889 let team_config_dir = tmp.path().join(".batty").join("team_config");
1890 fs::create_dir_all(&team_config_dir).unwrap();
1891 fs::write(
1892 team_config_dir.join("team.yaml"),
1893 "team: test\nworkflow_mode: hybrid\n",
1894 )
1895 .unwrap();
1896 write_board_task(
1897 tmp.path(),
1898 "001-runnable.md",
1899 "id: 1\ntitle: Runnable\nstatus: todo\npriority: high\n",
1900 );
1901
1902 let (formatted, metrics) =
1903 workflow_metrics_section(tmp.path(), &[engineer("eng-1")]).unwrap();
1904
1905 assert!(formatted.contains("Workflow Metrics"));
1906 assert!(formatted.contains("Runnable: 1"));
1907 assert_eq!(metrics.runnable_count, 1);
1908 }
1909
1910 #[test]
1911 fn build_team_status_json_report_serializes_machine_readable_json() {
1912 let report = build_team_status_json_report(TeamStatusJsonReportInput {
1913 team: "test".to_string(),
1914 session: "batty-test".to_string(),
1915 session_running: true,
1916 paused: false,
1917 workflow_metrics: Some(WorkflowMetrics {
1918 runnable_count: 1,
1919 ..WorkflowMetrics::default()
1920 }),
1921 active_tasks: Vec::new(),
1922 review_queue: Vec::new(),
1923 members: vec![TeamStatusRow {
1924 name: "eng-1".to_string(),
1925 role: "engineer".to_string(),
1926 role_type: "Engineer".to_string(),
1927 agent: Some("codex".to_string()),
1928 reports_to: Some("manager".to_string()),
1929 state: "idle".to_string(),
1930 pending_inbox: 0,
1931 triage_backlog: 0,
1932 active_owned_tasks: Vec::new(),
1933 review_owned_tasks: Vec::new(),
1934 signal: None,
1935 runtime_label: Some("idle".to_string()),
1936 health: AgentHealthSummary::default(),
1937 health_summary: "-".to_string(),
1938 eta: "-".to_string(),
1939 }],
1940 });
1941
1942 let json = serde_json::to_value(&report).unwrap();
1943 assert_eq!(json["team"], "test");
1944 assert_eq!(json["running"], true);
1945 assert_eq!(json["health"]["member_count"], 1);
1946 assert_eq!(json["workflow_metrics"]["runnable_count"], 1);
1947 assert!(json["members"].is_array());
1948 }
1949
1950 #[test]
1951 fn parse_assigned_task_id_accepts_plain_numeric_values() {
1952 assert_eq!(parse_assigned_task_id("42"), Some(42));
1953 }
1954
1955 #[test]
1956 fn parse_assigned_task_id_extracts_task_hash_values() {
1957 assert_eq!(
1958 parse_assigned_task_id("Task #119: expand coverage"),
1959 Some(119)
1960 );
1961 assert_eq!(parse_assigned_task_id("working on #508 next"), Some(508));
1962 }
1963
1964 #[test]
1965 fn parse_assigned_task_id_rejects_values_without_leading_digits() {
1966 assert_eq!(parse_assigned_task_id("Task #abc"), None);
1967 assert_eq!(parse_assigned_task_id("no task here"), None);
1968 }
1969
1970 #[test]
1971 fn format_health_duration_formats_seconds() {
1972 assert_eq!(format_health_duration(59), "59s");
1973 }
1974
1975 #[test]
1976 fn format_health_duration_formats_minutes() {
1977 assert_eq!(format_health_duration(60), "1m");
1978 }
1979
1980 #[test]
1981 fn format_health_duration_formats_hours() {
1982 assert_eq!(format_health_duration(3_600), "1h");
1983 }
1984
1985 #[test]
1986 fn format_health_duration_formats_days() {
1987 assert_eq!(format_health_duration(86_400), "1d");
1988 }
1989
1990 #[test]
1991 fn merge_status_signal_combines_existing_triage_and_review_signals() {
1992 assert_eq!(
1993 merge_status_signal(Some("nudged".to_string()), 2, 1),
1994 Some("nudged, needs triage (2), needs review (1)".to_string())
1995 );
1996 }
1997
1998 #[test]
1999 fn merge_status_signal_returns_none_when_no_signals_exist() {
2000 assert_eq!(merge_status_signal(None, 0, 0), None);
2001 }
2002
2003 #[test]
2004 fn agent_health_by_member_defaults_without_events_or_state() {
2005 let tmp = tempfile::tempdir().unwrap();
2006 let health = agent_health_by_member(tmp.path(), &[engineer("eng-1"), engineer("eng-2")]);
2007
2008 assert_eq!(health.get("eng-1"), Some(&AgentHealthSummary::default()));
2009 assert_eq!(health.get("eng-2"), Some(&AgentHealthSummary::default()));
2010 }
2011
2012 #[test]
2013 fn board_status_task_queues_split_active_and_review_tasks() {
2014 let tmp = tempfile::tempdir().unwrap();
2015 let tasks_dir = tmp
2016 .path()
2017 .join(".batty")
2018 .join("team_config")
2019 .join("board")
2020 .join("tasks");
2021 fs::create_dir_all(&tasks_dir).unwrap();
2022 fs::write(
2023 tasks_dir.join("041-active.md"),
2024 "---\nid: 41\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nbranch: eng-1/task-41\nclass: standard\n---\n",
2025 )
2026 .unwrap();
2027 fs::write(
2028 tasks_dir.join("042-review.md"),
2029 "---\nid: 42\ntitle: Review task\nstatus: review\npriority: medium\nclaimed_by: eng-2\nreview_owner: manager\nnext_action: review now\nclass: standard\n---\n",
2030 )
2031 .unwrap();
2032 fs::write(
2033 tasks_dir.join("043-done.md"),
2034 "---\nid: 43\ntitle: Done task\nstatus: done\npriority: low\nclass: standard\n---\n",
2035 )
2036 .unwrap();
2037
2038 let (active_tasks, review_queue) = board_status_task_queues(tmp.path()).unwrap();
2039
2040 assert_eq!(active_tasks.len(), 1);
2041 assert_eq!(active_tasks[0].id, 41);
2042 assert_eq!(active_tasks[0].branch.as_deref(), Some("eng-1/task-41"));
2043 assert_eq!(review_queue.len(), 1);
2044 assert_eq!(review_queue[0].id, 42);
2045 assert_eq!(review_queue[0].review_owner.as_deref(), Some("manager"));
2046 assert_eq!(review_queue[0].next_action.as_deref(), Some("review now"));
2047 }
2048
2049 #[test]
2050 fn build_team_status_json_report_includes_health_and_queues() {
2051 let report = build_team_status_json_report(TeamStatusJsonReportInput {
2052 team: "test".to_string(),
2053 session: "batty-test".to_string(),
2054 session_running: true,
2055 paused: true,
2056 workflow_metrics: Some(WorkflowMetrics {
2057 runnable_count: 2,
2058 blocked_count: 1,
2059 in_review_count: 1,
2060 in_progress_count: 3,
2061 idle_with_runnable: vec!["eng-2".to_string()],
2062 oldest_review_age_secs: Some(60),
2063 oldest_assignment_age_secs: Some(120),
2064 ..Default::default()
2065 }),
2066 active_tasks: vec![StatusTaskEntry {
2067 id: 41,
2068 title: "Active task".to_string(),
2069 status: "in-progress".to_string(),
2070 priority: "high".to_string(),
2071 claimed_by: Some("eng-1".to_string()),
2072 review_owner: None,
2073 blocked_on: None,
2074 branch: Some("eng-1/task-41".to_string()),
2075 worktree_path: None,
2076 commit: None,
2077 next_action: None,
2078 }],
2079 review_queue: vec![StatusTaskEntry {
2080 id: 42,
2081 title: "Review task".to_string(),
2082 status: "review".to_string(),
2083 priority: "medium".to_string(),
2084 claimed_by: Some("eng-2".to_string()),
2085 review_owner: Some("manager".to_string()),
2086 blocked_on: None,
2087 branch: None,
2088 worktree_path: None,
2089 commit: None,
2090 next_action: Some("review now".to_string()),
2091 }],
2092 members: vec![
2093 TeamStatusRow {
2094 name: "eng-1".to_string(),
2095 role: "engineer".to_string(),
2096 role_type: "Engineer".to_string(),
2097 agent: Some("codex".to_string()),
2098 reports_to: Some("manager".to_string()),
2099 state: "working".to_string(),
2100 pending_inbox: 2,
2101 triage_backlog: 0,
2102 active_owned_tasks: vec![41],
2103 review_owned_tasks: vec![],
2104 signal: None,
2105 runtime_label: Some("working".to_string()),
2106 health: AgentHealthSummary {
2107 restart_count: 1,
2108 context_exhaustion_count: 0,
2109 delivery_failure_count: 0,
2110 task_elapsed_secs: Some(30),
2111 backend_health: crate::agent::BackendHealth::default(),
2112 },
2113 health_summary: "r1 t30s".to_string(),
2114 eta: "-".to_string(),
2115 },
2116 TeamStatusRow {
2117 name: "eng-2".to_string(),
2118 role: "engineer".to_string(),
2119 role_type: "Engineer".to_string(),
2120 agent: Some("codex".to_string()),
2121 reports_to: Some("manager".to_string()),
2122 state: "idle".to_string(),
2123 pending_inbox: 1,
2124 triage_backlog: 2,
2125 active_owned_tasks: vec![],
2126 review_owned_tasks: vec![42],
2127 signal: Some("needs review (1)".to_string()),
2128 runtime_label: Some("idle".to_string()),
2129 health: AgentHealthSummary::default(),
2130 health_summary: "-".to_string(),
2131 eta: "-".to_string(),
2132 },
2133 ],
2134 });
2135
2136 assert_eq!(report.team, "test");
2137 assert_eq!(report.active_tasks.len(), 1);
2138 assert_eq!(report.review_queue.len(), 1);
2139 assert!(report.paused);
2140 assert_eq!(report.health.member_count, 2);
2141 assert_eq!(report.health.active_member_count, 1);
2142 assert_eq!(report.health.pending_inbox_count, 3);
2143 assert_eq!(report.health.triage_backlog_count, 2);
2144 assert_eq!(report.health.unhealthy_members, vec!["eng-1".to_string()]);
2145 assert_eq!(report.workflow_metrics.unwrap().runnable_count, 2);
2146 }
2147
2148 #[test]
2149 fn format_standup_status_marks_paused_while_member_is_working() {
2150 assert_eq!(
2151 format_standup_status(Some(Instant::now()), Duration::from_secs(600), true),
2152 " #[fg=244]standup paused#[default]"
2153 );
2154 }
2155
2156 #[test]
2157 fn format_nudge_status_marks_paused_while_member_is_working() {
2158 let schedule = NudgeSchedule {
2159 text: "check in".to_string(),
2160 interval: Duration::from_secs(600),
2161 idle_since: None,
2162 fired_this_idle: false,
2163 paused: true,
2164 };
2165
2166 assert_eq!(
2167 format_nudge_status(Some(&schedule)),
2168 " #[fg=244]nudge paused#[default]"
2169 );
2170 }
2171
2172 #[test]
2173 fn compose_pane_status_label_shows_pending_inbox_count() {
2174 let label = compose_pane_status_label(PaneStatusLabelArgs {
2175 state: MemberState::Idle,
2176 pending_inbox: 3,
2177 triage_backlog: 2,
2178 active_task_ids: &[191],
2179 review_task_ids: &[193, 194],
2180 globally_paused: false,
2181 nudge_status: " #[fg=magenta]nudge 0:30#[default]",
2182 standup_status: "",
2183 });
2184 assert!(label.contains("idle"));
2185 assert!(label.contains("inbox 3"));
2186 assert!(label.contains("triage 2"));
2187 assert!(label.contains("task 191"));
2188 assert!(label.contains("review 2"));
2189 assert!(label.contains("nudge 0:30"));
2190 }
2191
2192 #[test]
2193 fn compose_pane_status_label_shows_zero_inbox_and_pause_state() {
2194 let label = compose_pane_status_label(PaneStatusLabelArgs {
2195 state: MemberState::Working,
2196 pending_inbox: 0,
2197 triage_backlog: 0,
2198 active_task_ids: &[],
2199 review_task_ids: &[],
2200 globally_paused: true,
2201 nudge_status: "",
2202 standup_status: "",
2203 });
2204 assert!(label.contains("working"));
2205 assert!(label.contains("inbox 0"));
2206 assert!(label.contains("PAUSED"));
2207 }
2208
2209 #[test]
2210 fn format_agent_health_summary_compacts_metrics() {
2211 let summary = format_agent_health_summary(&AgentHealthSummary {
2212 restart_count: 2,
2213 context_exhaustion_count: 1,
2214 delivery_failure_count: 3,
2215 task_elapsed_secs: Some(750),
2216 backend_health: crate::agent::BackendHealth::default(),
2217 });
2218
2219 assert_eq!(summary, "r2 c1 d3 t12m");
2220 assert_eq!(
2221 format_agent_health_summary(&AgentHealthSummary::default()),
2222 "-"
2223 );
2224 }
2225
2226 #[test]
2227 fn agent_health_by_member_aggregates_events_and_active_task_elapsed() {
2228 let tmp = tempfile::tempdir().unwrap();
2229 let events_path = team_events_path(tmp.path());
2230 let mut sink = EventSink::new(&events_path).unwrap();
2231
2232 let mut assigned = TeamEvent::task_assigned("eng-1", "Task #42: fix it");
2233 assigned.ts = now_unix().saturating_sub(600);
2234 sink.emit(assigned).unwrap();
2235
2236 let mut restarted = TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 2);
2237 restarted.ts = now_unix().saturating_sub(590);
2238 sink.emit(restarted).unwrap();
2239
2240 let mut exhausted = TeamEvent::context_exhausted("eng-1", Some(42), Some(4_096));
2241 exhausted.ts = now_unix().saturating_sub(580);
2242 sink.emit(exhausted).unwrap();
2243
2244 let mut delivery_failed =
2245 TeamEvent::delivery_failed("eng-1", "manager", "message delivery failed after retries");
2246 delivery_failed.ts = now_unix().saturating_sub(570);
2247 sink.emit(delivery_failed).unwrap();
2248
2249 let daemon_state = serde_json::json!({
2250 "active_tasks": {"eng-1": 42},
2251 "retry_counts": {"eng-1": 1}
2252 });
2253 fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2254 fs::write(
2255 daemon_state_path(tmp.path()),
2256 serde_json::to_vec_pretty(&daemon_state).unwrap(),
2257 )
2258 .unwrap();
2259
2260 let health = agent_health_by_member(tmp.path(), &[engineer("eng-1"), engineer("eng-2")]);
2261 let eng_1 = health.get("eng-1").unwrap();
2262 assert_eq!(eng_1.restart_count, 2);
2263 assert_eq!(eng_1.context_exhaustion_count, 1);
2264 assert_eq!(eng_1.delivery_failure_count, 1);
2265 assert!(eng_1.task_elapsed_secs.unwrap() >= 600);
2266 assert_eq!(health.get("eng-2").unwrap(), &AgentHealthSummary::default());
2267 }
2268
2269 #[test]
2270 fn format_agent_health_summary_includes_backend_health() {
2271 let summary = format_agent_health_summary(&AgentHealthSummary {
2272 backend_health: crate::agent::BackendHealth::Unreachable,
2273 ..AgentHealthSummary::default()
2274 });
2275 assert_eq!(summary, "B:unreachable");
2276
2277 let summary = format_agent_health_summary(&AgentHealthSummary {
2278 backend_health: crate::agent::BackendHealth::Degraded,
2279 restart_count: 1,
2280 ..AgentHealthSummary::default()
2281 });
2282 assert_eq!(summary, "B:degraded r1");
2283 }
2284
2285 #[test]
2286 fn agent_health_by_member_reads_health_changed_events() {
2287 let tmp = tempfile::tempdir().unwrap();
2288 let events_path = team_events_path(tmp.path());
2289 let mut sink = EventSink::new(&events_path).unwrap();
2290 sink.emit(TeamEvent::health_changed("eng-1", "healthy→unreachable"))
2291 .unwrap();
2292
2293 let health = agent_health_by_member(tmp.path(), &[engineer("eng-1")]);
2294 assert_eq!(
2295 health.get("eng-1").unwrap().backend_health,
2296 crate::agent::BackendHealth::Unreachable,
2297 );
2298 }
2299
2300 #[test]
2301 fn agent_health_by_member_uses_latest_health_event() {
2302 let tmp = tempfile::tempdir().unwrap();
2303 let events_path = team_events_path(tmp.path());
2304 let mut sink = EventSink::new(&events_path).unwrap();
2305 sink.emit(TeamEvent::health_changed("eng-1", "healthy→unreachable"))
2306 .unwrap();
2307 sink.emit(TeamEvent::health_changed("eng-1", "unreachable→healthy"))
2308 .unwrap();
2309
2310 let health = agent_health_by_member(tmp.path(), &[engineer("eng-1")]);
2311 assert_eq!(
2312 health.get("eng-1").unwrap().backend_health,
2313 crate::agent::BackendHealth::Healthy,
2314 );
2315 }
2316
2317 #[test]
2318 fn build_team_status_health_counts_unhealthy_backend() {
2319 let rows = vec![TeamStatusRow {
2320 name: "eng-bad".to_string(),
2321 role: "engineer".to_string(),
2322 role_type: "Engineer".to_string(),
2323 agent: Some("claude".to_string()),
2324 reports_to: Some("manager".to_string()),
2325 state: "working".to_string(),
2326 pending_inbox: 0,
2327 triage_backlog: 0,
2328 active_owned_tasks: Vec::new(),
2329 review_owned_tasks: Vec::new(),
2330 signal: None,
2331 runtime_label: Some("working".to_string()),
2332 health: AgentHealthSummary {
2333 backend_health: crate::agent::BackendHealth::Unreachable,
2334 ..AgentHealthSummary::default()
2335 },
2336 health_summary: "B:unreachable".to_string(),
2337 eta: "-".to_string(),
2338 }];
2339 let health = build_team_status_health(&rows, true, false);
2340 assert_eq!(health.unhealthy_members, vec!["eng-bad".to_string()]);
2341 }
2342
2343 #[test]
2346 fn compute_metrics_with_telemetry_db_returns_review_metrics() {
2347 let tmp = tempfile::tempdir().unwrap();
2348
2349 write_board_task(
2351 tmp.path(),
2352 "001-task.md",
2353 "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2354 );
2355
2356 let conn = crate::team::telemetry_db::open_in_memory().unwrap();
2358 let events = vec![
2359 crate::team::events::TeamEvent::task_completed("eng-1", Some("1")),
2360 crate::team::events::TeamEvent::task_auto_merged("eng-1", "1", 0.9, 3, 50),
2361 crate::team::events::TeamEvent::task_completed("eng-1", Some("2")),
2362 crate::team::events::TeamEvent::task_manual_merged("2"),
2363 crate::team::events::TeamEvent::task_reworked("eng-1", "3"),
2364 ];
2365 for event in &events {
2366 crate::team::telemetry_db::insert_event(&conn, event).unwrap();
2367 }
2368
2369 let metrics =
2370 compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], Some(&conn), None).unwrap();
2371
2372 assert_eq!(metrics.auto_merge_count, 1);
2373 assert_eq!(metrics.manual_merge_count, 1);
2374 assert_eq!(metrics.rework_count, 1);
2375 assert!(metrics.auto_merge_rate.is_some());
2376 assert_eq!(metrics.runnable_count, 1);
2378 }
2379
2380 #[test]
2381 fn compute_metrics_without_db_falls_back_to_jsonl() {
2382 let tmp = tempfile::tempdir().unwrap();
2383
2384 write_board_task(
2385 tmp.path(),
2386 "001-task.md",
2387 "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2388 );
2389
2390 let events_path = team_events_path(tmp.path());
2392 let mut sink = EventSink::new(&events_path).unwrap();
2393 sink.emit(TeamEvent::task_auto_merged("eng-1", "1", 0.9, 3, 50))
2394 .unwrap();
2395
2396 let metrics =
2397 compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], None, Some(&events_path))
2398 .unwrap();
2399
2400 assert_eq!(metrics.auto_merge_count, 1);
2401 assert_eq!(metrics.runnable_count, 1);
2402 }
2403
2404 #[test]
2405 fn compute_metrics_without_db_or_events_returns_zero_review_metrics() {
2406 let tmp = tempfile::tempdir().unwrap();
2407
2408 write_board_task(
2409 tmp.path(),
2410 "001-task.md",
2411 "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2412 );
2413
2414 let metrics =
2416 compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], None, None).unwrap();
2417
2418 assert_eq!(metrics.auto_merge_count, 0);
2419 assert_eq!(metrics.manual_merge_count, 0);
2420 assert_eq!(metrics.rework_count, 0);
2421 assert_eq!(metrics.auto_merge_rate, None);
2422 assert_eq!(metrics.runnable_count, 1);
2424 }
2425
2426 #[test]
2427 fn format_metrics_unchanged_with_db_source() {
2428 let conn = crate::team::telemetry_db::open_in_memory().unwrap();
2429 let events = vec![
2430 crate::team::events::TeamEvent::task_completed("eng-1", Some("1")),
2431 crate::team::events::TeamEvent::task_auto_merged("eng-1", "1", 0.9, 3, 50),
2432 ];
2433 for event in &events {
2434 crate::team::telemetry_db::insert_event(&conn, event).unwrap();
2435 }
2436
2437 let tmp = tempfile::tempdir().unwrap();
2438 write_board_task(
2439 tmp.path(),
2440 "001-task.md",
2441 "id: 1\ntitle: Test\nstatus: review\npriority: high\nclaimed_by: eng-1\n",
2442 );
2443
2444 let metrics =
2445 compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], Some(&conn), None).unwrap();
2446
2447 let formatted = format_metrics(&metrics);
2448 assert!(formatted.contains("Workflow Metrics"));
2449 assert!(formatted.contains("Auto-merge Rate: 100%"));
2450 assert!(formatted.contains("In Review: 1"));
2451 }
2452}