1use crate::claude::{
2 run_corrector, run_for_commit, run_for_spec_generation, run_reviewer, ClaudeOutcome,
3 ClaudeRunner, ClaudeStoryResult, CommitOutcome, CorrectorOutcome, ReviewOutcome,
4};
5use crate::config::get_effective_config;
6use crate::display::{BannerColor, StoryResult};
7use crate::error::{Autom8Error, Result};
8use crate::gh::{create_pull_request, PRResult};
9use crate::git;
10use crate::output::{
11 print_all_complete, print_breadcrumb_trail, print_claude_output, print_error_panel,
12 print_full_progress, print_generating_spec, print_header, print_info, print_interrupted,
13 print_issues_found, print_iteration_complete, print_iteration_start,
14 print_max_review_iterations, print_phase_banner, print_phase_footer, print_pr_already_exists,
15 print_pr_skipped, print_pr_success, print_pr_updated, print_proceeding_to_implementation,
16 print_project_info, print_resuming_interrupted, print_review_passed, print_reviewing,
17 print_run_completed, print_run_summary, print_skip_review, print_spec_generated,
18 print_spec_loaded, print_state_transition, print_story_complete, print_tasks_progress,
19 print_worktree_context, print_worktree_created, print_worktree_reused, BOLD, CYAN, GRAY, RESET,
20 YELLOW,
21};
22use crate::progress::{
23 AgentDisplay, Breadcrumb, BreadcrumbState, ClaudeSpinner, Outcome, VerboseTimer,
24};
25use crate::signal::SignalHandler;
26use crate::spec::{Spec, UserStory};
27use crate::state::{IterationStatus, LiveState, MachineState, RunState, RunStatus, StateManager};
28use crate::worktree::{
29 ensure_worktree, format_worktree_error, generate_session_id, generate_worktree_path,
30 is_in_worktree, remove_worktree, WorktreeResult,
31};
32use std::fs;
33use std::path::{Path, PathBuf};
34use std::time::{Duration, Instant};
35
36const MAX_REVIEW_ITERATIONS: u32 = 3;
43
44const LIVE_FLUSH_INTERVAL_MS: u64 = 200;
51const LIVE_FLUSH_LINE_COUNT: usize = 10;
52
53const HEARTBEAT_INTERVAL_MS: u64 = 2500;
56
57struct LiveOutputFlusher<'a> {
61 state_manager: &'a StateManager,
62 live_state: LiveState,
63 line_count_since_flush: usize,
64 last_flush: Instant,
65 last_heartbeat: Instant,
66}
67
68impl<'a> LiveOutputFlusher<'a> {
69 fn new(state_manager: &'a StateManager, machine_state: MachineState) -> Self {
70 let mut flusher = Self {
71 state_manager,
72 live_state: LiveState::new(machine_state),
73 line_count_since_flush: 0,
74 last_flush: Instant::now(),
75 last_heartbeat: Instant::now(),
76 };
77 flusher.flush();
79 flusher
80 }
81
82 fn append(&mut self, line: &str) {
84 self.live_state.append_line(line.to_string());
85 self.line_count_since_flush += 1;
86
87 let time_elapsed =
89 self.last_flush.elapsed() >= Duration::from_millis(LIVE_FLUSH_INTERVAL_MS);
90 let lines_threshold = self.line_count_since_flush >= LIVE_FLUSH_LINE_COUNT;
91
92 if time_elapsed || lines_threshold {
93 self.flush();
94 }
95
96 self.maybe_update_heartbeat();
98 }
99
100 fn maybe_update_heartbeat(&mut self) {
103 if self.last_heartbeat.elapsed() >= Duration::from_millis(HEARTBEAT_INTERVAL_MS) {
104 self.live_state.update_heartbeat();
105 self.flush();
106 self.last_heartbeat = Instant::now();
107 }
108 }
109
110 fn flush(&mut self) {
112 self.live_state.update_heartbeat();
114 let _ = self.state_manager.save_live(&self.live_state);
116 self.line_count_since_flush = 0;
117 self.last_flush = Instant::now();
118 self.last_heartbeat = Instant::now();
119 }
120
121 fn final_flush(&mut self) {
123 if self.line_count_since_flush > 0 {
124 self.flush();
125 }
126 }
127}
128
129fn flush_live_state(state_manager: &StateManager, machine_state: MachineState) {
133 let live_state = LiveState::new(machine_state);
134 let _ = state_manager.save_live(&live_state);
135}
136
137fn with_progress_display<T, F, M>(
153 verbose: bool,
154 create_timer: impl FnOnce() -> VerboseTimer,
155 create_spinner: impl FnOnce() -> ClaudeSpinner,
156 run_operation: F,
157 map_outcome: M,
158) -> Result<T>
159where
160 F: FnOnce(&mut dyn FnMut(&str)) -> Result<T>,
161 M: FnOnce(&Result<T>) -> Outcome,
162{
163 if verbose {
164 let mut timer = create_timer();
165 let result = run_operation(&mut |line| {
166 print_claude_output(line);
167 });
168 let outcome = map_outcome(&result);
169 timer.finish_with_outcome(outcome);
170 result
171 } else {
172 let mut spinner = create_spinner();
173 let result = run_operation(&mut |line| {
174 spinner.update(line);
175 });
176 let outcome = map_outcome(&result);
177 spinner.finish_with_outcome(outcome);
178 result
179 }
180}
181
182fn with_progress_display_and_live<T, F, M>(
199 verbose: bool,
200 state_manager: &StateManager,
201 machine_state: MachineState,
202 create_timer: impl FnOnce() -> VerboseTimer,
203 create_spinner: impl FnOnce() -> ClaudeSpinner,
204 run_operation: F,
205 map_outcome: M,
206) -> Result<T>
207where
208 F: FnOnce(&mut dyn FnMut(&str)) -> Result<T>,
209 M: FnOnce(&Result<T>) -> Outcome,
210{
211 let mut live_flusher = LiveOutputFlusher::new(state_manager, machine_state);
212
213 let result = if verbose {
214 let mut timer = create_timer();
215 let result = run_operation(&mut |line| {
216 print_claude_output(line);
217 live_flusher.append(line);
218 });
219 let outcome = map_outcome(&result);
220 timer.finish_with_outcome(outcome);
221 result
222 } else {
223 let mut spinner = create_spinner();
224 let result = run_operation(&mut |line| {
225 spinner.update(line);
226 live_flusher.append(line);
227 });
228 let outcome = map_outcome(&result);
229 spinner.finish_with_outcome(outcome);
230 result
231 };
232
233 live_flusher.final_flush();
235
236 result
237}
238
239enum LoopAction {
242 Continue,
244 Break,
246}
247
248#[derive(Debug, Clone)]
256pub struct WorktreeSetupContext {
257 pub original_cwd: PathBuf,
259 pub worktree_path: Option<PathBuf>,
261 pub worktree_was_created: bool,
263 pub cwd_changed: bool,
265 pub metadata_saved: bool,
267}
268
269impl WorktreeSetupContext {
270 pub fn new() -> Result<Self> {
272 let original_cwd = std::env::current_dir()?;
273 Ok(Self {
274 original_cwd,
275 worktree_path: None,
276 worktree_was_created: false,
277 cwd_changed: false,
278 metadata_saved: false,
279 })
280 }
281
282 pub fn cleanup_on_interruption(&self) {
288 if self.cwd_changed {
290 if let Err(e) = std::env::set_current_dir(&self.original_cwd) {
291 eprintln!(
292 "Warning: failed to restore original directory '{}': {}",
293 self.original_cwd.display(),
294 e
295 );
296 }
297 }
298
299 if self.worktree_was_created && !self.metadata_saved {
303 if let Some(ref worktree_path) = self.worktree_path {
304 if let Err(e) = remove_worktree(worktree_path, true) {
306 eprintln!(
307 "Warning: failed to remove partial worktree '{}': {}",
308 worktree_path.display(),
309 e
310 );
311 }
312 }
313 }
314 }
315}
316
317pub struct Runner {
318 state_manager: StateManager,
319 verbose: bool,
320 skip_review: bool,
321 worktree_override: Option<bool>,
324 commit_override: Option<bool>,
327 pull_request_override: Option<bool>,
330}
331
332impl Runner {
333 pub fn new() -> Result<Self> {
334 Ok(Self {
335 state_manager: StateManager::new()?,
336 verbose: false,
337 skip_review: false,
338 worktree_override: None,
339 commit_override: None,
340 pull_request_override: None,
341 })
342 }
343
344 pub fn with_verbose(mut self, verbose: bool) -> Self {
345 self.verbose = verbose;
346 self
347 }
348
349 pub fn with_skip_review(mut self, skip_review: bool) -> Self {
350 self.skip_review = skip_review;
351 self
352 }
353
354 pub fn with_worktree(mut self, worktree: bool) -> Self {
360 self.worktree_override = Some(worktree);
361 self
362 }
363
364 pub fn with_commit(mut self, commit: bool) -> Self {
368 self.commit_override = Some(commit);
369 self
370 }
371
372 pub fn with_pull_request(mut self, pull_request: bool) -> Self {
376 self.pull_request_override = Some(pull_request);
377 self
378 }
379
380 #[allow(dead_code)]
384 pub fn effective_worktree(&self) -> Result<bool> {
385 if let Some(override_value) = self.worktree_override {
386 return Ok(override_value);
387 }
388 let config = get_effective_config()?;
389 Ok(config.worktree)
390 }
391
392 fn load_config_with_override(&self) -> Result<crate::config::Config> {
394 let mut config = get_effective_config()?;
395
396 if let Some(commit) = self.commit_override {
398 config.commit = commit;
399 }
400
401 if let Some(pull_request) = self.pull_request_override {
403 config.pull_request = pull_request;
404 }
405
406 Ok(config)
407 }
408
409 fn is_worktree_mode(&self, config: &crate::config::Config) -> bool {
411 if let Some(override_value) = self.worktree_override {
412 return override_value;
413 }
414 config.worktree
415 }
416
417 fn flush_live(&self, machine_state: MachineState) {
420 flush_live_state(&self.state_manager, machine_state);
421 }
422
423 fn setup_worktree_context(
434 &self,
435 config: &crate::config::Config,
436 branch_name: &str,
437 ) -> Result<(Option<(String, PathBuf)>, WorktreeSetupContext)> {
438 let mut setup_ctx = WorktreeSetupContext::new()?;
440
441 if !self.is_worktree_mode(config) {
443 return Ok((None, setup_ctx));
444 }
445
446 if !git::is_git_repo() {
448 print_info(
449 "Worktree mode enabled but not in a git repository. Running in current directory.",
450 );
451 return Ok((None, setup_ctx));
452 }
453
454 let pattern = &config.worktree_path_pattern;
456 let result = ensure_worktree(pattern, branch_name).map_err(|e| {
457 if let Autom8Error::WorktreeError(msg) = &e {
459 let worktree_path = generate_worktree_path(pattern, branch_name)
460 .unwrap_or_else(|_| PathBuf::from("<unknown>"));
461 let formatted = format_worktree_error(msg, branch_name, &worktree_path);
462 Autom8Error::WorktreeError(formatted)
463 } else {
464 e
465 }
466 })?;
467
468 let worktree_path = result.path().to_path_buf();
470 setup_ctx.worktree_path = Some(worktree_path.clone());
471 setup_ctx.worktree_was_created = result.was_created();
472
473 match result {
474 WorktreeResult::Created(_) => {
475 print_worktree_created(&worktree_path, branch_name);
476 }
477 WorktreeResult::Reused(_) => {
478 print_worktree_reused(&worktree_path, branch_name);
479 }
480 }
481
482 std::env::set_current_dir(&worktree_path).map_err(|e| {
484 setup_ctx.cleanup_on_interruption();
486 Autom8Error::WorktreeError(format!(
487 "Failed to change to worktree directory '{}': {}",
488 worktree_path.display(),
489 e
490 ))
491 })?;
492 setup_ctx.cwd_changed = true;
493
494 print_worktree_context(&worktree_path);
496
497 let session_id = generate_session_id(&worktree_path);
499
500 Ok((Some((session_id, worktree_path)), setup_ctx))
501 }
502
503 #[allow(clippy::too_many_arguments)]
506 fn handle_fatal_error<F>(
507 &self,
508 state: &mut RunState,
509 error_panel_title: &str,
510 error_panel_msg: &str,
511 exit_code: Option<i32>,
512 stderr: Option<&str>,
513 print_summary: Option<F>,
514 error: Autom8Error,
515 ) -> Autom8Error
516 where
517 F: FnOnce() -> Result<()>,
518 {
519 state.transition_to(MachineState::Failed);
521
522 if let Err(e) = self.state_manager.save(state) {
524 eprintln!("Warning: failed to save state: {}", e);
526 }
527
528 if !error_panel_title.is_empty() {
530 print_error_panel(error_panel_title, error_panel_msg, exit_code, stderr);
531 }
532
533 if let Some(summary_fn) = print_summary {
535 if let Err(e) = summary_fn() {
536 eprintln!("Warning: failed to print summary: {}", e);
537 }
538 }
539
540 error
541 }
542
543 fn handle_interruption(
555 &self,
556 state: &mut RunState,
557 claude_runner: &ClaudeRunner,
558 worktree_setup_ctx: Option<&WorktreeSetupContext>,
559 ) -> Autom8Error {
560 if let Err(e) = claude_runner.kill() {
562 eprintln!("Warning: failed to kill Claude subprocess: {}", e);
563 }
564
565 state.status = RunStatus::Interrupted;
567 state.finished_at = Some(chrono::Utc::now());
568
569 if let Err(e) = self.state_manager.save(state) {
572 eprintln!("Warning: failed to save state: {}", e);
573 }
574
575 if let Err(e) = self.state_manager.clear_live() {
577 eprintln!("Warning: failed to clear live output: {}", e);
578 }
579
580 if let Some(setup_ctx) = worktree_setup_ctx {
582 setup_ctx.cleanup_on_interruption();
583 }
584
585 print_interrupted();
587
588 Autom8Error::Interrupted
589 }
590
591 fn run_review_correct_loop(
594 &self,
595 state: &mut RunState,
596 spec: &Spec,
597 breadcrumb: &mut Breadcrumb,
598 story_results: &[StoryResult],
599 print_summary_fn: &impl Fn(u32, &[StoryResult]) -> Result<()>,
600 ) -> Result<()> {
601 state.review_iteration = 1;
602
603 loop {
604 if state.review_iteration > MAX_REVIEW_ITERATIONS {
606 print_max_review_iterations();
607 let iteration = state.iteration;
608 let results = story_results;
609 return Err(self.handle_fatal_error(
610 state,
611 "", "",
613 None,
614 None,
615 Some(|| print_summary_fn(iteration, results)),
616 Autom8Error::MaxReviewIterationsReached,
617 ));
618 }
619
620 print_state_transition(state.machine_state, MachineState::Reviewing);
622 state.transition_to(MachineState::Reviewing);
623 self.state_manager.save(state)?;
624 self.flush_live(MachineState::Reviewing);
625
626 breadcrumb.enter_state(BreadcrumbState::Review);
628
629 print_phase_banner("REVIEWING", BannerColor::Cyan);
630 print_reviewing(state.review_iteration, MAX_REVIEW_ITERATIONS);
631
632 let review_iter = state.review_iteration;
634 let review_result = with_progress_display_and_live(
635 self.verbose,
636 &self.state_manager,
637 MachineState::Reviewing,
638 || VerboseTimer::new_for_review(review_iter, MAX_REVIEW_ITERATIONS),
639 || ClaudeSpinner::new_for_review(review_iter, MAX_REVIEW_ITERATIONS),
640 |callback| run_reviewer(spec, review_iter, MAX_REVIEW_ITERATIONS, callback),
641 |res| match res {
642 Ok(r) => {
643 let tokens = r.usage.as_ref().map(|u| u.total_tokens());
644 match &r.outcome {
645 ReviewOutcome::Pass => {
646 Outcome::success("No issues found").with_optional_tokens(tokens)
647 }
648 ReviewOutcome::IssuesFound => {
649 Outcome::success("Issues found").with_optional_tokens(tokens)
650 }
651 ReviewOutcome::Error(e) => Outcome::failure(e.to_string()),
652 }
653 }
654 Err(e) => Outcome::failure(e.to_string()),
655 },
656 )?;
657
658 state.capture_usage("Final Review", review_result.usage.clone());
660
661 print_phase_footer(BannerColor::Cyan);
663
664 print_breadcrumb_trail(breadcrumb);
666
667 print_full_progress(
669 spec.completed_count(),
670 spec.total_count(),
671 state.review_iteration,
672 MAX_REVIEW_ITERATIONS,
673 );
674 println!();
675
676 match review_result.outcome {
677 ReviewOutcome::Pass => {
678 let review_path = std::path::Path::new("autom8_review.md");
680 if review_path.exists() {
681 let _ = fs::remove_file(review_path);
682 }
683 self.state_manager.save(state)?;
685 print_review_passed();
686 return Ok(()); }
688 ReviewOutcome::IssuesFound => {
689 print_state_transition(MachineState::Reviewing, MachineState::Correcting);
691 state.transition_to(MachineState::Correcting);
692 self.state_manager.save(state)?;
693 self.flush_live(MachineState::Correcting);
694
695 breadcrumb.enter_state(BreadcrumbState::Correct);
697
698 print_phase_banner("CORRECTING", BannerColor::Yellow);
699 print_issues_found(state.review_iteration, MAX_REVIEW_ITERATIONS);
700
701 let corrector_result = with_progress_display_and_live(
703 self.verbose,
704 &self.state_manager,
705 MachineState::Correcting,
706 || VerboseTimer::new_for_correct(review_iter, MAX_REVIEW_ITERATIONS),
707 || ClaudeSpinner::new_for_correct(review_iter, MAX_REVIEW_ITERATIONS),
708 |callback| run_corrector(spec, review_iter, callback),
709 |res| match res {
710 Ok(r) => {
711 let tokens = r.usage.as_ref().map(|u| u.total_tokens());
712 match &r.outcome {
713 CorrectorOutcome::Complete => {
714 Outcome::success("Issues addressed")
715 .with_optional_tokens(tokens)
716 }
717 CorrectorOutcome::Error(e) => Outcome::failure(e.to_string()),
718 }
719 }
720 Err(e) => Outcome::failure(e.to_string()),
721 },
722 )?;
723
724 state.capture_usage("Final Review", corrector_result.usage.clone());
727
728 print_phase_footer(BannerColor::Yellow);
730
731 print_breadcrumb_trail(breadcrumb);
733
734 print_full_progress(
736 spec.completed_count(),
737 spec.total_count(),
738 state.review_iteration,
739 MAX_REVIEW_ITERATIONS,
740 );
741 println!();
742
743 match corrector_result.outcome {
744 CorrectorOutcome::Complete => {
745 state.review_iteration += 1;
747 self.state_manager.save(state)?;
749 }
750 CorrectorOutcome::Error(e) => {
751 let iteration = state.iteration;
752 let results = story_results;
753 return Err(self.handle_fatal_error(
754 state,
755 "Corrector Failed",
756 &e.message,
757 e.exit_code,
758 e.stderr.as_deref(),
759 Some(|| print_summary_fn(iteration, results)),
760 Autom8Error::ClaudeError(format!("Corrector failed: {}", e)),
761 ));
762 }
763 }
764 }
765 ReviewOutcome::Error(e) => {
766 let iteration = state.iteration;
767 let results = story_results;
768 return Err(self.handle_fatal_error(
769 state,
770 "Review Failed",
771 &e.message,
772 e.exit_code,
773 e.stderr.as_deref(),
774 Some(|| print_summary_fn(iteration, results)),
775 Autom8Error::ClaudeError(format!("Review failed: {}", e)),
776 ));
777 }
778 }
779 }
780 }
781
782 fn handle_commit_and_pr(
787 &self,
788 state: &mut RunState,
789 spec: &Spec,
790 breadcrumb: &mut Breadcrumb,
791 ) -> Result<()> {
792 let config = state.effective_config();
794
795 if !config.commit {
797 print_state_transition(state.machine_state, MachineState::Completed);
798 print_info("Skipping commit (commit = false in config)");
799 return Ok(());
800 }
801
802 if !git::is_git_repo() {
803 print_state_transition(state.machine_state, MachineState::Completed);
804 return Ok(());
805 }
806
807 print_state_transition(state.machine_state, MachineState::Committing);
808 state.transition_to(MachineState::Committing);
809 self.state_manager.save(state)?;
810 self.flush_live(MachineState::Committing);
811
812 breadcrumb.enter_state(BreadcrumbState::Commit);
814
815 print_phase_banner("COMMITTING", BannerColor::Cyan);
816
817 let commit_result = with_progress_display_and_live(
819 self.verbose,
820 &self.state_manager,
821 MachineState::Committing,
822 VerboseTimer::new_for_commit,
823 ClaudeSpinner::new_for_commit,
824 |callback| run_for_commit(spec, callback),
825 |res| match res {
826 Ok(r) => {
827 let tokens = r.usage.as_ref().map(|u| u.total_tokens());
828 match &r.outcome {
829 CommitOutcome::Success(hash) => {
830 Outcome::success(hash.clone()).with_optional_tokens(tokens)
831 }
832 CommitOutcome::NothingToCommit => {
833 Outcome::success("Nothing to commit").with_optional_tokens(tokens)
834 }
835 CommitOutcome::Error(e) => Outcome::failure(e.to_string()),
836 }
837 }
838 Err(e) => Outcome::failure(e.to_string()),
839 },
840 )?;
841
842 state.capture_usage("PR & Commit", commit_result.usage.clone());
844 self.state_manager.save(state)?;
845
846 print_phase_footer(BannerColor::Cyan);
848
849 print_breadcrumb_trail(breadcrumb);
851
852 let commits_were_made = matches!(&commit_result.outcome, CommitOutcome::Success(_));
854
855 match &commit_result.outcome {
856 CommitOutcome::Success(hash) => {
857 print_info(&format!("Changes committed successfully ({})", hash))
858 }
859 CommitOutcome::NothingToCommit => print_info("Nothing to commit"),
860 CommitOutcome::Error(e) => {
861 print_error_panel(
862 "Commit Failed",
863 &e.message,
864 e.exit_code,
865 e.stderr.as_deref(),
866 );
867 }
868 }
869
870 if !config.pull_request {
872 print_state_transition(MachineState::Committing, MachineState::Completed);
873 print_info("Skipping PR creation (pull_request = false in config)");
874 return Ok(());
875 }
876
877 self.handle_pr_creation(state, spec, commits_were_made, config.pull_request_draft)
879 }
880
881 fn handle_pr_creation(
883 &self,
884 state: &mut RunState,
885 spec: &Spec,
886 commits_were_made: bool,
887 draft: bool,
888 ) -> Result<()> {
889 print_state_transition(MachineState::Committing, MachineState::CreatingPR);
890 state.transition_to(MachineState::CreatingPR);
891 self.state_manager.save(state)?;
892 self.flush_live(MachineState::CreatingPR);
893
894 match create_pull_request(spec, commits_were_made, draft) {
895 Ok(PRResult::Success(url)) => {
896 print_pr_success(&url);
897 print_state_transition(MachineState::CreatingPR, MachineState::Completed);
898 Ok(())
899 }
900 Ok(PRResult::Skipped(reason)) => {
901 print_pr_skipped(&reason);
902 print_state_transition(MachineState::CreatingPR, MachineState::Completed);
903 Ok(())
904 }
905 Ok(PRResult::AlreadyExists(url)) => {
906 print_pr_already_exists(&url);
907 print_state_transition(MachineState::CreatingPR, MachineState::Completed);
908 Ok(())
909 }
910 Ok(PRResult::Updated(url)) => {
911 print_pr_updated(&url);
912 print_state_transition(MachineState::CreatingPR, MachineState::Completed);
913 Ok(())
914 }
915 Ok(PRResult::Error(msg)) => {
916 print_state_transition(MachineState::CreatingPR, MachineState::Failed);
917 Err(self.handle_fatal_error(
918 state,
919 "PR Creation Failed",
920 &msg,
921 None,
922 None,
923 None::<fn() -> Result<()>>,
924 Autom8Error::ClaudeError(format!("PR creation failed: {}", msg)),
925 ))
926 }
927 Err(e) => {
928 print_state_transition(MachineState::CreatingPR, MachineState::Failed);
929 Err(self.handle_fatal_error(
930 state,
931 "PR Creation Error",
932 &e.to_string(),
933 None,
934 None,
935 None::<fn() -> Result<()>>,
936 e,
937 ))
938 }
939 }
940 }
941
942 fn handle_all_stories_complete(
945 &self,
946 state: &mut RunState,
947 spec: &Spec,
948 breadcrumb: &mut Breadcrumb,
949 story_results: &[StoryResult],
950 print_summary_fn: &impl Fn(u32, &[StoryResult]) -> Result<()>,
951 ) -> Result<LoopAction> {
952 print_all_complete();
953
954 let config = state.effective_config();
956
957 if self.skip_review || !config.review {
959 print_skip_review();
960 } else {
961 self.run_review_correct_loop(state, spec, breadcrumb, story_results, print_summary_fn)?;
963 }
964
965 self.handle_commit_and_pr(state, spec, breadcrumb)?;
967
968 state.transition_to(MachineState::Completed);
969 self.flush_live(MachineState::Completed);
971 self.state_manager.save(state)?;
972 print_summary_fn(state.iteration, story_results)?;
973
974 let duration_secs = state.run_duration_secs();
976 let total_tokens = state.total_usage.as_ref().map(|u| u.total_tokens());
977 print_run_completed(duration_secs, total_tokens);
978
979 self.archive_and_cleanup(state)?;
980 Ok(LoopAction::Break)
981 }
982
983 #[allow(clippy::too_many_arguments)]
986 fn handle_story_error(
987 &self,
988 state: &mut RunState,
989 story: &UserStory,
990 story_results: &mut Vec<StoryResult>,
991 story_start: Instant,
992 error_msg: &str,
993 error_panel_title: &str,
994 error_panel_msg: &str,
995 exit_code: Option<i32>,
996 stderr: Option<&str>,
997 print_summary_fn: &impl Fn(u32, &[StoryResult]) -> Result<()>,
998 ) -> Result<LoopAction> {
999 state.finish_iteration(IterationStatus::Failed, error_msg.to_string());
1000 state.transition_to(MachineState::Failed);
1001 let _ = self.state_manager.clear_live();
1003 self.state_manager.save(state)?;
1004
1005 story_results.push(StoryResult {
1006 id: story.id.clone(),
1007 title: story.title.clone(),
1008 passed: false,
1009 duration_secs: story_start.elapsed().as_secs(),
1010 });
1011
1012 print_error_panel(error_panel_title, error_panel_msg, exit_code, stderr);
1013 print_summary_fn(state.iteration, story_results)?;
1014 Err(Autom8Error::ClaudeError(error_msg.to_string()))
1015 }
1016
1017 #[allow(clippy::too_many_arguments)]
1020 fn handle_story_iteration(
1021 &self,
1022 state: &mut RunState,
1023 spec: &Spec,
1024 spec_json_path: &Path,
1025 story: &UserStory,
1026 breadcrumb: &mut Breadcrumb,
1027 story_results: &mut Vec<StoryResult>,
1028 story_start: Instant,
1029 claude_runner: &ClaudeRunner,
1030 print_summary_fn: &impl Fn(u32, &[StoryResult]) -> Result<()>,
1031 ) -> Result<LoopAction> {
1032 let story_index = spec
1034 .user_stories
1035 .iter()
1036 .position(|s| s.id == story.id)
1037 .map(|i| i as u32 + 1)
1038 .unwrap_or(state.iteration);
1039 let total_stories = spec.total_count() as u32;
1040 let story_id = story.id.clone();
1041 let iterations = state.iterations.clone();
1042 let knowledge = state.knowledge.clone();
1043
1044 let result = with_progress_display_and_live(
1047 self.verbose,
1048 &self.state_manager,
1049 MachineState::RunningClaude,
1050 || VerboseTimer::new_with_story_progress(&story_id, story_index, total_stories),
1051 || ClaudeSpinner::new_with_story_progress(&story_id, story_index, total_stories),
1052 |callback| {
1053 claude_runner.run(
1054 spec,
1055 story,
1056 spec_json_path,
1057 &iterations,
1058 &knowledge,
1059 callback,
1060 )
1061 },
1062 |res| match res {
1063 Ok(result) => {
1064 let tokens = result.usage.as_ref().map(|u| u.total_tokens());
1065 Outcome::success("Implementation done").with_optional_tokens(tokens)
1066 }
1067 Err(e) => Outcome::failure(e.to_string()),
1068 },
1069 );
1070
1071 match result {
1072 Ok(ClaudeStoryResult {
1073 outcome: ClaudeOutcome::AllStoriesComplete,
1074 work_summary,
1075 full_output,
1076 usage,
1077 }) => {
1078 state.capture_usage(&story.id, usage.clone());
1080 state.set_iteration_usage(usage);
1081 self.handle_all_stories_complete_from_story(
1082 state,
1083 spec,
1084 spec_json_path,
1085 story,
1086 breadcrumb,
1087 story_results,
1088 work_summary,
1089 &full_output,
1090 print_summary_fn,
1091 )
1092 }
1093 Ok(ClaudeStoryResult {
1094 outcome: ClaudeOutcome::IterationComplete,
1095 work_summary,
1096 full_output,
1097 usage,
1098 }) => {
1099 state.capture_usage(&story.id, usage.clone());
1101 state.set_iteration_usage(usage);
1102 self.handle_iteration_complete(
1103 state,
1104 spec_json_path,
1105 story,
1106 breadcrumb,
1107 story_results,
1108 work_summary,
1109 &full_output,
1110 )
1111 }
1112 Ok(ClaudeStoryResult {
1113 outcome: ClaudeOutcome::Error(error_info),
1114 usage,
1115 ..
1116 }) => {
1117 state.capture_usage(&story.id, usage.clone());
1119 state.set_iteration_usage(usage);
1120 self.handle_story_error(
1121 state,
1122 story,
1123 story_results,
1124 story_start,
1125 &error_info.message,
1126 "Claude Process Failed",
1127 &error_info.message,
1128 error_info.exit_code,
1129 error_info.stderr.as_deref(),
1130 print_summary_fn,
1131 )
1132 }
1133 Err(e) => self.handle_story_error(
1134 state,
1135 story,
1136 story_results,
1137 story_start,
1138 &e.to_string(),
1139 "Claude Error",
1140 &e.to_string(),
1141 None,
1142 None,
1143 print_summary_fn,
1144 ),
1145 }
1146 }
1147
1148 #[allow(clippy::too_many_arguments)]
1150 fn handle_all_stories_complete_from_story(
1151 &self,
1152 state: &mut RunState,
1153 spec: &Spec,
1154 spec_json_path: &Path,
1155 story: &UserStory,
1156 breadcrumb: &mut Breadcrumb,
1157 story_results: &mut Vec<StoryResult>,
1158 work_summary: Option<String>,
1159 full_output: &str,
1160 print_summary_fn: &impl Fn(u32, &[StoryResult]) -> Result<()>,
1161 ) -> Result<LoopAction> {
1162 state.finish_iteration(IterationStatus::Success, full_output.to_string());
1163 state.set_work_summary(work_summary.clone());
1164 let _ = self.state_manager.clear_live();
1166
1167 state.capture_story_knowledge(&story.id, full_output, None);
1169 self.state_manager.save(state)?;
1170
1171 let duration = state.current_iteration_duration();
1172 story_results.push(StoryResult {
1173 id: story.id.clone(),
1174 title: story.title.clone(),
1175 passed: true,
1176 duration_secs: duration,
1177 });
1178
1179 print_phase_footer(BannerColor::Cyan);
1181
1182 print_breadcrumb_trail(breadcrumb);
1184
1185 let updated_spec = Spec::load(spec_json_path)?;
1187 print_tasks_progress(updated_spec.completed_count(), updated_spec.total_count());
1188 println!();
1189
1190 if self.verbose {
1191 print_story_complete(&story.id, duration);
1192 }
1193
1194 if !updated_spec.all_complete() {
1196 return Ok(LoopAction::Continue);
1198 }
1199
1200 print_all_complete();
1201
1202 let config = state.effective_config();
1204
1205 if self.skip_review || !config.review {
1207 print_skip_review();
1208 } else {
1209 self.run_review_correct_loop(
1211 state,
1212 &updated_spec,
1213 breadcrumb,
1214 story_results,
1215 print_summary_fn,
1216 )?;
1217 }
1218
1219 self.handle_commit_and_pr(state, spec, breadcrumb)?;
1221
1222 state.transition_to(MachineState::Completed);
1223 self.flush_live(MachineState::Completed);
1225 self.state_manager.save(state)?;
1226 print_summary_fn(state.iteration, story_results)?;
1227
1228 let run_duration = state.run_duration_secs();
1230 let total_tokens = state.total_usage.as_ref().map(|u| u.total_tokens());
1231 print_run_completed(run_duration, total_tokens);
1232
1233 self.archive_and_cleanup(state)?;
1234 Ok(LoopAction::Break)
1235 }
1236
1237 #[allow(clippy::too_many_arguments)]
1239 fn handle_iteration_complete(
1240 &self,
1241 state: &mut RunState,
1242 spec_json_path: &Path,
1243 story: &UserStory,
1244 breadcrumb: &mut Breadcrumb,
1245 story_results: &mut Vec<StoryResult>,
1246 work_summary: Option<String>,
1247 full_output: &str,
1248 ) -> Result<LoopAction> {
1249 state.finish_iteration(IterationStatus::Success, full_output.to_string());
1250 state.set_work_summary(work_summary.clone());
1251 let _ = self.state_manager.clear_live();
1253
1254 state.capture_story_knowledge(&story.id, full_output, None);
1256 self.state_manager.save(state)?;
1257
1258 let duration = state.current_iteration_duration();
1259
1260 print_phase_footer(BannerColor::Cyan);
1262
1263 print_breadcrumb_trail(breadcrumb);
1265
1266 print_state_transition(MachineState::RunningClaude, MachineState::PickingStory);
1267 print_iteration_complete(state.iteration);
1268
1269 let updated_spec = Spec::load(spec_json_path)?;
1271 let story_passed = updated_spec
1272 .user_stories
1273 .iter()
1274 .find(|s| s.id == story.id)
1275 .is_some_and(|s| s.passes);
1276
1277 if story_passed {
1278 story_results.push(StoryResult {
1279 id: story.id.clone(),
1280 title: story.title.clone(),
1281 passed: true,
1282 duration_secs: duration,
1283 });
1284 if self.verbose {
1285 print_story_complete(&story.id, duration);
1286 }
1287 }
1288
1289 print_tasks_progress(updated_spec.completed_count(), updated_spec.total_count());
1291 println!();
1292
1293 Ok(LoopAction::Continue)
1295 }
1296
1297 pub fn run_from_spec(&self, spec_path: &Path) -> Result<()> {
1299 if self.state_manager.has_active_run()? {
1306 if let Some(state) = self.state_manager.load_current()? {
1307 return Err(Autom8Error::RunInProgress(state.run_id));
1308 }
1309 }
1310
1311 let config = self.load_config_with_override()?;
1313
1314 let spec_path = spec_path
1316 .canonicalize()
1317 .map_err(|_| Autom8Error::SpecNotFound(spec_path.to_path_buf()))?;
1318
1319 let stem = spec_path
1321 .file_stem()
1322 .and_then(|s| s.to_str())
1323 .unwrap_or("spec");
1324 let spec_dir = self.state_manager.ensure_spec_dir()?;
1325 let spec_json_path = spec_dir.join(format!("{}.json", stem));
1326
1327 let mut state = RunState::from_spec_with_config(
1332 spec_path.clone(),
1333 spec_json_path.clone(),
1334 config.clone(),
1335 );
1336
1337 print_state_transition(MachineState::Idle, MachineState::LoadingSpec);
1339
1340 let spec_content = fs::read_to_string(&spec_path)?;
1342 if spec_content.trim().is_empty() {
1343 return Err(Autom8Error::EmptySpec);
1344 }
1345
1346 let metadata = fs::metadata(&spec_path)?;
1347 print_spec_loaded(&spec_path, metadata.len());
1348 println!();
1349
1350 state.transition_to(MachineState::GeneratingSpec);
1354 print_state_transition(MachineState::LoadingSpec, MachineState::GeneratingSpec);
1355
1356 print_generating_spec();
1357
1358 let spec_result = match with_progress_display(
1360 self.verbose,
1361 VerboseTimer::new_for_spec,
1362 ClaudeSpinner::new_for_spec,
1363 |callback| run_for_spec_generation(&spec_content, &spec_json_path, callback),
1364 |res| match res {
1365 Ok(r) => {
1366 let tokens = r.usage.as_ref().map(|u| u.total_tokens());
1367 Outcome::success("Spec generated").with_optional_tokens(tokens)
1368 }
1369 Err(e) => Outcome::failure(e.to_string()),
1370 },
1371 ) {
1372 Ok(result) => result,
1373 Err(e) => {
1374 print_error_panel("Spec Generation Failed", &e.to_string(), None, None);
1375 return Err(e);
1376 }
1377 };
1378 let spec = spec_result.spec;
1379
1380 state.capture_usage("Planning", spec_result.usage);
1382
1383 print_spec_generated(&spec, &spec_json_path);
1384
1385 if let Some(conflict) = self
1388 .state_manager
1389 .check_branch_conflict(&spec.branch_name)?
1390 {
1391 return Err(Autom8Error::BranchConflict {
1392 branch: spec.branch_name.clone(),
1393 session_id: conflict.session_id,
1394 worktree_path: conflict.worktree_path,
1395 });
1396 }
1397
1398 let (worktree_context, mut worktree_setup_ctx) =
1401 self.setup_worktree_context(&config, &spec.branch_name)?;
1402
1403 let effective_state_manager = if let Some((ref session_id, _)) = worktree_context {
1405 StateManager::with_session(session_id.clone())?
1407 } else {
1408 StateManager::new()?
1410 };
1411
1412 let _ = effective_state_manager.clear_live();
1414
1415 if worktree_context.is_none() && git::is_git_repo() {
1417 let current_branch = git::current_branch()?;
1418 if current_branch != spec.branch_name {
1419 print_info(&format!(
1420 "Switching from '{}' to '{}'",
1421 current_branch, spec.branch_name
1422 ));
1423 git::ensure_branch(&spec.branch_name)?;
1424 }
1425 }
1426
1427 state.branch = spec.branch_name.clone();
1429 if let Some((ref session_id, _)) = &worktree_context {
1430 state.session_id = Some(session_id.clone());
1431 }
1432 state.transition_to(MachineState::Initializing);
1433 effective_state_manager.save(&state)?;
1434 flush_live_state(&effective_state_manager, MachineState::Initializing);
1436
1437 print_state_transition(MachineState::GeneratingSpec, MachineState::Initializing);
1438
1439 print_proceeding_to_implementation();
1440
1441 let effective_runner = Runner {
1443 state_manager: effective_state_manager,
1444 verbose: self.verbose,
1445 skip_review: self.skip_review,
1446 worktree_override: self.worktree_override,
1447 commit_override: self.commit_override,
1448 pull_request_override: self.pull_request_override,
1449 };
1450
1451 worktree_setup_ctx.metadata_saved = true;
1453
1454 effective_runner.run_implementation_loop(state, &spec_json_path, worktree_setup_ctx)
1455 }
1456
1457 pub fn run(&self, spec_json_path: &Path) -> Result<()> {
1458 if self.state_manager.has_active_run()? {
1465 if let Some(state) = self.state_manager.load_current()? {
1466 return Err(Autom8Error::RunInProgress(state.run_id));
1467 }
1468 }
1469
1470 let config = self.load_config_with_override()?;
1472
1473 let spec_json_path = spec_json_path
1475 .canonicalize()
1476 .map_err(|_| Autom8Error::SpecNotFound(spec_json_path.to_path_buf()))?;
1477
1478 let spec = Spec::load(&spec_json_path)?;
1480
1481 if let Some(conflict) = self
1484 .state_manager
1485 .check_branch_conflict(&spec.branch_name)?
1486 {
1487 return Err(Autom8Error::BranchConflict {
1488 branch: spec.branch_name.clone(),
1489 session_id: conflict.session_id,
1490 worktree_path: conflict.worktree_path,
1491 });
1492 }
1493
1494 let (worktree_context, worktree_setup_ctx) =
1497 self.setup_worktree_context(&config, &spec.branch_name)?;
1498
1499 let state_manager = if let Some((ref session_id, _)) = worktree_context {
1501 StateManager::with_session(session_id.clone())?
1503 } else {
1504 StateManager::new()?
1506 };
1507
1508 let _ = state_manager.clear_live();
1510
1511 if worktree_context.is_none() && git::is_git_repo() {
1513 let current_branch = git::current_branch()?;
1514 if current_branch != spec.branch_name {
1515 print_info(&format!(
1516 "Switching from '{}' to '{}'",
1517 current_branch, spec.branch_name
1518 ));
1519 git::ensure_branch(&spec.branch_name)?;
1520 }
1521 }
1522
1523 let state = if let Some((ref session_id, _)) = worktree_context {
1525 RunState::new_with_config_and_session(
1526 spec_json_path.to_path_buf(),
1527 spec.branch_name.clone(),
1528 config,
1529 session_id.clone(),
1530 )
1531 } else {
1532 RunState::new_with_config(
1533 spec_json_path.to_path_buf(),
1534 spec.branch_name.clone(),
1535 config,
1536 )
1537 };
1538
1539 print_state_transition(MachineState::Idle, MachineState::Initializing);
1540 print_project_info(&spec);
1541
1542 let worktree_runner = Runner {
1545 state_manager,
1546 verbose: self.verbose,
1547 skip_review: self.skip_review,
1548 worktree_override: self.worktree_override,
1549 commit_override: self.commit_override,
1550 pull_request_override: self.pull_request_override,
1551 };
1552
1553 worktree_runner.run_implementation_loop(state, &spec_json_path, worktree_setup_ctx)
1554 }
1555
1556 fn run_implementation_loop(
1557 &self,
1558 mut state: RunState,
1559 spec_json_path: &Path,
1560 mut worktree_setup_ctx: WorktreeSetupContext,
1561 ) -> Result<()> {
1562 let signal_handler = SignalHandler::new()?;
1564
1565 let claude_runner = ClaudeRunner::new();
1567
1568 print_state_transition(state.machine_state, MachineState::PickingStory);
1570 state.transition_to(MachineState::PickingStory);
1571 self.state_manager.save(&state)?;
1572 self.flush_live(MachineState::PickingStory);
1573
1574 worktree_setup_ctx.metadata_saved = true;
1577
1578 let mut story_results: Vec<StoryResult> = Vec::new();
1580 let run_start = Instant::now();
1581
1582 let mut breadcrumb = Breadcrumb::new();
1584
1585 let print_summary_fn = |iteration: u32, results: &[StoryResult]| -> Result<()> {
1587 let spec = Spec::load(spec_json_path)?;
1588 print_run_summary(
1589 spec.total_count(),
1590 spec.completed_count(),
1591 iteration,
1592 run_start.elapsed().as_secs(),
1593 results,
1594 );
1595 Ok(())
1596 };
1597
1598 loop {
1600 if signal_handler.is_shutdown_requested() {
1602 return Err(self.handle_interruption(
1603 &mut state,
1604 &claude_runner,
1605 Some(&worktree_setup_ctx),
1606 ));
1607 }
1608
1609 let spec = Spec::load(spec_json_path)?;
1611
1612 if spec.all_complete() {
1614 match self.handle_all_stories_complete(
1615 &mut state,
1616 &spec,
1617 &mut breadcrumb,
1618 &story_results,
1619 &print_summary_fn,
1620 )? {
1621 LoopAction::Break => return Ok(()),
1622 LoopAction::Continue => continue,
1623 }
1624 }
1625
1626 let story = spec
1628 .next_incomplete_story()
1629 .ok_or(Autom8Error::NoIncompleteStories)?
1630 .clone();
1631
1632 breadcrumb.reset();
1634
1635 state.capture_pre_story_state();
1637
1638 print_state_transition(MachineState::PickingStory, MachineState::RunningClaude);
1640 state.start_iteration(&story.id);
1641 self.state_manager.save(&state)?;
1642 self.flush_live(MachineState::RunningClaude);
1643
1644 breadcrumb.enter_state(BreadcrumbState::Story);
1646
1647 print_phase_banner("RUNNING", BannerColor::Cyan);
1648 print_iteration_start(state.iteration, &story.id, &story.title);
1649
1650 let story_start = Instant::now();
1652 match self.handle_story_iteration(
1653 &mut state,
1654 &spec,
1655 spec_json_path,
1656 &story,
1657 &mut breadcrumb,
1658 &mut story_results,
1659 story_start,
1660 &claude_runner,
1661 &print_summary_fn,
1662 )? {
1663 LoopAction::Break => return Ok(()),
1664 LoopAction::Continue => {
1665 if signal_handler.is_shutdown_requested() {
1667 return Err(self.handle_interruption(
1668 &mut state,
1669 &claude_runner,
1670 Some(&worktree_setup_ctx),
1671 ));
1672 }
1673 continue;
1674 }
1675 }
1676 }
1677 }
1678
1679 pub fn resume(&self) -> Result<()> {
1680 if let Some(state) = self.state_manager.load_current()? {
1682 if state.status == RunStatus::Running
1683 || state.status == RunStatus::Failed
1684 || state.status == RunStatus::Interrupted
1685 {
1686 if state.status == RunStatus::Interrupted {
1688 print_resuming_interrupted(&format!("{:?}", state.machine_state));
1689 }
1690
1691 let spec_json_path = state.spec_json_path.clone();
1692
1693 self.state_manager.archive(&state)?;
1695 self.state_manager.clear_current()?;
1696
1697 return self.run(&spec_json_path);
1699 }
1700 }
1701
1702 self.smart_resume()
1704 }
1705
1706 fn smart_resume(&self) -> Result<()> {
1708 use crate::prompt;
1709
1710 let spec_files = self.state_manager.list_specs()?;
1711 if spec_files.is_empty() {
1712 return Err(Autom8Error::NoSpecsToResume);
1713 }
1714
1715 let incomplete_specs: Vec<(PathBuf, Spec)> = spec_files
1717 .into_iter()
1718 .filter_map(|path| {
1719 Spec::load(&path).ok().and_then(|spec| {
1720 if spec.is_incomplete() {
1721 Some((path, spec))
1722 } else {
1723 None
1724 }
1725 })
1726 })
1727 .collect();
1728
1729 if incomplete_specs.is_empty() {
1730 return Err(Autom8Error::NoSpecsToResume);
1731 }
1732
1733 print_header();
1734 println!("{YELLOW}[resume]{RESET} No active run found, scanning for incomplete specs...");
1735 println!();
1736
1737 if incomplete_specs.len() == 1 {
1738 let (spec_path, spec) = &incomplete_specs[0];
1740 let (completed, total) = spec.progress();
1741 println!(
1742 "{CYAN}Found{RESET} {} {GRAY}({}/{}){RESET}",
1743 spec_path.display(),
1744 completed,
1745 total
1746 );
1747 println!();
1748 prompt::print_action(&format!("Resuming {}", spec.project));
1749 println!();
1750 return self.run(spec_path);
1751 }
1752
1753 println!(
1755 "{BOLD}Found {} incomplete specs:{RESET}",
1756 incomplete_specs.len()
1757 );
1758 println!();
1759
1760 let options: Vec<String> = incomplete_specs
1761 .iter()
1762 .map(|(path, spec)| {
1763 let (completed, total) = spec.progress();
1764 let filename = path
1765 .file_name()
1766 .and_then(|n| n.to_str())
1767 .unwrap_or("spec.json");
1768 format!("{} - {} ({}/{})", filename, spec.project, completed, total)
1769 })
1770 .chain(std::iter::once("Exit".to_string()))
1771 .collect();
1772
1773 let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
1774 let choice = prompt::select("Which spec would you like to resume?", &option_refs, 0);
1775
1776 if choice >= incomplete_specs.len() {
1778 println!();
1779 println!("Exiting.");
1780 return Err(Autom8Error::NoSpecsToResume);
1781 }
1782
1783 let (spec_path, spec) = &incomplete_specs[choice];
1784 println!();
1785 prompt::print_action(&format!("Resuming {}", spec.project));
1786 println!();
1787 self.run(spec_path)
1788 }
1789
1790 fn archive_and_cleanup(&self, state: &RunState) -> Result<()> {
1791 self.state_manager.archive(state)?;
1792
1793 let config = state.effective_config();
1799 if state.status == crate::state::RunStatus::Completed && config.worktree_cleanup {
1800 if let Ok(true) = is_in_worktree() {
1802 if let Ok(Some(metadata)) = self.state_manager.load_metadata() {
1804 let worktree_path = metadata.worktree_path;
1805
1806 self.state_manager.clear_current()?;
1808
1809 if let Ok(main_repo) = crate::worktree::get_main_repo_root() {
1812 if std::env::set_current_dir(&main_repo).is_ok() {
1813 match remove_worktree(&worktree_path, false) {
1815 Ok(()) => {
1816 print_info(&format!(
1817 "Cleaned up worktree: {}",
1818 worktree_path.display()
1819 ));
1820 }
1821 Err(e) => {
1822 print_info(&format!(
1824 "Warning: failed to remove worktree: {}",
1825 e
1826 ));
1827 }
1828 }
1829 }
1830 }
1831
1832 return Ok(());
1833 }
1834 }
1835 }
1836
1837 self.state_manager.clear_current()?;
1839 Ok(())
1840 }
1841
1842 pub fn status(&self) -> Result<Option<RunState>> {
1843 self.state_manager.load_current()
1844 }
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849 use super::*;
1850 use crate::config::Config;
1851 use crate::spec::{Spec, UserStory};
1852 use crate::state::RunStatus;
1853 use tempfile::TempDir;
1854
1855 fn create_test_spec(passes: bool) -> Spec {
1860 Spec {
1861 project: "TestProject".into(),
1862 branch_name: "test-branch".into(),
1863 description: "A test project".into(),
1864 user_stories: vec![UserStory {
1865 id: "US-001".into(),
1866 title: "Test Story".into(),
1867 description: "A test story".into(),
1868 acceptance_criteria: vec!["Test criterion".into()],
1869 priority: 1,
1870 passes,
1871 notes: String::new(),
1872 }],
1873 }
1874 }
1875
1876 fn create_multi_story_spec(completed_count: usize, total: usize) -> Spec {
1877 let stories = (0..total)
1878 .map(|i| UserStory {
1879 id: format!("US-{:03}", i + 1),
1880 title: format!("Story {}", i + 1),
1881 description: format!("Description for story {}", i + 1),
1882 acceptance_criteria: vec!["Criterion".into()],
1883 priority: (i + 1) as u32,
1884 passes: i < completed_count,
1885 notes: String::new(),
1886 })
1887 .collect();
1888 Spec {
1889 project: "TestProject".into(),
1890 branch_name: "test-branch".into(),
1891 description: "Multi-story test".into(),
1892 user_stories: stories,
1893 }
1894 }
1895
1896 #[test]
1901 fn test_runner_builder_pattern() {
1902 let runner = Runner::new()
1903 .unwrap()
1904 .with_verbose(true)
1905 .with_skip_review(true)
1906 .with_worktree(true);
1907
1908 assert!(runner.verbose);
1909 assert!(runner.skip_review);
1910 assert_eq!(runner.worktree_override, Some(true));
1911 }
1912
1913 #[test]
1914 fn test_runner_defaults() {
1915 let runner = Runner::new().unwrap();
1916 assert!(!runner.skip_review);
1917 assert!(!runner.verbose);
1918 assert!(runner.worktree_override.is_none());
1919 assert!(runner.commit_override.is_none());
1920 assert!(runner.pull_request_override.is_none());
1921 }
1922
1923 #[test]
1924 fn test_runner_commit_and_pull_request_overrides() {
1925 let runner = Runner::new()
1926 .unwrap()
1927 .with_commit(false)
1928 .with_pull_request(false);
1929
1930 assert_eq!(runner.commit_override, Some(false));
1931 assert_eq!(runner.pull_request_override, Some(false));
1932 }
1933
1934 #[test]
1939 fn test_story_index_is_one_indexed() {
1940 let story_ids = vec!["US-001", "US-002", "US-003"];
1941
1942 let idx = story_ids
1944 .iter()
1945 .position(|&s| s == "US-001")
1946 .map(|i| i as u32 + 1)
1947 .unwrap();
1948 assert_eq!(idx, 1);
1949
1950 let idx = story_ids
1952 .iter()
1953 .position(|&s| s == "US-003")
1954 .map(|i| i as u32 + 1)
1955 .unwrap();
1956 assert_eq!(idx, 3);
1957 }
1958
1959 #[test]
1964 fn test_spec_load_errors() {
1965 let result = Spec::load(Path::new("/nonexistent/spec.json"));
1967 assert!(matches!(result.unwrap_err(), Autom8Error::SpecNotFound(_)));
1968
1969 let temp_dir = TempDir::new().unwrap();
1971 let spec_path = temp_dir.path().join("spec.json");
1972 fs::write(&spec_path, "{ invalid }").unwrap();
1973 let result = Spec::load(&spec_path);
1974 assert!(matches!(result.unwrap_err(), Autom8Error::InvalidSpec(_)));
1975
1976 fs::write(
1978 &spec_path,
1979 r#"{"project": "", "branchName": "test", "description": "test", "userStories": [{"id": "US-001", "title": "t", "description": "d", "acceptanceCriteria": [], "priority": 1, "passes": false}]}"#,
1980 )
1981 .unwrap();
1982 let result = Spec::load(&spec_path);
1983 assert!(matches!(result.unwrap_err(), Autom8Error::InvalidSpec(_)));
1984 }
1985
1986 #[test]
1991 fn test_state_transitions_full_workflow() {
1992 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1993
1994 assert_eq!(state.machine_state, MachineState::Initializing);
1996
1997 state.transition_to(MachineState::PickingStory);
1998 state.start_iteration("US-001");
1999 assert_eq!(state.machine_state, MachineState::RunningClaude);
2000 assert_eq!(state.iteration, 1);
2001
2002 state.finish_iteration(IterationStatus::Success, String::new());
2003 assert_eq!(state.machine_state, MachineState::PickingStory);
2004
2005 state.transition_to(MachineState::Reviewing);
2007 state.review_iteration = 1;
2008 state.transition_to(MachineState::Correcting);
2009 state.transition_to(MachineState::Reviewing);
2010 state.review_iteration = 2;
2011
2012 state.transition_to(MachineState::Committing);
2014 state.transition_to(MachineState::CreatingPR);
2015 state.transition_to(MachineState::Completed);
2016
2017 assert_eq!(state.status, RunStatus::Completed);
2018 assert!(state.finished_at.is_some());
2019 }
2020
2021 #[test]
2022 fn test_state_transitions_to_failed() {
2023 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2024 state.transition_to(MachineState::Failed);
2025
2026 assert_eq!(state.status, RunStatus::Failed);
2027 assert!(state.finished_at.is_some());
2028 }
2029
2030 #[test]
2035 fn test_state_manager_save_load_clear() {
2036 let temp_dir = TempDir::new().unwrap();
2037 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2038
2039 assert!(sm.load_current().unwrap().is_none());
2041 assert!(!sm.has_active_run().unwrap());
2042
2043 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2045 sm.save(&state).unwrap();
2046 assert!(sm.has_active_run().unwrap());
2047
2048 let loaded = sm.load_current().unwrap().unwrap();
2049 assert_eq!(loaded.run_id, state.run_id);
2050
2051 sm.clear_current().unwrap();
2053 assert!(sm.load_current().unwrap().is_none());
2054 }
2055
2056 #[test]
2057 fn test_state_manager_completed_not_active() {
2058 let temp_dir = TempDir::new().unwrap();
2059 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2060
2061 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2062 state.transition_to(MachineState::Completed);
2063 sm.save(&state).unwrap();
2064
2065 assert!(!sm.has_active_run().unwrap());
2066 }
2067
2068 #[test]
2069 fn test_state_manager_archive() {
2070 let temp_dir = TempDir::new().unwrap();
2071 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2072
2073 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2074 let archive_path = sm.archive(&state).unwrap();
2075
2076 assert!(archive_path.exists());
2077 assert!(archive_path.parent().unwrap().ends_with("runs"));
2078 }
2079
2080 #[test]
2085 fn test_spec_completion_detection() {
2086 assert!(create_test_spec(true).all_complete());
2087 assert!(!create_test_spec(false).all_complete());
2088 }
2089
2090 #[test]
2091 fn test_spec_next_incomplete_story() {
2092 let spec = create_multi_story_spec(0, 3);
2093 assert_eq!(spec.next_incomplete_story().unwrap().id, "US-001");
2094
2095 let mut spec = create_multi_story_spec(0, 3);
2096 spec.user_stories[0].passes = true;
2097 assert_eq!(spec.next_incomplete_story().unwrap().id, "US-002");
2098
2099 let spec = create_multi_story_spec(3, 3);
2100 assert!(spec.next_incomplete_story().is_none());
2101 }
2102
2103 #[test]
2104 fn test_list_specs_sorted_by_mtime() {
2105 let temp_dir = TempDir::new().unwrap();
2106 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2107 let spec_dir = sm.ensure_spec_dir().unwrap();
2108
2109 create_test_spec(false)
2110 .save(&spec_dir.join("spec1.json"))
2111 .unwrap();
2112 std::thread::sleep(std::time::Duration::from_millis(10));
2113 create_multi_story_spec(1, 3)
2114 .save(&spec_dir.join("spec2.json"))
2115 .unwrap();
2116
2117 let specs = sm.list_specs().unwrap();
2118 assert_eq!(specs.len(), 2);
2119 assert!(specs[0].ends_with("spec2.json")); }
2121
2122 #[test]
2127 fn test_effective_config() {
2128 let state = RunState::new(PathBuf::from("test.json"), "test".to_string());
2130 let config = state.effective_config();
2131 assert!(config.review && config.commit && config.pull_request);
2132
2133 let custom = Config {
2135 review: false,
2136 commit: true,
2137 pull_request: false,
2138 ..Default::default()
2139 };
2140 let state =
2141 RunState::new_with_config(PathBuf::from("test.json"), "test".to_string(), custom);
2142 let config = state.effective_config();
2143 assert!(!config.review && config.commit && !config.pull_request);
2144 }
2145
2146 #[test]
2147 fn test_config_preserved_on_resume() {
2148 let temp_dir = TempDir::new().unwrap();
2149 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2150
2151 let config = Config {
2152 review: false,
2153 commit: true,
2154 pull_request: false,
2155 ..Default::default()
2156 };
2157 let state =
2158 RunState::new_with_config(PathBuf::from("test.json"), "test".to_string(), config);
2159 sm.save(&state).unwrap();
2160
2161 let loaded = sm.load_current().unwrap().unwrap();
2162 assert!(!loaded.effective_config().review);
2163 }
2164
2165 #[test]
2170 fn test_worktree_mode_override() {
2171 let runner = Runner::new().unwrap();
2172 let config_true = Config {
2173 worktree: true,
2174 ..Default::default()
2175 };
2176 let config_false = Config {
2177 worktree: false,
2178 ..Default::default()
2179 };
2180
2181 assert!(runner.is_worktree_mode(&config_true));
2183 assert!(!runner.is_worktree_mode(&config_false));
2184
2185 let runner_override = Runner::new().unwrap().with_worktree(true);
2187 assert!(runner_override.is_worktree_mode(&config_false));
2188
2189 let runner_override = Runner::new().unwrap().with_worktree(false);
2190 assert!(!runner_override.is_worktree_mode(&config_true));
2191 }
2192
2193 #[test]
2194 fn test_session_isolation() {
2195 let temp_dir = TempDir::new().unwrap();
2196
2197 let sm1 = StateManager::with_dir_and_session(
2198 temp_dir.path().to_path_buf(),
2199 "session1".to_string(),
2200 );
2201 let sm2 = StateManager::with_dir_and_session(
2202 temp_dir.path().to_path_buf(),
2203 "session2".to_string(),
2204 );
2205
2206 let state = RunState::new(PathBuf::from("test.json"), "test".to_string());
2207 sm1.save(&state).unwrap();
2208
2209 assert!(sm1.has_active_run().unwrap());
2210 assert!(!sm2.has_active_run().unwrap());
2211 }
2212
2213 #[test]
2218 fn test_iteration_tracking() {
2219 let mut state = RunState::new(PathBuf::from("test.json"), "test".to_string());
2220
2221 state.start_iteration("US-001");
2222 state.set_work_summary(Some("Work 1".to_string()));
2223 state.finish_iteration(IterationStatus::Success, String::new());
2224
2225 state.start_iteration("US-002");
2226 state.set_work_summary(Some("Work 2".to_string()));
2227 state.finish_iteration(IterationStatus::Success, String::new());
2228
2229 assert_eq!(state.iterations.len(), 2);
2230 assert_eq!(state.iterations[0].story_id, "US-001");
2231 assert_eq!(state.iterations[1].story_id, "US-002");
2232 }
2233
2234 #[test]
2239 fn test_live_output_flusher() {
2240 let temp_dir = TempDir::new().unwrap();
2241 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2242 sm.ensure_dirs().unwrap();
2243
2244 let mut flusher = LiveOutputFlusher::new(&sm, MachineState::RunningClaude);
2245 assert!(flusher.live_state.output_lines.is_empty());
2246
2247 flusher.append("Line 1");
2249 flusher.append("Line 2");
2250 assert_eq!(flusher.live_state.output_lines.len(), 2);
2251
2252 flusher.flush();
2254 assert_eq!(flusher.line_count_since_flush, 0);
2255
2256 for i in 0..10 {
2258 flusher.append(&format!("Line {}", i));
2259 }
2260 assert_eq!(flusher.line_count_since_flush, 0);
2261 assert!(sm.load_live().is_some());
2262 }
2263
2264 #[test]
2265 fn test_live_flush_constants() {
2266 assert_eq!(LIVE_FLUSH_INTERVAL_MS, 200);
2267 assert_eq!(LIVE_FLUSH_LINE_COUNT, 10);
2268 assert_eq!(HEARTBEAT_INTERVAL_MS, 2500);
2269 }
2270
2271 #[test]
2276 fn test_handle_interruption() {
2277 let temp_dir = TempDir::new().unwrap();
2278 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2279 sm.ensure_dirs().unwrap();
2280
2281 let runner = Runner {
2282 state_manager: StateManager::with_dir(temp_dir.path().to_path_buf()),
2283 verbose: false,
2284 skip_review: false,
2285 worktree_override: None,
2286 commit_override: None,
2287 pull_request_override: None,
2288 };
2289
2290 let mut state = RunState::new(PathBuf::from("test.json"), "test".to_string());
2291 state.transition_to(MachineState::RunningClaude);
2292 sm.save(&state).unwrap();
2293
2294 sm.save_live(&LiveState::new(MachineState::RunningClaude))
2296 .unwrap();
2297
2298 let claude_runner = ClaudeRunner::new();
2299 let error = runner.handle_interruption(&mut state, &claude_runner, None);
2300
2301 assert!(matches!(error, Autom8Error::Interrupted));
2303 assert_eq!(state.status, RunStatus::Interrupted);
2304 assert_eq!(state.machine_state, MachineState::RunningClaude); assert!(state.finished_at.is_some());
2306 assert!(sm.load_live().is_none()); }
2308
2309 #[test]
2310 fn test_interrupted_is_resumable() {
2311 for (status, resumable) in [
2312 (RunStatus::Interrupted, true),
2313 (RunStatus::Running, true),
2314 (RunStatus::Failed, true),
2315 (RunStatus::Completed, false),
2316 ] {
2317 let is_resumable = status == RunStatus::Running
2318 || status == RunStatus::Failed
2319 || status == RunStatus::Interrupted;
2320 assert_eq!(is_resumable, resumable, "{:?}", status);
2321 }
2322 }
2323
2324 #[test]
2329 fn test_worktree_setup_context() {
2330 let ctx = WorktreeSetupContext::new().unwrap();
2331 assert_eq!(ctx.original_cwd, std::env::current_dir().unwrap());
2332 assert!(ctx.worktree_path.is_none());
2333 assert!(!ctx.worktree_was_created);
2334 assert!(!ctx.cwd_changed);
2335 assert!(!ctx.metadata_saved);
2336 }
2337
2338 #[test]
2339 fn test_worktree_cleanup_logic() {
2340 let ctx = WorktreeSetupContext {
2342 original_cwd: PathBuf::from("/orig"),
2343 worktree_path: Some(PathBuf::from("/wt")),
2344 worktree_was_created: true,
2345 cwd_changed: false,
2346 metadata_saved: false,
2347 };
2348 assert!(ctx.worktree_was_created && !ctx.metadata_saved);
2349
2350 let ctx = WorktreeSetupContext {
2352 worktree_was_created: false,
2353 ..ctx.clone()
2354 };
2355 assert!(!ctx.worktree_was_created);
2356
2357 let ctx = WorktreeSetupContext {
2359 worktree_was_created: true,
2360 metadata_saved: true,
2361 ..ctx.clone()
2362 };
2363 assert!(ctx.metadata_saved);
2364 }
2365
2366 #[test]
2367 fn test_worktree_cleanup_restores_cwd() {
2368 let original_cwd = std::env::current_dir().unwrap();
2369 let temp_dir = TempDir::new().unwrap();
2370
2371 let mut ctx = WorktreeSetupContext::new().unwrap();
2372 std::env::set_current_dir(temp_dir.path()).unwrap();
2373 ctx.cwd_changed = true;
2374 ctx.worktree_was_created = false;
2375
2376 ctx.cleanup_on_interruption();
2377 assert_eq!(std::env::current_dir().unwrap(), original_cwd);
2378 }
2379
2380 #[test]
2385 fn test_state_not_saved_before_worktree_context() {
2386 let temp_dir = TempDir::new().unwrap();
2387 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2388
2389 let state = RunState::from_spec_with_config(
2391 PathBuf::from("spec.md"),
2392 PathBuf::from("spec.json"),
2393 Config::default(),
2394 );
2395 assert_eq!(state.machine_state, MachineState::LoadingSpec);
2396
2397 assert!(sm.load_current().unwrap().is_none());
2399 }
2400
2401 #[test]
2402 fn test_state_saved_to_correct_session() {
2403 let temp_dir = TempDir::new().unwrap();
2404 let main_sm =
2405 StateManager::with_dir_and_session(temp_dir.path().to_path_buf(), "main".to_string());
2406 let wt_sm = StateManager::with_dir_and_session(
2407 temp_dir.path().to_path_buf(),
2408 "abc12345".to_string(),
2409 );
2410
2411 let state = RunState::new_with_config_and_session(
2412 PathBuf::from("spec.json"),
2413 "feature".to_string(),
2414 Config::default(),
2415 "abc12345".to_string(),
2416 );
2417 wt_sm.save(&state).unwrap();
2418
2419 assert!(main_sm.load_current().unwrap().is_none());
2420 assert!(wt_sm.load_current().unwrap().is_some());
2421 }
2422}