1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::process::Command;
4use std::time::{Duration, Instant, SystemTime};
5
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8use tracing::warn;
9
10use crate::task;
11
12use super::config::{self, RoleType};
13use super::daemon::NudgeSchedule;
14use super::events;
15use super::hierarchy::MemberInstance;
16use super::inbox;
17use super::standup::MemberState;
18use super::{
19 TRIAGE_RESULT_FRESHNESS_SECONDS, daemon_state_path, now_unix, pause_marker_path,
20 team_config_dir, team_config_path, team_events_path,
21};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub(crate) struct RuntimeMemberStatus {
25 pub(crate) state: String,
26 pub(crate) signal: Option<String>,
27 pub(crate) label: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub(crate) struct TeamStatusRow {
32 pub(crate) name: String,
33 pub(crate) role: String,
34 pub(crate) role_type: String,
35 pub(crate) agent: Option<String>,
36 pub(crate) reports_to: Option<String>,
37 pub(crate) state: String,
38 pub(crate) pending_inbox: usize,
39 pub(crate) triage_backlog: usize,
40 pub(crate) active_owned_tasks: Vec<u32>,
41 pub(crate) review_owned_tasks: Vec<u32>,
42 pub(crate) signal: Option<String>,
43 pub(crate) runtime_label: Option<String>,
44 pub(crate) health: AgentHealthSummary,
45 pub(crate) health_summary: String,
46 pub(crate) eta: String,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(crate) struct TriageBacklogState {
51 pub(crate) count: usize,
52 pub(crate) newest_result_ts: u64,
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub(crate) struct OwnedTaskBuckets {
57 pub(crate) active: Vec<u32>,
58 pub(crate) review: Vec<u32>,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
62pub(crate) struct AgentHealthSummary {
63 pub(crate) restart_count: u32,
64 pub(crate) context_exhaustion_count: u32,
65 pub(crate) delivery_failure_count: u32,
66 pub(crate) task_elapsed_secs: Option<u64>,
67 pub(crate) backend_health: crate::agent::BackendHealth,
68}
69
70#[derive(Debug, Clone, Default, Deserialize)]
71struct PersistedDaemonHealthState {
72 #[serde(default)]
73 active_tasks: HashMap<String, u32>,
74 #[serde(default)]
75 retry_counts: HashMap<String, u32>,
76}
77
78#[derive(Debug, Clone, PartialEq, Default, Serialize)]
79pub struct WorkflowMetrics {
80 pub runnable_count: u32,
81 pub blocked_count: u32,
82 pub in_review_count: u32,
83 pub in_progress_count: u32,
84 pub idle_with_runnable: Vec<String>,
85 pub oldest_review_age_secs: Option<u64>,
86 pub oldest_assignment_age_secs: Option<u64>,
87 pub auto_merge_count: u32,
89 pub manual_merge_count: u32,
90 pub auto_merge_rate: Option<f64>,
91 pub rework_count: u32,
92 pub rework_rate: Option<f64>,
93 pub review_nudge_count: u32,
94 pub review_escalation_count: u32,
95 pub avg_review_latency_secs: Option<f64>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub(crate) struct StatusTaskEntry {
100 pub(crate) id: u32,
101 pub(crate) title: String,
102 pub(crate) status: String,
103 pub(crate) priority: String,
104 pub(crate) claimed_by: Option<String>,
105 pub(crate) review_owner: Option<String>,
106 pub(crate) blocked_on: Option<String>,
107 pub(crate) branch: Option<String>,
108 pub(crate) worktree_path: Option<String>,
109 pub(crate) commit: Option<String>,
110 pub(crate) next_action: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub(crate) struct TeamStatusHealth {
115 pub(crate) session_running: bool,
116 pub(crate) paused: bool,
117 pub(crate) member_count: usize,
118 pub(crate) active_member_count: usize,
119 pub(crate) pending_inbox_count: usize,
120 pub(crate) triage_backlog_count: usize,
121 pub(crate) unhealthy_members: Vec<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize)]
125pub(crate) struct TeamStatusJsonReport {
126 pub(crate) team: String,
127 pub(crate) session: String,
128 pub(crate) running: bool,
129 pub(crate) paused: bool,
130 pub(crate) health: TeamStatusHealth,
131 pub(crate) workflow_metrics: Option<WorkflowMetrics>,
132 pub(crate) active_tasks: Vec<StatusTaskEntry>,
133 pub(crate) review_queue: Vec<StatusTaskEntry>,
134 pub(crate) members: Vec<TeamStatusRow>,
135}
136
137pub(crate) fn list_runtime_member_statuses(
138 session: &str,
139) -> Result<HashMap<String, RuntimeMemberStatus>> {
140 let output = Command::new("tmux")
141 .args([
142 "list-panes",
143 "-t",
144 session,
145 "-F",
146 "#{pane_id}\t#{@batty_role}\t#{@batty_status}\t#{pane_dead}",
147 ])
148 .output()
149 .with_context(|| format!("failed to list panes for session '{session}'"))?;
150
151 if !output.status.success() {
152 let stderr = String::from_utf8_lossy(&output.stderr);
153 bail!("tmux list-panes runtime status failed: {stderr}");
154 }
155
156 let mut statuses = HashMap::new();
157 for line in String::from_utf8_lossy(&output.stdout).lines() {
158 let mut parts = line.splitn(4, '\t');
159 let Some(_pane_id) = parts.next() else {
160 continue;
161 };
162 let Some(member_name) = parts.next() else {
163 continue;
164 };
165 let Some(raw_status) = parts.next() else {
166 continue;
167 };
168 let Some(pane_dead) = parts.next() else {
169 continue;
170 };
171 if member_name.trim().is_empty() {
172 continue;
173 }
174
175 statuses.insert(
176 member_name.to_string(),
177 summarize_runtime_member_status(raw_status, pane_dead == "1"),
178 );
179 }
180
181 Ok(statuses)
182}
183
184pub(crate) fn summarize_runtime_member_status(
185 raw_status: &str,
186 pane_dead: bool,
187) -> RuntimeMemberStatus {
188 if pane_dead {
189 return RuntimeMemberStatus {
190 state: "crashed".to_string(),
191 signal: None,
192 label: Some("crashed".to_string()),
193 };
194 }
195
196 let label = strip_tmux_style(raw_status);
197 let normalized = label.to_ascii_lowercase();
198 let has_paused_nudge = normalized.contains("nudge paused");
199 let has_nudge_sent = normalized.contains("nudge sent");
200 let has_waiting_nudge = normalized.contains("nudge") && !has_nudge_sent && !has_paused_nudge;
201 let has_paused_standup = normalized.contains("standup paused");
202 let has_standup = normalized.contains("standup") && !has_paused_standup;
203
204 let state = if normalized.contains("crashed") {
205 "crashed"
206 } else if normalized.contains("working") {
207 "working"
208 } else if normalized.contains("done") || normalized.contains("completed") {
209 "done"
210 } else if normalized.contains("idle") {
211 "idle"
212 } else if label.is_empty() {
213 "starting"
214 } else {
215 "unknown"
216 };
217
218 let mut signals = Vec::new();
219 if has_paused_nudge {
220 signals.push("nudge paused");
221 } else if has_nudge_sent {
222 signals.push("nudged");
223 } else if has_waiting_nudge {
224 signals.push("waiting for nudge");
225 }
226 if has_paused_standup {
227 signals.push("standup paused");
228 } else if has_standup {
229 signals.push("standup");
230 }
231 let signal = (!signals.is_empty()).then(|| signals.join(", "));
232
233 RuntimeMemberStatus {
234 state: state.to_string(),
235 signal,
236 label: (!label.is_empty()).then_some(label),
237 }
238}
239
240pub(crate) fn strip_tmux_style(input: &str) -> String {
241 let mut output = String::new();
242 let mut chars = input.chars().peekable();
243
244 while let Some(ch) = chars.next() {
245 if ch == '#' && chars.peek() == Some(&'[') {
246 chars.next();
247 for next in chars.by_ref() {
248 if next == ']' {
249 break;
250 }
251 }
252 continue;
253 }
254 output.push(ch);
255 }
256
257 output.split_whitespace().collect::<Vec<_>>().join(" ")
258}
259
260pub(crate) fn build_team_status_rows(
261 members: &[MemberInstance],
262 session_running: bool,
263 runtime_statuses: &HashMap<String, RuntimeMemberStatus>,
264 pending_inbox_counts: &HashMap<String, usize>,
265 triage_backlog_counts: &HashMap<String, usize>,
266 owned_task_buckets: &HashMap<String, OwnedTaskBuckets>,
267 agent_health: &HashMap<String, AgentHealthSummary>,
268) -> Vec<TeamStatusRow> {
269 members
270 .iter()
271 .map(|member| {
272 let runtime = runtime_statuses.get(&member.name);
273 let pending_inbox = pending_inbox_counts.get(&member.name).copied().unwrap_or(0);
274 let triage_backlog = triage_backlog_counts
275 .get(&member.name)
276 .copied()
277 .unwrap_or(0);
278 let owned_tasks = owned_task_buckets
279 .get(&member.name)
280 .cloned()
281 .unwrap_or_default();
282 let (state, signal, runtime_label) = if member.role_type == config::RoleType::User {
283 ("user".to_string(), None, None)
284 } else if !session_running {
285 ("stopped".to_string(), None, None)
286 } else if let Some(runtime) = runtime {
287 (
288 runtime.state.clone(),
289 runtime.signal.clone(),
290 runtime.label.clone(),
291 )
292 } else {
293 ("starting".to_string(), None, None)
294 };
295
296 let review_backlog = owned_tasks.review.len();
297 let state = if session_running && state == "idle" && review_backlog > 0 {
298 "reviewing".to_string()
299 } else if session_running && state == "idle" && triage_backlog > 0 {
300 "triaging".to_string()
301 } else {
302 state
303 };
304
305 let signal = merge_status_signal(signal, triage_backlog, review_backlog);
306 let health = agent_health.get(&member.name).cloned().unwrap_or_default();
307 let health_summary = format_agent_health_summary(&health);
308
309 TeamStatusRow {
310 name: member.name.clone(),
311 role: member.role_name.clone(),
312 role_type: format!("{:?}", member.role_type),
313 agent: member.agent.clone(),
314 reports_to: member.reports_to.clone(),
315 state,
316 pending_inbox,
317 triage_backlog,
318 active_owned_tasks: owned_tasks.active,
319 review_owned_tasks: owned_tasks.review,
320 signal,
321 runtime_label,
322 health,
323 health_summary,
324 eta: "-".to_string(),
325 }
326 })
327 .collect()
328}
329
330pub(crate) fn agent_health_by_member(
331 project_root: &Path,
332 members: &[MemberInstance],
333) -> HashMap<String, AgentHealthSummary> {
334 let mut health_by_member = members
335 .iter()
336 .map(|member| (member.name.clone(), AgentHealthSummary::default()))
337 .collect::<HashMap<_, _>>();
338
339 let daemon_state = match load_persisted_daemon_health_state(&daemon_state_path(project_root)) {
340 Ok(state) => state.unwrap_or_default(),
341 Err(error) => {
342 warn!(error = %error, "failed to load daemon health state");
343 PersistedDaemonHealthState::default()
344 }
345 };
346
347 for (member, retry_count) in &daemon_state.retry_counts {
348 health_by_member
349 .entry(member.clone())
350 .or_default()
351 .restart_count = health_by_member
352 .get(member)
353 .map(|health| health.restart_count.max(*retry_count))
354 .unwrap_or(*retry_count);
355 }
356
357 let mut restart_events = HashMap::<String, u32>::new();
358 let mut latest_assignment_ts = HashMap::<String, u64>::new();
359 let mut latest_assignment_ts_by_task = HashMap::<(String, u32), u64>::new();
360 match events::read_events(&team_events_path(project_root)) {
361 Ok(events) => {
362 for event in events {
363 let Some(role) = event.role.as_deref() else {
364 continue;
365 };
366
367 match event.event.as_str() {
368 "agent_restarted" => {
369 *restart_events.entry(role.to_string()).or_insert(0) += 1;
370 if let Some(restart_count) = event.restart_count {
371 health_by_member
372 .entry(role.to_string())
373 .or_default()
374 .restart_count = health_by_member
375 .get(role)
376 .map(|health| health.restart_count.max(restart_count))
377 .unwrap_or(restart_count);
378 }
379 }
380 "context_exhausted" => {
381 health_by_member
382 .entry(role.to_string())
383 .or_default()
384 .context_exhaustion_count += 1;
385 }
386 "delivery_failed" => {
387 health_by_member
388 .entry(role.to_string())
389 .or_default()
390 .delivery_failure_count += 1;
391 }
392 "task_assigned" => {
393 latest_assignment_ts.insert(role.to_string(), event.ts);
394 if let Some(task_id) =
395 event.task.as_deref().and_then(parse_assigned_task_id)
396 {
397 latest_assignment_ts_by_task
398 .insert((role.to_string(), task_id), event.ts);
399 }
400 }
401 "health_changed" => {
402 if let Some(reason) = event.reason.as_deref() {
405 let new_state = reason.split('→').next_back().unwrap_or("healthy");
406 let health_val = match new_state {
407 "degraded" => crate::agent::BackendHealth::Degraded,
408 "unreachable" => crate::agent::BackendHealth::Unreachable,
409 _ => crate::agent::BackendHealth::Healthy,
410 };
411 health_by_member
412 .entry(role.to_string())
413 .or_default()
414 .backend_health = health_val;
415 }
416 }
417 _ => {}
418 }
419 }
420 }
421 Err(error) => {
422 warn!(error = %error, "failed to read team events for status health summary");
423 }
424 }
425
426 for (member, event_count) in restart_events {
427 let health = health_by_member.entry(member).or_default();
428 health.restart_count = health.restart_count.max(event_count);
429 }
430
431 let now = now_unix();
432 for (member, task_id) in daemon_state.active_tasks {
433 let assigned_ts = latest_assignment_ts_by_task
434 .get(&(member.clone(), task_id))
435 .copied()
436 .or_else(|| latest_assignment_ts.get(&member).copied());
437 if let Some(assigned_ts) = assigned_ts {
438 health_by_member
439 .entry(member)
440 .or_default()
441 .task_elapsed_secs = Some(now.saturating_sub(assigned_ts));
442 }
443 }
444
445 health_by_member
446}
447
448fn load_persisted_daemon_health_state(path: &Path) -> Result<Option<PersistedDaemonHealthState>> {
449 if !path.exists() {
450 return Ok(None);
451 }
452
453 let content = std::fs::read_to_string(path)
454 .with_context(|| format!("failed to read {}", path.display()))?;
455 let state = serde_json::from_str::<PersistedDaemonHealthState>(&content)
456 .with_context(|| format!("failed to parse {}", path.display()))?;
457 Ok(Some(state))
458}
459
460fn parse_assigned_task_id(task: &str) -> Option<u32> {
461 let trimmed = task.trim();
462 trimmed
463 .parse::<u32>()
464 .ok()
465 .or_else(|| {
466 trimmed
467 .split("Task #")
468 .nth(1)
469 .and_then(parse_leading_task_id)
470 })
471 .or_else(|| {
472 trimmed
473 .find('#')
474 .and_then(|idx| parse_leading_task_id(&trimmed[idx + 1..]))
475 })
476}
477
478fn parse_leading_task_id(value: &str) -> Option<u32> {
479 let digits = value
480 .trim_start()
481 .chars()
482 .take_while(|ch| ch.is_ascii_digit())
483 .collect::<String>();
484 (!digits.is_empty()).then(|| digits.parse().ok()).flatten()
485}
486
487pub(crate) fn format_agent_health_summary(health: &AgentHealthSummary) -> String {
488 let mut parts = Vec::new();
489 if !health.backend_health.is_healthy() {
490 parts.push(format!("B:{}", health.backend_health.as_str()));
491 }
492 if health.restart_count > 0 {
493 parts.push(format!("r{}", health.restart_count));
494 }
495 if health.context_exhaustion_count > 0 {
496 parts.push(format!("c{}", health.context_exhaustion_count));
497 }
498 if health.delivery_failure_count > 0 {
499 parts.push(format!("d{}", health.delivery_failure_count));
500 }
501 if let Some(task_elapsed_secs) = health.task_elapsed_secs {
502 parts.push(format!("t{}", format_health_duration(task_elapsed_secs)));
503 }
504
505 if parts.is_empty() {
506 "-".to_string()
507 } else {
508 parts.join(" ")
509 }
510}
511
512fn format_health_duration(task_elapsed_secs: u64) -> String {
513 if task_elapsed_secs < 60 {
514 format!("{task_elapsed_secs}s")
515 } else if task_elapsed_secs < 3_600 {
516 format!("{}m", task_elapsed_secs / 60)
517 } else if task_elapsed_secs < 86_400 {
518 format!("{}h", task_elapsed_secs / 3_600)
519 } else {
520 format!("{}d", task_elapsed_secs / 86_400)
521 }
522}
523
524fn merge_status_signal(
525 signal: Option<String>,
526 triage_backlog: usize,
527 review_backlog: usize,
528) -> Option<String> {
529 let triage_signal = (triage_backlog > 0).then(|| format!("needs triage ({triage_backlog})"));
530 let review_signal = (review_backlog > 0).then(|| format!("needs review ({review_backlog})"));
531 let mut signals = Vec::new();
532 if let Some(existing) = signal {
533 signals.push(existing);
534 }
535 if let Some(triage) = triage_signal {
536 signals.push(triage);
537 }
538 if let Some(review) = review_signal {
539 signals.push(review);
540 }
541 if signals.is_empty() {
542 None
543 } else {
544 Some(signals.join(", "))
545 }
546}
547
548pub(crate) fn triage_backlog_counts(
549 project_root: &Path,
550 members: &[MemberInstance],
551) -> HashMap<String, usize> {
552 let root = inbox::inboxes_root(project_root);
553 let direct_reports = direct_reports_by_member(members);
554 direct_reports
555 .into_iter()
556 .filter_map(|(member_name, reports)| {
557 match delivered_direct_report_triage_state(&root, &member_name, &reports) {
558 Ok(state) => Some((member_name, state.count)),
559 Err(error) => {
560 warn!(member = %member_name, error = %error, "failed to compute lead triage backlog");
561 None
562 }
563 }
564 })
565 .collect()
566}
567
568pub(crate) fn direct_reports_by_member(members: &[MemberInstance]) -> HashMap<String, Vec<String>> {
569 let mut direct_reports: HashMap<String, Vec<String>> = HashMap::new();
570 for member in members {
571 if let Some(parent) = &member.reports_to {
572 direct_reports
573 .entry(parent.clone())
574 .or_default()
575 .push(member.name.clone());
576 }
577 }
578 direct_reports
579}
580
581pub(crate) fn delivered_direct_report_triage_count(
582 inbox_root: &Path,
583 member_name: &str,
584 direct_reports: &[String],
585) -> Result<usize> {
586 Ok(delivered_direct_report_triage_state(inbox_root, member_name, direct_reports)?.count)
587}
588
589pub(crate) fn delivered_direct_report_triage_state(
590 inbox_root: &Path,
591 member_name: &str,
592 direct_reports: &[String],
593) -> Result<TriageBacklogState> {
594 delivered_direct_report_triage_state_at(inbox_root, member_name, direct_reports, now_unix())
595}
596
597pub(crate) fn delivered_direct_report_triage_state_at(
598 inbox_root: &Path,
599 member_name: &str,
600 direct_reports: &[String],
601 now_ts: u64,
602) -> Result<TriageBacklogState> {
603 if direct_reports.is_empty() {
604 return Ok(TriageBacklogState {
605 count: 0,
606 newest_result_ts: 0,
607 });
608 }
609
610 let mut latest_outbound_by_report = HashMap::new();
611 for report in direct_reports {
612 let report_messages = inbox::all_messages(inbox_root, report)?;
613 let latest_outbound = report_messages
614 .iter()
615 .filter_map(|(msg, _)| (msg.from == member_name).then_some(msg.timestamp))
616 .max()
617 .unwrap_or(0);
618 latest_outbound_by_report.insert(report.as_str(), latest_outbound);
619 }
620
621 let member_messages = inbox::all_messages(inbox_root, member_name)?;
622 let mut count = 0usize;
623 let mut newest_result_ts = 0u64;
624 for (msg, delivered) in &member_messages {
625 let is_fresh = now_ts.saturating_sub(msg.timestamp) <= TRIAGE_RESULT_FRESHNESS_SECONDS;
626 let needs_triage = *delivered
627 && is_fresh
628 && direct_reports.iter().any(|report| report == &msg.from)
629 && msg.timestamp
630 > *latest_outbound_by_report
631 .get(msg.from.as_str())
632 .unwrap_or(&0);
633 if needs_triage {
634 count += 1;
635 newest_result_ts = newest_result_ts.max(msg.timestamp);
636 }
637 }
638
639 Ok(TriageBacklogState {
640 count,
641 newest_result_ts,
642 })
643}
644
645pub(crate) fn pending_inbox_counts(
646 project_root: &Path,
647 members: &[MemberInstance],
648) -> HashMap<String, usize> {
649 let root = inbox::inboxes_root(project_root);
650 members
651 .iter()
652 .filter_map(|member| match inbox::pending_message_count(&root, &member.name) {
653 Ok(count) => Some((member.name.clone(), count)),
654 Err(error) => {
655 warn!(member = %member.name, error = %error, "failed to count pending inbox messages");
656 None
657 }
658 })
659 .collect()
660}
661
662fn classify_owned_task_status(status: &str) -> Option<bool> {
663 match status {
664 "done" | "archived" => None,
665 "review" => Some(false),
666 _ => Some(true),
667 }
668}
669
670pub(crate) fn owned_task_buckets(
671 project_root: &Path,
672 members: &[MemberInstance],
673) -> HashMap<String, OwnedTaskBuckets> {
674 let tasks_dir = project_root
675 .join(".batty")
676 .join("team_config")
677 .join("board")
678 .join("tasks");
679 if !tasks_dir.is_dir() {
680 return HashMap::new();
681 }
682
683 let member_names: HashSet<&str> = members.iter().map(|member| member.name.as_str()).collect();
684 let mut owned = HashMap::<String, OwnedTaskBuckets>::new();
685 let tasks = match crate::task::load_tasks_from_dir(&tasks_dir) {
686 Ok(tasks) => tasks,
687 Err(error) => {
688 warn!(path = %tasks_dir.display(), error = %error, "failed to load board tasks for status");
689 return HashMap::new();
690 }
691 };
692
693 for task in tasks {
694 let Some(claimed_by) = task.claimed_by else {
695 continue;
696 };
697 if !member_names.contains(claimed_by.as_str()) {
698 continue;
699 }
700 let Some(is_active) = classify_owned_task_status(task.status.as_str()) else {
701 continue;
702 };
703 let owner = if is_active {
704 claimed_by
705 } else {
706 members
707 .iter()
708 .find(|member| member.name == claimed_by)
709 .and_then(|member| member.reports_to.as_deref())
710 .unwrap_or(claimed_by.as_str())
711 .to_string()
712 };
713 let entry = owned.entry(owner).or_default();
714 if is_active {
715 entry.active.push(task.id);
716 } else {
717 entry.review.push(task.id);
718 }
719 }
720
721 for buckets in owned.values_mut() {
722 buckets.active.sort_unstable();
723 buckets.review.sort_unstable();
724 }
725
726 owned
727}
728
729pub(crate) fn format_owned_tasks_summary(task_ids: &[u32]) -> String {
730 match task_ids {
731 [] => "-".to_string(),
732 [task_id] => format!("#{task_id}"),
733 [first, second] => format!("#{first},#{second}"),
734 [first, second, rest @ ..] => format!("#{first},#{second},+{}", rest.len()),
735 }
736}
737
738pub(crate) fn board_status_task_queues(
739 project_root: &Path,
740) -> Result<(Vec<StatusTaskEntry>, Vec<StatusTaskEntry>)> {
741 let tasks_dir = project_root
742 .join(".batty")
743 .join("team_config")
744 .join("board")
745 .join("tasks");
746 if !tasks_dir.is_dir() {
747 return Ok((Vec::new(), Vec::new()));
748 }
749
750 let mut active_tasks = Vec::new();
751 let mut review_queue = Vec::new();
752 for task in task::load_tasks_from_dir(&tasks_dir)? {
753 let 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
904pub 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
949struct 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 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
1136fn 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 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 #[test]
2444 fn compute_metrics_with_telemetry_db_returns_review_metrics() {
2445 let tmp = tempfile::tempdir().unwrap();
2446
2447 write_board_task(
2449 tmp.path(),
2450 "001-task.md",
2451 "id: 1\ntitle: Test\nstatus: todo\npriority: high\n",
2452 );
2453
2454 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 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 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 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 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}