1pub mod artifact;
8pub mod auto_merge;
9#[cfg(test)]
10mod behavioral_tests;
11pub mod board;
12pub mod board_cmd;
13pub mod board_health;
14pub mod capability;
15pub mod checkpoint;
16pub mod comms;
17pub mod completion;
18pub mod config;
19pub mod cost;
20pub mod daemon;
21pub mod delivery;
22pub mod deps;
23pub mod doctor;
24pub mod errors;
25pub mod estimation;
26pub mod events;
27pub mod failure_patterns;
28pub mod git_cmd;
29pub mod harness;
30pub mod hierarchy;
31pub mod inbox;
32pub mod layout;
33pub mod merge;
34pub mod message;
35pub mod metrics;
36pub mod metrics_cmd;
37pub mod nudge;
38pub mod policy;
39pub mod resolver;
40pub mod retrospective;
41pub mod retry;
42pub mod review;
43pub mod standup;
44pub mod status;
45pub mod task_cmd;
46pub mod task_loop;
47pub mod telegram;
48pub mod telemetry_db;
49#[cfg(test)]
50pub mod test_helpers;
51#[cfg(test)]
52pub mod test_support;
53pub mod validation;
54pub mod watcher;
55pub mod workflow;
56
57use std::collections::HashMap;
58use std::fs::File;
59use std::path::{Path, PathBuf};
60use std::time::{Duration, SystemTime, UNIX_EPOCH};
61
62use anyhow::{Context, Result, bail};
63use serde::{Deserialize, Serialize};
64use tracing::{info, warn};
65
66use crate::tmux;
67
68pub const TEAM_CONFIG_DIR: &str = "team_config";
70pub const TEAM_CONFIG_FILE: &str = "team.yaml";
72
73const LOAD_GRAPH_WINDOW_SECONDS: u64 = 3_600;
75const LOAD_GRAPH_WIDTH: usize = 30;
76const INBOX_BODY_PREVIEW_CHARS: usize = 140;
77const TRIAGE_RESULT_FRESHNESS_SECONDS: u64 = 300;
78const LOG_ROTATION_BYTES: u64 = 5 * 1024 * 1024;
79const LOG_ROTATION_KEEP: usize = 3;
80pub(crate) const DEFAULT_EVENT_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
81const DAEMON_SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(5);
82const DAEMON_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum AssignmentResultStatus {
87 Delivered,
88 Failed,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct AssignmentDeliveryResult {
93 pub message_id: String,
94 pub status: AssignmentResultStatus,
95 pub engineer: String,
96 pub task_summary: String,
97 pub branch: Option<String>,
98 pub work_dir: Option<String>,
99 pub detail: String,
100 pub ts: u64,
101}
102
103pub fn team_config_dir(project_root: &Path) -> PathBuf {
105 project_root.join(".batty").join(TEAM_CONFIG_DIR)
106}
107
108pub fn team_config_path(project_root: &Path) -> PathBuf {
110 team_config_dir(project_root).join(TEAM_CONFIG_FILE)
111}
112
113pub fn team_events_path(project_root: &Path) -> PathBuf {
114 team_config_dir(project_root).join("events.jsonl")
115}
116
117pub(crate) fn orchestrator_log_path(project_root: &Path) -> PathBuf {
118 project_root.join(".batty").join("orchestrator.log")
119}
120
121pub(crate) fn orchestrator_ansi_log_path(project_root: &Path) -> PathBuf {
122 project_root.join(".batty").join("orchestrator.ansi.log")
123}
124
125pub fn templates_base_dir() -> Result<PathBuf> {
127 let home = std::env::var("HOME").context("cannot determine home directory")?;
128 Ok(PathBuf::from(home).join(".batty").join("templates"))
129}
130
131#[derive(Debug, Clone, Copy)]
132pub struct TeamLoadSnapshot {
133 pub timestamp: u64,
134 pub total_members: usize,
135 pub working_members: usize,
136 pub load: f64,
137 pub session_running: bool,
138}
139
140fn assignment_results_dir(project_root: &Path) -> PathBuf {
141 project_root.join(".batty").join("assignment_results")
142}
143
144fn assignment_result_path(project_root: &Path, message_id: &str) -> PathBuf {
145 assignment_results_dir(project_root).join(format!("{message_id}.json"))
146}
147
148pub(crate) fn store_assignment_result(
149 project_root: &Path,
150 result: &AssignmentDeliveryResult,
151) -> Result<()> {
152 let path = assignment_result_path(project_root, &result.message_id);
153 if let Some(parent) = path.parent() {
154 std::fs::create_dir_all(parent)?;
155 }
156 let content = serde_json::to_vec_pretty(result)?;
157 std::fs::write(&path, content)
158 .with_context(|| format!("failed to write assignment result {}", path.display()))?;
159 Ok(())
160}
161
162pub fn load_assignment_result(
163 project_root: &Path,
164 message_id: &str,
165) -> Result<Option<AssignmentDeliveryResult>> {
166 let path = assignment_result_path(project_root, message_id);
167 if !path.exists() {
168 return Ok(None);
169 }
170 let data = std::fs::read(&path)
171 .with_context(|| format!("failed to read assignment result {}", path.display()))?;
172 let result = serde_json::from_slice(&data)
173 .with_context(|| format!("failed to parse assignment result {}", path.display()))?;
174 Ok(Some(result))
175}
176
177pub fn wait_for_assignment_result(
178 project_root: &Path,
179 message_id: &str,
180 timeout: Duration,
181) -> Result<Option<AssignmentDeliveryResult>> {
182 let deadline = std::time::Instant::now() + timeout;
183 loop {
184 if let Some(result) = load_assignment_result(project_root, message_id)? {
185 return Ok(Some(result));
186 }
187 if std::time::Instant::now() >= deadline {
188 return Ok(None);
189 }
190 std::thread::sleep(Duration::from_millis(200));
191 }
192}
193
194pub fn format_assignment_result(result: &AssignmentDeliveryResult) -> String {
195 let mut text = match result.status {
196 AssignmentResultStatus::Delivered => {
197 format!(
198 "Assignment delivered: {} -> {}",
199 result.message_id, result.engineer
200 )
201 }
202 AssignmentResultStatus::Failed => {
203 format!(
204 "Assignment failed: {} -> {}",
205 result.message_id, result.engineer
206 )
207 }
208 };
209
210 text.push_str(&format!("\nTask: {}", result.task_summary));
211 if let Some(branch) = result.branch.as_deref() {
212 text.push_str(&format!("\nBranch: {branch}"));
213 }
214 if let Some(work_dir) = result.work_dir.as_deref() {
215 text.push_str(&format!("\nWorktree: {work_dir}"));
216 }
217 if !result.detail.is_empty() {
218 text.push_str(&format!("\nDetail: {}", result.detail));
219 }
220 text
221}
222
223pub(crate) fn now_unix() -> u64 {
224 SystemTime::now()
225 .duration_since(UNIX_EPOCH)
226 .unwrap_or_default()
227 .as_secs()
228}
229
230pub fn init_team(project_root: &Path, template: &str) -> Result<Vec<PathBuf>> {
232 let config_dir = team_config_dir(project_root);
233 std::fs::create_dir_all(&config_dir)
234 .with_context(|| format!("failed to create {}", config_dir.display()))?;
235
236 let mut created = Vec::new();
237
238 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
239 if yaml_path.exists() {
240 bail!(
241 "team config already exists at {}; remove it first or edit directly",
242 yaml_path.display()
243 );
244 }
245
246 let yaml_content = match template {
247 "solo" => include_str!("templates/team_solo.yaml"),
248 "pair" => include_str!("templates/team_pair.yaml"),
249 "squad" => include_str!("templates/team_squad.yaml"),
250 "large" => include_str!("templates/team_large.yaml"),
251 "research" => include_str!("templates/team_research.yaml"),
252 "software" => include_str!("templates/team_software.yaml"),
253 "batty" => include_str!("templates/team_batty.yaml"),
254 _ => include_str!("templates/team_simple.yaml"),
255 };
256 std::fs::write(&yaml_path, yaml_content)
257 .with_context(|| format!("failed to write {}", yaml_path.display()))?;
258 created.push(yaml_path);
259
260 let prompt_files: &[(&str, &str)] = match template {
262 "research" => &[
263 (
264 "research_lead.md",
265 include_str!("templates/research_lead.md"),
266 ),
267 ("sub_lead.md", include_str!("templates/sub_lead.md")),
268 ("researcher.md", include_str!("templates/researcher.md")),
269 ],
270 "software" => &[
271 ("tech_lead.md", include_str!("templates/tech_lead.md")),
272 ("eng_manager.md", include_str!("templates/eng_manager.md")),
273 ("developer.md", include_str!("templates/developer.md")),
274 ],
275 "batty" => &[
276 (
277 "batty_architect.md",
278 include_str!("templates/batty_architect.md"),
279 ),
280 (
281 "batty_manager.md",
282 include_str!("templates/batty_manager.md"),
283 ),
284 (
285 "batty_engineer.md",
286 include_str!("templates/batty_engineer.md"),
287 ),
288 ],
289 _ => &[
290 ("architect.md", include_str!("templates/architect.md")),
291 ("manager.md", include_str!("templates/manager.md")),
292 ("engineer.md", include_str!("templates/engineer.md")),
293 ],
294 };
295
296 for (name, content) in prompt_files {
297 let path = config_dir.join(name);
298 if !path.exists() {
299 std::fs::write(&path, content)
300 .with_context(|| format!("failed to write {}", path.display()))?;
301 created.push(path);
302 }
303 }
304
305 let directive_files = [
306 (
307 "replenishment_context.md",
308 include_str!("templates/replenishment_context.md"),
309 ),
310 (
311 "review_policy.md",
312 include_str!("templates/review_policy.md"),
313 ),
314 (
315 "escalation_policy.md",
316 include_str!("templates/escalation_policy.md"),
317 ),
318 ];
319 for (name, content) in directive_files {
320 let path = config_dir.join(name);
321 if !path.exists() {
322 std::fs::write(&path, content)
323 .with_context(|| format!("failed to write {}", path.display()))?;
324 created.push(path);
325 }
326 }
327
328 let board_dir = config_dir.join("board");
330 if !board_dir.exists() {
331 let output = std::process::Command::new("kanban-md")
332 .args(["init", "--dir", &board_dir.to_string_lossy()])
333 .output();
334 match output {
335 Ok(out) if out.status.success() => {
336 created.push(board_dir);
337 }
338 Ok(out) => {
339 let stderr = String::from_utf8_lossy(&out.stderr);
340 warn!("kanban-md init failed: {stderr}; falling back to plain kanban.md");
341 let kanban_path = config_dir.join("kanban.md");
342 std::fs::write(
343 &kanban_path,
344 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
345 )?;
346 created.push(kanban_path);
347 }
348 Err(_) => {
349 warn!("kanban-md not found; falling back to plain kanban.md");
350 let kanban_path = config_dir.join("kanban.md");
351 std::fs::write(
352 &kanban_path,
353 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
354 )?;
355 created.push(kanban_path);
356 }
357 }
358 }
359
360 info!(dir = %config_dir.display(), files = created.len(), "scaffolded team config");
361 Ok(created)
362}
363
364pub fn list_available_templates() -> Result<Vec<String>> {
365 let templates_dir = templates_base_dir()?;
366 if !templates_dir.is_dir() {
367 bail!(
368 "no templates directory found at {}",
369 templates_dir.display()
370 );
371 }
372
373 let mut templates = Vec::new();
374 for entry in std::fs::read_dir(&templates_dir)
375 .with_context(|| format!("failed to read {}", templates_dir.display()))?
376 {
377 let entry = entry?;
378 if entry.path().is_dir() {
379 templates.push(entry.file_name().to_string_lossy().into_owned());
380 }
381 }
382 templates.sort();
383 Ok(templates)
384}
385
386fn copy_template_dir(src: &Path, dst: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
387 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
388 for entry in
389 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
390 {
391 let entry = entry?;
392 let src_path = entry.path();
393 let dst_path = dst.join(entry.file_name());
394 if src_path.is_dir() {
395 copy_template_dir(&src_path, &dst_path, created)?;
396 } else {
397 std::fs::copy(&src_path, &dst_path).with_context(|| {
398 format!(
399 "failed to copy template file from {} to {}",
400 src_path.display(),
401 dst_path.display()
402 )
403 })?;
404 created.push(dst_path);
405 }
406 }
407 Ok(())
408}
409
410pub fn init_from_template(project_root: &Path, template_name: &str) -> Result<Vec<PathBuf>> {
411 let templates_dir = templates_base_dir()?;
412 if !templates_dir.is_dir() {
413 bail!(
414 "no templates directory found at {}",
415 templates_dir.display()
416 );
417 }
418
419 let available = list_available_templates()?;
420 if !available.iter().any(|name| name == template_name) {
421 let available_display = if available.is_empty() {
422 "(none)".to_string()
423 } else {
424 available.join(", ")
425 };
426 bail!(
427 "template '{}' not found in {}; available templates: {}",
428 template_name,
429 templates_dir.display(),
430 available_display
431 );
432 }
433
434 let config_dir = team_config_dir(project_root);
435 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
436 if yaml_path.exists() {
437 bail!(
438 "team config already exists at {}; remove it first or edit directly",
439 yaml_path.display()
440 );
441 }
442
443 let source_dir = templates_dir.join(template_name);
444 let mut created = Vec::new();
445 copy_template_dir(&source_dir, &config_dir, &mut created)?;
446 info!(
447 template = template_name,
448 source = %source_dir.display(),
449 dest = %config_dir.display(),
450 files = created.len(),
451 "copied team config from user template"
452 );
453 Ok(created)
454}
455
456pub fn export_template(project_root: &Path, name: &str) -> Result<usize> {
458 let config_dir = team_config_dir(project_root);
459 let team_yaml = config_dir.join(TEAM_CONFIG_FILE);
460 if !team_yaml.is_file() {
461 bail!("team config missing at {}", team_yaml.display());
462 }
463
464 let template_dir = templates_base_dir()?.join(name);
465 if template_dir.exists() {
466 eprintln!(
467 "warning: overwriting existing template at {}",
468 template_dir.display()
469 );
470 }
471 std::fs::create_dir_all(&template_dir)
472 .with_context(|| format!("failed to create {}", template_dir.display()))?;
473
474 let mut copied = 0usize;
475 copy_template_file(&team_yaml, &template_dir.join(TEAM_CONFIG_FILE))?;
476 copied += 1;
477
478 let mut prompt_paths = std::fs::read_dir(&config_dir)?
479 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
480 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
481 .collect::<Vec<_>>();
482 prompt_paths.sort();
483
484 for source in prompt_paths {
485 let file_name = source
486 .file_name()
487 .context("template source missing file name")?;
488 copy_template_file(&source, &template_dir.join(file_name))?;
489 copied += 1;
490 }
491
492 Ok(copied)
493}
494
495pub fn export_run(project_root: &Path) -> Result<PathBuf> {
496 let team_yaml = team_config_path(project_root);
497 if !team_yaml.is_file() {
498 bail!("team config missing at {}", team_yaml.display());
499 }
500
501 let export_dir = create_run_export_dir(project_root)?;
502 copy_template_file(&team_yaml, &export_dir.join(TEAM_CONFIG_FILE))?;
503
504 copy_dir_if_exists(
505 &team_config_dir(project_root).join("board").join("tasks"),
506 &export_dir.join("board").join("tasks"),
507 )?;
508 copy_file_if_exists(
509 &team_events_path(project_root),
510 &export_dir.join("events.jsonl"),
511 )?;
512 copy_file_if_exists(
513 &daemon_log_path(project_root),
514 &export_dir.join("daemon.log"),
515 )?;
516 copy_file_if_exists(
517 &orchestrator_log_path(project_root),
518 &export_dir.join("orchestrator.log"),
519 )?;
520 copy_dir_if_exists(
521 &project_root.join(".batty").join("retrospectives"),
522 &export_dir.join("retrospectives"),
523 )?;
524 copy_file_if_exists(
525 &project_root.join(".batty").join("test_timing.jsonl"),
526 &export_dir.join("test_timing.jsonl"),
527 )?;
528
529 Ok(export_dir)
530}
531
532fn copy_template_file(source: &Path, destination: &Path) -> Result<()> {
533 if let Some(parent) = destination.parent() {
534 std::fs::create_dir_all(parent)
535 .with_context(|| format!("failed to create {}", parent.display()))?;
536 }
537 std::fs::copy(source, destination).with_context(|| {
538 format!(
539 "failed to copy {} to {}",
540 source.display(),
541 destination.display()
542 )
543 })?;
544 Ok(())
545}
546
547fn daemon_pid_path(project_root: &Path) -> PathBuf {
549 project_root.join(".batty").join("daemon.pid")
550}
551
552fn exports_dir(project_root: &Path) -> PathBuf {
553 project_root.join(".batty").join("exports")
554}
555
556fn create_run_export_dir(project_root: &Path) -> Result<PathBuf> {
557 let base = exports_dir(project_root);
558 std::fs::create_dir_all(&base)
559 .with_context(|| format!("failed to create {}", base.display()))?;
560
561 let timestamp = now_unix();
562 let primary = base.join(timestamp.to_string());
563 if !primary.exists() {
564 std::fs::create_dir(&primary)
565 .with_context(|| format!("failed to create {}", primary.display()))?;
566 return Ok(primary);
567 }
568
569 for suffix in 1.. {
570 let candidate = base.join(format!("{timestamp}-{suffix}"));
571 if candidate.exists() {
572 continue;
573 }
574 std::fs::create_dir(&candidate)
575 .with_context(|| format!("failed to create {}", candidate.display()))?;
576 return Ok(candidate);
577 }
578
579 unreachable!("infinite suffix iterator should always return or continue");
580}
581
582fn daemon_log_path(project_root: &Path) -> PathBuf {
584 project_root.join(".batty").join("daemon.log")
585}
586
587fn rotated_log_path(path: &Path, generation: usize) -> PathBuf {
588 PathBuf::from(format!("{}.{}", path.display(), generation))
589}
590
591pub(crate) fn rotate_log_if_needed(path: &Path) -> Result<()> {
592 let len = match std::fs::metadata(path) {
593 Ok(metadata) => metadata.len(),
594 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
595 Err(error) => {
596 return Err(error).with_context(|| format!("failed to stat {}", path.display()));
597 }
598 };
599
600 if len <= LOG_ROTATION_BYTES {
601 return Ok(());
602 }
603
604 let oldest = rotated_log_path(path, LOG_ROTATION_KEEP);
605 if oldest.exists() {
606 std::fs::remove_file(&oldest)
607 .with_context(|| format!("failed to remove {}", oldest.display()))?;
608 }
609
610 for generation in (1..LOG_ROTATION_KEEP).rev() {
611 let source = rotated_log_path(path, generation);
612 if !source.exists() {
613 continue;
614 }
615 let destination = rotated_log_path(path, generation + 1);
616 std::fs::rename(&source, &destination).with_context(|| {
617 format!(
618 "failed to rotate {} to {}",
619 source.display(),
620 destination.display()
621 )
622 })?;
623 }
624
625 let rotated = rotated_log_path(path, 1);
626 std::fs::rename(path, &rotated).with_context(|| {
627 format!(
628 "failed to rotate {} to {}",
629 path.display(),
630 rotated.display()
631 )
632 })?;
633 Ok(())
634}
635
636fn copy_file_if_exists(source: &Path, destination: &Path) -> Result<()> {
637 if source.is_file() {
638 copy_template_file(source, destination)?;
639 }
640 Ok(())
641}
642
643fn copy_dir_if_exists(source: &Path, destination: &Path) -> Result<()> {
644 if source.is_dir() {
645 let mut created = Vec::new();
646 copy_template_dir(source, destination, &mut created)?;
647 }
648 Ok(())
649}
650
651pub(crate) fn open_log_for_append(path: &Path) -> Result<File> {
652 if let Some(parent) = path.parent() {
653 std::fs::create_dir_all(parent)?;
654 }
655 rotate_log_if_needed(path)?;
656 File::options()
657 .append(true)
658 .create(true)
659 .open(path)
660 .with_context(|| format!("failed to open log file: {}", path.display()))
661}
662
663fn daemon_spawn_args(root_str: &str, resume: bool) -> Vec<String> {
664 let mut args = vec![
665 "-v".to_string(),
666 "daemon".to_string(),
667 "--project-root".to_string(),
668 root_str.to_string(),
669 ];
670 if resume {
671 args.push("--resume".to_string());
672 }
673 args
674}
675
676pub(crate) fn daemon_state_path(project_root: &Path) -> PathBuf {
677 project_root.join(".batty").join("daemon-state.json")
678}
679
680fn workflow_mode_declared(config_path: &Path) -> Result<bool> {
681 let content = std::fs::read_to_string(config_path)
682 .with_context(|| format!("failed to read {}", config_path.display()))?;
683 let value: serde_yaml::Value = serde_yaml::from_str(&content)
684 .with_context(|| format!("failed to parse {}", config_path.display()))?;
685 let Some(mapping) = value.as_mapping() else {
686 return Ok(false);
687 };
688
689 Ok(mapping.contains_key(serde_yaml::Value::String("workflow_mode".to_string())))
690}
691
692fn migration_validation_notes(
693 team_config: &config::TeamConfig,
694 workflow_mode_is_explicit: bool,
695) -> Vec<String> {
696 if !workflow_mode_is_explicit {
697 return vec![
698 "Migration: workflow_mode omitted; defaulting to legacy so existing teams and boards run unchanged.".to_string(),
699 ];
700 }
701
702 match team_config.workflow_mode {
703 config::WorkflowMode::Legacy => vec![
704 "Migration: legacy mode selected; Batty keeps current runtime behavior and treats workflow metadata as optional.".to_string(),
705 ],
706 config::WorkflowMode::Hybrid => vec![
707 "Migration: hybrid mode selected; workflow adoption is incremental and legacy runtime behavior remains available.".to_string(),
708 ],
709 config::WorkflowMode::WorkflowFirst => vec![
710 "Migration: workflow_first mode selected; complete board metadata and orchestrator rollout before treating workflow state as primary truth.".to_string(),
711 ],
712 }
713}
714
715fn spawn_daemon(project_root: &Path, resume: bool) -> Result<u32> {
720 use std::process::{Command, Stdio};
721
722 let log_path = daemon_log_path(project_root);
723 let pid_path = daemon_pid_path(project_root);
724
725 if let Some(parent) = log_path.parent() {
727 std::fs::create_dir_all(parent)?;
728 }
729
730 let log_file = open_log_for_append(&log_path)?;
731 let log_err = log_file
732 .try_clone()
733 .context("failed to clone log file handle")?;
734
735 let exe = std::env::current_exe().context("failed to resolve current executable")?;
736 let root_str = project_root
737 .canonicalize()
738 .unwrap_or_else(|_| project_root.to_path_buf())
739 .to_string_lossy()
740 .to_string();
741
742 let mut cmd = Command::new(exe);
743 let args = daemon_spawn_args(&root_str, resume);
744 cmd.args(&args)
745 .stdin(Stdio::null())
746 .stdout(log_file)
747 .stderr(log_err);
748
749 #[cfg(unix)]
751 {
752 use std::os::unix::process::CommandExt;
753 cmd.process_group(0);
754 }
755
756 let mut child = cmd.spawn().context("failed to spawn daemon process")?;
757 let pid = child.id();
758
759 std::thread::sleep(std::time::Duration::from_millis(500));
762 match child.try_wait() {
763 Ok(Some(status)) => {
764 let _ = std::fs::remove_file(&pid_path);
765 let tail = std::fs::read_to_string(&log_path)
767 .ok()
768 .and_then(|s| {
769 let lines: Vec<&str> = s.lines().collect();
770 let start = lines.len().saturating_sub(5);
771 let tail = lines[start..].join("\n");
772 if tail.trim().is_empty() { None } else { Some(tail) }
773 });
774 match tail {
775 Some(detail) => bail!(
776 "daemon process exited immediately with {status}\n\n\
777 {detail}\n\n\
778 see full log: {log}",
779 log = log_path.display(),
780 ),
781 None => bail!(
782 "daemon process exited immediately with {status}; \
783 see {log} for details",
784 log = log_path.display(),
785 ),
786 }
787 }
788 Ok(None) => {} Err(e) => {
790 warn!(pid, error = %e, "failed to check daemon process status");
791 }
792 }
793
794 std::fs::write(&pid_path, pid.to_string())
795 .with_context(|| format!("failed to write PID file: {}", pid_path.display()))?;
796
797 info!(pid, log = %log_path.display(), "daemon spawned");
798 Ok(pid)
799}
800
801fn read_daemon_pid(project_root: &Path) -> Option<u32> {
803 let pid_path = daemon_pid_path(project_root);
804 let pid_str = std::fs::read_to_string(pid_path).ok()?;
805 pid_str.trim().parse::<u32>().ok()
806}
807
808#[cfg(unix)]
809fn send_unix_signal(pid: u32, signal: libc::c_int) -> bool {
810 let status = unsafe { libc::kill(pid as libc::pid_t, signal) };
811 if status == 0 {
812 true
813 } else {
814 let error = std::io::Error::last_os_error();
815 warn!(pid, signal, error = %error, "failed to signal daemon");
816 false
817 }
818}
819
820#[cfg(not(unix))]
821fn send_unix_signal(_pid: u32, _signal: i32) -> bool {
822 false
823}
824
825#[cfg(unix)]
826fn daemon_process_exists(pid: u32) -> bool {
827 let status = unsafe { libc::kill(pid as libc::pid_t, 0) };
828 if status == 0 {
829 true
830 } else {
831 !matches!(
832 std::io::Error::last_os_error().raw_os_error(),
833 Some(libc::ESRCH)
834 )
835 }
836}
837
838#[cfg(not(unix))]
839fn daemon_process_exists(_pid: u32) -> bool {
840 false
841}
842
843fn wait_for_graceful_daemon_shutdown(
844 project_root: &Path,
845 pid: u32,
846 previous_saved_at: Option<u64>,
847 timeout: Duration,
848) -> bool {
849 let deadline = std::time::Instant::now() + timeout;
850 loop {
851 let clean_snapshot = daemon_state_indicates_clean_shutdown(project_root, previous_saved_at);
852 if clean_snapshot {
853 let _ = std::fs::remove_file(daemon_pid_path(project_root));
854 return true;
855 }
856 let running = daemon_process_exists(pid);
857 if !running {
858 let _ = std::fs::remove_file(daemon_pid_path(project_root));
859 return false;
860 }
861 if std::time::Instant::now() >= deadline {
862 return false;
863 }
864 std::thread::sleep(DAEMON_SHUTDOWN_POLL_INTERVAL);
865 }
866}
867
868fn request_graceful_daemon_shutdown(project_root: &Path, timeout: Duration) -> bool {
869 let Some(pid) = read_daemon_pid(project_root) else {
870 return true;
871 };
872
873 let previous_saved_at = read_daemon_state_probe(project_root).and_then(|state| state.saved_at);
874 #[cfg(unix)]
875 {
876 if !send_unix_signal(pid, libc::SIGTERM) {
877 return false;
878 }
879 info!(pid, "sent SIGTERM to daemon");
880 }
881 #[cfg(not(unix))]
882 {
883 warn!(
884 pid,
885 "graceful daemon shutdown is not supported on this platform"
886 );
887 return false;
888 }
889
890 wait_for_graceful_daemon_shutdown(project_root, pid, previous_saved_at, timeout)
891}
892
893fn force_kill_daemon(project_root: &Path) {
894 let Some(pid) = read_daemon_pid(project_root) else {
895 return;
896 };
897
898 #[cfg(unix)]
899 {
900 if send_unix_signal(pid, libc::SIGKILL) {
901 info!(pid, "sent SIGKILL to daemon");
902 }
903 }
904 #[cfg(not(unix))]
905 {
906 warn!(pid, "cannot force-kill daemon on this platform");
907 }
908
909 let _ = std::fs::remove_file(daemon_pid_path(project_root));
910}
911
912pub fn start_team(project_root: &Path, attach: bool) -> Result<String> {
917 let config_path = team_config_path(project_root);
918 if !config_path.exists() {
919 bail!(
920 "no team config found at {}; run `batty init` first",
921 config_path.display()
922 );
923 }
924
925 let team_config = config::TeamConfig::load(&config_path)?;
926 team_config.validate()?;
927
928 let members = hierarchy::resolve_hierarchy(&team_config)?;
929 let session = format!("batty-{}", team_config.name);
930
931 if tmux::session_exists(&session) {
932 bail!("session '{session}' already exists; use `batty attach` or `batty stop` first");
933 }
934
935 layout::build_layout(
936 &session,
937 &members,
938 &team_config.layout,
939 project_root,
940 team_config.workflow_mode,
941 team_config.orchestrator_enabled(),
942 team_config.orchestrator_position,
943 )?;
944
945 let inboxes = inbox::inboxes_root(project_root);
947 for member in &members {
948 inbox::init_inbox(&inboxes, &member.name)?;
949 }
950
951 let marker = resume_marker_path(project_root);
953 let resume = marker.exists() || should_resume_from_daemon_state(project_root);
954 if resume {
955 if marker.exists() {
956 std::fs::remove_file(&marker).ok();
958 }
959 info!("resuming agent sessions from previous run");
960 }
961
962 info!(session = %session, members = members.len(), resume, "team session started");
963
964 let pid = spawn_daemon(project_root, resume)?;
966 info!(pid, "daemon process launched");
967
968 std::thread::sleep(std::time::Duration::from_secs(2));
970
971 if attach {
972 tmux::attach(&session)?;
973 }
974
975 Ok(session)
976}
977
978pub fn run_daemon(project_root: &Path, resume: bool) -> Result<()> {
982 let config_path = team_config_path(project_root);
983 if !config_path.exists() {
984 bail!(
985 "no team config found at {}; run `batty init` first",
986 config_path.display()
987 );
988 }
989
990 let team_config = config::TeamConfig::load(&config_path)?;
991 let members = hierarchy::resolve_hierarchy(&team_config)?;
992 let session = format!("batty-{}", team_config.name);
993
994 for _ in 0..30 {
996 if tmux::session_exists(&session) {
997 break;
998 }
999 std::thread::sleep(std::time::Duration::from_millis(200));
1000 }
1001
1002 if !tmux::session_exists(&session) {
1003 bail!("tmux session '{session}' not found — did `batty start` create it?");
1004 }
1005
1006 let mut pane_map = std::collections::HashMap::new();
1008 for member in &members {
1009 if let Some(pane_id) = find_pane_for_member(&session, &member.name) {
1011 pane_map.insert(member.name.clone(), pane_id);
1012 }
1013 }
1014
1015 let daemon_config = daemon::DaemonConfig {
1016 project_root: project_root.to_path_buf(),
1017 team_config,
1018 session,
1019 members,
1020 pane_map,
1021 };
1022
1023 let events_path = project_root
1024 .join(".batty")
1025 .join("team_config")
1026 .join("events.jsonl");
1027
1028 let mut d = daemon::TeamDaemon::new(daemon_config)?;
1029
1030 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| d.run(resume)));
1032
1033 match result {
1034 Ok(Ok(())) => Ok(()),
1035 Ok(Err(e)) => {
1036 eprintln!("daemon exited with error: {e:#}");
1037 if let Ok(mut sink) = events::EventSink::new(&events_path) {
1039 let _ = sink.emit(events::TeamEvent::daemon_stopped_with_reason(
1040 &format!("error: {e:#}"),
1041 0,
1042 ));
1043 }
1044 Err(e)
1045 }
1046 Err(panic_payload) => {
1047 let reason = match panic_payload.downcast_ref::<&str>() {
1048 Some(s) => s.to_string(),
1049 None => match panic_payload.downcast_ref::<String>() {
1050 Some(s) => s.clone(),
1051 None => "unknown panic".to_string(),
1052 },
1053 };
1054 eprintln!("daemon panicked: {reason}");
1055 if let Ok(mut sink) = events::EventSink::new(&events_path) {
1057 let _ = sink.emit(events::TeamEvent::daemon_panic(&reason));
1058 }
1059 std::panic::resume_unwind(panic_payload);
1060 }
1061 }
1062}
1063
1064fn find_pane_for_member(session: &str, member_name: &str) -> Option<String> {
1066 let output = std::process::Command::new("tmux")
1067 .args([
1068 "list-panes",
1069 "-t",
1070 session,
1071 "-F",
1072 "#{pane_id} #{@batty_role}",
1073 ])
1074 .output()
1075 .ok()?;
1076
1077 if !output.status.success() {
1078 return None;
1079 }
1080
1081 let stdout = String::from_utf8_lossy(&output.stdout);
1082 for line in stdout.lines() {
1083 let parts: Vec<&str> = line.splitn(2, ' ').collect();
1084 if parts.len() == 2 && parts[1] == member_name {
1085 return Some(parts[0].to_string());
1086 }
1087 }
1088 None
1089}
1090
1091fn resume_marker_path(project_root: &Path) -> PathBuf {
1093 project_root.join(".batty").join("resume")
1094}
1095
1096#[derive(Debug, Deserialize)]
1097struct DaemonStateResumeProbe {
1098 #[serde(default)]
1099 clean_shutdown: bool,
1100 #[serde(default)]
1101 saved_at: Option<u64>,
1102}
1103
1104fn read_daemon_state_probe(project_root: &Path) -> Option<DaemonStateResumeProbe> {
1105 let path = daemon_state_path(project_root);
1106 let content = std::fs::read_to_string(&path).ok()?;
1107
1108 match serde_json::from_str::<DaemonStateResumeProbe>(&content) {
1109 Ok(state) => Some(state),
1110 Err(error) => {
1111 warn!(
1112 path = %path.display(),
1113 error = %error,
1114 "failed to parse daemon state while probing for resume"
1115 );
1116 None
1117 }
1118 }
1119}
1120
1121fn daemon_state_indicates_clean_shutdown(
1122 project_root: &Path,
1123 previous_saved_at: Option<u64>,
1124) -> bool {
1125 let Some(state) = read_daemon_state_probe(project_root) else {
1126 return false;
1127 };
1128
1129 state.clean_shutdown
1130 && match (state.saved_at, previous_saved_at) {
1131 (Some(saved_at), Some(previous_saved_at)) => saved_at > previous_saved_at,
1132 (Some(_), None) => true,
1133 (None, Some(_)) => false,
1134 (None, None) => true,
1135 }
1136}
1137
1138fn should_resume_from_daemon_state(project_root: &Path) -> bool {
1139 read_daemon_state_probe(project_root)
1140 .map(|state| !state.clean_shutdown)
1141 .unwrap_or(false)
1142}
1143
1144pub fn pause_marker_path(project_root: &Path) -> PathBuf {
1146 project_root.join(".batty").join("paused")
1147}
1148
1149pub fn pause_team(project_root: &Path) -> Result<()> {
1151 let marker = pause_marker_path(project_root);
1152 if marker.exists() {
1153 bail!("Team is already paused.");
1154 }
1155 if let Some(parent) = marker.parent() {
1156 std::fs::create_dir_all(parent).ok();
1157 }
1158 std::fs::write(&marker, "").context("failed to write pause marker")?;
1159 info!("paused nudges and standups");
1160 Ok(())
1161}
1162
1163pub fn resume_team(project_root: &Path) -> Result<()> {
1165 let marker = pause_marker_path(project_root);
1166 if !marker.exists() {
1167 bail!("Team is not paused.");
1168 }
1169 std::fs::remove_file(&marker).context("failed to remove pause marker")?;
1170 info!("resumed nudges and standups");
1171 Ok(())
1172}
1173
1174pub fn nudge_disabled_marker_path(project_root: &Path, intervention: &str) -> PathBuf {
1176 project_root
1177 .join(".batty")
1178 .join(format!("nudge_{intervention}_disabled"))
1179}
1180
1181pub fn disable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
1183 let marker = nudge_disabled_marker_path(project_root, intervention);
1184 if marker.exists() {
1185 bail!("Intervention '{intervention}' is already disabled.");
1186 }
1187 if let Some(parent) = marker.parent() {
1188 std::fs::create_dir_all(parent).ok();
1189 }
1190 std::fs::write(&marker, "").context("failed to write nudge disabled marker")?;
1191 info!(intervention, "disabled intervention");
1192 Ok(())
1193}
1194
1195pub fn enable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
1197 let marker = nudge_disabled_marker_path(project_root, intervention);
1198 if !marker.exists() {
1199 bail!("Intervention '{intervention}' is not disabled.");
1200 }
1201 std::fs::remove_file(&marker).context("failed to remove nudge disabled marker")?;
1202 info!(intervention, "enabled intervention");
1203 Ok(())
1204}
1205
1206pub fn nudge_status(project_root: &Path) -> Result<()> {
1208 use crate::cli::NudgeIntervention;
1209
1210 let config_path = team_config_path(project_root);
1211 let automation = if config_path.exists() {
1212 let team_config = config::TeamConfig::load(&config_path)?;
1213 Some(team_config.automation)
1214 } else {
1215 None
1216 };
1217
1218 println!(
1219 "{:<16} {:<10} {:<10} {:<10}",
1220 "INTERVENTION", "CONFIG", "RUNTIME", "EFFECTIVE"
1221 );
1222
1223 for intervention in NudgeIntervention::ALL {
1224 let name = intervention.marker_name();
1225 let config_enabled = automation
1226 .as_ref()
1227 .map(|a| match intervention {
1228 NudgeIntervention::Replenish => true, NudgeIntervention::Triage => a.triage_interventions,
1230 NudgeIntervention::Review => a.review_interventions,
1231 NudgeIntervention::Dispatch => a.manager_dispatch_interventions,
1232 NudgeIntervention::Utilization => a.architect_utilization_interventions,
1233 NudgeIntervention::OwnedTask => a.owned_task_interventions,
1234 })
1235 .unwrap_or(true);
1236
1237 let runtime_disabled = nudge_disabled_marker_path(project_root, name).exists();
1238 let runtime_str = if runtime_disabled {
1239 "disabled"
1240 } else {
1241 "enabled"
1242 };
1243 let config_str = if config_enabled {
1244 "enabled"
1245 } else {
1246 "disabled"
1247 };
1248 let effective = config_enabled && !runtime_disabled;
1249 let effective_str = if effective { "enabled" } else { "DISABLED" };
1250
1251 println!(
1252 "{:<16} {:<10} {:<10} {:<10}",
1253 name, config_str, runtime_str, effective_str
1254 );
1255 }
1256
1257 Ok(())
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Eq)]
1263pub(crate) struct SessionSummary {
1264 pub tasks_completed: u32,
1265 pub tasks_merged: u32,
1266 pub runtime_secs: u64,
1267}
1268
1269impl SessionSummary {
1270 pub fn display(&self) -> String {
1271 format!(
1272 "Session summary: {} tasks completed, {} merged, runtime {}",
1273 self.tasks_completed,
1274 self.tasks_merged,
1275 format_runtime(self.runtime_secs),
1276 )
1277 }
1278}
1279
1280fn format_runtime(secs: u64) -> String {
1281 if secs < 60 {
1282 format!("{secs}s")
1283 } else if secs < 3600 {
1284 format!("{}m", secs / 60)
1285 } else {
1286 let hours = secs / 3600;
1287 let mins = (secs % 3600) / 60;
1288 if mins == 0 {
1289 format!("{hours}h")
1290 } else {
1291 format!("{hours}h {mins}m")
1292 }
1293 }
1294}
1295
1296pub(crate) fn compute_session_summary(project_root: &Path) -> Option<SessionSummary> {
1302 let events_path = team_events_path(project_root);
1303 let all_events = events::read_events(&events_path).ok()?;
1304
1305 let session_start = all_events
1307 .iter()
1308 .rev()
1309 .find(|e| e.event == "daemon_started")?;
1310 let start_ts = session_start.ts;
1311 let now_ts = now_unix();
1312
1313 let session_events: Vec<_> = all_events.iter().filter(|e| e.ts >= start_ts).collect();
1314
1315 let tasks_completed = session_events
1316 .iter()
1317 .filter(|e| e.event == "task_completed")
1318 .count() as u32;
1319
1320 let tasks_merged = session_events
1321 .iter()
1322 .filter(|e| e.event == "task_auto_merged" || e.event == "task_manual_merged")
1323 .count() as u32;
1324
1325 let runtime_secs = now_ts.saturating_sub(start_ts);
1326
1327 Some(SessionSummary {
1328 tasks_completed,
1329 tasks_merged,
1330 runtime_secs,
1331 })
1332}
1333
1334pub fn stop_team(project_root: &Path) -> Result<()> {
1335 let summary = compute_session_summary(project_root);
1337
1338 let marker = resume_marker_path(project_root);
1340 if let Some(parent) = marker.parent() {
1341 std::fs::create_dir_all(parent).ok();
1342 }
1343 std::fs::write(&marker, "").ok();
1344
1345 if !request_graceful_daemon_shutdown(project_root, DAEMON_SHUTDOWN_GRACE_PERIOD) {
1347 warn!("daemon did not stop gracefully; forcing shutdown");
1348 force_kill_daemon(project_root);
1349 }
1350
1351 let config_path = team_config_path(project_root);
1352 let primary_session = if config_path.exists() {
1353 let team_config = config::TeamConfig::load(&config_path)?;
1354 Some(format!("batty-{}", team_config.name))
1355 } else {
1356 None
1357 };
1358
1359 match &primary_session {
1361 Some(session) if tmux::session_exists(session) => {
1362 tmux::kill_session(session)?;
1363 info!(session = %session, "team session stopped");
1364 }
1365 Some(session) => {
1366 info!(session = %session, "no running session to stop");
1367 }
1368 None => {
1369 bail!("no team config found at {}", config_path.display());
1370 }
1371 }
1372
1373 if let Some(summary) = summary {
1375 println!();
1376 println!("{}", summary.display());
1377 }
1378
1379 Ok(())
1380}
1381
1382pub fn attach_team(project_root: &Path) -> Result<()> {
1387 let config_path = team_config_path(project_root);
1388
1389 let session = if config_path.exists() {
1390 let team_config = config::TeamConfig::load(&config_path)?;
1391 format!("batty-{}", team_config.name)
1392 } else {
1393 let mut sessions = tmux::list_sessions_with_prefix("batty-");
1395 match sessions.len() {
1396 0 => bail!("no team config found and no batty sessions running"),
1397 1 => sessions.swap_remove(0),
1398 _ => {
1399 let list = sessions.join(", ");
1400 bail!(
1401 "no team config found and multiple batty sessions running: {list}\n\
1402 Run from the project directory, or use: tmux attach -t <session>"
1403 );
1404 }
1405 }
1406 };
1407
1408 if !tmux::session_exists(&session) {
1409 bail!("no running session '{session}'; run `batty start` first");
1410 }
1411
1412 tmux::attach(&session)
1413}
1414
1415pub fn team_status(project_root: &Path, json: bool) -> Result<()> {
1417 let config_path = team_config_path(project_root);
1418 if !config_path.exists() {
1419 bail!("no team config found at {}", config_path.display());
1420 }
1421
1422 let team_config = config::TeamConfig::load(&config_path)?;
1423 let members = hierarchy::resolve_hierarchy(&team_config)?;
1424 let session = format!("batty-{}", team_config.name);
1425 let session_running = tmux::session_exists(&session);
1426 let runtime_statuses = if session_running {
1427 match status::list_runtime_member_statuses(&session) {
1428 Ok(statuses) => statuses,
1429 Err(error) => {
1430 warn!(session = %session, error = %error, "failed to read live runtime statuses");
1431 std::collections::HashMap::new()
1432 }
1433 }
1434 } else {
1435 std::collections::HashMap::new()
1436 };
1437 let pending_inbox_counts = status::pending_inbox_counts(project_root, &members);
1438 let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
1439 let owned_task_buckets = status::owned_task_buckets(project_root, &members);
1440 let agent_health = status::agent_health_by_member(project_root, &members);
1441 let paused = pause_marker_path(project_root).exists();
1442 let mut rows = status::build_team_status_rows(
1443 &members,
1444 session_running,
1445 &runtime_statuses,
1446 &pending_inbox_counts,
1447 &triage_backlog_counts,
1448 &owned_task_buckets,
1449 &agent_health,
1450 );
1451
1452 let active_task_elapsed: Vec<(u32, u64)> = rows
1454 .iter()
1455 .filter(|row| !row.active_owned_tasks.is_empty())
1456 .flat_map(|row| {
1457 let elapsed = row.health.task_elapsed_secs.unwrap_or(0);
1458 row.active_owned_tasks
1459 .iter()
1460 .map(move |&task_id| (task_id, elapsed))
1461 })
1462 .collect();
1463 let etas = estimation::compute_etas(project_root, &active_task_elapsed);
1464 for row in &mut rows {
1465 if let Some(&task_id) = row.active_owned_tasks.first() {
1466 if let Some(eta) = etas.get(&task_id) {
1467 row.eta = eta.clone();
1468 }
1469 }
1470 }
1471
1472 let workflow_metrics = status::workflow_metrics_section(project_root, &members);
1473 let (active_tasks, review_queue) = match status::board_status_task_queues(project_root) {
1474 Ok(queues) => queues,
1475 Err(error) => {
1476 warn!(error = %error, "failed to load board task queues for status json");
1477 (Vec::new(), Vec::new())
1478 }
1479 };
1480
1481 if json {
1482 let report = status::build_team_status_json_report(status::TeamStatusJsonReportInput {
1483 team: team_config.name.clone(),
1484 session: session.clone(),
1485 session_running,
1486 paused,
1487 workflow_metrics: workflow_metrics
1488 .as_ref()
1489 .map(|(_, metrics)| metrics.clone()),
1490 active_tasks,
1491 review_queue,
1492 members: rows,
1493 });
1494 println!("{}", serde_json::to_string_pretty(&report)?);
1495 } else {
1496 println!("Team: {}", team_config.name);
1497 println!(
1498 "Session: {} ({})",
1499 session,
1500 if session_running {
1501 "running"
1502 } else {
1503 "stopped"
1504 }
1505 );
1506 println!();
1507 println!(
1508 "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
1509 "MEMBER",
1510 "ROLE",
1511 "AGENT",
1512 "STATE",
1513 "INBOX",
1514 "TRIAGE",
1515 "ACTIVE",
1516 "REVIEW",
1517 "ETA",
1518 "HEALTH",
1519 "SIGNAL",
1520 "REPORTS TO"
1521 );
1522 println!("{}", "-".repeat(195));
1523 for row in &rows {
1524 println!(
1525 "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
1526 row.name,
1527 row.role,
1528 row.agent.as_deref().unwrap_or("-"),
1529 row.state,
1530 row.pending_inbox,
1531 row.triage_backlog,
1532 status::format_owned_tasks_summary(&row.active_owned_tasks),
1533 status::format_owned_tasks_summary(&row.review_owned_tasks),
1534 row.eta,
1535 row.health_summary,
1536 row.signal.as_deref().unwrap_or("-"),
1537 row.reports_to.as_deref().unwrap_or("-"),
1538 );
1539 }
1540 if let Some((formatted, _)) = workflow_metrics {
1541 println!();
1542 println!("{formatted}");
1543 }
1544 }
1545
1546 Ok(())
1547}
1548
1549pub fn show_load(project_root: &Path) -> Result<()> {
1551 let current = capture_team_load(project_root)?;
1552 if let Err(error) = log_team_load_snapshot(project_root, ¤t) {
1553 warn!(error = %error, "failed to append load snapshot to team event log");
1554 }
1555
1556 let mut history = read_team_load_history(project_root)?;
1557 history.push(current);
1558 history.sort_by_key(|snapshot| snapshot.timestamp);
1559
1560 println!(
1561 "Current load: {:.1}% ({} / {} members working)",
1562 current.load * 100.0,
1563 current.working_members,
1564 current.total_members.max(1)
1565 );
1566 println!(
1567 "Session: {}",
1568 if current.session_running {
1569 "running"
1570 } else {
1571 "stopped"
1572 }
1573 );
1574
1575 if let Some(avg) = average_load(&history, current.timestamp, 10 * 60) {
1576 println!("10m avg: {:.1}%", avg * 100.0);
1577 } else {
1578 println!("10m avg: n/a");
1579 }
1580 println!(
1581 "30m avg: {}",
1582 average_load(&history, current.timestamp, 30 * 60)
1583 .map(|avg| format!("{:.1}%", avg * 100.0))
1584 .unwrap_or_else(|| "n/a".to_string())
1585 );
1586 println!(
1587 "60m avg: {}",
1588 average_load(&history, current.timestamp, 60 * 60)
1589 .map(|avg| format!("{:.1}%", avg * 100.0))
1590 .unwrap_or_else(|| "n/a".to_string())
1591 );
1592
1593 println!("Load graph (1h):");
1594 println!("{}", render_load_graph(&history, current.timestamp));
1595 Ok(())
1596}
1597
1598fn capture_team_load(project_root: &Path) -> Result<TeamLoadSnapshot> {
1599 let config_path = team_config_path(project_root);
1600 if !config_path.exists() {
1601 bail!("no team config found at {}", config_path.display());
1602 }
1603
1604 let team_config = config::TeamConfig::load(&config_path)?;
1605 let members = hierarchy::resolve_hierarchy(&team_config)?;
1606 let session = format!("batty-{}", team_config.name);
1607 let session_running = tmux::session_exists(&session);
1608 let runtime_statuses = if session_running {
1609 match status::list_runtime_member_statuses(&session) {
1610 Ok(statuses) => statuses,
1611 Err(error) => {
1612 warn!(session = %session, error = %error, "failed to read runtime statuses for load sampling");
1613 std::collections::HashMap::new()
1614 }
1615 }
1616 } else {
1617 std::collections::HashMap::new()
1618 };
1619
1620 let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
1621 let owned_task_buckets = status::owned_task_buckets(project_root, &members);
1622 let rows = status::build_team_status_rows(
1623 &members,
1624 session_running,
1625 &runtime_statuses,
1626 &Default::default(),
1627 &triage_backlog_counts,
1628 &owned_task_buckets,
1629 &Default::default(),
1630 );
1631 let mut total_members = 0usize;
1632 let mut working_members = 0usize;
1633
1634 for row in &rows {
1635 if row.role_type == "User" {
1636 continue;
1637 }
1638 total_members += 1;
1639 if counts_as_active_load(row) {
1640 working_members += 1;
1641 }
1642 }
1643
1644 let load = if total_members == 0 {
1645 0.0
1646 } else {
1647 working_members as f64 / total_members as f64
1648 };
1649
1650 Ok(TeamLoadSnapshot {
1651 timestamp: now_unix(),
1652 total_members,
1653 working_members: working_members.min(total_members),
1654 load,
1655 session_running,
1656 })
1657}
1658
1659fn counts_as_active_load(row: &status::TeamStatusRow) -> bool {
1660 matches!(row.state.as_str(), "working" | "triaging" | "reviewing")
1661}
1662
1663fn log_team_load_snapshot(project_root: &Path, snapshot: &TeamLoadSnapshot) -> Result<()> {
1664 let events_path = team_events_path(project_root);
1665 let mut sink = events::EventSink::new(&events_path)?;
1666 let event = events::TeamEvent::load_snapshot(
1667 snapshot.working_members as u32,
1668 snapshot.total_members as u32,
1669 snapshot.session_running,
1670 );
1671 sink.emit(event)?;
1672 Ok(())
1673}
1674
1675fn read_team_load_history(project_root: &Path) -> Result<Vec<TeamLoadSnapshot>> {
1676 let events_path = team_events_path(project_root);
1677 let events = events::read_events(&events_path)?;
1678 let mut history = Vec::new();
1679 for event in events {
1680 if event.event != "load_snapshot" {
1681 continue;
1682 }
1683 let Some(load) = event.load else {
1684 continue;
1685 };
1686 let Some(working_members) = event.working_members else {
1687 continue;
1688 };
1689 let Some(total_members) = event.total_members else {
1690 continue;
1691 };
1692
1693 history.push(TeamLoadSnapshot {
1694 timestamp: event.ts,
1695 total_members: total_members as usize,
1696 working_members: working_members as usize,
1697 load,
1698 session_running: event.session_running.unwrap_or(false),
1699 });
1700 }
1701 Ok(history)
1702}
1703
1704fn average_load(samples: &[TeamLoadSnapshot], now: u64, window_seconds: u64) -> Option<f64> {
1705 let cutoff = now.saturating_sub(window_seconds);
1706 let mut values = Vec::new();
1707 for sample in samples {
1708 if sample.timestamp >= cutoff && sample.timestamp <= now {
1709 values.push(sample.load);
1710 }
1711 }
1712 if values.is_empty() {
1713 return None;
1714 }
1715 let sum: f64 = values.iter().copied().sum();
1716 Some(sum / values.len() as f64)
1717}
1718
1719fn render_load_graph(samples: &[TeamLoadSnapshot], now: u64) -> String {
1720 if samples.is_empty() {
1721 return "(no historical load data yet)".to_string();
1722 }
1723
1724 let bucket_size = (LOAD_GRAPH_WINDOW_SECONDS / LOAD_GRAPH_WIDTH as u64).max(1);
1725 let window_start = now.saturating_sub(LOAD_GRAPH_WINDOW_SECONDS);
1726 let mut history = String::new();
1727 let mut previous = 0.0;
1728 for index in 0..LOAD_GRAPH_WIDTH {
1729 let bucket_start = window_start + (index as u64 * bucket_size);
1730 let bucket_end = if index + 1 == LOAD_GRAPH_WIDTH {
1731 now + 1
1732 } else {
1733 bucket_start + bucket_size
1734 };
1735
1736 let mut sum = 0.0;
1737 let mut count = 0usize;
1738 for sample in samples {
1739 if sample.timestamp >= bucket_start && sample.timestamp < bucket_end {
1740 sum += sample.load;
1741 count += 1;
1742 }
1743 }
1744
1745 let value = if count == 0 {
1746 previous
1747 } else {
1748 sum / count as f64
1749 };
1750 previous = value;
1751 history.push(load_point_char(value));
1752 }
1753
1754 history
1755}
1756
1757fn load_point_char(value: f64) -> char {
1758 let clamped = value.clamp(0.0, 1.0);
1759 match (clamped * 5.0).round() as usize {
1760 0 => ' ',
1761 1 => '.',
1762 2 => ':',
1763 3 => '=',
1764 4 => '#',
1765 _ => '@',
1766 }
1767}
1768
1769pub fn validate_team(project_root: &Path, verbose: bool) -> Result<()> {
1771 let config_path = team_config_path(project_root);
1772 if !config_path.exists() {
1773 bail!("no team config found at {}", config_path.display());
1774 }
1775
1776 let team_config = config::TeamConfig::load(&config_path)?;
1777
1778 if verbose {
1779 let checks = team_config.validate_verbose();
1780 let mut any_failed = false;
1781 for check in &checks {
1782 let status = if check.passed { "PASS" } else { "FAIL" };
1783 println!("[{status}] {}: {}", check.name, check.detail);
1784 if !check.passed {
1785 any_failed = true;
1786 }
1787 }
1788 if any_failed {
1789 bail!("validation failed — see FAIL checks above");
1790 }
1791 } else {
1792 team_config.validate()?;
1793 }
1794
1795 let workflow_mode_is_explicit = workflow_mode_declared(&config_path)?;
1796
1797 let members = hierarchy::resolve_hierarchy(&team_config)?;
1798
1799 println!("Config: {}", config_path.display());
1800 println!("Team: {}", team_config.name);
1801 println!(
1802 "Workflow mode: {}",
1803 match team_config.workflow_mode {
1804 config::WorkflowMode::Legacy => "legacy",
1805 config::WorkflowMode::Hybrid => "hybrid",
1806 config::WorkflowMode::WorkflowFirst => "workflow_first",
1807 }
1808 );
1809 println!("Roles: {}", team_config.roles.len());
1810 println!("Total members: {}", members.len());
1811 for note in migration_validation_notes(&team_config, workflow_mode_is_explicit) {
1812 println!("{note}");
1813 }
1814 println!("Valid.");
1815 Ok(())
1816}
1817
1818fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
1821 if matches!(member_name, "human" | "daemon") {
1823 return member_name.to_string();
1824 }
1825 let config_path = team_config_path(project_root);
1826 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1827 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
1828 if let Some(m) = members.iter().find(|m| m.name == member_name) {
1829 return m.role_name.clone();
1830 }
1831 }
1832 }
1833 member_name.to_string()
1835}
1836
1837fn resolve_member_name(project_root: &Path, member_name: &str) -> Result<String> {
1844 if matches!(member_name, "human" | "daemon") {
1845 return Ok(member_name.to_string());
1846 }
1847
1848 let config_path = team_config_path(project_root);
1849 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1850 if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
1851 if let Some(member) = members.iter().find(|m| m.name == member_name) {
1852 return Ok(member.name.clone());
1853 }
1854
1855 let matches: Vec<String> = members
1856 .iter()
1857 .filter(|m| m.role_name == member_name)
1858 .map(|m| m.name.clone())
1859 .collect();
1860
1861 return match matches.len() {
1862 0 => Ok(member_name.to_string()),
1863 1 => Ok(matches[0].clone()),
1864 _ => bail!(
1865 "'{member_name}' matches multiple members: {}. Use the explicit member name.",
1866 matches.join(", ")
1867 ),
1868 };
1869 }
1870 }
1871
1872 Ok(member_name.to_string())
1873}
1874
1875pub fn send_message(project_root: &Path, role: &str, msg: &str) -> Result<()> {
1881 let from = detect_sender().unwrap_or_else(|| "human".to_string());
1882 let recipient = resolve_member_name(project_root, role)?;
1883
1884 let config_path = team_config_path(project_root);
1886 if config_path.exists() {
1887 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1888 let from_role = resolve_role_name(project_root, &from);
1889 let to_role = resolve_role_name(project_root, &recipient);
1890 if !team_config.can_talk(&from_role, &to_role) {
1891 bail!(
1892 "{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
1893 Check talks_to in team.yaml."
1894 );
1895 }
1896 }
1897 }
1898
1899 let root = inbox::inboxes_root(project_root);
1900 let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
1901 let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
1902 if let Err(error) = completion::ingest_completion_message(project_root, msg) {
1903 warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
1904 }
1905 info!(to = %recipient, id = %id, "message delivered to inbox");
1906 Ok(())
1907}
1908
1909fn detect_sender() -> Option<String> {
1912 let pane_id = std::env::var("TMUX_PANE").ok()?;
1913 let output = std::process::Command::new("tmux")
1914 .args(["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"])
1915 .output()
1916 .ok()?;
1917 if output.status.success() {
1918 let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
1919 if !role.is_empty() { Some(role) } else { None }
1920 } else {
1921 None
1922 }
1923}
1924
1925pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
1927 let from = detect_sender().unwrap_or_else(|| "human".to_string());
1928 let recipient = resolve_member_name(project_root, engineer)?;
1929
1930 let config_path = team_config_path(project_root);
1931 if config_path.exists() {
1932 if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1933 let from_role = resolve_role_name(project_root, &from);
1934 let to_role = resolve_role_name(project_root, &recipient);
1935 if !team_config.can_talk(&from_role, &to_role) {
1936 bail!(
1937 "{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
1938 Check talks_to in team.yaml."
1939 );
1940 }
1941 }
1942 }
1943
1944 let root = inbox::inboxes_root(project_root);
1945 let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
1946 let id = inbox::deliver_to_inbox(&root, &msg)?;
1947 info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
1948 Ok(id)
1949}
1950
1951pub fn list_inbox(project_root: &Path, member: &str, limit: Option<usize>) -> Result<()> {
1953 let member = resolve_member_name(project_root, member)?;
1954 let root = inbox::inboxes_root(project_root);
1955 let messages = inbox::all_messages(&root, &member)?;
1956 print!("{}", format_inbox_listing(&member, &messages, limit));
1957 Ok(())
1958}
1959
1960fn format_inbox_listing(
1961 member: &str,
1962 messages: &[(inbox::InboxMessage, bool)],
1963 limit: Option<usize>,
1964) -> String {
1965 if messages.is_empty() {
1966 return format!("No messages for {member}.\n");
1967 }
1968
1969 let start = match limit {
1970 Some(0) => messages.len(),
1971 Some(n) => messages.len().saturating_sub(n),
1972 None => 0,
1973 };
1974 let shown = &messages[start..];
1975 let refs = inbox_message_refs(messages);
1976 let shown_refs = &refs[start..];
1977
1978 let mut out = String::new();
1979 if shown.len() < messages.len() {
1980 out.push_str(&format!(
1981 "Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
1982 shown.len(),
1983 messages.len()
1984 ));
1985 }
1986 out.push_str(&format!(
1987 "{:<10} {:<12} {:<12} {:<14} BODY\n",
1988 "STATUS", "FROM", "TYPE", "REF"
1989 ));
1990 out.push_str(&format!("{}\n", "-".repeat(96)));
1991 for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
1992 let status = if *delivered { "delivered" } else { "pending" };
1993 let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
1994 out.push_str(&format!(
1995 "{:<10} {:<12} {:<12} {:<14} {}\n",
1996 status,
1997 msg.from,
1998 format!("{:?}", msg.msg_type).to_lowercase(),
1999 msg_ref,
2000 body_short,
2001 ));
2002 }
2003 out
2004}
2005
2006fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
2007 let mut totals = HashMap::new();
2008 for (msg, _) in messages {
2009 *totals.entry(msg.timestamp).or_insert(0usize) += 1;
2010 }
2011
2012 let mut seen = HashMap::new();
2013 messages
2014 .iter()
2015 .map(|(msg, _)| {
2016 let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
2017 *ordinal += 1;
2018 if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
2019 msg.timestamp.to_string()
2020 } else {
2021 format!("{}-{}", msg.timestamp, ordinal)
2022 }
2023 })
2024 .collect()
2025}
2026
2027fn resolve_inbox_message_indices(
2028 messages: &[(inbox::InboxMessage, bool)],
2029 selector: &str,
2030) -> Vec<usize> {
2031 let refs = inbox_message_refs(messages);
2032 messages
2033 .iter()
2034 .enumerate()
2035 .filter_map(|(idx, (msg, _))| {
2036 if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
2037 Some(idx)
2038 } else {
2039 None
2040 }
2041 })
2042 .collect()
2043}
2044
2045fn truncate_chars(input: &str, max_chars: usize) -> String {
2046 if input.chars().count() <= max_chars {
2047 return input.to_string();
2048 }
2049 let mut truncated: String = input.chars().take(max_chars).collect();
2050 truncated.push_str("...");
2051 truncated
2052}
2053
2054pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
2056 let member = resolve_member_name(project_root, member)?;
2057 let root = inbox::inboxes_root(project_root);
2058 let messages = inbox::all_messages(&root, &member)?;
2059
2060 let matching = resolve_inbox_message_indices(&messages, id);
2061
2062 match matching.len() {
2063 0 => bail!("no message matching '{id}' in {member}'s inbox"),
2064 1 => {
2065 let (msg, delivered) = &messages[matching[0]];
2066 let status = if *delivered { "delivered" } else { "pending" };
2067 println!("ID: {}", msg.id);
2068 println!("From: {}", msg.from);
2069 println!("To: {}", msg.to);
2070 println!("Type: {:?}", msg.msg_type);
2071 println!("Status: {status}");
2072 println!("Time: {}", msg.timestamp);
2073 println!();
2074 println!("{}", msg.body);
2075 }
2076 n => {
2077 bail!(
2078 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
2079 );
2080 }
2081 }
2082
2083 Ok(())
2084}
2085
2086pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
2088 let member = resolve_member_name(project_root, member)?;
2089 let root = inbox::inboxes_root(project_root);
2090 let messages = inbox::all_messages(&root, &member)?;
2091 let matching = resolve_inbox_message_indices(&messages, id);
2092 let resolved_id = match matching.len() {
2093 0 => bail!("no message matching '{id}' in {member}'s inbox"),
2094 1 => messages[matching[0]].0.id.clone(),
2095 n => bail!(
2096 "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
2097 ),
2098 };
2099 inbox::mark_delivered(&root, &member, &resolved_id)?;
2100 info!(member, id = %resolved_id, "message acknowledged");
2101 Ok(())
2102}
2103
2104pub fn purge_inbox(
2106 project_root: &Path,
2107 member: Option<&str>,
2108 all_roles: bool,
2109 before: Option<u64>,
2110 purge_all: bool,
2111) -> Result<inbox::InboxPurgeSummary> {
2112 if !purge_all && before.is_none() {
2113 bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
2114 }
2115
2116 let root = inbox::inboxes_root(project_root);
2117 if all_roles {
2118 return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
2119 }
2120
2121 let member = member.context("member is required unless using `--all-roles`")?;
2122 let member = resolve_member_name(project_root, member)?;
2123 let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
2124 Ok(inbox::InboxPurgeSummary { roles: 1, messages })
2125}
2126
2127pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
2129 let engineer = resolve_member_name(project_root, engineer)?;
2130 match merge::merge_engineer_branch(project_root, &engineer)? {
2131 merge::MergeOutcome::Success => Ok(()),
2132 merge::MergeOutcome::RebaseConflict(stderr) => {
2133 bail!("merge blocked by rebase conflict: {stderr}")
2134 }
2135 merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
2136 }
2137}
2138
2139pub fn setup_telegram(project_root: &Path) -> Result<()> {
2141 telegram::setup_telegram(project_root)
2142}
2143
2144#[cfg(test)]
2145mod tests {
2146 use super::status;
2147 use super::*;
2148 use crate::team::config::RoleType;
2149 use serial_test::serial;
2150 use std::ffi::OsString;
2151
2152 struct HomeGuard {
2153 original_home: Option<OsString>,
2154 }
2155
2156 impl HomeGuard {
2157 fn set(path: &Path) -> Self {
2158 let original_home = std::env::var_os("HOME");
2159 unsafe {
2160 std::env::set_var("HOME", path);
2161 }
2162 Self { original_home }
2163 }
2164 }
2165
2166 impl Drop for HomeGuard {
2167 fn drop(&mut self) {
2168 match &self.original_home {
2169 Some(home) => unsafe {
2170 std::env::set_var("HOME", home);
2171 },
2172 None => unsafe {
2173 std::env::remove_var("HOME");
2174 },
2175 }
2176 }
2177 }
2178
2179 struct EnvVarGuard {
2180 key: &'static str,
2181 original: Option<String>,
2182 }
2183
2184 impl EnvVarGuard {
2185 fn unset(key: &'static str) -> Self {
2186 let original = std::env::var(key).ok();
2187 unsafe {
2188 std::env::remove_var(key);
2189 }
2190 Self { key, original }
2191 }
2192 }
2193
2194 impl Drop for EnvVarGuard {
2195 fn drop(&mut self) {
2196 match self.original.as_deref() {
2197 Some(value) => unsafe {
2198 std::env::set_var(self.key, value);
2199 },
2200 None => unsafe {
2201 std::env::remove_var(self.key);
2202 },
2203 }
2204 }
2205 }
2206
2207 #[test]
2208 fn team_config_dir_is_under_batty() {
2209 let root = Path::new("/tmp/project");
2210 assert_eq!(
2211 team_config_dir(root),
2212 PathBuf::from("/tmp/project/.batty/team_config")
2213 );
2214 }
2215
2216 #[test]
2217 fn team_config_path_points_to_yaml() {
2218 let root = Path::new("/tmp/project");
2219 assert_eq!(
2220 team_config_path(root),
2221 PathBuf::from("/tmp/project/.batty/team_config/team.yaml")
2222 );
2223 }
2224
2225 #[test]
2226 fn init_team_creates_scaffolding() {
2227 let tmp = tempfile::tempdir().unwrap();
2228 let created = init_team(tmp.path(), "simple").unwrap();
2229 assert!(!created.is_empty());
2230 assert!(team_config_path(tmp.path()).exists());
2231 assert!(team_config_dir(tmp.path()).join("architect.md").exists());
2232 assert!(team_config_dir(tmp.path()).join("manager.md").exists());
2233 assert!(team_config_dir(tmp.path()).join("engineer.md").exists());
2234 assert!(
2235 team_config_dir(tmp.path())
2236 .join("replenishment_context.md")
2237 .exists()
2238 );
2239 assert!(
2240 team_config_dir(tmp.path())
2241 .join("review_policy.md")
2242 .exists()
2243 );
2244 assert!(
2245 team_config_dir(tmp.path())
2246 .join("escalation_policy.md")
2247 .exists()
2248 );
2249 let config = team_config_dir(tmp.path());
2251 assert!(config.join("board").is_dir() || config.join("kanban.md").exists());
2252 }
2253
2254 #[test]
2255 fn init_team_refuses_if_exists() {
2256 let tmp = tempfile::tempdir().unwrap();
2257 init_team(tmp.path(), "simple").unwrap();
2258 let result = init_team(tmp.path(), "simple");
2259 assert!(result.is_err());
2260 assert!(result.unwrap_err().to_string().contains("already exists"));
2261 }
2262
2263 #[test]
2264 #[serial]
2265 fn init_from_template_copies_files() {
2266 let project = tempfile::tempdir().unwrap();
2267 let home = tempfile::tempdir().unwrap();
2268 let _home_guard = HomeGuard::set(home.path());
2269
2270 let template_dir = home.path().join(".batty").join("templates").join("custom");
2271 std::fs::create_dir_all(template_dir.join("board")).unwrap();
2272 std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
2273 std::fs::write(template_dir.join("architect.md"), "# Architect\n").unwrap();
2274 std::fs::write(template_dir.join("board").join("task.md"), "task\n").unwrap();
2275
2276 let created = init_from_template(project.path(), "custom").unwrap();
2277
2278 assert!(!created.is_empty());
2279 assert_eq!(
2280 std::fs::read_to_string(team_config_path(project.path())).unwrap(),
2281 "name: custom\nroles: []\n"
2282 );
2283 assert!(
2284 team_config_dir(project.path())
2285 .join("architect.md")
2286 .exists()
2287 );
2288 assert!(
2289 team_config_dir(project.path())
2290 .join("board")
2291 .join("task.md")
2292 .exists()
2293 );
2294 }
2295
2296 #[test]
2297 #[serial]
2298 fn init_from_template_missing_template_errors_with_available_list() {
2299 let project = tempfile::tempdir().unwrap();
2300 let home = tempfile::tempdir().unwrap();
2301 let _home_guard = HomeGuard::set(home.path());
2302
2303 let templates_root = home.path().join(".batty").join("templates");
2304 std::fs::create_dir_all(templates_root.join("alpha")).unwrap();
2305 std::fs::create_dir_all(templates_root.join("beta")).unwrap();
2306
2307 let error = init_from_template(project.path(), "missing").unwrap_err();
2308 let message = error.to_string();
2309 assert!(message.contains("template 'missing' not found"));
2310 assert!(message.contains("alpha"));
2311 assert!(message.contains("beta"));
2312 }
2313
2314 #[test]
2315 #[serial]
2316 fn init_from_template_errors_when_templates_dir_is_missing() {
2317 let project = tempfile::tempdir().unwrap();
2318 let home = tempfile::tempdir().unwrap();
2319 let _home_guard = HomeGuard::set(home.path());
2320
2321 let error = init_from_template(project.path(), "missing").unwrap_err();
2322 assert!(error.to_string().contains("no templates directory found"));
2323 }
2324
2325 #[test]
2326 fn init_team_large_template() {
2327 let tmp = tempfile::tempdir().unwrap();
2328 let created = init_team(tmp.path(), "large").unwrap();
2329 assert!(!created.is_empty());
2330 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2331 assert!(content.contains("instances: 3") || content.contains("instances: 5"));
2332 }
2333
2334 #[test]
2335 fn init_team_solo_template() {
2336 let tmp = tempfile::tempdir().unwrap();
2337 let created = init_team(tmp.path(), "solo").unwrap();
2338 assert!(!created.is_empty());
2339 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2340 assert!(content.contains("role_type: engineer"));
2341 assert!(!content.contains("role_type: manager"));
2342 }
2343
2344 #[test]
2345 fn init_team_pair_template() {
2346 let tmp = tempfile::tempdir().unwrap();
2347 let created = init_team(tmp.path(), "pair").unwrap();
2348 assert!(!created.is_empty());
2349 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2350 assert!(content.contains("role_type: architect"));
2351 assert!(content.contains("role_type: engineer"));
2352 assert!(!content.contains("role_type: manager"));
2353 }
2354
2355 #[test]
2356 fn init_team_squad_template() {
2357 let tmp = tempfile::tempdir().unwrap();
2358 let created = init_team(tmp.path(), "squad").unwrap();
2359 assert!(!created.is_empty());
2360 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2361 assert!(content.contains("instances: 5"));
2362 assert!(content.contains("layout:"));
2363 }
2364
2365 #[test]
2366 fn init_team_research_template() {
2367 let tmp = tempfile::tempdir().unwrap();
2368 let created = init_team(tmp.path(), "research").unwrap();
2369 assert!(!created.is_empty());
2370 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2371 assert!(content.contains("principal"));
2372 assert!(content.contains("sub-lead"));
2373 assert!(content.contains("researcher"));
2374 assert!(
2376 team_config_dir(tmp.path())
2377 .join("research_lead.md")
2378 .exists()
2379 );
2380 assert!(team_config_dir(tmp.path()).join("sub_lead.md").exists());
2381 assert!(team_config_dir(tmp.path()).join("researcher.md").exists());
2382 assert!(!team_config_dir(tmp.path()).join("architect.md").exists());
2384 }
2385
2386 #[test]
2387 fn init_team_software_template() {
2388 let tmp = tempfile::tempdir().unwrap();
2389 let created = init_team(tmp.path(), "software").unwrap();
2390 assert!(!created.is_empty());
2391 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2392 assert!(content.contains("tech-lead"));
2393 assert!(content.contains("backend-mgr"));
2394 assert!(content.contains("frontend-mgr"));
2395 assert!(content.contains("developer"));
2396 assert!(team_config_dir(tmp.path()).join("tech_lead.md").exists());
2398 assert!(team_config_dir(tmp.path()).join("eng_manager.md").exists());
2399 assert!(team_config_dir(tmp.path()).join("developer.md").exists());
2400 }
2401
2402 #[test]
2403 fn init_team_batty_template() {
2404 let tmp = tempfile::tempdir().unwrap();
2405 let created = init_team(tmp.path(), "batty").unwrap();
2406 assert!(!created.is_empty());
2407 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2408 assert!(content.contains("batty-dev"));
2409 assert!(content.contains("role_type: architect"));
2410 assert!(content.contains("role_type: manager"));
2411 assert!(content.contains("instances: 4"));
2412 assert!(content.contains("batty_architect.md"));
2413 assert!(
2415 team_config_dir(tmp.path())
2416 .join("batty_architect.md")
2417 .exists()
2418 );
2419 assert!(
2420 team_config_dir(tmp.path())
2421 .join("batty_manager.md")
2422 .exists()
2423 );
2424 assert!(
2425 team_config_dir(tmp.path())
2426 .join("batty_engineer.md")
2427 .exists()
2428 );
2429 assert!(
2430 team_config_dir(tmp.path())
2431 .join("review_policy.md")
2432 .exists()
2433 );
2434 }
2435
2436 #[test]
2437 #[serial]
2438 fn export_template_creates_directory_and_copies_files() {
2439 let tmp = tempfile::tempdir().unwrap();
2440 let _home = HomeGuard::set(tmp.path());
2441 let project_root = tmp.path().join("project");
2442 let config_dir = team_config_dir(&project_root);
2443 std::fs::create_dir_all(&config_dir).unwrap();
2444 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
2445 std::fs::write(config_dir.join("architect.md"), "architect prompt\n").unwrap();
2446
2447 let copied = export_template(&project_root, "demo-template").unwrap();
2448 let template_dir = templates_base_dir().unwrap().join("demo-template");
2449
2450 assert_eq!(copied, 2);
2451 assert_eq!(
2452 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
2453 "name: demo\n"
2454 );
2455 assert_eq!(
2456 std::fs::read_to_string(template_dir.join("architect.md")).unwrap(),
2457 "architect prompt\n"
2458 );
2459 }
2460
2461 #[test]
2462 #[serial]
2463 fn export_template_overwrites_existing() {
2464 let tmp = tempfile::tempdir().unwrap();
2465 let _home = HomeGuard::set(tmp.path());
2466 let project_root = tmp.path().join("project");
2467 let config_dir = team_config_dir(&project_root);
2468 std::fs::create_dir_all(&config_dir).unwrap();
2469 std::fs::write(config_dir.join("team.yaml"), "name: first\n").unwrap();
2470 std::fs::write(config_dir.join("manager.md"), "v1\n").unwrap();
2471
2472 export_template(&project_root, "demo-template").unwrap();
2473
2474 std::fs::write(config_dir.join("team.yaml"), "name: second\n").unwrap();
2475 std::fs::write(config_dir.join("manager.md"), "v2\n").unwrap();
2476
2477 let copied = export_template(&project_root, "demo-template").unwrap();
2478 let template_dir = templates_base_dir().unwrap().join("demo-template");
2479
2480 assert_eq!(copied, 2);
2481 assert_eq!(
2482 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
2483 "name: second\n"
2484 );
2485 assert_eq!(
2486 std::fs::read_to_string(template_dir.join("manager.md")).unwrap(),
2487 "v2\n"
2488 );
2489 }
2490
2491 #[test]
2492 #[serial]
2493 fn export_template_missing_team_yaml_errors() {
2494 let tmp = tempfile::tempdir().unwrap();
2495 let _home = HomeGuard::set(tmp.path());
2496 let project_root = tmp.path().join("project");
2497 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
2498
2499 let error = export_template(&project_root, "demo-template").unwrap_err();
2500
2501 assert!(error.to_string().contains("team config missing"));
2502 }
2503
2504 #[test]
2505 fn export_run_copies_requested_run_state_only() {
2506 let tmp = tempfile::tempdir().unwrap();
2507 let project_root = tmp.path().join("project");
2508 let config_dir = team_config_dir(&project_root);
2509 let tasks_dir = config_dir.join("board").join("tasks");
2510 let retrospectives_dir = project_root.join(".batty").join("retrospectives");
2511 let worktree_dir = project_root
2512 .join(".batty")
2513 .join("worktrees")
2514 .join("eng-1-1")
2515 .join(".codex")
2516 .join("sessions");
2517 std::fs::create_dir_all(&tasks_dir).unwrap();
2518 std::fs::create_dir_all(&retrospectives_dir).unwrap();
2519 std::fs::create_dir_all(&worktree_dir).unwrap();
2520
2521 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
2522 std::fs::write(tasks_dir.join("001-task.md"), "---\nid: 1\n---\n").unwrap();
2523 std::fs::write(
2524 team_events_path(&project_root),
2525 "{\"event\":\"daemon_started\"}\n",
2526 )
2527 .unwrap();
2528 std::fs::write(daemon_log_path(&project_root), "daemon-log\n").unwrap();
2529 std::fs::write(orchestrator_log_path(&project_root), "orchestrator-log\n").unwrap();
2530 std::fs::write(retrospectives_dir.join("retro.md"), "# Retro\n").unwrap();
2531 std::fs::write(
2532 project_root.join(".batty").join("test_timing.jsonl"),
2533 "{\"task_id\":1}\n",
2534 )
2535 .unwrap();
2536 std::fs::write(worktree_dir.join("session.jsonl"), "secret\n").unwrap();
2537
2538 let export_dir = export_run(&project_root).unwrap();
2539
2540 assert_eq!(
2541 std::fs::read_to_string(export_dir.join("team.yaml")).unwrap(),
2542 "name: demo\n"
2543 );
2544 assert_eq!(
2545 std::fs::read_to_string(export_dir.join("board").join("tasks").join("001-task.md"))
2546 .unwrap(),
2547 "---\nid: 1\n---\n"
2548 );
2549 assert_eq!(
2550 std::fs::read_to_string(export_dir.join("events.jsonl")).unwrap(),
2551 "{\"event\":\"daemon_started\"}\n"
2552 );
2553 assert_eq!(
2554 std::fs::read_to_string(export_dir.join("daemon.log")).unwrap(),
2555 "daemon-log\n"
2556 );
2557 assert_eq!(
2558 std::fs::read_to_string(export_dir.join("orchestrator.log")).unwrap(),
2559 "orchestrator-log\n"
2560 );
2561 assert_eq!(
2562 std::fs::read_to_string(export_dir.join("retrospectives").join("retro.md")).unwrap(),
2563 "# Retro\n"
2564 );
2565 assert_eq!(
2566 std::fs::read_to_string(export_dir.join("test_timing.jsonl")).unwrap(),
2567 "{\"task_id\":1}\n"
2568 );
2569 assert!(!export_dir.join("worktrees").exists());
2570 assert!(!export_dir.join(".codex").exists());
2571 assert!(!export_dir.join("sessions").exists());
2572 }
2573
2574 #[test]
2575 fn export_run_skips_missing_optional_paths() {
2576 let tmp = tempfile::tempdir().unwrap();
2577 let project_root = tmp.path().join("project");
2578 let config_dir = team_config_dir(&project_root);
2579 std::fs::create_dir_all(&config_dir).unwrap();
2580 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
2581
2582 let export_dir = export_run(&project_root).unwrap();
2583
2584 assert!(export_dir.join("team.yaml").is_file());
2585 assert!(!export_dir.join("board").exists());
2586 assert!(!export_dir.join("events.jsonl").exists());
2587 assert!(!export_dir.join("daemon.log").exists());
2588 assert!(!export_dir.join("orchestrator.log").exists());
2589 assert!(!export_dir.join("retrospectives").exists());
2590 assert!(!export_dir.join("test_timing.jsonl").exists());
2591 }
2592
2593 #[test]
2594 fn export_run_missing_team_yaml_errors() {
2595 let tmp = tempfile::tempdir().unwrap();
2596 let project_root = tmp.path().join("project");
2597 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
2598
2599 let error = export_run(&project_root).unwrap_err();
2600
2601 assert!(error.to_string().contains("team config missing"));
2602 }
2603
2604 #[test]
2605 fn nudge_disable_creates_marker_and_enable_removes_it() {
2606 let tmp = tempfile::tempdir().unwrap();
2607 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2608
2609 let marker = nudge_disabled_marker_path(tmp.path(), "triage");
2610 assert!(!marker.exists());
2611
2612 disable_nudge(tmp.path(), "triage").unwrap();
2613 assert!(marker.exists());
2614
2615 assert!(disable_nudge(tmp.path(), "triage").is_err());
2617
2618 enable_nudge(tmp.path(), "triage").unwrap();
2619 assert!(!marker.exists());
2620
2621 assert!(enable_nudge(tmp.path(), "triage").is_err());
2623 }
2624
2625 #[test]
2626 fn nudge_marker_path_uses_intervention_name() {
2627 let root = std::path::Path::new("/tmp/test-project");
2628 assert_eq!(
2629 nudge_disabled_marker_path(root, "replenish"),
2630 root.join(".batty").join("nudge_replenish_disabled")
2631 );
2632 assert_eq!(
2633 nudge_disabled_marker_path(root, "owned-task"),
2634 root.join(".batty").join("nudge_owned-task_disabled")
2635 );
2636 }
2637
2638 #[test]
2639 fn nudge_multiple_interventions_independent() {
2640 let tmp = tempfile::tempdir().unwrap();
2641 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2642
2643 disable_nudge(tmp.path(), "triage").unwrap();
2644 disable_nudge(tmp.path(), "review").unwrap();
2645
2646 assert!(nudge_disabled_marker_path(tmp.path(), "triage").exists());
2647 assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
2648 assert!(!nudge_disabled_marker_path(tmp.path(), "dispatch").exists());
2649
2650 enable_nudge(tmp.path(), "triage").unwrap();
2651 assert!(!nudge_disabled_marker_path(tmp.path(), "triage").exists());
2652 assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
2653 }
2654
2655 #[test]
2656 fn pause_creates_marker_and_resume_removes_it() {
2657 let tmp = tempfile::tempdir().unwrap();
2658 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2659
2660 assert!(!pause_marker_path(tmp.path()).exists());
2661 pause_team(tmp.path()).unwrap();
2662 assert!(pause_marker_path(tmp.path()).exists());
2663
2664 assert!(pause_team(tmp.path()).is_err());
2666
2667 resume_team(tmp.path()).unwrap();
2668 assert!(!pause_marker_path(tmp.path()).exists());
2669
2670 assert!(resume_team(tmp.path()).is_err());
2672 }
2673
2674 #[test]
2675 fn daemon_state_probe_requests_resume_after_unclean_shutdown() {
2676 let tmp = tempfile::tempdir().unwrap();
2677 let path = daemon_state_path(tmp.path());
2678 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2679 std::fs::write(&path, r#"{"clean_shutdown":false}"#).unwrap();
2680
2681 assert!(should_resume_from_daemon_state(tmp.path()));
2682 }
2683
2684 #[test]
2685 fn daemon_state_probe_ignores_clean_shutdown() {
2686 let tmp = tempfile::tempdir().unwrap();
2687 let path = daemon_state_path(tmp.path());
2688 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2689 std::fs::write(&path, r#"{"clean_shutdown":true}"#).unwrap();
2690
2691 assert!(!should_resume_from_daemon_state(tmp.path()));
2692 }
2693
2694 #[cfg(unix)]
2695 fn write_daemon_script(script_path: &Path, body: &str) {
2696 std::fs::write(script_path, body).unwrap();
2697 use std::os::unix::fs::PermissionsExt;
2698 std::fs::set_permissions(script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
2699 }
2700
2701 #[cfg(unix)]
2702 #[test]
2703 #[serial]
2704 fn graceful_daemon_shutdown_waits_for_clean_snapshot() {
2705 let tmp = tempfile::tempdir().unwrap();
2706 let state_path = daemon_state_path(tmp.path());
2707 let state_dir = state_path.parent().unwrap();
2708 std::fs::create_dir_all(state_dir).unwrap();
2709 std::fs::write(&state_path, r#"{"clean_shutdown":false,"saved_at":1}"#).unwrap();
2710
2711 let state_path_for_thread = state_path.clone();
2712 let state_dir_for_thread = state_dir.to_path_buf();
2713 let writer = std::thread::spawn(move || {
2714 std::thread::sleep(Duration::from_millis(200));
2715 std::fs::create_dir_all(&state_dir_for_thread).unwrap();
2716 std::fs::write(
2717 &state_path_for_thread,
2718 r#"{"clean_shutdown":true,"saved_at":2}"#,
2719 )
2720 .unwrap();
2721 });
2722
2723 assert!(wait_for_graceful_daemon_shutdown(
2724 tmp.path(),
2725 std::process::id(),
2726 Some(1),
2727 Duration::from_secs(2)
2728 ));
2729
2730 writer.join().unwrap();
2731 assert!(daemon_state_indicates_clean_shutdown(tmp.path(), Some(1)));
2732 }
2733
2734 #[cfg(unix)]
2735 #[test]
2736 #[serial]
2737 fn graceful_daemon_shutdown_times_out_before_force_kill_fallback() {
2738 let tmp = tempfile::tempdir().unwrap();
2739 let script_path = tmp.path().join("stubborn-daemon.sh");
2740 write_daemon_script(
2741 &script_path,
2742 "#!/bin/sh\ntrap '' TERM\nwhile :; do :; done\n",
2743 );
2744
2745 let mut child = std::process::Command::new(&script_path).spawn().unwrap();
2746 std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2747 std::fs::write(daemon_pid_path(tmp.path()), child.id().to_string()).unwrap();
2748 std::thread::sleep(Duration::from_millis(200));
2749
2750 assert!(!request_graceful_daemon_shutdown(
2751 tmp.path(),
2752 Duration::from_millis(300)
2753 ));
2754 assert!(daemon_process_exists(child.id()));
2755
2756 force_kill_daemon(tmp.path());
2757 let _ = child.wait().unwrap();
2758 assert!(!daemon_pid_path(tmp.path()).exists());
2759 }
2760
2761 #[test]
2762 fn test_rotate_log_shifts_files() {
2763 let tmp = tempfile::tempdir().unwrap();
2764 let log_path = daemon_log_path(tmp.path());
2765 std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
2766 std::fs::write(&log_path, b"current").unwrap();
2767 std::fs::write(rotated_log_path(&log_path, 1), b"older-1").unwrap();
2768 std::fs::write(rotated_log_path(&log_path, 2), b"older-2").unwrap();
2769 std::fs::OpenOptions::new()
2770 .write(true)
2771 .open(&log_path)
2772 .unwrap()
2773 .set_len(LOG_ROTATION_BYTES + 1)
2774 .unwrap();
2775
2776 rotate_log_if_needed(&log_path).unwrap();
2777
2778 assert!(!log_path.exists());
2779 assert_eq!(
2780 std::fs::read(rotated_log_path(&log_path, 1)).unwrap().len() as u64,
2781 LOG_ROTATION_BYTES + 1
2782 );
2783 assert_eq!(
2784 std::fs::read_to_string(rotated_log_path(&log_path, 2)).unwrap(),
2785 "older-1"
2786 );
2787 assert_eq!(
2788 std::fs::read_to_string(rotated_log_path(&log_path, 3)).unwrap(),
2789 "older-2"
2790 );
2791 }
2792
2793 #[test]
2794 fn test_rotate_log_keeps_max_3() {
2795 let tmp = tempfile::tempdir().unwrap();
2796 let log_path = orchestrator_log_path(tmp.path());
2797 std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
2798 std::fs::write(&log_path, b"current").unwrap();
2799 std::fs::write(rotated_log_path(&log_path, 1), b"older-1").unwrap();
2800 std::fs::write(rotated_log_path(&log_path, 2), b"older-2").unwrap();
2801 std::fs::write(rotated_log_path(&log_path, 3), b"older-3").unwrap();
2802 std::fs::OpenOptions::new()
2803 .write(true)
2804 .open(&log_path)
2805 .unwrap()
2806 .set_len(LOG_ROTATION_BYTES + 1)
2807 .unwrap();
2808
2809 rotate_log_if_needed(&log_path).unwrap();
2810
2811 assert_eq!(
2812 std::fs::read(rotated_log_path(&log_path, 1)).unwrap().len() as u64,
2813 LOG_ROTATION_BYTES + 1
2814 );
2815 assert_eq!(
2816 std::fs::read_to_string(rotated_log_path(&log_path, 2)).unwrap(),
2817 "older-1"
2818 );
2819 assert_eq!(
2820 std::fs::read_to_string(rotated_log_path(&log_path, 3)).unwrap(),
2821 "older-2"
2822 );
2823 assert!(!rotated_log_path(&log_path, 4).exists());
2824 }
2825
2826 #[test]
2827 fn test_rotate_log_noop_under_threshold() {
2828 let tmp = tempfile::tempdir().unwrap();
2829 let log_path = daemon_log_path(tmp.path());
2830 std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
2831 std::fs::write(&log_path, b"small-log").unwrap();
2832
2833 rotate_log_if_needed(&log_path).unwrap();
2834
2835 assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "small-log");
2836 assert!(!rotated_log_path(&log_path, 1).exists());
2837 }
2838
2839 #[test]
2840 fn test_daemon_log_append_mode() {
2841 let tmp = tempfile::tempdir().unwrap();
2842 let log_path = daemon_log_path(tmp.path());
2843
2844 {
2845 let mut file = open_log_for_append(&log_path).unwrap();
2846 use std::io::Write;
2847 writeln!(file, "first").unwrap();
2848 }
2849
2850 {
2851 let mut file = open_log_for_append(&log_path).unwrap();
2852 use std::io::Write;
2853 writeln!(file, "second").unwrap();
2854 }
2855
2856 assert_eq!(
2857 std::fs::read_to_string(&log_path).unwrap(),
2858 "first\nsecond\n"
2859 );
2860 }
2861
2862 #[test]
2863 fn daemon_spawn_args_include_verbose_and_resume() {
2864 assert_eq!(
2865 daemon_spawn_args("/tmp/project", false),
2866 vec![
2867 "-v".to_string(),
2868 "daemon".to_string(),
2869 "--project-root".to_string(),
2870 "/tmp/project".to_string()
2871 ]
2872 );
2873 assert_eq!(
2874 daemon_spawn_args("/tmp/project", true),
2875 vec![
2876 "-v".to_string(),
2877 "daemon".to_string(),
2878 "--project-root".to_string(),
2879 "/tmp/project".to_string(),
2880 "--resume".to_string()
2881 ]
2882 );
2883 }
2884
2885 #[test]
2886 fn send_message_delivers_to_inbox() {
2887 let tmp = tempfile::tempdir().unwrap();
2888 send_message(tmp.path(), "architect", "hello").unwrap();
2889
2890 let root = inbox::inboxes_root(tmp.path());
2891 let pending = inbox::pending_messages(&root, "architect").unwrap();
2892 assert_eq!(pending.len(), 1);
2893 let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
2896 assert_eq!(pending[0].from, expected_from);
2897 assert_eq!(pending[0].to, "architect");
2898 assert_eq!(pending[0].body, "hello");
2899 }
2900
2901 #[test]
2902 fn send_message_ingests_completion_packet_into_workflow_metadata() {
2903 let tmp = tempfile::tempdir().unwrap();
2904 let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
2905 std::fs::create_dir_all(&tasks_dir).unwrap();
2906 let task_path = tasks_dir.join("027-completion-packets.md");
2907 std::fs::write(
2908 &task_path,
2909 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
2910 )
2911 .unwrap();
2912
2913 send_message(
2914 tmp.path(),
2915 "architect",
2916 r#"Done.
2917
2918## Completion Packet
2919
2920```json
2921{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}
2922```"#,
2923 )
2924 .unwrap();
2925
2926 let metadata = board::read_workflow_metadata(&task_path).unwrap();
2927 assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
2928 assert_eq!(
2929 metadata.worktree_path.as_deref(),
2930 Some(".batty/worktrees/eng-1-4")
2931 );
2932 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
2933 assert_eq!(metadata.tests_run, Some(true));
2934 assert_eq!(metadata.tests_passed, Some(true));
2935 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
2936 assert!(metadata.review_blockers.is_empty());
2937 }
2938
2939 #[test]
2940 fn assign_task_delivers_to_inbox() {
2941 let tmp = tempfile::tempdir().unwrap();
2942 let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
2943 assert!(!id.is_empty());
2944
2945 let root = inbox::inboxes_root(tmp.path());
2946 let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
2947 assert_eq!(pending.len(), 1);
2948 let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
2949 assert_eq!(pending[0].from, expected_from);
2950 assert_eq!(pending[0].to, "eng-1-1");
2951 assert_eq!(pending[0].body, "fix bug");
2952 assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
2953 }
2954
2955 fn write_team_config(project_root: &Path, yaml: &str) {
2956 std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
2957 std::fs::write(team_config_path(project_root), yaml).unwrap();
2958 }
2959
2960 #[test]
2961 fn workflow_mode_declared_detects_absent_field() {
2962 let tmp = tempfile::tempdir().unwrap();
2963 write_team_config(
2964 tmp.path(),
2965 r#"
2966name: test
2967roles:
2968 - name: engineer
2969 role_type: engineer
2970 agent: codex
2971"#,
2972 );
2973
2974 assert!(!workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
2975 }
2976
2977 #[test]
2978 fn workflow_mode_declared_detects_present_field() {
2979 let tmp = tempfile::tempdir().unwrap();
2980 write_team_config(
2981 tmp.path(),
2982 r#"
2983name: test
2984workflow_mode: hybrid
2985roles:
2986 - name: engineer
2987 role_type: engineer
2988 agent: codex
2989"#,
2990 );
2991
2992 assert!(workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
2993 }
2994
2995 #[test]
2996 fn migration_validation_notes_explain_legacy_default_for_older_configs() {
2997 let config =
2998 config::TeamConfig::load(Path::new("src/team/templates/team_pair.yaml")).unwrap();
2999 let notes = migration_validation_notes(&config, false);
3000
3001 assert_eq!(notes.len(), 1);
3002 assert!(notes[0].contains("workflow_mode omitted"));
3003 assert!(notes[0].contains("run unchanged"));
3004 }
3005
3006 #[test]
3007 fn migration_validation_notes_warn_about_workflow_first_partial_rollout() {
3008 let config: config::TeamConfig = serde_yaml::from_str(
3009 r#"
3010name: test
3011workflow_mode: workflow_first
3012roles:
3013 - name: engineer
3014 role_type: engineer
3015 agent: codex
3016"#,
3017 )
3018 .unwrap();
3019 let notes = migration_validation_notes(&config, true);
3020
3021 assert_eq!(notes.len(), 1);
3022 assert!(notes[0].contains("workflow_first mode selected"));
3023 assert!(notes[0].contains("primary truth"));
3024 }
3025
3026 #[test]
3027 fn resolve_member_name_maps_unique_role_alias_to_instance() {
3028 let tmp = tempfile::tempdir().unwrap();
3029 write_team_config(
3030 tmp.path(),
3031 r#"
3032name: test
3033roles:
3034 - name: human
3035 role_type: user
3036 talks_to:
3037 - sam-designer
3038 - name: jordan-pm
3039 role_type: manager
3040 agent: claude
3041 instances: 1
3042 - name: sam-designer
3043 role_type: engineer
3044 agent: codex
3045 instances: 1
3046 talks_to:
3047 - jordan-pm
3048"#,
3049 );
3050
3051 assert_eq!(
3052 resolve_member_name(tmp.path(), "sam-designer").unwrap(),
3053 "sam-designer-1-1"
3054 );
3055 assert_eq!(
3056 resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
3057 "sam-designer-1-1"
3058 );
3059 }
3060
3061 #[test]
3062 fn resolve_member_name_rejects_ambiguous_role_alias() {
3063 let tmp = tempfile::tempdir().unwrap();
3064 write_team_config(
3065 tmp.path(),
3066 r#"
3067name: test
3068roles:
3069 - name: jordan-pm
3070 role_type: manager
3071 agent: claude
3072 instances: 2
3073 - name: sam-designer
3074 role_type: engineer
3075 agent: codex
3076 instances: 1
3077 talks_to:
3078 - jordan-pm
3079"#,
3080 );
3081
3082 let error = resolve_member_name(tmp.path(), "sam-designer")
3083 .unwrap_err()
3084 .to_string();
3085 assert!(error.contains("matches multiple members"));
3086 assert!(error.contains("sam-designer-1-1"));
3087 assert!(error.contains("sam-designer-2-1"));
3088 }
3089
3090 #[test]
3091 #[serial]
3092 fn send_message_delivers_to_unique_instance_inbox() {
3093 let tmp = tempfile::tempdir().unwrap();
3094 let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
3095 write_team_config(
3096 tmp.path(),
3097 r#"
3098name: test
3099roles:
3100 - name: human
3101 role_type: user
3102 talks_to:
3103 - sam-designer
3104 - name: jordan-pm
3105 role_type: manager
3106 agent: claude
3107 instances: 1
3108 - name: sam-designer
3109 role_type: engineer
3110 agent: codex
3111 instances: 1
3112 talks_to:
3113 - jordan-pm
3114"#,
3115 );
3116
3117 let original_tmux_pane = std::env::var_os("TMUX_PANE");
3118 unsafe {
3119 std::env::remove_var("TMUX_PANE");
3120 }
3121 let send_result = send_message(tmp.path(), "sam-designer", "hello");
3122 match original_tmux_pane {
3123 Some(value) => unsafe {
3124 std::env::set_var("TMUX_PANE", value);
3125 },
3126 None => unsafe {
3127 std::env::remove_var("TMUX_PANE");
3128 },
3129 }
3130 send_result.unwrap();
3131
3132 let root = inbox::inboxes_root(tmp.path());
3133 assert!(
3134 inbox::pending_messages(&root, "sam-designer")
3135 .unwrap()
3136 .is_empty()
3137 );
3138
3139 let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
3140 assert_eq!(pending.len(), 1);
3141 assert_eq!(pending[0].to, "sam-designer-1-1");
3142 assert_eq!(pending[0].body, "hello");
3143 }
3144
3145 #[test]
3146 fn truncate_chars_handles_unicode_boundaries() {
3147 let body = "Task #109 confirmed complete on main. I’m available for next assignment.";
3148 let truncated = truncate_chars(body, 40);
3149 assert!(truncated.ends_with("..."));
3150 assert!(truncated.starts_with("Task #109 confirmed complete on main."));
3151 }
3152
3153 #[test]
3154 fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
3155 let messages: Vec<_> = (0..25)
3156 .map(|idx| {
3157 (
3158 inbox::InboxMessage {
3159 id: format!("msg{idx:05}"),
3160 from: "architect".to_string(),
3161 to: "black-lead".to_string(),
3162 body: format!("message {idx}"),
3163 msg_type: inbox::MessageType::Send,
3164 timestamp: idx,
3165 },
3166 true,
3167 )
3168 })
3169 .collect();
3170
3171 let rendered = format_inbox_listing("black-lead", &messages, Some(20));
3172 assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
3173 assert!(!rendered.contains("message 0"));
3174 assert!(rendered.contains("message 5"));
3175 assert!(rendered.contains("message 24"));
3176 assert!(!rendered.contains("msg00005"));
3177 assert!(!rendered.contains("msg00024"));
3178 }
3179
3180 #[test]
3181 fn format_inbox_listing_allows_showing_all_messages() {
3182 let messages: Vec<_> = (0..3)
3183 .map(|idx| {
3184 (
3185 inbox::InboxMessage {
3186 id: format!("msg{idx:05}"),
3187 from: "architect".to_string(),
3188 to: "black-lead".to_string(),
3189 body: format!("message {idx}"),
3190 msg_type: inbox::MessageType::Send,
3191 timestamp: idx,
3192 },
3193 idx % 2 == 0,
3194 )
3195 })
3196 .collect();
3197
3198 let rendered = format_inbox_listing("black-lead", &messages, None);
3199 assert!(!rendered.contains("Showing 20"));
3200 assert!(rendered.contains("REF"));
3201 assert!(rendered.contains("BODY"));
3202 assert!(rendered.contains("message 0"));
3203 assert!(rendered.contains("message 1"));
3204 assert!(rendered.contains("message 2"));
3205 assert!(!rendered.contains("msg00000"));
3206 assert!(!rendered.contains("msg00001"));
3207 assert!(!rendered.contains("msg00002"));
3208 }
3209
3210 #[test]
3211 fn format_inbox_listing_hides_internal_message_ids() {
3212 let messages = vec![(
3213 inbox::InboxMessage {
3214 id: "1773930387654321.M123456P7890Q42.example".to_string(),
3215 from: "architect".to_string(),
3216 to: "black-lead".to_string(),
3217 body: "message body".to_string(),
3218 msg_type: inbox::MessageType::Send,
3219 timestamp: 1_773_930_725,
3220 },
3221 true,
3222 )];
3223
3224 let rendered = format_inbox_listing("black-lead", &messages, None);
3225 assert!(rendered.contains("1773930725"));
3226 assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
3227 assert!(!rendered.contains("ID BODY"));
3228 }
3229
3230 #[test]
3231 fn inbox_message_refs_use_timestamp_when_unique() {
3232 let messages = vec![(
3233 inbox::InboxMessage {
3234 id: "msg-1".to_string(),
3235 from: "architect".to_string(),
3236 to: "black-lead".to_string(),
3237 body: "message body".to_string(),
3238 msg_type: inbox::MessageType::Send,
3239 timestamp: 1_773_930_725,
3240 },
3241 true,
3242 )];
3243
3244 let refs = inbox_message_refs(&messages);
3245 assert_eq!(refs, vec!["1773930725".to_string()]);
3246 assert_eq!(
3247 resolve_inbox_message_indices(&messages, "1773930725"),
3248 vec![0]
3249 );
3250 }
3251
3252 #[test]
3253 fn inbox_message_refs_suffix_same_second_collisions() {
3254 let messages = vec![
3255 (
3256 inbox::InboxMessage {
3257 id: "msg-1".to_string(),
3258 from: "architect".to_string(),
3259 to: "black-lead".to_string(),
3260 body: "first".to_string(),
3261 msg_type: inbox::MessageType::Send,
3262 timestamp: 1_773_930_725,
3263 },
3264 true,
3265 ),
3266 (
3267 inbox::InboxMessage {
3268 id: "msg-2".to_string(),
3269 from: "architect".to_string(),
3270 to: "black-lead".to_string(),
3271 body: "second".to_string(),
3272 msg_type: inbox::MessageType::Send,
3273 timestamp: 1_773_930_725,
3274 },
3275 true,
3276 ),
3277 ];
3278
3279 let refs = inbox_message_refs(&messages);
3280 assert_eq!(
3281 refs,
3282 vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
3283 );
3284 assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
3285 assert_eq!(
3286 resolve_inbox_message_indices(&messages, "1773930725-1"),
3287 vec![0]
3288 );
3289 assert_eq!(
3290 resolve_inbox_message_indices(&messages, "1773930725-2"),
3291 vec![1]
3292 );
3293 }
3294
3295 #[test]
3296 fn assignment_result_round_trip_and_format() {
3297 let tmp = tempfile::tempdir().unwrap();
3298 let result = AssignmentDeliveryResult {
3299 message_id: "msg-1".to_string(),
3300 status: AssignmentResultStatus::Delivered,
3301 engineer: "eng-1-1".to_string(),
3302 task_summary: "Say Hello".to_string(),
3303 branch: Some("eng-1-1/task-1".to_string()),
3304 work_dir: Some("/tmp/worktree".to_string()),
3305 detail: "assignment launched".to_string(),
3306 ts: now_unix(),
3307 };
3308
3309 store_assignment_result(tmp.path(), &result).unwrap();
3310 let loaded = load_assignment_result(tmp.path(), "msg-1")
3311 .unwrap()
3312 .unwrap();
3313 assert_eq!(loaded, result);
3314
3315 let formatted = format_assignment_result(&loaded);
3316 assert!(formatted.contains("Assignment delivered: msg-1 -> eng-1-1"));
3317 assert!(formatted.contains("Branch: eng-1-1/task-1"));
3318 assert!(formatted.contains("Worktree: /tmp/worktree"));
3319 }
3320
3321 #[test]
3322 fn wait_for_assignment_result_returns_none_when_missing() {
3323 let tmp = tempfile::tempdir().unwrap();
3324 let result =
3325 wait_for_assignment_result(tmp.path(), "missing", Duration::from_millis(10)).unwrap();
3326 assert!(result.is_none());
3327 }
3328
3329 fn make_member(name: &str, role_name: &str, role_type: RoleType) -> hierarchy::MemberInstance {
3330 hierarchy::MemberInstance {
3331 name: name.to_string(),
3332 role_name: role_name.to_string(),
3333 role_type,
3334 agent: Some("codex".to_string()),
3335 prompt: None,
3336 reports_to: None,
3337 use_worktrees: false,
3338 }
3339 }
3340
3341 #[test]
3342 fn strip_tmux_style_removes_formatting_sequences() {
3343 let raw = "#[fg=yellow]idle#[default] #[fg=magenta]nudge 1:05#[default]";
3344 assert_eq!(status::strip_tmux_style(raw), "idle nudge 1:05");
3345 }
3346
3347 #[test]
3348 fn summarize_runtime_member_status_extracts_state_and_signal() {
3349 let summary = status::summarize_runtime_member_status(
3350 "#[fg=cyan]working#[default] #[fg=blue]standup 4:12#[default]",
3351 false,
3352 );
3353
3354 assert_eq!(summary.state, "working");
3355 assert_eq!(summary.signal.as_deref(), Some("standup"));
3356 assert_eq!(summary.label.as_deref(), Some("working standup 4:12"));
3357 }
3358
3359 #[test]
3360 fn summarize_runtime_member_status_marks_nudge_and_standup_together() {
3361 let summary = status::summarize_runtime_member_status(
3362 "#[fg=yellow]idle#[default] #[fg=magenta]nudge now#[default] #[fg=blue]standup 0:10#[default]",
3363 false,
3364 );
3365
3366 assert_eq!(summary.state, "idle");
3367 assert_eq!(
3368 summary.signal.as_deref(),
3369 Some("waiting for nudge, standup")
3370 );
3371 }
3372
3373 #[test]
3374 fn summarize_runtime_member_status_distinguishes_sent_nudge() {
3375 let summary = status::summarize_runtime_member_status(
3376 "#[fg=yellow]idle#[default] #[fg=magenta]nudge sent#[default]",
3377 false,
3378 );
3379
3380 assert_eq!(summary.state, "idle");
3381 assert_eq!(summary.signal.as_deref(), Some("nudged"));
3382 assert_eq!(summary.label.as_deref(), Some("idle nudge sent"));
3383 }
3384
3385 #[test]
3386 fn summarize_runtime_member_status_tracks_paused_automation() {
3387 let summary = status::summarize_runtime_member_status(
3388 "#[fg=cyan]working#[default] #[fg=244]nudge paused#[default] #[fg=244]standup paused#[default]",
3389 false,
3390 );
3391
3392 assert_eq!(summary.state, "working");
3393 assert_eq!(
3394 summary.signal.as_deref(),
3395 Some("nudge paused, standup paused")
3396 );
3397 assert_eq!(
3398 summary.label.as_deref(),
3399 Some("working nudge paused standup paused")
3400 );
3401 }
3402
3403 #[test]
3404 fn build_team_status_rows_defaults_by_session_state() {
3405 let architect = make_member("architect", "architect", RoleType::Architect);
3406 let human = hierarchy::MemberInstance {
3407 name: "human".to_string(),
3408 role_name: "human".to_string(),
3409 role_type: RoleType::User,
3410 agent: None,
3411 prompt: None,
3412 reports_to: None,
3413 use_worktrees: false,
3414 };
3415
3416 let pending = std::collections::HashMap::from([
3417 (architect.name.clone(), 3usize),
3418 (human.name.clone(), 1usize),
3419 ]);
3420 let triage = std::collections::HashMap::from([(architect.name.clone(), 2usize)]);
3421 let owned = std::collections::HashMap::from([(
3422 architect.name.clone(),
3423 status::OwnedTaskBuckets {
3424 active: vec![191u32],
3425 review: vec![193u32],
3426 },
3427 )]);
3428 let rows = status::build_team_status_rows(
3429 &[architect.clone(), human.clone()],
3430 false,
3431 &Default::default(),
3432 &pending,
3433 &triage,
3434 &owned,
3435 &Default::default(),
3436 );
3437 assert_eq!(rows[0].state, "stopped");
3438 assert_eq!(rows[0].pending_inbox, 3);
3439 assert_eq!(rows[0].triage_backlog, 2);
3440 assert_eq!(rows[0].active_owned_tasks, vec![191]);
3441 assert_eq!(rows[0].review_owned_tasks, vec![193]);
3442 assert_eq!(rows[0].health_summary, "-");
3443 assert_eq!(rows[1].state, "user");
3444 assert_eq!(rows[1].pending_inbox, 1);
3445 assert_eq!(rows[1].triage_backlog, 0);
3446 assert!(rows[1].active_owned_tasks.is_empty());
3447 assert!(rows[1].review_owned_tasks.is_empty());
3448
3449 let runtime = std::collections::HashMap::from([(
3450 architect.name.clone(),
3451 status::RuntimeMemberStatus {
3452 state: "idle".to_string(),
3453 signal: Some("standup".to_string()),
3454 label: Some("idle standup 2:00".to_string()),
3455 },
3456 )]);
3457 let rows = status::build_team_status_rows(
3458 &[architect],
3459 true,
3460 &runtime,
3461 &pending,
3462 &triage,
3463 &owned,
3464 &Default::default(),
3465 );
3466 assert_eq!(rows[0].state, "reviewing");
3467 assert_eq!(rows[0].pending_inbox, 3);
3468 assert_eq!(rows[0].triage_backlog, 2);
3469 assert_eq!(rows[0].active_owned_tasks, vec![191]);
3470 assert_eq!(rows[0].review_owned_tasks, vec![193]);
3471 assert_eq!(
3472 rows[0].signal.as_deref(),
3473 Some("standup, needs triage (2), needs review (1)")
3474 );
3475 assert_eq!(rows[0].runtime_label.as_deref(), Some("idle standup 2:00"));
3476 }
3477
3478 #[test]
3479 fn delivered_direct_report_triage_count_only_counts_results_newer_than_lead_response() {
3480 let tmp = tempfile::tempdir().unwrap();
3481 let root = inbox::inboxes_root(tmp.path());
3482 inbox::init_inbox(&root, "lead").unwrap();
3483 inbox::init_inbox(&root, "eng-1").unwrap();
3484 inbox::init_inbox(&root, "eng-2").unwrap();
3485
3486 let mut old_result = inbox::InboxMessage::new_send("eng-1", "lead", "old result");
3487 old_result.timestamp = 10;
3488 let old_result_id = inbox::deliver_to_inbox(&root, &old_result).unwrap();
3489 inbox::mark_delivered(&root, "lead", &old_result_id).unwrap();
3490
3491 let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "next task");
3492 lead_reply.timestamp = 20;
3493 let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
3494 inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
3495
3496 let mut new_result = inbox::InboxMessage::new_send("eng-1", "lead", "new result");
3497 new_result.timestamp = 30;
3498 let new_result_id = inbox::deliver_to_inbox(&root, &new_result).unwrap();
3499 inbox::mark_delivered(&root, "lead", &new_result_id).unwrap();
3500
3501 let mut other_result = inbox::InboxMessage::new_send("eng-2", "lead", "parallel result");
3502 other_result.timestamp = 40;
3503 let other_result_id = inbox::deliver_to_inbox(&root, &other_result).unwrap();
3504 inbox::mark_delivered(&root, "lead", &other_result_id).unwrap();
3505
3506 let triage_state = status::delivered_direct_report_triage_state_at(
3507 &root,
3508 "lead",
3509 &["eng-1".to_string(), "eng-2".to_string()],
3510 100,
3511 )
3512 .unwrap();
3513 assert_eq!(triage_state.count, 2);
3514 assert_eq!(triage_state.newest_result_ts, 40);
3515 }
3516
3517 #[test]
3518 fn delivered_direct_report_triage_count_excludes_stale_delivered_results() {
3519 let tmp = tempfile::tempdir().unwrap();
3520 let root = inbox::inboxes_root(tmp.path());
3521 inbox::init_inbox(&root, "lead").unwrap();
3522 inbox::init_inbox(&root, "eng-1").unwrap();
3523
3524 let mut stale_result = inbox::InboxMessage::new_send("eng-1", "lead", "stale result");
3525 stale_result.timestamp = 10;
3526 let stale_result_id = inbox::deliver_to_inbox(&root, &stale_result).unwrap();
3527 inbox::mark_delivered(&root, "lead", &stale_result_id).unwrap();
3528
3529 let triage_state = status::delivered_direct_report_triage_state_at(
3530 &root,
3531 "lead",
3532 &["eng-1".to_string()],
3533 10 + TRIAGE_RESULT_FRESHNESS_SECONDS + 1,
3534 )
3535 .unwrap();
3536
3537 assert_eq!(triage_state.count, 0);
3538 assert_eq!(triage_state.newest_result_ts, 0);
3539 }
3540
3541 #[test]
3542 fn delivered_direct_report_triage_count_keeps_fresh_delivered_results() {
3543 let tmp = tempfile::tempdir().unwrap();
3544 let root = inbox::inboxes_root(tmp.path());
3545 inbox::init_inbox(&root, "lead").unwrap();
3546 inbox::init_inbox(&root, "eng-1").unwrap();
3547
3548 let mut fresh_result = inbox::InboxMessage::new_send("eng-1", "lead", "fresh result");
3549 fresh_result.timestamp = 100;
3550 let fresh_result_id = inbox::deliver_to_inbox(&root, &fresh_result).unwrap();
3551 inbox::mark_delivered(&root, "lead", &fresh_result_id).unwrap();
3552
3553 let triage_state = status::delivered_direct_report_triage_state_at(
3554 &root,
3555 "lead",
3556 &["eng-1".to_string()],
3557 150,
3558 )
3559 .unwrap();
3560
3561 assert_eq!(triage_state.count, 1);
3562 assert_eq!(triage_state.newest_result_ts, 100);
3563 }
3564
3565 #[test]
3566 fn delivered_direct_report_triage_count_excludes_acked_results() {
3567 let tmp = tempfile::tempdir().unwrap();
3568 let root = inbox::inboxes_root(tmp.path());
3569 inbox::init_inbox(&root, "lead").unwrap();
3570 inbox::init_inbox(&root, "eng-1").unwrap();
3571
3572 let mut result = inbox::InboxMessage::new_send("eng-1", "lead", "task complete");
3573 result.timestamp = 100;
3574 let result_id = inbox::deliver_to_inbox(&root, &result).unwrap();
3575 inbox::mark_delivered(&root, "lead", &result_id).unwrap();
3576
3577 let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "acknowledged");
3578 lead_reply.timestamp = 110;
3579 let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
3580 inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
3581
3582 let triage_state = status::delivered_direct_report_triage_state_at(
3583 &root,
3584 "lead",
3585 &["eng-1".to_string()],
3586 150,
3587 )
3588 .unwrap();
3589
3590 assert_eq!(triage_state.count, 0);
3591 assert_eq!(triage_state.newest_result_ts, 0);
3592 }
3593
3594 #[test]
3595 fn counts_as_active_load_treats_triaging_as_working() {
3596 let triaging = status::TeamStatusRow {
3597 name: "lead".to_string(),
3598 role: "lead".to_string(),
3599 role_type: "Manager".to_string(),
3600 agent: Some("codex".to_string()),
3601 reports_to: Some("architect".to_string()),
3602 state: "triaging".to_string(),
3603 pending_inbox: 0,
3604 triage_backlog: 2,
3605 active_owned_tasks: vec![191],
3606 review_owned_tasks: vec![193],
3607 signal: Some("needs triage (2)".to_string()),
3608 runtime_label: Some("idle".to_string()),
3609 health: status::AgentHealthSummary::default(),
3610 health_summary: "-".to_string(),
3611 eta: "-".to_string(),
3612 };
3613 let reviewing = status::TeamStatusRow {
3614 state: "reviewing".to_string(),
3615 triage_backlog: 0,
3616 signal: Some("needs review (1)".to_string()),
3617 runtime_label: Some("idle".to_string()),
3618 ..triaging.clone()
3619 };
3620 let idle = status::TeamStatusRow {
3621 state: "idle".to_string(),
3622 triage_backlog: 0,
3623 signal: None,
3624 runtime_label: Some("idle".to_string()),
3625 ..triaging.clone()
3626 };
3627
3628 assert!(counts_as_active_load(&triaging));
3629 assert!(counts_as_active_load(&reviewing));
3630 assert!(!counts_as_active_load(&idle));
3631 }
3632
3633 #[test]
3634 fn format_owned_tasks_summary_compacts_multiple_ids() {
3635 assert_eq!(status::format_owned_tasks_summary(&[]), "-");
3636 assert_eq!(status::format_owned_tasks_summary(&[191]), "#191");
3637 assert_eq!(status::format_owned_tasks_summary(&[191, 192]), "#191,#192");
3638 assert_eq!(
3639 status::format_owned_tasks_summary(&[191, 192, 193]),
3640 "#191,#192,+1"
3641 );
3642 }
3643
3644 #[test]
3645 fn owned_task_buckets_split_active_and_review_claims() {
3646 let tmp = tempfile::tempdir().unwrap();
3647 let members = vec![
3648 make_member("lead", "lead", RoleType::Manager),
3649 hierarchy::MemberInstance {
3650 name: "eng-1".to_string(),
3651 role_name: "eng".to_string(),
3652 role_type: RoleType::Engineer,
3653 agent: Some("codex".to_string()),
3654 prompt: None,
3655 reports_to: Some("lead".to_string()),
3656 use_worktrees: false,
3657 },
3658 ];
3659 std::fs::create_dir_all(
3660 tmp.path()
3661 .join(".batty")
3662 .join("team_config")
3663 .join("board")
3664 .join("tasks"),
3665 )
3666 .unwrap();
3667 std::fs::write(
3668 tmp.path()
3669 .join(".batty")
3670 .join("team_config")
3671 .join("board")
3672 .join("tasks")
3673 .join("191-active.md"),
3674 "---\nid: 191\ntitle: Active\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
3675 )
3676 .unwrap();
3677 std::fs::write(
3678 tmp.path()
3679 .join(".batty")
3680 .join("team_config")
3681 .join("board")
3682 .join("tasks")
3683 .join("193-review.md"),
3684 "---\nid: 193\ntitle: Review\nstatus: review\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
3685 )
3686 .unwrap();
3687
3688 let owned = status::owned_task_buckets(tmp.path(), &members);
3689 let buckets = owned.get("eng-1").unwrap();
3690 assert_eq!(buckets.active, vec![191]);
3691 assert!(buckets.review.is_empty());
3692 let review_buckets = owned.get("lead").unwrap();
3693 assert!(review_buckets.active.is_empty());
3694 assert_eq!(review_buckets.review, vec![193]);
3695 }
3696
3697 #[test]
3698 fn workflow_metrics_enabled_detects_supported_modes() {
3699 let tmp = tempfile::tempdir().unwrap();
3700 let config_path = tmp.path().join("team.yaml");
3701
3702 std::fs::write(
3703 &config_path,
3704 "name: test\nworkflow_mode: hybrid\nroles: []\n",
3705 )
3706 .unwrap();
3707 assert!(status::workflow_metrics_enabled(&config_path));
3708
3709 std::fs::write(
3710 &config_path,
3711 "name: test\nworkflow_mode: workflow_first\nroles: []\n",
3712 )
3713 .unwrap();
3714 assert!(status::workflow_metrics_enabled(&config_path));
3715
3716 std::fs::write(&config_path, "name: test\nroles: []\n").unwrap();
3717 assert!(!status::workflow_metrics_enabled(&config_path));
3718 }
3719
3720 #[test]
3721 fn team_status_metrics_section_renders_when_workflow_mode_enabled() {
3722 let tmp = tempfile::tempdir().unwrap();
3723 let team_dir = tmp.path().join(".batty").join("team_config");
3724 let board_dir = team_dir.join("board");
3725 let tasks_dir = board_dir.join("tasks");
3726 std::fs::create_dir_all(&tasks_dir).unwrap();
3727 std::fs::write(
3728 team_dir.join("team.yaml"),
3729 "name: test\nworkflow_mode: hybrid\nroles:\n - name: engineer\n role_type: engineer\n agent: codex\n",
3730 )
3731 .unwrap();
3732 std::fs::write(
3733 tasks_dir.join("031-runnable.md"),
3734 "---\nid: 31\ntitle: Runnable\nstatus: todo\npriority: medium\nclass: standard\n---\n\nTask body.\n",
3735 )
3736 .unwrap();
3737
3738 let members = vec![make_member("eng-1-1", "engineer", RoleType::Engineer)];
3739 let section = status::workflow_metrics_section(tmp.path(), &members).unwrap();
3740
3741 assert!(section.0.contains("Workflow Metrics"));
3742 assert_eq!(section.1.runnable_count, 1);
3743 assert_eq!(section.1.idle_with_runnable, vec!["eng-1-1"]);
3744 }
3745
3746 #[test]
3747 #[serial]
3748 #[cfg_attr(not(feature = "integration"), ignore)]
3749 fn list_runtime_member_statuses_reads_tmux_role_and_status_options() {
3750 let session = "batty-test-team-status-runtime";
3751 let _ = crate::tmux::kill_session(session);
3752
3753 crate::tmux::create_session(session, "sleep", &["20".to_string()], "/tmp").unwrap();
3754 let pane_id = crate::tmux::pane_id(session).unwrap();
3755
3756 let role_output = std::process::Command::new("tmux")
3757 .args(["set-option", "-p", "-t", &pane_id, "@batty_role", "eng-1"])
3758 .output()
3759 .unwrap();
3760 assert!(role_output.status.success());
3761
3762 let status_output = std::process::Command::new("tmux")
3763 .args([
3764 "set-option",
3765 "-p",
3766 "-t",
3767 &pane_id,
3768 "@batty_status",
3769 "#[fg=yellow]idle#[default] #[fg=magenta]nudge 0:30#[default]",
3770 ])
3771 .output()
3772 .unwrap();
3773 assert!(status_output.status.success());
3774
3775 let statuses = status::list_runtime_member_statuses(session).unwrap();
3776 let eng = statuses.get("eng-1").unwrap();
3777 assert_eq!(eng.state, "idle");
3778 assert_eq!(eng.signal.as_deref(), Some("waiting for nudge"));
3779 assert_eq!(eng.label.as_deref(), Some("idle nudge 0:30"));
3780
3781 crate::tmux::kill_session(session).unwrap();
3782 }
3783
3784 #[test]
3785 fn average_load_ignores_points_older_than_window() {
3786 let now = 10_000u64;
3787 let samples = vec![
3788 TeamLoadSnapshot {
3789 timestamp: now - 3_000,
3790 total_members: 10,
3791 working_members: 0,
3792 load: 0.8,
3793 session_running: true,
3794 },
3795 TeamLoadSnapshot {
3796 timestamp: now - 10,
3797 total_members: 10,
3798 working_members: 0,
3799 load: 0.4,
3800 session_running: true,
3801 },
3802 TeamLoadSnapshot {
3803 timestamp: now - 20,
3804 total_members: 10,
3805 working_members: 0,
3806 load: 0.6,
3807 session_running: true,
3808 },
3809 ];
3810
3811 let avg_60s = average_load(&samples, now, 60).unwrap();
3812 assert!((avg_60s - 0.5).abs() < 0.0001);
3813 assert!(average_load(&samples, now, 5).is_none());
3814 }
3815
3816 #[test]
3817 fn render_load_graph_returns_expected_width() {
3818 let now = 10_000u64;
3819 let samples = vec![
3820 TeamLoadSnapshot {
3821 timestamp: now - 3_600,
3822 total_members: 10,
3823 working_members: 2,
3824 load: 0.2,
3825 session_running: true,
3826 },
3827 TeamLoadSnapshot {
3828 timestamp: now - 1_800,
3829 total_members: 10,
3830 working_members: 5,
3831 load: 0.5,
3832 session_running: true,
3833 },
3834 TeamLoadSnapshot {
3835 timestamp: now - 900,
3836 total_members: 10,
3837 working_members: 10,
3838 load: 1.0,
3839 session_running: true,
3840 },
3841 TeamLoadSnapshot {
3842 timestamp: now - 600,
3843 total_members: 10,
3844 working_members: 0,
3845 load: 0.0,
3846 session_running: true,
3847 },
3848 ];
3849
3850 let graph = render_load_graph(&samples, now);
3851 assert_eq!(graph.len(), LOAD_GRAPH_WIDTH);
3852 assert!(graph.chars().all(|c| " .:=#@".contains(c)));
3853 }
3854
3855 fn production_unwrap_expect_count(source: &str) -> usize {
3857 let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
3859 &source[..pos]
3860 } else {
3861 source
3862 };
3863 prod.lines()
3864 .filter(|line| {
3865 let trimmed = line.trim();
3866 !trimmed.starts_with("#[cfg(test)]")
3868 && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
3869 })
3870 .count()
3871 }
3872
3873 #[test]
3874 fn production_mod_has_no_unwrap_or_expect_calls() {
3875 let src = include_str!("mod.rs");
3876 assert_eq!(
3877 production_unwrap_expect_count(src),
3878 0,
3879 "production mod.rs should avoid unwrap/expect"
3880 );
3881 }
3882
3883 #[test]
3886 fn session_summary_counts_completions_correctly() {
3887 let tmp = tempfile::tempdir().unwrap();
3888 let events_dir = tmp.path().join(".batty").join("team_config");
3889 std::fs::create_dir_all(&events_dir).unwrap();
3890
3891 let now = now_unix();
3892 let events = [
3893 format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 3600),
3894 format!(
3895 r#"{{"event":"task_completed","role":"eng-1","task":"10","ts":{}}}"#,
3896 now - 3000
3897 ),
3898 format!(
3899 r#"{{"event":"task_completed","role":"eng-2","task":"11","ts":{}}}"#,
3900 now - 2000
3901 ),
3902 format!(
3903 r#"{{"event":"task_auto_merged","role":"eng-1","task":"10","ts":{}}}"#,
3904 now - 2900
3905 ),
3906 format!(
3907 r#"{{"event":"task_manual_merged","role":"eng-2","task":"11","ts":{}}}"#,
3908 now - 1900
3909 ),
3910 format!(
3911 r#"{{"event":"task_completed","role":"eng-1","task":"12","ts":{}}}"#,
3912 now - 1000
3913 ),
3914 ];
3915 std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
3916
3917 let summary = compute_session_summary(tmp.path()).unwrap();
3918 assert_eq!(summary.tasks_completed, 3);
3919 assert_eq!(summary.tasks_merged, 2);
3920 assert!(summary.runtime_secs >= 3599 && summary.runtime_secs <= 3601);
3921 }
3922
3923 #[test]
3924 fn session_summary_calculates_runtime() {
3925 let tmp = tempfile::tempdir().unwrap();
3926 let events_dir = tmp.path().join(".batty").join("team_config");
3927 std::fs::create_dir_all(&events_dir).unwrap();
3928
3929 let now = now_unix();
3930 let events = [format!(
3931 r#"{{"event":"daemon_started","ts":{}}}"#,
3932 now - 7200
3933 )];
3934 std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
3935
3936 let summary = compute_session_summary(tmp.path()).unwrap();
3937 assert_eq!(summary.tasks_completed, 0);
3938 assert_eq!(summary.tasks_merged, 0);
3939 assert!(summary.runtime_secs >= 7199 && summary.runtime_secs <= 7201);
3940 }
3941
3942 #[test]
3943 fn session_summary_handles_empty_session() {
3944 let tmp = tempfile::tempdir().unwrap();
3945 let events_dir = tmp.path().join(".batty").join("team_config");
3946 std::fs::create_dir_all(&events_dir).unwrap();
3947
3948 std::fs::write(events_dir.join("events.jsonl"), "").unwrap();
3950 assert!(compute_session_summary(tmp.path()).is_none());
3951 }
3952
3953 #[test]
3954 fn session_summary_handles_missing_events_file() {
3955 let tmp = tempfile::tempdir().unwrap();
3956 assert!(compute_session_summary(tmp.path()).is_none());
3958 }
3959
3960 #[test]
3961 fn session_summary_display_format() {
3962 let summary = SessionSummary {
3963 tasks_completed: 5,
3964 tasks_merged: 4,
3965 runtime_secs: 8100, };
3967 assert_eq!(
3968 summary.display(),
3969 "Session summary: 5 tasks completed, 4 merged, runtime 2h 15m"
3970 );
3971 }
3972
3973 #[test]
3974 fn format_runtime_seconds() {
3975 assert_eq!(format_runtime(45), "45s");
3976 }
3977
3978 #[test]
3979 fn format_runtime_minutes() {
3980 assert_eq!(format_runtime(300), "5m");
3981 }
3982
3983 #[test]
3984 fn format_runtime_hours_and_minutes() {
3985 assert_eq!(format_runtime(5400), "1h 30m");
3986 }
3987
3988 #[test]
3989 fn format_runtime_exact_hours() {
3990 assert_eq!(format_runtime(7200), "2h");
3991 }
3992
3993 #[test]
3994 fn session_summary_uses_latest_daemon_started() {
3995 let tmp = tempfile::tempdir().unwrap();
3996 let events_dir = tmp.path().join(".batty").join("team_config");
3997 std::fs::create_dir_all(&events_dir).unwrap();
3998
3999 let now = now_unix();
4000 let events = [
4002 format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 7200),
4003 format!(
4004 r#"{{"event":"task_completed","role":"eng-1","task":"1","ts":{}}}"#,
4005 now - 6000
4006 ),
4007 format!(
4008 r#"{{"event":"task_completed","role":"eng-1","task":"2","ts":{}}}"#,
4009 now - 5000
4010 ),
4011 format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 1800),
4012 format!(
4013 r#"{{"event":"task_completed","role":"eng-1","task":"3","ts":{}}}"#,
4014 now - 1000
4015 ),
4016 ];
4017 std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
4018
4019 let summary = compute_session_summary(tmp.path()).unwrap();
4020 assert_eq!(summary.tasks_completed, 1);
4022 assert!(summary.runtime_secs >= 1799 && summary.runtime_secs <= 1801);
4023 }
4024}