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