use crate::agent::stream::{classify_result, StreamMonitor};
use crate::plan::schema::Phase;
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum PhaseResult {
AgentDone {
cost_usd: Option<f64>,
result_text: Option<String>,
},
AgentCrash {
error: String,
},
Timeout,
MaxTurns {
cost_usd: Option<f64>,
},
BudgetExceeded {
cost_usd: Option<f64>,
},
}
#[async_trait::async_trait]
pub trait AgentLauncher: Send + Sync {
async fn run_phase(
&self,
phase: &Phase,
prompt: &str,
plan_context: &str,
session_id: &str,
cwd: &Path,
cancel: CancellationToken,
) -> Result<PhaseResult>;
}
const CONDUCTOR_NS: Uuid = Uuid::from_bytes([
0xed, 0xda, 0xc0, 0x5d, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
]);
pub fn phase_session_id(plan_name: &str, phase_id: &str) -> Uuid {
phase_session_id_attempt(plan_name, phase_id, 1)
}
pub fn phase_session_id_attempt(plan_name: &str, phase_id: &str, attempt: u32) -> Uuid {
Uuid::new_v5(
&CONDUCTOR_NS,
format!("{plan_name}-{phase_id}-{attempt}").as_bytes(),
)
}
pub struct ClaudeCodeLauncher {
pub claude_bin: PathBuf,
pub verbose: bool,
pub transcript_dir: Option<PathBuf>,
}
impl Default for ClaudeCodeLauncher {
fn default() -> Self {
Self::new()
}
}
impl ClaudeCodeLauncher {
pub fn new() -> Self {
Self {
claude_bin: PathBuf::from("claude"),
verbose: false,
transcript_dir: None,
}
}
pub fn with_bin(claude_bin: PathBuf) -> Self {
Self {
claude_bin,
verbose: false,
transcript_dir: None,
}
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn verify_available(&self) -> Result<()> {
let status = std::process::Command::new(&self.claude_bin)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => Ok(()),
_ => anyhow::bail!(
"Claude CLI not found (looked for {:?}).\n\
Install: npm install -g @anthropic-ai/claude-code",
self.claude_bin
),
}
}
}
#[async_trait::async_trait]
impl AgentLauncher for ClaudeCodeLauncher {
async fn run_phase(
&self,
phase: &Phase,
prompt: &str,
plan_context: &str,
session_id: &str,
cwd: &Path,
cancel: CancellationToken,
) -> Result<PhaseResult> {
let mut cmd = tokio::process::Command::new(&self.claude_bin);
cmd.arg("-p")
.arg(prompt)
.arg("--verbose")
.arg("--output-format")
.arg("stream-json")
.arg("--session-id")
.arg(session_id)
.arg("--permission-mode")
.arg(&phase.permission_mode)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.env_remove("CLAUDE_CODE")
.env_remove("CLAUDECODE")
.env("EDDA_CONDUCTOR_MODE", "1")
.env("EDDA_SESSION_ID", session_id);
if let Some(budget) = phase.budget_usd {
cmd.arg("--max-budget-usd").arg(budget.to_string());
}
if !plan_context.is_empty() {
cmd.arg("--append-system-prompt").arg(plan_context);
}
if let Some(tools) = &phase.allowed_tools {
cmd.arg("--allowedTools").arg(tools.join(","));
}
for (k, v) in &phase.env {
cmd.env(k, v);
}
let mut child = cmd.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout"))?;
let tee_path = self.transcript_dir.as_ref().map(|dir| {
let sid_prefix = &session_id[..session_id.len().min(8)];
dir.join(format!("{}-{sid_prefix}.jsonl", phase.id))
});
let mut monitor = StreamMonitor::new(stdout)
.with_verbose(self.verbose)
.with_tee(tee_path);
let timeout_sec = phase.timeout_sec.unwrap_or(1800);
tokio::select! {
result = monitor.run() => {
let monitor_result = result?;
let exit = child.wait().await?;
Ok(classify_result(&monitor_result, exit.code()))
}
_ = tokio::time::sleep(Duration::from_secs(timeout_sec)) => {
child.kill().await.ok();
Ok(PhaseResult::Timeout)
}
_ = cancel.cancelled() => {
child.kill().await.ok();
Ok(PhaseResult::AgentCrash { error: "conductor shutdown".into() })
}
}
}
}
pub struct MockLauncher {
results: std::sync::Mutex<std::collections::HashMap<String, Vec<PhaseResult>>>,
}
impl Default for MockLauncher {
fn default() -> Self {
Self::new()
}
}
impl MockLauncher {
pub fn new() -> Self {
Self {
results: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
pub fn set_results(&self, phase_id: &str, results: Vec<PhaseResult>) {
self.results
.lock()
.unwrap()
.insert(phase_id.to_string(), results);
}
}
#[async_trait::async_trait]
impl AgentLauncher for MockLauncher {
async fn run_phase(
&self,
phase: &Phase,
_prompt: &str,
_plan_context: &str,
_session_id: &str,
_cwd: &Path,
cancel: CancellationToken,
) -> Result<PhaseResult> {
if cancel.is_cancelled() {
return Ok(PhaseResult::AgentCrash {
error: "cancelled".into(),
});
}
let mut map = self.results.lock().unwrap();
if let Some(vec) = map.get_mut(&phase.id) {
if !vec.is_empty() {
return Ok(vec.remove(0));
}
}
Ok(PhaseResult::AgentDone {
cost_usd: Some(0.10),
result_text: Some("(mock) phase completed".into()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::parser::parse_plan;
#[test]
fn session_id_deterministic() {
let id1 = phase_session_id("my-plan", "build");
let id2 = phase_session_id("my-plan", "build");
assert_eq!(id1, id2);
}
#[test]
fn session_id_differs_per_phase() {
let id1 = phase_session_id("plan", "a");
let id2 = phase_session_id("plan", "b");
assert_ne!(id1, id2);
}
#[test]
fn session_id_is_valid_uuid() {
let id = phase_session_id("test", "phase");
assert_eq!(id.get_version_num(), 5);
}
#[tokio::test]
async fn mock_returns_default() {
let launcher = MockLauncher::new();
let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
let cancel = CancellationToken::new();
let result = launcher
.run_phase(&plan.phases[0], "prompt", "", "sid", Path::new("."), cancel)
.await
.unwrap();
assert!(matches!(result, PhaseResult::AgentDone { .. }));
}
#[tokio::test]
async fn mock_returns_configured() {
let launcher = MockLauncher::new();
launcher.set_results(
"a",
vec![PhaseResult::AgentCrash {
error: "boom".into(),
}],
);
let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
let cancel = CancellationToken::new();
let result = launcher
.run_phase(&plan.phases[0], "prompt", "", "sid", Path::new("."), cancel)
.await
.unwrap();
assert!(matches!(result, PhaseResult::AgentCrash { .. }));
}
#[tokio::test]
async fn mock_pops_sequential_results() {
let launcher = MockLauncher::new();
launcher.set_results(
"a",
vec![
PhaseResult::AgentCrash {
error: "first".into(),
},
PhaseResult::AgentDone {
cost_usd: Some(1.0),
result_text: None,
},
],
);
let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
let cancel = CancellationToken::new();
let r1 = launcher
.run_phase(&plan.phases[0], "", "", "s", Path::new("."), cancel.clone())
.await
.unwrap();
assert!(matches!(r1, PhaseResult::AgentCrash { .. }));
let r2 = launcher
.run_phase(&plan.phases[0], "", "", "s", Path::new("."), cancel)
.await
.unwrap();
assert!(
matches!(r2, PhaseResult::AgentDone { cost_usd: Some(c), .. } if (c - 1.0).abs() < 0.01)
);
}
#[tokio::test]
async fn mock_respects_cancel() {
let launcher = MockLauncher::new();
let plan = parse_plan("name: t\nphases:\n - id: a\n prompt: x\n").unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let result = launcher
.run_phase(&plan.phases[0], "", "", "s", Path::new("."), cancel)
.await
.unwrap();
assert!(matches!(result, PhaseResult::AgentCrash { .. }));
}
}