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_telemetry(board_dir, members, None, None)
850}
851
852/// Compute workflow metrics, preferring SQLite telemetry DB over JSONL events.
853///
854/// If a `db` connection is provided, review pipeline metrics come from SQLite.
855/// Otherwise falls back to `events_path` (JSONL), or returns zero review metrics.
856pub 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
897/// Board-only metrics (no event/review data).
898struct 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    // Track review enter/exit times per task for latency computation.
1014    // "task_completed" with a task field entering review; merged events exiting.
1015    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
1084/// Compute review pipeline metrics from the SQLite telemetry database.
1085fn 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    // Try SQLite telemetry DB first, fall back to JSONL events.
1237    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    // --- SQLite telemetry migration tests ---
2344
2345    #[test]
2346    fn compute_metrics_with_telemetry_db_returns_review_metrics() {
2347        let tmp = tempfile::tempdir().unwrap();
2348
2349        // Set up board with a task.
2350        write_board_task(
2351            tmp.path(),
2352            "001-task.md",
2353            "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2354        );
2355
2356        // Set up in-memory telemetry DB with events.
2357        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        // Board metric still works.
2377        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        // Write events to JSONL (no DB).
2391        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        // No DB, no events path.
2415        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        // Board metric still works.
2423        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}