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