1use 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
22pub fn pause_marker_path(project_root: &Path) -> PathBuf {
24 project_root.join(".batty").join("paused")
25}
26
27pub 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
41pub 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
52pub 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
59pub 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
73pub 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
84pub 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, 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#[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
217pub(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 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(project_root, None);
271
272 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 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 if let Some(summary) = final_summary {
297 println!();
298 println!("{}", summary.display());
299 }
300
301 Ok(())
302}
303
304pub 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 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
337pub 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 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
585pub 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 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 assert!(disable_nudge(tmp.path(), "triage").is_err());
667
668 enable_nudge(tmp.path(), "triage").unwrap();
669 assert!(!marker.exists());
670
671 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 assert!(pause_team(tmp.path()).is_err());
716
717 resume_team(tmp.path()).unwrap();
718 assert!(!pause_marker_path(tmp.path()).exists());
719
720 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 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 #[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 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 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, };
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 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 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 fn production_unwrap_expect_count(source: &str) -> usize {
1462 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 !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 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}