1use crate::agent::stream::{classify_result, StreamMonitor};
2use crate::plan::schema::Phase;
3use anyhow::Result;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::time::Duration;
7use tokio_util::sync::CancellationToken;
8use uuid::Uuid;
9
10#[derive(Debug, Clone)]
12pub enum PhaseResult {
13 AgentDone {
14 cost_usd: Option<f64>,
15 result_text: Option<String>,
17 },
18 AgentCrash {
19 error: String,
20 },
21 Timeout,
22 MaxTurns {
23 cost_usd: Option<f64>,
24 },
25 BudgetExceeded {
26 cost_usd: Option<f64>,
27 },
28}
29
30#[async_trait::async_trait]
33pub trait AgentLauncher: Send + Sync {
34 async fn run_phase(
35 &self,
36 phase: &Phase,
37 prompt: &str,
38 plan_context: &str,
39 session_id: &str,
40 cwd: &Path,
41 cancel: CancellationToken,
42 ) -> Result<PhaseResult>;
43}
44
45const CONDUCTOR_NS: Uuid = Uuid::from_bytes([
47 0xed, 0xda, 0xc0, 0x5d, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
48]);
49
50pub fn phase_session_id(plan_name: &str, phase_id: &str) -> Uuid {
53 phase_session_id_attempt(plan_name, phase_id, 1)
54}
55
56pub fn phase_session_id_attempt(plan_name: &str, phase_id: &str, attempt: u32) -> Uuid {
58 Uuid::new_v5(
59 &CONDUCTOR_NS,
60 format!("{plan_name}-{phase_id}-{attempt}").as_bytes(),
61 )
62}
63
64pub struct ClaudeCodeLauncher {
66 pub claude_bin: PathBuf,
67 pub verbose: bool,
68 pub transcript_dir: Option<PathBuf>,
70}
71
72impl Default for ClaudeCodeLauncher {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl ClaudeCodeLauncher {
79 pub fn new() -> Self {
80 Self {
81 claude_bin: PathBuf::from("claude"),
82 verbose: false,
83 transcript_dir: None,
84 }
85 }
86
87 pub fn with_bin(claude_bin: PathBuf) -> Self {
88 Self {
89 claude_bin,
90 verbose: false,
91 transcript_dir: None,
92 }
93 }
94
95 pub fn with_verbose(mut self, verbose: bool) -> Self {
97 self.verbose = verbose;
98 self
99 }
100
101 pub fn verify_available(&self) -> Result<()> {
103 let status = std::process::Command::new(&self.claude_bin)
104 .arg("--version")
105 .stdout(std::process::Stdio::null())
106 .stderr(std::process::Stdio::null())
107 .status();
108 match status {
109 Ok(s) if s.success() => Ok(()),
110 _ => anyhow::bail!(
111 "Claude CLI not found (looked for {:?}).\n\
112 Install: npm install -g @anthropic-ai/claude-code",
113 self.claude_bin
114 ),
115 }
116 }
117}
118
119#[async_trait::async_trait]
120impl AgentLauncher for ClaudeCodeLauncher {
121 async fn run_phase(
122 &self,
123 phase: &Phase,
124 prompt: &str,
125 plan_context: &str,
126 session_id: &str,
127 cwd: &Path,
128 cancel: CancellationToken,
129 ) -> Result<PhaseResult> {
130 let mut cmd = tokio::process::Command::new(&self.claude_bin);
131 cmd.arg("-p")
132 .arg(prompt)
133 .arg("--verbose")
134 .arg("--output-format")
135 .arg("stream-json")
136 .arg("--session-id")
137 .arg(session_id)
138 .arg("--permission-mode")
139 .arg(&phase.permission_mode)
140 .current_dir(cwd)
141 .stdout(Stdio::piped())
142 .stderr(Stdio::null())
143 .env_remove("CLAUDE_CODE")
145 .env_remove("CLAUDECODE")
146 .env("EDDA_CONDUCTOR_MODE", "1")
148 .env("EDDA_SESSION_ID", session_id);
150
151 if let Some(budget) = phase.budget_usd {
153 cmd.arg("--max-budget-usd").arg(budget.to_string());
154 }
155
156 if !plan_context.is_empty() {
158 cmd.arg("--append-system-prompt").arg(plan_context);
159 }
160
161 if let Some(tools) = &phase.allowed_tools {
163 cmd.arg("--allowedTools").arg(tools.join(","));
164 }
165
166 for (k, v) in &phase.env {
168 cmd.env(k, v);
169 }
170
171 let mut child = cmd.spawn()?;
172 let stdout = child
173 .stdout
174 .take()
175 .ok_or_else(|| anyhow::anyhow!("failed to capture stdout"))?;
176
177 let tee_path = self.transcript_dir.as_ref().map(|dir| {
178 let sid_prefix = &session_id[..session_id.len().min(8)];
179 dir.join(format!("{}-{sid_prefix}.jsonl", phase.id))
180 });
181 let mut monitor = StreamMonitor::new(stdout)
182 .with_verbose(self.verbose)
183 .with_tee(tee_path);
184 let timeout_sec = phase.timeout_sec.unwrap_or(1800);
185
186 tokio::select! {
187 result = monitor.run() => {
188 let monitor_result = result?;
189 let exit = child.wait().await?;
190 Ok(classify_result(&monitor_result, exit.code()))
191 }
192 _ = tokio::time::sleep(Duration::from_secs(timeout_sec)) => {
193 child.kill().await.ok();
194 Ok(PhaseResult::Timeout)
195 }
196 _ = cancel.cancelled() => {
197 child.kill().await.ok();
198 Ok(PhaseResult::AgentCrash { error: "conductor shutdown".into() })
199 }
200 }
201 }
202}
203
204pub struct MockLauncher {
207 results: std::sync::Mutex<std::collections::HashMap<String, Vec<PhaseResult>>>,
208}
209
210impl Default for MockLauncher {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216impl MockLauncher {
217 pub fn new() -> Self {
218 Self {
219 results: std::sync::Mutex::new(std::collections::HashMap::new()),
220 }
221 }
222
223 pub fn set_results(&self, phase_id: &str, results: Vec<PhaseResult>) {
224 self.results
225 .lock()
226 .unwrap()
227 .insert(phase_id.to_string(), results);
228 }
229}
230
231#[async_trait::async_trait]
232impl AgentLauncher for MockLauncher {
233 async fn run_phase(
234 &self,
235 phase: &Phase,
236 _prompt: &str,
237 _plan_context: &str,
238 _session_id: &str,
239 _cwd: &Path,
240 cancel: CancellationToken,
241 ) -> Result<PhaseResult> {
242 if cancel.is_cancelled() {
243 return Ok(PhaseResult::AgentCrash {
244 error: "cancelled".into(),
245 });
246 }
247
248 let mut map = self.results.lock().unwrap();
249 if let Some(vec) = map.get_mut(&phase.id) {
250 if !vec.is_empty() {
251 return Ok(vec.remove(0));
252 }
253 }
254 Ok(PhaseResult::AgentDone {
255 cost_usd: Some(0.10),
256 result_text: Some("(mock) phase completed".into()),
257 })
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::plan::parser::parse_plan;
265
266 #[test]
267 fn session_id_deterministic() {
268 let id1 = phase_session_id("my-plan", "build");
269 let id2 = phase_session_id("my-plan", "build");
270 assert_eq!(id1, id2);
271 }
272
273 #[test]
274 fn session_id_differs_per_phase() {
275 let id1 = phase_session_id("plan", "a");
276 let id2 = phase_session_id("plan", "b");
277 assert_ne!(id1, id2);
278 }
279
280 #[test]
281 fn session_id_is_valid_uuid() {
282 let id = phase_session_id("test", "phase");
283 assert_eq!(id.get_version_num(), 5);
285 }
286
287 #[tokio::test]
288 async fn mock_returns_default() {
289 let launcher = MockLauncher::new();
290 let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
291 let cancel = CancellationToken::new();
292 let result = launcher
293 .run_phase(&plan.phases[0], "prompt", "", "sid", Path::new("."), cancel)
294 .await
295 .unwrap();
296 assert!(matches!(result, PhaseResult::AgentDone { .. }));
297 }
298
299 #[tokio::test]
300 async fn mock_returns_configured() {
301 let launcher = MockLauncher::new();
302 launcher.set_results(
303 "a",
304 vec![PhaseResult::AgentCrash {
305 error: "boom".into(),
306 }],
307 );
308 let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
309 let cancel = CancellationToken::new();
310 let result = launcher
311 .run_phase(&plan.phases[0], "prompt", "", "sid", Path::new("."), cancel)
312 .await
313 .unwrap();
314 assert!(matches!(result, PhaseResult::AgentCrash { .. }));
315 }
316
317 #[tokio::test]
318 async fn mock_pops_sequential_results() {
319 let launcher = MockLauncher::new();
320 launcher.set_results(
321 "a",
322 vec![
323 PhaseResult::AgentCrash {
324 error: "first".into(),
325 },
326 PhaseResult::AgentDone {
327 cost_usd: Some(1.0),
328 result_text: None,
329 },
330 ],
331 );
332 let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
333 let cancel = CancellationToken::new();
334
335 let r1 = launcher
336 .run_phase(&plan.phases[0], "", "", "s", Path::new("."), cancel.clone())
337 .await
338 .unwrap();
339 assert!(matches!(r1, PhaseResult::AgentCrash { .. }));
340
341 let r2 = launcher
342 .run_phase(&plan.phases[0], "", "", "s", Path::new("."), cancel)
343 .await
344 .unwrap();
345 assert!(
346 matches!(r2, PhaseResult::AgentDone { cost_usd: Some(c), .. } if (c - 1.0).abs() < 0.01)
347 );
348 }
349
350 #[tokio::test]
351 async fn mock_respects_cancel() {
352 let launcher = MockLauncher::new();
353 let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
354 let cancel = CancellationToken::new();
355 cancel.cancel();
356 let result = launcher
357 .run_phase(&plan.phases[0], "", "", "s", Path::new("."), cancel)
358 .await
359 .unwrap();
360 assert!(matches!(result, PhaseResult::AgentCrash { .. }));
361 }
362}