Skip to main content

edda_conductor/runner/
sequential.rs

1use crate::agent::budget::BudgetTracker;
2use crate::agent::launcher::{phase_session_id_attempt, AgentLauncher, PhaseResult};
3use crate::check::engine::{CheckEngine, CheckRunResult};
4use crate::plan::schema::{CheckSpec, OnFail, Plan};
5use crate::plan::topo::topo_sort;
6use crate::runner::edda;
7use crate::runner::event_log::{self, Event, EventLogger};
8use crate::runner::notify::Notifier;
9use crate::state::brief::write_brief;
10use crate::state::derive::{
11    detect_stale_phases, find_next_phase, is_plan_blocked, is_plan_complete, update_plan_status,
12};
13use crate::state::machine::{
14    transition, CheckResult, CheckStatus, ErrorInfo, ErrorType, PhaseStatus, PhaseUpdate,
15    PlanState, PlanStatus,
16};
17use crate::state::persist::save_state;
18use crate::tmux::TmuxSession;
19use anyhow::Context;
20use anyhow::Result;
21use std::path::Path;
22use std::time::Instant;
23use tokio_util::sync::CancellationToken;
24
25/// Runtime context for [`run_plan`], grouping execution environment parameters.
26pub struct RunContext<'a> {
27    pub launcher: &'a dyn AgentLauncher,
28    pub check_engine: &'a CheckEngine,
29    pub notifier: &'a dyn Notifier,
30    pub budget: &'a mut BudgetTracker,
31    pub cancel: CancellationToken,
32    pub cwd: &'a Path,
33    pub interactive: bool,
34    pub json_events: bool,
35    /// Optional tmux session for updating pane status during execution.
36    pub tmux_session: Option<&'a TmuxSession>,
37}
38
39/// Run a plan sequentially. The main conductor loop.
40pub async fn run_plan(plan: &Plan, state: &mut PlanState, ctx: RunContext<'_>) -> Result<()> {
41    let RunContext {
42        launcher,
43        check_engine,
44        notifier,
45        budget,
46        cancel,
47        cwd,
48        interactive,
49        json_events,
50        tmux_session,
51    } = ctx;
52    let order = topo_sort(plan)?;
53    let total_phases = order.len();
54    let mut event_log = EventLogger::new(cwd, &plan.name).with_stdout_json(json_events);
55
56    // Initialize edda ledger if available
57    edda::ensure_init(cwd);
58
59    // Detect stale phases from previous run
60    detect_stale_phases(state, plan);
61
62    // Record plan start
63    if state.started_at.is_none() {
64        state.started_at = Some(now_rfc3339());
65        state.plan_status = PlanStatus::Running;
66        save_state(cwd, state)?;
67        event_log::write_runner_status(cwd, state, None);
68        write_brief(cwd, state, None);
69        event_log.record(Event::PlanStart {
70            plan_name: plan.name.clone(),
71            phase_count: total_phases,
72        });
73    }
74
75    loop {
76        // 1. Check termination
77        if cancel.is_cancelled() {
78            println!("Shutdown. Run `edda conduct run` to resume.");
79            break;
80        }
81
82        update_plan_status(state);
83
84        if state.plan_status == PlanStatus::Aborted {
85            break;
86        }
87
88        if is_plan_blocked(state) {
89            let failed = state
90                .phases
91                .iter()
92                .find(|p| p.status == PhaseStatus::Failed || p.status == PhaseStatus::Stale);
93            let failed_id = failed.map(|f| f.id.clone()).unwrap_or_default();
94
95            if interactive {
96                match prompt_blocked_action(&failed_id) {
97                    BlockedAction::Retry => {
98                        let current = state
99                            .get_phase(&failed_id)
100                            .map(|p| p.status)
101                            .unwrap_or(PhaseStatus::Failed);
102                        let _ = transition(state, &failed_id, current, PhaseStatus::Pending, None);
103                        state.plan_status = PlanStatus::Running;
104                        save_state(cwd, state)?;
105                        println!("  ↻ Retrying \"{failed_id}\"");
106                        continue;
107                    }
108                    BlockedAction::Skip => {
109                        let ps = state.get_phase_mut(&failed_id)?;
110                        ps.status = PhaseStatus::Skipped;
111                        ps.skip_reason = Some("manually skipped (interactive)".into());
112                        state.plan_status = PlanStatus::Running;
113                        save_state(cwd, state)?;
114                        event_log.record(Event::PhaseSkipped {
115                            phase_id: failed_id.clone(),
116                            reason: "manually skipped (interactive)".into(),
117                        });
118                        println!("  ⊘ Skipped \"{failed_id}\"");
119                        continue;
120                    }
121                    BlockedAction::Abort => {
122                        state.plan_status = PlanStatus::Aborted;
123                        state.aborted_at = Some(now_rfc3339());
124                        save_state(cwd, state)?;
125                        event_log.record(Event::PlanAborted {
126                            phases_passed: state
127                                .phases
128                                .iter()
129                                .filter(|p| p.status == PhaseStatus::Passed)
130                                .count(),
131                            phases_pending: state
132                                .phases
133                                .iter()
134                                .filter(|p| p.status == PhaseStatus::Pending)
135                                .count(),
136                        });
137                        println!("  ✗ Plan aborted.");
138                        break;
139                    }
140                    BlockedAction::Quit => {
141                        println!("Paused. Run `edda conduct run` to resume.");
142                        break;
143                    }
144                }
145            } else {
146                notifier
147                    .notify(&format!(
148                        "Plan blocked: phase \"{}\" is {:?}. Use retry/skip/abort.",
149                        failed_id,
150                        failed.map(|f| f.status),
151                    ))
152                    .await;
153                break;
154            }
155        }
156
157        if budget.is_exhausted() {
158            notifier.notify("Plan budget exhausted.").await;
159            break;
160        }
161
162        // 2. Find next runnable phase
163        let Some(phase_id) = find_next_phase(plan, state, &order) else {
164            break; // all done or no runnable phase
165        };
166        let phase = plan
167            .phases
168            .iter()
169            .find(|p| p.id == phase_id)
170            .context("runnable phase not found in plan")?;
171        let phase_state = state.get_phase_mut(&phase_id)?;
172        let attempt = phase_state.attempts + 1;
173        let phase_cwd = phase
174            .cwd
175            .as_deref()
176            .or(plan.cwd.as_deref())
177            .map(|p| cwd.join(p))
178            .unwrap_or_else(|| cwd.to_path_buf());
179
180        let phase_num = order.iter().position(|id| id == &phase_id).unwrap_or(0) + 1;
181
182        // Clear retry_context on new attempt start (it was already consumed for prompt building)
183        let retry_ctx = phase_state.retry_context.take();
184
185        // 3. Transition: pending → running
186        transition(
187            state,
188            &phase_id,
189            PhaseStatus::Pending,
190            PhaseStatus::Running,
191            Some(PhaseUpdate {
192                started_at: Some(now_rfc3339()),
193                attempts: Some(attempt),
194                checks: Some(vec![]),
195                error: None,
196                ..Default::default()
197            }),
198        )?;
199        save_state(cwd, state)?;
200
201        println!("\n▶ [{phase_num}/{total_phases}] Phase \"{phase_id}\" (attempt {attempt})");
202        if let Some(tmux) = tmux_session {
203            let _ = tmux.update_phase_status(&phase_id, "Running");
204        }
205        let phase_start = Instant::now();
206        event_log.record(Event::PhaseStart {
207            phase_id: phase_id.clone(),
208            attempt,
209        });
210        event_log::write_runner_status(cwd, state, Some(&phase_id));
211        write_brief(cwd, state, None);
212
213        // 4. Build prompt + launch agent
214        let prompt = build_phase_prompt(phase, retry_ctx.as_deref());
215        let plan_context = build_plan_context_with_edda(plan, state, &phase_id, cwd);
216        let session_id = phase_session_id_attempt(&plan.name, &phase_id, attempt).to_string();
217
218        // Auto-claim scope for this phase (so peers can see it and send requests)
219        write_phase_claim(cwd, &session_id, &phase_id);
220
221        let result = launcher
222            .run_phase(
223                phase,
224                &prompt,
225                &plan_context,
226                &session_id,
227                &phase_cwd,
228                cancel.child_token(),
229            )
230            .await?;
231
232        // 5. Process result
233        match result {
234            PhaseResult::AgentDone {
235                cost_usd,
236                result_text,
237            } => {
238                if let Some(cost) = cost_usd {
239                    budget.record(cost);
240                    state.total_cost_usd += cost;
241                }
242
243                // running → checking
244                transition(
245                    state,
246                    &phase_id,
247                    PhaseStatus::Running,
248                    PhaseStatus::Checking,
249                    None,
250                )?;
251                save_state(cwd, state)?;
252
253                // Run checks
254                let check_result = check_engine
255                    .run_all(
256                        &phase.check,
257                        state.get_phase(&phase_id)?.started_at.as_deref(),
258                    )
259                    .await;
260
261                if check_result.all_passed {
262                    transition(
263                        state,
264                        &phase_id,
265                        PhaseStatus::Checking,
266                        PhaseStatus::Passed,
267                        Some(PhaseUpdate {
268                            completed_at: Some(now_rfc3339()),
269                            checks: Some(check_result.results),
270                            ..Default::default()
271                        }),
272                    )?;
273                    let elapsed_ms = phase_start.elapsed().as_millis() as u64;
274                    println!(
275                        "  ✓ Phase \"{phase_id}\" passed ({})",
276                        format_elapsed(phase_start.elapsed())
277                    );
278                    if let Some(tmux) = tmux_session {
279                        let _ = tmux.update_phase_status(&phase_id, "Passed");
280                    }
281
282                    // Record to edda ledger
283                    edda::record_phase_done(cwd, &phase_id, result_text.as_deref(), cost_usd);
284                    event_log.record(Event::PhasePassed {
285                        phase_id: phase_id.clone(),
286                        attempt,
287                        duration_ms: elapsed_ms,
288                        cost_usd,
289                    });
290                } else {
291                    transition(
292                        state,
293                        &phase_id,
294                        PhaseStatus::Checking,
295                        PhaseStatus::Failed,
296                        Some(PhaseUpdate {
297                            checks: Some(check_result.results.clone()),
298                            error: check_result.error.clone(),
299                            ..Default::default()
300                        }),
301                    )?;
302                    let elapsed_ms = phase_start.elapsed().as_millis() as u64;
303                    let err_msg = check_result
304                        .error
305                        .as_ref()
306                        .map(|e| e.message.as_str())
307                        .unwrap_or("check failed");
308                    println!(
309                        "  ✗ Phase \"{phase_id}\" failed ({}): {err_msg}",
310                        format_elapsed(phase_start.elapsed()),
311                    );
312                    if let Some(tmux) = tmux_session {
313                        let _ = tmux.update_phase_status(&phase_id, "Failed");
314                    }
315                    edda::record_phase_failed(cwd, &phase_id, err_msg);
316                    event_log.record(Event::PhaseFailed {
317                        phase_id: phase_id.clone(),
318                        attempt,
319                        duration_ms: elapsed_ms,
320                        error: err_msg.to_string(),
321                    });
322                    handle_on_fail(
323                        plan,
324                        phase,
325                        state,
326                        &phase_id,
327                        &check_result,
328                        notifier,
329                        &mut event_log,
330                    )
331                    .await;
332                }
333            }
334            PhaseResult::Timeout => {
335                transition(
336                    state,
337                    &phase_id,
338                    PhaseStatus::Running,
339                    PhaseStatus::Stale,
340                    Some(PhaseUpdate {
341                        error: Some(ErrorInfo {
342                            error_type: ErrorType::Timeout,
343                            message: format!("phase \"{phase_id}\" timed out"),
344                            retryable: true,
345                            check_index: None,
346                            timestamp: now_rfc3339(),
347                        }),
348                        ..Default::default()
349                    }),
350                )?;
351                let elapsed_ms = phase_start.elapsed().as_millis() as u64;
352                println!(
353                    "  ⏰ Phase \"{phase_id}\" timed out ({})",
354                    format_elapsed(phase_start.elapsed())
355                );
356                if let Some(tmux) = tmux_session {
357                    let _ = tmux.update_phase_status(&phase_id, "Stale");
358                }
359                edda::record_phase_failed(cwd, &phase_id, "timed out");
360                event_log.record(Event::PhaseFailed {
361                    phase_id: phase_id.clone(),
362                    attempt,
363                    duration_ms: elapsed_ms,
364                    error: "timed out".into(),
365                });
366            }
367            PhaseResult::AgentCrash { error } => {
368                transition(
369                    state,
370                    &phase_id,
371                    PhaseStatus::Running,
372                    PhaseStatus::Failed,
373                    Some(PhaseUpdate {
374                        error: Some(ErrorInfo {
375                            error_type: ErrorType::AgentCrash,
376                            message: error.clone(),
377                            retryable: true,
378                            check_index: None,
379                            timestamp: now_rfc3339(),
380                        }),
381                        ..Default::default()
382                    }),
383                )?;
384                let elapsed_ms = phase_start.elapsed().as_millis() as u64;
385                println!(
386                    "  ✗ Phase \"{phase_id}\" crashed ({}): {error}",
387                    format_elapsed(phase_start.elapsed())
388                );
389                if let Some(tmux) = tmux_session {
390                    let _ = tmux.update_phase_status(&phase_id, "Failed");
391                }
392                edda::record_phase_failed(cwd, &phase_id, &error);
393                event_log.record(Event::PhaseFailed {
394                    phase_id: phase_id.clone(),
395                    attempt,
396                    duration_ms: elapsed_ms,
397                    error: error.clone(),
398                });
399                // For crash, use empty check results
400                let empty_result = CheckRunResult {
401                    all_passed: false,
402                    results: vec![],
403                    error: None,
404                };
405                handle_on_fail(
406                    plan,
407                    phase,
408                    state,
409                    &phase_id,
410                    &empty_result,
411                    notifier,
412                    &mut event_log,
413                )
414                .await;
415            }
416            PhaseResult::MaxTurns { cost_usd } | PhaseResult::BudgetExceeded { cost_usd } => {
417                if let Some(cost) = cost_usd {
418                    budget.record(cost);
419                    state.total_cost_usd += cost;
420                }
421                let elapsed_ms = phase_start.elapsed().as_millis() as u64;
422                let msg = format!("{result:?}");
423                transition(
424                    state,
425                    &phase_id,
426                    PhaseStatus::Running,
427                    PhaseStatus::Failed,
428                    Some(PhaseUpdate {
429                        error: Some(ErrorInfo {
430                            error_type: ErrorType::BudgetExceeded,
431                            message: msg.clone(),
432                            retryable: false,
433                            check_index: None,
434                            timestamp: now_rfc3339(),
435                        }),
436                        ..Default::default()
437                    }),
438                )?;
439                event_log.record(Event::PhaseFailed {
440                    phase_id: phase_id.clone(),
441                    attempt,
442                    duration_ms: elapsed_ms,
443                    error: msg,
444                });
445            }
446        }
447
448        save_state(cwd, state)?;
449    }
450
451    // Plan completion check
452    update_plan_status(state);
453    if is_plan_complete(state) {
454        state.plan_status = PlanStatus::Completed;
455        state.completed_at = Some(now_rfc3339());
456        save_state(cwd, state)?;
457        let passed = state
458            .phases
459            .iter()
460            .filter(|p| p.status == PhaseStatus::Passed)
461            .count();
462        println!("\n✓ Plan \"{}\" completed ({passed} passed)", plan.name);
463        event_log.record(Event::PlanCompleted {
464            phases_passed: passed,
465            total_cost_usd: state.total_cost_usd,
466        });
467        notifier
468            .notify(&format!(
469                "Plan \"{}\" completed! {passed} phases passed.",
470                plan.name
471            ))
472            .await;
473    }
474
475    event_log::write_runner_status(cwd, state, None);
476    write_brief(cwd, state, None);
477    Ok(())
478}
479
480async fn handle_on_fail(
481    plan: &Plan,
482    phase: &crate::plan::schema::Phase,
483    state: &mut PlanState,
484    phase_id: &str,
485    check_result: &CheckRunResult,
486    notifier: &dyn Notifier,
487    event_log: &mut EventLogger,
488) {
489    let on_fail = phase.on_fail.unwrap_or(plan.on_fail);
490
491    match on_fail {
492        OnFail::AutoRetry => {
493            let max = phase.max_attempts.unwrap_or(plan.max_attempts);
494            let (attempts, should_retry) = {
495                let ps = state
496                    .get_phase_mut(phase_id)
497                    .expect("phase must exist in state");
498                if ps.attempts < max {
499                    let error_context = format_check_failures(&check_result.results);
500                    ps.retry_context = Some(error_context);
501                    (ps.attempts, true)
502                } else {
503                    (ps.attempts, false)
504                }
505            };
506            if should_retry {
507                let _ = transition(
508                    state,
509                    phase_id,
510                    PhaseStatus::Failed,
511                    PhaseStatus::Pending,
512                    None,
513                );
514                println!("  ↻ Auto-retrying ({attempts}/{max})");
515            } else {
516                notifier
517                    .notify(&format!(
518                        "Phase \"{phase_id}\" failed after {max} attempts. Retry, skip, or abort?"
519                    ))
520                    .await;
521            }
522        }
523        OnFail::Skip => {
524            let ps = state
525                .get_phase_mut(phase_id)
526                .expect("phase must exist in state");
527            ps.status = PhaseStatus::Skipped;
528            ps.skip_reason = Some("auto-skipped by on_fail policy".into());
529            event_log.record(Event::PhaseSkipped {
530                phase_id: phase_id.to_string(),
531                reason: "auto-skipped by on_fail policy".into(),
532            });
533            println!("  → Auto-skipped (on_fail: skip)");
534        }
535        OnFail::Abort => {
536            state.plan_status = PlanStatus::Aborted;
537            state.aborted_at = Some(now_rfc3339());
538            event_log.record(Event::PlanAborted {
539                phases_passed: state
540                    .phases
541                    .iter()
542                    .filter(|p| p.status == PhaseStatus::Passed)
543                    .count(),
544                phases_pending: state
545                    .phases
546                    .iter()
547                    .filter(|p| p.status == PhaseStatus::Pending)
548                    .count(),
549            });
550            println!("  → Plan aborted (on_fail: abort)");
551        }
552        OnFail::Ask => {
553            notifier
554                .notify(&format!(
555                    "Phase \"{phase_id}\" failed. Retry, skip, or abort?"
556                ))
557                .await;
558        }
559    }
560}
561
562/// Build the full prompt for a phase, including retry context if any.
563fn build_phase_prompt(phase: &crate::plan::schema::Phase, retry_context: Option<&str>) -> String {
564    let mut prompt = String::new();
565    if let Some(ctx) = &phase.context {
566        prompt.push_str(ctx);
567        prompt.push_str("\n\n");
568    }
569    prompt.push_str(&phase.prompt);
570
571    // Layer 1: append self-check instruction if phase has checks
572    if !phase.check.is_empty() {
573        prompt.push_str("\n\n## Verification\n");
574        prompt.push_str(
575            "After completing the task, run these checks yourself and fix any failures:\n",
576        );
577        for check in &phase.check {
578            match check {
579                CheckSpec::CmdSucceeds { cmd, .. } => {
580                    prompt.push_str(&format!("- `{cmd}`\n"));
581                }
582                CheckSpec::FileExists { path } => {
583                    prompt.push_str(&format!("- Verify `{path}` exists\n"));
584                }
585                CheckSpec::FileContains { path, pattern } => {
586                    prompt.push_str(&format!("- Verify `{path}` contains \"{pattern}\"\n"));
587                }
588                // GitClean, EddaEvent, WaitUntil are not actionable by the agent
589                _ => {}
590            }
591        }
592        prompt.push_str("Repeat until all pass. Do not stop with failing checks.\n");
593    }
594
595    // Layer 2: inject previous failure details on retry
596    if let Some(error) = retry_context {
597        prompt.push_str("\n\n## Previous Attempt Failed\n");
598        prompt.push_str(error);
599        prompt.push_str("\n\nYour previous changes are still on disk. Fix the issues above.");
600    }
601
602    // Layer 3: write-back reminder for decision recording + cross-phase messaging
603    prompt.push_str("\n\n## Decision Write-Back\n");
604    prompt.push_str(
605        "Record architectural decisions from this phase: \
606         `edda decide \"key=value\" --reason \"why\"`\n\
607         Message another phase: `edda request \"phase-label\" \"message\"`\n",
608    );
609
610    prompt
611}
612
613fn format_check_failures(results: &[CheckResult]) -> String {
614    let mut out = String::new();
615    for r in results {
616        let icon = match r.status {
617            CheckStatus::Passed => "✓",
618            CheckStatus::Failed => "✗",
619            _ => "○",
620        };
621        out.push_str(&format!(
622            "{icon} {}: {}\n",
623            r.check_type,
624            r.detail.as_deref().unwrap_or("(no detail)"),
625        ));
626    }
627    out
628}
629
630/// Build plan progress context with edda decision history for --append-system-prompt.
631fn build_plan_context_with_edda(
632    plan: &Plan,
633    state: &PlanState,
634    current_phase: &str,
635    cwd: &Path,
636) -> String {
637    let base = build_plan_context(plan, state, current_phase);
638    let edda_ctx = edda::get_context(cwd);
639    if edda_ctx.is_empty() {
640        base
641    } else {
642        format!("{base}\n\n## Decision History (from edda)\n{edda_ctx}")
643    }
644}
645
646/// Build plan progress context for --append-system-prompt.
647fn build_plan_context(plan: &Plan, state: &PlanState, current_phase: &str) -> String {
648    let mut ctx = String::new();
649
650    // Purpose first — keeps every agent aligned with user intent
651    if let Some(purpose) = plan.purpose.as_deref() {
652        if !purpose.is_empty() {
653            ctx.push_str(&format!("## Purpose\n{purpose}\n\n"));
654        }
655    }
656
657    ctx.push_str(&format!("## Plan: {}\n", plan.name));
658    for ps in &state.phases {
659        let icon = match ps.status {
660            PhaseStatus::Passed => "✓",
661            PhaseStatus::Failed => "✗",
662            PhaseStatus::Running | PhaseStatus::Checking => "▶",
663            PhaseStatus::Skipped => "⊘",
664            PhaseStatus::Stale => "⏰",
665            PhaseStatus::Pending => {
666                if ps.id == current_phase {
667                    "▶"
668                } else {
669                    "○"
670                }
671            }
672        };
673        ctx.push_str(&format!("{icon} {}\n", ps.id));
674    }
675    ctx
676}
677
678enum BlockedAction {
679    Retry,
680    Skip,
681    Abort,
682    Quit,
683}
684
685fn prompt_blocked_action(phase_id: &str) -> BlockedAction {
686    use std::io::{BufRead, Write};
687    println!("\n  Phase \"{phase_id}\" is blocked.\n");
688    println!("  [R] Retry   [S] Skip   [A] Abort   [Q] Quit (resume later)");
689    loop {
690        print!("  > ");
691        let _ = std::io::stdout().flush();
692        let mut input = String::new();
693        match std::io::stdin().lock().read_line(&mut input) {
694            Ok(0) | Err(_) => return BlockedAction::Quit, // EOF or error
695            _ => {}
696        }
697        match input.trim().to_lowercase().as_str() {
698            "r" | "retry" => return BlockedAction::Retry,
699            "s" | "skip" => return BlockedAction::Skip,
700            "a" | "abort" => return BlockedAction::Abort,
701            "q" | "quit" => return BlockedAction::Quit,
702            _ => println!("  Invalid choice. Enter R, S, A, or Q."),
703        }
704    }
705}
706
707fn format_elapsed(d: std::time::Duration) -> String {
708    let secs = d.as_secs();
709    if secs < 60 {
710        format!("{secs}s")
711    } else {
712        format!("{}m{}s", secs / 60, secs % 60)
713    }
714}
715
716fn now_rfc3339() -> String {
717    time::OffsetDateTime::now_utc()
718        .format(&time::format_description::well_known::Rfc3339)
719        .unwrap_or_default()
720}
721
722/// Write a claim event to coordination.jsonl for a conductor phase.
723/// Written directly (no edda-bridge-claude dependency) since the format is simple.
724fn write_phase_claim(cwd: &Path, session_id: &str, phase_id: &str) {
725    let project_id = edda_store::project_id(cwd);
726    let state_dir = edda_store::project_dir(&project_id).join("state");
727    let coord_path = state_dir.join("coordination.jsonl");
728    let event = serde_json::json!({
729        "ts": now_rfc3339(),
730        "session_id": session_id,
731        "event_type": "claim",
732        "payload": { "label": phase_id, "paths": serde_json::Value::Array(vec![]) }
733    });
734    if let Ok(line) = serde_json::to_string(&event) {
735        use std::io::Write;
736        if let Ok(mut f) = std::fs::OpenOptions::new()
737            .create(true)
738            .append(true)
739            .open(&coord_path)
740        {
741            let _ = writeln!(f, "{line}");
742        }
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use crate::agent::launcher::{MockLauncher, PhaseResult};
750    use crate::plan::parser::parse_plan;
751    use crate::runner::notify::CollectNotifier;
752
753    async fn run_test_plan(yaml: &str, launcher: &dyn AgentLauncher) -> (PlanState, Vec<String>) {
754        let plan = parse_plan(yaml).unwrap();
755        let dir = tempfile::tempdir().unwrap();
756        let mut state = PlanState::from_plan(&plan, "test.yaml");
757        let engine = CheckEngine::new(dir.path().to_path_buf());
758        let notifier = CollectNotifier::new();
759        let mut budget = BudgetTracker::new(plan.budget_usd);
760        let cancel = CancellationToken::new();
761
762        run_plan(
763            &plan,
764            &mut state,
765            RunContext {
766                launcher,
767                check_engine: &engine,
768                notifier: &notifier,
769                budget: &mut budget,
770                cancel,
771                cwd: dir.path(),
772                interactive: false,
773                json_events: false,
774                tmux_session: None,
775            },
776        )
777        .await
778        .unwrap();
779
780        let msgs = notifier.messages();
781        (state, msgs)
782    }
783
784    #[tokio::test]
785    async fn single_phase_passes() {
786        let yaml = r#"
787name: test
788phases:
789  - id: a
790    prompt: "do it"
791"#;
792        let launcher = MockLauncher::new();
793        let (state, msgs) = run_test_plan(yaml, &launcher).await;
794
795        assert_eq!(state.plan_status, PlanStatus::Completed);
796        assert_eq!(state.phases[0].status, PhaseStatus::Passed);
797        assert!(msgs.iter().any(|m| m.contains("completed")));
798    }
799
800    #[tokio::test]
801    async fn two_phases_sequential() {
802        let yaml = r#"
803name: test
804phases:
805  - id: a
806    prompt: "first"
807  - id: b
808    prompt: "second"
809    depends_on: [a]
810"#;
811        let launcher = MockLauncher::new();
812        let (state, _) = run_test_plan(yaml, &launcher).await;
813
814        assert_eq!(state.plan_status, PlanStatus::Completed);
815        assert!(state.phases.iter().all(|p| p.status == PhaseStatus::Passed));
816    }
817
818    #[tokio::test]
819    async fn phase_crash_with_auto_retry() {
820        let yaml = r#"
821name: test
822max_attempts: 2
823on_fail: auto_retry
824phases:
825  - id: a
826    prompt: "crash then succeed"
827"#;
828        let launcher = MockLauncher::new();
829        launcher.set_results(
830            "a",
831            vec![
832                PhaseResult::AgentCrash {
833                    error: "oops".into(),
834                },
835                PhaseResult::AgentDone {
836                    cost_usd: Some(0.5),
837                    result_text: None,
838                },
839            ],
840        );
841        let (state, _) = run_test_plan(yaml, &launcher).await;
842
843        assert_eq!(state.plan_status, PlanStatus::Completed);
844        assert_eq!(state.phases[0].status, PhaseStatus::Passed);
845        assert_eq!(state.phases[0].attempts, 2);
846    }
847
848    #[tokio::test]
849    async fn phase_crash_exhausts_retries() {
850        let yaml = r#"
851name: test
852max_attempts: 2
853on_fail: auto_retry
854phases:
855  - id: a
856    prompt: "always crash"
857"#;
858        let launcher = MockLauncher::new();
859        launcher.set_results(
860            "a",
861            vec![
862                PhaseResult::AgentCrash { error: "1".into() },
863                PhaseResult::AgentCrash { error: "2".into() },
864            ],
865        );
866        let (state, msgs) = run_test_plan(yaml, &launcher).await;
867
868        assert_eq!(state.plan_status, PlanStatus::Blocked);
869        assert_eq!(state.phases[0].status, PhaseStatus::Failed);
870        assert!(msgs.iter().any(|m| m.contains("failed after 2 attempts")));
871    }
872
873    #[tokio::test]
874    async fn on_fail_skip() {
875        let yaml = r#"
876name: test
877on_fail: skip
878phases:
879  - id: a
880    prompt: "crash"
881  - id: b
882    prompt: "should still run"
883"#;
884        let launcher = MockLauncher::new();
885        launcher.set_results(
886            "a",
887            vec![PhaseResult::AgentCrash {
888                error: "boom".into(),
889            }],
890        );
891        let (state, _) = run_test_plan(yaml, &launcher).await;
892
893        assert_eq!(state.phases[0].status, PhaseStatus::Skipped);
894        assert_eq!(state.phases[1].status, PhaseStatus::Passed);
895        assert_eq!(state.plan_status, PlanStatus::Completed);
896    }
897
898    #[tokio::test]
899    async fn on_fail_abort() {
900        let yaml = r#"
901name: test
902on_fail: abort
903phases:
904  - id: a
905    prompt: "crash"
906  - id: b
907    prompt: "never runs"
908"#;
909        let launcher = MockLauncher::new();
910        launcher.set_results(
911            "a",
912            vec![PhaseResult::AgentCrash {
913                error: "boom".into(),
914            }],
915        );
916        let (state, _) = run_test_plan(yaml, &launcher).await;
917
918        assert_eq!(state.plan_status, PlanStatus::Aborted);
919        assert_eq!(state.phases[0].status, PhaseStatus::Failed);
920        assert_eq!(state.phases[1].status, PhaseStatus::Pending);
921    }
922
923    #[tokio::test]
924    async fn budget_exhaustion_stops() {
925        let yaml = r#"
926name: test
927budget_usd: 0.5
928phases:
929  - id: a
930    prompt: "expensive"
931  - id: b
932    prompt: "should not run"
933"#;
934        let launcher = MockLauncher::new();
935        launcher.set_results(
936            "a",
937            vec![PhaseResult::AgentDone {
938                cost_usd: Some(1.0),
939                result_text: None,
940            }],
941        );
942        let (state, msgs) = run_test_plan(yaml, &launcher).await;
943
944        assert_eq!(state.phases[0].status, PhaseStatus::Passed);
945        assert_eq!(state.phases[1].status, PhaseStatus::Pending);
946        assert!(msgs.iter().any(|m| m.contains("budget exhausted")));
947    }
948
949    #[tokio::test]
950    async fn check_failure_triggers_auto_retry() {
951        // Agent succeeds (AgentDone) but check fails (file doesn't exist).
952        // Verifies that check failure → auto_retry, not just agent crash.
953        let yaml = r#"
954name: test
955max_attempts: 2
956phases:
957  - id: a
958    prompt: "make file"
959    check:
960      - file_exists: "output.txt"
961"#;
962        let launcher = MockLauncher::new();
963        launcher.set_results(
964            "a",
965            vec![
966                PhaseResult::AgentDone {
967                    cost_usd: Some(0.1),
968                    result_text: None,
969                },
970                PhaseResult::AgentDone {
971                    cost_usd: Some(0.1),
972                    result_text: None,
973                },
974            ],
975        );
976        let (state, msgs) = run_test_plan(yaml, &launcher).await;
977
978        // Both attempts: agent done → check fails → auto-retry → exhausts
979        assert_eq!(state.phases[0].status, PhaseStatus::Failed);
980        assert_eq!(state.phases[0].attempts, 2);
981        assert!(msgs.iter().any(|m| m.contains("failed after 2 attempts")));
982    }
983
984    #[tokio::test]
985    async fn cancellation_stops_runner() {
986        let yaml = r#"
987name: test
988phases:
989  - id: a
990    prompt: "x"
991  - id: b
992    prompt: "x"
993"#;
994        let plan = parse_plan(yaml).unwrap();
995        let dir = tempfile::tempdir().unwrap();
996        let mut state = PlanState::from_plan(&plan, "test.yaml");
997        let engine = CheckEngine::new(dir.path().to_path_buf());
998        let notifier = CollectNotifier::new();
999        let mut budget = BudgetTracker::new(None);
1000        let cancel = CancellationToken::new();
1001        cancel.cancel(); // Cancel immediately
1002
1003        let launcher = MockLauncher::new();
1004
1005        run_plan(
1006            &plan,
1007            &mut state,
1008            RunContext {
1009                launcher: &launcher,
1010                check_engine: &engine,
1011                notifier: &notifier,
1012                budget: &mut budget,
1013                cancel,
1014                cwd: dir.path(),
1015                interactive: false,
1016                json_events: false,
1017                tmux_session: None,
1018            },
1019        )
1020        .await
1021        .unwrap();
1022
1023        // Should stop without running any phases
1024        assert!(state
1025            .phases
1026            .iter()
1027            .all(|p| p.status == PhaseStatus::Pending));
1028    }
1029
1030    #[test]
1031    fn build_prompt_basic() {
1032        let plan = parse_plan("name: t\nphases:\n  - id: a\n    prompt: do it\n").unwrap();
1033        let prompt = build_phase_prompt(&plan.phases[0], None);
1034        assert!(prompt.contains("do it"));
1035        assert!(!prompt.contains("Verification"));
1036    }
1037
1038    #[test]
1039    fn build_prompt_with_checks() {
1040        let yaml = r#"
1041name: t
1042phases:
1043  - id: a
1044    prompt: do it
1045    check:
1046      - cmd_succeeds: "cargo test"
1047      - file_exists: "output.txt"
1048"#;
1049        let plan = parse_plan(yaml).unwrap();
1050        let prompt = build_phase_prompt(&plan.phases[0], None);
1051        assert!(prompt.contains("Verification"));
1052        assert!(prompt.contains("`cargo test`"));
1053        assert!(prompt.contains("`output.txt`"));
1054    }
1055
1056    #[test]
1057    fn build_prompt_with_retry_context() {
1058        let plan = parse_plan("name: t\nphases:\n  - id: a\n    prompt: do it\n").unwrap();
1059        let prompt = build_phase_prompt(&plan.phases[0], Some("✗ cmd_succeeds: exit 1"));
1060        assert!(prompt.contains("Previous Attempt Failed"));
1061        assert!(prompt.contains("exit 1"));
1062    }
1063
1064    #[test]
1065    fn format_check_failures_output() {
1066        let results = vec![
1067            CheckResult {
1068                check_type: "file_exists".into(),
1069                status: CheckStatus::Passed,
1070                detail: None,
1071                duration_ms: 0,
1072            },
1073            CheckResult {
1074                check_type: "cmd_succeeds".into(),
1075                status: CheckStatus::Failed,
1076                detail: Some("exit 1: test failed".into()),
1077                duration_ms: 100,
1078            },
1079        ];
1080        let out = format_check_failures(&results);
1081        assert!(out.contains("✓ file_exists"));
1082        assert!(out.contains("✗ cmd_succeeds: exit 1"));
1083    }
1084
1085    #[test]
1086    fn build_plan_context_includes_purpose() {
1087        let yaml = r#"
1088name: todo-app
1089purpose: "Simple todo app for demo, keep it minimal"
1090phases:
1091  - id: db
1092    prompt: "schema"
1093  - id: api
1094    prompt: "endpoints"
1095"#;
1096        let plan = parse_plan(yaml).unwrap();
1097        let state = PlanState::from_plan(&plan, "test.yaml");
1098        let ctx = build_plan_context(&plan, &state, "db");
1099
1100        assert!(
1101            ctx.contains("## Purpose"),
1102            "missing Purpose section in:\n{ctx}"
1103        );
1104        assert!(
1105            ctx.contains("Simple todo app"),
1106            "missing purpose text in:\n{ctx}"
1107        );
1108        // Purpose comes before Plan
1109        let purpose_pos = ctx.find("## Purpose").unwrap();
1110        let plan_pos = ctx.find("## Plan:").unwrap();
1111        assert!(purpose_pos < plan_pos, "Purpose should come before Plan");
1112    }
1113
1114    #[test]
1115    fn build_plan_context_no_purpose() {
1116        let yaml = "name: t\nphases:\n  - id: a\n    prompt: do it\n";
1117        let plan = parse_plan(yaml).unwrap();
1118        let state = PlanState::from_plan(&plan, "test.yaml");
1119        let ctx = build_plan_context(&plan, &state, "a");
1120
1121        assert!(
1122            !ctx.contains("## Purpose"),
1123            "should not have Purpose when not set"
1124        );
1125        assert!(
1126            ctx.starts_with("## Plan:"),
1127            "should start with Plan when no purpose"
1128        );
1129    }
1130
1131    #[test]
1132    fn build_prompt_includes_write_back() {
1133        let plan = parse_plan("name: t\nphases:\n  - id: a\n    prompt: do it\n").unwrap();
1134        let prompt = build_phase_prompt(&plan.phases[0], None);
1135        assert!(prompt.contains("Decision Write-Back"));
1136        assert!(prompt.contains("edda decide"));
1137        assert!(prompt.contains("edda request"));
1138    }
1139
1140    // ── Event log integration tests ──
1141
1142    /// Helper that returns the tempdir so callers can inspect files.
1143    async fn run_test_plan_with_dir(
1144        yaml: &str,
1145        launcher: &dyn AgentLauncher,
1146    ) -> (PlanState, tempfile::TempDir) {
1147        let plan = parse_plan(yaml).unwrap();
1148        let dir = tempfile::tempdir().unwrap();
1149        let mut state = PlanState::from_plan(&plan, "test.yaml");
1150        let engine = CheckEngine::new(dir.path().to_path_buf());
1151        let notifier = CollectNotifier::new();
1152        let mut budget = BudgetTracker::new(plan.budget_usd);
1153        let cancel = CancellationToken::new();
1154
1155        run_plan(
1156            &plan,
1157            &mut state,
1158            RunContext {
1159                launcher,
1160                check_engine: &engine,
1161                notifier: &notifier,
1162                budget: &mut budget,
1163                cancel,
1164                cwd: dir.path(),
1165                interactive: false,
1166                json_events: false,
1167                tmux_session: None,
1168            },
1169        )
1170        .await
1171        .unwrap();
1172
1173        (state, dir)
1174    }
1175
1176    fn read_events(dir: &Path, plan_name: &str) -> Vec<serde_json::Value> {
1177        let path = dir
1178            .join(".edda")
1179            .join("conductor")
1180            .join(plan_name)
1181            .join("events.jsonl");
1182        if !path.exists() {
1183            return vec![];
1184        }
1185        std::fs::read_to_string(&path)
1186            .unwrap()
1187            .lines()
1188            .filter(|l| !l.trim().is_empty())
1189            .map(|l| serde_json::from_str(l).unwrap())
1190            .collect()
1191    }
1192
1193    fn read_runner_status(dir: &Path, plan_name: &str) -> Option<serde_json::Value> {
1194        let path = dir
1195            .join(".edda")
1196            .join("conductor")
1197            .join(plan_name)
1198            .join("runner-status.json");
1199        if !path.exists() {
1200            return None;
1201        }
1202        Some(serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap())
1203    }
1204
1205    #[tokio::test]
1206    async fn events_jsonl_written_for_passing_plan() {
1207        let yaml = r#"
1208name: test
1209phases:
1210  - id: a
1211    prompt: "do it"
1212"#;
1213        let launcher = MockLauncher::new();
1214        let (_state, dir) = run_test_plan_with_dir(yaml, &launcher).await;
1215
1216        let events = read_events(dir.path(), "test");
1217        // Expect: PlanStart, PhaseStart, PhasePassed, PlanCompleted
1218        assert_eq!(events.len(), 4, "events: {events:?}");
1219        assert_eq!(events[0]["type"], "plan_start");
1220        assert_eq!(events[0]["phase_count"], 1);
1221        assert_eq!(events[1]["type"], "phase_start");
1222        assert_eq!(events[1]["phase_id"], "a");
1223        assert_eq!(events[2]["type"], "phase_passed");
1224        assert_eq!(events[2]["phase_id"], "a");
1225        assert_eq!(events[3]["type"], "plan_completed");
1226        // Seq increments
1227        assert_eq!(events[0]["seq"], 0);
1228        assert_eq!(events[3]["seq"], 3);
1229    }
1230
1231    #[tokio::test]
1232    async fn events_jsonl_records_crash_failure() {
1233        let yaml = r#"
1234name: test
1235on_fail: abort
1236phases:
1237  - id: a
1238    prompt: "crash"
1239"#;
1240        let launcher = MockLauncher::new();
1241        launcher.set_results(
1242            "a",
1243            vec![PhaseResult::AgentCrash {
1244                error: "boom".into(),
1245            }],
1246        );
1247        let (_state, dir) = run_test_plan_with_dir(yaml, &launcher).await;
1248
1249        let events = read_events(dir.path(), "test");
1250        // PlanStart, PhaseStart, PhaseFailed, PlanAborted
1251        assert_eq!(events.len(), 4, "events: {events:?}");
1252        assert_eq!(events[2]["type"], "phase_failed");
1253        assert_eq!(events[2]["error"], "boom");
1254        assert_eq!(events[3]["type"], "plan_aborted");
1255    }
1256
1257    #[tokio::test]
1258    async fn events_jsonl_records_skip() {
1259        let yaml = r#"
1260name: test
1261on_fail: skip
1262phases:
1263  - id: a
1264    prompt: "crash"
1265  - id: b
1266    prompt: "should run"
1267"#;
1268        let launcher = MockLauncher::new();
1269        launcher.set_results("a", vec![PhaseResult::AgentCrash { error: "x".into() }]);
1270        let (_state, dir) = run_test_plan_with_dir(yaml, &launcher).await;
1271
1272        let events = read_events(dir.path(), "test");
1273        let types: Vec<&str> = events.iter().map(|e| e["type"].as_str().unwrap()).collect();
1274        assert!(types.contains(&"phase_skipped"), "types: {types:?}");
1275        assert!(types.contains(&"plan_completed"), "types: {types:?}");
1276    }
1277
1278    #[tokio::test]
1279    async fn runner_status_written_after_run() {
1280        let yaml = r#"
1281name: test
1282phases:
1283  - id: a
1284    prompt: "do it"
1285"#;
1286        let launcher = MockLauncher::new();
1287        let (_state, dir) = run_test_plan_with_dir(yaml, &launcher).await;
1288
1289        let status = read_runner_status(dir.path(), "test").expect("runner-status.json missing");
1290        assert_eq!(status["plan"], "test");
1291        assert_eq!(status["status"], "completed");
1292        assert!(status["completed"]
1293            .as_array()
1294            .unwrap()
1295            .contains(&serde_json::json!("a")));
1296    }
1297}