batty_cli/team/tact/
mod.rs1use std::path::Path;
2use std::time::{Duration, Instant};
3
4use anyhow::Result;
5
6pub mod parser;
7pub mod prompt;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct TactPrompt {
11 pub board_summary: String,
12 pub recent_completions: Vec<String>,
13 pub roadmap_priorities: Vec<String>,
14 pub idle_count: usize,
15 pub dispatchable_count: usize,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TaskSpec {
21 pub title: String,
22 pub body: String,
23 pub priority: Option<String>,
24 pub depends_on: Vec<u32>,
25 pub tags: Vec<String>,
26}
27
28pub type GeneratedTask = TaskSpec;
29
30pub use parser::{create_board_tasks, parse_planning_response};
31pub use prompt::{PLANNING_RESPONSE_FORMAT, compose_planning_prompt};
32
33pub fn dispatchable_task_count(
34 board_dir: &Path,
35 _members: &[crate::team::hierarchy::MemberInstance],
36) -> Result<usize> {
37 Ok(crate::team::resolver::dispatchable_tasks(board_dir)?.len())
38}
39
40pub fn should_trigger_planning_cycle(idle_engineers: usize, dispatchable_tasks: usize) -> bool {
41 idle_engineers > dispatchable_tasks
42}
43
44pub fn should_trigger(idle_engineers: usize, dispatchable_tasks: usize) -> bool {
45 should_trigger_planning_cycle(idle_engineers, dispatchable_tasks)
46}
47
48pub fn compose_prompt(ctx: &TactPrompt) -> String {
49 prompt::compose_prompt(ctx)
50}
51
52pub fn parse_task_specs(response: &str) -> Vec<TaskSpec> {
53 parser::parse_task_specs(response)
54}
55
56pub fn planning_cycle_ready(
57 active: bool,
58 last_fired: Option<Instant>,
59 cooldown: Duration,
60 idle_engineers: usize,
61 dispatchable_tasks: usize,
62) -> bool {
63 if active || !should_trigger_planning_cycle(idle_engineers, dispatchable_tasks) {
64 return false;
65 }
66
67 last_fired.is_none_or(|last| last.elapsed() >= cooldown)
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::team::config::TeamConfig;
74 use crate::team::hierarchy::resolve_hierarchy;
75
76 fn solo_members() -> Vec<crate::team::hierarchy::MemberInstance> {
77 let config: TeamConfig = serde_yaml::from_str(
78 r#"
79name: solo
80roles:
81 - name: engineer
82 role_type: engineer
83 agent: codex
84 instances: 1
85"#,
86 )
87 .unwrap();
88 resolve_hierarchy(&config).unwrap()
89 }
90
91 #[test]
92 fn trigger_fires_when_idle_exceeds_dispatchable() {
93 assert!(should_trigger_planning_cycle(3, 1));
94 }
95
96 #[test]
97 fn trigger_does_not_fire_when_board_has_enough_tasks() {
98 assert!(!should_trigger_planning_cycle(2, 2));
99 assert!(!should_trigger_planning_cycle(1, 3));
100 }
101
102 #[test]
103 fn cooldown_prevents_rapid_retrigger() {
104 assert!(!planning_cycle_ready(
105 false,
106 Some(Instant::now()),
107 Duration::from_secs(120),
108 3,
109 0,
110 ));
111 }
112
113 #[test]
114 fn planning_cycle_ready_when_cooldown_elapsed() {
115 assert!(planning_cycle_ready(
116 false,
117 Some(Instant::now() - Duration::from_secs(121)),
118 Duration::from_secs(120),
119 3,
120 0,
121 ));
122 }
123
124 #[test]
125 fn dispatchable_task_count_uses_workflow_resolver() {
126 let tmp = tempfile::tempdir().unwrap();
127 let board_dir = tmp.path();
128 std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
129 std::fs::write(
130 board_dir.join("tasks/001-runnable.md"),
131 "---\nid: 1\ntitle: runnable\nstatus: backlog\npriority: medium\nclass: standard\n---\n\nBody.\n",
132 )
133 .unwrap();
134 std::fs::write(
135 board_dir.join("tasks/002-blocked.md"),
136 "---\nid: 2\ntitle: blocked\nstatus: blocked\npriority: medium\nclass: standard\nblocked: waiting\n---\n\nBody.\n",
137 )
138 .unwrap();
139
140 let count = dispatchable_task_count(board_dir, &solo_members()).unwrap();
141 assert_eq!(count, 1);
142 }
143
144 #[test]
145 fn dispatchable_task_count_excludes_manual_blocked_todo() {
146 let tmp = tempfile::tempdir().unwrap();
147 let board_dir = tmp.path();
148 std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
149 std::fs::write(
150 board_dir.join("tasks/001-runnable-a.md"),
151 "---\nid: 1\ntitle: runnable-a\nstatus: todo\npriority: medium\nclass: standard\n---\n\nBody.\n",
152 )
153 .unwrap();
154 std::fs::write(
155 board_dir.join("tasks/002-runnable-b.md"),
156 "---\nid: 2\ntitle: runnable-b\nstatus: todo\npriority: medium\nclass: standard\n---\n\nBody.\n",
157 )
158 .unwrap();
159 std::fs::write(
160 board_dir.join("tasks/003-manual.md"),
161 "---\nid: 3\ntitle: manual\nstatus: todo\npriority: medium\nblocked: manual provider-console token rotation\nclass: standard\n---\n\nBody.\n",
162 )
163 .unwrap();
164
165 let count = dispatchable_task_count(board_dir, &solo_members()).unwrap();
166 assert_eq!(count, 2);
167 }
168
169 #[test]
170 fn test_should_trigger_true() {
171 assert!(should_trigger(3, 1));
172 }
173
174 #[test]
175 fn test_should_trigger_false() {
176 assert!(!should_trigger(1, 3));
177 }
178
179 #[test]
180 fn test_should_trigger_equal() {
181 assert!(!should_trigger(2, 2));
182 }
183
184 #[test]
185 fn prompt_request_count_uses_dispatchable_deficit() {
186 let prompt = compose_prompt(&TactPrompt {
187 board_summary: "todo=3, dispatchable_tasks=1, idle_engineers=2".to_string(),
188 recent_completions: vec!["Finished parser".to_string()],
189 roadmap_priorities: vec!["Ship tact".to_string()],
190 idle_count: 2,
191 dispatchable_count: 1,
192 });
193 assert!(prompt.contains("Please specify 1 new tasks"));
194 }
195}