1use anyhow::Result;
12use colored::Colorize;
13use std::path::PathBuf;
14
15use crate::backpressure::{run_validation, BackpressureConfig};
16use crate::commands::helpers::resolve_group_tag;
17use crate::commands::spawn::monitor::{self, AgentStatus, SpawnSession};
18use crate::commands::spawn::terminal::{self, Harness};
19use crate::models::task::TaskStatus;
20use crate::storage::Storage;
21
22#[allow(clippy::too_many_arguments)]
23pub fn run(
24 project_root: Option<PathBuf>,
25 tag: Option<&str>,
26 max_iterations: usize,
27 no_validate: bool,
28 no_repair: bool,
29 max_repair_attempts: usize,
30 harness_arg: &str,
31 model: Option<&str>,
32 session_name: Option<String>,
33 dry_run: bool,
34 push: bool,
35) -> Result<()> {
36 let storage = Storage::new(project_root.clone());
37
38 if !storage.is_initialized() {
39 anyhow::bail!("SCUD not initialized. Run: scud init");
40 }
41
42 terminal::check_tmux_available()?;
44
45 let effective_tag = resolve_group_tag(&storage, tag, true)?;
47
48 let harness = Harness::parse(harness_arg)?;
50 terminal::find_harness_binary(harness)?;
51
52 let session_name = session_name.unwrap_or_else(|| format!("ralph-{}", effective_tag));
54
55 let bp_config = BackpressureConfig::load(project_root.as_ref())?;
57
58 let working_dir = project_root
60 .clone()
61 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
62
63 println!("{}", "SCUD Ralph Mode".cyan().bold());
65 println!("{}", "═".repeat(50));
66 println!("{:<20} {}", "Tag:".dimmed(), effective_tag.green());
67 println!("{:<20} {}", "Terminal:".dimmed(), "tmux".cyan());
68 println!("{:<20} {}", "Harness:".dimmed(), harness.name().cyan());
69 if let Some(m) = model {
70 println!("{:<20} {}", "Model:".dimmed(), m.cyan());
71 }
72 println!(
73 "{:<20} {}",
74 "Validation:".dimmed(),
75 if no_validate {
76 "skip".yellow()
77 } else {
78 "enabled".green()
79 }
80 );
81 println!(
82 "{:<20} {}",
83 "Repair:".dimmed(),
84 if no_repair {
85 "disabled".yellow()
86 } else {
87 format!("up to {} attempts", max_repair_attempts).green()
88 }
89 );
90 if max_iterations > 0 {
91 println!(
92 "{:<20} {}",
93 "Max iterations:".dimmed(),
94 max_iterations.to_string().cyan()
95 );
96 }
97 println!();
98
99 if dry_run {
100 return run_dry_run(&storage, &effective_tag);
101 }
102
103 let mut spawn_session = SpawnSession::new(
105 &session_name,
106 &effective_tag,
107 "tmux",
108 &working_dir.to_string_lossy(),
109 );
110
111 run_ralph_loop(
113 &storage,
114 &mut spawn_session,
115 &effective_tag,
116 max_iterations,
117 no_validate,
118 no_repair,
119 max_repair_attempts,
120 harness,
121 model,
122 &session_name,
123 &working_dir,
124 &bp_config,
125 push,
126 &project_root,
127 )
128}
129
130fn run_dry_run(storage: &Storage, tag: &str) -> Result<()> {
131 println!("{}", "Dry run - showing execution plan:".yellow());
132 println!();
133
134 let phases = storage.load_tasks()?;
135 let phase = phases.get(tag);
136
137 if let Some(phase) = phase {
138 let pending: Vec<_> = phase
139 .tasks
140 .iter()
141 .filter(|t| t.status == TaskStatus::Pending)
142 .collect();
143
144 println!("Tasks to process ({}):", pending.len());
145 for (i, task) in pending.iter().enumerate() {
146 println!(" {}. {} - {}", i + 1, task.id.cyan(), task.title);
147 }
148 } else {
149 println!("No tasks found for tag: {}", tag);
150 }
151
152 Ok(())
153}
154
155#[allow(clippy::too_many_arguments)]
156fn run_ralph_loop(
157 storage: &Storage,
158 spawn_session: &mut SpawnSession,
159 tag: &str,
160 max_iterations: usize,
161 no_validate: bool,
162 no_repair: bool,
163 max_repair_attempts: usize,
164 harness: Harness,
165 model: Option<&str>,
166 session_name: &str,
167 working_dir: &PathBuf,
168 bp_config: &BackpressureConfig,
169 push: bool,
170 project_root: &Option<PathBuf>,
171) -> Result<()> {
172 let mut iteration = 0;
173 let mut completed_count = 0;
174 let mut failed_count = 0;
175
176 loop {
177 if max_iterations > 0 && iteration >= max_iterations {
179 println!(
180 "{}",
181 format!("Reached max iterations: {}", max_iterations).yellow()
182 );
183 break;
184 }
185
186 iteration += 1;
187 println!();
188 println!(
189 "{}",
190 format!("═══════════════ ITERATION {} ═══════════════", iteration)
191 .cyan()
192 .bold()
193 );
194
195 let task = get_next_task(storage, tag)?;
197
198 let Some((task_id, task_title, task_description)) = task else {
199 println!(
200 "{}",
201 "No more tasks available. Ralph complete!".green().bold()
202 );
203 break;
204 };
205
206 println!("Task: {} - {}", task_id.cyan(), task_title);
207
208 storage.update_task_status(tag, &task_id, TaskStatus::InProgress)?;
210
211 spawn_session.add_agent(&task_id, &task_title, tag);
213 spawn_session.update_agent_status(&task_id, AgentStatus::Running);
214 monitor::save_session(project_root.as_ref(), spawn_session)?;
215
216 let window_name = format!("task-{}", task_id);
218 spawn_ralph_agent(
219 &task_id,
220 &task_title,
221 &task_description,
222 harness,
223 model,
224 session_name,
225 &window_name,
226 working_dir,
227 )?;
228
229 println!(" {} Waiting for agent to complete...", "→".dimmed());
231 wait_for_agent_completion(session_name, &window_name)?;
232 println!(" {} Agent completed", "✓".green());
233
234 if !no_validate && !bp_config.commands.is_empty() {
236 println!(" {} Running validation...", "→".dimmed());
237 let validation = run_validation(working_dir, bp_config)?;
238
239 if !validation.all_passed {
240 println!(" {} Validation failed", "✗".red());
241
242 if no_repair {
243 storage.update_task_status(tag, &task_id, TaskStatus::Failed)?;
245 spawn_session.update_agent_status(&task_id, AgentStatus::Failed);
246 monitor::save_session(project_root.as_ref(), spawn_session)?;
247 failed_count += 1;
248 continue;
249 }
250
251 let repaired = run_repair_loop(
253 &task_id,
254 &task_title,
255 max_repair_attempts,
256 harness,
257 model,
258 session_name,
259 working_dir,
260 bp_config,
261 &validation,
262 )?;
263
264 if !repaired {
265 println!(
266 " {} Repair failed after {} attempts",
267 "✗".red(),
268 max_repair_attempts
269 );
270 storage.update_task_status(tag, &task_id, TaskStatus::Failed)?;
271 spawn_session.update_agent_status(&task_id, AgentStatus::Failed);
272 monitor::save_session(project_root.as_ref(), spawn_session)?;
273 failed_count += 1;
274 continue;
275 }
276 }
277 println!(" {} Validation passed", "✓".green());
278 }
279
280 storage.update_task_status(tag, &task_id, TaskStatus::Done)?;
282 spawn_session.update_agent_status(&task_id, AgentStatus::Completed);
283 monitor::save_session(project_root.as_ref(), spawn_session)?;
284 completed_count += 1;
285
286 if push {
288 println!(" {} Pushing to remote...", "→".dimmed());
289 if let Err(e) = git_push(working_dir) {
290 println!(" {} Push failed: {}", "!".yellow(), e);
291 } else {
292 println!(" {} Pushed", "✓".green());
293 }
294 }
295
296 println!(" {} Task {} complete", "✓".green().bold(), task_id);
297 }
298
299 println!();
301 println!(
302 "{}",
303 "═══════════════ SUMMARY ═══════════════"
304 .cyan()
305 .bold()
306 );
307 println!(" Iterations: {}", iteration);
308 println!(" Completed: {} tasks", completed_count);
309 println!(" Failed: {} tasks", failed_count);
310
311 Ok(())
312}
313
314fn get_next_task(storage: &Storage, tag: &str) -> Result<Option<(String, String, String)>> {
316 let phases = storage.load_tasks()?;
317 let phase = phases.get(tag);
318
319 let Some(phase) = phase else {
320 return Ok(None);
321 };
322
323 for task in &phase.tasks {
325 if task.status != TaskStatus::Pending {
326 continue;
327 }
328
329 let deps_satisfied = task.dependencies.iter().all(|dep_id| {
331 phase
332 .tasks
333 .iter()
334 .find(|t| t.id == *dep_id)
335 .map(|t| t.status == TaskStatus::Done)
336 .unwrap_or(true) });
338
339 if deps_satisfied {
340 return Ok(Some((
341 task.id.clone(),
342 task.title.clone(),
343 task.description.clone(),
344 )));
345 }
346 }
347
348 Ok(None)
349}
350
351fn spawn_ralph_agent(
352 task_id: &str,
353 task_title: &str,
354 task_description: &str,
355 harness: Harness,
356 model: Option<&str>,
357 session_name: &str,
358 window_name: &str,
359 working_dir: &PathBuf,
360) -> Result<()> {
361 let prompt = generate_ralph_prompt(task_id, task_title, task_description);
363
364 let prompt_file = std::env::temp_dir().join(format!("ralph-prompt-{}.txt", task_id));
366 std::fs::write(&prompt_file, &prompt)?;
367
368 let binary_path = terminal::find_harness_binary(harness)?;
370
371 let command = harness.command(binary_path, &prompt_file, model);
373
374 terminal::spawn_in_tmux(session_name, window_name, &command, working_dir)?;
376
377 Ok(())
378}
379
380fn generate_ralph_prompt(task_id: &str, task_title: &str, task_description: &str) -> String {
381 format!(
382 r#"You are working on task: {} - {}
383
384## Task Description
385
386{}
387
388## Instructions
389
3901. Study the codebase to understand current state (don't assume functionality is missing)
3912. Implement the required changes completely - no placeholders or stubs
3923. Run tests to verify your implementation works
3934. When tests pass, commit your changes: `git add -A && git commit -m "feat: {}"`
394
395IMPORTANT:
396- Complete the entire task in this session
397- If you encounter blockers, document them clearly before stopping
398- Do NOT leave partial implementations
399"#,
400 task_id, task_title, task_description, task_title
401 )
402}
403
404fn wait_for_agent_completion(session_name: &str, window_name: &str) -> Result<()> {
405 use std::thread;
406 use std::time::Duration;
407
408 loop {
410 let output = std::process::Command::new("tmux")
411 .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
412 .output()?;
413
414 let windows = String::from_utf8_lossy(&output.stdout);
415 if !windows.lines().any(|w| w == window_name) {
416 break;
417 }
418
419 thread::sleep(Duration::from_secs(5));
420 }
421
422 Ok(())
423}
424
425#[allow(clippy::too_many_arguments)]
426fn run_repair_loop(
427 task_id: &str,
428 task_title: &str,
429 max_attempts: usize,
430 harness: Harness,
431 model: Option<&str>,
432 session_name: &str,
433 working_dir: &PathBuf,
434 bp_config: &BackpressureConfig,
435 initial_failure: &crate::backpressure::ValidationResult,
436) -> Result<bool> {
437 let mut last_failure = initial_failure.clone();
438
439 for attempt in 1..=max_attempts {
440 println!(
441 " {} Repair attempt {}/{}...",
442 "→".dimmed(),
443 attempt,
444 max_attempts
445 );
446
447 let repair_prompt = generate_repair_prompt(task_id, task_title, &last_failure);
449
450 let prompt_file =
452 std::env::temp_dir().join(format!("ralph-repair-{}-{}.txt", task_id, attempt));
453 std::fs::write(&prompt_file, &repair_prompt)?;
454
455 let binary_path = terminal::find_harness_binary(harness)?;
457
458 let command = harness.command(binary_path, &prompt_file, model);
460
461 let window_name = format!("repair-{}-{}", task_id, attempt);
463 terminal::spawn_in_tmux(session_name, &window_name, &command, working_dir)?;
464
465 wait_for_agent_completion(session_name, &window_name)?;
467
468 let validation = run_validation(working_dir, bp_config)?;
470 if validation.all_passed {
471 return Ok(true);
472 }
473
474 last_failure = validation;
475 }
476
477 Ok(false)
478}
479
480fn generate_repair_prompt(
481 task_id: &str,
482 task_title: &str,
483 failure: &crate::backpressure::ValidationResult,
484) -> String {
485 let failures: Vec<String> = failure
486 .results
487 .iter()
488 .filter(|r| !r.passed)
489 .map(|r| format!("Command `{}` failed:\n{}\n{}", r.command, r.stdout, r.stderr))
490 .collect();
491
492 format!(
493 r#"You are repairing validation failures for task: {} - {}
494
495## Validation Failures
496
497{}
498
499## Instructions
500
5011. Analyze the error output above
5022. Fix the issues causing validation to fail
5033. Run the failing commands to verify your fixes work
5044. Commit your fixes: `git add -A && git commit -m "fix: repair validation for {}"`
505
506IMPORTANT:
507- Focus only on fixing the validation failures
508- Do NOT add new features or refactor unrelated code
509- Make minimal changes to fix the specific errors
510"#,
511 task_id,
512 task_title,
513 failures.join("\n\n"),
514 task_id
515 )
516}
517
518fn git_push(working_dir: &PathBuf) -> Result<()> {
519 let output = std::process::Command::new("git")
520 .args(["push"])
521 .current_dir(working_dir)
522 .output()?;
523
524 if !output.status.success() {
525 anyhow::bail!(
526 "git push failed: {}",
527 String::from_utf8_lossy(&output.stderr)
528 );
529 }
530
531 Ok(())
532}