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 ito_domain::changes::{
14 ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
15 ChangeWorkStatus,
16};
17use ito_domain::modules::ModuleRepository as DomainModuleRepository;
18use ito_domain::tasks::TaskRepository as DomainTaskRepository;
19use std::path::{Path, PathBuf};
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22#[derive(Debug, Clone, Default)]
24pub struct WorktreeConfig {
25 pub enabled: bool,
27 pub dir_name: String,
33}
34
35#[derive(Debug, Clone)]
36pub struct RalphOptions {
38 pub prompt: String,
40
41 pub change_id: Option<String>,
43
44 pub module_id: Option<String>,
46
47 pub model: Option<String>,
49
50 pub min_iterations: u32,
52
53 pub max_iterations: Option<u32>,
55
56 pub completion_promise: String,
58
59 pub allow_all: bool,
61
62 pub no_commit: bool,
64
65 pub interactive: bool,
67
68 pub status: bool,
70
71 pub add_context: Option<String>,
73
74 pub clear_context: bool,
76
77 pub verbose: bool,
79
80 pub continue_module: bool,
82
83 pub continue_ready: bool,
87
88 pub inactivity_timeout: Option<Duration>,
90
91 pub skip_validation: bool,
95
96 pub validation_command: Option<String>,
100
101 pub exit_on_error: bool,
105
106 pub error_threshold: u32,
110
111 pub worktree: WorktreeConfig,
113}
114
115pub const DEFAULT_ERROR_THRESHOLD: u32 = 10;
117
118#[derive(Debug, Clone)]
123pub struct ResolvedCwd {
124 pub path: PathBuf,
126 pub ito_path: PathBuf,
129}
130
131pub fn resolve_effective_cwd(
137 ito_path: &Path,
138 change_id: Option<&str>,
139 worktree: &WorktreeConfig,
140) -> ResolvedCwd {
141 let lookup = |branch: &str| crate::audit::worktree::find_worktree_for_branch(branch);
142 let fallback_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
143 resolve_effective_cwd_with(ito_path, change_id, worktree, fallback_path, lookup)
144}
145
146fn resolve_effective_cwd_with(
151 ito_path: &Path,
152 change_id: Option<&str>,
153 worktree: &WorktreeConfig,
154 fallback_path: PathBuf,
155 lookup: impl Fn(&str) -> Option<PathBuf>,
156) -> ResolvedCwd {
157 let fallback = ResolvedCwd {
158 path: fallback_path,
159 ito_path: ito_path.to_path_buf(),
160 };
161
162 let wt_path = if worktree.enabled {
163 change_id.and_then(lookup)
164 } else {
165 None
166 };
167
168 let Some(wt_path) = wt_path else {
169 return fallback;
170 };
171
172 let wt_ito_path = wt_path.join(".ito");
173 ResolvedCwd {
174 path: wt_path,
175 ito_path: wt_ito_path,
176 }
177}
178
179pub fn run_ralph(
194 ito_path: &Path,
195 change_repo: &impl DomainChangeRepository,
196 task_repo: &impl DomainTaskRepository,
197 module_repo: &impl DomainModuleRepository,
198 opts: RalphOptions,
199 harness: &mut dyn Harness,
200) -> CoreResult<()> {
201 let process_runner = SystemProcessRunner;
202
203 if opts.continue_ready {
204 if opts.continue_module {
205 return Err(CoreError::Validation(
206 "--continue-ready cannot be used with --continue-module".into(),
207 ));
208 }
209 if opts.change_id.is_some() || opts.module_id.is_some() {
210 return Err(CoreError::Validation(
211 "--continue-ready cannot be used with --change or --module".into(),
212 ));
213 }
214 if opts.status || opts.add_context.is_some() || opts.clear_context {
215 return Err(CoreError::Validation(
216 "--continue-ready cannot be combined with --status, --add-context, or --clear-context".into(),
217 ));
218 }
219
220 loop {
221 let current_changes = repo_changes(change_repo)?;
222 let eligible_changes = repo_eligible_change_ids(¤t_changes);
223 print_eligible_changes(&eligible_changes);
224
225 if eligible_changes.is_empty() {
226 let incomplete = repo_incomplete_change_ids(¤t_changes);
227 if incomplete.is_empty() {
228 println!("\nAll changes are complete.");
229 return Ok(());
230 }
231
232 return Err(CoreError::Validation(format!(
233 "Repository has no eligible changes. Remaining non-complete changes: {}",
234 incomplete.join(", ")
235 )));
236 }
237
238 let mut next_change = eligible_changes[0].clone();
239
240 let preflight_changes = repo_changes(change_repo)?;
241 let preflight_eligible = repo_eligible_change_ids(&preflight_changes);
242 if preflight_eligible.is_empty() {
243 let incomplete = repo_incomplete_change_ids(&preflight_changes);
244 if incomplete.is_empty() {
245 println!("\nAll changes are complete.");
246 return Ok(());
247 }
248 return Err(CoreError::Validation(format!(
249 "Repository changed during selection and now has no eligible changes. Remaining non-complete changes: {}",
250 incomplete.join(", ")
251 )));
252 }
253 let preflight_first = preflight_eligible[0].clone();
254 if preflight_first != next_change {
255 println!(
256 "\nRepository state shifted before start; reorienting from {from} to {to}.",
257 from = next_change,
258 to = preflight_first
259 );
260 next_change = preflight_first;
261 }
262
263 println!(
264 "\nStarting change {change} (lowest eligible change id).",
265 change = next_change
266 );
267
268 let mut single_opts = opts.clone();
269 single_opts.continue_ready = false;
270 single_opts.change_id = Some(next_change);
271
272 run_ralph(
273 ito_path,
274 change_repo,
275 task_repo,
276 module_repo,
277 single_opts,
278 harness,
279 )?;
280 }
281 }
282
283 if opts.continue_module {
284 if opts.change_id.is_some() {
285 return Err(CoreError::Validation(
286 "--continue-module cannot be used with --change. Use --module only.".into(),
287 ));
288 }
289 let Some(module_id) = opts.module_id.clone() else {
290 return Err(CoreError::Validation(
291 "--continue-module requires --module".into(),
292 ));
293 };
294 if opts.status || opts.add_context.is_some() || opts.clear_context {
295 return Err(CoreError::Validation(
296 "--continue-module cannot be combined with --status, --add-context, or --clear-context".into()
297 ));
298 }
299
300 loop {
301 let current_changes = module_changes(change_repo, &module_id)?;
302 let ready_changes = module_ready_change_ids(¤t_changes);
303 print_ready_changes(&module_id, &ready_changes);
304
305 if ready_changes.is_empty() {
306 let incomplete = module_incomplete_change_ids(¤t_changes);
307
308 if incomplete.is_empty() {
309 println!("\nModule {module} is complete.", module = module_id);
310 return Ok(());
311 }
312
313 return Err(CoreError::Validation(format!(
314 "Module {module} has no ready changes. Remaining non-complete changes: {}",
315 incomplete.join(", "),
316 module = module_id
317 )));
318 }
319
320 let mut next_change = ready_changes[0].clone();
321
322 let preflight_changes = module_changes(change_repo, &module_id)?;
323 let preflight_ready = module_ready_change_ids(&preflight_changes);
324 if preflight_ready.is_empty() {
325 let incomplete = module_incomplete_change_ids(&preflight_changes);
326 if incomplete.is_empty() {
327 println!("\nModule {module} is complete.", module = module_id);
328 return Ok(());
329 }
330 return Err(CoreError::Validation(format!(
331 "Module {module} changed during selection and now has no ready changes. Remaining non-complete changes: {}",
332 incomplete.join(", "),
333 module = module_id
334 )));
335 }
336 let preflight_first = preflight_ready[0].clone();
337 if preflight_first != next_change {
338 println!(
339 "\nModule state shifted before start; reorienting from {from} to {to}.",
340 from = next_change,
341 to = preflight_first
342 );
343 next_change = preflight_first;
344 }
345
346 println!(
347 "\nStarting module change {change} (lowest ready change id).",
348 change = next_change
349 );
350
351 let mut single_opts = opts.clone();
352 single_opts.continue_module = false;
353 single_opts.continue_ready = false;
354 single_opts.change_id = Some(next_change);
355
356 run_ralph(
357 ito_path,
358 change_repo,
359 task_repo,
360 module_repo,
361 single_opts,
362 harness,
363 )?;
364
365 let post_changes = module_changes(change_repo, &module_id)?;
366 let post_ready = module_ready_change_ids(&post_changes);
367 print_ready_changes(&module_id, &post_ready);
368 }
369 }
370
371 if opts.change_id.is_none()
372 && let Some(module_id) = opts.module_id.as_deref()
373 && !opts.status
374 && opts.add_context.is_none()
375 && !opts.clear_context
376 {
377 let module_changes = module_changes(change_repo, module_id)?;
378 let ready_changes = module_ready_change_ids(&module_changes);
379 print_ready_changes(module_id, &ready_changes);
380 }
381
382 let unscoped_target = opts.change_id.is_none() && opts.module_id.is_none();
383
384 let resolved_cwd = resolve_effective_cwd(ito_path, opts.change_id.as_deref(), &opts.worktree);
387 let effective_ito_path = &resolved_cwd.ito_path;
388
389 if opts.verbose {
390 if effective_ito_path != ito_path {
391 println!("Resolved worktree: {}", resolved_cwd.path.display());
392 } else {
393 println!(
394 "Using current working directory: {}",
395 resolved_cwd.path.display()
396 );
397 }
398 }
399
400 let (change_id, module_id) = if unscoped_target {
401 ("unscoped".to_string(), "unscoped".to_string())
402 } else {
403 resolve_target(
404 change_repo,
405 opts.change_id,
406 opts.module_id,
407 opts.interactive,
408 )?
409 };
410
411 if opts.status {
412 let state = load_state(effective_ito_path, &change_id)?;
413 if let Some(state) = state {
414 println!("\n=== Ralph Status for {id} ===\n", id = state.change_id);
415 println!("Iteration: {iter}", iter = state.iteration);
416 println!("History entries: {n}", n = state.history.len());
417 if !state.history.is_empty() {
418 println!("\nRecent iterations:");
419 let n = state.history.len();
420 let start = n.saturating_sub(5);
421 for (i, h) in state.history.iter().enumerate().skip(start) {
422 println!(
423 " {idx}: duration={dur}ms, changes={chg}, promise={p}",
424 idx = i + 1,
425 dur = h.duration,
426 chg = h.file_changes_count,
427 p = h.completion_promise_found
428 );
429 }
430 }
431 } else {
432 println!("\n=== Ralph Status for {id} ===\n", id = change_id);
433 println!("No state found");
434 }
435 return Ok(());
436 }
437
438 if let Some(text) = opts.add_context.as_deref() {
439 append_context(effective_ito_path, &change_id, text)?;
440 println!("Added context to {id}", id = change_id);
441 return Ok(());
442 }
443 if opts.clear_context {
444 clear_context(effective_ito_path, &change_id)?;
445 println!("Cleared Ralph context for {id}", id = change_id);
446 return Ok(());
447 }
448
449 let ito_dir_name = effective_ito_path
450 .file_name()
451 .map(|s| s.to_string_lossy().to_string())
452 .unwrap_or_else(|| ".ito".to_string());
453 let context_file = format!(
454 "{ito_dir}/.state/ralph/{change}/context.md",
455 ito_dir = ito_dir_name,
456 change = change_id
457 );
458
459 let mut state = load_state(effective_ito_path, &change_id)?.unwrap_or(RalphState {
460 change_id: change_id.clone(),
461 iteration: 0,
462 history: vec![],
463 context_file,
464 });
465
466 let max_iters = opts.max_iterations.unwrap_or(u32::MAX);
467 if max_iters == 0 {
468 return Err(CoreError::Validation(
469 "--max-iterations must be >= 1".into(),
470 ));
471 }
472 if opts.error_threshold == 0 {
473 return Err(CoreError::Validation(
474 "--error-threshold must be >= 1".into(),
475 ));
476 }
477
478 println!(
480 "\n=== Starting Ralph for {change} (harness: {harness}) ===",
481 change = change_id,
482 harness = harness.name()
483 );
484 if let Some(model) = &opts.model {
485 println!("Model: {model}");
486 }
487 if let Some(max) = opts.max_iterations {
488 println!("Max iterations: {max}");
489 }
490 if opts.allow_all {
491 println!("Mode: --yolo (auto-approve all)");
492 }
493 if let Some(timeout) = opts.inactivity_timeout {
494 println!("Inactivity timeout: {}", format_duration(timeout));
495 }
496 println!();
497
498 let mut last_validation_failure: Option<String> = None;
499 let mut harness_error_count: u32 = 0;
500 let mut retriable_retry_count: u32 = 0;
501
502 for _ in 0..max_iters {
503 let iteration = state.iteration.saturating_add(1);
504
505 println!("\n=== Ralph Loop Iteration {i} ===\n", i = iteration);
506
507 let context_content = load_context(effective_ito_path, &change_id)?;
508 let prompt = build_ralph_prompt(
509 effective_ito_path,
510 change_repo,
511 module_repo,
512 &opts.prompt,
513 BuildPromptOptions {
514 change_id: if unscoped_target {
515 None
516 } else {
517 Some(change_id.clone())
518 },
519 module_id: if unscoped_target {
520 None
521 } else {
522 Some(module_id.clone())
523 },
524 iteration: Some(iteration),
525 max_iterations: opts.max_iterations,
526 min_iterations: opts.min_iterations,
527 completion_promise: opts.completion_promise.clone(),
528 context_content: Some(context_content),
529 validation_failure: last_validation_failure.clone(),
530 },
531 )?;
532
533 if opts.verbose {
534 println!("--- Prompt sent to harness ---");
535 println!("{}", prompt);
536 println!("--- End of prompt ---\n");
537 }
538
539 let started = std::time::Instant::now();
540 let run = harness
541 .run(&crate::harness::HarnessRunConfig {
542 prompt,
543 model: opts.model.clone(),
544 cwd: resolved_cwd.path.clone(),
545 env: std::collections::BTreeMap::new(),
546 interactive: opts.interactive && !opts.allow_all,
547 allow_all: opts.allow_all,
548 inactivity_timeout: opts.inactivity_timeout,
549 })
550 .map_err(|e| CoreError::Process(format!("Harness execution failed: {e}")))?;
551
552 if !harness.streams_output() {
554 if !run.stdout.is_empty() {
555 print!("{}", run.stdout);
556 }
557 if !run.stderr.is_empty() {
558 eprint!("{}", run.stderr);
559 }
560 }
561
562 let completion_found = completion_promise_found(&run.stdout, &opts.completion_promise);
564
565 let file_changes_count = if harness.name() != HarnessName::Stub {
566 count_git_changes(&process_runner, &resolved_cwd.path)? as u32
567 } else {
568 0
569 };
570
571 if run.timed_out {
573 println!("\n=== Inactivity timeout reached. Restarting iteration... ===\n");
574 retriable_retry_count = 0;
575 continue;
577 }
578
579 if run.exit_code != 0 {
580 if run.is_retriable() {
581 retriable_retry_count = retriable_retry_count.saturating_add(1);
582 if retriable_retry_count > MAX_RETRIABLE_RETRIES {
583 return Err(CoreError::Process(format!(
584 "Harness '{name}' crashed {count} consecutive times (exit code {code}); giving up",
585 name = harness.name(),
586 count = retriable_retry_count,
587 code = run.exit_code
588 )));
589 }
590 println!(
591 "\n=== Harness process crashed (exit code {code}, attempt {count}/{max}). Retrying... ===\n",
592 code = run.exit_code,
593 count = retriable_retry_count,
594 max = MAX_RETRIABLE_RETRIES
595 );
596 continue;
597 }
598
599 retriable_retry_count = 0;
601
602 if opts.exit_on_error {
603 return Err(CoreError::Process(format!(
604 "Harness '{name}' exited with code {code}",
605 name = harness.name(),
606 code = run.exit_code
607 )));
608 }
609
610 harness_error_count = harness_error_count.saturating_add(1);
611 if harness_error_count >= opts.error_threshold {
612 return Err(CoreError::Process(format!(
613 "Harness '{name}' exceeded non-zero exit threshold ({count}/{threshold}); last exit code {code}",
614 name = harness.name(),
615 count = harness_error_count,
616 threshold = opts.error_threshold,
617 code = run.exit_code
618 )));
619 }
620
621 last_validation_failure = Some(render_harness_failure(
622 harness.name().as_str(),
623 run.exit_code,
624 &run.stdout,
625 &run.stderr,
626 ));
627 println!(
628 "\n=== Harness exited with code {code} ({count}/{threshold}). Continuing to let Ralph fix it... ===\n",
629 code = run.exit_code,
630 count = harness_error_count,
631 threshold = opts.error_threshold
632 );
633 continue;
634 }
635
636 retriable_retry_count = 0;
638
639 if !opts.no_commit {
640 if file_changes_count > 0 {
641 commit_iteration(&process_runner, iteration, &resolved_cwd.path)?;
642 } else {
643 println!(
644 "No git changes detected after iteration {iter}; skipping commit.",
645 iter = iteration
646 );
647 }
648 }
649
650 let timestamp = now_ms()?;
651 let duration = started.elapsed().as_millis() as i64;
652 state.history.push(RalphHistoryEntry {
653 timestamp,
654 duration,
655 completion_promise_found: completion_found,
656 file_changes_count,
657 });
658 state.iteration = iteration;
659 save_state(effective_ito_path, &change_id, &state)?;
660
661 if completion_found && iteration >= opts.min_iterations {
662 if opts.skip_validation {
663 println!("\n=== Warning: --skip-validation set. Completion is not verified. ===\n");
664 println!(
665 "\n=== Completion promise \"{p}\" detected. Loop complete. ===\n",
666 p = opts.completion_promise
667 );
668 return Ok(());
669 }
670
671 let report = validate_completion(
672 effective_ito_path,
673 task_repo,
674 if unscoped_target {
675 None
676 } else {
677 Some(change_id.as_str())
678 },
679 opts.validation_command.as_deref(),
680 )?;
681 if report.passed {
682 println!(
683 "\n=== Completion promise \"{p}\" detected (validated). Loop complete. ===\n",
684 p = opts.completion_promise
685 );
686 return Ok(());
687 }
688
689 last_validation_failure = Some(report.context_markdown);
690 println!(
691 "\n=== Completion promise detected, but validation failed. Continuing... ===\n"
692 );
693 }
694 }
695
696 Ok(())
697}
698
699fn module_changes(
700 change_repo: &impl DomainChangeRepository,
701 module_id: &str,
702) -> CoreResult<Vec<ChangeSummary>> {
703 let changes = change_repo.list_by_module(module_id).into_core()?;
704 if changes.is_empty() {
705 return Err(CoreError::NotFound(format!(
706 "No changes found for module {module}",
707 module = module_id
708 )));
709 }
710 Ok(changes)
711}
712
713fn module_ready_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
714 let mut ready_change_ids = Vec::new();
715 for change in changes {
716 if change.is_ready() {
717 ready_change_ids.push(change.id.clone());
718 }
719 }
720 ready_change_ids
721}
722
723fn repo_changes(change_repo: &impl DomainChangeRepository) -> CoreResult<Vec<ChangeSummary>> {
724 change_repo.list().into_core()
725}
726
727fn repo_eligible_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
728 let mut eligible_change_ids = Vec::new();
729 for change in changes {
730 let work_status = change.work_status();
731 if work_status == ChangeWorkStatus::Ready || work_status == ChangeWorkStatus::InProgress {
732 eligible_change_ids.push(change.id.clone());
733 }
734 }
735 eligible_change_ids.sort();
736 eligible_change_ids
737}
738
739fn repo_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
740 let mut incomplete_change_ids = Vec::new();
741 for change in changes {
742 if change.work_status() != ChangeWorkStatus::Complete {
743 incomplete_change_ids.push(change.id.clone());
744 }
745 }
746 incomplete_change_ids.sort();
747 incomplete_change_ids
748}
749
750fn print_eligible_changes(eligible_changes: &[String]) {
751 println!("\nEligible changes (ready or in-progress):");
752 if eligible_changes.is_empty() {
753 println!(" (none)");
754 return;
755 }
756
757 for (idx, change_id) in eligible_changes.iter().enumerate() {
758 if idx == 0 {
759 println!(" - {change} (selected first)", change = change_id);
760 continue;
761 }
762 println!(" - {change}", change = change_id);
763 }
764}
765
766fn module_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
767 let mut incomplete_change_ids = Vec::new();
768 for change in changes {
769 if change.work_status() != ChangeWorkStatus::Complete {
770 incomplete_change_ids.push(change.id.clone());
771 }
772 }
773 incomplete_change_ids
774}
775
776fn print_ready_changes(module_id: &str, ready_changes: &[String]) {
777 println!("\nReady changes for module {module}:", module = module_id);
778 if ready_changes.is_empty() {
779 println!(" (none)");
780 return;
781 }
782
783 for (idx, change_id) in ready_changes.iter().enumerate() {
784 if idx == 0 {
785 println!(" - {change} (selected first)", change = change_id);
786 continue;
787 }
788 println!(" - {change}", change = change_id);
789 }
790}
791
792#[derive(Debug)]
793struct CompletionValidationReport {
794 passed: bool,
795 context_markdown: String,
796}
797
798fn validate_completion(
799 ito_path: &Path,
800 task_repo: &impl DomainTaskRepository,
801 change_id: Option<&str>,
802 extra_command: Option<&str>,
803) -> CoreResult<CompletionValidationReport> {
804 let mut passed = true;
805 let mut sections: Vec<String> = Vec::new();
806
807 if let Some(change_id) = change_id {
808 let task = validation::check_task_completion(task_repo, change_id)?;
809 sections.push(render_validation_result("Ito task status", &task));
810 if !task.success {
811 passed = false;
812 }
813
814 let audit_report = crate::audit::run_reconcile(ito_path, Some(change_id), false);
816 if !audit_report.drifts.is_empty() {
817 let drift_lines: Vec<String> = audit_report
818 .drifts
819 .iter()
820 .map(|d| format!(" - {d}"))
821 .collect();
822 sections.push(format!(
823 "### Audit consistency\n\n- Result: WARN\n- Summary: {} drift items detected between audit log and file state\n\n{}",
824 audit_report.drifts.len(),
825 drift_lines.join("\n")
826 ));
827 }
828 } else {
829 sections.push(
830 "### Ito task status\n\n- Result: SKIP\n- Summary: No change selected; skipped task validation"
831 .to_string(),
832 );
833 }
834
835 let timeout = Duration::from_secs(5 * 60);
836 let project = validation::run_project_validation(ito_path, timeout)?;
837 sections.push(render_validation_result("Project validation", &project));
838 if !project.success {
839 passed = false;
840 }
841
842 if let Some(cmd) = extra_command {
843 let project_root = ito_path.parent().unwrap_or_else(|| Path::new("."));
844 let extra = validation::run_extra_validation(project_root, cmd, timeout)?;
845 sections.push(render_validation_result("Extra validation", &extra));
846 if !extra.success {
847 passed = false;
848 }
849 }
850
851 Ok(CompletionValidationReport {
852 passed,
853 context_markdown: sections.join("\n\n"),
854 })
855}
856
857fn render_validation_result(title: &str, r: &validation::ValidationResult) -> String {
858 let mut md = String::new();
859 md.push_str(&format!("### {title}\n\n"));
860 md.push_str(&format!(
861 "- Result: {}\n",
862 if r.success { "PASS" } else { "FAIL" }
863 ));
864 md.push_str(&format!("- Summary: {}\n", r.message.trim()));
865 if let Some(out) = r.output.as_deref() {
866 let out = out.trim();
867 if !out.is_empty() {
868 md.push_str("\nOutput:\n\n```text\n");
869 md.push_str(out);
870 md.push_str("\n```\n");
871 }
872 }
873 md
874}
875
876fn render_harness_failure(name: &str, exit_code: i32, stdout: &str, stderr: &str) -> String {
877 let mut md = String::new();
878 md.push_str("### Harness execution\n\n");
879 md.push_str("- Result: FAIL\n");
880 md.push_str(&format!("- Harness: {name}\n"));
881 md.push_str(&format!("- Exit code: {code}\n", code = exit_code));
882
883 let stdout = stdout.trim();
884 if !stdout.is_empty() {
885 md.push_str("\nStdout:\n\n```text\n");
886 md.push_str(stdout);
887 md.push_str("\n```\n");
888 }
889
890 let stderr = stderr.trim();
891 if !stderr.is_empty() {
892 md.push_str("\nStderr:\n\n```text\n");
893 md.push_str(stderr);
894 md.push_str("\n```\n");
895 }
896
897 md
898}
899
900fn completion_promise_found(stdout: &str, token: &str) -> bool {
901 let mut rest = stdout;
902 loop {
903 let Some(start) = rest.find("<promise>") else {
904 return false;
905 };
906 let after_start = &rest[start + "<promise>".len()..];
907 let Some(end) = after_start.find("</promise>") else {
908 return false;
909 };
910 let inner = &after_start[..end];
911 if inner.trim() == token {
912 return true;
913 }
914
915 rest = &after_start[end + "</promise>".len()..];
916 }
917}
918
919fn resolve_target(
920 change_repo: &impl DomainChangeRepository,
921 change_id: Option<String>,
922 module_id: Option<String>,
923 interactive: bool,
924) -> CoreResult<(String, String)> {
925 if let Some(change) = change_id {
927 let change = match change_repo.resolve_target(&change) {
928 ChangeTargetResolution::Unique(id) => id,
929 ChangeTargetResolution::Ambiguous(matches) => {
930 return Err(CoreError::Validation(format!(
931 "Change '{change}' is ambiguous. Matches: {}",
932 matches.join(", ")
933 )));
934 }
935 ChangeTargetResolution::NotFound => {
936 return Err(CoreError::NotFound(format!("Change '{change}' not found")));
937 }
938 };
939 let module = infer_module_from_change(&change)?;
940 return Ok((change, module));
941 }
942
943 if let Some(module) = module_id {
944 let changes = change_repo.list_by_module(&module).into_core()?;
945 if changes.is_empty() {
946 return Err(CoreError::NotFound(format!(
947 "No changes found for module {module}",
948 module = module
949 )));
950 }
951
952 let ready_changes = module_ready_change_ids(&changes);
953 if let Some(change_id) = ready_changes.first() {
954 return Ok((change_id.clone(), infer_module_from_change(change_id)?));
955 }
956
957 let incomplete = module_incomplete_change_ids(&changes);
958
959 if incomplete.is_empty() {
960 return Err(CoreError::Validation(format!(
961 "Module {module} has no ready changes because all changes are complete",
962 module = module
963 )));
964 }
965
966 return Err(CoreError::Validation(format!(
967 "Module {module} has no ready changes. Remaining non-complete changes: {}",
968 incomplete.join(", "),
969 module = module
970 )));
971 }
972
973 let msg = if interactive {
974 "No change selected. Provide --change or --module (or run `ito ralph` interactively to select a change)."
975 } else {
976 "No change selected. Provide --change or --module."
977 };
978
979 Err(CoreError::Validation(msg.into()))
980}
981
982fn infer_module_from_change(change_id: &str) -> CoreResult<String> {
983 let Some((module, _rest)) = change_id.split_once('-') else {
984 return Err(CoreError::Validation(format!(
985 "Invalid change ID format: {id}",
986 id = change_id
987 )));
988 };
989 Ok(module.to_string())
990}
991
992fn now_ms() -> CoreResult<i64> {
993 let dur = SystemTime::now()
994 .duration_since(UNIX_EPOCH)
995 .map_err(|e| CoreError::Process(format!("Clock error: {e}")))?;
996 Ok(dur.as_millis() as i64)
997}
998
999fn count_git_changes(runner: &dyn ProcessRunner, cwd: &Path) -> CoreResult<usize> {
1000 let request = ProcessRequest::new("git")
1001 .args(["status", "--porcelain"])
1002 .current_dir(cwd.to_path_buf());
1003 let out = runner
1004 .run(&request)
1005 .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
1006 if !out.success {
1007 let err = out.stderr;
1009 if !err.is_empty() {
1010 eprint!("{}", err);
1011 }
1012 return Ok(0);
1013 }
1014 let s = out.stdout;
1015 let mut line_count = 0;
1016 for line in s.lines() {
1017 if !line.trim().is_empty() {
1018 line_count += 1;
1019 }
1020 }
1021 Ok(line_count)
1022}
1023
1024fn commit_iteration(runner: &dyn ProcessRunner, iteration: u32, cwd: &Path) -> CoreResult<()> {
1025 let add_request = ProcessRequest::new("git")
1026 .args(["add", "-A"])
1027 .current_dir(cwd.to_path_buf());
1028 let add = runner
1029 .run(&add_request)
1030 .map_err(|e| CoreError::Process(format!("Failed to run git add: {e}")))?;
1031 if !add.success {
1032 let stdout = add.stdout.trim().to_string();
1033 let stderr = add.stderr.trim().to_string();
1034 let mut msg = String::from("git add failed");
1035 if !stdout.is_empty() {
1036 msg.push_str("\nstdout:\n");
1037 msg.push_str(&stdout);
1038 }
1039 if !stderr.is_empty() {
1040 msg.push_str("\nstderr:\n");
1041 msg.push_str(&stderr);
1042 }
1043 return Err(CoreError::Process(msg));
1044 }
1045
1046 let msg = format!("Ralph loop iteration {iteration}");
1047 let commit_request = ProcessRequest::new("git")
1048 .args(["commit", "-m", &msg])
1049 .current_dir(cwd.to_path_buf());
1050 let commit = runner
1051 .run(&commit_request)
1052 .map_err(|e| CoreError::Process(format!("Failed to run git commit: {e}")))?;
1053 if !commit.success {
1054 let stdout = commit.stdout.trim().to_string();
1055 let stderr = commit.stderr.trim().to_string();
1056 let mut msg = format!("git commit failed for iteration {iteration}");
1057 if !stdout.is_empty() {
1058 msg.push_str("\nstdout:\n");
1059 msg.push_str(&stdout);
1060 }
1061 if !stderr.is_empty() {
1062 msg.push_str("\nstderr:\n");
1063 msg.push_str(&stderr);
1064 }
1065 return Err(CoreError::Process(msg));
1066 }
1067 Ok(())
1068}
1069
1070#[cfg(test)]
1071mod runner_tests;