Skip to main content

edda_conductor/agent/
launcher.rs

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/// Result of running an agent for a phase.
11#[derive(Debug, Clone)]
12pub enum PhaseResult {
13    AgentDone {
14        cost_usd: Option<f64>,
15        /// The agent's final summary text (from stream-json result message).
16        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/// Trait for launching AI agents. Implemented by MockLauncher (tests)
31/// and ClaudeCodeLauncher (real, Wave 4).
32#[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
45/// Fixed namespace UUID for conductor sessions.
46const 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
50/// Deterministic session ID per plan+phase+attempt.
51/// Each attempt gets a unique session to avoid "session already in use" errors.
52pub fn phase_session_id(plan_name: &str, phase_id: &str) -> Uuid {
53    phase_session_id_attempt(plan_name, phase_id, 1)
54}
55
56/// Deterministic session ID with attempt number.
57pub 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
64/// Launches real Claude Code processes via `claude -p`.
65pub struct ClaudeCodeLauncher {
66    pub claude_bin: PathBuf,
67    pub verbose: bool,
68    /// If set, raw agent stdout is captured to `{transcript_dir}/{phase_id}-{session_id_prefix}.jsonl`.
69    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    /// Enable verbose mode: print live agent activity (tool calls, file writes, etc.)
96    pub fn with_verbose(mut self, verbose: bool) -> Self {
97        self.verbose = verbose;
98        self
99    }
100
101    /// Check that the Claude CLI binary is reachable.
102    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            // Allow nesting — remove markers that prevent Claude Code from spawning
144            .env_remove("CLAUDE_CODE")
145            .env_remove("CLAUDECODE")
146            // Tell edda hooks to use conductor-optimized injection
147            .env("EDDA_CONDUCTOR_MODE", "1")
148            // Propagate session_id so agent-spawned `edda decide` etc. can resolve identity
149            .env("EDDA_SESSION_ID", session_id);
150
151        // Optional: per-phase budget
152        if let Some(budget) = phase.budget_usd {
153            cmd.arg("--max-budget-usd").arg(budget.to_string());
154        }
155
156        // Optional: plan context as system prompt
157        if !plan_context.is_empty() {
158            cmd.arg("--append-system-prompt").arg(plan_context);
159        }
160
161        // Optional: allowed tools
162        if let Some(tools) = &phase.allowed_tools {
163            cmd.arg("--allowedTools").arg(tools.join(","));
164        }
165
166        // Merge plan-level + phase-level env
167        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
204/// Mock launcher for testing. Pops results on each call per phase ID.
205/// If no results configured (or exhausted), returns AgentDone.
206pub 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        // UUID v5 has version nibble = 5
284        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}