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