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
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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}