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