Skip to main content

ito_core/ralph/
runner.rs

1use crate::error_bridge::IntoCoreResult;
2use crate::errors::{CoreError, CoreResult};
3use crate::harness::types::MAX_RETRIABLE_RETRIES;
4use crate::harness::{Harness, HarnessName};
5use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
6use crate::ralph::duration::format_duration;
7use crate::ralph::prompt::{BuildPromptOptions, build_ralph_prompt};
8use crate::ralph::state::{
9    RalphHistoryEntry, RalphState, append_context, clear_context, load_context, load_state,
10    save_state,
11};
12use crate::ralph::validation;
13use crate::task_repository::FsTaskRepository;
14use crate::tasks::{get_next_task_from_summary, get_task_status_from_repository};
15use ito_domain::changes::{
16    ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
17    ChangeWorkStatus,
18};
19use ito_domain::modules::ModuleRepository as DomainModuleRepository;
20use ito_domain::tasks::TaskRepository as DomainTaskRepository;
21use std::collections::BTreeSet;
22use std::path::{Path, PathBuf};
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25/// Worktree configuration subset needed for Ralph's working directory resolution.
26#[derive(Debug, Clone, Default)]
27pub struct WorktreeConfig {
28    /// Whether worktree-based workflows are enabled for this project.
29    pub enabled: bool,
30    /// The directory name where change worktrees live (e.g. `ito-worktrees`).
31    ///
32    /// Not currently used in resolution logic (branch lookup via `git worktree
33    /// list` does not need this), but carried for future use such as
34    /// constructing expected worktree paths without invoking git.
35    pub dir_name: String,
36}
37
38#[derive(Debug, Clone)]
39/// Runtime options for a single Ralph loop invocation.
40pub struct RalphOptions {
41    /// Base prompt content appended after any change/module context.
42    pub prompt: String,
43
44    /// Optional change id to scope the loop to.
45    pub change_id: Option<String>,
46
47    /// Optional module id to scope the loop to.
48    pub module_id: Option<String>,
49
50    /// Optional model override passed through to the harness.
51    pub model: Option<String>,
52
53    /// Minimum number of iterations required before a completion promise is honored.
54    pub min_iterations: u32,
55
56    /// Optional maximum iteration count.
57    pub max_iterations: Option<u32>,
58
59    /// Completion token that signals the loop is done (e.g. `COMPLETE`).
60    pub completion_promise: String,
61
62    /// Auto-approve all harness prompts and actions.
63    pub allow_all: bool,
64
65    /// Skip creating a git commit after each iteration.
66    pub no_commit: bool,
67
68    /// Enable interactive mode when supported by the harness.
69    pub interactive: bool,
70
71    /// Print the current saved state without running a new iteration.
72    pub status: bool,
73
74    /// Append additional markdown to the saved Ralph context and exit.
75    pub add_context: Option<String>,
76
77    /// Clear any saved Ralph context and exit.
78    pub clear_context: bool,
79
80    /// Print the full prompt sent to the harness.
81    pub verbose: bool,
82
83    /// When targeting a module, continue through ready changes until module work is complete.
84    pub continue_module: bool,
85
86    /// When set, continuously process eligible changes across the repo.
87    ///
88    /// Eligible changes are those whose derived work status is `Ready` or `InProgress`.
89    pub continue_ready: bool,
90
91    /// Inactivity timeout - restart iteration if no output for this duration.
92    pub inactivity_timeout: Option<Duration>,
93
94    /// Skip all completion validation.
95    ///
96    /// When set, the loop trusts the completion promise and exits immediately.
97    pub skip_validation: bool,
98
99    /// Additional validation command to run when a completion promise is detected.
100    ///
101    /// This runs after the project validation steps.
102    pub validation_command: Option<String>,
103
104    /// Exit immediately when the harness process returns non-zero.
105    ///
106    /// When false, Ralph captures the failure output and continues iterating.
107    pub exit_on_error: bool,
108
109    /// Maximum number of non-zero harness exits allowed before failing.
110    ///
111    /// Applies only when `exit_on_error` is false.
112    pub error_threshold: u32,
113
114    /// Worktree configuration for working directory resolution.
115    pub worktree: WorktreeConfig,
116}
117
118/// Default maximum number of non-zero harness exits Ralph tolerates.
119pub const DEFAULT_ERROR_THRESHOLD: u32 = 10;
120
121/// Resolved working directory for a Ralph invocation.
122///
123/// Bundles the effective working directory path with the `.ito` directory
124/// that should be used for state file writes.
125#[derive(Debug, Clone)]
126pub struct ResolvedCwd {
127    /// The directory where the harness and git commands should execute.
128    pub path: PathBuf,
129    /// The `.ito` directory for state file writes (may differ from the
130    /// process's `.ito` when a worktree is resolved).
131    pub ito_path: PathBuf,
132}
133
134/// Resolve the effective working directory for a Ralph invocation.
135///
136/// When worktrees are enabled and a matching worktree exists for
137/// `change_id`, returns the worktree path. Otherwise falls back to the
138/// process's current working directory.
139pub fn resolve_effective_cwd(
140    ito_path: &Path,
141    change_id: Option<&str>,
142    worktree: &WorktreeConfig,
143) -> ResolvedCwd {
144    let lookup = |branch: &str| crate::audit::worktree::find_worktree_for_branch(branch);
145    let fallback_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
146    resolve_effective_cwd_with(ito_path, change_id, worktree, fallback_path, lookup)
147}
148
149/// Testable core of [`resolve_effective_cwd`].
150///
151/// Accepts an explicit fallback path and a worktree lookup function so
152/// callers can inject test doubles.
153fn resolve_effective_cwd_with(
154    ito_path: &Path,
155    change_id: Option<&str>,
156    worktree: &WorktreeConfig,
157    fallback_path: PathBuf,
158    lookup: impl Fn(&str) -> Option<PathBuf>,
159) -> ResolvedCwd {
160    let fallback = ResolvedCwd {
161        path: fallback_path,
162        ito_path: ito_path.to_path_buf(),
163    };
164
165    let wt_path = if worktree.enabled {
166        change_id.and_then(lookup)
167    } else {
168        None
169    };
170
171    let Some(wt_path) = wt_path else {
172        return fallback;
173    };
174
175    let wt_ito_path = wt_path.join(".ito");
176    ResolvedCwd {
177        path: wt_path,
178        ito_path: wt_ito_path,
179    }
180}
181
182/// Run the Ralph loop for a change (or repository/module sequence) until the configured completion promise is detected.
183///
184/// Persists lightweight per-change state under `.ito/.state/ralph/<change>/` so iteration history and context are available for inspection.
185///
186/// # Examples
187///
188/// ```no_run
189/// use std::path::Path;
190///
191/// // Prepare repositories, options and a harness implementing the required traits,
192/// // then invoke run_ralph with the workspace path:
193/// // let ito = Path::new(".");
194/// // run_ralph(ito, &change_repo, &task_repo, &module_repo, opts, &mut harness)?;
195/// ```
196pub fn run_ralph(
197    ito_path: &Path,
198    change_repo: &(impl DomainChangeRepository + ?Sized),
199    task_repo: &dyn DomainTaskRepository,
200    module_repo: &(impl DomainModuleRepository + ?Sized),
201    opts: RalphOptions,
202    harness: &mut dyn Harness,
203) -> CoreResult<()> {
204    let process_runner = SystemProcessRunner;
205    if opts.continue_ready {
206        if opts.continue_module {
207            return Err(CoreError::Validation(
208                "--continue-ready cannot be used with --continue-module".into(),
209            ));
210        }
211        if opts.change_id.is_some() || opts.module_id.is_some() {
212            return Err(CoreError::Validation(
213                "--continue-ready cannot be used with --change or --module".into(),
214            ));
215        }
216        if opts.status || opts.add_context.is_some() || opts.clear_context {
217            return Err(CoreError::Validation(
218                "--continue-ready cannot be combined with --status, --add-context, or --clear-context".into(),
219            ));
220        }
221
222        let mut processed: BTreeSet<String> = BTreeSet::new();
223        let mut succeeded: Vec<String> = Vec::new();
224        let mut failed: Vec<(String, String)> = Vec::new();
225
226        loop {
227            let current_changes = repo_changes(change_repo)?;
228            let eligible_all = repo_eligible_change_ids(&current_changes);
229            print_eligible_changes(&eligible_all);
230            let eligible_changes = unprocessed_change_ids(&eligible_all, &processed);
231
232            if eligible_changes.is_empty() {
233                if eligible_all.is_empty() {
234                    let incomplete = repo_incomplete_change_ids(&current_changes);
235                    if incomplete.is_empty() {
236                        println!("\nAll changes are complete.");
237                        return finalize_queue_results("Repository", &succeeded, &failed);
238                    }
239
240                    return Err(CoreError::Validation(format!(
241                        "Repository has no eligible changes. Remaining non-complete changes: {}",
242                        incomplete.join(", ")
243                    )));
244                }
245
246                println!(
247                    "\nRepository has no additional eligible changes (all eligible changes were already processed in this run)."
248                );
249                return finalize_queue_results("Repository", &succeeded, &failed);
250            }
251
252            let mut next_change = eligible_changes[0].clone();
253
254            let preflight_changes = repo_changes(change_repo)?;
255            let preflight_eligible_all = repo_eligible_change_ids(&preflight_changes);
256            let preflight_eligible = unprocessed_change_ids(&preflight_eligible_all, &processed);
257            if preflight_eligible.is_empty() {
258                let incomplete = repo_incomplete_change_ids(&preflight_changes);
259                if incomplete.is_empty() {
260                    println!("\nAll changes are complete.");
261                    return finalize_queue_results("Repository", &succeeded, &failed);
262                }
263                return Err(CoreError::Validation(format!(
264                    "Repository changed during selection and now has no eligible changes. Remaining non-complete changes: {}",
265                    incomplete.join(", ")
266                )));
267            }
268            let preflight_first = preflight_eligible[0].clone();
269            if preflight_first != next_change {
270                println!(
271                    "\nRepository state shifted before start; reorienting from {from} to {to}.",
272                    from = next_change,
273                    to = preflight_first
274                );
275                next_change = preflight_first;
276            }
277
278            println!(
279                "\nStarting change {change} (lowest eligible change id).",
280                change = next_change
281            );
282
283            let mut single_opts = opts.clone();
284            single_opts.continue_ready = false;
285            single_opts.change_id = Some(next_change.clone());
286
287            let result = run_ralph(
288                ito_path,
289                change_repo,
290                task_repo,
291                module_repo,
292                single_opts,
293                harness,
294            );
295
296            processed.insert(next_change.clone());
297            match result {
298                Ok(()) => succeeded.push(next_change),
299                Err(err) => {
300                    println!(
301                        "\nChange {change} failed during continue-ready sweep: {err}\n",
302                        change = next_change,
303                        err = err
304                    );
305                    failed.push((next_change, err.to_string()));
306                }
307            }
308        }
309    }
310
311    if opts.continue_module {
312        if opts.change_id.is_some() {
313            return Err(CoreError::Validation(
314                "--continue-module cannot be used with --change. Use --module only.".into(),
315            ));
316        }
317        let Some(module_id) = opts.module_id.clone() else {
318            return Err(CoreError::Validation(
319                "--continue-module requires --module".into(),
320            ));
321        };
322        if opts.status || opts.add_context.is_some() || opts.clear_context {
323            return Err(CoreError::Validation(
324                "--continue-module cannot be combined with --status, --add-context, or --clear-context".into()
325            ));
326        }
327
328        let mut processed: BTreeSet<String> = BTreeSet::new();
329        let mut succeeded: Vec<String> = Vec::new();
330        let mut failed: Vec<(String, String)> = Vec::new();
331
332        loop {
333            let current_changes = module_changes(change_repo, &module_id)?;
334            let ready_all = module_ready_change_ids(&current_changes);
335            print_ready_changes(&module_id, &ready_all);
336
337            // Filter out changes already processed in this `--continue-module` session.
338            let ready_changes = unprocessed_change_ids(&ready_all, &processed);
339
340            if ready_changes.is_empty() {
341                // If there were no ready changes at all, preserve existing behavior.
342                if ready_all.is_empty() {
343                    let incomplete = module_incomplete_change_ids(&current_changes);
344
345                    if incomplete.is_empty() {
346                        println!("\nModule {module} is complete.", module = module_id);
347                        return finalize_queue_results(
348                            &format!("Module {module_id}"),
349                            &succeeded,
350                            &failed,
351                        );
352                    }
353
354                    return Err(CoreError::Validation(format!(
355                        "Module {module} has no ready changes. Remaining non-complete changes: {}",
356                        incomplete.join(", "),
357                        module = module_id
358                    )));
359                }
360
361                // All ready changes were already processed in this run. Exit cleanly so callers
362                // can re-run the loop after merging/refreshing state.
363                println!(
364                    "\nModule {module} has no additional ready changes (all ready changes were already processed in this run).",
365                    module = module_id
366                );
367                return finalize_queue_results(&format!("Module {module_id}"), &succeeded, &failed);
368            }
369
370            let mut next_change = ready_changes[0].clone();
371
372            let preflight_changes = module_changes(change_repo, &module_id)?;
373            let preflight_ready_all = module_ready_change_ids(&preflight_changes);
374            if preflight_ready_all.is_empty() {
375                let incomplete = module_incomplete_change_ids(&preflight_changes);
376                if incomplete.is_empty() {
377                    println!("\nModule {module} is complete.", module = module_id);
378                    return finalize_queue_results(
379                        &format!("Module {module_id}"),
380                        &succeeded,
381                        &failed,
382                    );
383                }
384                return Err(CoreError::Validation(format!(
385                    "Module {module} changed during selection and now has no ready changes. Remaining non-complete changes: {}",
386                    incomplete.join(", "),
387                    module = module_id
388                )));
389            }
390
391            let preflight_ready = unprocessed_change_ids(&preflight_ready_all, &processed);
392
393            if preflight_ready.is_empty() {
394                println!(
395                    "\nModule {module} has no additional ready changes (all ready changes were already processed in this run).",
396                    module = module_id
397                );
398                return finalize_queue_results(&format!("Module {module_id}"), &succeeded, &failed);
399            }
400
401            let preflight_first = preflight_ready[0].clone();
402            if preflight_first != next_change {
403                println!(
404                    "\nModule state shifted before start; reorienting from {from} to {to}.",
405                    from = next_change,
406                    to = preflight_first
407                );
408                next_change = preflight_first;
409            }
410
411            println!(
412                "\nStarting module change {change} (lowest ready change id).",
413                change = next_change
414            );
415
416            let mut single_opts = opts.clone();
417            single_opts.continue_module = false;
418            single_opts.continue_ready = false;
419            single_opts.change_id = Some(next_change.clone());
420
421            let result = run_ralph(
422                ito_path,
423                change_repo,
424                task_repo,
425                module_repo,
426                single_opts,
427                harness,
428            );
429
430            // Avoid re-processing the same ready change repeatedly within the same `--continue-module` run.
431            processed.insert(next_change.clone());
432            match result {
433                Ok(()) => succeeded.push(next_change.clone()),
434                Err(err) => {
435                    println!(
436                        "\nModule change {change} failed during continue-module sweep: {err}\n",
437                        change = next_change,
438                        err = err
439                    );
440                    failed.push((next_change.clone(), err.to_string()));
441                }
442            }
443
444            let post_changes = module_changes(change_repo, &module_id)?;
445            let post_ready = module_ready_change_ids(&post_changes);
446            print_ready_changes(&module_id, &post_ready);
447        }
448    }
449
450    if opts.change_id.is_none()
451        && let Some(module_id) = opts.module_id.as_deref()
452        && !opts.status
453        && opts.add_context.is_none()
454        && !opts.clear_context
455    {
456        let module_changes = module_changes(change_repo, module_id)?;
457        let ready_changes = module_ready_change_ids(&module_changes);
458        print_ready_changes(module_id, &ready_changes);
459    }
460
461    let unscoped_target = opts.change_id.is_none() && opts.module_id.is_none();
462
463    let (change_id, module_id) = if unscoped_target {
464        ("unscoped".to_string(), "unscoped".to_string())
465    } else {
466        resolve_target(
467            change_repo,
468            opts.change_id,
469            opts.module_id,
470            opts.interactive,
471        )?
472    };
473
474    // Resolve worktree-aware working directory using the canonical selected change id.
475    let resolved_cwd = resolve_effective_cwd(
476        ito_path,
477        if unscoped_target {
478            None
479        } else {
480            Some(change_id.as_str())
481        },
482        &opts.worktree,
483    );
484    let effective_ito_path = &resolved_cwd.ito_path;
485
486    if opts.verbose {
487        if effective_ito_path != ito_path {
488            println!("Resolved worktree: {}", resolved_cwd.path.display());
489        } else {
490            println!(
491                "Using current working directory: {}",
492                resolved_cwd.path.display()
493            );
494        }
495    }
496
497    if opts.status {
498        let state = load_state(effective_ito_path, &change_id)?;
499        if let Some(state) = state {
500            println!("\n=== Ralph Status for {id} ===\n", id = state.change_id);
501            println!("Iteration: {iter}", iter = state.iteration);
502            println!("History entries: {n}", n = state.history.len());
503            if let Some(outcome) = state.last_outcome.as_deref() {
504                println!("Last outcome: {outcome}");
505            }
506            if let Some(failure) = state.last_failure.as_deref() {
507                println!("\nLast failure:\n{failure}\n");
508            }
509            let change_id_opt = if unscoped_target {
510                None
511            } else {
512                Some(change_id.as_str())
513            };
514            let fs_task_repo_for_status;
515            let task_repo_for_status: &dyn DomainTaskRepository =
516                if should_validate_tasks_from_effective_worktree(
517                    change_id_opt,
518                    ito_path,
519                    effective_ito_path,
520                ) {
521                    fs_task_repo_for_status = FsTaskRepository::new(effective_ito_path);
522                    &fs_task_repo_for_status
523                } else {
524                    task_repo
525                };
526            if let Ok(summary) =
527                get_task_status_from_repository(task_repo_for_status, &state.change_id)
528            {
529                println!(
530                    "Task progress: {complete}/{total} complete, {in_progress} in progress, {pending} pending, {shelved} shelved",
531                    complete = summary.progress.complete,
532                    total = summary.progress.total,
533                    in_progress = summary.progress.in_progress,
534                    pending = summary.progress.pending,
535                    shelved = summary.progress.shelved,
536                );
537                if let Ok(Some(task)) = get_next_task_from_summary(&summary, "tasks.md") {
538                    println!("Next task: {} {}", task.id, task.name);
539                }
540            }
541            if !state.history.is_empty() {
542                println!("\nRecent iterations:");
543                let n = state.history.len();
544                let start = n.saturating_sub(5);
545                for (i, h) in state.history.iter().enumerate().skip(start) {
546                    println!(
547                        "  {idx}: duration={dur}ms, changes={chg}, promise={p}, validated={v}, exit={exit}, cwd={cwd}",
548                        idx = i + 1,
549                        dur = h.duration,
550                        chg = h.file_changes_count,
551                        p = h.completion_promise_found,
552                        v = h.completion_validated,
553                        exit = h.harness_exit_code,
554                        cwd = h.effective_cwd
555                    );
556                }
557            }
558        } else {
559            println!("\n=== Ralph Status for {id} ===\n", id = change_id);
560            println!("No state found");
561        }
562        return Ok(());
563    }
564
565    if let Some(text) = opts.add_context.as_deref() {
566        append_context(effective_ito_path, &change_id, text)?;
567        println!("Added context to {id}", id = change_id);
568        return Ok(());
569    }
570    if opts.clear_context {
571        clear_context(effective_ito_path, &change_id)?;
572        println!("Cleared Ralph context for {id}", id = change_id);
573        return Ok(());
574    }
575
576    let ito_dir_name = effective_ito_path
577        .file_name()
578        .map(|s| s.to_string_lossy().to_string())
579        .unwrap_or_else(|| ".ito".to_string());
580    let context_file = format!(
581        "{ito_dir}/.state/ralph/{change}/context.md",
582        ito_dir = ito_dir_name,
583        change = change_id
584    );
585
586    let mut state = load_state(effective_ito_path, &change_id)?.unwrap_or(RalphState {
587        change_id: change_id.clone(),
588        iteration: 0,
589        history: vec![],
590        context_file,
591        last_outcome: None,
592        last_failure: None,
593    });
594
595    let max_iters = opts.max_iterations.unwrap_or(u32::MAX);
596    if max_iters == 0 {
597        return Err(CoreError::Validation(
598            "--max-iterations must be >= 1".into(),
599        ));
600    }
601    if opts.error_threshold == 0 {
602        return Err(CoreError::Validation(
603            "--error-threshold must be >= 1".into(),
604        ));
605    }
606
607    // Print startup message so user knows something is happening
608    println!(
609        "\n=== Starting Ralph for {change} (harness: {harness}) ===",
610        change = change_id,
611        harness = harness.name()
612    );
613    if let Some(model) = &opts.model {
614        println!("Model: {model}");
615    }
616    if let Some(max) = opts.max_iterations {
617        println!("Max iterations: {max}");
618    }
619    if opts.allow_all {
620        println!("Mode: --yolo (auto-approve all)");
621    }
622    if let Some(timeout) = opts.inactivity_timeout {
623        println!("Inactivity timeout: {}", format_duration(timeout));
624    }
625    println!();
626
627    let mut last_validation_failure: Option<String> = None;
628    let mut harness_error_count: u32 = 0;
629    let mut retriable_retry_count: u32 = 0;
630
631    for _ in 0..max_iters {
632        let iteration = state.iteration.saturating_add(1);
633
634        println!("\n=== Ralph Loop Iteration {i} ===\n", i = iteration);
635
636        let context_content = load_context(effective_ito_path, &change_id)?;
637        let change_id_opt = if unscoped_target {
638            None
639        } else {
640            Some(change_id.as_str())
641        };
642        let fs_task_repo_for_prompt;
643        let task_repo_for_prompt: &dyn DomainTaskRepository =
644            if should_validate_tasks_from_effective_worktree(
645                change_id_opt,
646                ito_path,
647                effective_ito_path,
648            ) {
649                fs_task_repo_for_prompt = FsTaskRepository::new(effective_ito_path);
650                &fs_task_repo_for_prompt
651            } else {
652                task_repo
653            };
654        let prompt = build_ralph_prompt(
655            effective_ito_path,
656            change_repo,
657            task_repo_for_prompt,
658            module_repo,
659            &opts.prompt,
660            BuildPromptOptions {
661                change_id: if unscoped_target {
662                    None
663                } else {
664                    Some(change_id.clone())
665                },
666                module_id: if unscoped_target {
667                    None
668                } else {
669                    Some(module_id.clone())
670                },
671                iteration: Some(iteration),
672                max_iterations: opts.max_iterations,
673                min_iterations: opts.min_iterations,
674                completion_promise: opts.completion_promise.clone(),
675                context_content: Some(context_content),
676                validation_failure: last_validation_failure.clone(),
677            },
678        )?;
679
680        if opts.verbose {
681            println!("--- Prompt sent to harness ---");
682            println!("{}", prompt);
683            println!("--- End of prompt ---\n");
684        }
685
686        let started = std::time::Instant::now();
687        let run = harness
688            .run(&crate::harness::HarnessRunConfig {
689                prompt,
690                model: opts.model.clone(),
691                cwd: resolved_cwd.path.clone(),
692                env: std::collections::BTreeMap::new(),
693                interactive: opts.interactive && !opts.allow_all,
694                allow_all: opts.allow_all,
695                inactivity_timeout: opts.inactivity_timeout,
696            })
697            .map_err(|e| CoreError::Process(format!("Harness execution failed: {e}")))?;
698
699        // Pass through output if harness didn't already stream it
700        if !harness.streams_output() {
701            if !run.stdout.is_empty() {
702                print!("{}", run.stdout);
703            }
704            if !run.stderr.is_empty() {
705                eprint!("{}", run.stderr);
706            }
707        }
708
709        // Mirror TS: completion promise is detected from stdout (not stderr).
710        let completion_found = completion_promise_found(&run.stdout, &opts.completion_promise);
711
712        let file_changes_count = if harness.name() != HarnessName::Stub {
713            count_git_changes(&process_runner, &resolved_cwd.path)? as u32
714        } else {
715            0
716        };
717
718        // Handle timeout - log and continue to next iteration
719        if run.timed_out {
720            state.last_outcome = Some("timed-out".to_string());
721            state.last_failure = Some("Harness run timed out due to inactivity".to_string());
722            println!("\n=== Inactivity timeout reached. Restarting iteration... ===\n");
723            retriable_retry_count = 0;
724            // Don't update state for timed out iterations, just retry
725            continue;
726        }
727
728        if run.exit_code != 0 {
729            if run.is_retriable() {
730                retriable_retry_count = retriable_retry_count.saturating_add(1);
731                if retriable_retry_count > MAX_RETRIABLE_RETRIES {
732                    return Err(CoreError::Process(format!(
733                        "Harness '{name}' crashed {count} consecutive times (exit code {code}); giving up",
734                        name = harness.name(),
735                        count = retriable_retry_count,
736                        code = run.exit_code
737                    )));
738                }
739                println!(
740                    "\n=== Harness process crashed (exit code {code}, attempt {count}/{max}). Retrying... ===\n",
741                    code = run.exit_code,
742                    count = retriable_retry_count,
743                    max = MAX_RETRIABLE_RETRIES
744                );
745                continue;
746            }
747
748            // Non-retriable non-zero exit: reset the consecutive crash counter.
749            retriable_retry_count = 0;
750
751            if opts.exit_on_error {
752                state.last_outcome = Some("harness-error".to_string());
753                state.last_failure = Some(render_harness_failure(
754                    harness.name().as_str(),
755                    run.exit_code,
756                    &run.stdout,
757                    &run.stderr,
758                ));
759                state.history.push(RalphHistoryEntry {
760                    timestamp: now_ms()?,
761                    duration: started.elapsed().as_millis() as i64,
762                    completion_promise_found: completion_found,
763                    file_changes_count,
764                    harness_exit_code: run.exit_code,
765                    completion_validated: false,
766                    effective_cwd: resolved_cwd.path.display().to_string(),
767                });
768                state.iteration = iteration;
769                save_state(effective_ito_path, &change_id, &state)?;
770                return Err(CoreError::Process(format!(
771                    "Harness '{name}' exited with code {code}",
772                    name = harness.name(),
773                    code = run.exit_code
774                )));
775            }
776
777            harness_error_count = harness_error_count.saturating_add(1);
778            if harness_error_count >= opts.error_threshold {
779                state.last_outcome = Some("harness-error-threshold".to_string());
780                state.last_failure = Some(render_harness_failure(
781                    harness.name().as_str(),
782                    run.exit_code,
783                    &run.stdout,
784                    &run.stderr,
785                ));
786                state.history.push(RalphHistoryEntry {
787                    timestamp: now_ms()?,
788                    duration: started.elapsed().as_millis() as i64,
789                    completion_promise_found: completion_found,
790                    file_changes_count,
791                    harness_exit_code: run.exit_code,
792                    completion_validated: false,
793                    effective_cwd: resolved_cwd.path.display().to_string(),
794                });
795                state.iteration = iteration;
796                save_state(effective_ito_path, &change_id, &state)?;
797                return Err(CoreError::Process(format!(
798                    "Harness '{name}' exceeded non-zero exit threshold ({count}/{threshold}); last exit code {code}",
799                    name = harness.name(),
800                    count = harness_error_count,
801                    threshold = opts.error_threshold,
802                    code = run.exit_code
803                )));
804            }
805
806            last_validation_failure = Some(render_harness_failure(
807                harness.name().as_str(),
808                run.exit_code,
809                &run.stdout,
810                &run.stderr,
811            ));
812            state.last_outcome = Some("harness-error".to_string());
813            state.last_failure = last_validation_failure.clone();
814            state.history.push(RalphHistoryEntry {
815                timestamp: now_ms()?,
816                duration: started.elapsed().as_millis() as i64,
817                completion_promise_found: completion_found,
818                file_changes_count,
819                harness_exit_code: run.exit_code,
820                completion_validated: false,
821                effective_cwd: resolved_cwd.path.display().to_string(),
822            });
823            state.iteration = iteration;
824            save_state(effective_ito_path, &change_id, &state)?;
825            println!(
826                "\n=== Harness exited with code {code} ({count}/{threshold}). Continuing to let Ralph fix it... ===\n",
827                code = run.exit_code,
828                count = harness_error_count,
829                threshold = opts.error_threshold
830            );
831            continue;
832        }
833
834        // Successful exit: reset both counters.
835        retriable_retry_count = 0;
836
837        if !opts.no_commit {
838            if file_changes_count > 0 {
839                commit_iteration(&process_runner, iteration, &resolved_cwd.path)?;
840            } else {
841                println!(
842                    "No git changes detected after iteration {iter}; skipping commit.",
843                    iter = iteration
844                );
845            }
846        }
847
848        let timestamp = now_ms()?;
849        let duration = started.elapsed().as_millis() as i64;
850        state.history.push(RalphHistoryEntry {
851            timestamp,
852            duration,
853            completion_promise_found: completion_found,
854            file_changes_count,
855            harness_exit_code: run.exit_code,
856            completion_validated: false,
857            effective_cwd: resolved_cwd.path.display().to_string(),
858        });
859        state.iteration = iteration;
860        state.last_outcome = Some("iteration-complete".to_string());
861        state.last_failure = None;
862        save_state(effective_ito_path, &change_id, &state)?;
863
864        if completion_found && iteration >= opts.min_iterations {
865            if opts.skip_validation {
866                state.last_outcome = Some("unvalidated-complete".to_string());
867                state.last_failure = None;
868                save_state(effective_ito_path, &change_id, &state)?;
869                println!("\n=== Warning: --skip-validation set. Completion is not verified. ===\n");
870                println!(
871                    "\n=== Completion promise \"{p}\" detected. Loop complete. ===\n",
872                    p = opts.completion_promise
873                );
874                return Ok(());
875            }
876
877            let change_id_opt = if unscoped_target {
878                None
879            } else {
880                Some(change_id.as_str())
881            };
882
883            // If we're running inside a resolved worktree for a specific change,
884            // always validate tasks against that worktree-local `.ito/` state.
885            let fs_task_repo;
886            let task_repo_for_validation: &dyn DomainTaskRepository =
887                if should_validate_tasks_from_effective_worktree(
888                    change_id_opt,
889                    ito_path,
890                    effective_ito_path,
891                ) {
892                    fs_task_repo = FsTaskRepository::new(effective_ito_path);
893                    &fs_task_repo
894                } else {
895                    task_repo
896                };
897
898            let report = validate_completion(
899                effective_ito_path,
900                task_repo_for_validation,
901                change_id_opt,
902                opts.validation_command.as_deref(),
903            )?;
904            if report.passed {
905                if let Some(last) = state.history.last_mut() {
906                    last.completion_validated = true;
907                }
908                state.last_outcome = Some("validated-complete".to_string());
909                state.last_failure = None;
910                save_state(effective_ito_path, &change_id, &state)?;
911                println!(
912                    "\n=== Completion promise \"{p}\" detected (validated). Loop complete. ===\n",
913                    p = opts.completion_promise
914                );
915                return Ok(());
916            }
917            last_validation_failure = Some(report.context_markdown);
918            state.last_outcome = Some("validation-rejected".to_string());
919            state.last_failure = last_validation_failure.clone();
920            save_state(effective_ito_path, &change_id, &state)?;
921            println!(
922                "\n=== Completion promise detected, but validation failed. Continuing... ===\n"
923            );
924        }
925    }
926
927    state.last_outcome = Some("max-iterations-exhausted".to_string());
928    save_state(effective_ito_path, &change_id, &state)?;
929
930    Ok(())
931}
932
933fn finalize_queue_results(
934    label: &str,
935    succeeded: &[String],
936    failed: &[(String, String)],
937) -> CoreResult<()> {
938    if succeeded.is_empty() && failed.is_empty() {
939        return Ok(());
940    }
941
942    println!("\n=== {label} Ralph Summary ===", label = label);
943    if !succeeded.is_empty() {
944        println!("Succeeded:");
945        for change in succeeded {
946            println!("  - {change}");
947        }
948    }
949    if !failed.is_empty() {
950        println!("Failed:");
951        for (change, reason) in failed {
952            println!("  - {change}: {reason}", change = change, reason = reason);
953        }
954    }
955
956    if failed.is_empty() {
957        return Ok(());
958    }
959
960    let failed_ids = failed
961        .iter()
962        .map(|(change, _)| change.as_str())
963        .collect::<Vec<_>>()
964        .join(", ");
965    Err(CoreError::Process(format!(
966        "{label} Ralph sweep completed with failures in: {failed_ids}",
967        label = label,
968        failed_ids = failed_ids
969    )))
970}
971
972fn module_changes(
973    change_repo: &(impl DomainChangeRepository + ?Sized),
974    module_id: &str,
975) -> CoreResult<Vec<ChangeSummary>> {
976    let changes = change_repo.list_by_module(module_id).into_core()?;
977    if changes.is_empty() {
978        return Err(CoreError::NotFound(format!(
979            "No changes found for module {module}",
980            module = module_id
981        )));
982    }
983    Ok(changes)
984}
985
986fn module_ready_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
987    let mut ready_change_ids = Vec::new();
988    for change in changes {
989        if change.is_ready() {
990            ready_change_ids.push(change.id.clone());
991        }
992    }
993    ready_change_ids
994}
995
996fn unprocessed_change_ids(change_ids: &[String], processed: &BTreeSet<String>) -> Vec<String> {
997    let mut filtered = Vec::new();
998    for change_id in change_ids {
999        if !processed.contains(change_id) {
1000            filtered.push(change_id.clone());
1001        }
1002    }
1003    filtered
1004}
1005
1006fn repo_changes(
1007    change_repo: &(impl DomainChangeRepository + ?Sized),
1008) -> CoreResult<Vec<ChangeSummary>> {
1009    change_repo.list().into_core()
1010}
1011
1012fn repo_eligible_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
1013    let mut eligible_change_ids = Vec::new();
1014    for change in changes {
1015        let work_status = change.work_status();
1016        if work_status == ChangeWorkStatus::Ready || work_status == ChangeWorkStatus::InProgress {
1017            eligible_change_ids.push(change.id.clone());
1018        }
1019    }
1020    eligible_change_ids.sort();
1021    eligible_change_ids
1022}
1023
1024fn repo_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
1025    let mut incomplete_change_ids = Vec::new();
1026    for change in changes {
1027        if change.work_status() != ChangeWorkStatus::Complete {
1028            incomplete_change_ids.push(change.id.clone());
1029        }
1030    }
1031    incomplete_change_ids.sort();
1032    incomplete_change_ids
1033}
1034
1035fn print_eligible_changes(eligible_changes: &[String]) {
1036    println!("\nEligible changes (ready or in-progress):");
1037    if eligible_changes.is_empty() {
1038        println!("  (none)");
1039        return;
1040    }
1041
1042    for (idx, change_id) in eligible_changes.iter().enumerate() {
1043        if idx == 0 {
1044            println!("  - {change} (selected first)", change = change_id);
1045            continue;
1046        }
1047        println!("  - {change}", change = change_id);
1048    }
1049}
1050
1051fn module_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
1052    let mut incomplete_change_ids = Vec::new();
1053    for change in changes {
1054        if change.work_status() != ChangeWorkStatus::Complete {
1055            incomplete_change_ids.push(change.id.clone());
1056        }
1057    }
1058    incomplete_change_ids
1059}
1060
1061fn print_ready_changes(module_id: &str, ready_changes: &[String]) {
1062    println!("\nReady changes for module {module}:", module = module_id);
1063    if ready_changes.is_empty() {
1064        println!("  (none)");
1065        return;
1066    }
1067
1068    for (idx, change_id) in ready_changes.iter().enumerate() {
1069        if idx == 0 {
1070            println!("  - {change} (selected first)", change = change_id);
1071            continue;
1072        }
1073        println!("  - {change}", change = change_id);
1074    }
1075}
1076
1077#[derive(Debug)]
1078struct CompletionValidationReport {
1079    passed: bool,
1080    context_markdown: String,
1081}
1082
1083fn validate_completion(
1084    ito_path: &Path,
1085    task_repo: &dyn DomainTaskRepository,
1086    change_id: Option<&str>,
1087    extra_command: Option<&str>,
1088) -> CoreResult<CompletionValidationReport> {
1089    let mut passed = true;
1090    let mut sections: Vec<String> = Vec::new();
1091
1092    if let Some(change_id) = change_id {
1093        let task = validation::check_task_completion(task_repo, change_id)?;
1094        sections.push(render_validation_result("Ito task status", &task));
1095        if !task.success {
1096            passed = false;
1097        }
1098
1099        // Audit consistency check (warning only, does not fail validation)
1100        let audit_report = crate::audit::run_reconcile(ito_path, Some(change_id), false);
1101        if !audit_report.drifts.is_empty() {
1102            let drift_lines: Vec<String> = audit_report
1103                .drifts
1104                .iter()
1105                .map(|d| format!("  - {d}"))
1106                .collect();
1107            sections.push(format!(
1108                "### Audit consistency\n\n- Result: WARN\n- Summary: {} drift items detected between audit log and file state\n\n{}",
1109                audit_report.drifts.len(),
1110                drift_lines.join("\n")
1111            ));
1112        }
1113    } else {
1114        sections.push(
1115            "### Ito task status\n\n- Result: SKIP\n- Summary: No change selected; skipped task validation"
1116                .to_string(),
1117        );
1118    }
1119
1120    let timeout = Duration::from_secs(5 * 60);
1121    let project = validation::run_project_validation(ito_path, timeout)?;
1122    sections.push(render_validation_result("Project validation", &project));
1123    if !project.success {
1124        passed = false;
1125    }
1126
1127    if let Some(cmd) = extra_command {
1128        let project_root = ito_path.parent().unwrap_or_else(|| Path::new("."));
1129        let extra = validation::run_extra_validation(project_root, cmd, timeout)?;
1130        sections.push(render_validation_result("Extra validation", &extra));
1131        if !extra.success {
1132            passed = false;
1133        }
1134    }
1135
1136    Ok(CompletionValidationReport {
1137        passed,
1138        context_markdown: sections.join("\n\n"),
1139    })
1140}
1141
1142fn should_validate_tasks_from_effective_worktree(
1143    change_id: Option<&str>,
1144    ito_path: &Path,
1145    effective_ito_path: &Path,
1146) -> bool {
1147    change_id.is_some() && effective_ito_path != ito_path
1148}
1149
1150fn render_validation_result(title: &str, r: &validation::ValidationResult) -> String {
1151    let mut md = String::new();
1152    md.push_str(&format!("### {title}\n\n"));
1153    md.push_str(&format!(
1154        "- Result: {}\n",
1155        if r.success { "PASS" } else { "FAIL" }
1156    ));
1157    md.push_str(&format!("- Summary: {}\n", r.message.trim()));
1158    if let Some(out) = r.output.as_deref() {
1159        let out = out.trim();
1160        if !out.is_empty() {
1161            md.push_str("\nOutput:\n\n```text\n");
1162            md.push_str(out);
1163            md.push_str("\n```\n");
1164        }
1165    }
1166    md
1167}
1168
1169fn render_harness_failure(name: &str, exit_code: i32, stdout: &str, stderr: &str) -> String {
1170    let mut md = String::new();
1171    md.push_str("### Harness execution\n\n");
1172    md.push_str("- Result: FAIL\n");
1173    md.push_str(&format!("- Harness: {name}\n"));
1174    md.push_str(&format!("- Exit code: {code}\n", code = exit_code));
1175
1176    let stdout = stdout.trim();
1177    if !stdout.is_empty() {
1178        md.push_str("\nStdout:\n\n```text\n");
1179        md.push_str(stdout);
1180        md.push_str("\n```\n");
1181    }
1182
1183    let stderr = stderr.trim();
1184    if !stderr.is_empty() {
1185        md.push_str("\nStderr:\n\n```text\n");
1186        md.push_str(stderr);
1187        md.push_str("\n```\n");
1188    }
1189
1190    md
1191}
1192
1193fn completion_promise_found(stdout: &str, token: &str) -> bool {
1194    let mut rest = stdout;
1195    loop {
1196        let Some(start) = rest.find("<promise>") else {
1197            return false;
1198        };
1199        let after_start = &rest[start + "<promise>".len()..];
1200        let Some(end) = after_start.find("</promise>") else {
1201            return false;
1202        };
1203        let inner = &after_start[..end];
1204        if inner.trim() == token {
1205            return true;
1206        }
1207
1208        rest = &after_start[end + "</promise>".len()..];
1209    }
1210}
1211
1212fn resolve_target(
1213    change_repo: &(impl DomainChangeRepository + ?Sized),
1214    change_id: Option<String>,
1215    module_id: Option<String>,
1216    interactive: bool,
1217) -> CoreResult<(String, String)> {
1218    // If change is provided, resolve canonical ID and infer module.
1219    if let Some(change) = change_id {
1220        let change = match change_repo.resolve_target(&change) {
1221            ChangeTargetResolution::Unique(id) => id,
1222            ChangeTargetResolution::Ambiguous(matches) => {
1223                return Err(CoreError::Validation(format!(
1224                    "Change '{change}' is ambiguous. Matches: {}",
1225                    matches.join(", ")
1226                )));
1227            }
1228            ChangeTargetResolution::NotFound => {
1229                return Err(CoreError::NotFound(format!("Change '{change}' not found")));
1230            }
1231        };
1232        let module = infer_module_from_change(&change)?;
1233        return Ok((change, module));
1234    }
1235
1236    if let Some(module) = module_id {
1237        let changes = change_repo.list_by_module(&module).into_core()?;
1238        if changes.is_empty() {
1239            return Err(CoreError::NotFound(format!(
1240                "No changes found for module {module}",
1241                module = module
1242            )));
1243        }
1244
1245        let ready_changes = module_ready_change_ids(&changes);
1246        if let Some(change_id) = ready_changes.first() {
1247            return Ok((change_id.clone(), infer_module_from_change(change_id)?));
1248        }
1249
1250        let incomplete = module_incomplete_change_ids(&changes);
1251
1252        if incomplete.is_empty() {
1253            return Err(CoreError::Validation(format!(
1254                "Module {module} has no ready changes because all changes are complete",
1255                module = module
1256            )));
1257        }
1258
1259        return Err(CoreError::Validation(format!(
1260            "Module {module} has no ready changes. Remaining non-complete changes: {}",
1261            incomplete.join(", "),
1262            module = module
1263        )));
1264    }
1265
1266    let msg = if interactive {
1267        "No change selected. Provide --change or --module (or run `ito ralph` interactively to select a change)."
1268    } else {
1269        "No change selected. Provide --change or --module."
1270    };
1271
1272    Err(CoreError::Validation(msg.into()))
1273}
1274
1275fn infer_module_from_change(change_id: &str) -> CoreResult<String> {
1276    let Some((module, _rest)) = change_id.split_once('-') else {
1277        return Err(CoreError::Validation(format!(
1278            "Invalid change ID format: {id}",
1279            id = change_id
1280        )));
1281    };
1282    Ok(module.to_string())
1283}
1284
1285fn now_ms() -> CoreResult<i64> {
1286    let dur = SystemTime::now()
1287        .duration_since(UNIX_EPOCH)
1288        .map_err(|e| CoreError::Process(format!("Clock error: {e}")))?;
1289    Ok(dur.as_millis() as i64)
1290}
1291
1292fn count_git_changes(runner: &dyn ProcessRunner, cwd: &Path) -> CoreResult<usize> {
1293    let request = ProcessRequest::new("git")
1294        .args(["status", "--porcelain"])
1295        .current_dir(cwd.to_path_buf());
1296    let out = runner
1297        .run(&request)
1298        .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
1299    if !out.success {
1300        // Match TS behavior: the git error output is visible to the user.
1301        let err = out.stderr;
1302        if !err.is_empty() {
1303            eprint!("{}", err);
1304        }
1305        return Ok(0);
1306    }
1307    let s = out.stdout;
1308    let mut line_count = 0;
1309    for line in s.lines() {
1310        if !line.trim().is_empty() {
1311            line_count += 1;
1312        }
1313    }
1314    Ok(line_count)
1315}
1316
1317fn commit_iteration(runner: &dyn ProcessRunner, iteration: u32, cwd: &Path) -> CoreResult<()> {
1318    let state_before_add = git_status_state(runner, cwd)?;
1319    if !state_before_add.has_working_tree_changes {
1320        return Ok(());
1321    }
1322
1323    let add_request = ProcessRequest::new("git")
1324        .args(["add", "-A"])
1325        .current_dir(cwd.to_path_buf());
1326    let add = runner
1327        .run(&add_request)
1328        .map_err(|e| CoreError::Process(format!("Failed to run git add: {e}")))?;
1329    if !add.success {
1330        let stdout = add.stdout.trim().to_string();
1331        let stderr = add.stderr.trim().to_string();
1332        let mut msg = String::from("git add failed");
1333        if !stdout.is_empty() {
1334            msg.push_str("\nstdout:\n");
1335            msg.push_str(&stdout);
1336        }
1337        if !stderr.is_empty() {
1338            msg.push_str("\nstderr:\n");
1339            msg.push_str(&stderr);
1340        }
1341        return Err(CoreError::Process(msg));
1342    }
1343
1344    let state_after_add = git_status_state(runner, cwd)?;
1345    if !state_after_add.has_staged_changes {
1346        return Ok(());
1347    }
1348
1349    let msg = format!("Ralph loop iteration {iteration}");
1350    let commit_request = ProcessRequest::new("git")
1351        .args(["commit", "-m", &msg])
1352        .current_dir(cwd.to_path_buf());
1353    let commit = runner
1354        .run(&commit_request)
1355        .map_err(|e| CoreError::Process(format!("Failed to run git commit: {e}")))?;
1356    if !commit.success {
1357        let stdout = commit.stdout.trim().to_string();
1358        let stderr = commit.stderr.trim().to_string();
1359
1360        let state_after_failed_commit = git_status_state(runner, cwd)?;
1361        if !state_after_failed_commit.has_staged_changes {
1362            return Ok(());
1363        }
1364
1365        let mut msg = format!("git commit failed for iteration {iteration}");
1366        if !stdout.is_empty() {
1367            msg.push_str("\nstdout:\n");
1368            msg.push_str(&stdout);
1369        }
1370        if !stderr.is_empty() {
1371            msg.push_str("\nstderr:\n");
1372            msg.push_str(&stderr);
1373        }
1374        return Err(CoreError::Process(msg));
1375    }
1376    Ok(())
1377}
1378
1379#[derive(Debug, Default, Clone, Copy)]
1380struct GitStatusState {
1381    has_staged_changes: bool,
1382    has_working_tree_changes: bool,
1383}
1384
1385fn git_status_state(runner: &dyn ProcessRunner, cwd: &Path) -> CoreResult<GitStatusState> {
1386    let request = ProcessRequest::new("git")
1387        .args(["status", "--porcelain"])
1388        .current_dir(cwd.to_path_buf());
1389    let out = runner
1390        .run(&request)
1391        .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
1392    if !out.success {
1393        let stdout = out.stdout.trim().to_string();
1394        let stderr = out.stderr.trim().to_string();
1395        let mut msg = String::from("git status failed");
1396        if !stdout.is_empty() {
1397            msg.push_str("\nstdout:\n");
1398            msg.push_str(&stdout);
1399        }
1400        if !stderr.is_empty() {
1401            msg.push_str("\nstderr:\n");
1402            msg.push_str(&stderr);
1403        }
1404        return Err(CoreError::Process(msg));
1405    }
1406
1407    let mut state = GitStatusState::default();
1408    for line in out.stdout.lines() {
1409        if line.trim().is_empty() {
1410            continue;
1411        }
1412
1413        state.has_working_tree_changes = true;
1414
1415        let mut chars = line.chars();
1416        let index_status = chars.next().unwrap_or(' ');
1417        if index_status != ' ' && index_status != '?' {
1418            state.has_staged_changes = true;
1419        }
1420    }
1421
1422    Ok(state)
1423}
1424
1425#[cfg(test)]
1426mod runner_tests;