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
25pub 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 pub tmux_session: Option<&'a TmuxSession>,
37}
38
39pub 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 edda::ensure_init(cwd);
58
59 detect_stale_phases(state, plan);
61
62 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 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 let Some(phase_id) = find_next_phase(plan, state, &order) else {
164 break; };
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 let retry_ctx = phase_state.retry_context.take();
184
185 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 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 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 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 transition(
245 state,
246 &phase_id,
247 PhaseStatus::Running,
248 PhaseStatus::Checking,
249 None,
250 )?;
251 save_state(cwd, state)?;
252
253 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 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 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 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
562fn 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 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 _ => {}
590 }
591 }
592 prompt.push_str("Repeat until all pass. Do not stop with failing checks.\n");
593 }
594
595 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 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
630fn 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
646fn build_plan_context(plan: &Plan, state: &PlanState, current_phase: &str) -> String {
648 let mut ctx = String::new();
649
650 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, _ => {}
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
722fn 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: ¬ifier,
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 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 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(); let launcher = MockLauncher::new();
1004
1005 run_plan(
1006 &plan,
1007 &mut state,
1008 RunContext {
1009 launcher: &launcher,
1010 check_engine: &engine,
1011 notifier: ¬ifier,
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 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 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 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: ¬ifier,
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 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 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 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}