1#![cfg_attr(not(test), allow(dead_code))]
2
3use 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}