Skip to main content

scud/commands/
ralph.rs

1//! Ralph mode - Sequential iteration loop with fresh context per task
2//!
3//! Implements the Ralph methodology:
4//! 1. Fresh context each iteration - spawns new agent each time
5//! 2. One task per loop - focus, complete, validate
6//! 3. Backpressure - tests/lint force self-correction
7//!
8//! Ralph is essentially "swarm with round_size=1" - it processes tasks
9//! sequentially with validation between each task.
10
11use 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    // Check tmux is available
43    terminal::check_tmux_available()?;
44
45    // Resolve tag
46    let effective_tag = resolve_group_tag(&storage, tag, true)?;
47
48    // Parse harness
49    let harness = Harness::parse(harness_arg)?;
50    terminal::find_harness_binary(harness)?;
51
52    // Generate session name
53    let session_name = session_name.unwrap_or_else(|| format!("ralph-{}", effective_tag));
54
55    // Load backpressure config
56    let bp_config = BackpressureConfig::load(project_root.as_ref())?;
57
58    // Get working directory
59    let working_dir = project_root
60        .clone()
61        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
62
63    // Display header
64    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    // Create spawn session for TUI monitoring
104    let mut spawn_session = SpawnSession::new(
105        &session_name,
106        &effective_tag,
107        "tmux",
108        &working_dir.to_string_lossy(),
109    );
110
111    // Main Ralph loop
112    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        // Check iteration limit
178        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        // Get next task
196        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        // Mark task in-progress
209        storage.update_task_status(tag, &task_id, TaskStatus::InProgress)?;
210
211        // Add to spawn session for monitoring
212        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        // Spawn agent with fresh context
217        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        // Wait for agent completion
230        println!("  {} Waiting for agent to complete...", "→".dimmed());
231        wait_for_agent_completion(session_name, &window_name)?;
232        println!("  {} Agent completed", "✓".green());
233
234        // Run backpressure validation
235        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                    // Mark task as failed and continue
244                    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                // Attempt repairs
252                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        // Mark task complete
281        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        // Git push if enabled
287        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    // Final summary
300    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
314/// Get next pending task with satisfied dependencies
315fn 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    // Find first pending task with no blocking dependencies
324    for task in &phase.tasks {
325        if task.status != TaskStatus::Pending {
326            continue;
327        }
328
329        // Check if all dependencies are done
330        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) // Treat missing deps as satisfied
337        });
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    // Generate prompt
362    let prompt = generate_ralph_prompt(task_id, task_title, task_description);
363
364    // Write prompt to temp file
365    let prompt_file = std::env::temp_dir().join(format!("ralph-prompt-{}.txt", task_id));
366    std::fs::write(&prompt_file, &prompt)?;
367
368    // Find harness binary
369    let binary_path = terminal::find_harness_binary(harness)?;
370
371    // Generate command
372    let command = harness.command(binary_path, &prompt_file, model);
373
374    // Spawn in tmux
375    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    // Poll tmux window until it's gone (agent exited)
409    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        // Generate repair prompt
448        let repair_prompt = generate_repair_prompt(task_id, task_title, &last_failure);
449
450        // Write prompt to temp file
451        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        // Find harness binary
456        let binary_path = terminal::find_harness_binary(harness)?;
457
458        // Generate command
459        let command = harness.command(binary_path, &prompt_file, model);
460
461        // Spawn repair agent
462        let window_name = format!("repair-{}-{}", task_id, attempt);
463        terminal::spawn_in_tmux(session_name, &window_name, &command, working_dir)?;
464
465        // Wait for repair agent
466        wait_for_agent_completion(session_name, &window_name)?;
467
468        // Re-validate
469        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}