diff --git a/.boop/changelogs/patch-01kj38wz92k71p6cz1r85jgye0.md b/.boop/changelogs/patch-01kj38wz92k71p6cz1r85jgye0.md new file mode 100644 index 0000000..b0a35b7 --- /dev/null +++ b/.boop/changelogs/patch-01kj38wz92k71p6cz1r85jgye0.md @@ -0,0 +1,13 @@ +### Fix task.next events not firing on project dependency completion + +Workers subscribing to `task.next` events were never notified when a project dependency was satisfied through auto-completion. This caused tasks in dependent projects to sit idle even though `granary next --all` correctly showed them as actionable. + +**Root cause:** The `trg_task_next_on_project_dep_completed` trigger fired when a project's status changed to `'done'` or `'archived'`, but the auto-complete system sets projects to `'completed'` — a status the trigger didn't recognize. Since no code path ever sets a project to `'done'`, this cascade trigger has never fired. + +**What changed:** + +- The `trg_task_next_on_project_dep_completed` trigger now recognizes `'completed'` as a valid completion status, so it fires when auto-complete transitions a project +- All other `task.next` triggers updated to include `'completed'` in project dependency status checks for consistency +- Rust-side `next` queries updated to treat `'completed'` projects as satisfied dependencies (defensive, previously handled by the task-existence subquery) + +**Impact:** Workers will now correctly pick up tasks as soon as all tasks in a dependency project are done, without needing the dependency project to be manually archived. \ No newline at end of file diff --git a/migrations/20260216000003_project_dep_task_completion_trigger.sql b/migrations/20260216000003_project_dep_task_completion_trigger.sql new file mode 100644 index 0000000..4c6dbb3 --- /dev/null +++ b/migrations/20260216000003_project_dep_task_completion_trigger.sql @@ -0,0 +1,304 @@ +-- Fix: 'completed' status not recognized in project dependency triggers and queries +-- +-- The project auto-complete trigger (trg_project_auto_complete) sets projects to +-- status 'completed', but the project-dependency triggers only checked for 'done' +-- and 'archived'. This caused task.next events to never be emitted when a project +-- dependency was satisfied via auto-completion (all tasks done → project auto- +-- completes to 'completed' → trigger doesn't fire because 'completed' != 'done'). +-- +-- Fix: Add 'completed' to all project-dependency status checks in triggers. +-- Also add a new trigger for the edge case where all tasks in a dependency +-- project complete but the project hasn't auto-completed yet (race/ordering). + +-------------------------------------------------------------------------------- +-- 1. Fix trg_task_next_on_project_dep_completed to recognize 'completed' status +-------------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_task_next_on_project_dep_completed; + +CREATE TRIGGER trg_task_next_on_project_dep_completed +AFTER UPDATE ON projects +WHEN NEW.status IN ('done', 'completed', 'archived') AND OLD.status NOT IN ('done', 'completed', 'archived') +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + SELECT 'task.next', 'task', t.id, NULL, NULL, + json_object( + 'id', t.id, 'project_id', t.project_id, + 'title', t.title, 'priority', t.priority, + 'status', t.status, 'owner', t.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + FROM tasks t + JOIN project_dependencies pd ON pd.project_id = t.project_id + WHERE pd.depends_on_project_id = NEW.id + AND t.status = 'todo' + AND t.blocked_reason IS NULL + AND (t.claim_owner IS NULL OR t.claim_lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + -- No unmet task dependencies + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td + JOIN tasks dep ON dep.id = td.depends_on_task_id + WHERE td.task_id = t.id + AND dep.status != 'done' + ) + -- No other unmet project dependencies + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd2 + JOIN projects dep_p ON dep_p.id = pd2.depends_on_project_id + WHERE pd2.project_id = t.project_id + AND pd2.depends_on_project_id != NEW.id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd2.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + -- Task's own project must be active + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.status = 'active' + ); +END; + +-------------------------------------------------------------------------------- +-- 2. Recreate all task.next triggers with 'completed' in project dep checks +-------------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_task_next_on_insert_todo; +DROP TRIGGER IF EXISTS trg_task_next_on_status_todo; +DROP TRIGGER IF EXISTS trg_task_next_on_dep_completed; +DROP TRIGGER IF EXISTS trg_task_next_on_unblocked; +DROP TRIGGER IF EXISTS trg_task_next_on_released; +DROP TRIGGER IF EXISTS trg_task_next_on_dep_removed; + +-- 2a. New task created as 'todo' with no blockers +CREATE TRIGGER trg_task_next_on_insert_todo +AFTER INSERT ON tasks +WHEN NEW.status = 'todo' + AND NEW.blocked_reason IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td + JOIN tasks dep ON dep.id = td.depends_on_task_id + WHERE td.task_id = NEW.id AND dep.status != 'done' + ) + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd + JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id + WHERE pd.project_id = NEW.project_id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = NEW.project_id AND p.status = 'active' + ) +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + VALUES ( + 'task.next', 'task', NEW.id, NULL, NULL, + json_object( + 'id', NEW.id, 'project_id', NEW.project_id, + 'title', NEW.title, 'priority', NEW.priority, + 'status', NEW.status, 'owner', NEW.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ); +END; + +-- 2b. Task transitions to 'todo' status +CREATE TRIGGER trg_task_next_on_status_todo +AFTER UPDATE ON tasks +WHEN NEW.status = 'todo' + AND NEW.blocked_reason IS NULL + AND (NEW.claim_owner IS NULL OR NEW.claim_lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td + JOIN tasks dep ON dep.id = td.depends_on_task_id + WHERE td.task_id = NEW.id AND dep.status != 'done' + ) + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd + JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id + WHERE pd.project_id = NEW.project_id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = NEW.project_id AND p.status = 'active' + ) +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + VALUES ( + 'task.next', 'task', NEW.id, NULL, NULL, + json_object( + 'id', NEW.id, 'project_id', NEW.project_id, + 'title', NEW.title, 'priority', NEW.priority, + 'status', NEW.status, 'owner', NEW.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ); +END; + +-- 2c. Task dependency completed → unblocks dependents +CREATE TRIGGER trg_task_next_on_dep_completed +AFTER UPDATE ON tasks +WHEN OLD.status != 'done' AND NEW.status = 'done' +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + SELECT 'task.next', 'task', t.id, NULL, NULL, + json_object( + 'id', t.id, 'project_id', t.project_id, + 'title', t.title, 'priority', t.priority, + 'status', t.status, 'owner', t.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + FROM tasks t + JOIN task_dependencies td ON td.task_id = t.id + WHERE td.depends_on_task_id = NEW.id + AND t.status = 'todo' + AND t.blocked_reason IS NULL + AND (t.claim_owner IS NULL OR t.claim_lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td2 + JOIN tasks dep ON dep.id = td2.depends_on_task_id + WHERE td2.task_id = t.id + AND dep.id != NEW.id + AND dep.status != 'done' + ) + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd + JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id + WHERE pd.project_id = t.project_id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.status = 'active' + ); +END; + +-- 2d. Task unblocked (blocked_reason cleared) +CREATE TRIGGER trg_task_next_on_unblocked +AFTER UPDATE ON tasks +WHEN OLD.blocked_reason IS NOT NULL AND NEW.blocked_reason IS NULL + AND NEW.status = 'todo' + AND (NEW.claim_owner IS NULL OR NEW.claim_lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td + JOIN tasks dep ON dep.id = td.depends_on_task_id + WHERE td.task_id = NEW.id AND dep.status != 'done' + ) + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd + JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id + WHERE pd.project_id = NEW.project_id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = NEW.project_id AND p.status = 'active' + ) +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + VALUES ( + 'task.next', 'task', NEW.id, NULL, NULL, + json_object( + 'id', NEW.id, 'project_id', NEW.project_id, + 'title', NEW.title, 'priority', NEW.priority, + 'status', NEW.status, 'owner', NEW.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ); +END; + +-- 2e. Task released (claim cleared) +CREATE TRIGGER trg_task_next_on_released +AFTER UPDATE ON tasks +WHEN OLD.claim_owner IS NOT NULL AND NEW.claim_owner IS NULL + AND NEW.status = 'todo' + AND NEW.blocked_reason IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td + JOIN tasks dep ON dep.id = td.depends_on_task_id + WHERE td.task_id = NEW.id AND dep.status != 'done' + ) + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd + JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id + WHERE pd.project_id = NEW.project_id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = NEW.project_id AND p.status = 'active' + ) +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + VALUES ( + 'task.next', 'task', NEW.id, NULL, NULL, + json_object( + 'id', NEW.id, 'project_id', NEW.project_id, + 'title', NEW.title, 'priority', NEW.priority, + 'status', NEW.status, 'owner', NEW.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ); +END; + +-- 2f. Dependency removed → task may become actionable +CREATE TRIGGER trg_task_next_on_dep_removed +AFTER DELETE ON task_dependencies +BEGIN + INSERT INTO events (event_type, entity_type, entity_id, actor, session_id, payload, created_at) + SELECT 'task.next', 'task', t.id, NULL, NULL, + json_object( + 'id', t.id, 'project_id', t.project_id, + 'title', t.title, 'priority', t.priority, + 'status', t.status, 'owner', t.owner + ), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + FROM tasks t + WHERE t.id = OLD.task_id + AND t.status = 'todo' + AND t.blocked_reason IS NULL + AND (t.claim_owner IS NULL OR t.claim_lease_expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + AND NOT EXISTS ( + SELECT 1 FROM task_dependencies td2 + JOIN tasks dep ON dep.id = td2.depends_on_task_id + WHERE td2.task_id = t.id + AND dep.status != 'done' + ) + AND NOT EXISTS ( + SELECT 1 FROM project_dependencies pd + JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id + WHERE pd.project_id = t.project_id + AND dep_p.status NOT IN ('done', 'completed', 'archived') + AND EXISTS ( + SELECT 1 FROM tasks dep_t + WHERE dep_t.project_id = pd.depends_on_project_id + AND dep_t.status != 'done' + ) + ) + AND EXISTS ( + SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.status = 'active' + ); +END; diff --git a/src/db/mod.rs b/src/db/mod.rs index 8b291db..c4f91e1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -561,7 +561,7 @@ pub mod tasks { SELECT 1 FROM project_dependencies pd JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id WHERE pd.project_id = t.project_id - AND dep_p.status NOT IN ('done', 'archived') + AND dep_p.status NOT IN ('done', 'completed', 'archived') AND EXISTS ( SELECT 1 FROM tasks dep_t WHERE dep_t.project_id = pd.depends_on_project_id @@ -638,7 +638,7 @@ pub mod tasks { SELECT 1 FROM project_dependencies pd JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id WHERE pd.project_id = t.project_id - AND dep_p.status NOT IN ('done', 'archived') + AND dep_p.status NOT IN ('done', 'completed', 'archived') AND EXISTS ( SELECT 1 FROM tasks dep_t WHERE dep_t.project_id = pd.depends_on_project_id @@ -2100,7 +2100,7 @@ pub mod initiative_tasks { SELECT COUNT(*) FROM project_dependencies pd JOIN projects dep_p ON dep_p.id = pd.depends_on_project_id WHERE pd.project_id = ? - AND dep_p.status NOT IN ('done', 'archived') + AND dep_p.status NOT IN ('done', 'completed', 'archived') AND EXISTS ( SELECT 1 FROM tasks t WHERE t.project_id = pd.depends_on_project_id diff --git a/tests/trigger_events_e2e.rs b/tests/trigger_events_e2e.rs index 5d0af3a..edff93a 100644 --- a/tests/trigger_events_e2e.rs +++ b/tests/trigger_events_e2e.rs @@ -1114,6 +1114,116 @@ async fn test_project_next_follows_task_next() { assert_eq!(payload["status"], "active"); } +// ============================================================================ +// Project dependency satisfaction via task completion (task.next cascade) +// ============================================================================ + +#[tokio::test] +async fn test_task_next_emitted_when_project_dep_satisfied_via_auto_complete() { + let pool = setup_pool().await; + + // Create two projects: Backend (dependency) and Frontend (depends on Backend) + let backend = create_project(&pool, "backend").await; + let frontend = create_project(&pool, "frontend").await; + + // Frontend depends on Backend + db::project_dependencies::add(&pool, &frontend.id, &backend.id) + .await + .expect("add project dep"); + + // Create a task in Backend (the dependency project) + let backend_task = create_task(&pool, &backend.id, "setup API", "in_progress").await; + + // Create a task in Frontend (should be blocked by project dep) + let frontend_task = create_task(&pool, &frontend.id, "build UI", "todo").await; + + // Frontend task should NOT get a task.next event (project dep unmet) + let next_before: Vec<_> = events_of_type(&pool, "task.next") + .await + .into_iter() + .filter(|e| e.entity_id == frontend_task.id) + .collect(); + assert!( + next_before.is_empty(), + "frontend task should not have task.next while backend has incomplete tasks" + ); + + // Complete the backend task → triggers auto-complete → backend status = 'completed' + // → should cascade task.next for frontend task + let mut backend_task = db::tasks::get(&pool, &backend_task.id) + .await + .expect("get") + .unwrap(); + backend_task.status = "done".to_string(); + backend_task.completed_at = Some(chrono::Utc::now().to_rfc3339()); + db::tasks::update(&pool, &backend_task) + .await + .expect("complete backend task"); + + // Backend project should have auto-completed to 'completed' + let backend_proj = db::projects::get(&pool, &backend.id) + .await + .expect("get") + .unwrap(); + assert_eq!( + backend_proj.status, "completed", + "backend project should auto-complete" + ); + + // Frontend task should NOW have a task.next event + let next_after: Vec<_> = events_of_type(&pool, "task.next") + .await + .into_iter() + .filter(|e| e.entity_id == frontend_task.id) + .collect(); + assert!( + !next_after.is_empty(), + "frontend task should get task.next after project dependency is satisfied via auto-complete" + ); +} + +#[tokio::test] +async fn test_task_next_not_emitted_when_partial_project_dep_satisfied() { + let pool = setup_pool().await; + + // Project C depends on both A and B + let proj_a = create_project(&pool, "dep-a").await; + let proj_b = create_project(&pool, "dep-b").await; + let proj_c = create_project(&pool, "dependent-c").await; + + db::project_dependencies::add(&pool, &proj_c.id, &proj_a.id) + .await + .expect("add dep a"); + db::project_dependencies::add(&pool, &proj_c.id, &proj_b.id) + .await + .expect("add dep b"); + + // Tasks in each project + let task_a = create_task(&pool, &proj_a.id, "work in A", "in_progress").await; + let _task_b = create_task(&pool, &proj_b.id, "work in B", "in_progress").await; + let task_c = create_task(&pool, &proj_c.id, "work in C", "todo").await; + + // Complete all tasks in A → A auto-completes, but B still has work + let mut task_a = db::tasks::get(&pool, &task_a.id) + .await + .expect("get") + .unwrap(); + task_a.status = "done".to_string(); + task_a.completed_at = Some(chrono::Utc::now().to_rfc3339()); + db::tasks::update(&pool, &task_a).await.expect("complete a"); + + // C's task should NOT get task.next (B still has incomplete tasks) + let next_events: Vec<_> = events_of_type(&pool, "task.next") + .await + .into_iter() + .filter(|e| e.entity_id == task_c.id) + .collect(); + assert!( + next_events.is_empty(), + "task in C should not get task.next when only one of two project deps is satisfied" + ); +} + // ============================================================================ // Project auto-complete trigger tests // ============================================================================