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