Skip to main content

batty_cli/team/
status.rs

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    // Review pipeline metrics (computed from event log)
88    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                        // Parse the latest backend health from the transition reason.
403                        // Format is "prev→new", e.g. "healthy→unreachable".
404                        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_events(board_dir, members, None)
850}
851
852pub fn compute_metrics_with_events(
853    board_dir: &Path,
854    members: &[MemberInstance],
855    events_path: Option<&Path>,
856) -> Result<WorkflowMetrics> {
857    let tasks_dir = board_dir.join("tasks");
858    if !tasks_dir.is_dir() {
859        return Ok(WorkflowMetrics::default());
860    }
861
862    let tasks = task::load_tasks_from_dir(&tasks_dir)?;
863    if tasks.is_empty() {
864        return Ok(WorkflowMetrics::default());
865    }
866
867    let task_status_by_id: HashMap<u32, String> = tasks
868        .iter()
869        .map(|task| (task.id, task.status.clone()))
870        .collect();
871
872    let now = SystemTime::now();
873    let runnable_count = tasks
874        .iter()
875        .filter(|task| matches!(task.status.as_str(), "backlog" | "todo"))
876        .filter(|task| task.claimed_by.is_none())
877        .filter(|task| task.blocked.is_none())
878        .filter(|task| {
879            task.depends_on.iter().all(|dep_id| {
880                task_status_by_id
881                    .get(dep_id)
882                    .is_none_or(|status| status == "done")
883            })
884        })
885        .count() as u32;
886
887    let blocked_count = tasks
888        .iter()
889        .filter(|task| task.status == "blocked" || task.blocked.is_some())
890        .count() as u32;
891    let in_review_count = tasks.iter().filter(|task| task.status == "review").count() as u32;
892    let in_progress_count = tasks
893        .iter()
894        .filter(|task| matches!(task.status.as_str(), "in-progress" | "in_progress"))
895        .count() as u32;
896
897    let oldest_review_age_secs = tasks
898        .iter()
899        .filter(|task| task.status == "review")
900        .filter_map(|task| file_age_secs(&task.source_path, now))
901        .max();
902    let oldest_assignment_age_secs = tasks
903        .iter()
904        .filter(|task| task.claimed_by.is_some())
905        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
906        .filter_map(|task| file_age_secs(&task.source_path, now))
907        .max();
908
909    let idle_with_runnable = compute_idle_with_runnable(board_dir, members, &tasks, runnable_count);
910
911    let review = compute_review_metrics(events_path);
912
913    Ok(WorkflowMetrics {
914        runnable_count,
915        blocked_count,
916        in_review_count,
917        in_progress_count,
918        idle_with_runnable,
919        oldest_review_age_secs,
920        oldest_assignment_age_secs,
921        auto_merge_count: review.auto_merge_count,
922        manual_merge_count: review.manual_merge_count,
923        auto_merge_rate: review.auto_merge_rate,
924        rework_count: review.rework_count,
925        rework_rate: review.rework_rate,
926        review_nudge_count: review.review_nudge_count,
927        review_escalation_count: review.review_escalation_count,
928        avg_review_latency_secs: review.avg_review_latency_secs,
929    })
930}
931
932struct ReviewMetrics {
933    auto_merge_count: u32,
934    manual_merge_count: u32,
935    auto_merge_rate: Option<f64>,
936    rework_count: u32,
937    rework_rate: Option<f64>,
938    review_nudge_count: u32,
939    review_escalation_count: u32,
940    avg_review_latency_secs: Option<f64>,
941}
942
943fn compute_review_metrics(events_path: Option<&Path>) -> ReviewMetrics {
944    let events = events_path
945        .and_then(|path| events::read_events(path).ok())
946        .unwrap_or_default();
947
948    let mut auto_merge_count: u32 = 0;
949    let mut manual_merge_count: u32 = 0;
950    let mut rework_count: u32 = 0;
951    let mut review_nudge_count: u32 = 0;
952    let mut review_escalation_count: u32 = 0;
953
954    // Track review enter/exit times per task for latency computation.
955    // "task_completed" with a task field entering review; merged events exiting.
956    let mut review_enter_ts: HashMap<String, u64> = HashMap::new();
957    let mut review_latencies: Vec<f64> = Vec::new();
958
959    for event in &events {
960        match event.event.as_str() {
961            "task_auto_merged" => {
962                auto_merge_count += 1;
963                if let Some(task_id) = &event.task {
964                    if let Some(enter_ts) = review_enter_ts.remove(task_id) {
965                        review_latencies.push((event.ts - enter_ts) as f64);
966                    }
967                }
968            }
969            "task_manual_merged" => {
970                manual_merge_count += 1;
971                if let Some(task_id) = &event.task {
972                    if let Some(enter_ts) = review_enter_ts.remove(task_id) {
973                        review_latencies.push((event.ts - enter_ts) as f64);
974                    }
975                }
976            }
977            "task_reworked" => {
978                rework_count += 1;
979            }
980            "review_nudge_sent" => {
981                review_nudge_count += 1;
982            }
983            "review_escalated" => {
984                review_escalation_count += 1;
985            }
986            "task_completed" => {
987                if let Some(task_id) = &event.task {
988                    review_enter_ts.insert(task_id.clone(), event.ts);
989                }
990            }
991            _ => {}
992        }
993    }
994
995    let total_merges = auto_merge_count + manual_merge_count;
996    let auto_merge_rate = if total_merges > 0 {
997        Some(auto_merge_count as f64 / total_merges as f64)
998    } else {
999        None
1000    };
1001    let total_reviewed = total_merges + rework_count;
1002    let rework_rate = if total_reviewed > 0 {
1003        Some(rework_count as f64 / total_reviewed as f64)
1004    } else {
1005        None
1006    };
1007    let avg_review_latency_secs = if review_latencies.is_empty() {
1008        None
1009    } else {
1010        Some(review_latencies.iter().sum::<f64>() / review_latencies.len() as f64)
1011    };
1012
1013    ReviewMetrics {
1014        auto_merge_count,
1015        manual_merge_count,
1016        auto_merge_rate,
1017        rework_count,
1018        rework_rate,
1019        review_nudge_count,
1020        review_escalation_count,
1021        avg_review_latency_secs,
1022    }
1023}
1024
1025pub fn format_metrics(metrics: &WorkflowMetrics) -> String {
1026    let idle = if metrics.idle_with_runnable.is_empty() {
1027        "-".to_string()
1028    } else {
1029        metrics.idle_with_runnable.join(", ")
1030    };
1031
1032    let auto_merge_rate_str = metrics
1033        .auto_merge_rate
1034        .map(|r| format!("{:.0}%", r * 100.0))
1035        .unwrap_or_else(|| "-".to_string());
1036    let rework_rate_str = metrics
1037        .rework_rate
1038        .map(|r| format!("{:.0}%", r * 100.0))
1039        .unwrap_or_else(|| "-".to_string());
1040    let avg_latency_str = metrics
1041        .avg_review_latency_secs
1042        .map(|secs| format_age(Some(secs as u64)))
1043        .unwrap_or_else(|| "-".to_string());
1044
1045    format!(
1046        "Workflow Metrics\n\
1047Runnable: {}\n\
1048Blocked: {}\n\
1049In Review: {}\n\
1050In Progress: {}\n\
1051Idle With Runnable: {}\n\
1052Oldest Review Age: {}\n\
1053Oldest Assignment Age: {}\n\n\
1054Review Pipeline\n\
1055Queue: {} | Avg Latency: {} | Auto-merge Rate: {} | Rework Rate: {}\n\
1056Auto: {} | Manual: {} | Rework: {} | Nudges: {} | Escalations: {}",
1057        metrics.runnable_count,
1058        metrics.blocked_count,
1059        metrics.in_review_count,
1060        metrics.in_progress_count,
1061        idle,
1062        format_age(metrics.oldest_review_age_secs),
1063        format_age(metrics.oldest_assignment_age_secs),
1064        metrics.in_review_count,
1065        avg_latency_str,
1066        auto_merge_rate_str,
1067        rework_rate_str,
1068        metrics.auto_merge_count,
1069        metrics.manual_merge_count,
1070        metrics.rework_count,
1071        metrics.review_nudge_count,
1072        metrics.review_escalation_count,
1073    )
1074}
1075
1076fn compute_idle_with_runnable(
1077    board_dir: &Path,
1078    members: &[MemberInstance],
1079    tasks: &[task::Task],
1080    runnable_count: u32,
1081) -> Vec<String> {
1082    if runnable_count == 0 {
1083        return Vec::new();
1084    }
1085
1086    let busy_engineers: HashSet<&str> = tasks
1087        .iter()
1088        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
1089        .filter_map(|task| task.claimed_by.as_deref())
1090        .collect();
1091
1092    let pending_root = project_root_from_board_dir(board_dir).map(inbox::inboxes_root);
1093    let mut idle = members
1094        .iter()
1095        .filter(|member| member.role_type == RoleType::Engineer)
1096        .filter(|member| !busy_engineers.contains(member.name.as_str()))
1097        .filter(|member| {
1098            pending_root
1099                .as_ref()
1100                .and_then(|root| inbox::pending_message_count(root, &member.name).ok())
1101                .unwrap_or(0)
1102                == 0
1103        })
1104        .map(|member| member.name.clone())
1105        .collect::<Vec<_>>();
1106    idle.sort();
1107    idle
1108}
1109
1110fn project_root_from_board_dir(board_dir: &Path) -> Option<&Path> {
1111    board_dir.parent()?.parent()?.parent()
1112}
1113
1114fn file_age_secs(path: &Path, now: SystemTime) -> Option<u64> {
1115    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
1116    now.duration_since(modified)
1117        .ok()
1118        .map(|duration| duration.as_secs())
1119}
1120
1121fn format_age(age_secs: Option<u64>) -> String {
1122    age_secs
1123        .map(|secs| format!("{secs}s"))
1124        .unwrap_or_else(|| "n/a".to_string())
1125}
1126
1127pub(crate) fn workflow_metrics_section(
1128    project_root: &Path,
1129    members: &[MemberInstance],
1130) -> Option<(String, WorkflowMetrics)> {
1131    let config_path = team_config_path(project_root);
1132    if !workflow_metrics_enabled(&config_path) {
1133        return None;
1134    }
1135
1136    let board_dir = team_config_dir(project_root).join("board");
1137    match compute_metrics(&board_dir, members) {
1138        Ok(metrics) => {
1139            let formatted = format_metrics(&metrics);
1140            Some((formatted, metrics))
1141        }
1142        Err(error) => {
1143            warn!(path = %board_dir.display(), error = %error, "failed to compute workflow metrics");
1144            None
1145        }
1146    }
1147}
1148
1149pub(crate) fn workflow_metrics_enabled(config_path: &Path) -> bool {
1150    let Ok(content) = std::fs::read_to_string(config_path) else {
1151        return false;
1152    };
1153
1154    content.lines().any(|line| {
1155        let line = line.trim();
1156        matches!(
1157            line,
1158            "workflow_mode: hybrid" | "workflow_mode: workflow_first"
1159        )
1160    })
1161}
1162
1163pub(crate) struct PaneStatusLabelUpdateContext<'a, F>
1164where
1165    F: Fn(&str) -> Option<Duration>,
1166{
1167    pub(crate) project_root: &'a Path,
1168    pub(crate) members: &'a [MemberInstance],
1169    pub(crate) pane_map: &'a HashMap<String, String>,
1170    pub(crate) states: &'a HashMap<String, MemberState>,
1171    pub(crate) nudges: &'a HashMap<String, NudgeSchedule>,
1172    pub(crate) last_standup: &'a HashMap<String, Instant>,
1173    pub(crate) paused_standups: &'a HashSet<String>,
1174    pub(crate) standup_interval_for_member: F,
1175}
1176
1177pub(crate) fn update_pane_status_labels<F>(context: PaneStatusLabelUpdateContext<'_, F>)
1178where
1179    F: Fn(&str) -> Option<Duration>,
1180{
1181    let PaneStatusLabelUpdateContext {
1182        project_root,
1183        members,
1184        pane_map,
1185        states,
1186        nudges,
1187        last_standup,
1188        paused_standups,
1189        standup_interval_for_member,
1190    } = context;
1191    let globally_paused = pause_marker_path(project_root).exists();
1192    let inbox_root = inbox::inboxes_root(project_root);
1193    let direct_reports = direct_reports_by_member(members);
1194    let owned_task_buckets = owned_task_buckets(project_root, members);
1195
1196    for member in members {
1197        if member.role_type == RoleType::User {
1198            continue;
1199        }
1200        let Some(pane_id) = pane_map.get(&member.name) else {
1201            continue;
1202        };
1203
1204        let state = states
1205            .get(&member.name)
1206            .copied()
1207            .unwrap_or(MemberState::Idle);
1208
1209        let pending_inbox = match inbox::pending_message_count(&inbox_root, &member.name) {
1210            Ok(count) => count,
1211            Err(error) => {
1212                warn!(member = %member.name, error = %error, "failed to count pending inbox messages");
1213                0
1214            }
1215        };
1216        let triage_backlog = match direct_reports.get(&member.name) {
1217            Some(reports) => {
1218                match delivered_direct_report_triage_count(&inbox_root, &member.name, reports) {
1219                    Ok(count) => count,
1220                    Err(error) => {
1221                        warn!(member = %member.name, error = %error, "failed to compute triage backlog");
1222                        0
1223                    }
1224                }
1225            }
1226            None => 0,
1227        };
1228        let member_owned_tasks = owned_task_buckets
1229            .get(&member.name)
1230            .cloned()
1231            .unwrap_or_default();
1232
1233        let label = if globally_paused {
1234            compose_pane_status_label(PaneStatusLabelArgs {
1235                state,
1236                pending_inbox,
1237                triage_backlog,
1238                active_task_ids: &member_owned_tasks.active,
1239                review_task_ids: &member_owned_tasks.review,
1240                globally_paused: true,
1241                nudge_status: "",
1242                standup_status: "",
1243            })
1244        } else {
1245            let nudge_str = format_nudge_status(nudges.get(&member.name));
1246            let standup_str = standup_interval_for_member(&member.name)
1247                .map(|standup_interval| {
1248                    format_standup_status(
1249                        last_standup.get(&member.name).copied(),
1250                        standup_interval,
1251                        paused_standups.contains(&member.name),
1252                    )
1253                })
1254                .unwrap_or_default();
1255            compose_pane_status_label(PaneStatusLabelArgs {
1256                state,
1257                pending_inbox,
1258                triage_backlog,
1259                active_task_ids: &member_owned_tasks.active,
1260                review_task_ids: &member_owned_tasks.review,
1261                globally_paused: false,
1262                nudge_status: &nudge_str,
1263                standup_status: &standup_str,
1264            })
1265        };
1266
1267        let _ = Command::new("tmux")
1268            .args(["set-option", "-p", "-t", pane_id, "@batty_status", &label])
1269            .output();
1270    }
1271}
1272
1273pub(crate) fn format_nudge_status(schedule: Option<&NudgeSchedule>) -> String {
1274    let Some(schedule) = schedule else {
1275        return String::new();
1276    };
1277
1278    if schedule.fired_this_idle {
1279        return " #[fg=magenta]nudge sent#[default]".to_string();
1280    }
1281
1282    if schedule.paused {
1283        return " #[fg=244]nudge paused#[default]".to_string();
1284    }
1285
1286    let Some(idle_since) = schedule.idle_since else {
1287        return String::new();
1288    };
1289
1290    let elapsed = idle_since.elapsed();
1291    if elapsed < schedule.interval {
1292        let remaining = schedule.interval - elapsed;
1293        let mins = remaining.as_secs() / 60;
1294        let secs = remaining.as_secs() % 60;
1295        format!(" #[fg=magenta]nudge {mins}:{secs:02}#[default]")
1296    } else {
1297        " #[fg=magenta]nudge now#[default]".to_string()
1298    }
1299}
1300
1301fn format_inbox_status(pending_count: usize) -> String {
1302    if pending_count == 0 {
1303        " #[fg=244]inbox 0#[default]".to_string()
1304    } else {
1305        format!(" #[fg=colour214,bold]inbox {pending_count}#[default]")
1306    }
1307}
1308
1309fn format_active_task_status(active_task_ids: &[u32]) -> String {
1310    match active_task_ids {
1311        [] => String::new(),
1312        [task_id] => format!(" #[fg=green,bold]task {task_id}#[default]"),
1313        _ => format!(" #[fg=green,bold]tasks {}#[default]", active_task_ids.len()),
1314    }
1315}
1316
1317fn format_review_task_status(review_task_ids: &[u32]) -> String {
1318    match review_task_ids {
1319        [] => String::new(),
1320        [task_id] => format!(" #[fg=blue,bold]review {task_id}#[default]"),
1321        _ => format!(" #[fg=blue,bold]review {}#[default]", review_task_ids.len()),
1322    }
1323}
1324
1325pub(crate) struct PaneStatusLabelArgs<'a> {
1326    pub(crate) state: MemberState,
1327    pub(crate) pending_inbox: usize,
1328    pub(crate) triage_backlog: usize,
1329    pub(crate) active_task_ids: &'a [u32],
1330    pub(crate) review_task_ids: &'a [u32],
1331    pub(crate) globally_paused: bool,
1332    pub(crate) nudge_status: &'a str,
1333    pub(crate) standup_status: &'a str,
1334}
1335
1336pub(crate) fn compose_pane_status_label(args: PaneStatusLabelArgs<'_>) -> String {
1337    let PaneStatusLabelArgs {
1338        state,
1339        pending_inbox,
1340        triage_backlog,
1341        active_task_ids,
1342        review_task_ids,
1343        globally_paused,
1344        nudge_status,
1345        standup_status,
1346    } = args;
1347    let state_str = match state {
1348        MemberState::Idle => "#[fg=yellow]idle#[default]",
1349        MemberState::Working => "#[fg=cyan]working#[default]",
1350    };
1351    let inbox_str = format_inbox_status(pending_inbox);
1352    let triage_str = if triage_backlog > 0 {
1353        format!(" #[fg=red,bold]triage {triage_backlog}#[default]")
1354    } else {
1355        String::new()
1356    };
1357    let active_task_str = format_active_task_status(active_task_ids);
1358    let review_task_str = format_review_task_status(review_task_ids);
1359
1360    if globally_paused {
1361        return format!(
1362            "{state_str}{inbox_str}{triage_str}{active_task_str}{review_task_str} #[fg=red]PAUSED#[default]"
1363        );
1364    }
1365
1366    format!(
1367        "{state_str}{inbox_str}{triage_str}{active_task_str}{review_task_str}{nudge_status}{standup_status}"
1368    )
1369}
1370
1371pub(crate) fn format_standup_status(
1372    last_standup: Option<Instant>,
1373    interval: Duration,
1374    paused: bool,
1375) -> String {
1376    if paused {
1377        return " #[fg=244]standup paused#[default]".to_string();
1378    }
1379
1380    let Some(last_standup) = last_standup else {
1381        return String::new();
1382    };
1383
1384    let elapsed = last_standup.elapsed();
1385    if elapsed < interval {
1386        let remaining = interval - elapsed;
1387        let mins = remaining.as_secs() / 60;
1388        let secs = remaining.as_secs() % 60;
1389        format!(" #[fg=blue]standup {mins}:{secs:02}#[default]")
1390    } else {
1391        " #[fg=blue]standup now#[default]".to_string()
1392    }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::*;
1398    use std::fs;
1399
1400    use crate::team::config::RoleType;
1401    use crate::team::events::{EventSink, TeamEvent};
1402    use crate::team::hierarchy::MemberInstance;
1403    use crate::team::inbox::InboxMessage;
1404
1405    fn engineer(name: &str) -> MemberInstance {
1406        MemberInstance {
1407            name: name.to_string(),
1408            role_name: name.to_string(),
1409            role_type: RoleType::Engineer,
1410            agent: Some("codex".to_string()),
1411            prompt: None,
1412            reports_to: Some("manager".to_string()),
1413            use_worktrees: false,
1414        }
1415    }
1416
1417    fn manager(name: &str) -> MemberInstance {
1418        MemberInstance {
1419            name: name.to_string(),
1420            role_name: name.to_string(),
1421            role_type: RoleType::Manager,
1422            agent: Some("codex".to_string()),
1423            prompt: None,
1424            reports_to: Some("architect".to_string()),
1425            use_worktrees: false,
1426        }
1427    }
1428
1429    fn user_member(name: &str) -> MemberInstance {
1430        MemberInstance {
1431            name: name.to_string(),
1432            role_name: name.to_string(),
1433            role_type: RoleType::User,
1434            agent: None,
1435            prompt: None,
1436            reports_to: None,
1437            use_worktrees: false,
1438        }
1439    }
1440
1441    fn board_dir(project_root: &Path) -> std::path::PathBuf {
1442        project_root
1443            .join(".batty")
1444            .join("team_config")
1445            .join("board")
1446    }
1447
1448    fn write_board_task(project_root: &Path, filename: &str, frontmatter: &str) {
1449        let tasks_dir = board_dir(project_root).join("tasks");
1450        fs::create_dir_all(&tasks_dir).unwrap();
1451        fs::write(
1452            tasks_dir.join(filename),
1453            format!("---\n{frontmatter}class: standard\n---\n"),
1454        )
1455        .unwrap();
1456    }
1457
1458    #[test]
1459    fn build_team_status_rows_marks_user_and_stopped_session() {
1460        let members = vec![engineer("eng-1"), user_member("human")];
1461        let rows = build_team_status_rows(
1462            &members,
1463            false,
1464            &HashMap::new(),
1465            &HashMap::new(),
1466            &HashMap::new(),
1467            &HashMap::new(),
1468            &HashMap::new(),
1469        );
1470
1471        assert_eq!(rows[0].state, "stopped");
1472        assert_eq!(rows[0].runtime_label, None);
1473        assert_eq!(rows[1].state, "user");
1474        assert_eq!(rows[1].role_type, "User");
1475        assert_eq!(rows[1].agent, None);
1476    }
1477
1478    #[test]
1479    fn build_team_status_rows_promotes_idle_member_with_triage_backlog() {
1480        let members = vec![manager("manager")];
1481        let runtime_statuses = HashMap::from([(
1482            "manager".to_string(),
1483            RuntimeMemberStatus {
1484                state: "idle".to_string(),
1485                signal: None,
1486                label: Some("idle".to_string()),
1487            },
1488        )]);
1489        let triage_backlog_counts = HashMap::from([("manager".to_string(), 2usize)]);
1490
1491        let rows = build_team_status_rows(
1492            &members,
1493            true,
1494            &runtime_statuses,
1495            &HashMap::new(),
1496            &triage_backlog_counts,
1497            &HashMap::new(),
1498            &HashMap::new(),
1499        );
1500
1501        assert_eq!(rows[0].state, "triaging");
1502        assert_eq!(rows[0].signal.as_deref(), Some("needs triage (2)"));
1503    }
1504
1505    #[test]
1506    fn build_team_status_rows_promotes_idle_member_with_review_backlog() {
1507        let members = vec![manager("manager")];
1508        let runtime_statuses = HashMap::from([(
1509            "manager".to_string(),
1510            RuntimeMemberStatus {
1511                state: "idle".to_string(),
1512                signal: Some("nudge paused".to_string()),
1513                label: Some("idle".to_string()),
1514            },
1515        )]);
1516        let owned_task_buckets = HashMap::from([(
1517            "manager".to_string(),
1518            OwnedTaskBuckets {
1519                active: Vec::new(),
1520                review: vec![41, 42],
1521            },
1522        )]);
1523
1524        let rows = build_team_status_rows(
1525            &members,
1526            true,
1527            &runtime_statuses,
1528            &HashMap::new(),
1529            &HashMap::new(),
1530            &owned_task_buckets,
1531            &HashMap::new(),
1532        );
1533
1534        assert_eq!(rows[0].state, "reviewing");
1535        assert_eq!(
1536            rows[0].signal.as_deref(),
1537            Some("nudge paused, needs review (2)")
1538        );
1539    }
1540
1541    #[test]
1542    fn build_team_status_rows_defaults_to_starting_when_runtime_missing() {
1543        let members = vec![engineer("eng-1")];
1544        let rows = build_team_status_rows(
1545            &members,
1546            true,
1547            &HashMap::new(),
1548            &HashMap::new(),
1549            &HashMap::new(),
1550            &HashMap::new(),
1551            &HashMap::new(),
1552        );
1553
1554        assert_eq!(rows[0].state, "starting");
1555        assert_eq!(rows[0].runtime_label, None);
1556    }
1557
1558    #[test]
1559    fn build_team_status_health_counts_non_user_members_and_sorts_unhealthy() {
1560        let rows = vec![
1561            TeamStatusRow {
1562                name: "human".to_string(),
1563                role: "user".to_string(),
1564                role_type: "User".to_string(),
1565                agent: None,
1566                reports_to: None,
1567                state: "user".to_string(),
1568                pending_inbox: 9,
1569                triage_backlog: 9,
1570                active_owned_tasks: Vec::new(),
1571                review_owned_tasks: Vec::new(),
1572                signal: None,
1573                runtime_label: None,
1574                health: AgentHealthSummary::default(),
1575                health_summary: "-".to_string(),
1576                eta: "-".to_string(),
1577            },
1578            TeamStatusRow {
1579                name: "eng-2".to_string(),
1580                role: "engineer".to_string(),
1581                role_type: "Engineer".to_string(),
1582                agent: Some("codex".to_string()),
1583                reports_to: Some("manager".to_string()),
1584                state: "working".to_string(),
1585                pending_inbox: 1,
1586                triage_backlog: 2,
1587                active_owned_tasks: vec![2],
1588                review_owned_tasks: Vec::new(),
1589                signal: None,
1590                runtime_label: Some("working".to_string()),
1591                health: AgentHealthSummary {
1592                    restart_count: 1,
1593                    context_exhaustion_count: 0,
1594                    delivery_failure_count: 0,
1595                    task_elapsed_secs: None,
1596                    backend_health: crate::agent::BackendHealth::default(),
1597                },
1598                health_summary: "r1".to_string(),
1599                eta: "-".to_string(),
1600            },
1601            TeamStatusRow {
1602                name: "eng-1".to_string(),
1603                role: "engineer".to_string(),
1604                role_type: "Engineer".to_string(),
1605                agent: Some("codex".to_string()),
1606                reports_to: Some("manager".to_string()),
1607                state: "reviewing".to_string(),
1608                pending_inbox: 3,
1609                triage_backlog: 1,
1610                active_owned_tasks: Vec::new(),
1611                review_owned_tasks: vec![1],
1612                signal: None,
1613                runtime_label: Some("idle".to_string()),
1614                health: AgentHealthSummary {
1615                    restart_count: 0,
1616                    context_exhaustion_count: 1,
1617                    delivery_failure_count: 1,
1618                    task_elapsed_secs: None,
1619                    backend_health: crate::agent::BackendHealth::default(),
1620                },
1621                health_summary: "c1 d1".to_string(),
1622                eta: "-".to_string(),
1623            },
1624        ];
1625
1626        let health = build_team_status_health(&rows, true, false);
1627        assert_eq!(health.member_count, 2);
1628        assert_eq!(health.active_member_count, 2);
1629        assert_eq!(health.pending_inbox_count, 4);
1630        assert_eq!(health.triage_backlog_count, 3);
1631        assert_eq!(
1632            health.unhealthy_members,
1633            vec!["eng-1".to_string(), "eng-2".to_string()]
1634        );
1635    }
1636
1637    #[test]
1638    fn board_status_task_queues_returns_empty_when_board_is_missing() {
1639        let tmp = tempfile::tempdir().unwrap();
1640        let (active_tasks, review_queue) = board_status_task_queues(tmp.path()).unwrap();
1641
1642        assert!(active_tasks.is_empty());
1643        assert!(review_queue.is_empty());
1644    }
1645
1646    #[test]
1647    fn owned_task_buckets_routes_review_items_to_manager() {
1648        let tmp = tempfile::tempdir().unwrap();
1649        write_board_task(
1650            tmp.path(),
1651            "003-review.md",
1652            "id: 3\ntitle: Review one\nstatus: review\npriority: high\nclaimed_by: eng-2\n",
1653        );
1654        write_board_task(
1655            tmp.path(),
1656            "004-review.md",
1657            "id: 4\ntitle: Review two\nstatus: review\npriority: high\nclaimed_by: eng-1\n",
1658        );
1659        write_board_task(
1660            tmp.path(),
1661            "005-active.md",
1662            "id: 5\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-2\n",
1663        );
1664
1665        let buckets = owned_task_buckets(
1666            tmp.path(),
1667            &[manager("manager"), engineer("eng-1"), engineer("eng-2")],
1668        );
1669
1670        assert_eq!(
1671            buckets.get("manager"),
1672            Some(&OwnedTaskBuckets {
1673                active: Vec::new(),
1674                review: vec![3, 4],
1675            })
1676        );
1677        assert_eq!(
1678            buckets.get("eng-2"),
1679            Some(&OwnedTaskBuckets {
1680                active: vec![5],
1681                review: Vec::new(),
1682            })
1683        );
1684    }
1685
1686    #[test]
1687    fn compute_metrics_returns_default_when_board_is_missing() {
1688        let metrics =
1689            compute_metrics(&tempfile::tempdir().unwrap().path().join("board"), &[]).unwrap();
1690
1691        assert_eq!(metrics, WorkflowMetrics::default());
1692    }
1693
1694    #[test]
1695    fn compute_metrics_returns_default_when_board_is_empty() {
1696        let tmp = tempfile::tempdir().unwrap();
1697        fs::create_dir_all(board_dir(tmp.path()).join("tasks")).unwrap();
1698
1699        let metrics = compute_metrics(&board_dir(tmp.path()), &[]).unwrap();
1700        assert_eq!(metrics, WorkflowMetrics::default());
1701    }
1702
1703    #[test]
1704    fn compute_metrics_counts_workflow_states_and_idle_runnable() {
1705        let tmp = tempfile::tempdir().unwrap();
1706        write_board_task(
1707            tmp.path(),
1708            "001-runnable.md",
1709            "id: 1\ntitle: Runnable\nstatus: todo\npriority: high\n",
1710        );
1711        write_board_task(
1712            tmp.path(),
1713            "002-blocked.md",
1714            "id: 2\ntitle: Blocked\nstatus: blocked\npriority: medium\n",
1715        );
1716        write_board_task(
1717            tmp.path(),
1718            "003-review.md",
1719            "id: 3\ntitle: Review\nstatus: review\npriority: medium\nclaimed_by: eng-1\n",
1720        );
1721        write_board_task(
1722            tmp.path(),
1723            "004-in-progress.md",
1724            "id: 4\ntitle: In progress\nstatus: in-progress\npriority: medium\nclaimed_by: eng-1\n",
1725        );
1726        write_board_task(
1727            tmp.path(),
1728            "005-claimed.md",
1729            "id: 5\ntitle: Claimed todo\nstatus: todo\npriority: low\nclaimed_by: eng-3\n",
1730        );
1731        write_board_task(
1732            tmp.path(),
1733            "006-waiting.md",
1734            "id: 6\ntitle: Waiting\nstatus: todo\npriority: low\ndepends_on:\n  - 7\n",
1735        );
1736        write_board_task(
1737            tmp.path(),
1738            "007-parent.md",
1739            "id: 7\ntitle: Parent\nstatus: in-progress\npriority: low\nclaimed_by: eng-3\n",
1740        );
1741
1742        let inbox_root = crate::team::inbox::inboxes_root(tmp.path());
1743        crate::team::inbox::deliver_to_inbox(
1744            &inbox_root,
1745            &InboxMessage::new_send("manager", "eng-2", "please pick this up"),
1746        )
1747        .unwrap();
1748
1749        let metrics = compute_metrics(
1750            &board_dir(tmp.path()),
1751            &[
1752                engineer("eng-1"),
1753                engineer("eng-2"),
1754                engineer("eng-3"),
1755                engineer("eng-4"),
1756            ],
1757        )
1758        .unwrap();
1759
1760        assert_eq!(metrics.runnable_count, 1);
1761        assert_eq!(metrics.blocked_count, 1);
1762        assert_eq!(metrics.in_review_count, 1);
1763        assert_eq!(metrics.in_progress_count, 2);
1764        assert_eq!(metrics.idle_with_runnable, vec!["eng-4".to_string()]);
1765        assert!(metrics.oldest_review_age_secs.is_some());
1766        assert!(metrics.oldest_assignment_age_secs.is_some());
1767    }
1768
1769    #[test]
1770    fn workflow_metrics_section_returns_none_when_mode_disabled() {
1771        let tmp = tempfile::tempdir().unwrap();
1772        let team_config_dir = tmp.path().join(".batty").join("team_config");
1773        fs::create_dir_all(&team_config_dir).unwrap();
1774        fs::write(team_config_dir.join("team.yaml"), "team: test\n").unwrap();
1775
1776        assert!(workflow_metrics_section(tmp.path(), &[engineer("eng-1")]).is_none());
1777    }
1778
1779    #[test]
1780    fn workflow_metrics_section_returns_formatted_metrics_when_enabled() {
1781        let tmp = tempfile::tempdir().unwrap();
1782        let team_config_dir = tmp.path().join(".batty").join("team_config");
1783        fs::create_dir_all(&team_config_dir).unwrap();
1784        fs::write(
1785            team_config_dir.join("team.yaml"),
1786            "team: test\nworkflow_mode: hybrid\n",
1787        )
1788        .unwrap();
1789        write_board_task(
1790            tmp.path(),
1791            "001-runnable.md",
1792            "id: 1\ntitle: Runnable\nstatus: todo\npriority: high\n",
1793        );
1794
1795        let (formatted, metrics) =
1796            workflow_metrics_section(tmp.path(), &[engineer("eng-1")]).unwrap();
1797
1798        assert!(formatted.contains("Workflow Metrics"));
1799        assert!(formatted.contains("Runnable: 1"));
1800        assert_eq!(metrics.runnable_count, 1);
1801    }
1802
1803    #[test]
1804    fn build_team_status_json_report_serializes_machine_readable_json() {
1805        let report = build_team_status_json_report(TeamStatusJsonReportInput {
1806            team: "test".to_string(),
1807            session: "batty-test".to_string(),
1808            session_running: true,
1809            paused: false,
1810            workflow_metrics: Some(WorkflowMetrics {
1811                runnable_count: 1,
1812                ..WorkflowMetrics::default()
1813            }),
1814            active_tasks: Vec::new(),
1815            review_queue: Vec::new(),
1816            members: vec![TeamStatusRow {
1817                name: "eng-1".to_string(),
1818                role: "engineer".to_string(),
1819                role_type: "Engineer".to_string(),
1820                agent: Some("codex".to_string()),
1821                reports_to: Some("manager".to_string()),
1822                state: "idle".to_string(),
1823                pending_inbox: 0,
1824                triage_backlog: 0,
1825                active_owned_tasks: Vec::new(),
1826                review_owned_tasks: Vec::new(),
1827                signal: None,
1828                runtime_label: Some("idle".to_string()),
1829                health: AgentHealthSummary::default(),
1830                health_summary: "-".to_string(),
1831                eta: "-".to_string(),
1832            }],
1833        });
1834
1835        let json = serde_json::to_value(&report).unwrap();
1836        assert_eq!(json["team"], "test");
1837        assert_eq!(json["running"], true);
1838        assert_eq!(json["health"]["member_count"], 1);
1839        assert_eq!(json["workflow_metrics"]["runnable_count"], 1);
1840        assert!(json["members"].is_array());
1841    }
1842
1843    #[test]
1844    fn parse_assigned_task_id_accepts_plain_numeric_values() {
1845        assert_eq!(parse_assigned_task_id("42"), Some(42));
1846    }
1847
1848    #[test]
1849    fn parse_assigned_task_id_extracts_task_hash_values() {
1850        assert_eq!(
1851            parse_assigned_task_id("Task #119: expand coverage"),
1852            Some(119)
1853        );
1854        assert_eq!(parse_assigned_task_id("working on #508 next"), Some(508));
1855    }
1856
1857    #[test]
1858    fn parse_assigned_task_id_rejects_values_without_leading_digits() {
1859        assert_eq!(parse_assigned_task_id("Task #abc"), None);
1860        assert_eq!(parse_assigned_task_id("no task here"), None);
1861    }
1862
1863    #[test]
1864    fn format_health_duration_formats_seconds() {
1865        assert_eq!(format_health_duration(59), "59s");
1866    }
1867
1868    #[test]
1869    fn format_health_duration_formats_minutes() {
1870        assert_eq!(format_health_duration(60), "1m");
1871    }
1872
1873    #[test]
1874    fn format_health_duration_formats_hours() {
1875        assert_eq!(format_health_duration(3_600), "1h");
1876    }
1877
1878    #[test]
1879    fn format_health_duration_formats_days() {
1880        assert_eq!(format_health_duration(86_400), "1d");
1881    }
1882
1883    #[test]
1884    fn merge_status_signal_combines_existing_triage_and_review_signals() {
1885        assert_eq!(
1886            merge_status_signal(Some("nudged".to_string()), 2, 1),
1887            Some("nudged, needs triage (2), needs review (1)".to_string())
1888        );
1889    }
1890
1891    #[test]
1892    fn merge_status_signal_returns_none_when_no_signals_exist() {
1893        assert_eq!(merge_status_signal(None, 0, 0), None);
1894    }
1895
1896    #[test]
1897    fn agent_health_by_member_defaults_without_events_or_state() {
1898        let tmp = tempfile::tempdir().unwrap();
1899        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1"), engineer("eng-2")]);
1900
1901        assert_eq!(health.get("eng-1"), Some(&AgentHealthSummary::default()));
1902        assert_eq!(health.get("eng-2"), Some(&AgentHealthSummary::default()));
1903    }
1904
1905    #[test]
1906    fn board_status_task_queues_split_active_and_review_tasks() {
1907        let tmp = tempfile::tempdir().unwrap();
1908        let tasks_dir = tmp
1909            .path()
1910            .join(".batty")
1911            .join("team_config")
1912            .join("board")
1913            .join("tasks");
1914        fs::create_dir_all(&tasks_dir).unwrap();
1915        fs::write(
1916            tasks_dir.join("041-active.md"),
1917            "---\nid: 41\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nbranch: eng-1/task-41\nclass: standard\n---\n",
1918        )
1919        .unwrap();
1920        fs::write(
1921            tasks_dir.join("042-review.md"),
1922            "---\nid: 42\ntitle: Review task\nstatus: review\npriority: medium\nclaimed_by: eng-2\nreview_owner: manager\nnext_action: review now\nclass: standard\n---\n",
1923        )
1924        .unwrap();
1925        fs::write(
1926            tasks_dir.join("043-done.md"),
1927            "---\nid: 43\ntitle: Done task\nstatus: done\npriority: low\nclass: standard\n---\n",
1928        )
1929        .unwrap();
1930
1931        let (active_tasks, review_queue) = board_status_task_queues(tmp.path()).unwrap();
1932
1933        assert_eq!(active_tasks.len(), 1);
1934        assert_eq!(active_tasks[0].id, 41);
1935        assert_eq!(active_tasks[0].branch.as_deref(), Some("eng-1/task-41"));
1936        assert_eq!(review_queue.len(), 1);
1937        assert_eq!(review_queue[0].id, 42);
1938        assert_eq!(review_queue[0].review_owner.as_deref(), Some("manager"));
1939        assert_eq!(review_queue[0].next_action.as_deref(), Some("review now"));
1940    }
1941
1942    #[test]
1943    fn build_team_status_json_report_includes_health_and_queues() {
1944        let report = build_team_status_json_report(TeamStatusJsonReportInput {
1945            team: "test".to_string(),
1946            session: "batty-test".to_string(),
1947            session_running: true,
1948            paused: true,
1949            workflow_metrics: Some(WorkflowMetrics {
1950                runnable_count: 2,
1951                blocked_count: 1,
1952                in_review_count: 1,
1953                in_progress_count: 3,
1954                idle_with_runnable: vec!["eng-2".to_string()],
1955                oldest_review_age_secs: Some(60),
1956                oldest_assignment_age_secs: Some(120),
1957                ..Default::default()
1958            }),
1959            active_tasks: vec![StatusTaskEntry {
1960                id: 41,
1961                title: "Active task".to_string(),
1962                status: "in-progress".to_string(),
1963                priority: "high".to_string(),
1964                claimed_by: Some("eng-1".to_string()),
1965                review_owner: None,
1966                blocked_on: None,
1967                branch: Some("eng-1/task-41".to_string()),
1968                worktree_path: None,
1969                commit: None,
1970                next_action: None,
1971            }],
1972            review_queue: vec![StatusTaskEntry {
1973                id: 42,
1974                title: "Review task".to_string(),
1975                status: "review".to_string(),
1976                priority: "medium".to_string(),
1977                claimed_by: Some("eng-2".to_string()),
1978                review_owner: Some("manager".to_string()),
1979                blocked_on: None,
1980                branch: None,
1981                worktree_path: None,
1982                commit: None,
1983                next_action: Some("review now".to_string()),
1984            }],
1985            members: vec![
1986                TeamStatusRow {
1987                    name: "eng-1".to_string(),
1988                    role: "engineer".to_string(),
1989                    role_type: "Engineer".to_string(),
1990                    agent: Some("codex".to_string()),
1991                    reports_to: Some("manager".to_string()),
1992                    state: "working".to_string(),
1993                    pending_inbox: 2,
1994                    triage_backlog: 0,
1995                    active_owned_tasks: vec![41],
1996                    review_owned_tasks: vec![],
1997                    signal: None,
1998                    runtime_label: Some("working".to_string()),
1999                    health: AgentHealthSummary {
2000                        restart_count: 1,
2001                        context_exhaustion_count: 0,
2002                        delivery_failure_count: 0,
2003                        task_elapsed_secs: Some(30),
2004                        backend_health: crate::agent::BackendHealth::default(),
2005                    },
2006                    health_summary: "r1 t30s".to_string(),
2007                    eta: "-".to_string(),
2008                },
2009                TeamStatusRow {
2010                    name: "eng-2".to_string(),
2011                    role: "engineer".to_string(),
2012                    role_type: "Engineer".to_string(),
2013                    agent: Some("codex".to_string()),
2014                    reports_to: Some("manager".to_string()),
2015                    state: "idle".to_string(),
2016                    pending_inbox: 1,
2017                    triage_backlog: 2,
2018                    active_owned_tasks: vec![],
2019                    review_owned_tasks: vec![42],
2020                    signal: Some("needs review (1)".to_string()),
2021                    runtime_label: Some("idle".to_string()),
2022                    health: AgentHealthSummary::default(),
2023                    health_summary: "-".to_string(),
2024                    eta: "-".to_string(),
2025                },
2026            ],
2027        });
2028
2029        assert_eq!(report.team, "test");
2030        assert_eq!(report.active_tasks.len(), 1);
2031        assert_eq!(report.review_queue.len(), 1);
2032        assert!(report.paused);
2033        assert_eq!(report.health.member_count, 2);
2034        assert_eq!(report.health.active_member_count, 1);
2035        assert_eq!(report.health.pending_inbox_count, 3);
2036        assert_eq!(report.health.triage_backlog_count, 2);
2037        assert_eq!(report.health.unhealthy_members, vec!["eng-1".to_string()]);
2038        assert_eq!(report.workflow_metrics.unwrap().runnable_count, 2);
2039    }
2040
2041    #[test]
2042    fn format_standup_status_marks_paused_while_member_is_working() {
2043        assert_eq!(
2044            format_standup_status(Some(Instant::now()), Duration::from_secs(600), true),
2045            " #[fg=244]standup paused#[default]"
2046        );
2047    }
2048
2049    #[test]
2050    fn format_nudge_status_marks_paused_while_member_is_working() {
2051        let schedule = NudgeSchedule {
2052            text: "check in".to_string(),
2053            interval: Duration::from_secs(600),
2054            idle_since: None,
2055            fired_this_idle: false,
2056            paused: true,
2057        };
2058
2059        assert_eq!(
2060            format_nudge_status(Some(&schedule)),
2061            " #[fg=244]nudge paused#[default]"
2062        );
2063    }
2064
2065    #[test]
2066    fn compose_pane_status_label_shows_pending_inbox_count() {
2067        let label = compose_pane_status_label(PaneStatusLabelArgs {
2068            state: MemberState::Idle,
2069            pending_inbox: 3,
2070            triage_backlog: 2,
2071            active_task_ids: &[191],
2072            review_task_ids: &[193, 194],
2073            globally_paused: false,
2074            nudge_status: " #[fg=magenta]nudge 0:30#[default]",
2075            standup_status: "",
2076        });
2077        assert!(label.contains("idle"));
2078        assert!(label.contains("inbox 3"));
2079        assert!(label.contains("triage 2"));
2080        assert!(label.contains("task 191"));
2081        assert!(label.contains("review 2"));
2082        assert!(label.contains("nudge 0:30"));
2083    }
2084
2085    #[test]
2086    fn compose_pane_status_label_shows_zero_inbox_and_pause_state() {
2087        let label = compose_pane_status_label(PaneStatusLabelArgs {
2088            state: MemberState::Working,
2089            pending_inbox: 0,
2090            triage_backlog: 0,
2091            active_task_ids: &[],
2092            review_task_ids: &[],
2093            globally_paused: true,
2094            nudge_status: "",
2095            standup_status: "",
2096        });
2097        assert!(label.contains("working"));
2098        assert!(label.contains("inbox 0"));
2099        assert!(label.contains("PAUSED"));
2100    }
2101
2102    #[test]
2103    fn format_agent_health_summary_compacts_metrics() {
2104        let summary = format_agent_health_summary(&AgentHealthSummary {
2105            restart_count: 2,
2106            context_exhaustion_count: 1,
2107            delivery_failure_count: 3,
2108            task_elapsed_secs: Some(750),
2109            backend_health: crate::agent::BackendHealth::default(),
2110        });
2111
2112        assert_eq!(summary, "r2 c1 d3 t12m");
2113        assert_eq!(
2114            format_agent_health_summary(&AgentHealthSummary::default()),
2115            "-"
2116        );
2117    }
2118
2119    #[test]
2120    fn agent_health_by_member_aggregates_events_and_active_task_elapsed() {
2121        let tmp = tempfile::tempdir().unwrap();
2122        let events_path = team_events_path(tmp.path());
2123        let mut sink = EventSink::new(&events_path).unwrap();
2124
2125        let mut assigned = TeamEvent::task_assigned("eng-1", "Task #42: fix it");
2126        assigned.ts = now_unix().saturating_sub(600);
2127        sink.emit(assigned).unwrap();
2128
2129        let mut restarted = TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 2);
2130        restarted.ts = now_unix().saturating_sub(590);
2131        sink.emit(restarted).unwrap();
2132
2133        let mut exhausted = TeamEvent::context_exhausted("eng-1", Some(42), Some(4_096));
2134        exhausted.ts = now_unix().saturating_sub(580);
2135        sink.emit(exhausted).unwrap();
2136
2137        let mut delivery_failed =
2138            TeamEvent::delivery_failed("eng-1", "manager", "message delivery failed after retries");
2139        delivery_failed.ts = now_unix().saturating_sub(570);
2140        sink.emit(delivery_failed).unwrap();
2141
2142        let daemon_state = serde_json::json!({
2143            "active_tasks": {"eng-1": 42},
2144            "retry_counts": {"eng-1": 1}
2145        });
2146        fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2147        fs::write(
2148            daemon_state_path(tmp.path()),
2149            serde_json::to_vec_pretty(&daemon_state).unwrap(),
2150        )
2151        .unwrap();
2152
2153        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1"), engineer("eng-2")]);
2154        let eng_1 = health.get("eng-1").unwrap();
2155        assert_eq!(eng_1.restart_count, 2);
2156        assert_eq!(eng_1.context_exhaustion_count, 1);
2157        assert_eq!(eng_1.delivery_failure_count, 1);
2158        assert!(eng_1.task_elapsed_secs.unwrap() >= 600);
2159        assert_eq!(health.get("eng-2").unwrap(), &AgentHealthSummary::default());
2160    }
2161
2162    #[test]
2163    fn format_agent_health_summary_includes_backend_health() {
2164        let summary = format_agent_health_summary(&AgentHealthSummary {
2165            backend_health: crate::agent::BackendHealth::Unreachable,
2166            ..AgentHealthSummary::default()
2167        });
2168        assert_eq!(summary, "B:unreachable");
2169
2170        let summary = format_agent_health_summary(&AgentHealthSummary {
2171            backend_health: crate::agent::BackendHealth::Degraded,
2172            restart_count: 1,
2173            ..AgentHealthSummary::default()
2174        });
2175        assert_eq!(summary, "B:degraded r1");
2176    }
2177
2178    #[test]
2179    fn agent_health_by_member_reads_health_changed_events() {
2180        let tmp = tempfile::tempdir().unwrap();
2181        let events_path = team_events_path(tmp.path());
2182        let mut sink = EventSink::new(&events_path).unwrap();
2183        sink.emit(TeamEvent::health_changed("eng-1", "healthy→unreachable"))
2184            .unwrap();
2185
2186        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1")]);
2187        assert_eq!(
2188            health.get("eng-1").unwrap().backend_health,
2189            crate::agent::BackendHealth::Unreachable,
2190        );
2191    }
2192
2193    #[test]
2194    fn agent_health_by_member_uses_latest_health_event() {
2195        let tmp = tempfile::tempdir().unwrap();
2196        let events_path = team_events_path(tmp.path());
2197        let mut sink = EventSink::new(&events_path).unwrap();
2198        sink.emit(TeamEvent::health_changed("eng-1", "healthy→unreachable"))
2199            .unwrap();
2200        sink.emit(TeamEvent::health_changed("eng-1", "unreachable→healthy"))
2201            .unwrap();
2202
2203        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1")]);
2204        assert_eq!(
2205            health.get("eng-1").unwrap().backend_health,
2206            crate::agent::BackendHealth::Healthy,
2207        );
2208    }
2209
2210    #[test]
2211    fn build_team_status_health_counts_unhealthy_backend() {
2212        let rows = vec![TeamStatusRow {
2213            name: "eng-bad".to_string(),
2214            role: "engineer".to_string(),
2215            role_type: "Engineer".to_string(),
2216            agent: Some("claude".to_string()),
2217            reports_to: Some("manager".to_string()),
2218            state: "working".to_string(),
2219            pending_inbox: 0,
2220            triage_backlog: 0,
2221            active_owned_tasks: Vec::new(),
2222            review_owned_tasks: Vec::new(),
2223            signal: None,
2224            runtime_label: Some("working".to_string()),
2225            health: AgentHealthSummary {
2226                backend_health: crate::agent::BackendHealth::Unreachable,
2227                ..AgentHealthSummary::default()
2228            },
2229            health_summary: "B:unreachable".to_string(),
2230            eta: "-".to_string(),
2231        }];
2232        let health = build_team_status_health(&rows, true, false);
2233        assert_eq!(health.unhealthy_members, vec!["eng-bad".to_string()]);
2234    }
2235}