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
9pub enum NextTaskResult<'a> {
11 Available(&'a crate::models::task::Task),
13 NoPendingTasks,
15 BlockedByDependencies,
17 AllLocked,
19}
20
21pub 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 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 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 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 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 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 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 let all_phases = storage.load_tasks()?;
147 let all_tasks_flat = flatten_all_tasks(&all_phases);
148
149 let mut phase = storage.load_group(phase_tag)?;
152
153 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 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 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 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 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 storage.update_group(phase_tag, &phase)?;
247
248 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 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 let mut phase = storage.load_group(phase_tag)?;
307
308 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 task.release();
316 task.assigned_to = None;
317 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 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 let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), "Desc 3".to_string());
420 task3.dependencies = vec!["2".to_string()];
421 phase.add_task(task1);
424 phase.add_task(task2);
425 phase.add_task(task3);
426
427 phase
428 }
429
430 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 phase.get_task_mut("2").unwrap().claim("alice").unwrap();
454
455 let all_tasks = get_task_refs(&phase);
456
457 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 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 let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc".to_string());
495 task2.dependencies = vec!["1".to_string()];
496 phase.add_task(task2);
500 phase.add_task(task1);
501
502 let all_tasks = get_task_refs(&phase);
503
504 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 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 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()]; phase.add_task(api_task);
542
543 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 let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
553
554 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 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()]; phase.add_task(api_task);
574
575 let auth_task = Task::new(
577 "auth:1".to_string(),
578 "Auth Task".to_string(),
579 "Desc".to_string(),
580 );
581
582 let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
584
585 match find_next_available(&phase, &all_tasks, false) {
587 NextTaskResult::BlockedByDependencies => {}
588 _ => panic!("Expected BlockedByDependencies with cross-tag dep not met"),
589 }
590 }
591}