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