diff --git a/src/db/schema.rs b/src/db/schema.rs index 926987b..2d7097c 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -185,27 +185,41 @@ const INDEX_LEARNING_TAGS_TAG: &str = "CREATE INDEX IF NOT EXISTS idx_learning_tags_tag ON learning_tags(tag);"; const CREATE_TASK_READINESS_VIEW: &str = r#" -CREATE VIEW IF NOT EXISTS task_readiness AS +DROP VIEW IF EXISTS task_readiness; +CREATE VIEW task_readiness AS +WITH RECURSIVE ancestor_chain(task_id, ancestor_id, depth) AS ( + SELECT id, id, 0 FROM tasks + UNION ALL + SELECT ac.task_id, t.parent_task_id, ac.depth + 1 + FROM ancestor_chain ac + JOIN tasks t ON t.id = ac.ancestor_id + WHERE t.parent_task_id IS NOT NULL + AND ac.depth < 32 +), +effective_unmet AS ( + SELECT + ac.task_id, + COUNT(CASE + WHEN d.kind IN ('blocks', 'feeds_into') + AND upstream.status NOT IN ('done', 'done_partial') + THEN 1 + END) AS unmet_deps + FROM ancestor_chain ac + LEFT JOIN dependencies d ON d.to_task = ac.ancestor_id + LEFT JOIN tasks upstream ON upstream.id = d.from_task + GROUP BY ac.task_id +) SELECT t.id, t.status, - COUNT(CASE - WHEN d.kind IN ('blocks', 'feeds_into') - AND upstream.status NOT IN ('done', 'done_partial') - THEN 1 - END) AS unmet_deps, + COALESCE(eu.unmet_deps, 0) AS unmet_deps, CASE WHEN t.status = 'pending' - AND COUNT(CASE - WHEN d.kind IN ('blocks','feeds_into') - AND upstream.status NOT IN ('done','done_partial') - THEN 1 END) = 0 + AND COALESCE(eu.unmet_deps, 0) = 0 THEN 1 ELSE 0 END AS promotable FROM tasks t -LEFT JOIN dependencies d ON d.to_task = t.id -LEFT JOIN tasks upstream ON upstream.id = d.from_task -GROUP BY t.id; +LEFT JOIN effective_unmet eu ON eu.task_id = t.id; "#; pub fn init_db(path: &str) -> Result { diff --git a/src/db/tests.rs b/src/db/tests.rs index 1e93128..8491fe7 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -132,6 +132,207 @@ fn promote_sweep_moves_pending_to_ready() { assert_eq!(updated.status, TaskStatus::Ready); } +#[test] +fn test_child_of_composite_with_unmet_parent_deps_not_ready() { + let db_path = test_db_path(); + let db = init_db(&db_path).unwrap(); + let project = create_project(&db, "CompositeBlocked", None, None).unwrap(); + + let blocker = create_task( + &db, + &make_task(&project.id, "blocker", TaskStatus::Pending), + &[], + ) + .unwrap(); + + let mut parent = make_task(&project.id, "parent", TaskStatus::Pending); + parent.is_composite = true; + let parent = create_task(&db, &parent, &[]).unwrap(); + + let mut child = make_task(&project.id, "child", TaskStatus::Pending); + child.parent_task_id = Some(parent.id.clone()); + let child = create_task(&db, &child, &[]).unwrap(); + + add_dependency( + &db, + &blocker.id, + &parent.id, + DependencyKind::FeedsInto, + DependencyCondition::All, + None, + ) + .unwrap(); + + promote_ready_tasks(&db).unwrap(); + + assert_eq!( + get_task(&db, &blocker.id).unwrap().status, + TaskStatus::Ready + ); + assert_eq!( + get_task(&db, &parent.id).unwrap().status, + TaskStatus::Pending + ); + assert_eq!( + get_task(&db, &child.id).unwrap().status, + TaskStatus::Pending + ); +} + +#[test] +fn test_child_promotes_when_parent_deps_satisfied() { + let db_path = test_db_path(); + let db = init_db(&db_path).unwrap(); + let project = create_project(&db, "CompositeUnblocked", None, None).unwrap(); + + let blocker = create_task( + &db, + &make_task(&project.id, "blocker", TaskStatus::Pending), + &[], + ) + .unwrap(); + + let mut parent = make_task(&project.id, "parent", TaskStatus::Pending); + parent.is_composite = true; + let parent = create_task(&db, &parent, &[]).unwrap(); + + let mut child = make_task(&project.id, "child", TaskStatus::Pending); + child.parent_task_id = Some(parent.id.clone()); + let child = create_task(&db, &child, &[]).unwrap(); + + add_dependency( + &db, + &blocker.id, + &parent.id, + DependencyKind::FeedsInto, + DependencyCondition::All, + None, + ) + .unwrap(); + + promote_ready_tasks(&db).unwrap(); + complete_task(&db, &blocker.id, None).unwrap(); + promote_ready_tasks(&db).unwrap(); + + assert_eq!(get_task(&db, &parent.id).unwrap().status, TaskStatus::Ready); + assert_eq!(get_task(&db, &child.id).unwrap().status, TaskStatus::Ready); +} + +#[test] +fn test_grandchild_inherits_through_two_levels() { + let db_path = test_db_path(); + let db = init_db(&db_path).unwrap(); + let project = create_project(&db, "CompositeGrandchild", None, None).unwrap(); + + let blocker = create_task( + &db, + &make_task(&project.id, "blocker", TaskStatus::Pending), + &[], + ) + .unwrap(); + + let mut parent = make_task(&project.id, "parent", TaskStatus::Pending); + parent.is_composite = true; + let parent = create_task(&db, &parent, &[]).unwrap(); + + let mut child = make_task(&project.id, "child", TaskStatus::Pending); + child.parent_task_id = Some(parent.id.clone()); + child.is_composite = true; + let child = create_task(&db, &child, &[]).unwrap(); + + let mut grandchild = make_task(&project.id, "grandchild", TaskStatus::Pending); + grandchild.parent_task_id = Some(child.id.clone()); + let grandchild = create_task(&db, &grandchild, &[]).unwrap(); + + add_dependency( + &db, + &blocker.id, + &parent.id, + DependencyKind::Blocks, + DependencyCondition::All, + None, + ) + .unwrap(); + + promote_ready_tasks(&db).unwrap(); + + assert_eq!( + get_task(&db, &blocker.id).unwrap().status, + TaskStatus::Ready + ); + assert_eq!( + get_task(&db, &parent.id).unwrap().status, + TaskStatus::Pending + ); + assert_eq!( + get_task(&db, &child.id).unwrap().status, + TaskStatus::Pending + ); + assert_eq!( + get_task(&db, &grandchild.id).unwrap().status, + TaskStatus::Pending + ); + + complete_task(&db, &blocker.id, None).unwrap(); + promote_ready_tasks(&db).unwrap(); + + assert_eq!(get_task(&db, &parent.id).unwrap().status, TaskStatus::Ready); + assert_eq!(get_task(&db, &child.id).unwrap().status, TaskStatus::Ready); + assert_eq!( + get_task(&db, &grandchild.id).unwrap().status, + TaskStatus::Ready + ); +} + +#[test] +fn test_no_parent_existing_behavior_preserved() { + let db_path = test_db_path(); + let db = init_db(&db_path).unwrap(); + let project = create_project(&db, "FlatDeps", None, None).unwrap(); + + let upstream = create_task( + &db, + &make_task(&project.id, "upstream", TaskStatus::Pending), + &[], + ) + .unwrap(); + let downstream = create_task( + &db, + &make_task(&project.id, "downstream", TaskStatus::Pending), + &[], + ) + .unwrap(); + + add_dependency( + &db, + &upstream.id, + &downstream.id, + DependencyKind::FeedsInto, + DependencyCondition::All, + None, + ) + .unwrap(); + + promote_ready_tasks(&db).unwrap(); + + assert_eq!( + get_task(&db, &upstream.id).unwrap().status, + TaskStatus::Ready + ); + assert_eq!( + get_task(&db, &downstream.id).unwrap().status, + TaskStatus::Pending + ); + + complete_task(&db, &upstream.id, None).unwrap(); + promote_ready_tasks(&db).unwrap(); + + assert_eq!( + get_task(&db, &downstream.id).unwrap().status, + TaskStatus::Ready + ); +} + #[test] fn sweeper_reclaims_retries_and_rolls_up_composites() { let db_path = test_db_path();