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 inferred = infer_runtime_task_metadata(project_root, &task);
754        let entry = StatusTaskEntry {
755            id: task.id,
756            title: task.title,
757            status: task.status.clone(),
758            priority: task.priority,
759            claimed_by: task.claimed_by,
760            review_owner: task.review_owner,
761            blocked_on: task.blocked_on,
762            branch: task.branch.or_else(|| inferred.branch.clone()),
763            worktree_path: task
764                .worktree_path
765                .or_else(|| inferred.worktree_path.clone()),
766            commit: task.commit.or_else(|| inferred.commit.clone()),
767            next_action: task.next_action,
768        };
769
770        match task.status.as_str() {
771            "in-progress" | "in_progress" => active_tasks.push(entry),
772            "review" => review_queue.push(entry),
773            _ => {}
774        }
775    }
776
777    Ok((active_tasks, review_queue))
778}
779
780#[derive(Default)]
781struct InferredTaskMetadata {
782    branch: Option<String>,
783    worktree_path: Option<String>,
784    commit: Option<String>,
785}
786
787fn infer_runtime_task_metadata(project_root: &Path, task: &task::Task) -> InferredTaskMetadata {
788    let Some(claimed_by) = task.claimed_by.as_deref() else {
789        return InferredTaskMetadata::default();
790    };
791    if !claimed_by.starts_with("eng-") {
792        return InferredTaskMetadata::default();
793    }
794
795    let worktree_path = project_root
796        .join(".batty")
797        .join("worktrees")
798        .join(claimed_by);
799    if !worktree_path.is_dir() {
800        return InferredTaskMetadata::default();
801    }
802
803    InferredTaskMetadata {
804        branch: git_stdout(&worktree_path, ["rev-parse", "--abbrev-ref", "HEAD"]),
805        worktree_path: relative_to_project_root(project_root, &worktree_path),
806        commit: git_stdout(&worktree_path, ["rev-parse", "--short", "HEAD"]),
807    }
808}
809
810fn git_stdout<const N: usize>(cwd: &Path, args: [&str; N]) -> Option<String> {
811    let output = Command::new("git")
812        .args(args)
813        .current_dir(cwd)
814        .output()
815        .ok()?;
816    if !output.status.success() {
817        return None;
818    }
819    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
820    (!value.is_empty() && value != "HEAD").then_some(value)
821}
822
823fn relative_to_project_root(project_root: &Path, path: &Path) -> Option<String> {
824    path.strip_prefix(project_root)
825        .ok()
826        .map(|relative| relative.display().to_string())
827}
828
829pub(crate) fn build_team_status_health(
830    rows: &[TeamStatusRow],
831    session_running: bool,
832    paused: bool,
833) -> TeamStatusHealth {
834    let member_rows: Vec<&TeamStatusRow> =
835        rows.iter().filter(|row| row.role_type != "User").collect();
836    let mut unhealthy_members = member_rows
837        .iter()
838        .filter(|row| {
839            row.health.restart_count > 0
840                || row.health.context_exhaustion_count > 0
841                || row.health.delivery_failure_count > 0
842                || !row.health.backend_health.is_healthy()
843        })
844        .map(|row| row.name.clone())
845        .collect::<Vec<_>>();
846    unhealthy_members.sort();
847
848    TeamStatusHealth {
849        session_running,
850        paused,
851        member_count: member_rows.len(),
852        active_member_count: member_rows
853            .iter()
854            .filter(|row| matches!(row.state.as_str(), "working" | "triaging" | "reviewing"))
855            .count(),
856        pending_inbox_count: member_rows.iter().map(|row| row.pending_inbox).sum(),
857        triage_backlog_count: member_rows.iter().map(|row| row.triage_backlog).sum(),
858        unhealthy_members,
859    }
860}
861
862pub(crate) struct TeamStatusJsonReportInput {
863    pub(crate) team: String,
864    pub(crate) session: String,
865    pub(crate) session_running: bool,
866    pub(crate) paused: bool,
867    pub(crate) workflow_metrics: Option<WorkflowMetrics>,
868    pub(crate) active_tasks: Vec<StatusTaskEntry>,
869    pub(crate) review_queue: Vec<StatusTaskEntry>,
870    pub(crate) members: Vec<TeamStatusRow>,
871}
872
873pub(crate) fn build_team_status_json_report(
874    input: TeamStatusJsonReportInput,
875) -> TeamStatusJsonReport {
876    let TeamStatusJsonReportInput {
877        team,
878        session,
879        session_running,
880        paused,
881        workflow_metrics,
882        active_tasks,
883        review_queue,
884        members,
885    } = input;
886    let health = build_team_status_health(&members, session_running, paused);
887    TeamStatusJsonReport {
888        team,
889        session,
890        running: session_running,
891        paused,
892        health,
893        workflow_metrics,
894        active_tasks,
895        review_queue,
896        members,
897    }
898}
899
900pub fn compute_metrics(board_dir: &Path, members: &[MemberInstance]) -> Result<WorkflowMetrics> {
901    compute_metrics_with_telemetry(board_dir, members, None, None)
902}
903
904/// Compute workflow metrics, preferring SQLite telemetry DB over JSONL events.
905///
906/// If a `db` connection is provided, review pipeline metrics come from SQLite.
907/// Otherwise falls back to `events_path` (JSONL), or returns zero review metrics.
908pub fn compute_metrics_with_telemetry(
909    board_dir: &Path,
910    members: &[MemberInstance],
911    db: Option<&rusqlite::Connection>,
912    events_path: Option<&Path>,
913) -> Result<WorkflowMetrics> {
914    let board_metrics = compute_board_metrics(board_dir, members)?;
915
916    let review = if let Some(conn) = db {
917        compute_review_metrics_from_db(conn)
918    } else {
919        compute_review_metrics(events_path)
920    };
921
922    Ok(WorkflowMetrics {
923        runnable_count: board_metrics.runnable_count,
924        blocked_count: board_metrics.blocked_count,
925        in_review_count: board_metrics.in_review_count,
926        in_progress_count: board_metrics.in_progress_count,
927        idle_with_runnable: board_metrics.idle_with_runnable,
928        oldest_review_age_secs: board_metrics.oldest_review_age_secs,
929        oldest_assignment_age_secs: board_metrics.oldest_assignment_age_secs,
930        auto_merge_count: review.auto_merge_count,
931        manual_merge_count: review.manual_merge_count,
932        auto_merge_rate: review.auto_merge_rate,
933        rework_count: review.rework_count,
934        rework_rate: review.rework_rate,
935        review_nudge_count: review.review_nudge_count,
936        review_escalation_count: review.review_escalation_count,
937        avg_review_latency_secs: review.avg_review_latency_secs,
938    })
939}
940
941pub fn compute_metrics_with_events(
942    board_dir: &Path,
943    members: &[MemberInstance],
944    events_path: Option<&Path>,
945) -> Result<WorkflowMetrics> {
946    compute_metrics_with_telemetry(board_dir, members, None, events_path)
947}
948
949/// Board-only metrics (no event/review data).
950struct BoardMetrics {
951    runnable_count: u32,
952    blocked_count: u32,
953    in_review_count: u32,
954    in_progress_count: u32,
955    idle_with_runnable: Vec<String>,
956    oldest_review_age_secs: Option<u64>,
957    oldest_assignment_age_secs: Option<u64>,
958}
959
960fn compute_board_metrics(board_dir: &Path, members: &[MemberInstance]) -> Result<BoardMetrics> {
961    let tasks_dir = board_dir.join("tasks");
962    if !tasks_dir.is_dir() {
963        return Ok(BoardMetrics {
964            runnable_count: 0,
965            blocked_count: 0,
966            in_review_count: 0,
967            in_progress_count: 0,
968            idle_with_runnable: Vec::new(),
969            oldest_review_age_secs: None,
970            oldest_assignment_age_secs: None,
971        });
972    }
973
974    let tasks = task::load_tasks_from_dir(&tasks_dir)?;
975    if tasks.is_empty() {
976        return Ok(BoardMetrics {
977            runnable_count: 0,
978            blocked_count: 0,
979            in_review_count: 0,
980            in_progress_count: 0,
981            idle_with_runnable: Vec::new(),
982            oldest_review_age_secs: None,
983            oldest_assignment_age_secs: None,
984        });
985    }
986
987    let task_status_by_id: HashMap<u32, String> = tasks
988        .iter()
989        .map(|task| (task.id, task.status.clone()))
990        .collect();
991
992    let now = SystemTime::now();
993    let runnable_count = tasks
994        .iter()
995        .filter(|task| matches!(task.status.as_str(), "backlog" | "todo"))
996        .filter(|task| task.claimed_by.is_none())
997        .filter(|task| task.blocked.is_none())
998        .filter(|task| {
999            task.depends_on.iter().all(|dep_id| {
1000                task_status_by_id
1001                    .get(dep_id)
1002                    .is_none_or(|status| status == "done")
1003            })
1004        })
1005        .count() as u32;
1006
1007    let blocked_count = tasks
1008        .iter()
1009        .filter(|task| task.status == "blocked" || task.blocked.is_some())
1010        .count() as u32;
1011    let in_review_count = tasks.iter().filter(|task| task.status == "review").count() as u32;
1012    let in_progress_count = tasks
1013        .iter()
1014        .filter(|task| matches!(task.status.as_str(), "in-progress" | "in_progress"))
1015        .count() as u32;
1016
1017    let oldest_review_age_secs = tasks
1018        .iter()
1019        .filter(|task| task.status == "review")
1020        .filter_map(|task| file_age_secs(&task.source_path, now))
1021        .max();
1022    let oldest_assignment_age_secs = tasks
1023        .iter()
1024        .filter(|task| task.claimed_by.is_some())
1025        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
1026        .filter_map(|task| file_age_secs(&task.source_path, now))
1027        .max();
1028
1029    let idle_with_runnable = compute_idle_with_runnable(board_dir, members, &tasks, runnable_count);
1030
1031    Ok(BoardMetrics {
1032        runnable_count,
1033        blocked_count,
1034        in_review_count,
1035        in_progress_count,
1036        idle_with_runnable,
1037        oldest_review_age_secs,
1038        oldest_assignment_age_secs,
1039    })
1040}
1041
1042#[derive(Default)]
1043struct ReviewMetrics {
1044    auto_merge_count: u32,
1045    manual_merge_count: u32,
1046    auto_merge_rate: Option<f64>,
1047    rework_count: u32,
1048    rework_rate: Option<f64>,
1049    review_nudge_count: u32,
1050    review_escalation_count: u32,
1051    avg_review_latency_secs: Option<f64>,
1052}
1053
1054fn compute_review_metrics(events_path: Option<&Path>) -> ReviewMetrics {
1055    let events = events_path
1056        .and_then(|path| events::read_events(path).ok())
1057        .unwrap_or_default();
1058
1059    let mut auto_merge_count: u32 = 0;
1060    let mut manual_merge_count: u32 = 0;
1061    let mut rework_count: u32 = 0;
1062    let mut review_nudge_count: u32 = 0;
1063    let mut review_escalation_count: u32 = 0;
1064
1065    // Track review enter/exit times per task for latency computation.
1066    // "task_completed" with a task field entering review; merged events exiting.
1067    let mut review_enter_ts: HashMap<String, u64> = HashMap::new();
1068    let mut review_latencies: Vec<f64> = Vec::new();
1069
1070    for event in &events {
1071        match event.event.as_str() {
1072            "task_auto_merged" => {
1073                auto_merge_count += 1;
1074                if let Some(task_id) = &event.task {
1075                    if let Some(enter_ts) = review_enter_ts.remove(task_id) {
1076                        review_latencies.push((event.ts - enter_ts) as f64);
1077                    }
1078                }
1079            }
1080            "task_manual_merged" => {
1081                manual_merge_count += 1;
1082                if let Some(task_id) = &event.task {
1083                    if let Some(enter_ts) = review_enter_ts.remove(task_id) {
1084                        review_latencies.push((event.ts - enter_ts) as f64);
1085                    }
1086                }
1087            }
1088            "task_reworked" => {
1089                rework_count += 1;
1090            }
1091            "review_nudge_sent" => {
1092                review_nudge_count += 1;
1093            }
1094            "review_escalated" => {
1095                review_escalation_count += 1;
1096            }
1097            "task_completed" => {
1098                if let Some(task_id) = &event.task {
1099                    review_enter_ts.insert(task_id.clone(), event.ts);
1100                }
1101            }
1102            _ => {}
1103        }
1104    }
1105
1106    let total_merges = auto_merge_count + manual_merge_count;
1107    let auto_merge_rate = if total_merges > 0 {
1108        Some(auto_merge_count as f64 / total_merges as f64)
1109    } else {
1110        None
1111    };
1112    let total_reviewed = total_merges + rework_count;
1113    let rework_rate = if total_reviewed > 0 {
1114        Some(rework_count as f64 / total_reviewed as f64)
1115    } else {
1116        None
1117    };
1118    let avg_review_latency_secs = if review_latencies.is_empty() {
1119        None
1120    } else {
1121        Some(review_latencies.iter().sum::<f64>() / review_latencies.len() as f64)
1122    };
1123
1124    ReviewMetrics {
1125        auto_merge_count,
1126        manual_merge_count,
1127        auto_merge_rate,
1128        rework_count,
1129        rework_rate,
1130        review_nudge_count,
1131        review_escalation_count,
1132        avg_review_latency_secs,
1133    }
1134}
1135
1136/// Compute review pipeline metrics from the SQLite telemetry database.
1137fn compute_review_metrics_from_db(conn: &rusqlite::Connection) -> ReviewMetrics {
1138    let row = match crate::team::telemetry_db::query_review_metrics(conn) {
1139        Ok(row) => row,
1140        Err(error) => {
1141            warn!(error = %error, "failed to query review metrics from telemetry DB; returning zeros");
1142            return ReviewMetrics::default();
1143        }
1144    };
1145
1146    let auto_merge_count = row.auto_merge_count as u32;
1147    let manual_merge_count = row.manual_merge_count as u32;
1148    let rework_count = row.rework_count as u32;
1149    let total_merges = auto_merge_count + manual_merge_count;
1150    let auto_merge_rate = if total_merges > 0 {
1151        Some(auto_merge_count as f64 / total_merges as f64)
1152    } else {
1153        None
1154    };
1155    let total_reviewed = total_merges + rework_count;
1156    let rework_rate = if total_reviewed > 0 {
1157        Some(rework_count as f64 / total_reviewed as f64)
1158    } else {
1159        None
1160    };
1161
1162    ReviewMetrics {
1163        auto_merge_count,
1164        manual_merge_count,
1165        auto_merge_rate,
1166        rework_count,
1167        rework_rate,
1168        review_nudge_count: row.review_nudge_count as u32,
1169        review_escalation_count: row.review_escalation_count as u32,
1170        avg_review_latency_secs: row.avg_review_latency_secs,
1171    }
1172}
1173
1174pub fn format_metrics(metrics: &WorkflowMetrics) -> String {
1175    let idle = if metrics.idle_with_runnable.is_empty() {
1176        "-".to_string()
1177    } else {
1178        metrics.idle_with_runnable.join(", ")
1179    };
1180
1181    let auto_merge_rate_str = metrics
1182        .auto_merge_rate
1183        .map(|r| format!("{:.0}%", r * 100.0))
1184        .unwrap_or_else(|| "-".to_string());
1185    let rework_rate_str = metrics
1186        .rework_rate
1187        .map(|r| format!("{:.0}%", r * 100.0))
1188        .unwrap_or_else(|| "-".to_string());
1189    let avg_latency_str = metrics
1190        .avg_review_latency_secs
1191        .map(|secs| format_age(Some(secs as u64)))
1192        .unwrap_or_else(|| "-".to_string());
1193
1194    format!(
1195        "Workflow Metrics\n\
1196Runnable: {}\n\
1197Blocked: {}\n\
1198In Review: {}\n\
1199In Progress: {}\n\
1200Idle With Runnable: {}\n\
1201Oldest Review Age: {}\n\
1202Oldest Assignment Age: {}\n\n\
1203Review Pipeline\n\
1204Queue: {} | Avg Latency: {} | Auto-merge Rate: {} | Rework Rate: {}\n\
1205Auto: {} | Manual: {} | Rework: {} | Nudges: {} | Escalations: {}",
1206        metrics.runnable_count,
1207        metrics.blocked_count,
1208        metrics.in_review_count,
1209        metrics.in_progress_count,
1210        idle,
1211        format_age(metrics.oldest_review_age_secs),
1212        format_age(metrics.oldest_assignment_age_secs),
1213        metrics.in_review_count,
1214        avg_latency_str,
1215        auto_merge_rate_str,
1216        rework_rate_str,
1217        metrics.auto_merge_count,
1218        metrics.manual_merge_count,
1219        metrics.rework_count,
1220        metrics.review_nudge_count,
1221        metrics.review_escalation_count,
1222    )
1223}
1224
1225fn compute_idle_with_runnable(
1226    board_dir: &Path,
1227    members: &[MemberInstance],
1228    tasks: &[task::Task],
1229    runnable_count: u32,
1230) -> Vec<String> {
1231    if runnable_count == 0 {
1232        return Vec::new();
1233    }
1234
1235    let busy_engineers: HashSet<&str> = tasks
1236        .iter()
1237        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
1238        .filter_map(|task| task.claimed_by.as_deref())
1239        .collect();
1240
1241    let pending_root = project_root_from_board_dir(board_dir).map(inbox::inboxes_root);
1242    let mut idle = members
1243        .iter()
1244        .filter(|member| member.role_type == RoleType::Engineer)
1245        .filter(|member| !busy_engineers.contains(member.name.as_str()))
1246        .filter(|member| {
1247            pending_root
1248                .as_ref()
1249                .and_then(|root| inbox::pending_message_count(root, &member.name).ok())
1250                .unwrap_or(0)
1251                == 0
1252        })
1253        .map(|member| member.name.clone())
1254        .collect::<Vec<_>>();
1255    idle.sort();
1256    idle
1257}
1258
1259fn project_root_from_board_dir(board_dir: &Path) -> Option<&Path> {
1260    board_dir.parent()?.parent()?.parent()
1261}
1262
1263fn file_age_secs(path: &Path, now: SystemTime) -> Option<u64> {
1264    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
1265    now.duration_since(modified)
1266        .ok()
1267        .map(|duration| duration.as_secs())
1268}
1269
1270fn format_age(age_secs: Option<u64>) -> String {
1271    age_secs
1272        .map(|secs| format!("{secs}s"))
1273        .unwrap_or_else(|| "n/a".to_string())
1274}
1275
1276pub(crate) fn workflow_metrics_section(
1277    project_root: &Path,
1278    members: &[MemberInstance],
1279) -> Option<(String, WorkflowMetrics)> {
1280    let config_path = team_config_path(project_root);
1281    if !workflow_metrics_enabled(&config_path) {
1282        return None;
1283    }
1284
1285    let board_dir = team_config_dir(project_root).join("board");
1286    let events_path = team_events_path(project_root);
1287
1288    // Try SQLite telemetry DB first, fall back to JSONL events.
1289    let db = crate::team::telemetry_db::open(project_root).ok();
1290    let events_fallback = if db.is_none() && events_path.is_file() {
1291        Some(events_path.as_path())
1292    } else {
1293        None
1294    };
1295
1296    match compute_metrics_with_telemetry(&board_dir, members, db.as_ref(), events_fallback) {
1297        Ok(metrics) => {
1298            let formatted = format_metrics(&metrics);
1299            Some((formatted, metrics))
1300        }
1301        Err(error) => {
1302            warn!(path = %board_dir.display(), error = %error, "failed to compute workflow metrics");
1303            None
1304        }
1305    }
1306}
1307
1308pub(crate) fn workflow_metrics_enabled(config_path: &Path) -> bool {
1309    let Ok(content) = std::fs::read_to_string(config_path) else {
1310        return false;
1311    };
1312
1313    content.lines().any(|line| {
1314        let line = line.trim();
1315        matches!(
1316            line,
1317            "workflow_mode: hybrid" | "workflow_mode: workflow_first"
1318        )
1319    })
1320}
1321
1322pub(crate) struct PaneStatusLabelUpdateContext<'a, F>
1323where
1324    F: Fn(&str) -> Option<Duration>,
1325{
1326    pub(crate) project_root: &'a Path,
1327    pub(crate) members: &'a [MemberInstance],
1328    pub(crate) pane_map: &'a HashMap<String, String>,
1329    pub(crate) states: &'a HashMap<String, MemberState>,
1330    pub(crate) nudges: &'a HashMap<String, NudgeSchedule>,
1331    pub(crate) last_standup: &'a HashMap<String, Instant>,
1332    pub(crate) paused_standups: &'a HashSet<String>,
1333    pub(crate) standup_interval_for_member: F,
1334}
1335
1336pub(crate) fn update_pane_status_labels<F>(context: PaneStatusLabelUpdateContext<'_, F>)
1337where
1338    F: Fn(&str) -> Option<Duration>,
1339{
1340    let PaneStatusLabelUpdateContext {
1341        project_root,
1342        members,
1343        pane_map,
1344        states,
1345        nudges,
1346        last_standup,
1347        paused_standups,
1348        standup_interval_for_member,
1349    } = context;
1350    let globally_paused = pause_marker_path(project_root).exists();
1351    let inbox_root = inbox::inboxes_root(project_root);
1352    let direct_reports = direct_reports_by_member(members);
1353    let owned_task_buckets = owned_task_buckets(project_root, members);
1354
1355    for member in members {
1356        if member.role_type == RoleType::User {
1357            continue;
1358        }
1359        let Some(pane_id) = pane_map.get(&member.name) else {
1360            continue;
1361        };
1362
1363        let state = states
1364            .get(&member.name)
1365            .copied()
1366            .unwrap_or(MemberState::Idle);
1367
1368        let pending_inbox = match inbox::pending_message_count(&inbox_root, &member.name) {
1369            Ok(count) => count,
1370            Err(error) => {
1371                warn!(member = %member.name, error = %error, "failed to count pending inbox messages");
1372                0
1373            }
1374        };
1375        let triage_backlog = match direct_reports.get(&member.name) {
1376            Some(reports) => {
1377                match delivered_direct_report_triage_count(&inbox_root, &member.name, reports) {
1378                    Ok(count) => count,
1379                    Err(error) => {
1380                        warn!(member = %member.name, error = %error, "failed to compute triage backlog");
1381                        0
1382                    }
1383                }
1384            }
1385            None => 0,
1386        };
1387        let member_owned_tasks = owned_task_buckets
1388            .get(&member.name)
1389            .cloned()
1390            .unwrap_or_default();
1391
1392        let label = if globally_paused {
1393            compose_pane_status_label(PaneStatusLabelArgs {
1394                state,
1395                pending_inbox,
1396                triage_backlog,
1397                active_task_ids: &member_owned_tasks.active,
1398                review_task_ids: &member_owned_tasks.review,
1399                globally_paused: true,
1400                nudge_status: "",
1401                standup_status: "",
1402            })
1403        } else {
1404            let nudge_str = format_nudge_status(nudges.get(&member.name));
1405            let standup_str = standup_interval_for_member(&member.name)
1406                .map(|standup_interval| {
1407                    format_standup_status(
1408                        last_standup.get(&member.name).copied(),
1409                        standup_interval,
1410                        paused_standups.contains(&member.name),
1411                    )
1412                })
1413                .unwrap_or_default();
1414            compose_pane_status_label(PaneStatusLabelArgs {
1415                state,
1416                pending_inbox,
1417                triage_backlog,
1418                active_task_ids: &member_owned_tasks.active,
1419                review_task_ids: &member_owned_tasks.review,
1420                globally_paused: false,
1421                nudge_status: &nudge_str,
1422                standup_status: &standup_str,
1423            })
1424        };
1425
1426        let _ = Command::new("tmux")
1427            .args(["set-option", "-p", "-t", pane_id, "@batty_status", &label])
1428            .output();
1429    }
1430}
1431
1432pub(crate) fn format_nudge_status(schedule: Option<&NudgeSchedule>) -> String {
1433    let Some(schedule) = schedule else {
1434        return String::new();
1435    };
1436
1437    if schedule.fired_this_idle {
1438        return " #[fg=magenta]nudge sent#[default]".to_string();
1439    }
1440
1441    if schedule.paused {
1442        return " #[fg=244]nudge paused#[default]".to_string();
1443    }
1444
1445    let Some(idle_since) = schedule.idle_since else {
1446        return String::new();
1447    };
1448
1449    let elapsed = idle_since.elapsed();
1450    if elapsed < schedule.interval {
1451        let remaining = schedule.interval - elapsed;
1452        let mins = remaining.as_secs() / 60;
1453        let secs = remaining.as_secs() % 60;
1454        format!(" #[fg=magenta]nudge {mins}:{secs:02}#[default]")
1455    } else {
1456        " #[fg=magenta]nudge now#[default]".to_string()
1457    }
1458}
1459
1460fn format_inbox_status(pending_count: usize) -> String {
1461    if pending_count == 0 {
1462        " #[fg=244]inbox 0#[default]".to_string()
1463    } else {
1464        format!(" #[fg=colour214,bold]inbox {pending_count}#[default]")
1465    }
1466}
1467
1468fn format_active_task_status(active_task_ids: &[u32]) -> String {
1469    match active_task_ids {
1470        [] => String::new(),
1471        [task_id] => format!(" #[fg=green,bold]task {task_id}#[default]"),
1472        _ => format!(" #[fg=green,bold]tasks {}#[default]", active_task_ids.len()),
1473    }
1474}
1475
1476fn format_review_task_status(review_task_ids: &[u32]) -> String {
1477    match review_task_ids {
1478        [] => String::new(),
1479        [task_id] => format!(" #[fg=blue,bold]review {task_id}#[default]"),
1480        _ => format!(" #[fg=blue,bold]review {}#[default]", review_task_ids.len()),
1481    }
1482}
1483
1484pub(crate) struct PaneStatusLabelArgs<'a> {
1485    pub(crate) state: MemberState,
1486    pub(crate) pending_inbox: usize,
1487    pub(crate) triage_backlog: usize,
1488    pub(crate) active_task_ids: &'a [u32],
1489    pub(crate) review_task_ids: &'a [u32],
1490    pub(crate) globally_paused: bool,
1491    pub(crate) nudge_status: &'a str,
1492    pub(crate) standup_status: &'a str,
1493}
1494
1495pub(crate) fn compose_pane_status_label(args: PaneStatusLabelArgs<'_>) -> String {
1496    let PaneStatusLabelArgs {
1497        state,
1498        pending_inbox,
1499        triage_backlog,
1500        active_task_ids,
1501        review_task_ids,
1502        globally_paused,
1503        nudge_status,
1504        standup_status,
1505    } = args;
1506    let state_str = match state {
1507        MemberState::Idle => "#[fg=yellow]idle#[default]",
1508        MemberState::Working => "#[fg=cyan]working#[default]",
1509    };
1510    let inbox_str = format_inbox_status(pending_inbox);
1511    let triage_str = if triage_backlog > 0 {
1512        format!(" #[fg=red,bold]triage {triage_backlog}#[default]")
1513    } else {
1514        String::new()
1515    };
1516    let active_task_str = format_active_task_status(active_task_ids);
1517    let review_task_str = format_review_task_status(review_task_ids);
1518
1519    if globally_paused {
1520        return format!(
1521            "{state_str}{inbox_str}{triage_str}{active_task_str}{review_task_str} #[fg=red]PAUSED#[default]"
1522        );
1523    }
1524
1525    format!(
1526        "{state_str}{inbox_str}{triage_str}{active_task_str}{review_task_str}{nudge_status}{standup_status}"
1527    )
1528}
1529
1530pub(crate) fn format_standup_status(
1531    last_standup: Option<Instant>,
1532    interval: Duration,
1533    paused: bool,
1534) -> String {
1535    if paused {
1536        return " #[fg=244]standup paused#[default]".to_string();
1537    }
1538
1539    let Some(last_standup) = last_standup else {
1540        return String::new();
1541    };
1542
1543    let elapsed = last_standup.elapsed();
1544    if elapsed < interval {
1545        let remaining = interval - elapsed;
1546        let mins = remaining.as_secs() / 60;
1547        let secs = remaining.as_secs() % 60;
1548        format!(" #[fg=blue]standup {mins}:{secs:02}#[default]")
1549    } else {
1550        " #[fg=blue]standup now#[default]".to_string()
1551    }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556    use super::*;
1557    use std::fs;
1558
1559    use crate::team::config::RoleType;
1560    use crate::team::events::{EventSink, TeamEvent};
1561    use crate::team::hierarchy::MemberInstance;
1562    use crate::team::inbox::InboxMessage;
1563
1564    fn engineer(name: &str) -> MemberInstance {
1565        MemberInstance {
1566            name: name.to_string(),
1567            role_name: name.to_string(),
1568            role_type: RoleType::Engineer,
1569            agent: Some("codex".to_string()),
1570            prompt: None,
1571            reports_to: Some("manager".to_string()),
1572            use_worktrees: false,
1573        }
1574    }
1575
1576    fn manager(name: &str) -> MemberInstance {
1577        MemberInstance {
1578            name: name.to_string(),
1579            role_name: name.to_string(),
1580            role_type: RoleType::Manager,
1581            agent: Some("codex".to_string()),
1582            prompt: None,
1583            reports_to: Some("architect".to_string()),
1584            use_worktrees: false,
1585        }
1586    }
1587
1588    fn user_member(name: &str) -> MemberInstance {
1589        MemberInstance {
1590            name: name.to_string(),
1591            role_name: name.to_string(),
1592            role_type: RoleType::User,
1593            agent: None,
1594            prompt: None,
1595            reports_to: None,
1596            use_worktrees: false,
1597        }
1598    }
1599
1600    fn board_dir(project_root: &Path) -> std::path::PathBuf {
1601        project_root
1602            .join(".batty")
1603            .join("team_config")
1604            .join("board")
1605    }
1606
1607    fn write_board_task(project_root: &Path, filename: &str, frontmatter: &str) {
1608        let tasks_dir = board_dir(project_root).join("tasks");
1609        fs::create_dir_all(&tasks_dir).unwrap();
1610        fs::write(
1611            tasks_dir.join(filename),
1612            format!("---\n{frontmatter}class: standard\n---\n"),
1613        )
1614        .unwrap();
1615    }
1616
1617    #[test]
1618    fn build_team_status_rows_marks_user_and_stopped_session() {
1619        let members = vec![engineer("eng-1"), user_member("human")];
1620        let rows = build_team_status_rows(
1621            &members,
1622            false,
1623            &HashMap::new(),
1624            &HashMap::new(),
1625            &HashMap::new(),
1626            &HashMap::new(),
1627            &HashMap::new(),
1628        );
1629
1630        assert_eq!(rows[0].state, "stopped");
1631        assert_eq!(rows[0].runtime_label, None);
1632        assert_eq!(rows[1].state, "user");
1633        assert_eq!(rows[1].role_type, "User");
1634        assert_eq!(rows[1].agent, None);
1635    }
1636
1637    #[test]
1638    fn build_team_status_rows_promotes_idle_member_with_triage_backlog() {
1639        let members = vec![manager("manager")];
1640        let runtime_statuses = HashMap::from([(
1641            "manager".to_string(),
1642            RuntimeMemberStatus {
1643                state: "idle".to_string(),
1644                signal: None,
1645                label: Some("idle".to_string()),
1646            },
1647        )]);
1648        let triage_backlog_counts = HashMap::from([("manager".to_string(), 2usize)]);
1649
1650        let rows = build_team_status_rows(
1651            &members,
1652            true,
1653            &runtime_statuses,
1654            &HashMap::new(),
1655            &triage_backlog_counts,
1656            &HashMap::new(),
1657            &HashMap::new(),
1658        );
1659
1660        assert_eq!(rows[0].state, "triaging");
1661        assert_eq!(rows[0].signal.as_deref(), Some("needs triage (2)"));
1662    }
1663
1664    #[test]
1665    fn build_team_status_rows_promotes_idle_member_with_review_backlog() {
1666        let members = vec![manager("manager")];
1667        let runtime_statuses = HashMap::from([(
1668            "manager".to_string(),
1669            RuntimeMemberStatus {
1670                state: "idle".to_string(),
1671                signal: Some("nudge paused".to_string()),
1672                label: Some("idle".to_string()),
1673            },
1674        )]);
1675        let owned_task_buckets = HashMap::from([(
1676            "manager".to_string(),
1677            OwnedTaskBuckets {
1678                active: Vec::new(),
1679                review: vec![41, 42],
1680            },
1681        )]);
1682
1683        let rows = build_team_status_rows(
1684            &members,
1685            true,
1686            &runtime_statuses,
1687            &HashMap::new(),
1688            &HashMap::new(),
1689            &owned_task_buckets,
1690            &HashMap::new(),
1691        );
1692
1693        assert_eq!(rows[0].state, "reviewing");
1694        assert_eq!(
1695            rows[0].signal.as_deref(),
1696            Some("nudge paused, needs review (2)")
1697        );
1698    }
1699
1700    #[test]
1701    fn build_team_status_rows_defaults_to_starting_when_runtime_missing() {
1702        let members = vec![engineer("eng-1")];
1703        let rows = build_team_status_rows(
1704            &members,
1705            true,
1706            &HashMap::new(),
1707            &HashMap::new(),
1708            &HashMap::new(),
1709            &HashMap::new(),
1710            &HashMap::new(),
1711        );
1712
1713        assert_eq!(rows[0].state, "starting");
1714        assert_eq!(rows[0].runtime_label, None);
1715    }
1716
1717    #[test]
1718    fn build_team_status_health_counts_non_user_members_and_sorts_unhealthy() {
1719        let rows = vec![
1720            TeamStatusRow {
1721                name: "human".to_string(),
1722                role: "user".to_string(),
1723                role_type: "User".to_string(),
1724                agent: None,
1725                reports_to: None,
1726                state: "user".to_string(),
1727                pending_inbox: 9,
1728                triage_backlog: 9,
1729                active_owned_tasks: Vec::new(),
1730                review_owned_tasks: Vec::new(),
1731                signal: None,
1732                runtime_label: None,
1733                health: AgentHealthSummary::default(),
1734                health_summary: "-".to_string(),
1735                eta: "-".to_string(),
1736            },
1737            TeamStatusRow {
1738                name: "eng-2".to_string(),
1739                role: "engineer".to_string(),
1740                role_type: "Engineer".to_string(),
1741                agent: Some("codex".to_string()),
1742                reports_to: Some("manager".to_string()),
1743                state: "working".to_string(),
1744                pending_inbox: 1,
1745                triage_backlog: 2,
1746                active_owned_tasks: vec![2],
1747                review_owned_tasks: Vec::new(),
1748                signal: None,
1749                runtime_label: Some("working".to_string()),
1750                health: AgentHealthSummary {
1751                    restart_count: 1,
1752                    context_exhaustion_count: 0,
1753                    delivery_failure_count: 0,
1754                    task_elapsed_secs: None,
1755                    backend_health: crate::agent::BackendHealth::default(),
1756                },
1757                health_summary: "r1".to_string(),
1758                eta: "-".to_string(),
1759            },
1760            TeamStatusRow {
1761                name: "eng-1".to_string(),
1762                role: "engineer".to_string(),
1763                role_type: "Engineer".to_string(),
1764                agent: Some("codex".to_string()),
1765                reports_to: Some("manager".to_string()),
1766                state: "reviewing".to_string(),
1767                pending_inbox: 3,
1768                triage_backlog: 1,
1769                active_owned_tasks: Vec::new(),
1770                review_owned_tasks: vec![1],
1771                signal: None,
1772                runtime_label: Some("idle".to_string()),
1773                health: AgentHealthSummary {
1774                    restart_count: 0,
1775                    context_exhaustion_count: 1,
1776                    delivery_failure_count: 1,
1777                    task_elapsed_secs: None,
1778                    backend_health: crate::agent::BackendHealth::default(),
1779                },
1780                health_summary: "c1 d1".to_string(),
1781                eta: "-".to_string(),
1782            },
1783        ];
1784
1785        let health = build_team_status_health(&rows, true, false);
1786        assert_eq!(health.member_count, 2);
1787        assert_eq!(health.active_member_count, 2);
1788        assert_eq!(health.pending_inbox_count, 4);
1789        assert_eq!(health.triage_backlog_count, 3);
1790        assert_eq!(
1791            health.unhealthy_members,
1792            vec!["eng-1".to_string(), "eng-2".to_string()]
1793        );
1794    }
1795
1796    #[test]
1797    fn board_status_task_queues_returns_empty_when_board_is_missing() {
1798        let tmp = tempfile::tempdir().unwrap();
1799        let (active_tasks, review_queue) = board_status_task_queues(tmp.path()).unwrap();
1800
1801        assert!(active_tasks.is_empty());
1802        assert!(review_queue.is_empty());
1803    }
1804
1805    #[test]
1806    fn owned_task_buckets_routes_review_items_to_manager() {
1807        let tmp = tempfile::tempdir().unwrap();
1808        write_board_task(
1809            tmp.path(),
1810            "003-review.md",
1811            "id: 3\ntitle: Review one\nstatus: review\npriority: high\nclaimed_by: eng-2\n",
1812        );
1813        write_board_task(
1814            tmp.path(),
1815            "004-review.md",
1816            "id: 4\ntitle: Review two\nstatus: review\npriority: high\nclaimed_by: eng-1\n",
1817        );
1818        write_board_task(
1819            tmp.path(),
1820            "005-active.md",
1821            "id: 5\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-2\n",
1822        );
1823
1824        let buckets = owned_task_buckets(
1825            tmp.path(),
1826            &[manager("manager"), engineer("eng-1"), engineer("eng-2")],
1827        );
1828
1829        assert_eq!(
1830            buckets.get("manager"),
1831            Some(&OwnedTaskBuckets {
1832                active: Vec::new(),
1833                review: vec![3, 4],
1834            })
1835        );
1836        assert_eq!(
1837            buckets.get("eng-2"),
1838            Some(&OwnedTaskBuckets {
1839                active: vec![5],
1840                review: Vec::new(),
1841            })
1842        );
1843    }
1844
1845    #[test]
1846    fn compute_metrics_returns_default_when_board_is_missing() {
1847        let metrics =
1848            compute_metrics(&tempfile::tempdir().unwrap().path().join("board"), &[]).unwrap();
1849
1850        assert_eq!(metrics, WorkflowMetrics::default());
1851    }
1852
1853    #[test]
1854    fn compute_metrics_returns_default_when_board_is_empty() {
1855        let tmp = tempfile::tempdir().unwrap();
1856        fs::create_dir_all(board_dir(tmp.path()).join("tasks")).unwrap();
1857
1858        let metrics = compute_metrics(&board_dir(tmp.path()), &[]).unwrap();
1859        assert_eq!(metrics, WorkflowMetrics::default());
1860    }
1861
1862    #[test]
1863    fn compute_metrics_counts_workflow_states_and_idle_runnable() {
1864        let tmp = tempfile::tempdir().unwrap();
1865        write_board_task(
1866            tmp.path(),
1867            "001-runnable.md",
1868            "id: 1\ntitle: Runnable\nstatus: todo\npriority: high\n",
1869        );
1870        write_board_task(
1871            tmp.path(),
1872            "002-blocked.md",
1873            "id: 2\ntitle: Blocked\nstatus: blocked\npriority: medium\n",
1874        );
1875        write_board_task(
1876            tmp.path(),
1877            "003-review.md",
1878            "id: 3\ntitle: Review\nstatus: review\npriority: medium\nclaimed_by: eng-1\n",
1879        );
1880        write_board_task(
1881            tmp.path(),
1882            "004-in-progress.md",
1883            "id: 4\ntitle: In progress\nstatus: in-progress\npriority: medium\nclaimed_by: eng-1\n",
1884        );
1885        write_board_task(
1886            tmp.path(),
1887            "005-claimed.md",
1888            "id: 5\ntitle: Claimed todo\nstatus: todo\npriority: low\nclaimed_by: eng-3\n",
1889        );
1890        write_board_task(
1891            tmp.path(),
1892            "006-waiting.md",
1893            "id: 6\ntitle: Waiting\nstatus: todo\npriority: low\ndepends_on:\n  - 7\n",
1894        );
1895        write_board_task(
1896            tmp.path(),
1897            "007-parent.md",
1898            "id: 7\ntitle: Parent\nstatus: in-progress\npriority: low\nclaimed_by: eng-3\n",
1899        );
1900
1901        let inbox_root = crate::team::inbox::inboxes_root(tmp.path());
1902        crate::team::inbox::deliver_to_inbox(
1903            &inbox_root,
1904            &InboxMessage::new_send("manager", "eng-2", "please pick this up"),
1905        )
1906        .unwrap();
1907
1908        let metrics = compute_metrics(
1909            &board_dir(tmp.path()),
1910            &[
1911                engineer("eng-1"),
1912                engineer("eng-2"),
1913                engineer("eng-3"),
1914                engineer("eng-4"),
1915            ],
1916        )
1917        .unwrap();
1918
1919        assert_eq!(metrics.runnable_count, 1);
1920        assert_eq!(metrics.blocked_count, 1);
1921        assert_eq!(metrics.in_review_count, 1);
1922        assert_eq!(metrics.in_progress_count, 2);
1923        assert_eq!(metrics.idle_with_runnable, vec!["eng-4".to_string()]);
1924        assert!(metrics.oldest_review_age_secs.is_some());
1925        assert!(metrics.oldest_assignment_age_secs.is_some());
1926    }
1927
1928    #[test]
1929    fn workflow_metrics_section_returns_none_when_mode_disabled() {
1930        let tmp = tempfile::tempdir().unwrap();
1931        let team_config_dir = tmp.path().join(".batty").join("team_config");
1932        fs::create_dir_all(&team_config_dir).unwrap();
1933        fs::write(team_config_dir.join("team.yaml"), "team: test\n").unwrap();
1934
1935        assert!(workflow_metrics_section(tmp.path(), &[engineer("eng-1")]).is_none());
1936    }
1937
1938    #[test]
1939    fn workflow_metrics_section_returns_formatted_metrics_when_enabled() {
1940        let tmp = tempfile::tempdir().unwrap();
1941        let team_config_dir = tmp.path().join(".batty").join("team_config");
1942        fs::create_dir_all(&team_config_dir).unwrap();
1943        fs::write(
1944            team_config_dir.join("team.yaml"),
1945            "team: test\nworkflow_mode: hybrid\n",
1946        )
1947        .unwrap();
1948        write_board_task(
1949            tmp.path(),
1950            "001-runnable.md",
1951            "id: 1\ntitle: Runnable\nstatus: todo\npriority: high\n",
1952        );
1953
1954        let (formatted, metrics) =
1955            workflow_metrics_section(tmp.path(), &[engineer("eng-1")]).unwrap();
1956
1957        assert!(formatted.contains("Workflow Metrics"));
1958        assert!(formatted.contains("Runnable: 1"));
1959        assert_eq!(metrics.runnable_count, 1);
1960    }
1961
1962    #[test]
1963    fn build_team_status_json_report_serializes_machine_readable_json() {
1964        let report = build_team_status_json_report(TeamStatusJsonReportInput {
1965            team: "test".to_string(),
1966            session: "batty-test".to_string(),
1967            session_running: true,
1968            paused: false,
1969            workflow_metrics: Some(WorkflowMetrics {
1970                runnable_count: 1,
1971                ..WorkflowMetrics::default()
1972            }),
1973            active_tasks: Vec::new(),
1974            review_queue: Vec::new(),
1975            members: vec![TeamStatusRow {
1976                name: "eng-1".to_string(),
1977                role: "engineer".to_string(),
1978                role_type: "Engineer".to_string(),
1979                agent: Some("codex".to_string()),
1980                reports_to: Some("manager".to_string()),
1981                state: "idle".to_string(),
1982                pending_inbox: 0,
1983                triage_backlog: 0,
1984                active_owned_tasks: Vec::new(),
1985                review_owned_tasks: Vec::new(),
1986                signal: None,
1987                runtime_label: Some("idle".to_string()),
1988                health: AgentHealthSummary::default(),
1989                health_summary: "-".to_string(),
1990                eta: "-".to_string(),
1991            }],
1992        });
1993
1994        let json = serde_json::to_value(&report).unwrap();
1995        assert_eq!(json["team"], "test");
1996        assert_eq!(json["running"], true);
1997        assert_eq!(json["health"]["member_count"], 1);
1998        assert_eq!(json["workflow_metrics"]["runnable_count"], 1);
1999        assert!(json["members"].is_array());
2000    }
2001
2002    #[test]
2003    fn parse_assigned_task_id_accepts_plain_numeric_values() {
2004        assert_eq!(parse_assigned_task_id("42"), Some(42));
2005    }
2006
2007    #[test]
2008    fn parse_assigned_task_id_extracts_task_hash_values() {
2009        assert_eq!(
2010            parse_assigned_task_id("Task #119: expand coverage"),
2011            Some(119)
2012        );
2013        assert_eq!(parse_assigned_task_id("working on #508 next"), Some(508));
2014    }
2015
2016    #[test]
2017    fn parse_assigned_task_id_rejects_values_without_leading_digits() {
2018        assert_eq!(parse_assigned_task_id("Task #abc"), None);
2019        assert_eq!(parse_assigned_task_id("no task here"), None);
2020    }
2021
2022    #[test]
2023    fn format_health_duration_formats_seconds() {
2024        assert_eq!(format_health_duration(59), "59s");
2025    }
2026
2027    #[test]
2028    fn format_health_duration_formats_minutes() {
2029        assert_eq!(format_health_duration(60), "1m");
2030    }
2031
2032    #[test]
2033    fn format_health_duration_formats_hours() {
2034        assert_eq!(format_health_duration(3_600), "1h");
2035    }
2036
2037    #[test]
2038    fn format_health_duration_formats_days() {
2039        assert_eq!(format_health_duration(86_400), "1d");
2040    }
2041
2042    #[test]
2043    fn merge_status_signal_combines_existing_triage_and_review_signals() {
2044        assert_eq!(
2045            merge_status_signal(Some("nudged".to_string()), 2, 1),
2046            Some("nudged, needs triage (2), needs review (1)".to_string())
2047        );
2048    }
2049
2050    #[test]
2051    fn merge_status_signal_returns_none_when_no_signals_exist() {
2052        assert_eq!(merge_status_signal(None, 0, 0), None);
2053    }
2054
2055    #[test]
2056    fn agent_health_by_member_defaults_without_events_or_state() {
2057        let tmp = tempfile::tempdir().unwrap();
2058        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1"), engineer("eng-2")]);
2059
2060        assert_eq!(health.get("eng-1"), Some(&AgentHealthSummary::default()));
2061        assert_eq!(health.get("eng-2"), Some(&AgentHealthSummary::default()));
2062    }
2063
2064    #[test]
2065    fn board_status_task_queues_split_active_and_review_tasks() {
2066        let tmp = tempfile::tempdir().unwrap();
2067        let tasks_dir = tmp
2068            .path()
2069            .join(".batty")
2070            .join("team_config")
2071            .join("board")
2072            .join("tasks");
2073        fs::create_dir_all(&tasks_dir).unwrap();
2074        fs::write(
2075            tasks_dir.join("041-active.md"),
2076            "---\nid: 41\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nbranch: eng-1/task-41\nclass: standard\n---\n",
2077        )
2078        .unwrap();
2079        fs::write(
2080            tasks_dir.join("042-review.md"),
2081            "---\nid: 42\ntitle: Review task\nstatus: review\npriority: medium\nclaimed_by: eng-2\nreview_owner: manager\nnext_action: review now\nclass: standard\n---\n",
2082        )
2083        .unwrap();
2084        fs::write(
2085            tasks_dir.join("043-done.md"),
2086            "---\nid: 43\ntitle: Done task\nstatus: done\npriority: low\nclass: standard\n---\n",
2087        )
2088        .unwrap();
2089
2090        let (active_tasks, review_queue) = board_status_task_queues(tmp.path()).unwrap();
2091
2092        assert_eq!(active_tasks.len(), 1);
2093        assert_eq!(active_tasks[0].id, 41);
2094        assert_eq!(active_tasks[0].branch.as_deref(), Some("eng-1/task-41"));
2095        assert_eq!(review_queue.len(), 1);
2096        assert_eq!(review_queue[0].id, 42);
2097        assert_eq!(review_queue[0].review_owner.as_deref(), Some("manager"));
2098        assert_eq!(review_queue[0].next_action.as_deref(), Some("review now"));
2099    }
2100
2101    #[test]
2102    fn board_status_task_queues_infers_worktree_metadata_when_frontmatter_missing() {
2103        let tmp = tempfile::tempdir().unwrap();
2104        let repo = crate::team::test_support::init_git_repo(&tmp, "status-infer");
2105        let tasks_dir = repo
2106            .join(".batty")
2107            .join("team_config")
2108            .join("board")
2109            .join("tasks");
2110        fs::create_dir_all(&tasks_dir).unwrap();
2111        fs::write(
2112            tasks_dir.join("041-active.md"),
2113            "---\nid: 41\ntitle: Active task\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
2114        )
2115        .unwrap();
2116
2117        let team_config_dir = repo.join(".batty").join("team_config");
2118        let worktree_dir = repo.join(".batty").join("worktrees").join("eng-1");
2119        crate::team::task_loop::setup_engineer_worktree(
2120            &repo,
2121            &worktree_dir,
2122            "eng-1",
2123            &team_config_dir,
2124        )
2125        .unwrap();
2126        fs::write(worktree_dir.join("note.txt"), "done\n").unwrap();
2127        crate::team::test_support::git_ok(&worktree_dir, &["add", "note.txt"]);
2128        crate::team::test_support::git_ok(&worktree_dir, &["commit", "-m", "note"]);
2129
2130        let (active_tasks, review_queue) = board_status_task_queues(&repo).unwrap();
2131
2132        assert!(review_queue.is_empty());
2133        assert_eq!(active_tasks.len(), 1);
2134        assert_eq!(
2135            active_tasks[0].worktree_path.as_deref(),
2136            Some(".batty/worktrees/eng-1")
2137        );
2138        assert!(
2139            active_tasks[0]
2140                .branch
2141                .as_deref()
2142                .is_some_and(|branch| branch.contains("eng-1"))
2143        );
2144        assert!(active_tasks[0].commit.as_deref().is_some());
2145    }
2146
2147    #[test]
2148    fn build_team_status_json_report_includes_health_and_queues() {
2149        let report = build_team_status_json_report(TeamStatusJsonReportInput {
2150            team: "test".to_string(),
2151            session: "batty-test".to_string(),
2152            session_running: true,
2153            paused: true,
2154            workflow_metrics: Some(WorkflowMetrics {
2155                runnable_count: 2,
2156                blocked_count: 1,
2157                in_review_count: 1,
2158                in_progress_count: 3,
2159                idle_with_runnable: vec!["eng-2".to_string()],
2160                oldest_review_age_secs: Some(60),
2161                oldest_assignment_age_secs: Some(120),
2162                ..Default::default()
2163            }),
2164            active_tasks: vec![StatusTaskEntry {
2165                id: 41,
2166                title: "Active task".to_string(),
2167                status: "in-progress".to_string(),
2168                priority: "high".to_string(),
2169                claimed_by: Some("eng-1".to_string()),
2170                review_owner: None,
2171                blocked_on: None,
2172                branch: Some("eng-1/task-41".to_string()),
2173                worktree_path: None,
2174                commit: None,
2175                next_action: None,
2176            }],
2177            review_queue: vec![StatusTaskEntry {
2178                id: 42,
2179                title: "Review task".to_string(),
2180                status: "review".to_string(),
2181                priority: "medium".to_string(),
2182                claimed_by: Some("eng-2".to_string()),
2183                review_owner: Some("manager".to_string()),
2184                blocked_on: None,
2185                branch: None,
2186                worktree_path: None,
2187                commit: None,
2188                next_action: Some("review now".to_string()),
2189            }],
2190            members: vec![
2191                TeamStatusRow {
2192                    name: "eng-1".to_string(),
2193                    role: "engineer".to_string(),
2194                    role_type: "Engineer".to_string(),
2195                    agent: Some("codex".to_string()),
2196                    reports_to: Some("manager".to_string()),
2197                    state: "working".to_string(),
2198                    pending_inbox: 2,
2199                    triage_backlog: 0,
2200                    active_owned_tasks: vec![41],
2201                    review_owned_tasks: vec![],
2202                    signal: None,
2203                    runtime_label: Some("working".to_string()),
2204                    health: AgentHealthSummary {
2205                        restart_count: 1,
2206                        context_exhaustion_count: 0,
2207                        delivery_failure_count: 0,
2208                        task_elapsed_secs: Some(30),
2209                        backend_health: crate::agent::BackendHealth::default(),
2210                    },
2211                    health_summary: "r1 t30s".to_string(),
2212                    eta: "-".to_string(),
2213                },
2214                TeamStatusRow {
2215                    name: "eng-2".to_string(),
2216                    role: "engineer".to_string(),
2217                    role_type: "Engineer".to_string(),
2218                    agent: Some("codex".to_string()),
2219                    reports_to: Some("manager".to_string()),
2220                    state: "idle".to_string(),
2221                    pending_inbox: 1,
2222                    triage_backlog: 2,
2223                    active_owned_tasks: vec![],
2224                    review_owned_tasks: vec![42],
2225                    signal: Some("needs review (1)".to_string()),
2226                    runtime_label: Some("idle".to_string()),
2227                    health: AgentHealthSummary::default(),
2228                    health_summary: "-".to_string(),
2229                    eta: "-".to_string(),
2230                },
2231            ],
2232        });
2233
2234        assert_eq!(report.team, "test");
2235        assert_eq!(report.active_tasks.len(), 1);
2236        assert_eq!(report.review_queue.len(), 1);
2237        assert!(report.paused);
2238        assert_eq!(report.health.member_count, 2);
2239        assert_eq!(report.health.active_member_count, 1);
2240        assert_eq!(report.health.pending_inbox_count, 3);
2241        assert_eq!(report.health.triage_backlog_count, 2);
2242        assert_eq!(report.health.unhealthy_members, vec!["eng-1".to_string()]);
2243        assert_eq!(report.workflow_metrics.unwrap().runnable_count, 2);
2244    }
2245
2246    #[test]
2247    fn format_standup_status_marks_paused_while_member_is_working() {
2248        assert_eq!(
2249            format_standup_status(Some(Instant::now()), Duration::from_secs(600), true),
2250            " #[fg=244]standup paused#[default]"
2251        );
2252    }
2253
2254    #[test]
2255    fn format_nudge_status_marks_paused_while_member_is_working() {
2256        let schedule = NudgeSchedule {
2257            text: "check in".to_string(),
2258            interval: Duration::from_secs(600),
2259            idle_since: None,
2260            fired_this_idle: false,
2261            paused: true,
2262        };
2263
2264        assert_eq!(
2265            format_nudge_status(Some(&schedule)),
2266            " #[fg=244]nudge paused#[default]"
2267        );
2268    }
2269
2270    #[test]
2271    fn compose_pane_status_label_shows_pending_inbox_count() {
2272        let label = compose_pane_status_label(PaneStatusLabelArgs {
2273            state: MemberState::Idle,
2274            pending_inbox: 3,
2275            triage_backlog: 2,
2276            active_task_ids: &[191],
2277            review_task_ids: &[193, 194],
2278            globally_paused: false,
2279            nudge_status: " #[fg=magenta]nudge 0:30#[default]",
2280            standup_status: "",
2281        });
2282        assert!(label.contains("idle"));
2283        assert!(label.contains("inbox 3"));
2284        assert!(label.contains("triage 2"));
2285        assert!(label.contains("task 191"));
2286        assert!(label.contains("review 2"));
2287        assert!(label.contains("nudge 0:30"));
2288    }
2289
2290    #[test]
2291    fn compose_pane_status_label_shows_zero_inbox_and_pause_state() {
2292        let label = compose_pane_status_label(PaneStatusLabelArgs {
2293            state: MemberState::Working,
2294            pending_inbox: 0,
2295            triage_backlog: 0,
2296            active_task_ids: &[],
2297            review_task_ids: &[],
2298            globally_paused: true,
2299            nudge_status: "",
2300            standup_status: "",
2301        });
2302        assert!(label.contains("working"));
2303        assert!(label.contains("inbox 0"));
2304        assert!(label.contains("PAUSED"));
2305    }
2306
2307    #[test]
2308    fn format_agent_health_summary_compacts_metrics() {
2309        let summary = format_agent_health_summary(&AgentHealthSummary {
2310            restart_count: 2,
2311            context_exhaustion_count: 1,
2312            delivery_failure_count: 3,
2313            task_elapsed_secs: Some(750),
2314            backend_health: crate::agent::BackendHealth::default(),
2315        });
2316
2317        assert_eq!(summary, "r2 c1 d3 t12m");
2318        assert_eq!(
2319            format_agent_health_summary(&AgentHealthSummary::default()),
2320            "-"
2321        );
2322    }
2323
2324    #[test]
2325    fn agent_health_by_member_aggregates_events_and_active_task_elapsed() {
2326        let tmp = tempfile::tempdir().unwrap();
2327        let events_path = team_events_path(tmp.path());
2328        let mut sink = EventSink::new(&events_path).unwrap();
2329
2330        let mut assigned = TeamEvent::task_assigned("eng-1", "Task #42: fix it");
2331        assigned.ts = now_unix().saturating_sub(600);
2332        sink.emit(assigned).unwrap();
2333
2334        let mut restarted = TeamEvent::agent_restarted("eng-1", "42", "context_exhausted", 2);
2335        restarted.ts = now_unix().saturating_sub(590);
2336        sink.emit(restarted).unwrap();
2337
2338        let mut exhausted = TeamEvent::context_exhausted("eng-1", Some(42), Some(4_096));
2339        exhausted.ts = now_unix().saturating_sub(580);
2340        sink.emit(exhausted).unwrap();
2341
2342        let mut delivery_failed =
2343            TeamEvent::delivery_failed("eng-1", "manager", "message delivery failed after retries");
2344        delivery_failed.ts = now_unix().saturating_sub(570);
2345        sink.emit(delivery_failed).unwrap();
2346
2347        let daemon_state = serde_json::json!({
2348            "active_tasks": {"eng-1": 42},
2349            "retry_counts": {"eng-1": 1}
2350        });
2351        fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2352        fs::write(
2353            daemon_state_path(tmp.path()),
2354            serde_json::to_vec_pretty(&daemon_state).unwrap(),
2355        )
2356        .unwrap();
2357
2358        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1"), engineer("eng-2")]);
2359        let eng_1 = health.get("eng-1").unwrap();
2360        assert_eq!(eng_1.restart_count, 2);
2361        assert_eq!(eng_1.context_exhaustion_count, 1);
2362        assert_eq!(eng_1.delivery_failure_count, 1);
2363        assert!(eng_1.task_elapsed_secs.unwrap() >= 600);
2364        assert_eq!(health.get("eng-2").unwrap(), &AgentHealthSummary::default());
2365    }
2366
2367    #[test]
2368    fn format_agent_health_summary_includes_backend_health() {
2369        let summary = format_agent_health_summary(&AgentHealthSummary {
2370            backend_health: crate::agent::BackendHealth::Unreachable,
2371            ..AgentHealthSummary::default()
2372        });
2373        assert_eq!(summary, "B:unreachable");
2374
2375        let summary = format_agent_health_summary(&AgentHealthSummary {
2376            backend_health: crate::agent::BackendHealth::Degraded,
2377            restart_count: 1,
2378            ..AgentHealthSummary::default()
2379        });
2380        assert_eq!(summary, "B:degraded r1");
2381    }
2382
2383    #[test]
2384    fn agent_health_by_member_reads_health_changed_events() {
2385        let tmp = tempfile::tempdir().unwrap();
2386        let events_path = team_events_path(tmp.path());
2387        let mut sink = EventSink::new(&events_path).unwrap();
2388        sink.emit(TeamEvent::health_changed("eng-1", "healthy→unreachable"))
2389            .unwrap();
2390
2391        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1")]);
2392        assert_eq!(
2393            health.get("eng-1").unwrap().backend_health,
2394            crate::agent::BackendHealth::Unreachable,
2395        );
2396    }
2397
2398    #[test]
2399    fn agent_health_by_member_uses_latest_health_event() {
2400        let tmp = tempfile::tempdir().unwrap();
2401        let events_path = team_events_path(tmp.path());
2402        let mut sink = EventSink::new(&events_path).unwrap();
2403        sink.emit(TeamEvent::health_changed("eng-1", "healthy→unreachable"))
2404            .unwrap();
2405        sink.emit(TeamEvent::health_changed("eng-1", "unreachable→healthy"))
2406            .unwrap();
2407
2408        let health = agent_health_by_member(tmp.path(), &[engineer("eng-1")]);
2409        assert_eq!(
2410            health.get("eng-1").unwrap().backend_health,
2411            crate::agent::BackendHealth::Healthy,
2412        );
2413    }
2414
2415    #[test]
2416    fn build_team_status_health_counts_unhealthy_backend() {
2417        let rows = vec![TeamStatusRow {
2418            name: "eng-bad".to_string(),
2419            role: "engineer".to_string(),
2420            role_type: "Engineer".to_string(),
2421            agent: Some("claude".to_string()),
2422            reports_to: Some("manager".to_string()),
2423            state: "working".to_string(),
2424            pending_inbox: 0,
2425            triage_backlog: 0,
2426            active_owned_tasks: Vec::new(),
2427            review_owned_tasks: Vec::new(),
2428            signal: None,
2429            runtime_label: Some("working".to_string()),
2430            health: AgentHealthSummary {
2431                backend_health: crate::agent::BackendHealth::Unreachable,
2432                ..AgentHealthSummary::default()
2433            },
2434            health_summary: "B:unreachable".to_string(),
2435            eta: "-".to_string(),
2436        }];
2437        let health = build_team_status_health(&rows, true, false);
2438        assert_eq!(health.unhealthy_members, vec!["eng-bad".to_string()]);
2439    }
2440
2441    // --- SQLite telemetry migration tests ---
2442
2443    #[test]
2444    fn compute_metrics_with_telemetry_db_returns_review_metrics() {
2445        let tmp = tempfile::tempdir().unwrap();
2446
2447        // Set up board with a task.
2448        write_board_task(
2449            tmp.path(),
2450            "001-task.md",
2451            "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2452        );
2453
2454        // Set up in-memory telemetry DB with events.
2455        let conn = crate::team::telemetry_db::open_in_memory().unwrap();
2456        let events = vec![
2457            crate::team::events::TeamEvent::task_completed("eng-1", Some("1")),
2458            crate::team::events::TeamEvent::task_auto_merged("eng-1", "1", 0.9, 3, 50),
2459            crate::team::events::TeamEvent::task_completed("eng-1", Some("2")),
2460            crate::team::events::TeamEvent::task_manual_merged("2"),
2461            crate::team::events::TeamEvent::task_reworked("eng-1", "3"),
2462        ];
2463        for event in &events {
2464            crate::team::telemetry_db::insert_event(&conn, event).unwrap();
2465        }
2466
2467        let metrics =
2468            compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], Some(&conn), None).unwrap();
2469
2470        assert_eq!(metrics.auto_merge_count, 1);
2471        assert_eq!(metrics.manual_merge_count, 1);
2472        assert_eq!(metrics.rework_count, 1);
2473        assert!(metrics.auto_merge_rate.is_some());
2474        // Board metric still works.
2475        assert_eq!(metrics.runnable_count, 1);
2476    }
2477
2478    #[test]
2479    fn compute_metrics_without_db_falls_back_to_jsonl() {
2480        let tmp = tempfile::tempdir().unwrap();
2481
2482        write_board_task(
2483            tmp.path(),
2484            "001-task.md",
2485            "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2486        );
2487
2488        // Write events to JSONL (no DB).
2489        let events_path = team_events_path(tmp.path());
2490        let mut sink = EventSink::new(&events_path).unwrap();
2491        sink.emit(TeamEvent::task_auto_merged("eng-1", "1", 0.9, 3, 50))
2492            .unwrap();
2493
2494        let metrics =
2495            compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], None, Some(&events_path))
2496                .unwrap();
2497
2498        assert_eq!(metrics.auto_merge_count, 1);
2499        assert_eq!(metrics.runnable_count, 1);
2500    }
2501
2502    #[test]
2503    fn compute_metrics_without_db_or_events_returns_zero_review_metrics() {
2504        let tmp = tempfile::tempdir().unwrap();
2505
2506        write_board_task(
2507            tmp.path(),
2508            "001-task.md",
2509            "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2510        );
2511
2512        // No DB, no events path.
2513        let metrics =
2514            compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], None, None).unwrap();
2515
2516        assert_eq!(metrics.auto_merge_count, 0);
2517        assert_eq!(metrics.manual_merge_count, 0);
2518        assert_eq!(metrics.rework_count, 0);
2519        assert_eq!(metrics.auto_merge_rate, None);
2520        // Board metric still works.
2521        assert_eq!(metrics.runnable_count, 1);
2522    }
2523
2524    #[test]
2525    fn format_metrics_unchanged_with_db_source() {
2526        let conn = crate::team::telemetry_db::open_in_memory().unwrap();
2527        let events = vec![
2528            crate::team::events::TeamEvent::task_completed("eng-1", Some("1")),
2529            crate::team::events::TeamEvent::task_auto_merged("eng-1", "1", 0.9, 3, 50),
2530        ];
2531        for event in &events {
2532            crate::team::telemetry_db::insert_event(&conn, event).unwrap();
2533        }
2534
2535        let tmp = tempfile::tempdir().unwrap();
2536        write_board_task(
2537            tmp.path(),
2538            "001-task.md",
2539            "id: 1\ntitle: Test\nstatus: review\npriority: high\nclaimed_by: eng-1\n",
2540        );
2541
2542        let metrics =
2543            compute_metrics_with_telemetry(&board_dir(tmp.path()), &[], Some(&conn), None).unwrap();
2544
2545        let formatted = format_metrics(&metrics);
2546        assert!(formatted.contains("Workflow Metrics"));
2547        assert!(formatted.contains("Auto-merge Rate: 100%"));
2548        assert!(formatted.contains("In Review: 1"));
2549    }
2550}