Skip to main content

batty_cli/team/
mod.rs

1//! Team mode — hierarchical agent org chart with daemon-managed communication.
2//!
3//! A YAML-defined team (architect ↔ manager ↔ N engineers) runs in a tmux
4//! session. The daemon monitors panes, routes messages between roles, and
5//! manages agent lifecycles.
6
7pub mod artifact;
8pub mod auto_merge;
9#[cfg(test)]
10mod behavioral_tests;
11pub mod board;
12#[cfg(test)]
13mod smoke_tests;
14// -- Decomposed submodules --
15mod init;
16pub use init::*;
17mod load;
18pub use load::*;
19mod messaging;
20pub use messaging::*;
21pub mod board_cmd;
22pub mod board_health;
23pub mod capability;
24pub mod checkpoint;
25pub mod comms;
26pub mod completion;
27pub mod config;
28pub mod config_diff;
29pub mod cost;
30pub mod daemon;
31mod daemon_mgmt;
32pub mod delivery;
33pub mod deps;
34pub mod doctor;
35pub mod errors;
36pub mod estimation;
37pub mod events;
38pub mod failure_patterns;
39pub mod git_cmd;
40pub mod grafana;
41pub mod harness;
42pub mod hierarchy;
43pub mod inbox;
44pub mod layout;
45pub use daemon_mgmt::*;
46mod session;
47pub use session::*;
48pub mod merge;
49pub mod message;
50pub mod metrics;
51pub mod metrics_cmd;
52pub mod nudge;
53pub mod policy;
54pub mod resolver;
55pub mod retrospective;
56pub mod retry;
57pub mod review;
58pub mod scale;
59pub mod standup;
60pub mod status;
61pub mod tact;
62pub mod task_cmd;
63pub mod task_loop;
64pub mod telegram;
65pub mod telemetry_db;
66#[cfg(test)]
67pub mod test_helpers;
68#[cfg(test)]
69pub mod test_support;
70pub mod validation;
71pub mod watcher;
72pub mod workflow;
73
74use std::path::{Path, PathBuf};
75use std::time::{Duration, SystemTime, UNIX_EPOCH};
76
77use anyhow::{Context, Result};
78use serde::{Deserialize, Serialize};
79
80/// Team config directory name inside `.batty/`.
81pub const TEAM_CONFIG_DIR: &str = "team_config";
82/// Team config filename.
83pub const TEAM_CONFIG_FILE: &str = "team.yaml";
84
85const TRIAGE_RESULT_FRESHNESS_SECONDS: u64 = 300;
86pub(crate) const DEFAULT_EVENT_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum AssignmentResultStatus {
91    Delivered,
92    Failed,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct AssignmentDeliveryResult {
97    pub message_id: String,
98    pub status: AssignmentResultStatus,
99    pub engineer: String,
100    pub task_summary: String,
101    pub branch: Option<String>,
102    pub work_dir: Option<String>,
103    pub detail: String,
104    pub ts: u64,
105}
106
107/// Resolve the team config directory for a project root.
108pub fn team_config_dir(project_root: &Path) -> PathBuf {
109    project_root.join(".batty").join(TEAM_CONFIG_DIR)
110}
111
112/// Resolve the path to team.yaml.
113pub fn team_config_path(project_root: &Path) -> PathBuf {
114    team_config_dir(project_root).join(TEAM_CONFIG_FILE)
115}
116
117pub fn team_events_path(project_root: &Path) -> PathBuf {
118    team_config_dir(project_root).join("events.jsonl")
119}
120
121pub(crate) fn orchestrator_log_path(project_root: &Path) -> PathBuf {
122    project_root.join(".batty").join("orchestrator.log")
123}
124
125pub(crate) fn orchestrator_ansi_log_path(project_root: &Path) -> PathBuf {
126    project_root.join(".batty").join("orchestrator.ansi.log")
127}
128
129/// Directory containing per-agent PTY log files written by the shim.
130#[allow(dead_code)] // Public API for future shim-mode daemon integration
131pub(crate) fn shim_logs_dir(project_root: &Path) -> PathBuf {
132    project_root.join(".batty").join("shim-logs")
133}
134
135/// Path to an individual agent's PTY log file.
136#[allow(dead_code)] // Public API for future shim-mode daemon integration
137pub(crate) fn shim_log_path(project_root: &Path, agent_id: &str) -> PathBuf {
138    shim_logs_dir(project_root).join(format!("{agent_id}.pty.log"))
139}
140
141pub(crate) fn shim_events_log_path(project_root: &Path, agent_id: &str) -> PathBuf {
142    shim_logs_dir(project_root).join(format!("{agent_id}.events.log"))
143}
144
145pub(crate) fn append_shim_event_log(project_root: &Path, agent_id: &str, line: &str) -> Result<()> {
146    let path = shim_events_log_path(project_root, agent_id);
147    if let Some(parent) = path.parent() {
148        std::fs::create_dir_all(parent)?;
149    }
150    let mut file = std::fs::OpenOptions::new()
151        .create(true)
152        .append(true)
153        .open(&path)
154        .with_context(|| format!("failed to open shim event log {}", path.display()))?;
155    use std::io::Write;
156    writeln!(file, "[{}] {}", now_unix(), line)
157        .with_context(|| format!("failed to write shim event log {}", path.display()))?;
158    Ok(())
159}
160
161fn assignment_results_dir(project_root: &Path) -> PathBuf {
162    project_root.join(".batty").join("assignment_results")
163}
164
165fn assignment_result_path(project_root: &Path, message_id: &str) -> PathBuf {
166    assignment_results_dir(project_root).join(format!("{message_id}.json"))
167}
168
169pub(crate) fn store_assignment_result(
170    project_root: &Path,
171    result: &AssignmentDeliveryResult,
172) -> Result<()> {
173    let path = assignment_result_path(project_root, &result.message_id);
174    if let Some(parent) = path.parent() {
175        std::fs::create_dir_all(parent)?;
176    }
177    let content = serde_json::to_vec_pretty(result)?;
178    std::fs::write(&path, content)
179        .with_context(|| format!("failed to write assignment result {}", path.display()))?;
180    Ok(())
181}
182
183pub fn load_assignment_result(
184    project_root: &Path,
185    message_id: &str,
186) -> Result<Option<AssignmentDeliveryResult>> {
187    let path = assignment_result_path(project_root, message_id);
188    if !path.exists() {
189        return Ok(None);
190    }
191    let data = std::fs::read(&path)
192        .with_context(|| format!("failed to read assignment result {}", path.display()))?;
193    let result = serde_json::from_slice(&data)
194        .with_context(|| format!("failed to parse assignment result {}", path.display()))?;
195    Ok(Some(result))
196}
197
198pub fn wait_for_assignment_result(
199    project_root: &Path,
200    message_id: &str,
201    timeout: Duration,
202) -> Result<Option<AssignmentDeliveryResult>> {
203    let deadline = std::time::Instant::now() + timeout;
204    loop {
205        if let Some(result) = load_assignment_result(project_root, message_id)? {
206            return Ok(Some(result));
207        }
208        if std::time::Instant::now() >= deadline {
209            return Ok(None);
210        }
211        std::thread::sleep(Duration::from_millis(200));
212    }
213}
214
215pub fn format_assignment_result(result: &AssignmentDeliveryResult) -> String {
216    let mut text = match result.status {
217        AssignmentResultStatus::Delivered => {
218            format!(
219                "Assignment delivered: {} -> {}",
220                result.message_id, result.engineer
221            )
222        }
223        AssignmentResultStatus::Failed => {
224            format!(
225                "Assignment failed: {} -> {}",
226                result.message_id, result.engineer
227            )
228        }
229    };
230
231    text.push_str(&format!("\nTask: {}", result.task_summary));
232    if let Some(branch) = result.branch.as_deref() {
233        text.push_str(&format!("\nBranch: {branch}"));
234    }
235    if let Some(work_dir) = result.work_dir.as_deref() {
236        text.push_str(&format!("\nWorktree: {work_dir}"));
237    }
238    if !result.detail.is_empty() {
239        text.push_str(&format!("\nDetail: {}", result.detail));
240    }
241    text
242}
243
244pub(crate) fn now_unix() -> u64 {
245    SystemTime::now()
246        .duration_since(UNIX_EPOCH)
247        .unwrap_or_default()
248        .as_secs()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn team_config_dir_is_under_batty() {
257        let root = Path::new("/tmp/project");
258        assert_eq!(
259            team_config_dir(root),
260            PathBuf::from("/tmp/project/.batty/team_config")
261        );
262    }
263
264    #[test]
265    fn team_config_path_points_to_yaml() {
266        let root = Path::new("/tmp/project");
267        assert_eq!(
268            team_config_path(root),
269            PathBuf::from("/tmp/project/.batty/team_config/team.yaml")
270        );
271    }
272
273    #[test]
274    fn assignment_result_round_trip_and_format() {
275        let tmp = tempfile::tempdir().unwrap();
276        let result = AssignmentDeliveryResult {
277            message_id: "msg-1".to_string(),
278            status: AssignmentResultStatus::Delivered,
279            engineer: "eng-1-1".to_string(),
280            task_summary: "Say Hello".to_string(),
281            branch: Some("eng-1-1/task-1".to_string()),
282            work_dir: Some("/tmp/worktree".to_string()),
283            detail: "assignment launched".to_string(),
284            ts: now_unix(),
285        };
286
287        store_assignment_result(tmp.path(), &result).unwrap();
288        let loaded = load_assignment_result(tmp.path(), "msg-1")
289            .unwrap()
290            .unwrap();
291        assert_eq!(loaded, result);
292
293        let formatted = format_assignment_result(&loaded);
294        assert!(formatted.contains("Assignment delivered: msg-1 -> eng-1-1"));
295        assert!(formatted.contains("Branch: eng-1-1/task-1"));
296        assert!(formatted.contains("Worktree: /tmp/worktree"));
297    }
298
299    #[test]
300    fn wait_for_assignment_result_returns_none_when_missing() {
301        let tmp = tempfile::tempdir().unwrap();
302        let result =
303            wait_for_assignment_result(tmp.path(), "missing", Duration::from_millis(10)).unwrap();
304        assert!(result.is_none());
305    }
306
307    #[test]
308    fn shim_logs_dir_path() {
309        let root = Path::new("/tmp/project");
310        assert_eq!(
311            shim_logs_dir(root),
312            PathBuf::from("/tmp/project/.batty/shim-logs")
313        );
314    }
315
316    #[test]
317    fn shim_log_path_includes_agent_id() {
318        let root = Path::new("/tmp/project");
319        assert_eq!(
320            shim_log_path(root, "eng-1-1"),
321            PathBuf::from("/tmp/project/.batty/shim-logs/eng-1-1.pty.log")
322        );
323    }
324
325    #[test]
326    fn shim_events_log_path_includes_agent_id() {
327        let root = Path::new("/tmp/project");
328        assert_eq!(
329            shim_events_log_path(root, "eng-1-1"),
330            PathBuf::from("/tmp/project/.batty/shim-logs/eng-1-1.events.log")
331        );
332    }
333
334    /// Count unwrap()/expect() calls in production code (before `#[cfg(test)] mod tests`).
335    fn production_unwrap_expect_count(source: &str) -> usize {
336        // Split at the test module boundary, not individual #[cfg(test)] items
337        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
338            &source[..pos]
339        } else {
340            source
341        };
342        prod.lines()
343            .filter(|line| {
344                let trimmed = line.trim();
345                // Skip lines that are themselves cfg(test)-gated items
346                !trimmed.starts_with("#[cfg(test)]")
347                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
348            })
349            .count()
350    }
351
352    #[test]
353    fn production_mod_has_no_unwrap_or_expect_calls() {
354        let src = include_str!("mod.rs");
355        assert_eq!(
356            production_unwrap_expect_count(src),
357            0,
358            "production mod.rs should avoid unwrap/expect"
359        );
360    }
361}