Skip to main content

batty_cli/team/
session.rs

1//! Session lifecycle: pause/resume, nudge management, stop/attach/status/validate.
2//!
3//! Extracted from `lifecycle.rs` — pure refactor, zero logic changes.
4
5use std::path::Path;
6use std::path::PathBuf;
7
8use anyhow::{Context, Result, bail};
9use tracing::{info, warn};
10
11use super::daemon_mgmt::{
12    DAEMON_SHUTDOWN_GRACE_PERIOD, force_kill_daemon, request_graceful_daemon_shutdown,
13    resume_marker_path,
14};
15use super::{
16    config, estimation, events, hierarchy, now_unix, status, team_config_path, team_events_path,
17};
18use crate::tmux;
19
20/// Path to the pause marker file. Presence pauses nudges and standups.
21pub fn pause_marker_path(project_root: &Path) -> PathBuf {
22    project_root.join(".batty").join("paused")
23}
24
25/// Create the pause marker file, pausing nudges and standups.
26pub fn pause_team(project_root: &Path) -> Result<()> {
27    let marker = pause_marker_path(project_root);
28    if marker.exists() {
29        bail!("Team is already paused.");
30    }
31    if let Some(parent) = marker.parent() {
32        std::fs::create_dir_all(parent).ok();
33    }
34    std::fs::write(&marker, "").context("failed to write pause marker")?;
35    info!("paused nudges and standups");
36    Ok(())
37}
38
39/// Remove the pause marker file, resuming nudges and standups.
40pub fn resume_team(project_root: &Path) -> Result<()> {
41    let marker = pause_marker_path(project_root);
42    if !marker.exists() {
43        bail!("Team is not paused.");
44    }
45    std::fs::remove_file(&marker).context("failed to remove pause marker")?;
46    info!("resumed nudges and standups");
47    Ok(())
48}
49
50/// Path to the nudge-disabled marker for a given intervention.
51pub fn nudge_disabled_marker_path(project_root: &Path, intervention: &str) -> PathBuf {
52    project_root
53        .join(".batty")
54        .join(format!("nudge_{intervention}_disabled"))
55}
56
57/// Create a nudge-disabled marker file, disabling the intervention at runtime.
58pub fn disable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
59    let marker = nudge_disabled_marker_path(project_root, intervention);
60    if marker.exists() {
61        bail!("Intervention '{intervention}' is already disabled.");
62    }
63    if let Some(parent) = marker.parent() {
64        std::fs::create_dir_all(parent).ok();
65    }
66    std::fs::write(&marker, "").context("failed to write nudge disabled marker")?;
67    info!(intervention, "disabled intervention");
68    Ok(())
69}
70
71/// Remove a nudge-disabled marker file, re-enabling the intervention.
72pub fn enable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
73    let marker = nudge_disabled_marker_path(project_root, intervention);
74    if !marker.exists() {
75        bail!("Intervention '{intervention}' is not disabled.");
76    }
77    std::fs::remove_file(&marker).context("failed to remove nudge disabled marker")?;
78    info!(intervention, "enabled intervention");
79    Ok(())
80}
81
82/// Print a table showing config, runtime, and effective state for each intervention.
83pub fn nudge_status(project_root: &Path) -> Result<()> {
84    use crate::cli::NudgeIntervention;
85
86    let config_path = team_config_path(project_root);
87    let automation = if config_path.exists() {
88        let team_config = config::TeamConfig::load(&config_path)?;
89        Some(team_config.automation)
90    } else {
91        None
92    };
93
94    println!(
95        "{:<16} {:<10} {:<10} {:<10}",
96        "INTERVENTION", "CONFIG", "RUNTIME", "EFFECTIVE"
97    );
98
99    for intervention in NudgeIntervention::ALL {
100        let name = intervention.marker_name();
101        let config_enabled = automation
102            .as_ref()
103            .map(|a| match intervention {
104                NudgeIntervention::Replenish => true, // no dedicated config flag
105                NudgeIntervention::Triage => a.triage_interventions,
106                NudgeIntervention::Review => a.review_interventions,
107                NudgeIntervention::Dispatch => a.manager_dispatch_interventions,
108                NudgeIntervention::Utilization => a.architect_utilization_interventions,
109                NudgeIntervention::OwnedTask => a.owned_task_interventions,
110            })
111            .unwrap_or(true);
112
113        let runtime_disabled = nudge_disabled_marker_path(project_root, name).exists();
114        let runtime_str = if runtime_disabled {
115            "disabled"
116        } else {
117            "enabled"
118        };
119        let config_str = if config_enabled {
120            "enabled"
121        } else {
122            "disabled"
123        };
124        let effective = config_enabled && !runtime_disabled;
125        let effective_str = if effective { "enabled" } else { "DISABLED" };
126
127        println!(
128            "{:<16} {:<10} {:<10} {:<10}",
129            name, config_str, runtime_str, effective_str
130        );
131    }
132
133    Ok(())
134}
135
136/// Stop a running team session and clean up any orphaned `batty-` sessions.
137/// Summary statistics for a completed session.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub(crate) struct SessionSummary {
140    pub tasks_completed: u32,
141    pub tasks_merged: u32,
142    pub runtime_secs: u64,
143}
144
145impl SessionSummary {
146    pub fn display(&self) -> String {
147        format!(
148            "Session summary: {} tasks completed, {} merged, runtime {}\nBatty v{} — https://github.com/battysh/batty",
149            self.tasks_completed,
150            self.tasks_merged,
151            format_runtime(self.runtime_secs),
152            env!("CARGO_PKG_VERSION"),
153        )
154    }
155}
156
157fn format_runtime(secs: u64) -> String {
158    if secs < 60 {
159        format!("{secs}s")
160    } else if secs < 3600 {
161        format!("{}m", secs / 60)
162    } else {
163        let hours = secs / 3600;
164        let mins = (secs % 3600) / 60;
165        if mins == 0 {
166            format!("{hours}h")
167        } else {
168            format!("{hours}h {mins}m")
169        }
170    }
171}
172
173/// Compute session summary from the event log.
174///
175/// Finds the most recent `daemon_started` event and counts completions and
176/// merges that occurred after it. Runtime is calculated from the daemon start
177/// timestamp to now.
178pub(crate) fn compute_session_summary(project_root: &Path) -> Option<SessionSummary> {
179    let events_path = team_events_path(project_root);
180    let all_events = events::read_events(&events_path).ok()?;
181
182    // Find the most recent daemon_started event.
183    let session_start = all_events
184        .iter()
185        .rev()
186        .find(|e| e.event == "daemon_started")?;
187    let start_ts = session_start.ts;
188    let now_ts = now_unix();
189
190    let session_events: Vec<_> = all_events.iter().filter(|e| e.ts >= start_ts).collect();
191
192    let tasks_completed = session_events
193        .iter()
194        .filter(|e| e.event == "task_completed")
195        .count() as u32;
196
197    let tasks_merged = session_events
198        .iter()
199        .filter(|e| e.event == "task_auto_merged" || e.event == "task_manual_merged")
200        .count() as u32;
201
202    let runtime_secs = now_ts.saturating_sub(start_ts);
203
204    Some(SessionSummary {
205        tasks_completed,
206        tasks_merged,
207        runtime_secs,
208    })
209}
210
211pub fn stop_team(project_root: &Path) -> Result<()> {
212    // Compute session summary before shutting down (events log is still available).
213    let summary = compute_session_summary(project_root);
214
215    // Write resume marker before tearing down — agents have sessions to continue
216    let marker = resume_marker_path(project_root);
217    if let Some(parent) = marker.parent() {
218        std::fs::create_dir_all(parent).ok();
219    }
220    std::fs::write(&marker, "").ok();
221
222    // Ask the daemon to persist a final clean snapshot before the tmux session is torn down.
223    if !request_graceful_daemon_shutdown(project_root, DAEMON_SHUTDOWN_GRACE_PERIOD) {
224        warn!("daemon did not stop gracefully; forcing shutdown");
225        force_kill_daemon(project_root);
226    }
227
228    let config_path = team_config_path(project_root);
229    let primary_session = if config_path.exists() {
230        let team_config = config::TeamConfig::load(&config_path)?;
231        Some(format!("batty-{}", team_config.name))
232    } else {
233        None
234    };
235
236    // Kill only the session belonging to this project
237    match &primary_session {
238        Some(session) if tmux::session_exists(session) => {
239            tmux::kill_session(session)?;
240            info!(session = %session, "team session stopped");
241        }
242        Some(session) => {
243            info!(session = %session, "no running session to stop");
244        }
245        None => {
246            bail!("no team config found at {}", config_path.display());
247        }
248    }
249
250    // Print session summary after teardown.
251    if let Some(summary) = summary {
252        println!();
253        println!("{}", summary.display());
254    }
255
256    Ok(())
257}
258
259/// Attach to a running team session.
260///
261/// First tries the team config in the project root. If not found, looks for
262/// any running `batty-*` tmux session and attaches to it.
263pub fn attach_team(project_root: &Path) -> Result<()> {
264    let config_path = team_config_path(project_root);
265
266    let session = if config_path.exists() {
267        let team_config = config::TeamConfig::load(&config_path)?;
268        format!("batty-{}", team_config.name)
269    } else {
270        // No local config — find any running batty session
271        let mut sessions = tmux::list_sessions_with_prefix("batty-");
272        match sessions.len() {
273            0 => bail!("no team config found and no batty sessions running"),
274            1 => sessions.swap_remove(0),
275            _ => {
276                let list = sessions.join(", ");
277                bail!(
278                    "no team config found and multiple batty sessions running: {list}\n\
279                     Run from the project directory, or use: tmux attach -t <session>"
280                );
281            }
282        }
283    };
284
285    if !tmux::session_exists(&session) {
286        bail!("no running session '{session}'; run `batty start` first");
287    }
288
289    tmux::attach(&session)
290}
291
292/// Show team status.
293pub fn team_status(project_root: &Path, json: bool) -> Result<()> {
294    let config_path = team_config_path(project_root);
295    if !config_path.exists() {
296        bail!("no team config found at {}", config_path.display());
297    }
298
299    let team_config = config::TeamConfig::load(&config_path)?;
300    let members = hierarchy::resolve_hierarchy(&team_config)?;
301    let session = format!("batty-{}", team_config.name);
302    let session_running = tmux::session_exists(&session);
303    let runtime_statuses = if session_running {
304        match status::list_runtime_member_statuses(&session) {
305            Ok(statuses) => statuses,
306            Err(error) => {
307                warn!(session = %session, error = %error, "failed to read live runtime statuses");
308                std::collections::HashMap::new()
309            }
310        }
311    } else {
312        std::collections::HashMap::new()
313    };
314    let pending_inbox_counts = status::pending_inbox_counts(project_root, &members);
315    let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
316    let owned_task_buckets = status::owned_task_buckets(project_root, &members);
317    let agent_health = status::agent_health_by_member(project_root, &members);
318    let paused = pause_marker_path(project_root).exists();
319    let mut rows = status::build_team_status_rows(
320        &members,
321        session_running,
322        &runtime_statuses,
323        &pending_inbox_counts,
324        &triage_backlog_counts,
325        &owned_task_buckets,
326        &agent_health,
327    );
328
329    // Populate ETA estimates for members with active tasks.
330    let active_task_elapsed: Vec<(u32, u64)> = rows
331        .iter()
332        .filter(|row| !row.active_owned_tasks.is_empty())
333        .flat_map(|row| {
334            let elapsed = row.health.task_elapsed_secs.unwrap_or(0);
335            row.active_owned_tasks
336                .iter()
337                .map(move |&task_id| (task_id, elapsed))
338        })
339        .collect();
340    let etas = estimation::compute_etas(project_root, &active_task_elapsed);
341    for row in &mut rows {
342        if let Some(&task_id) = row.active_owned_tasks.first() {
343            if let Some(eta) = etas.get(&task_id) {
344                row.eta = eta.clone();
345            }
346        }
347    }
348
349    let workflow_metrics = status::workflow_metrics_section(project_root, &members);
350    let (active_tasks, review_queue) = match status::board_status_task_queues(project_root) {
351        Ok(queues) => queues,
352        Err(error) => {
353            warn!(error = %error, "failed to load board task queues for status json");
354            (Vec::new(), Vec::new())
355        }
356    };
357
358    if json {
359        let report = status::build_team_status_json_report(status::TeamStatusJsonReportInput {
360            team: team_config.name.clone(),
361            session: session.clone(),
362            session_running,
363            paused,
364            workflow_metrics: workflow_metrics
365                .as_ref()
366                .map(|(_, metrics)| metrics.clone()),
367            active_tasks,
368            review_queue,
369            members: rows,
370        });
371        println!("{}", serde_json::to_string_pretty(&report)?);
372    } else {
373        println!("Team: {}", team_config.name);
374        println!(
375            "Session: {} ({})",
376            session,
377            if session_running {
378                "running"
379            } else {
380                "stopped"
381            }
382        );
383        println!();
384        println!(
385            "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
386            "MEMBER",
387            "ROLE",
388            "AGENT",
389            "STATE",
390            "INBOX",
391            "TRIAGE",
392            "ACTIVE",
393            "REVIEW",
394            "ETA",
395            "HEALTH",
396            "SIGNAL",
397            "REPORTS TO"
398        );
399        println!("{}", "-".repeat(195));
400        for row in &rows {
401            println!(
402                "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
403                row.name,
404                row.role,
405                row.agent.as_deref().unwrap_or("-"),
406                row.state,
407                row.pending_inbox,
408                row.triage_backlog,
409                status::format_owned_tasks_summary(&row.active_owned_tasks),
410                status::format_owned_tasks_summary(&row.review_owned_tasks),
411                row.eta,
412                row.health_summary,
413                row.signal.as_deref().unwrap_or("-"),
414                row.reports_to.as_deref().unwrap_or("-"),
415            );
416        }
417        if let Some((formatted, _)) = workflow_metrics {
418            println!();
419            println!("{formatted}");
420        }
421    }
422
423    Ok(())
424}
425
426fn workflow_mode_declared(config_path: &Path) -> Result<bool> {
427    let content = std::fs::read_to_string(config_path)
428        .with_context(|| format!("failed to read {}", config_path.display()))?;
429    let value: serde_yaml::Value = serde_yaml::from_str(&content)
430        .with_context(|| format!("failed to parse {}", config_path.display()))?;
431    let Some(mapping) = value.as_mapping() else {
432        return Ok(false);
433    };
434
435    Ok(mapping.contains_key(serde_yaml::Value::String("workflow_mode".to_string())))
436}
437
438fn migration_validation_notes(
439    team_config: &config::TeamConfig,
440    workflow_mode_is_explicit: bool,
441) -> Vec<String> {
442    if !workflow_mode_is_explicit {
443        if team_config.orchestrator_pane
444            && matches!(team_config.workflow_mode, config::WorkflowMode::Hybrid)
445        {
446            return vec![
447                "Migration: workflow_mode omitted; orchestrator_pane=true promotes the team to hybrid mode so the orchestrator surface is active.".to_string(),
448            ];
449        }
450        return vec![
451            "Migration: workflow_mode omitted; defaulting to legacy so existing teams and boards run unchanged.".to_string(),
452        ];
453    }
454
455    match team_config.workflow_mode {
456        config::WorkflowMode::Legacy => vec![
457            "Migration: legacy mode selected; Batty keeps current runtime behavior and treats workflow metadata as optional.".to_string(),
458        ],
459        config::WorkflowMode::Hybrid => vec![
460            "Migration: hybrid mode selected; workflow adoption is incremental and legacy runtime behavior remains available.".to_string(),
461        ],
462        config::WorkflowMode::WorkflowFirst => vec![
463            "Migration: workflow_first mode selected; complete board metadata and orchestrator rollout before treating workflow state as primary truth.".to_string(),
464        ],
465    }
466}
467
468/// Validate team config without launching.
469pub fn validate_team(project_root: &Path, verbose: bool) -> Result<()> {
470    let config_path = team_config_path(project_root);
471    if !config_path.exists() {
472        bail!("no team config found at {}", config_path.display());
473    }
474
475    let team_config = config::TeamConfig::load(&config_path)?;
476
477    if verbose {
478        let checks = team_config.validate_verbose();
479        let mut any_failed = false;
480        for check in &checks {
481            let status = if check.passed { "PASS" } else { "FAIL" };
482            println!("[{status}] {}: {}", check.name, check.detail);
483            if !check.passed {
484                any_failed = true;
485            }
486        }
487        if any_failed {
488            bail!("validation failed — see FAIL checks above");
489        }
490    } else {
491        team_config.validate()?;
492    }
493
494    let workflow_mode_is_explicit = workflow_mode_declared(&config_path)?;
495
496    let members = hierarchy::resolve_hierarchy(&team_config)?;
497
498    println!("Config: {}", config_path.display());
499    println!("Team: {}", team_config.name);
500    println!(
501        "Workflow mode: {}",
502        match team_config.workflow_mode {
503            config::WorkflowMode::Legacy => "legacy",
504            config::WorkflowMode::Hybrid => "hybrid",
505            config::WorkflowMode::WorkflowFirst => "workflow_first",
506        }
507    );
508    println!("Roles: {}", team_config.roles.len());
509    println!("Total members: {}", members.len());
510
511    // Backend health checks — warn about missing binaries but don't fail validation.
512    let backend_warnings = team_config.check_backend_health();
513    for warning in &backend_warnings {
514        println!("[WARN] {warning}");
515    }
516
517    for note in migration_validation_notes(&team_config, workflow_mode_is_explicit) {
518        println!("{note}");
519    }
520    println!("Valid.");
521    Ok(())
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::team::TRIAGE_RESULT_FRESHNESS_SECONDS;
528    use crate::team::config::RoleType;
529    use crate::team::hierarchy;
530    use crate::team::inbox;
531    use crate::team::status;
532    use crate::team::team_config_dir;
533    use crate::team::team_config_path;
534    use serial_test::serial;
535
536    #[test]
537    fn nudge_disable_creates_marker_and_enable_removes_it() {
538        let tmp = tempfile::tempdir().unwrap();
539        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
540
541        let marker = nudge_disabled_marker_path(tmp.path(), "triage");
542        assert!(!marker.exists());
543
544        disable_nudge(tmp.path(), "triage").unwrap();
545        assert!(marker.exists());
546
547        // Double-disable should fail
548        assert!(disable_nudge(tmp.path(), "triage").is_err());
549
550        enable_nudge(tmp.path(), "triage").unwrap();
551        assert!(!marker.exists());
552
553        // Double-enable should fail
554        assert!(enable_nudge(tmp.path(), "triage").is_err());
555    }
556
557    #[test]
558    fn nudge_marker_path_uses_intervention_name() {
559        let root = std::path::Path::new("/tmp/test-project");
560        assert_eq!(
561            nudge_disabled_marker_path(root, "replenish"),
562            root.join(".batty").join("nudge_replenish_disabled")
563        );
564        assert_eq!(
565            nudge_disabled_marker_path(root, "owned-task"),
566            root.join(".batty").join("nudge_owned-task_disabled")
567        );
568    }
569
570    #[test]
571    fn nudge_multiple_interventions_independent() {
572        let tmp = tempfile::tempdir().unwrap();
573        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
574
575        disable_nudge(tmp.path(), "triage").unwrap();
576        disable_nudge(tmp.path(), "review").unwrap();
577
578        assert!(nudge_disabled_marker_path(tmp.path(), "triage").exists());
579        assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
580        assert!(!nudge_disabled_marker_path(tmp.path(), "dispatch").exists());
581
582        enable_nudge(tmp.path(), "triage").unwrap();
583        assert!(!nudge_disabled_marker_path(tmp.path(), "triage").exists());
584        assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
585    }
586
587    #[test]
588    fn pause_creates_marker_and_resume_removes_it() {
589        let tmp = tempfile::tempdir().unwrap();
590        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
591
592        assert!(!pause_marker_path(tmp.path()).exists());
593        pause_team(tmp.path()).unwrap();
594        assert!(pause_marker_path(tmp.path()).exists());
595
596        // Double-pause should fail
597        assert!(pause_team(tmp.path()).is_err());
598
599        resume_team(tmp.path()).unwrap();
600        assert!(!pause_marker_path(tmp.path()).exists());
601
602        // Double-resume should fail
603        assert!(resume_team(tmp.path()).is_err());
604    }
605
606    fn write_team_config(project_root: &std::path::Path, yaml: &str) {
607        std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
608        std::fs::write(team_config_path(project_root), yaml).unwrap();
609    }
610
611    #[test]
612    fn workflow_mode_declared_detects_absent_field() {
613        let tmp = tempfile::tempdir().unwrap();
614        write_team_config(
615            tmp.path(),
616            r#"
617name: test
618roles:
619  - name: engineer
620    role_type: engineer
621    agent: codex
622"#,
623        );
624
625        assert!(!workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
626    }
627
628    #[test]
629    fn workflow_mode_declared_detects_present_field() {
630        let tmp = tempfile::tempdir().unwrap();
631        write_team_config(
632            tmp.path(),
633            r#"
634name: test
635workflow_mode: hybrid
636roles:
637  - name: engineer
638    role_type: engineer
639    agent: codex
640"#,
641        );
642
643        assert!(workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
644    }
645
646    #[test]
647    fn migration_validation_notes_explain_legacy_default_for_older_configs() {
648        let config =
649            config::TeamConfig::load(std::path::Path::new("src/team/templates/team_pair.yaml"))
650                .unwrap();
651        let notes = migration_validation_notes(&config, false);
652
653        assert_eq!(notes.len(), 1);
654        assert!(notes[0].contains("workflow_mode omitted"));
655        // team_pair.yaml has orchestrator_pane: true, so it gets promoted to hybrid
656        assert!(notes[0].contains("promotes the team to hybrid"));
657    }
658
659    #[test]
660    fn migration_validation_notes_warn_about_workflow_first_partial_rollout() {
661        let config: config::TeamConfig = serde_yaml::from_str(
662            r#"
663name: test
664workflow_mode: workflow_first
665roles:
666  - name: engineer
667    role_type: engineer
668    agent: codex
669"#,
670        )
671        .unwrap();
672        let notes = migration_validation_notes(&config, true);
673
674        assert_eq!(notes.len(), 1);
675        assert!(notes[0].contains("workflow_first mode selected"));
676        assert!(notes[0].contains("primary truth"));
677    }
678
679    fn make_member(name: &str, role_name: &str, role_type: RoleType) -> hierarchy::MemberInstance {
680        hierarchy::MemberInstance {
681            name: name.to_string(),
682            role_name: role_name.to_string(),
683            role_type,
684            agent: Some("codex".to_string()),
685            prompt: None,
686            reports_to: None,
687            use_worktrees: false,
688        }
689    }
690
691    #[test]
692    fn strip_tmux_style_removes_formatting_sequences() {
693        let raw = "#[fg=yellow]idle#[default] #[fg=magenta]nudge 1:05#[default]";
694        assert_eq!(status::strip_tmux_style(raw), "idle nudge 1:05");
695    }
696
697    #[test]
698    fn summarize_runtime_member_status_extracts_state_and_signal() {
699        let summary = status::summarize_runtime_member_status(
700            "#[fg=cyan]working#[default] #[fg=blue]standup 4:12#[default]",
701            false,
702        );
703
704        assert_eq!(summary.state, "working");
705        assert_eq!(summary.signal.as_deref(), Some("standup"));
706        assert_eq!(summary.label.as_deref(), Some("working standup 4:12"));
707    }
708
709    #[test]
710    fn summarize_runtime_member_status_marks_nudge_and_standup_together() {
711        let summary = status::summarize_runtime_member_status(
712            "#[fg=yellow]idle#[default] #[fg=magenta]nudge now#[default] #[fg=blue]standup 0:10#[default]",
713            false,
714        );
715
716        assert_eq!(summary.state, "idle");
717        assert_eq!(
718            summary.signal.as_deref(),
719            Some("waiting for nudge, standup")
720        );
721    }
722
723    #[test]
724    fn summarize_runtime_member_status_distinguishes_sent_nudge() {
725        let summary = status::summarize_runtime_member_status(
726            "#[fg=yellow]idle#[default] #[fg=magenta]nudge sent#[default]",
727            false,
728        );
729
730        assert_eq!(summary.state, "idle");
731        assert_eq!(summary.signal.as_deref(), Some("nudged"));
732        assert_eq!(summary.label.as_deref(), Some("idle nudge sent"));
733    }
734
735    #[test]
736    fn summarize_runtime_member_status_tracks_paused_automation() {
737        let summary = status::summarize_runtime_member_status(
738            "#[fg=cyan]working#[default] #[fg=244]nudge paused#[default] #[fg=244]standup paused#[default]",
739            false,
740        );
741
742        assert_eq!(summary.state, "working");
743        assert_eq!(
744            summary.signal.as_deref(),
745            Some("nudge paused, standup paused")
746        );
747        assert_eq!(
748            summary.label.as_deref(),
749            Some("working nudge paused standup paused")
750        );
751    }
752
753    #[test]
754    fn build_team_status_rows_defaults_by_session_state() {
755        let architect = make_member("architect", "architect", RoleType::Architect);
756        let human = hierarchy::MemberInstance {
757            name: "human".to_string(),
758            role_name: "human".to_string(),
759            role_type: RoleType::User,
760            agent: None,
761            prompt: None,
762            reports_to: None,
763            use_worktrees: false,
764        };
765
766        let pending = std::collections::HashMap::from([
767            (architect.name.clone(), 3usize),
768            (human.name.clone(), 1usize),
769        ]);
770        let triage = std::collections::HashMap::from([(architect.name.clone(), 2usize)]);
771        let owned = std::collections::HashMap::from([(
772            architect.name.clone(),
773            status::OwnedTaskBuckets {
774                active: vec![191u32],
775                review: vec![193u32],
776            },
777        )]);
778        let rows = status::build_team_status_rows(
779            &[architect.clone(), human.clone()],
780            false,
781            &Default::default(),
782            &pending,
783            &triage,
784            &owned,
785            &Default::default(),
786        );
787        assert_eq!(rows[0].state, "stopped");
788        assert_eq!(rows[0].pending_inbox, 3);
789        assert_eq!(rows[0].triage_backlog, 2);
790        assert_eq!(rows[0].active_owned_tasks, vec![191]);
791        assert_eq!(rows[0].review_owned_tasks, vec![193]);
792        assert_eq!(rows[0].health_summary, "-");
793        assert_eq!(rows[1].state, "user");
794        assert_eq!(rows[1].pending_inbox, 1);
795        assert_eq!(rows[1].triage_backlog, 0);
796        assert!(rows[1].active_owned_tasks.is_empty());
797        assert!(rows[1].review_owned_tasks.is_empty());
798
799        let runtime = std::collections::HashMap::from([(
800            architect.name.clone(),
801            status::RuntimeMemberStatus {
802                state: "idle".to_string(),
803                signal: Some("standup".to_string()),
804                label: Some("idle standup 2:00".to_string()),
805            },
806        )]);
807        let rows = status::build_team_status_rows(
808            &[architect],
809            true,
810            &runtime,
811            &pending,
812            &triage,
813            &owned,
814            &Default::default(),
815        );
816        assert_eq!(rows[0].state, "reviewing");
817        assert_eq!(rows[0].pending_inbox, 3);
818        assert_eq!(rows[0].triage_backlog, 2);
819        assert_eq!(rows[0].active_owned_tasks, vec![191]);
820        assert_eq!(rows[0].review_owned_tasks, vec![193]);
821        assert_eq!(
822            rows[0].signal.as_deref(),
823            Some("standup, needs triage (2), needs review (1)")
824        );
825        assert_eq!(rows[0].runtime_label.as_deref(), Some("idle standup 2:00"));
826    }
827
828    #[test]
829    fn delivered_direct_report_triage_count_only_counts_results_newer_than_lead_response() {
830        let tmp = tempfile::tempdir().unwrap();
831        let root = inbox::inboxes_root(tmp.path());
832        inbox::init_inbox(&root, "lead").unwrap();
833        inbox::init_inbox(&root, "eng-1").unwrap();
834        inbox::init_inbox(&root, "eng-2").unwrap();
835
836        let mut old_result = inbox::InboxMessage::new_send("eng-1", "lead", "old result");
837        old_result.timestamp = 10;
838        let old_result_id = inbox::deliver_to_inbox(&root, &old_result).unwrap();
839        inbox::mark_delivered(&root, "lead", &old_result_id).unwrap();
840
841        let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "next task");
842        lead_reply.timestamp = 20;
843        let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
844        inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
845
846        let mut new_result = inbox::InboxMessage::new_send("eng-1", "lead", "new result");
847        new_result.timestamp = 30;
848        let new_result_id = inbox::deliver_to_inbox(&root, &new_result).unwrap();
849        inbox::mark_delivered(&root, "lead", &new_result_id).unwrap();
850
851        let mut other_result = inbox::InboxMessage::new_send("eng-2", "lead", "parallel result");
852        other_result.timestamp = 40;
853        let other_result_id = inbox::deliver_to_inbox(&root, &other_result).unwrap();
854        inbox::mark_delivered(&root, "lead", &other_result_id).unwrap();
855
856        let triage_state = status::delivered_direct_report_triage_state_at(
857            &root,
858            "lead",
859            &["eng-1".to_string(), "eng-2".to_string()],
860            100,
861        )
862        .unwrap();
863        assert_eq!(triage_state.count, 2);
864        assert_eq!(triage_state.newest_result_ts, 40);
865    }
866
867    #[test]
868    fn delivered_direct_report_triage_count_excludes_stale_delivered_results() {
869        let tmp = tempfile::tempdir().unwrap();
870        let root = inbox::inboxes_root(tmp.path());
871        inbox::init_inbox(&root, "lead").unwrap();
872        inbox::init_inbox(&root, "eng-1").unwrap();
873
874        let mut stale_result = inbox::InboxMessage::new_send("eng-1", "lead", "stale result");
875        stale_result.timestamp = 10;
876        let stale_result_id = inbox::deliver_to_inbox(&root, &stale_result).unwrap();
877        inbox::mark_delivered(&root, "lead", &stale_result_id).unwrap();
878
879        let triage_state = status::delivered_direct_report_triage_state_at(
880            &root,
881            "lead",
882            &["eng-1".to_string()],
883            10 + TRIAGE_RESULT_FRESHNESS_SECONDS + 1,
884        )
885        .unwrap();
886
887        assert_eq!(triage_state.count, 0);
888        assert_eq!(triage_state.newest_result_ts, 0);
889    }
890
891    #[test]
892    fn delivered_direct_report_triage_count_keeps_fresh_delivered_results() {
893        let tmp = tempfile::tempdir().unwrap();
894        let root = inbox::inboxes_root(tmp.path());
895        inbox::init_inbox(&root, "lead").unwrap();
896        inbox::init_inbox(&root, "eng-1").unwrap();
897
898        let mut fresh_result = inbox::InboxMessage::new_send("eng-1", "lead", "fresh result");
899        fresh_result.timestamp = 100;
900        let fresh_result_id = inbox::deliver_to_inbox(&root, &fresh_result).unwrap();
901        inbox::mark_delivered(&root, "lead", &fresh_result_id).unwrap();
902
903        let triage_state = status::delivered_direct_report_triage_state_at(
904            &root,
905            "lead",
906            &["eng-1".to_string()],
907            150,
908        )
909        .unwrap();
910
911        assert_eq!(triage_state.count, 1);
912        assert_eq!(triage_state.newest_result_ts, 100);
913    }
914
915    #[test]
916    fn delivered_direct_report_triage_count_excludes_acked_results() {
917        let tmp = tempfile::tempdir().unwrap();
918        let root = inbox::inboxes_root(tmp.path());
919        inbox::init_inbox(&root, "lead").unwrap();
920        inbox::init_inbox(&root, "eng-1").unwrap();
921
922        let mut result = inbox::InboxMessage::new_send("eng-1", "lead", "task complete");
923        result.timestamp = 100;
924        let result_id = inbox::deliver_to_inbox(&root, &result).unwrap();
925        inbox::mark_delivered(&root, "lead", &result_id).unwrap();
926
927        let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "acknowledged");
928        lead_reply.timestamp = 110;
929        let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
930        inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
931
932        let triage_state = status::delivered_direct_report_triage_state_at(
933            &root,
934            "lead",
935            &["eng-1".to_string()],
936            150,
937        )
938        .unwrap();
939
940        assert_eq!(triage_state.count, 0);
941        assert_eq!(triage_state.newest_result_ts, 0);
942    }
943
944    #[test]
945    fn format_owned_tasks_summary_compacts_multiple_ids() {
946        assert_eq!(status::format_owned_tasks_summary(&[]), "-");
947        assert_eq!(status::format_owned_tasks_summary(&[191]), "#191");
948        assert_eq!(status::format_owned_tasks_summary(&[191, 192]), "#191,#192");
949        assert_eq!(
950            status::format_owned_tasks_summary(&[191, 192, 193]),
951            "#191,#192,+1"
952        );
953    }
954
955    #[test]
956    fn owned_task_buckets_split_active_and_review_claims() {
957        let tmp = tempfile::tempdir().unwrap();
958        let members = vec![
959            make_member("lead", "lead", RoleType::Manager),
960            hierarchy::MemberInstance {
961                name: "eng-1".to_string(),
962                role_name: "eng".to_string(),
963                role_type: RoleType::Engineer,
964                agent: Some("codex".to_string()),
965                prompt: None,
966                reports_to: Some("lead".to_string()),
967                use_worktrees: false,
968            },
969        ];
970        std::fs::create_dir_all(
971            tmp.path()
972                .join(".batty")
973                .join("team_config")
974                .join("board")
975                .join("tasks"),
976        )
977        .unwrap();
978        std::fs::write(
979            tmp.path()
980                .join(".batty")
981                .join("team_config")
982                .join("board")
983                .join("tasks")
984                .join("191-active.md"),
985            "---\nid: 191\ntitle: Active\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
986        )
987        .unwrap();
988        std::fs::write(
989            tmp.path()
990                .join(".batty")
991                .join("team_config")
992                .join("board")
993                .join("tasks")
994                .join("193-review.md"),
995            "---\nid: 193\ntitle: Review\nstatus: review\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
996        )
997        .unwrap();
998
999        let owned = status::owned_task_buckets(tmp.path(), &members);
1000        let buckets = owned.get("eng-1").unwrap();
1001        assert_eq!(buckets.active, vec![191]);
1002        assert!(buckets.review.is_empty());
1003        let review_buckets = owned.get("lead").unwrap();
1004        assert!(review_buckets.active.is_empty());
1005        assert_eq!(review_buckets.review, vec![193]);
1006    }
1007
1008    #[test]
1009    fn workflow_metrics_enabled_detects_supported_modes() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let config_path = tmp.path().join("team.yaml");
1012
1013        std::fs::write(
1014            &config_path,
1015            "name: test\nworkflow_mode: hybrid\nroles: []\n",
1016        )
1017        .unwrap();
1018        assert!(status::workflow_metrics_enabled(&config_path));
1019
1020        std::fs::write(
1021            &config_path,
1022            "name: test\nworkflow_mode: workflow_first\nroles: []\n",
1023        )
1024        .unwrap();
1025        assert!(status::workflow_metrics_enabled(&config_path));
1026
1027        std::fs::write(&config_path, "name: test\nroles: []\n").unwrap();
1028        assert!(!status::workflow_metrics_enabled(&config_path));
1029    }
1030
1031    #[test]
1032    fn team_status_metrics_section_renders_when_workflow_mode_enabled() {
1033        let tmp = tempfile::tempdir().unwrap();
1034        let team_dir = tmp.path().join(".batty").join("team_config");
1035        let board_dir = team_dir.join("board");
1036        let tasks_dir = board_dir.join("tasks");
1037        std::fs::create_dir_all(&tasks_dir).unwrap();
1038        std::fs::write(
1039            team_dir.join("team.yaml"),
1040            "name: test\nworkflow_mode: hybrid\nroles:\n  - name: engineer\n    role_type: engineer\n    agent: codex\n",
1041        )
1042        .unwrap();
1043        std::fs::write(
1044            tasks_dir.join("031-runnable.md"),
1045            "---\nid: 31\ntitle: Runnable\nstatus: todo\npriority: medium\nclass: standard\n---\n\nTask body.\n",
1046        )
1047        .unwrap();
1048
1049        let members = vec![make_member("eng-1-1", "engineer", RoleType::Engineer)];
1050        let section = status::workflow_metrics_section(tmp.path(), &members).unwrap();
1051
1052        assert!(section.0.contains("Workflow Metrics"));
1053        assert_eq!(section.1.runnable_count, 1);
1054        assert_eq!(section.1.idle_with_runnable, vec!["eng-1-1"]);
1055    }
1056
1057    #[test]
1058    #[serial]
1059    #[cfg_attr(not(feature = "integration"), ignore)]
1060    fn list_runtime_member_statuses_reads_tmux_role_and_status_options() {
1061        let session = "batty-test-team-status-runtime";
1062        let _ = crate::tmux::kill_session(session);
1063
1064        crate::tmux::create_session(session, "sleep", &["20".to_string()], "/tmp").unwrap();
1065        let pane_id = crate::tmux::pane_id(session).unwrap();
1066
1067        let role_output = std::process::Command::new("tmux")
1068            .args(["set-option", "-p", "-t", &pane_id, "@batty_role", "eng-1"])
1069            .output()
1070            .unwrap();
1071        assert!(role_output.status.success());
1072
1073        let status_output = std::process::Command::new("tmux")
1074            .args([
1075                "set-option",
1076                "-p",
1077                "-t",
1078                &pane_id,
1079                "@batty_status",
1080                "#[fg=yellow]idle#[default] #[fg=magenta]nudge 0:30#[default]",
1081            ])
1082            .output()
1083            .unwrap();
1084        assert!(status_output.status.success());
1085
1086        let statuses = status::list_runtime_member_statuses(session).unwrap();
1087        let eng = statuses.get("eng-1").unwrap();
1088        assert_eq!(eng.state, "idle");
1089        assert_eq!(eng.signal.as_deref(), Some("waiting for nudge"));
1090        assert_eq!(eng.label.as_deref(), Some("idle nudge 0:30"));
1091
1092        crate::tmux::kill_session(session).unwrap();
1093    }
1094
1095    // --- Session summary tests ---
1096
1097    #[test]
1098    fn session_summary_counts_completions_correctly() {
1099        let tmp = tempfile::tempdir().unwrap();
1100        let events_dir = tmp.path().join(".batty").join("team_config");
1101        std::fs::create_dir_all(&events_dir).unwrap();
1102
1103        let now = crate::team::now_unix();
1104        let events = [
1105            format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 3600),
1106            format!(
1107                r#"{{"event":"task_completed","role":"eng-1","task":"10","ts":{}}}"#,
1108                now - 3000
1109            ),
1110            format!(
1111                r#"{{"event":"task_completed","role":"eng-2","task":"11","ts":{}}}"#,
1112                now - 2000
1113            ),
1114            format!(
1115                r#"{{"event":"task_auto_merged","role":"eng-1","task":"10","ts":{}}}"#,
1116                now - 2900
1117            ),
1118            format!(
1119                r#"{{"event":"task_manual_merged","role":"eng-2","task":"11","ts":{}}}"#,
1120                now - 1900
1121            ),
1122            format!(
1123                r#"{{"event":"task_completed","role":"eng-1","task":"12","ts":{}}}"#,
1124                now - 1000
1125            ),
1126        ];
1127        std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
1128
1129        let summary = compute_session_summary(tmp.path()).unwrap();
1130        assert_eq!(summary.tasks_completed, 3);
1131        assert_eq!(summary.tasks_merged, 2);
1132        assert!(summary.runtime_secs >= 3599 && summary.runtime_secs <= 3601);
1133    }
1134
1135    #[test]
1136    fn session_summary_calculates_runtime() {
1137        let tmp = tempfile::tempdir().unwrap();
1138        let events_dir = tmp.path().join(".batty").join("team_config");
1139        std::fs::create_dir_all(&events_dir).unwrap();
1140
1141        let now = crate::team::now_unix();
1142        let events = [format!(
1143            r#"{{"event":"daemon_started","ts":{}}}"#,
1144            now - 7200
1145        )];
1146        std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
1147
1148        let summary = compute_session_summary(tmp.path()).unwrap();
1149        assert_eq!(summary.tasks_completed, 0);
1150        assert_eq!(summary.tasks_merged, 0);
1151        assert!(summary.runtime_secs >= 7199 && summary.runtime_secs <= 7201);
1152    }
1153
1154    #[test]
1155    fn session_summary_handles_empty_session() {
1156        let tmp = tempfile::tempdir().unwrap();
1157        let events_dir = tmp.path().join(".batty").join("team_config");
1158        std::fs::create_dir_all(&events_dir).unwrap();
1159
1160        // No daemon_started event — summary returns None.
1161        std::fs::write(events_dir.join("events.jsonl"), "").unwrap();
1162        assert!(compute_session_summary(tmp.path()).is_none());
1163    }
1164
1165    #[test]
1166    fn session_summary_handles_missing_events_file() {
1167        let tmp = tempfile::tempdir().unwrap();
1168        // No events.jsonl at all.
1169        assert!(compute_session_summary(tmp.path()).is_none());
1170    }
1171
1172    #[test]
1173    fn session_summary_display_format() {
1174        let summary = SessionSummary {
1175            tasks_completed: 5,
1176            tasks_merged: 4,
1177            runtime_secs: 8100, // 2h 15m
1178        };
1179        assert_eq!(
1180            summary.display(),
1181            format!(
1182                "Session summary: 5 tasks completed, 4 merged, runtime 2h 15m\nBatty v{} — https://github.com/battysh/batty",
1183                env!("CARGO_PKG_VERSION")
1184            )
1185        );
1186    }
1187
1188    #[test]
1189    fn format_runtime_seconds() {
1190        assert_eq!(format_runtime(45), "45s");
1191    }
1192
1193    #[test]
1194    fn format_runtime_minutes() {
1195        assert_eq!(format_runtime(300), "5m");
1196    }
1197
1198    #[test]
1199    fn format_runtime_hours_and_minutes() {
1200        assert_eq!(format_runtime(5400), "1h 30m");
1201    }
1202
1203    #[test]
1204    fn format_runtime_exact_hours() {
1205        assert_eq!(format_runtime(7200), "2h");
1206    }
1207
1208    #[test]
1209    fn session_summary_uses_latest_daemon_started() {
1210        let tmp = tempfile::tempdir().unwrap();
1211        let events_dir = tmp.path().join(".batty").join("team_config");
1212        std::fs::create_dir_all(&events_dir).unwrap();
1213
1214        let now = crate::team::now_unix();
1215        // First session had 2 completions, second session has 1.
1216        let events = [
1217            format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 7200),
1218            format!(
1219                r#"{{"event":"task_completed","role":"eng-1","task":"1","ts":{}}}"#,
1220                now - 6000
1221            ),
1222            format!(
1223                r#"{{"event":"task_completed","role":"eng-1","task":"2","ts":{}}}"#,
1224                now - 5000
1225            ),
1226            format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 1800),
1227            format!(
1228                r#"{{"event":"task_completed","role":"eng-1","task":"3","ts":{}}}"#,
1229                now - 1000
1230            ),
1231        ];
1232        std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
1233
1234        let summary = compute_session_summary(tmp.path()).unwrap();
1235        // Should only count events from the latest daemon_started.
1236        assert_eq!(summary.tasks_completed, 1);
1237        assert!(summary.runtime_secs >= 1799 && summary.runtime_secs <= 1801);
1238    }
1239
1240    /// Count unwrap()/expect() calls in production code (before `#[cfg(test)] mod tests`).
1241    fn production_unwrap_expect_count(source: &str) -> usize {
1242        // Split at the test module boundary, not individual #[cfg(test)] items
1243        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
1244            &source[..pos]
1245        } else {
1246            source
1247        };
1248        prod.lines()
1249            .filter(|line| {
1250                let trimmed = line.trim();
1251                // Skip lines that are themselves cfg(test)-gated items
1252                !trimmed.starts_with("#[cfg(test)]")
1253                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
1254            })
1255            .count()
1256    }
1257
1258    #[test]
1259    fn production_daemon_mgmt_has_limited_unwrap_or_expect_calls() {
1260        let src = include_str!("daemon_mgmt.rs");
1261        // spawn_daemon uses unwrap_or_else for canonicalize — this is acceptable
1262        assert!(
1263            production_unwrap_expect_count(src) <= 1,
1264            "daemon_mgmt.rs should minimize unwrap/expect in production code"
1265        );
1266    }
1267
1268    #[test]
1269    fn production_session_has_no_unwrap_or_expect_calls() {
1270        let src = include_str!("session.rs");
1271        assert_eq!(
1272            production_unwrap_expect_count(src),
1273            0,
1274            "session.rs should avoid unwrap/expect"
1275        );
1276    }
1277}