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