Skip to main content

batty_cli/team/
resolver.rs

1#![cfg_attr(not(test), allow(dead_code))]
2
3//! Resolve board tasks into runnable workflow states.
4
5use std::collections::HashSet;
6use std::path::Path;
7
8use anyhow::{Context, Result};
9use serde::Deserialize;
10
11use crate::task::{Task, load_tasks_from_dir};
12
13use super::capability::WorkflowCapability;
14use super::config::RoleType;
15use super::hierarchy::MemberInstance;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ResolutionStatus {
19    Runnable,
20    Blocked,
21    NeedsReview,
22    NeedsAction,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct TaskResolution {
27    pub task_id: u32,
28    pub title: String,
29    pub status: ResolutionStatus,
30    pub execution_owner: Option<String>,
31    pub review_owner: Option<String>,
32    pub blocking_reason: Option<String>,
33    pub acting_capability: Option<WorkflowCapability>,
34}
35
36#[derive(Debug, Default, Deserialize)]
37struct WorkflowMetadata {
38    #[serde(default)]
39    execution_owner: Option<String>,
40    #[serde(default)]
41    blocked_on: Option<String>,
42    #[serde(default)]
43    review_owner: Option<String>,
44}
45
46pub fn resolve_board(board_dir: &Path, members: &[MemberInstance]) -> Result<Vec<TaskResolution>> {
47    let tasks = load_tasks_from_dir(&board_dir.join("tasks"))?;
48    let done: HashSet<u32> = tasks
49        .iter()
50        .filter(|task| matches!(task.status.as_str(), "done" | "archived"))
51        .map(|task| task.id)
52        .collect();
53
54    let mut resolutions = Vec::new();
55    for task in tasks
56        .iter()
57        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
58    {
59        let metadata = load_workflow_metadata(task)?;
60        let execution_owner = metadata.execution_owner.clone().or(task.claimed_by.clone());
61        let blocking_reason = blocking_reason(task, &metadata, &done);
62        let status = if blocking_reason.is_some() {
63            ResolutionStatus::Blocked
64        } else if task.status == "review" {
65            ResolutionStatus::NeedsReview
66        } else if matches!(
67            task.status.as_str(),
68            "todo" | "backlog" | "in-progress" | "runnable"
69        ) {
70            ResolutionStatus::Runnable
71        } else {
72            ResolutionStatus::NeedsAction
73        };
74        resolutions.push(TaskResolution {
75            task_id: task.id,
76            title: task.title.clone(),
77            status,
78            execution_owner: execution_owner.clone(),
79            review_owner: metadata.review_owner.clone(),
80            blocking_reason,
81            acting_capability: acting_capability(task, &metadata, status, members, execution_owner),
82        });
83    }
84
85    resolutions.sort_by_key(|resolution| resolution.task_id);
86    Ok(resolutions)
87}
88
89pub fn runnable_tasks(resolutions: &[TaskResolution]) -> Vec<TaskResolution> {
90    resolutions
91        .iter()
92        .filter(|resolution| resolution.status == ResolutionStatus::Runnable)
93        .cloned()
94        .collect()
95}
96
97fn acting_capability(
98    task: &Task,
99    metadata: &WorkflowMetadata,
100    status: ResolutionStatus,
101    members: &[MemberInstance],
102    execution_owner: Option<String>,
103) -> Option<WorkflowCapability> {
104    match status {
105        ResolutionStatus::Blocked => None,
106        ResolutionStatus::NeedsReview => {
107            if metadata.review_owner.is_some() || has_reviewer(members) {
108                Some(WorkflowCapability::Reviewer)
109            } else {
110                None
111            }
112        }
113        ResolutionStatus::Runnable => {
114            if execution_owner.is_some() {
115                Some(WorkflowCapability::Executor)
116            } else if has_dispatcher(members) {
117                Some(WorkflowCapability::Dispatcher)
118            } else if has_executor(members) {
119                Some(WorkflowCapability::Executor)
120            } else {
121                None
122            }
123        }
124        ResolutionStatus::NeedsAction => {
125            if task.status == "in-progress" && has_executor(members) {
126                Some(WorkflowCapability::Executor)
127            } else if task.status == "backlog" && has_planner(members) {
128                Some(WorkflowCapability::Planner)
129            } else if has_dispatcher(members) {
130                Some(WorkflowCapability::Dispatcher)
131            } else {
132                None
133            }
134        }
135    }
136}
137
138fn blocking_reason(
139    task: &Task,
140    metadata: &WorkflowMetadata,
141    done: &HashSet<u32>,
142) -> Option<String> {
143    if let Some(reason) = task.blocked.as_ref() {
144        return Some(reason.clone());
145    }
146    if let Some(reason) = metadata.blocked_on.as_ref() {
147        return Some(reason.clone());
148    }
149    if task.is_schedule_blocked() {
150        return Some(format!(
151            "scheduled for {}",
152            task.scheduled_for.as_deref().unwrap_or("unknown")
153        ));
154    }
155    task.depends_on
156        .iter()
157        .find(|dep_id| !done.contains(dep_id))
158        .map(|dep_id| format!("unmet dependency #{dep_id}"))
159}
160
161fn load_workflow_metadata(task: &Task) -> Result<WorkflowMetadata> {
162    if task.source_path.as_os_str().is_empty() {
163        return Ok(WorkflowMetadata::default());
164    }
165
166    let content = std::fs::read_to_string(&task.source_path)
167        .with_context(|| format!("failed to read task file: {}", task.source_path.display()))?;
168    let Some(frontmatter) = content
169        .trim_start()
170        .strip_prefix("---")
171        .and_then(|rest| rest.strip_prefix('\n'))
172        .and_then(|rest| rest.split_once("\n---").map(|(frontmatter, _)| frontmatter))
173    else {
174        return Ok(WorkflowMetadata::default());
175    };
176
177    serde_yaml::from_str(frontmatter).context("failed to parse workflow metadata")
178}
179
180fn has_planner(members: &[MemberInstance]) -> bool {
181    members
182        .iter()
183        .any(|member| matches!(member.role_type, RoleType::Architect | RoleType::Manager))
184        || has_executor(members)
185}
186
187fn has_dispatcher(members: &[MemberInstance]) -> bool {
188    members
189        .iter()
190        .any(|member| matches!(member.role_type, RoleType::Manager | RoleType::Architect))
191        || has_executor(members)
192}
193
194fn has_executor(members: &[MemberInstance]) -> bool {
195    members
196        .iter()
197        .any(|member| member.role_type == RoleType::Engineer)
198        || members
199            .iter()
200            .any(|member| matches!(member.role_type, RoleType::Manager | RoleType::Architect))
201}
202
203fn has_reviewer(members: &[MemberInstance]) -> bool {
204    members
205        .iter()
206        .any(|member| matches!(member.role_type, RoleType::Manager | RoleType::Architect))
207        || has_executor(members)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::team::config::TeamConfig;
214    use crate::team::hierarchy::resolve_hierarchy;
215
216    fn members(yaml: &str) -> Vec<MemberInstance> {
217        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
218        resolve_hierarchy(&config).unwrap()
219    }
220
221    fn write_task(tasks_dir: &Path, id: u32, extra_frontmatter: &str) {
222        let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
223        std::fs::write(
224            path,
225            format!(
226                "---\nid: {id}\ntitle: Task {id}\npriority: high\n{extra_frontmatter}class: standard\n---\n\nBody.\n"
227            ),
228        )
229        .unwrap();
230    }
231
232    #[test]
233    fn todo_without_deps_is_runnable() {
234        let tmp = tempfile::tempdir().unwrap();
235        let tasks_dir = tmp.path().join("tasks");
236        std::fs::create_dir_all(&tasks_dir).unwrap();
237        write_task(&tasks_dir, 1, "status: todo\n");
238
239        let resolutions = resolve_board(
240            tmp.path(),
241            &members(
242                r#"
243name: team
244roles:
245  - name: lead
246    role_type: manager
247    agent: claude
248  - name: builder
249    role_type: engineer
250    agent: codex
251"#,
252            ),
253        )
254        .unwrap();
255
256        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
257        assert_eq!(
258            resolutions[0].acting_capability,
259            Some(WorkflowCapability::Dispatcher)
260        );
261    }
262
263    #[test]
264    fn unmet_dependency_is_blocked() {
265        let tmp = tempfile::tempdir().unwrap();
266        let tasks_dir = tmp.path().join("tasks");
267        std::fs::create_dir_all(&tasks_dir).unwrap();
268        write_task(&tasks_dir, 1, "status: todo\n");
269        write_task(&tasks_dir, 2, "status: todo\ndepends_on:\n  - 1\n");
270
271        let resolutions = resolve_board(tmp.path(), &members("name: solo\nroles:\n  - name: builder\n    role_type: engineer\n    agent: codex\n")).unwrap();
272
273        assert_eq!(resolutions[1].status, ResolutionStatus::Blocked);
274        assert_eq!(
275            resolutions[1].blocking_reason.as_deref(),
276            Some("unmet dependency #1")
277        );
278    }
279
280    #[test]
281    fn blocked_on_is_blocked() {
282        let tmp = tempfile::tempdir().unwrap();
283        let tasks_dir = tmp.path().join("tasks");
284        std::fs::create_dir_all(&tasks_dir).unwrap();
285        write_task(
286            &tasks_dir,
287            1,
288            "status: todo\nblocked_on: waiting-for-review\n",
289        );
290
291        let resolutions = resolve_board(tmp.path(), &members("name: solo\nroles:\n  - name: builder\n    role_type: engineer\n    agent: codex\n")).unwrap();
292
293        assert_eq!(resolutions[0].status, ResolutionStatus::Blocked);
294        assert_eq!(
295            resolutions[0].blocking_reason.as_deref(),
296            Some("waiting-for-review")
297        );
298    }
299
300    #[test]
301    fn review_without_disposition_needs_review() {
302        let tmp = tempfile::tempdir().unwrap();
303        let tasks_dir = tmp.path().join("tasks");
304        std::fs::create_dir_all(&tasks_dir).unwrap();
305        write_task(&tasks_dir, 1, "status: review\nreview_owner: lead\n");
306
307        let resolutions = resolve_board(
308            tmp.path(),
309            &members(
310                r#"
311name: pair
312roles:
313  - name: lead
314    role_type: architect
315    agent: claude
316  - name: builder
317    role_type: engineer
318    agent: codex
319"#,
320            ),
321        )
322        .unwrap();
323
324        assert_eq!(resolutions[0].status, ResolutionStatus::NeedsReview);
325        assert_eq!(
326            resolutions[0].acting_capability,
327            Some(WorkflowCapability::Reviewer)
328        );
329    }
330
331    #[test]
332    fn runnable_tasks_filters_only_runnable_items() {
333        let resolutions = vec![
334            TaskResolution {
335                task_id: 1,
336                title: "Task 1".to_string(),
337                status: ResolutionStatus::Runnable,
338                execution_owner: None,
339                review_owner: None,
340                blocking_reason: None,
341                acting_capability: Some(WorkflowCapability::Dispatcher),
342            },
343            TaskResolution {
344                task_id: 2,
345                title: "Task 2".to_string(),
346                status: ResolutionStatus::Blocked,
347                execution_owner: None,
348                review_owner: None,
349                blocking_reason: Some("waiting".to_string()),
350                acting_capability: None,
351            },
352            TaskResolution {
353                task_id: 3,
354                title: "Task 3".to_string(),
355                status: ResolutionStatus::NeedsReview,
356                execution_owner: None,
357                review_owner: None,
358                blocking_reason: None,
359                acting_capability: Some(WorkflowCapability::Reviewer),
360            },
361        ];
362
363        let runnable = runnable_tasks(&resolutions);
364        assert_eq!(runnable.len(), 1);
365        assert_eq!(runnable[0].task_id, 1);
366    }
367
368    fn solo_members() -> Vec<MemberInstance> {
369        members(
370            "name: solo\nroles:\n  - name: builder\n    role_type: engineer\n    agent: codex\n",
371        )
372    }
373
374    #[test]
375    fn scheduled_future_is_blocked() {
376        let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
377        let tmp = tempfile::tempdir().unwrap();
378        let tasks_dir = tmp.path().join("tasks");
379        std::fs::create_dir_all(&tasks_dir).unwrap();
380        write_task(
381            &tasks_dir,
382            1,
383            &format!("status: todo\nscheduled_for: \"{future}\"\n"),
384        );
385
386        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
387        assert_eq!(resolutions[0].status, ResolutionStatus::Blocked);
388        assert!(
389            resolutions[0]
390                .blocking_reason
391                .as_ref()
392                .unwrap()
393                .contains("scheduled for")
394        );
395    }
396
397    #[test]
398    fn scheduled_past_is_runnable() {
399        let past = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
400        let tmp = tempfile::tempdir().unwrap();
401        let tasks_dir = tmp.path().join("tasks");
402        std::fs::create_dir_all(&tasks_dir).unwrap();
403        write_task(
404            &tasks_dir,
405            1,
406            &format!("status: todo\nscheduled_for: \"{past}\"\n"),
407        );
408
409        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
410        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
411        assert!(resolutions[0].blocking_reason.is_none());
412    }
413
414    #[test]
415    fn no_scheduled_for_is_runnable() {
416        let tmp = tempfile::tempdir().unwrap();
417        let tasks_dir = tmp.path().join("tasks");
418        std::fs::create_dir_all(&tasks_dir).unwrap();
419        write_task(&tasks_dir, 1, "status: todo\n");
420
421        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
422        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
423        assert!(resolutions[0].blocking_reason.is_none());
424    }
425
426    #[test]
427    fn scheduled_just_passed_is_runnable() {
428        let just_passed = (chrono::Utc::now() - chrono::Duration::seconds(1)).to_rfc3339();
429        let tmp = tempfile::tempdir().unwrap();
430        let tasks_dir = tmp.path().join("tasks");
431        std::fs::create_dir_all(&tasks_dir).unwrap();
432        write_task(
433            &tasks_dir,
434            1,
435            &format!("status: todo\nscheduled_for: \"{just_passed}\"\n"),
436        );
437
438        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
439        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
440        assert!(resolutions[0].blocking_reason.is_none());
441    }
442
443    // --- done tasks are excluded ---
444
445    #[test]
446    fn done_tasks_excluded_from_resolutions() {
447        let tmp = tempfile::tempdir().unwrap();
448        let tasks_dir = tmp.path().join("tasks");
449        std::fs::create_dir_all(&tasks_dir).unwrap();
450        write_task(&tasks_dir, 1, "status: done\n");
451        write_task(&tasks_dir, 2, "status: todo\n");
452
453        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
454        assert_eq!(resolutions.len(), 1);
455        assert_eq!(resolutions[0].task_id, 2);
456    }
457
458    #[test]
459    fn archived_tasks_excluded_from_resolutions() {
460        let tmp = tempfile::tempdir().unwrap();
461        let tasks_dir = tmp.path().join("tasks");
462        std::fs::create_dir_all(&tasks_dir).unwrap();
463        write_task(&tasks_dir, 1, "status: archived\n");
464
465        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
466        assert!(resolutions.is_empty());
467    }
468
469    // --- dependency resolution ---
470
471    #[test]
472    fn all_deps_met_makes_task_runnable() {
473        let tmp = tempfile::tempdir().unwrap();
474        let tasks_dir = tmp.path().join("tasks");
475        std::fs::create_dir_all(&tasks_dir).unwrap();
476        write_task(&tasks_dir, 1, "status: done\n");
477        write_task(&tasks_dir, 2, "status: done\n");
478        write_task(&tasks_dir, 3, "status: todo\ndepends_on:\n  - 1\n  - 2\n");
479
480        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
481        assert_eq!(resolutions[0].task_id, 3);
482        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
483        assert!(resolutions[0].blocking_reason.is_none());
484    }
485
486    #[test]
487    fn partial_deps_met_is_blocked() {
488        let tmp = tempfile::tempdir().unwrap();
489        let tasks_dir = tmp.path().join("tasks");
490        std::fs::create_dir_all(&tasks_dir).unwrap();
491        write_task(&tasks_dir, 1, "status: done\n");
492        write_task(&tasks_dir, 2, "status: todo\n");
493        write_task(&tasks_dir, 3, "status: todo\ndepends_on:\n  - 1\n  - 2\n");
494
495        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
496        let task3 = resolutions.iter().find(|r| r.task_id == 3).unwrap();
497        assert_eq!(task3.status, ResolutionStatus::Blocked);
498        assert_eq!(
499            task3.blocking_reason.as_deref(),
500            Some("unmet dependency #2")
501        );
502    }
503
504    #[test]
505    fn diamond_dependency_graph() {
506        let tmp = tempfile::tempdir().unwrap();
507        let tasks_dir = tmp.path().join("tasks");
508        std::fs::create_dir_all(&tasks_dir).unwrap();
509        // Diamond: 4 depends on 2,3; both 2,3 depend on 1
510        write_task(&tasks_dir, 1, "status: done\n");
511        write_task(&tasks_dir, 2, "status: done\ndepends_on:\n  - 1\n");
512        write_task(&tasks_dir, 3, "status: done\ndepends_on:\n  - 1\n");
513        write_task(&tasks_dir, 4, "status: todo\ndepends_on:\n  - 2\n  - 3\n");
514
515        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
516        assert_eq!(resolutions[0].task_id, 4);
517        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
518    }
519
520    // --- empty board ---
521
522    #[test]
523    fn empty_board_returns_no_resolutions() {
524        let tmp = tempfile::tempdir().unwrap();
525        let tasks_dir = tmp.path().join("tasks");
526        std::fs::create_dir_all(&tasks_dir).unwrap();
527
528        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
529        assert!(resolutions.is_empty());
530    }
531
532    // --- execution_owner fallback ---
533
534    #[test]
535    fn execution_owner_falls_back_to_claimed_by() {
536        let tmp = tempfile::tempdir().unwrap();
537        let tasks_dir = tmp.path().join("tasks");
538        std::fs::create_dir_all(&tasks_dir).unwrap();
539        write_task(&tasks_dir, 1, "status: todo\nclaimed_by: eng-1-1\n");
540
541        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
542        assert_eq!(resolutions[0].execution_owner.as_deref(), Some("eng-1-1"));
543    }
544
545    // --- blocked field ---
546
547    #[test]
548    fn task_with_blocked_field_is_blocked() {
549        let tmp = tempfile::tempdir().unwrap();
550        let tasks_dir = tmp.path().join("tasks");
551        std::fs::create_dir_all(&tasks_dir).unwrap();
552        write_task(&tasks_dir, 1, "status: todo\nblocked: waiting-for-api\n");
553
554        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
555        assert_eq!(resolutions[0].status, ResolutionStatus::Blocked);
556        assert_eq!(
557            resolutions[0].blocking_reason.as_deref(),
558            Some("waiting-for-api")
559        );
560    }
561
562    // --- status variations ---
563
564    #[test]
565    fn backlog_status_is_runnable() {
566        let tmp = tempfile::tempdir().unwrap();
567        let tasks_dir = tmp.path().join("tasks");
568        std::fs::create_dir_all(&tasks_dir).unwrap();
569        write_task(&tasks_dir, 1, "status: backlog\n");
570
571        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
572        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
573    }
574
575    #[test]
576    fn in_progress_is_runnable() {
577        let tmp = tempfile::tempdir().unwrap();
578        let tasks_dir = tmp.path().join("tasks");
579        std::fs::create_dir_all(&tasks_dir).unwrap();
580        write_task(&tasks_dir, 1, "status: in-progress\n");
581
582        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
583        assert_eq!(resolutions[0].status, ResolutionStatus::Runnable);
584    }
585
586    #[test]
587    fn unknown_status_is_needs_action() {
588        let tmp = tempfile::tempdir().unwrap();
589        let tasks_dir = tmp.path().join("tasks");
590        std::fs::create_dir_all(&tasks_dir).unwrap();
591        write_task(&tasks_dir, 1, "status: custom-status\n");
592
593        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
594        assert_eq!(resolutions[0].status, ResolutionStatus::NeedsAction);
595    }
596
597    // --- capability resolution ---
598
599    #[test]
600    fn runnable_with_owner_gets_executor_capability() {
601        let tmp = tempfile::tempdir().unwrap();
602        let tasks_dir = tmp.path().join("tasks");
603        std::fs::create_dir_all(&tasks_dir).unwrap();
604        write_task(&tasks_dir, 1, "status: todo\nclaimed_by: builder-1-1\n");
605
606        let resolutions = resolve_board(
607            tmp.path(),
608            &members(
609                "name: team\nroles:\n  - name: lead\n    role_type: manager\n    agent: claude\n  - name: builder\n    role_type: engineer\n    agent: codex\n",
610            ),
611        )
612        .unwrap();
613        assert_eq!(
614            resolutions[0].acting_capability,
615            Some(WorkflowCapability::Executor)
616        );
617    }
618
619    #[test]
620    fn resolutions_sorted_by_task_id() {
621        let tmp = tempfile::tempdir().unwrap();
622        let tasks_dir = tmp.path().join("tasks");
623        std::fs::create_dir_all(&tasks_dir).unwrap();
624        write_task(&tasks_dir, 5, "status: todo\n");
625        write_task(&tasks_dir, 2, "status: todo\n");
626        write_task(&tasks_dir, 8, "status: todo\n");
627
628        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
629        let ids: Vec<u32> = resolutions.iter().map(|r| r.task_id).collect();
630        assert_eq!(ids, vec![2, 5, 8]);
631    }
632
633    // --- blocked takes priority over deps ---
634
635    #[test]
636    fn blocked_field_takes_priority_over_dependency_check() {
637        let tmp = tempfile::tempdir().unwrap();
638        let tasks_dir = tmp.path().join("tasks");
639        std::fs::create_dir_all(&tasks_dir).unwrap();
640        write_task(&tasks_dir, 1, "status: done\n");
641        write_task(
642            &tasks_dir,
643            2,
644            "status: todo\nblocked: manual-hold\ndepends_on:\n  - 1\n",
645        );
646
647        let resolutions = resolve_board(tmp.path(), &solo_members()).unwrap();
648        let task2 = resolutions.iter().find(|r| r.task_id == 2).unwrap();
649        assert_eq!(task2.status, ResolutionStatus::Blocked);
650        assert_eq!(task2.blocking_reason.as_deref(), Some("manual-hold"));
651    }
652}