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#[derive(Debug, Clone, Default)]
26pub struct WorktreeConfig {
27 pub enabled: bool,
29 pub dir_name: String,
35}
36
37#[derive(Debug, Clone)]
38pub struct RalphOptions {
40 pub prompt: String,
42
43 pub change_id: Option<String>,
45
46 pub module_id: Option<String>,
48
49 pub model: Option<String>,
51
52 pub min_iterations: u32,
54
55 pub max_iterations: Option<u32>,
57
58 pub completion_promise: String,
60
61 pub allow_all: bool,
63
64 pub no_commit: bool,
66
67 pub interactive: bool,
69
70 pub status: bool,
72
73 pub add_context: Option<String>,
75
76 pub clear_context: bool,
78
79 pub verbose: bool,
81
82 pub continue_module: bool,
84
85 pub continue_ready: bool,
89
90 pub inactivity_timeout: Option<Duration>,
92
93 pub skip_validation: bool,
97
98 pub validation_command: Option<String>,
102
103 pub exit_on_error: bool,
107
108 pub error_threshold: u32,
112
113 pub worktree: WorktreeConfig,
115}
116
117pub const DEFAULT_ERROR_THRESHOLD: u32 = 10;
119
120#[derive(Debug, Clone)]
125pub struct ResolvedCwd {
126 pub path: PathBuf,
128 pub ito_path: PathBuf,
131}
132
133pub 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
148fn 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
181pub fn run_ralph(
196 ito_path: &Path,
197 change_repo: &impl DomainChangeRepository,
198 task_repo: &impl DomainTaskRepository,
199 module_repo: &impl DomainModuleRepository,
200 opts: RalphOptions,
201 harness: &mut dyn Harness,
202) -> CoreResult<()> {
203 let process_runner = SystemProcessRunner;
204
205 if opts.continue_ready {
206 if opts.continue_module {
207 return Err(CoreError::Validation(
208 "--continue-ready cannot be used with --continue-module".into(),
209 ));
210 }
211 if opts.change_id.is_some() || opts.module_id.is_some() {
212 return Err(CoreError::Validation(
213 "--continue-ready cannot be used with --change or --module".into(),
214 ));
215 }
216 if opts.status || opts.add_context.is_some() || opts.clear_context {
217 return Err(CoreError::Validation(
218 "--continue-ready cannot be combined with --status, --add-context, or --clear-context".into(),
219 ));
220 }
221
222 loop {
223 let current_changes = repo_changes(change_repo)?;
224 let eligible_changes = repo_eligible_change_ids(¤t_changes);
225 print_eligible_changes(&eligible_changes);
226
227 if eligible_changes.is_empty() {
228 let incomplete = repo_incomplete_change_ids(¤t_changes);
229 if incomplete.is_empty() {
230 println!("\nAll changes are complete.");
231 return Ok(());
232 }
233
234 return Err(CoreError::Validation(format!(
235 "Repository has no eligible changes. Remaining non-complete changes: {}",
236 incomplete.join(", ")
237 )));
238 }
239
240 let mut next_change = eligible_changes[0].clone();
241
242 let preflight_changes = repo_changes(change_repo)?;
243 let preflight_eligible = repo_eligible_change_ids(&preflight_changes);
244 if preflight_eligible.is_empty() {
245 let incomplete = repo_incomplete_change_ids(&preflight_changes);
246 if incomplete.is_empty() {
247 println!("\nAll changes are complete.");
248 return Ok(());
249 }
250 return Err(CoreError::Validation(format!(
251 "Repository changed during selection and now has no eligible changes. Remaining non-complete changes: {}",
252 incomplete.join(", ")
253 )));
254 }
255 let preflight_first = preflight_eligible[0].clone();
256 if preflight_first != next_change {
257 println!(
258 "\nRepository state shifted before start; reorienting from {from} to {to}.",
259 from = next_change,
260 to = preflight_first
261 );
262 next_change = preflight_first;
263 }
264
265 println!(
266 "\nStarting change {change} (lowest eligible change id).",
267 change = next_change
268 );
269
270 let mut single_opts = opts.clone();
271 single_opts.continue_ready = false;
272 single_opts.change_id = Some(next_change);
273
274 run_ralph(
275 ito_path,
276 change_repo,
277 task_repo,
278 module_repo,
279 single_opts,
280 harness,
281 )?;
282 }
283 }
284
285 if opts.continue_module {
286 if opts.change_id.is_some() {
287 return Err(CoreError::Validation(
288 "--continue-module cannot be used with --change. Use --module only.".into(),
289 ));
290 }
291 let Some(module_id) = opts.module_id.clone() else {
292 return Err(CoreError::Validation(
293 "--continue-module requires --module".into(),
294 ));
295 };
296 if opts.status || opts.add_context.is_some() || opts.clear_context {
297 return Err(CoreError::Validation(
298 "--continue-module cannot be combined with --status, --add-context, or --clear-context".into()
299 ));
300 }
301
302 let mut processed: BTreeSet<String> = BTreeSet::new();
303
304 loop {
305 let current_changes = module_changes(change_repo, &module_id)?;
306 let ready_all = module_ready_change_ids(¤t_changes);
307 print_ready_changes(&module_id, &ready_all);
308
309 let ready_changes = unprocessed_change_ids(&ready_all, &processed);
311
312 if ready_changes.is_empty() {
313 if ready_all.is_empty() {
315 let incomplete = module_incomplete_change_ids(¤t_changes);
316
317 if incomplete.is_empty() {
318 println!("\nModule {module} is complete.", module = module_id);
319 return Ok(());
320 }
321
322 return Err(CoreError::Validation(format!(
323 "Module {module} has no ready changes. Remaining non-complete changes: {}",
324 incomplete.join(", "),
325 module = module_id
326 )));
327 }
328
329 println!(
332 "\nModule {module} has no additional ready changes (all ready changes were already processed in this run).",
333 module = module_id
334 );
335 return Ok(());
336 }
337
338 let mut next_change = ready_changes[0].clone();
339
340 let preflight_changes = module_changes(change_repo, &module_id)?;
341 let preflight_ready_all = module_ready_change_ids(&preflight_changes);
342 if preflight_ready_all.is_empty() {
343 let incomplete = module_incomplete_change_ids(&preflight_changes);
344 if incomplete.is_empty() {
345 println!("\nModule {module} is complete.", module = module_id);
346 return Ok(());
347 }
348 return Err(CoreError::Validation(format!(
349 "Module {module} changed during selection and now has no ready changes. Remaining non-complete changes: {}",
350 incomplete.join(", "),
351 module = module_id
352 )));
353 }
354
355 let preflight_ready = unprocessed_change_ids(&preflight_ready_all, &processed);
356
357 if preflight_ready.is_empty() {
358 println!(
359 "\nModule {module} has no additional ready changes (all ready changes were already processed in this run).",
360 module = module_id
361 );
362 return Ok(());
363 }
364
365 let preflight_first = preflight_ready[0].clone();
366 if preflight_first != next_change {
367 println!(
368 "\nModule state shifted before start; reorienting from {from} to {to}.",
369 from = next_change,
370 to = preflight_first
371 );
372 next_change = preflight_first;
373 }
374
375 println!(
376 "\nStarting module change {change} (lowest ready change id).",
377 change = next_change
378 );
379
380 let mut single_opts = opts.clone();
381 single_opts.continue_module = false;
382 single_opts.continue_ready = false;
383 single_opts.change_id = Some(next_change.clone());
384
385 run_ralph(
386 ito_path,
387 change_repo,
388 task_repo,
389 module_repo,
390 single_opts,
391 harness,
392 )?;
393
394 processed.insert(next_change);
396
397 let post_changes = module_changes(change_repo, &module_id)?;
398 let post_ready = module_ready_change_ids(&post_changes);
399 print_ready_changes(&module_id, &post_ready);
400 }
401 }
402
403 if opts.change_id.is_none()
404 && let Some(module_id) = opts.module_id.as_deref()
405 && !opts.status
406 && opts.add_context.is_none()
407 && !opts.clear_context
408 {
409 let module_changes = module_changes(change_repo, module_id)?;
410 let ready_changes = module_ready_change_ids(&module_changes);
411 print_ready_changes(module_id, &ready_changes);
412 }
413
414 let unscoped_target = opts.change_id.is_none() && opts.module_id.is_none();
415
416 let resolved_cwd = resolve_effective_cwd(ito_path, opts.change_id.as_deref(), &opts.worktree);
419 let effective_ito_path = &resolved_cwd.ito_path;
420
421 if opts.verbose {
422 if effective_ito_path != ito_path {
423 println!("Resolved worktree: {}", resolved_cwd.path.display());
424 } else {
425 println!(
426 "Using current working directory: {}",
427 resolved_cwd.path.display()
428 );
429 }
430 }
431
432 let (change_id, module_id) = if unscoped_target {
433 ("unscoped".to_string(), "unscoped".to_string())
434 } else {
435 resolve_target(
436 change_repo,
437 opts.change_id,
438 opts.module_id,
439 opts.interactive,
440 )?
441 };
442
443 if opts.status {
444 let state = load_state(effective_ito_path, &change_id)?;
445 if let Some(state) = state {
446 println!("\n=== Ralph Status for {id} ===\n", id = state.change_id);
447 println!("Iteration: {iter}", iter = state.iteration);
448 println!("History entries: {n}", n = state.history.len());
449 if !state.history.is_empty() {
450 println!("\nRecent iterations:");
451 let n = state.history.len();
452 let start = n.saturating_sub(5);
453 for (i, h) in state.history.iter().enumerate().skip(start) {
454 println!(
455 " {idx}: duration={dur}ms, changes={chg}, promise={p}",
456 idx = i + 1,
457 dur = h.duration,
458 chg = h.file_changes_count,
459 p = h.completion_promise_found
460 );
461 }
462 }
463 } else {
464 println!("\n=== Ralph Status for {id} ===\n", id = change_id);
465 println!("No state found");
466 }
467 return Ok(());
468 }
469
470 if let Some(text) = opts.add_context.as_deref() {
471 append_context(effective_ito_path, &change_id, text)?;
472 println!("Added context to {id}", id = change_id);
473 return Ok(());
474 }
475 if opts.clear_context {
476 clear_context(effective_ito_path, &change_id)?;
477 println!("Cleared Ralph context for {id}", id = change_id);
478 return Ok(());
479 }
480
481 let ito_dir_name = effective_ito_path
482 .file_name()
483 .map(|s| s.to_string_lossy().to_string())
484 .unwrap_or_else(|| ".ito".to_string());
485 let context_file = format!(
486 "{ito_dir}/.state/ralph/{change}/context.md",
487 ito_dir = ito_dir_name,
488 change = change_id
489 );
490
491 let mut state = load_state(effective_ito_path, &change_id)?.unwrap_or(RalphState {
492 change_id: change_id.clone(),
493 iteration: 0,
494 history: vec![],
495 context_file,
496 });
497
498 let max_iters = opts.max_iterations.unwrap_or(u32::MAX);
499 if max_iters == 0 {
500 return Err(CoreError::Validation(
501 "--max-iterations must be >= 1".into(),
502 ));
503 }
504 if opts.error_threshold == 0 {
505 return Err(CoreError::Validation(
506 "--error-threshold must be >= 1".into(),
507 ));
508 }
509
510 println!(
512 "\n=== Starting Ralph for {change} (harness: {harness}) ===",
513 change = change_id,
514 harness = harness.name()
515 );
516 if let Some(model) = &opts.model {
517 println!("Model: {model}");
518 }
519 if let Some(max) = opts.max_iterations {
520 println!("Max iterations: {max}");
521 }
522 if opts.allow_all {
523 println!("Mode: --yolo (auto-approve all)");
524 }
525 if let Some(timeout) = opts.inactivity_timeout {
526 println!("Inactivity timeout: {}", format_duration(timeout));
527 }
528 println!();
529
530 let mut last_validation_failure: Option<String> = None;
531 let mut harness_error_count: u32 = 0;
532 let mut retriable_retry_count: u32 = 0;
533
534 for _ in 0..max_iters {
535 let iteration = state.iteration.saturating_add(1);
536
537 println!("\n=== Ralph Loop Iteration {i} ===\n", i = iteration);
538
539 let context_content = load_context(effective_ito_path, &change_id)?;
540 let prompt = build_ralph_prompt(
541 effective_ito_path,
542 change_repo,
543 module_repo,
544 &opts.prompt,
545 BuildPromptOptions {
546 change_id: if unscoped_target {
547 None
548 } else {
549 Some(change_id.clone())
550 },
551 module_id: if unscoped_target {
552 None
553 } else {
554 Some(module_id.clone())
555 },
556 iteration: Some(iteration),
557 max_iterations: opts.max_iterations,
558 min_iterations: opts.min_iterations,
559 completion_promise: opts.completion_promise.clone(),
560 context_content: Some(context_content),
561 validation_failure: last_validation_failure.clone(),
562 },
563 )?;
564
565 if opts.verbose {
566 println!("--- Prompt sent to harness ---");
567 println!("{}", prompt);
568 println!("--- End of prompt ---\n");
569 }
570
571 let started = std::time::Instant::now();
572 let run = harness
573 .run(&crate::harness::HarnessRunConfig {
574 prompt,
575 model: opts.model.clone(),
576 cwd: resolved_cwd.path.clone(),
577 env: std::collections::BTreeMap::new(),
578 interactive: opts.interactive && !opts.allow_all,
579 allow_all: opts.allow_all,
580 inactivity_timeout: opts.inactivity_timeout,
581 })
582 .map_err(|e| CoreError::Process(format!("Harness execution failed: {e}")))?;
583
584 if !harness.streams_output() {
586 if !run.stdout.is_empty() {
587 print!("{}", run.stdout);
588 }
589 if !run.stderr.is_empty() {
590 eprint!("{}", run.stderr);
591 }
592 }
593
594 let completion_found = completion_promise_found(&run.stdout, &opts.completion_promise);
596
597 let file_changes_count = if harness.name() != HarnessName::Stub {
598 count_git_changes(&process_runner, &resolved_cwd.path)? as u32
599 } else {
600 0
601 };
602
603 if run.timed_out {
605 println!("\n=== Inactivity timeout reached. Restarting iteration... ===\n");
606 retriable_retry_count = 0;
607 continue;
609 }
610
611 if run.exit_code != 0 {
612 if run.is_retriable() {
613 retriable_retry_count = retriable_retry_count.saturating_add(1);
614 if retriable_retry_count > MAX_RETRIABLE_RETRIES {
615 return Err(CoreError::Process(format!(
616 "Harness '{name}' crashed {count} consecutive times (exit code {code}); giving up",
617 name = harness.name(),
618 count = retriable_retry_count,
619 code = run.exit_code
620 )));
621 }
622 println!(
623 "\n=== Harness process crashed (exit code {code}, attempt {count}/{max}). Retrying... ===\n",
624 code = run.exit_code,
625 count = retriable_retry_count,
626 max = MAX_RETRIABLE_RETRIES
627 );
628 continue;
629 }
630
631 retriable_retry_count = 0;
633
634 if opts.exit_on_error {
635 return Err(CoreError::Process(format!(
636 "Harness '{name}' exited with code {code}",
637 name = harness.name(),
638 code = run.exit_code
639 )));
640 }
641
642 harness_error_count = harness_error_count.saturating_add(1);
643 if harness_error_count >= opts.error_threshold {
644 return Err(CoreError::Process(format!(
645 "Harness '{name}' exceeded non-zero exit threshold ({count}/{threshold}); last exit code {code}",
646 name = harness.name(),
647 count = harness_error_count,
648 threshold = opts.error_threshold,
649 code = run.exit_code
650 )));
651 }
652
653 last_validation_failure = Some(render_harness_failure(
654 harness.name().as_str(),
655 run.exit_code,
656 &run.stdout,
657 &run.stderr,
658 ));
659 println!(
660 "\n=== Harness exited with code {code} ({count}/{threshold}). Continuing to let Ralph fix it... ===\n",
661 code = run.exit_code,
662 count = harness_error_count,
663 threshold = opts.error_threshold
664 );
665 continue;
666 }
667
668 retriable_retry_count = 0;
670
671 if !opts.no_commit {
672 if file_changes_count > 0 {
673 commit_iteration(&process_runner, iteration, &resolved_cwd.path)?;
674 } else {
675 println!(
676 "No git changes detected after iteration {iter}; skipping commit.",
677 iter = iteration
678 );
679 }
680 }
681
682 let timestamp = now_ms()?;
683 let duration = started.elapsed().as_millis() as i64;
684 state.history.push(RalphHistoryEntry {
685 timestamp,
686 duration,
687 completion_promise_found: completion_found,
688 file_changes_count,
689 });
690 state.iteration = iteration;
691 save_state(effective_ito_path, &change_id, &state)?;
692
693 if completion_found && iteration >= opts.min_iterations {
694 if opts.skip_validation {
695 println!("\n=== Warning: --skip-validation set. Completion is not verified. ===\n");
696 println!(
697 "\n=== Completion promise \"{p}\" detected. Loop complete. ===\n",
698 p = opts.completion_promise
699 );
700 return Ok(());
701 }
702
703 let change_id_opt = if unscoped_target {
704 None
705 } else {
706 Some(change_id.as_str())
707 };
708
709 let fs_task_repo;
712 let task_repo_for_validation: &dyn DomainTaskRepository =
713 if should_validate_tasks_from_effective_worktree(
714 change_id_opt,
715 ito_path,
716 effective_ito_path,
717 ) {
718 fs_task_repo = FsTaskRepository::new(effective_ito_path);
719 &fs_task_repo
720 } else {
721 task_repo
722 };
723
724 let report = validate_completion(
725 effective_ito_path,
726 task_repo_for_validation,
727 change_id_opt,
728 opts.validation_command.as_deref(),
729 )?;
730 if report.passed {
731 println!(
732 "\n=== Completion promise \"{p}\" detected (validated). Loop complete. ===\n",
733 p = opts.completion_promise
734 );
735 return Ok(());
736 }
737
738 last_validation_failure = Some(report.context_markdown);
739 println!(
740 "\n=== Completion promise detected, but validation failed. Continuing... ===\n"
741 );
742 }
743 }
744
745 Ok(())
746}
747
748fn module_changes(
749 change_repo: &impl DomainChangeRepository,
750 module_id: &str,
751) -> CoreResult<Vec<ChangeSummary>> {
752 let changes = change_repo.list_by_module(module_id).into_core()?;
753 if changes.is_empty() {
754 return Err(CoreError::NotFound(format!(
755 "No changes found for module {module}",
756 module = module_id
757 )));
758 }
759 Ok(changes)
760}
761
762fn module_ready_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
763 let mut ready_change_ids = Vec::new();
764 for change in changes {
765 if change.is_ready() {
766 ready_change_ids.push(change.id.clone());
767 }
768 }
769 ready_change_ids
770}
771
772fn unprocessed_change_ids(change_ids: &[String], processed: &BTreeSet<String>) -> Vec<String> {
773 let mut filtered = Vec::new();
774 for change_id in change_ids {
775 if !processed.contains(change_id) {
776 filtered.push(change_id.clone());
777 }
778 }
779 filtered
780}
781
782fn repo_changes(change_repo: &impl DomainChangeRepository) -> 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 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,
988 change_id: Option<String>,
989 module_id: Option<String>,
990 interactive: bool,
991) -> CoreResult<(String, String)> {
992 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 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;