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