use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::{Connection, params};
use super::events::{TeamEvent, read_events};
use super::telemetry_db;
use crate::task;
#[derive(Debug, Clone, PartialEq)]
pub struct RunStats {
pub run_start: u64,
pub run_end: u64,
pub total_duration_secs: u64,
pub task_stats: Vec<TaskStats>,
pub average_cycle_time_secs: Option<u64>,
pub fastest_task_id: Option<String>,
pub fastest_cycle_time_secs: Option<u64>,
pub longest_task_id: Option<String>,
pub longest_cycle_time_secs: Option<u64>,
pub idle_time_pct: f64,
pub escalation_count: u32,
pub message_count: u32,
pub auto_merge_count: u32,
pub manual_merge_count: u32,
pub rework_count: u32,
pub review_nudge_count: u32,
pub review_escalation_count: u32,
pub avg_review_stall_secs: Option<u64>,
pub max_review_stall_secs: Option<u64>,
pub max_review_stall_task: Option<String>,
pub task_rework_counts: Vec<(String, u32)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskStats {
pub task_id: String,
pub assigned_to: String,
pub assigned_at: u64,
pub completed_at: Option<u64>,
pub cycle_time_secs: Option<u64>,
pub retry_count: u32,
pub was_escalated: bool,
}
#[derive(Debug, Clone)]
struct TaskAccumulator {
task_id: String,
assigned_to: String,
assigned_at: u64,
completed_at: Option<u64>,
cycle_time_secs: Option<u64>,
retry_count: u32,
was_escalated: bool,
}
impl TaskAccumulator {
fn new(task_id: String, assigned_to: String, assigned_at: u64, retry_count: u32) -> Self {
Self {
task_id,
assigned_to,
assigned_at,
completed_at: None,
cycle_time_secs: None,
retry_count,
was_escalated: false,
}
}
fn into_stats(self) -> TaskStats {
TaskStats {
task_id: self.task_id,
assigned_to: self.assigned_to,
assigned_at: self.assigned_at,
completed_at: self.completed_at,
cycle_time_secs: self.cycle_time_secs,
retry_count: self.retry_count,
was_escalated: self.was_escalated,
}
}
}
fn task_reference(task: &str) -> String {
let line = task
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or_else(|| task.trim());
task_id_from_assignment_line(line).unwrap_or_else(|| line.to_string())
}
fn task_id_from_assignment_line(line: &str) -> Option<String> {
let suffix = line.strip_prefix("Task #")?;
let digits: String = suffix
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect();
if digits.is_empty() {
None
} else {
Some(digits)
}
}
type CycleTimeMetrics = (
Option<u64>,
Option<String>,
Option<u64>,
Option<String>,
Option<u64>,
);
fn cycle_time_metrics(task_stats: &[TaskStats]) -> CycleTimeMetrics {
let completed: Vec<(&TaskStats, u64)> = task_stats
.iter()
.filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
.collect();
if completed.is_empty() {
return (None, None, None, None, None);
}
let total_cycle_secs: u64 = completed.iter().map(|(_, cycle)| *cycle).sum();
let average_cycle_time_secs = Some(total_cycle_secs / completed.len() as u64);
let (fastest_task, fastest_cycle_time_secs) = completed
.iter()
.min_by_key(|(_, cycle)| *cycle)
.map(|(task, cycle)| (task.task_id.clone(), *cycle))
.expect("completed is not empty");
let (longest_task, longest_cycle_time_secs) = completed
.iter()
.max_by_key(|(_, cycle)| *cycle)
.map(|(task, cycle)| (task.task_id.clone(), *cycle))
.expect("completed is not empty");
(
average_cycle_time_secs,
Some(fastest_task),
Some(fastest_cycle_time_secs),
Some(longest_task),
Some(longest_cycle_time_secs),
)
}
pub fn analyze_events(events: &[TeamEvent]) -> Option<RunStats> {
if events.is_empty() {
return None;
}
let last_run_start = events
.iter()
.rposition(|event| event.event == "daemon_started")
.unwrap_or(0);
let run_events = &events[last_run_start..];
if run_events.is_empty() {
return None;
}
let run_start = run_events[0].ts;
let run_end = run_events
.iter()
.rev()
.find(|event| event.event == "daemon_stopped")
.map(|event| event.ts)
.unwrap_or_else(|| run_events.last().map(|event| event.ts).unwrap_or(run_start));
let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
let mut active_task_by_role: HashMap<String, String> = HashMap::new();
let mut idle_samples = Vec::new();
let mut escalation_count = 0u32;
let mut message_count = 0u32;
let mut auto_merge_count = 0u32;
let mut manual_merge_count = 0u32;
let mut rework_count = 0u32;
let mut review_nudge_count = 0u32;
let mut review_escalation_count = 0u32;
let mut task_completed_at: HashMap<String, u64> = HashMap::new();
let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
let mut per_task_rework: HashMap<String, u32> = HashMap::new();
for event in run_events {
match event.event.as_str() {
"task_assigned" => {
let Some(role) = event.role.as_deref() else {
continue;
};
let Some(task) = event.task.as_deref() else {
continue;
};
let task_id = task_reference(task);
let entry = tasks.entry(task_id.clone()).or_insert_with(|| {
TaskAccumulator::new(task_id.clone(), role.to_string(), event.ts, 0)
});
entry.retry_count += 1;
entry.assigned_to = role.to_string();
active_task_by_role.insert(role.to_string(), task_id);
}
"task_completed" => {
let Some(role) = event.role.as_deref() else {
continue;
};
let Some(task_id) = active_task_by_role.remove(role) else {
continue;
};
let Some(task) = tasks.get_mut(&task_id) else {
continue;
};
if task.completed_at.is_none() {
task.completed_at = Some(event.ts);
task.cycle_time_secs = Some(event.ts.saturating_sub(task.assigned_at));
}
task_completed_at.insert(task_id, event.ts);
}
"task_escalated" => {
escalation_count += 1;
let Some(task_id) = event.task.as_deref() else {
continue;
};
let role = event.role.clone().unwrap_or_default();
let entry = tasks.entry(task_id.to_string()).or_insert_with(|| {
TaskAccumulator::new(task_id.to_string(), role, event.ts, 0)
});
entry.was_escalated = true;
}
"message_routed" => {
message_count += 1;
}
"task_auto_merged" => {
auto_merge_count += 1;
if let Some(task) = event.task.as_deref() {
let task_id = task_reference(task);
if let Some(completed_ts) = task_completed_at.get(&task_id) {
review_stall_durations
.push((task_id, event.ts.saturating_sub(*completed_ts)));
}
}
}
"task_manual_merged" => {
manual_merge_count += 1;
if let Some(task) = event.task.as_deref() {
let task_id = task_reference(task);
if let Some(completed_ts) = task_completed_at.get(&task_id) {
review_stall_durations
.push((task_id, event.ts.saturating_sub(*completed_ts)));
}
}
}
"task_reworked" => {
rework_count += 1;
if let Some(task) = event.task.as_deref() {
let task_id = task_reference(task);
*per_task_rework.entry(task_id).or_insert(0) += 1;
}
}
"review_nudge_sent" => {
review_nudge_count += 1;
}
"review_escalated" => {
review_escalation_count += 1;
}
"load_snapshot" => {
let Some(working_members) = event.working_members else {
continue;
};
let Some(total_members) = event.total_members else {
continue;
};
let idle_pct = if total_members == 0 {
1.0
} else {
1.0 - (working_members as f64 / total_members as f64)
};
idle_samples.push(idle_pct);
}
_ => {}
}
}
let mut task_stats: Vec<TaskStats> =
tasks.into_values().map(|task| task.into_stats()).collect();
task_stats.sort_by(|left, right| {
left.assigned_at
.cmp(&right.assigned_at)
.then_with(|| left.task_id.cmp(&right.task_id))
});
let idle_time_pct = if idle_samples.is_empty() {
0.0
} else {
idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
};
let (
average_cycle_time_secs,
fastest_task_id,
fastest_cycle_time_secs,
longest_task_id,
longest_cycle_time_secs,
) = cycle_time_metrics(&task_stats);
let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
if review_stall_durations.is_empty() {
(None, None, None)
} else {
let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
let avg = total / review_stall_durations.len() as u64;
let (max_task, max_dur) = review_stall_durations
.iter()
.max_by_key(|(_, d)| *d)
.map(|(t, d)| (t.clone(), *d))
.expect("non-empty");
(Some(avg), Some(max_dur), Some(max_task))
};
let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some(RunStats {
run_start,
run_end,
total_duration_secs: run_end.saturating_sub(run_start),
task_stats,
average_cycle_time_secs,
fastest_task_id,
fastest_cycle_time_secs,
longest_task_id,
longest_cycle_time_secs,
idle_time_pct,
escalation_count,
message_count,
auto_merge_count,
manual_merge_count,
rework_count,
review_nudge_count,
review_escalation_count,
avg_review_stall_secs,
max_review_stall_secs,
max_review_stall_task,
task_rework_counts,
})
}
pub fn analyze_from_db(conn: &Connection) -> Option<RunStats> {
let last_session_start: Option<i64> = conn
.query_row(
"SELECT MAX(timestamp) FROM events WHERE event_type = 'daemon_started'",
[],
|row| row.get(0),
)
.ok()?;
let run_start = last_session_start? as u64;
let run_end: u64 = conn
.query_row(
"SELECT timestamp FROM events
WHERE event_type = 'daemon_stopped' AND timestamp >= ?1
ORDER BY timestamp DESC LIMIT 1",
params![run_start as i64],
|row| row.get::<_, i64>(0),
)
.unwrap_or_else(|_| {
conn.query_row(
"SELECT MAX(timestamp) FROM events WHERE timestamp >= ?1",
params![run_start as i64],
|row| row.get::<_, Option<i64>>(0),
)
.unwrap_or(None)
.unwrap_or(run_start as i64)
}) as u64;
let mut stmt = conn
.prepare(
"SELECT timestamp, event_type, role, task_id, payload FROM events
WHERE timestamp >= ?1 ORDER BY timestamp ASC",
)
.ok()?;
type EventRow = (i64, String, Option<String>, Option<String>, String);
let rows: Vec<EventRow> = stmt
.query_map(params![run_start as i64], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.ok()?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return None;
}
let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
let mut active_task_by_role: HashMap<String, String> = HashMap::new();
let mut idle_samples = Vec::new();
let mut escalation_count = 0u32;
let mut message_count = 0u32;
let mut auto_merge_count = 0u32;
let mut manual_merge_count = 0u32;
let mut rework_count = 0u32;
let mut review_nudge_count = 0u32;
let mut review_escalation_count = 0u32;
let mut task_completed_at: HashMap<String, u64> = HashMap::new();
let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
let mut per_task_rework: HashMap<String, u32> = HashMap::new();
for (ts, event_type, role, task_id, payload) in &rows {
let ts = *ts as u64;
match event_type.as_str() {
"task_assigned" => {
let Some(role) = role.as_deref() else {
continue;
};
let Some(task) = task_id.as_deref() else {
continue;
};
let tid = task_reference(task);
let entry = tasks
.entry(tid.clone())
.or_insert_with(|| TaskAccumulator::new(tid.clone(), role.to_string(), ts, 0));
entry.retry_count += 1;
entry.assigned_to = role.to_string();
active_task_by_role.insert(role.to_string(), tid);
}
"task_completed" => {
let Some(role) = role.as_deref() else {
continue;
};
let Some(tid) = active_task_by_role.remove(role) else {
continue;
};
let Some(task) = tasks.get_mut(&tid) else {
continue;
};
if task.completed_at.is_none() {
task.completed_at = Some(ts);
task.cycle_time_secs = Some(ts.saturating_sub(task.assigned_at));
}
task_completed_at.insert(tid, ts);
}
"task_escalated" => {
escalation_count += 1;
let Some(task) = task_id.as_deref() else {
continue;
};
let r = role.clone().unwrap_or_default();
let entry = tasks
.entry(task.to_string())
.or_insert_with(|| TaskAccumulator::new(task.to_string(), r, ts, 0));
entry.was_escalated = true;
}
"message_routed" => {
message_count += 1;
}
"task_auto_merged" => {
auto_merge_count += 1;
if let Some(task) = task_id.as_deref() {
let tid = task_reference(task);
if let Some(completed_ts) = task_completed_at.get(&tid) {
review_stall_durations.push((tid, ts.saturating_sub(*completed_ts)));
}
}
}
"task_manual_merged" => {
manual_merge_count += 1;
if let Some(task) = task_id.as_deref() {
let tid = task_reference(task);
if let Some(completed_ts) = task_completed_at.get(&tid) {
review_stall_durations.push((tid, ts.saturating_sub(*completed_ts)));
}
}
}
"task_reworked" => {
rework_count += 1;
if let Some(task) = task_id.as_deref() {
let tid = task_reference(task);
*per_task_rework.entry(tid).or_insert(0) += 1;
}
}
"review_nudge_sent" => {
review_nudge_count += 1;
}
"review_escalated" => {
review_escalation_count += 1;
}
"load_snapshot" => {
if let Ok(evt) = serde_json::from_str::<TeamEvent>(payload) {
let Some(working_members) = evt.working_members else {
continue;
};
let Some(total_members) = evt.total_members else {
continue;
};
let idle_pct = if total_members == 0 {
1.0
} else {
1.0 - (working_members as f64 / total_members as f64)
};
idle_samples.push(idle_pct);
}
}
_ => {}
}
}
let mut task_stats: Vec<TaskStats> = tasks.into_values().map(|t| t.into_stats()).collect();
task_stats.sort_by(|a, b| {
a.assigned_at
.cmp(&b.assigned_at)
.then_with(|| a.task_id.cmp(&b.task_id))
});
let idle_time_pct = if idle_samples.is_empty() {
0.0
} else {
idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
};
let (
average_cycle_time_secs,
fastest_task_id,
fastest_cycle_time_secs,
longest_task_id,
longest_cycle_time_secs,
) = cycle_time_metrics(&task_stats);
let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
if review_stall_durations.is_empty() {
(None, None, None)
} else {
let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
let avg = total / review_stall_durations.len() as u64;
let (max_task, max_dur) = review_stall_durations
.iter()
.max_by_key(|(_, d)| *d)
.map(|(t, d)| (t.clone(), *d))
.expect("non-empty");
(Some(avg), Some(max_dur), Some(max_task))
};
let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some(RunStats {
run_start,
run_end,
total_duration_secs: run_end.saturating_sub(run_start),
task_stats,
average_cycle_time_secs,
fastest_task_id,
fastest_cycle_time_secs,
longest_task_id,
longest_cycle_time_secs,
idle_time_pct,
escalation_count,
message_count,
auto_merge_count,
manual_merge_count,
rework_count,
review_nudge_count,
review_escalation_count,
avg_review_stall_secs,
max_review_stall_secs,
max_review_stall_task,
task_rework_counts,
})
}
pub fn analyze_project(project_root: &Path) -> Result<Option<RunStats>> {
let db_path = project_root.join(".batty").join("telemetry.db");
if db_path.exists() {
if let Ok(conn) = telemetry_db::open(project_root) {
if let Some(stats) = analyze_from_db(&conn) {
return Ok(Some(stats));
}
}
}
let events_path = project_root
.join(".batty")
.join("team_config")
.join("events.jsonl");
analyze_event_log(&events_path)
}
pub fn analyze_event_log(path: &Path) -> Result<Option<RunStats>> {
let events = read_events(path)?;
Ok(analyze_events(&events))
}
pub fn should_generate_retro(
project_root: &Path,
retro_generated: bool,
min_duration_secs: u64,
) -> Result<Option<RunStats>> {
if retro_generated {
return Ok(None);
}
let board_dir = project_root
.join(".batty")
.join("team_config")
.join("board");
let tasks_dir = board_dir.join("tasks");
if !tasks_dir.is_dir() {
return Ok(None);
}
let tasks = task::load_tasks_from_dir(&tasks_dir)?;
let active_tasks: Vec<&task::Task> = tasks
.iter()
.filter(|task| task.status != "archived")
.collect();
if active_tasks.is_empty() || active_tasks.iter().any(|task| task.status != "done") {
return Ok(None);
}
let stats = analyze_project(project_root)?;
if let Some(ref stats) = stats {
let completed = stats
.task_stats
.iter()
.filter(|t| t.completed_at.is_some())
.count();
if stats.total_duration_secs < min_duration_secs && completed == 0 {
tracing::debug!(
duration_secs = stats.total_duration_secs,
completed_tasks = completed,
"Skipping trivial retrospective: {}s, {} tasks",
stats.total_duration_secs,
completed,
);
return Ok(None);
}
}
Ok(stats)
}
pub fn generate_retrospective(project_root: &Path, stats: &RunStats) -> Result<PathBuf> {
let retrospectives_dir = project_root.join(".batty").join("retrospectives");
fs::create_dir_all(&retrospectives_dir).with_context(|| {
format!(
"failed to create retrospectives directory: {}",
retrospectives_dir.display()
)
})?;
let report_path = retrospectives_dir.join(format!("{}.md", stats.run_end));
let report = render_retrospective(stats);
fs::write(&report_path, report)
.with_context(|| format!("failed to write retrospective: {}", report_path.display()))?;
Ok(report_path)
}
pub fn format_duration(secs: u64) -> String {
let hours = secs / 3_600;
let minutes = (secs % 3_600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{hours}h {minutes:02}m {seconds:02}s")
} else if minutes > 0 {
format!("{minutes}m {seconds:02}s")
} else {
format!("{seconds}s")
}
}
fn render_retrospective(stats: &RunStats) -> String {
let completed_tasks = stats
.task_stats
.iter()
.filter(|task| task.completed_at.is_some())
.count();
let average_cycle_time = stats
.average_cycle_time_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string());
let fastest_cycle_time = stats
.fastest_cycle_time_secs
.map(|cycle| {
format!(
"{} ({})",
format_duration(cycle),
stats.fastest_task_id.as_deref().unwrap_or("-")
)
})
.unwrap_or_else(|| "-".to_string());
let longest_cycle_time = stats
.longest_cycle_time_secs
.map(|cycle| {
format!(
"{} ({})",
format_duration(cycle),
stats.longest_task_id.as_deref().unwrap_or("-")
)
})
.unwrap_or_else(|| "-".to_string());
let task_cycle_rows = render_task_cycle_rows(&stats.task_stats);
let bottlenecks = render_bottlenecks(&stats.task_stats);
let recommendations = render_recommendations(stats);
let review_section = render_review_performance(stats);
format!(
"# Batty Retrospective\n\n\
## Summary\n\n\
- Duration: {}\n\
- Tasks completed: {}\n\
- Average cycle time: {}\n\
- Fastest task: {}\n\
- Longest task: {}\n\
- Messages: {}\n\
- Escalations: {}\n\
- Idle: {:.1}%\n\n\
## Task Cycle Times\n\n\
| Task | Assignee | Status | Cycle Time | Retries | Escalated |\n\
| --- | --- | --- | --- | --- | --- |\n\
{}\
\n\
{}\
## Bottlenecks\n\n\
{}\
\n\
## Recommendations\n\n\
{}",
format_duration(stats.total_duration_secs),
completed_tasks,
average_cycle_time,
fastest_cycle_time,
longest_cycle_time,
stats.message_count,
stats.escalation_count,
stats.idle_time_pct * 100.0,
task_cycle_rows,
review_section,
bottlenecks,
recommendations
)
}
fn render_review_performance(stats: &RunStats) -> String {
let total_merges = stats.auto_merge_count + stats.manual_merge_count;
if total_merges == 0 && stats.rework_count == 0 && stats.review_nudge_count == 0 {
return String::new();
}
let auto_rate = if total_merges > 0 {
format!(
"{:.0}%",
stats.auto_merge_count as f64 / total_merges as f64 * 100.0
)
} else {
"-".to_string()
};
let total_reviewed = total_merges + stats.rework_count;
let rework_rate = if total_reviewed > 0 {
format!(
"{:.0}%",
stats.rework_count as f64 / total_reviewed as f64 * 100.0
)
} else {
"-".to_string()
};
let avg_stall = stats
.avg_review_stall_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string());
let max_stall = stats
.max_review_stall_secs
.map(|secs| {
format!(
"{} ({})",
format_duration(secs),
stats.max_review_stall_task.as_deref().unwrap_or("-")
)
})
.unwrap_or_else(|| "-".to_string());
let mut section = format!(
"## Review Pipeline\n\n\
- Auto-merged: {}\n\
- Manually merged: {}\n\
- Auto-merge rate: {}\n\
- Avg review stall: {}\n\
- Max review stall: {}\n\
- Rework cycles: {}\n\
- Rework rate: {}\n\
- Review nudges: {}\n\
- Review escalations: {}\n",
stats.auto_merge_count,
stats.manual_merge_count,
auto_rate,
avg_stall,
max_stall,
stats.rework_count,
rework_rate,
stats.review_nudge_count,
stats.review_escalation_count,
);
if !stats.task_rework_counts.is_empty() {
section.push_str(
"\n### Rework by Task\n\n\
| Task | Rework Cycles |\n\
| --- | --- |\n",
);
for (task_id, count) in &stats.task_rework_counts {
section.push_str(&format!("| {} | {} |\n", task_id, count));
}
}
section.push('\n');
section
}
fn render_task_cycle_rows(tasks: &[TaskStats]) -> String {
if tasks.is_empty() {
return "| No tasks recorded | - | - | - | - | - |\n".to_string();
}
let mut rows = String::new();
for task in tasks {
let status = if task.completed_at.is_some() {
"completed"
} else {
"incomplete"
};
let cycle_time = task
.cycle_time_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string());
let escalated = if task.was_escalated { "yes" } else { "no" };
rows.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
task.task_id, task.assigned_to, status, cycle_time, task.retry_count, escalated
));
}
rows
}
fn render_bottlenecks(tasks: &[TaskStats]) -> String {
let longest_task = tasks
.iter()
.filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
.max_by_key(|(_, cycle)| *cycle);
let most_retried = tasks.iter().max_by_key(|task| task.retry_count);
let mut lines = Vec::new();
match longest_task {
Some((task, cycle)) => lines.push(format!(
"- Longest task: `{}` owned by `{}` at {}.",
task.task_id,
task.assigned_to,
format_duration(cycle)
)),
None => lines.push("- Longest task: no completed tasks recorded.".to_string()),
}
match most_retried {
Some(task) if task.retry_count > 1 => lines.push(format!(
"- Most retried: `{}` retried {} times.",
task.task_id, task.retry_count
)),
_ => lines.push("- Most retried: no task needed multiple attempts.".to_string()),
}
format!("{}\n", lines.join("\n"))
}
fn render_recommendations(stats: &RunStats) -> String {
let mut lines = Vec::new();
let max_retry_count = stats
.task_stats
.iter()
.map(|task| task.retry_count)
.max()
.unwrap_or(0);
if stats.idle_time_pct >= 0.5 {
lines.push(
"- Idle time stayed high. Queue more ready tasks so engineers are not waiting on assignment."
.to_string(),
);
}
if max_retry_count >= 3 {
lines.push(
"- Several retries were needed. Break work into smaller tasks with clearer acceptance criteria."
.to_string(),
);
}
if lines.is_empty() {
lines.push(
"- No major bottlenecks stood out. Keep the current task sizing and routing cadence."
.to_string(),
);
}
format!("{}\n", lines.join("\n"))
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
fn at(mut event: TeamEvent, ts: u64) -> TeamEvent {
event.ts = ts;
event
}
#[test]
fn test_analyze_events_basic_run() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::message_routed("manager", "eng-1"), 115),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.run_start, 100);
assert_eq!(stats.run_end, 160);
assert_eq!(stats.total_duration_secs, 60);
assert_eq!(stats.escalation_count, 0);
assert_eq!(stats.message_count, 1);
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.average_cycle_time_secs, Some(40));
assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
assert_eq!(stats.fastest_cycle_time_secs, Some(40));
assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
assert_eq!(stats.longest_cycle_time_secs, Some(40));
assert_eq!(
stats.task_stats[0],
TaskStats {
task_id: "42".to_string(),
assigned_to: "eng-1".to_string(),
assigned_at: 110,
completed_at: Some(150),
cycle_time_secs: Some(40),
retry_count: 1,
was_escalated: false,
}
);
}
#[test]
fn test_analyze_events_with_retries() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(
TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
110,
),
at(
TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
130,
),
at(TeamEvent::task_completed("eng-1", None), 170),
at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].retry_count, 2);
assert_eq!(stats.task_stats[0].assigned_at, 110);
assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
assert_eq!(stats.task_stats[0].task_id, "42");
}
#[test]
fn test_analyze_events_with_escalation() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::task_escalated("eng-1", "42", None), 125),
at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.escalation_count, 1);
assert_eq!(stats.task_stats.len(), 1);
assert!(stats.task_stats[0].was_escalated);
assert_eq!(stats.task_stats[0].completed_at, None);
}
#[test]
fn test_analyze_events_idle_time() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::load_snapshot(1, 4, true), 110),
at(TeamEvent::load_snapshot(3, 4, true), 120),
at(TeamEvent::daemon_stopped_with_reason("signal", 25), 125),
];
let stats = analyze_events(&events).unwrap();
assert!((stats.idle_time_pct - 0.5).abs() < 1e-9);
}
#[test]
fn test_analyze_events_empty() {
assert_eq!(analyze_events(&[]), None);
}
#[test]
fn test_analyze_events_multiple_runs() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
at(TeamEvent::daemon_started(), 200),
at(
TeamEvent::task_assigned("eng-2", "Task #12: new-task\n\nTask details."),
210,
),
at(TeamEvent::task_completed("eng-2", None), 240),
at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.run_start, 200);
assert_eq!(stats.run_end, 245);
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].task_id, "12");
assert_eq!(stats.task_stats[0].assigned_to, "eng-2");
assert_eq!(stats.task_stats[0].cycle_time_secs, Some(30));
assert_eq!(stats.average_cycle_time_secs, Some(30));
assert_eq!(stats.fastest_task_id.as_deref(), Some("12"));
assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
}
#[test]
fn test_analyze_events_computes_average_fastest_and_longest_cycle_times() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(
TeamEvent::task_assigned("eng-1", "Task #11: short task\n\nBody."),
110,
),
at(TeamEvent::task_completed("eng-1", None), 140),
at(
TeamEvent::task_assigned("eng-2", "Task #12: long task\n\nBody."),
150,
),
at(TeamEvent::task_completed("eng-2", None), 240),
at(TeamEvent::daemon_stopped_with_reason("signal", 150), 250),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.average_cycle_time_secs, Some(60));
assert_eq!(stats.fastest_task_id.as_deref(), Some("11"));
assert_eq!(stats.fastest_cycle_time_secs, Some(30));
assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
assert_eq!(stats.longest_cycle_time_secs, Some(90));
}
fn sample_task(task_id: &str, cycle_time_secs: Option<u64>, retry_count: u32) -> TaskStats {
TaskStats {
task_id: task_id.to_string(),
assigned_to: "eng-1".to_string(),
assigned_at: 100,
completed_at: cycle_time_secs.map(|cycle| 100 + cycle),
cycle_time_secs,
retry_count,
was_escalated: retry_count > 2,
}
}
#[test]
fn format_duration_variants() {
assert_eq!(format_duration(45), "45s");
assert_eq!(format_duration(65), "1m 05s");
assert_eq!(format_duration(3_665), "1h 01m 05s");
}
#[test]
fn generate_retrospective_writes_report_with_sections() {
let tmp = tempdir().unwrap();
let stats = RunStats {
run_start: 1_700_000_000,
run_end: 1_700_000_123,
total_duration_secs: 123,
task_stats: vec![
sample_task("T-101", Some(90), 1),
sample_task("T-102", Some(30), 2),
],
average_cycle_time_secs: Some(60),
fastest_task_id: Some("T-102".to_string()),
fastest_cycle_time_secs: Some(30),
longest_task_id: Some("T-101".to_string()),
longest_cycle_time_secs: Some(90),
idle_time_pct: 0.25,
escalation_count: 1,
message_count: 6,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let path = generate_retrospective(tmp.path(), &stats).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert_eq!(
path,
tmp.path()
.join(".batty")
.join("retrospectives")
.join("1700000123.md")
);
assert!(content.contains("## Summary"));
assert!(content.contains("## Task Cycle Times"));
assert!(content.contains("## Bottlenecks"));
assert!(content.contains("## Recommendations"));
assert!(content.contains("| T-101 | eng-1 | completed | 1m 30s | 1 | no |"));
assert!(content.contains("- Tasks completed: 2"));
assert!(content.contains("- Average cycle time: 1m 00s"));
assert!(content.contains("- Fastest task: 30s (T-102)"));
assert!(content.contains("- Longest task: 1m 30s (T-101)"));
}
#[test]
fn generate_retrospective_handles_empty_tasks() {
let tmp = tempdir().unwrap();
let stats = RunStats {
run_start: 10,
run_end: 20,
total_duration_secs: 10,
task_stats: Vec::new(),
average_cycle_time_secs: None,
fastest_task_id: None,
fastest_cycle_time_secs: None,
longest_task_id: None,
longest_cycle_time_secs: None,
idle_time_pct: 0.0,
escalation_count: 0,
message_count: 0,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let path = generate_retrospective(tmp.path(), &stats).unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("| No tasks recorded | - | - | - | - | - |"));
assert!(content.contains("- Average cycle time: -"));
assert!(content.contains("- Fastest task: -"));
assert!(content.contains("- Longest task: -"));
assert!(content.contains("- Longest task: no completed tasks recorded."));
assert!(content.contains("- Most retried: no task needed multiple attempts."));
}
#[test]
fn generate_retrospective_adds_high_idle_recommendation() {
let tmp = tempdir().unwrap();
let stats = RunStats {
run_start: 10,
run_end: 30,
total_duration_secs: 20,
task_stats: vec![sample_task("T-201", Some(20), 1)],
average_cycle_time_secs: Some(20),
fastest_task_id: Some("T-201".to_string()),
fastest_cycle_time_secs: Some(20),
longest_task_id: Some("T-201".to_string()),
longest_cycle_time_secs: Some(20),
idle_time_pct: 0.75,
escalation_count: 0,
message_count: 1,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let path = generate_retrospective(tmp.path(), &stats).unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("Idle time stayed high"));
assert!(content.contains("Queue more ready tasks"));
}
#[test]
fn generate_retrospective_adds_high_retry_recommendation() {
let tmp = tempdir().unwrap();
let stats = RunStats {
run_start: 10,
run_end: 40,
total_duration_secs: 30,
task_stats: vec![sample_task("T-301", Some(25), 3)],
average_cycle_time_secs: Some(25),
fastest_task_id: Some("T-301".to_string()),
fastest_cycle_time_secs: Some(25),
longest_task_id: Some("T-301".to_string()),
longest_cycle_time_secs: Some(25),
idle_time_pct: 0.1,
escalation_count: 0,
message_count: 2,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let path = generate_retrospective(tmp.path(), &stats).unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("Several retries were needed"));
assert!(content.contains("smaller tasks"));
}
fn write_owned_task_file(
project_root: &Path,
task_id: u32,
title: &str,
status: &str,
claimed_by: &str,
) {
let board_dir = project_root
.join(".batty")
.join("team_config")
.join("board");
let tasks_dir = board_dir.join("tasks");
fs::create_dir_all(&tasks_dir).unwrap();
let slug = title.replace(' ', "-");
let task_path = tasks_dir.join(format!("{task_id:03}-{slug}.md"));
let content = format!(
r#"---
id: {task_id}
title: "{title}"
status: {status}
claimed_by: {claimed_by}
---
Task body.
"#
);
fs::write(task_path, content).unwrap();
}
fn write_event_log(project_root: &Path, events: &[TeamEvent]) {
let events_path = project_root
.join(".batty")
.join("team_config")
.join("events.jsonl");
fs::create_dir_all(events_path.parent().unwrap()).unwrap();
let body = events
.iter()
.map(|event| serde_json::to_string(event).unwrap())
.collect::<Vec<_>>()
.join("\n");
fs::write(events_path, format!("{body}\n")).unwrap();
}
#[test]
fn should_generate_retro_when_all_active_tasks_are_done() {
let tmp = tempdir().unwrap();
write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
write_event_log(
tmp.path(),
&[
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "45"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped(), 160),
],
);
let stats = should_generate_retro(tmp.path(), false, 60)
.unwrap()
.unwrap();
assert_eq!(stats.run_start, 100);
assert_eq!(stats.run_end, 160);
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].task_id, "45");
}
#[test]
fn should_not_generate_retro_when_task_is_not_done() {
let tmp = tempdir().unwrap();
write_owned_task_file(tmp.path(), 45, "retro-task", "in-progress", "eng-1");
write_event_log(tmp.path(), &[at(TeamEvent::daemon_started(), 100)]);
let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
assert_eq!(stats, None);
}
#[test]
fn should_not_generate_retro_twice() {
let tmp = tempdir().unwrap();
write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
write_event_log(
tmp.path(),
&[
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "45"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped(), 160),
],
);
let stats = should_generate_retro(tmp.path(), true, 60).unwrap();
assert_eq!(stats, None);
}
#[test]
fn skip_retro_for_short_run() {
let tmp = tempdir().unwrap();
write_owned_task_file(tmp.path(), 50, "short-task", "done", "eng-1");
write_event_log(
tmp.path(),
&[
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::daemon_stopped(), 104),
],
);
let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
assert_eq!(stats, None);
}
#[test]
fn generate_retro_for_long_run() {
let tmp = tempdir().unwrap();
write_owned_task_file(tmp.path(), 51, "long-task", "done", "eng-1");
write_event_log(
tmp.path(),
&[
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "51"), 110),
at(TeamEvent::task_completed("eng-1", None), 200),
at(TeamEvent::task_assigned("eng-1", "52"), 210),
at(TeamEvent::task_completed("eng-1", None), 300),
at(TeamEvent::task_assigned("eng-1", "53"), 310),
at(TeamEvent::task_completed("eng-1", None), 380),
at(TeamEvent::daemon_stopped(), 400),
],
);
let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
assert!(stats.is_some());
let stats = stats.unwrap();
assert_eq!(stats.total_duration_secs, 300);
}
#[test]
fn skip_retro_for_short_run_with_completions() {
let tmp = tempdir().unwrap();
write_owned_task_file(tmp.path(), 55, "quick-task", "done", "eng-1");
write_event_log(
tmp.path(),
&[
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "55"), 105),
at(TeamEvent::task_completed("eng-1", None), 115),
at(TeamEvent::task_assigned("eng-1", "56"), 118),
at(TeamEvent::task_completed("eng-1", None), 125),
at(TeamEvent::daemon_stopped(), 130),
],
);
let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
assert!(stats.is_some());
let stats = stats.unwrap();
assert_eq!(stats.total_duration_secs, 30);
}
#[test]
fn analyze_events_computes_review_stall_duration() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(
TeamEvent::task_assigned("eng-1", "Task #10: fast task"),
110,
),
at(TeamEvent::task_completed("eng-1", None), 150),
at(
TeamEvent::task_auto_merged("eng-1", "Task #10: fast task", 0.9, 2, 10),
180,
),
at(
TeamEvent::task_assigned("eng-2", "Task #20: slow task"),
120,
),
at(TeamEvent::task_completed("eng-2", None), 200),
at(TeamEvent::task_manual_merged("Task #20: slow task"), 300),
at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.auto_merge_count, 1);
assert_eq!(stats.manual_merge_count, 1);
assert_eq!(stats.avg_review_stall_secs, Some(65));
assert_eq!(stats.max_review_stall_secs, Some(100));
assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
}
#[test]
fn analyze_events_no_stall_without_merges() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.avg_review_stall_secs, None);
assert_eq!(stats.max_review_stall_secs, None);
assert_eq!(stats.max_review_stall_task, None);
}
#[test]
fn analyze_events_tracks_per_task_rework() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "Task #10: reworked"), 110),
at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 120),
at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 130),
at(TeamEvent::task_assigned("eng-2", "Task #20: once"), 115),
at(TeamEvent::task_reworked("eng-2", "Task #20: once"), 140),
at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.rework_count, 3);
assert_eq!(stats.task_rework_counts.len(), 2);
assert_eq!(stats.task_rework_counts[0], ("10".to_string(), 2));
assert_eq!(stats.task_rework_counts[1], ("20".to_string(), 1));
}
#[test]
fn analyze_events_empty_rework_list() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
];
let stats = analyze_events(&events).unwrap();
assert!(stats.task_rework_counts.is_empty());
}
#[test]
fn render_review_pipeline_section_includes_stall_and_rework() {
let tmp = tempdir().unwrap();
let stats = RunStats {
run_start: 100,
run_end: 500,
total_duration_secs: 400,
task_stats: Vec::new(),
average_cycle_time_secs: None,
fastest_task_id: None,
fastest_cycle_time_secs: None,
longest_task_id: None,
longest_cycle_time_secs: None,
idle_time_pct: 0.0,
escalation_count: 0,
message_count: 0,
auto_merge_count: 3,
manual_merge_count: 1,
rework_count: 2,
review_nudge_count: 1,
review_escalation_count: 0,
avg_review_stall_secs: Some(90),
max_review_stall_secs: Some(180),
max_review_stall_task: Some("T-5".to_string()),
task_rework_counts: vec![("T-5".to_string(), 2)],
};
let path = generate_retrospective(tmp.path(), &stats).unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("## Review Pipeline"));
assert!(content.contains("Auto-merged: 3"));
assert!(content.contains("Manually merged: 1"));
assert!(content.contains("Auto-merge rate: 75%"));
assert!(content.contains("Avg review stall: 1m 30s"));
assert!(content.contains("Max review stall: 3m 00s (T-5)"));
assert!(content.contains("Rework cycles: 2"));
assert!(content.contains("### Rework by Task"));
assert!(content.contains("| T-5 | 2 |"));
}
#[test]
fn render_review_pipeline_no_stall_data() {
let tmp = tempdir().unwrap();
let stats = RunStats {
run_start: 100,
run_end: 300,
total_duration_secs: 200,
task_stats: Vec::new(),
average_cycle_time_secs: None,
fastest_task_id: None,
fastest_cycle_time_secs: None,
longest_task_id: None,
longest_cycle_time_secs: None,
idle_time_pct: 0.0,
escalation_count: 0,
message_count: 0,
auto_merge_count: 2,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let path = generate_retrospective(tmp.path(), &stats).unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("## Review Pipeline"));
assert!(content.contains("Avg review stall: -"));
assert!(content.contains("Max review stall: -"));
assert!(!content.contains("### Rework by Task"));
}
#[test]
fn task_reference_extracts_id_from_task_prefix() {
assert_eq!(task_reference("Task #42: build feature"), "42");
}
#[test]
fn task_reference_returns_full_line_when_no_prefix() {
assert_eq!(task_reference("build feature"), "build feature");
}
#[test]
fn task_reference_skips_blank_lines() {
assert_eq!(task_reference("\n\n Task #99: test\nbody"), "99");
}
#[test]
fn task_reference_handles_whitespace_only_input() {
assert_eq!(task_reference(" "), "");
}
#[test]
fn task_id_from_assignment_line_valid() {
assert_eq!(
task_id_from_assignment_line("Task #123: some task"),
Some("123".to_string())
);
}
#[test]
fn task_id_from_assignment_line_no_prefix() {
assert_eq!(task_id_from_assignment_line("no prefix here"), None);
}
#[test]
fn task_id_from_assignment_line_empty_digits() {
assert_eq!(task_id_from_assignment_line("Task #abc: letters"), None);
}
#[test]
fn cycle_time_metrics_no_completed_tasks() {
let tasks = vec![sample_task("T-1", None, 1)];
let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
assert_eq!(avg, None);
assert_eq!(fastest, None);
assert_eq!(fastest_time, None);
assert_eq!(longest, None);
assert_eq!(longest_time, None);
}
#[test]
fn cycle_time_metrics_single_completed_task() {
let tasks = vec![sample_task("T-1", Some(60), 1)];
let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
assert_eq!(avg, Some(60));
assert_eq!(fastest, Some("T-1".to_string()));
assert_eq!(fastest_time, Some(60));
assert_eq!(longest, Some("T-1".to_string()));
assert_eq!(longest_time, Some(60));
}
#[test]
fn cycle_time_metrics_multiple_tasks_picks_extremes() {
let tasks = vec![
sample_task("T-fast", Some(10), 1),
sample_task("T-mid", Some(50), 1),
sample_task("T-slow", Some(90), 1),
sample_task("T-incomplete", None, 1),
];
let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
assert_eq!(avg, Some(50)); assert_eq!(fastest, Some("T-fast".to_string()));
assert_eq!(fastest_time, Some(10));
assert_eq!(longest, Some("T-slow".to_string()));
assert_eq!(longest_time, Some(90));
}
#[test]
fn format_duration_zero() {
assert_eq!(format_duration(0), "0s");
}
#[test]
fn format_duration_exact_minute() {
assert_eq!(format_duration(60), "1m 00s");
}
#[test]
fn format_duration_exact_hour() {
assert_eq!(format_duration(3600), "1h 00m 00s");
}
#[test]
fn format_duration_large() {
assert_eq!(format_duration(7322), "2h 02m 02s");
}
#[test]
fn render_task_cycle_rows_empty() {
let rows = render_task_cycle_rows(&[]);
assert!(rows.contains("No tasks recorded"));
}
#[test]
fn render_task_cycle_rows_completed_and_incomplete() {
let tasks = vec![
sample_task("T-1", Some(120), 1),
sample_task("T-2", None, 2),
];
let rows = render_task_cycle_rows(&tasks);
assert!(rows.contains("| T-1 | eng-1 | completed | 2m 00s | 1 | no |"));
assert!(rows.contains("| T-2 | eng-1 | incomplete | - | 2 | no |"));
}
#[test]
fn render_task_cycle_rows_escalated_task() {
let tasks = vec![sample_task("T-esc", Some(200), 4)]; let rows = render_task_cycle_rows(&tasks);
assert!(rows.contains("| T-esc | eng-1 | completed | 3m 20s | 4 | yes |"));
}
#[test]
fn render_bottlenecks_no_completed_tasks() {
let tasks = vec![sample_task("T-1", None, 1)];
let output = render_bottlenecks(&tasks);
assert!(output.contains("no completed tasks recorded"));
assert!(output.contains("no task needed multiple attempts"));
}
#[test]
fn render_bottlenecks_with_retries() {
let tasks = vec![
sample_task("T-1", Some(100), 1),
sample_task("T-2", Some(200), 3),
];
let output = render_bottlenecks(&tasks);
assert!(output.contains("Longest task: `T-2`"));
assert!(output.contains("Most retried: `T-2` retried 3 times"));
}
#[test]
fn render_bottlenecks_single_retry_shows_no_retries_message() {
let tasks = vec![sample_task("T-1", Some(60), 1)];
let output = render_bottlenecks(&tasks);
assert!(output.contains("no task needed multiple attempts"));
}
#[test]
fn render_recommendations_low_idle_low_retries() {
let stats = RunStats {
run_start: 0,
run_end: 100,
total_duration_secs: 100,
task_stats: vec![sample_task("T-1", Some(50), 1)],
average_cycle_time_secs: Some(50),
fastest_task_id: Some("T-1".to_string()),
fastest_cycle_time_secs: Some(50),
longest_task_id: Some("T-1".to_string()),
longest_cycle_time_secs: Some(50),
idle_time_pct: 0.1,
escalation_count: 0,
message_count: 1,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let output = render_recommendations(&stats);
assert!(output.contains("No major bottlenecks"));
}
#[test]
fn render_recommendations_both_high_idle_and_high_retries() {
let stats = RunStats {
run_start: 0,
run_end: 100,
total_duration_secs: 100,
task_stats: vec![sample_task("T-1", Some(50), 5)],
average_cycle_time_secs: Some(50),
fastest_task_id: Some("T-1".to_string()),
fastest_cycle_time_secs: Some(50),
longest_task_id: Some("T-1".to_string()),
longest_cycle_time_secs: Some(50),
idle_time_pct: 0.8,
escalation_count: 0,
message_count: 1,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let output = render_recommendations(&stats);
assert!(output.contains("Idle time stayed high"));
assert!(output.contains("Several retries were needed"));
}
#[test]
fn render_review_performance_empty_when_no_merges() {
let stats = RunStats {
run_start: 0,
run_end: 100,
total_duration_secs: 100,
task_stats: Vec::new(),
average_cycle_time_secs: None,
fastest_task_id: None,
fastest_cycle_time_secs: None,
longest_task_id: None,
longest_cycle_time_secs: None,
idle_time_pct: 0.0,
escalation_count: 0,
message_count: 0,
auto_merge_count: 0,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let section = render_review_performance(&stats);
assert!(section.is_empty());
}
#[test]
fn render_review_performance_100_percent_auto_merge_rate() {
let stats = RunStats {
run_start: 0,
run_end: 100,
total_duration_secs: 100,
task_stats: Vec::new(),
average_cycle_time_secs: None,
fastest_task_id: None,
fastest_cycle_time_secs: None,
longest_task_id: None,
longest_cycle_time_secs: None,
idle_time_pct: 0.0,
escalation_count: 0,
message_count: 0,
auto_merge_count: 5,
manual_merge_count: 0,
rework_count: 0,
review_nudge_count: 0,
review_escalation_count: 0,
avg_review_stall_secs: None,
max_review_stall_secs: None,
max_review_stall_task: None,
task_rework_counts: Vec::new(),
};
let section = render_review_performance(&stats);
assert!(section.contains("Auto-merge rate: 100%"));
assert!(section.contains("Auto-merged: 5"));
assert!(section.contains("Manually merged: 0"));
}
#[test]
fn analyze_events_multiple_tasks_different_engineers() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "Task #10: task-a"), 110),
at(TeamEvent::task_assigned("eng-2", "Task #20: task-b"), 115),
at(TeamEvent::task_completed("eng-1", None), 160),
at(TeamEvent::task_completed("eng-2", None), 200),
at(TeamEvent::daemon_stopped_with_reason("signal", 110), 210),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.task_stats.len(), 2);
let t10 = stats.task_stats.iter().find(|t| t.task_id == "10").unwrap();
assert_eq!(t10.assigned_to, "eng-1");
assert_eq!(t10.cycle_time_secs, Some(50));
let t20 = stats.task_stats.iter().find(|t| t.task_id == "20").unwrap();
assert_eq!(t20.assigned_to, "eng-2");
assert_eq!(t20.cycle_time_secs, Some(85));
}
#[test]
fn analyze_events_tracks_review_nudges_and_escalations() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(
TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
120,
),
at(
TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
140,
),
at(
TeamEvent::review_escalated("Task #5: reviewed", "stale"),
160,
),
at(TeamEvent::daemon_stopped_with_reason("signal", 80), 180),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.review_nudge_count, 2);
assert_eq!(stats.review_escalation_count, 1);
}
#[test]
fn analyze_events_completion_without_assignment_is_ignored() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
];
let stats = analyze_events(&events).unwrap();
assert!(stats.task_stats.is_empty());
assert_eq!(stats.average_cycle_time_secs, None);
}
#[test]
fn analyze_events_escalation_without_prior_assignment_creates_task() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(
TeamEvent::task_escalated("eng-1", "Task #99: escalated-only", None),
120,
),
at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.escalation_count, 1);
assert_eq!(stats.task_stats.len(), 1);
assert!(stats.task_stats[0].was_escalated);
assert_eq!(stats.task_stats[0].task_id, "Task #99: escalated-only");
}
#[test]
fn analyze_events_daemon_started_only() {
let events = vec![at(TeamEvent::daemon_started(), 100)];
let stats = analyze_events(&events).unwrap();
assert_eq!(stats.run_start, 100);
assert_eq!(stats.run_end, 100);
assert_eq!(stats.total_duration_secs, 0);
assert!(stats.task_stats.is_empty());
}
#[test]
fn analyze_events_load_snapshot_all_working() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::load_snapshot(4, 4, true), 110),
at(TeamEvent::load_snapshot(4, 4, true), 120),
at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
];
let stats = analyze_events(&events).unwrap();
assert!((stats.idle_time_pct - 0.0).abs() < 1e-9);
}
#[test]
fn analyze_events_load_snapshot_all_idle() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::load_snapshot(0, 4, true), 110),
at(TeamEvent::load_snapshot(0, 4, true), 120),
at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
];
let stats = analyze_events(&events).unwrap();
assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
}
#[test]
fn analyze_events_load_snapshot_zero_members() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::load_snapshot(0, 0, true), 110),
at(TeamEvent::daemon_stopped_with_reason("signal", 20), 120),
];
let stats = analyze_events(&events).unwrap();
assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
}
#[test]
fn should_generate_retro_no_board_dir_returns_none() {
let tmp = tempdir().unwrap();
let result = should_generate_retro(tmp.path(), false, 60).unwrap();
assert_eq!(result, None);
}
#[test]
fn should_generate_retro_empty_board_returns_none() {
let tmp = tempdir().unwrap();
let tasks_dir = tmp
.path()
.join(".batty")
.join("team_config")
.join("board")
.join("tasks");
fs::create_dir_all(&tasks_dir).unwrap();
let result = should_generate_retro(tmp.path(), false, 60).unwrap();
assert_eq!(result, None);
}
use crate::team::telemetry_db;
fn populate_basic_run_db(conn: &Connection) {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::message_routed("manager", "eng-1"), 115),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
];
for event in &events {
telemetry_db::insert_event(conn, event).unwrap();
}
}
#[test]
fn retro_with_telemetry_db() {
let conn = telemetry_db::open_in_memory().unwrap();
populate_basic_run_db(&conn);
let stats = analyze_from_db(&conn).unwrap();
assert_eq!(stats.run_start, 100);
assert_eq!(stats.run_end, 160);
assert_eq!(stats.total_duration_secs, 60);
assert_eq!(stats.message_count, 1);
assert_eq!(stats.escalation_count, 0);
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].task_id, "42");
assert_eq!(stats.task_stats[0].assigned_to, "eng-1");
assert_eq!(stats.task_stats[0].cycle_time_secs, Some(40));
assert_eq!(stats.average_cycle_time_secs, Some(40));
assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
}
#[test]
fn retro_from_db_matches_jsonl_analysis() {
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::message_routed("manager", "eng-1"), 115),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
];
let jsonl_stats = analyze_events(&events).unwrap();
let conn = telemetry_db::open_in_memory().unwrap();
for event in &events {
telemetry_db::insert_event(&conn, event).unwrap();
}
let db_stats = analyze_from_db(&conn).unwrap();
assert_eq!(jsonl_stats.run_start, db_stats.run_start);
assert_eq!(jsonl_stats.run_end, db_stats.run_end);
assert_eq!(
jsonl_stats.total_duration_secs,
db_stats.total_duration_secs
);
assert_eq!(jsonl_stats.task_stats.len(), db_stats.task_stats.len());
assert_eq!(
jsonl_stats.average_cycle_time_secs,
db_stats.average_cycle_time_secs
);
assert_eq!(jsonl_stats.fastest_task_id, db_stats.fastest_task_id);
assert_eq!(jsonl_stats.longest_task_id, db_stats.longest_task_id);
assert_eq!(jsonl_stats.escalation_count, db_stats.escalation_count);
assert_eq!(jsonl_stats.message_count, db_stats.message_count);
assert_eq!(jsonl_stats.idle_time_pct, db_stats.idle_time_pct);
assert_eq!(jsonl_stats.auto_merge_count, db_stats.auto_merge_count);
assert_eq!(jsonl_stats.manual_merge_count, db_stats.manual_merge_count);
assert_eq!(jsonl_stats.rework_count, db_stats.rework_count);
}
#[test]
fn retro_without_db_falls_back() {
let tmp = tempdir().unwrap();
let events_dir = tmp.path().join(".batty").join("team_config");
fs::create_dir_all(&events_dir).unwrap();
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "42"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped(), 160),
];
write_event_log(tmp.path(), &events);
let stats = analyze_project(tmp.path()).unwrap().unwrap();
assert_eq!(stats.run_start, 100);
assert_eq!(stats.run_end, 160);
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].task_id, "42");
}
#[test]
fn retro_report_format_unchanged() {
let conn = telemetry_db::open_in_memory().unwrap();
populate_basic_run_db(&conn);
let stats = analyze_from_db(&conn).unwrap();
let report = render_retrospective(&stats);
assert!(report.contains("# Batty Retrospective"));
assert!(report.contains("## Summary"));
assert!(report.contains("## Task Cycle Times"));
assert!(report.contains("## Bottlenecks"));
assert!(report.contains("## Recommendations"));
assert!(report.contains("- Tasks completed: 1"));
assert!(report.contains("- Average cycle time: 40s"));
assert!(report.contains("| 42 | eng-1 | completed | 40s | 1 | no |"));
}
#[test]
fn retro_from_db_empty_returns_none() {
let conn = telemetry_db::open_in_memory().unwrap();
assert!(analyze_from_db(&conn).is_none());
}
#[test]
fn retro_from_db_with_retries_and_escalations() {
let conn = telemetry_db::open_in_memory().unwrap();
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(
TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
110,
),
at(
TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
130,
),
at(TeamEvent::task_escalated("eng-1", "42", None), 135),
at(TeamEvent::task_completed("eng-1", None), 170),
at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
];
for event in &events {
telemetry_db::insert_event(&conn, event).unwrap();
}
let stats = analyze_from_db(&conn).unwrap();
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].task_id, "42");
assert_eq!(stats.task_stats[0].retry_count, 2);
assert!(stats.task_stats[0].was_escalated);
assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
assert_eq!(stats.escalation_count, 1);
}
#[test]
fn retro_from_db_with_review_pipeline() {
let conn = telemetry_db::open_in_memory().unwrap();
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "Task #10: fast"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(
TeamEvent::task_auto_merged("eng-1", "Task #10: fast", 0.9, 2, 10),
180,
),
at(TeamEvent::task_assigned("eng-2", "Task #20: slow"), 120),
at(TeamEvent::task_completed("eng-2", None), 200),
at(TeamEvent::task_manual_merged("Task #20: slow"), 300),
at(TeamEvent::task_reworked("eng-1", "Task #10: fast"), 145),
at(
TeamEvent::review_nudge_sent("manager", "Task #10: fast"),
155,
),
at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
];
for event in &events {
telemetry_db::insert_event(&conn, event).unwrap();
}
let stats = analyze_from_db(&conn).unwrap();
assert_eq!(stats.auto_merge_count, 1);
assert_eq!(stats.manual_merge_count, 1);
assert_eq!(stats.rework_count, 1);
assert_eq!(stats.review_nudge_count, 1);
assert_eq!(stats.avg_review_stall_secs, Some(65));
assert_eq!(stats.max_review_stall_secs, Some(100));
assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
assert_eq!(stats.task_rework_counts, vec![("10".to_string(), 1)]);
}
#[test]
fn retro_from_db_multiple_runs_uses_last() {
let conn = telemetry_db::open_in_memory().unwrap();
let events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
at(TeamEvent::daemon_started(), 200),
at(TeamEvent::task_assigned("eng-2", "Task #12: new-task"), 210),
at(TeamEvent::task_completed("eng-2", None), 240),
at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
];
for event in &events {
telemetry_db::insert_event(&conn, event).unwrap();
}
let stats = analyze_from_db(&conn).unwrap();
assert_eq!(stats.run_start, 200);
assert_eq!(stats.run_end, 245);
assert_eq!(stats.task_stats.len(), 1);
assert_eq!(stats.task_stats[0].task_id, "12");
}
#[test]
fn analyze_project_prefers_db_over_jsonl() {
let tmp = tempdir().unwrap();
let jsonl_events = vec![
at(TeamEvent::daemon_started(), 100),
at(TeamEvent::task_assigned("eng-1", "99"), 110),
at(TeamEvent::task_completed("eng-1", None), 150),
at(TeamEvent::daemon_stopped(), 160),
];
write_event_log(tmp.path(), &jsonl_events);
let db_path = tmp.path().join(".batty");
fs::create_dir_all(&db_path).unwrap();
let conn = telemetry_db::open(tmp.path()).unwrap();
let db_events = vec![
at(TeamEvent::daemon_started(), 200),
at(TeamEvent::task_assigned("eng-1", "42"), 210),
at(TeamEvent::task_completed("eng-1", None), 250),
at(TeamEvent::daemon_stopped(), 260),
];
for event in &db_events {
telemetry_db::insert_event(&conn, event).unwrap();
}
drop(conn);
let stats = analyze_project(tmp.path()).unwrap().unwrap();
assert_eq!(stats.task_stats[0].task_id, "42");
}
}