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