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