scud/commands/
next.rs

1use anyhow::Result;
2use colored::Colorize;
3use std::path::PathBuf;
4
5use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
6use crate::models::task::{Task, TaskStatus};
7use crate::storage::Storage;
8
9/// Result of finding the next task
10pub enum NextTaskResult<'a> {
11    /// Found a task with dependencies met
12    Available(&'a crate::models::task::Task),
13    /// No pending tasks at all
14    NoPendingTasks,
15    /// Pending tasks exist but blocked by dependencies
16    BlockedByDependencies,
17    /// All pending tasks are locked by others
18    AllLocked,
19}
20
21/// Find the next available task, considering locks
22/// all_tasks should contain tasks from all phases for cross-tag dependency resolution
23pub fn find_next_available<'a>(
24    phase: &'a crate::models::phase::Phase,
25    all_tasks: &[&Task],
26    exclude_locked: bool,
27) -> NextTaskResult<'a> {
28    let pending_tasks: Vec<_> = phase
29        .tasks
30        .iter()
31        .filter(|t| t.status == TaskStatus::Pending)
32        .collect();
33
34    if pending_tasks.is_empty() {
35        return NextTaskResult::NoPendingTasks;
36    }
37
38    // Find tasks with dependencies met (checking across all phases)
39    let deps_met: Vec<_> = pending_tasks
40        .iter()
41        .filter(|t| t.has_dependencies_met_refs(all_tasks))
42        .collect();
43
44    if deps_met.is_empty() {
45        return NextTaskResult::BlockedByDependencies;
46    }
47
48    // Filter out locked tasks if requested
49    if exclude_locked {
50        let unlocked: Vec<_> = deps_met.iter().filter(|t| !t.is_locked()).collect();
51        if unlocked.is_empty() {
52            return NextTaskResult::AllLocked;
53        }
54        return NextTaskResult::Available(unlocked[0]);
55    }
56
57    NextTaskResult::Available(deps_met[0])
58}
59
60pub fn run(
61    project_root: Option<PathBuf>,
62    tag: Option<&str>,
63    claim: bool,
64    name: Option<&str>,
65    release: bool,
66    spawn: bool,
67) -> Result<()> {
68    let storage = Storage::new(project_root);
69    let phase_tag = resolve_group_tag(&storage, tag, true)?;
70
71    // Handle --release mode
72    if release {
73        let agent_name =
74            name.ok_or_else(|| anyhow::anyhow!("--name is required with --release"))?;
75        return handle_release(&storage, &phase_tag, agent_name);
76    }
77
78    // Handle --claim mode (experimental dynamic-wave)
79    if claim {
80        let agent_name = name.ok_or_else(|| anyhow::anyhow!("--name is required with --claim"))?;
81        return handle_claim(&storage, &phase_tag, agent_name);
82    }
83
84    // Standard next task behavior (read-only)
85    let tasks = storage.load_tasks()?;
86    let all_tasks_flat = flatten_all_tasks(&tasks);
87    let phase = tasks
88        .get(&phase_tag)
89        .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
90
91    // Handle --spawn mode (machine-readable JSON output)
92    if spawn {
93        match find_next_available(phase, &all_tasks_flat, true) {
94            NextTaskResult::Available(task) => {
95                let output = serde_json::json!({
96                    "task_id": task.id,
97                    "title": task.title,
98                    "tag": phase_tag,
99                    "complexity": task.complexity,
100                });
101                println!("{}", serde_json::to_string(&output)?);
102            }
103            _ => {
104                println!("null");
105            }
106        }
107        return Ok(());
108    }
109
110    match find_next_available(phase, &all_tasks_flat, false) {
111        NextTaskResult::Available(task) => {
112            print_task_details(task);
113            print_standard_instructions(&task.id);
114        }
115        NextTaskResult::NoPendingTasks => {
116            println!("{}", "All tasks completed or in progress!".green().bold());
117            println!("Run: scud list --status in-progress");
118        }
119        NextTaskResult::BlockedByDependencies => {
120            println!(
121                "{}",
122                "No available tasks - all pending tasks blocked by dependencies".yellow()
123            );
124            println!("Run: scud list --status pending");
125            println!("Run: scud doctor  # to diagnose stuck states");
126        }
127        NextTaskResult::AllLocked => {
128            println!("{}", "All available tasks are currently locked".yellow());
129            println!("Run: scud whois  # to see who's working on what");
130        }
131    }
132
133    Ok(())
134}
135
136fn handle_claim(storage: &Storage, phase_tag: &str, agent_name: &str) -> Result<()> {
137    println!(
138        "{}",
139        "[EXPERIMENTAL] Dynamic-wave mode: claiming next task"
140            .yellow()
141            .bold()
142    );
143    println!();
144
145    // Load all phases for cross-tag dependency checking
146    let all_phases = storage.load_tasks()?;
147    let all_tasks_flat = flatten_all_tasks(&all_phases);
148
149    // Use atomic update_group to hold lock across read-modify-write cycle
150    // This prevents race conditions when multiple agents claim simultaneously
151    let mut phase = storage.load_group(phase_tag)?;
152
153    // Find next available task (exclude locked ones)
154    let task_id = {
155        let pending_tasks: Vec<_> = phase
156            .tasks
157            .iter()
158            .filter(|t| t.status == TaskStatus::Pending)
159            .collect();
160
161        if pending_tasks.is_empty() {
162            println!("{}", "No pending tasks available".yellow());
163            println!();
164            println!("{}", "All tasks may be:".blue());
165            println!("  - Already done");
166            println!("  - In progress by others");
167            println!("  - Blocked by dependencies");
168            println!();
169            println!("Run: scud list  # to see all tasks");
170            println!("Run: scud stats  # to see completion status");
171            return Ok(());
172        }
173
174        // Find first task with dependencies met that isn't locked (cross-tag aware)
175        let available: Vec<_> = pending_tasks
176            .iter()
177            .filter(|t| t.has_dependencies_met_refs(&all_tasks_flat) && !t.is_locked())
178            .collect();
179
180        if available.is_empty() {
181            // Check if blocked by deps or by locks
182            let deps_met: Vec<_> = pending_tasks
183                .iter()
184                .filter(|t| t.has_dependencies_met_refs(&all_tasks_flat))
185                .collect();
186
187            if deps_met.is_empty() {
188                println!(
189                    "{}",
190                    "No tasks available - all pending tasks blocked by dependencies"
191                        .yellow()
192                        .bold()
193                );
194                println!();
195                println!("{}", "Possible causes:".blue());
196                println!("  - Dependencies not marked as done");
197                println!("  - Circular dependency issues");
198                println!("  - Dependencies on cancelled/blocked tasks");
199                println!();
200                println!("Run: scud doctor  # to diagnose stuck states");
201            } else {
202                println!(
203                    "{}",
204                    "No tasks available - all eligible tasks are locked by other agents"
205                        .yellow()
206                        .bold()
207                );
208                println!();
209                println!("{}", "Currently locked tasks:".blue());
210                for task in deps_met {
211                    if let Some(ref locked_by) = task.locked_by {
212                        println!(
213                            "  {} - {} (locked by {})",
214                            task.id.cyan(),
215                            task.title,
216                            locked_by.green()
217                        );
218                    }
219                }
220                println!();
221                println!("Run: scud whois  # to see all assignments");
222                println!("Run: scud doctor  # to check for stale locks");
223            }
224            return Ok(());
225        }
226
227        available[0].id.clone()
228    };
229
230    // Claim the task
231    let task = phase
232        .get_task_mut(&task_id)
233        .ok_or_else(|| anyhow::anyhow!("Task {} not found", task_id))?;
234
235    task.claim(agent_name).map_err(|e| anyhow::anyhow!(e))?;
236    task.set_status(TaskStatus::InProgress);
237
238    // Get task details before saving
239    let task_title = task.title.clone();
240    let task_description = task.description.clone();
241    let task_complexity = task.complexity;
242    let task_details = task.details.clone();
243    let task_test_strategy = task.test_strategy.clone();
244
245    // Use atomic update_group which holds lock across read-modify-write
246    storage.update_group(phase_tag, &phase)?;
247
248    // Print claimed task details
249    println!("{}", "Task claimed successfully!".green().bold());
250    println!();
251    println!("{:<20} {}", "ID:".yellow(), task_id.cyan());
252    println!("{:<20} {}", "Title:".yellow(), task_title.bold());
253    println!("{:<20} {}", "Complexity:".yellow(), task_complexity);
254    println!("{:<20} {}", "Claimed by:".yellow(), agent_name.green());
255    println!("{:<20} {}", "Status:".yellow(), "in-progress".cyan());
256    println!();
257    println!("{}", "Description:".yellow());
258    println!("{}", task_description);
259
260    if let Some(details) = &task_details {
261        println!();
262        println!("{}", "Technical Details:".yellow());
263        println!("{}", details);
264    }
265
266    if let Some(test_strategy) = &task_test_strategy {
267        println!();
268        println!("{}", "Test Strategy:".yellow());
269        println!("{}", test_strategy);
270    }
271
272    // Critical: Status discipline messaging
273    println!();
274    println!("{}", "=".repeat(60).yellow());
275    println!("{}", "IMPORTANT: Status Update Required".red().bold());
276    println!("{}", "=".repeat(60).yellow());
277    println!();
278    println!(
279        "{}",
280        "When you complete this task, you MUST run:".yellow().bold()
281    );
282    println!();
283    println!(
284        "    {}",
285        format!("scud set-status {} done", task_id).cyan().bold()
286    );
287    println!();
288    println!(
289        "{}",
290        "This ensures the workflow stays healthy and other agents".dimmed()
291    );
292    println!("{}", "can claim dependent tasks.".dimmed());
293    println!();
294
295    Ok(())
296}
297
298fn handle_release(storage: &Storage, phase_tag: &str, agent_name: &str) -> Result<()> {
299    println!(
300        "{}",
301        "[EXPERIMENTAL] Releasing tasks for agent".yellow().bold()
302    );
303    println!();
304
305    // Use atomic update_group to hold lock across read-modify-write cycle
306    let mut phase = storage.load_group(phase_tag)?;
307
308    // Find tasks locked by this agent
309    let mut released_count = 0;
310    for task in &mut phase.tasks {
311        if task.is_locked_by(agent_name) {
312            let task_id = task.id.clone();
313            let task_title = task.title.clone();
314            // Clear both lock and assignment for clean release
315            task.release();
316            task.assigned_to = None;
317            // Reset status back to pending if it was in-progress
318            if task.status == TaskStatus::InProgress {
319                task.set_status(TaskStatus::Pending);
320            }
321            println!(
322                "{} Released: {} - {}",
323                "✓".green(),
324                task_id.cyan(),
325                task_title
326            );
327            released_count += 1;
328        }
329    }
330
331    if released_count == 0 {
332        println!(
333            "{}",
334            format!("No tasks found locked by '{}'", agent_name).yellow()
335        );
336        return Ok(());
337    }
338
339    // Use atomic update_group which holds lock across read-modify-write
340    storage.update_group(phase_tag, &phase)?;
341
342    println!();
343    println!("{} {} task(s) released", "✓".green(), released_count);
344
345    Ok(())
346}
347
348fn print_task_details(task: &crate::models::task::Task) {
349    println!("{}", "Next Available Task:".green().bold());
350    println!();
351    println!("{:<20} {}", "ID:".yellow(), task.id.cyan());
352    println!("{:<20} {}", "Title:".yellow(), task.title.bold());
353    println!("{:<20} {}", "Complexity:".yellow(), task.complexity);
354    println!("{:<20} {:?}", "Priority:".yellow(), task.priority);
355
356    if let Some(ref assigned) = task.assigned_to {
357        println!("{:<20} {}", "Assigned to:".yellow(), assigned.green());
358    }
359
360    if task.is_locked() {
361        if let Some(ref locked_by) = task.locked_by {
362            println!(
363                "{:<20} {} (by {})",
364                "Status:".yellow(),
365                "LOCKED".red(),
366                locked_by
367            );
368        }
369    }
370
371    println!();
372    println!("{}", "Description:".yellow());
373    println!("{}", task.description);
374
375    if let Some(details) = &task.details {
376        println!();
377        println!("{}", "Technical Details:".yellow());
378        println!("{}", details);
379    }
380
381    if let Some(test_strategy) = &task.test_strategy {
382        println!();
383        println!("{}", "Test Strategy:".yellow());
384        println!("{}", test_strategy);
385    }
386}
387
388fn print_standard_instructions(task_id: &str) {
389    println!();
390    println!("{}", "To start this task:".blue());
391    println!("  scud set-status {} in-progress", task_id);
392    println!();
393    println!(
394        "{}",
395        "Or use experimental dynamic-wave mode:".blue().dimmed()
396    );
397    println!(
398        "  scud next --claim --name <your-name>  {}",
399        "# auto-claims next task".dimmed()
400    );
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::models::phase::Phase;
407    use crate::models::task::{Task, TaskStatus};
408
409    fn create_test_phase() -> Phase {
410        let mut phase = Phase::new("test-phase".to_string());
411
412        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc 1".to_string());
413        task1.set_status(TaskStatus::Done);
414
415        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc 2".to_string());
416        task2.dependencies = vec!["1".to_string()];
417        // task2 is pending with deps met
418
419        let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), "Desc 3".to_string());
420        task3.dependencies = vec!["2".to_string()];
421        // task3 is pending with deps NOT met
422
423        phase.add_task(task1);
424        phase.add_task(task2);
425        phase.add_task(task3);
426
427        phase
428    }
429
430    /// Helper to get task refs from phase for testing
431    fn get_task_refs(phase: &Phase) -> Vec<&Task> {
432        phase.tasks.iter().collect()
433    }
434
435    #[test]
436    fn test_find_next_available_basic() {
437        let phase = create_test_phase();
438        let all_tasks = get_task_refs(&phase);
439
440        match find_next_available(&phase, &all_tasks, false) {
441            NextTaskResult::Available(task) => {
442                assert_eq!(task.id, "2");
443            }
444            _ => panic!("Expected Available result"),
445        }
446    }
447
448    #[test]
449    fn test_find_next_available_exclude_locked() {
450        let mut phase = create_test_phase();
451
452        // Lock task 2
453        phase.get_task_mut("2").unwrap().claim("alice").unwrap();
454
455        let all_tasks = get_task_refs(&phase);
456
457        // Without exclude_locked, should still find task 2
458        match find_next_available(&phase, &all_tasks, false) {
459            NextTaskResult::Available(task) => {
460                assert_eq!(task.id, "2");
461            }
462            _ => panic!("Expected Available result"),
463        }
464
465        // With exclude_locked, should return AllLocked
466        match find_next_available(&phase, &all_tasks, true) {
467            NextTaskResult::AllLocked => {}
468            _ => panic!("Expected AllLocked result"),
469        }
470    }
471
472    #[test]
473    fn test_find_next_no_pending() {
474        let mut phase = Phase::new("test".to_string());
475        let mut task = Task::new("1".to_string(), "Done".to_string(), "Desc".to_string());
476        task.set_status(TaskStatus::Done);
477        phase.add_task(task);
478
479        let all_tasks = get_task_refs(&phase);
480
481        match find_next_available(&phase, &all_tasks, false) {
482            NextTaskResult::NoPendingTasks => {}
483            _ => panic!("Expected NoPendingTasks result"),
484        }
485    }
486
487    #[test]
488    fn test_find_next_blocked_by_deps() {
489        let mut phase = Phase::new("test".to_string());
490
491        let task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
492        // task1 is pending
493
494        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc".to_string());
495        task2.dependencies = vec!["1".to_string()];
496        // task2 depends on pending task1
497
498        // Add task2 first, task1 second (so task2 is checked first)
499        phase.add_task(task2);
500        phase.add_task(task1);
501
502        let all_tasks = get_task_refs(&phase);
503
504        // task1 should be found since it has no deps
505        match find_next_available(&phase, &all_tasks, false) {
506            NextTaskResult::Available(task) => {
507                assert_eq!(task.id, "1");
508            }
509            _ => panic!("Expected task 1 to be available"),
510        }
511    }
512
513    #[test]
514    fn test_find_next_all_blocked() {
515        let mut phase = Phase::new("test".to_string());
516
517        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
518        task1.dependencies = vec!["nonexistent".to_string()];
519        // task1 depends on non-existent task
520
521        phase.add_task(task1);
522
523        let all_tasks = get_task_refs(&phase);
524
525        match find_next_available(&phase, &all_tasks, false) {
526            NextTaskResult::BlockedByDependencies => {}
527            _ => panic!("Expected BlockedByDependencies result"),
528        }
529    }
530
531    #[test]
532    fn test_find_next_cross_tag_dependency() {
533        // Create a phase with a task that depends on a task from another "phase"
534        let mut phase = Phase::new("api".to_string());
535        let mut api_task = Task::new(
536            "api:1".to_string(),
537            "API Task".to_string(),
538            "Desc".to_string(),
539        );
540        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
541        phase.add_task(api_task);
542
543        // Create "auth" task (simulating another phase)
544        let mut auth_task = Task::new(
545            "auth:1".to_string(),
546            "Auth Task".to_string(),
547            "Desc".to_string(),
548        );
549        auth_task.set_status(TaskStatus::Done);
550
551        // Combine all tasks (simulating flattened all_phases)
552        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
553
554        // With cross-tag tasks included, dependency should be met
555        match find_next_available(&phase, &all_tasks, false) {
556            NextTaskResult::Available(task) => {
557                assert_eq!(task.id, "api:1");
558            }
559            _ => panic!("Expected Available result with cross-tag dependency met"),
560        }
561    }
562
563    #[test]
564    fn test_find_next_cross_tag_dependency_not_met() {
565        // Create a phase with a task that depends on a task from another "phase"
566        let mut phase = Phase::new("api".to_string());
567        let mut api_task = Task::new(
568            "api:1".to_string(),
569            "API Task".to_string(),
570            "Desc".to_string(),
571        );
572        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
573        phase.add_task(api_task);
574
575        // Create "auth" task (NOT done)
576        let auth_task = Task::new(
577            "auth:1".to_string(),
578            "Auth Task".to_string(),
579            "Desc".to_string(),
580        );
581
582        // Combine all tasks (simulating flattened all_phases)
583        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
584
585        // With cross-tag dep NOT met, should be blocked
586        match find_next_available(&phase, &all_tasks, false) {
587            NextTaskResult::BlockedByDependencies => {}
588            _ => panic!("Expected BlockedByDependencies with cross-tag dep not met"),
589        }
590    }
591}