diff --git a/README.md b/README.md index d6a31a23..97eaa521 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,36 @@ Beads has four types of dependencies: Only `blocks` dependencies affect the ready work queue. +### Hierarchical Blocking + +**Important:** Blocking propagates through parent-child hierarchies. When a parent (epic) is blocked, all of its children are automatically blocked, even if they have no direct blockers. + +This transitive blocking behavior ensures that subtasks don't show up as ready work when their parent epic is blocked: + +```bash +# Create an epic and a child task +bd create "Epic: User Authentication" -t epic -p 1 +bd create "Task: Add login form" -t task -p 1 +bd dep add bd-2 bd-1 --type parent-child # bd-2 is child of bd-1 + +# Block the epic +bd create "Design authentication system" -t task -p 0 +bd dep add bd-1 bd-3 --type blocks # bd-1 blocked by bd-3 + +# Now both bd-1 (epic) AND bd-2 (child task) are blocked +bd ready # Neither will show up +bd blocked # Shows both bd-1 and bd-2 as blocked +``` + +**Blocking propagation rules:** +- `blocks` + `parent-child` together create transitive blocking (up to 50 levels deep) +- Children of blocked parents are automatically blocked +- Grandchildren, great-grandchildren, etc. are also blocked recursively +- `related` and `discovered-from` do **NOT** propagate blocking +- Only direct `blocks` dependencies and inherited parent blocking affect ready work + +This design ensures that work on child tasks doesn't begin until the parent epic's blockers are resolved, maintaining logical work order in complex hierarchies. + ### Dependency Type Usage - **blocks**: Use when issue X cannot start until issue Y is completed diff --git a/internal/storage/sqlite/ready_test.go b/internal/storage/sqlite/ready_test.go index 4e15328c..aed3327d 100644 --- a/internal/storage/sqlite/ready_test.go +++ b/internal/storage/sqlite/ready_test.go @@ -658,3 +658,96 @@ func TestReadyIssuesViewMatchesGetReadyWork(t *testing.T) { t.Errorf("Expected task1 to be blocked in VIEW (parent is blocked)") } } + +// TestDeepHierarchyBlocking tests blocking propagation through 50-level deep hierarchy +func TestDeepHierarchyBlocking(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a blocker at the root + blocker := &types.Issue{Title: "Root Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + store.CreateIssue(ctx, blocker, "test-user") + + // Create 50-level hierarchy: root → level1 → level2 → ... → level50 + var issues []*types.Issue + for i := 0; i < 50; i++ { + issue := &types.Issue{ + Title: "Level " + string(rune(i)), + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeEpic, + } + store.CreateIssue(ctx, issue, "test-user") + issues = append(issues, issue) + + if i == 0 { + // First level: blocked by blocker + store.AddDependency(ctx, &types.Dependency{ + IssueID: issue.ID, + DependsOnID: blocker.ID, + Type: types.DepBlocks, + }, "test-user") + } else { + // Each subsequent level: child of previous level + store.AddDependency(ctx, &types.Dependency{ + IssueID: issue.ID, + DependsOnID: issues[i-1].ID, + Type: types.DepParentChild, + }, "test-user") + } + } + + // Get ready work + ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) + if err != nil { + t.Fatalf("GetReadyWork failed: %v", err) + } + + // Build set of ready IDs + readyIDs := make(map[string]bool) + for _, issue := range ready { + readyIDs[issue.ID] = true + } + + // Only the blocker should be ready + if len(ready) != 1 { + t.Errorf("Expected exactly 1 ready issue (the blocker), got %d", len(ready)) + } + + if !readyIDs[blocker.ID] { + t.Errorf("Expected blocker to be ready") + } + + // All 50 levels should be blocked + for i, issue := range issues { + if readyIDs[issue.ID] { + t.Errorf("Expected level %d (issue %s) to be blocked, but it was ready", i, issue.ID) + } + } + + // Now close the blocker and verify all levels become ready + store.CloseIssue(ctx, blocker.ID, "Done", "test-user") + + ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) + if err != nil { + t.Fatalf("GetReadyWork failed after closing blocker: %v", err) + } + + // All 50 levels should now be ready + if len(ready) != 50 { + t.Errorf("Expected 50 ready issues after closing blocker, got %d", len(ready)) + } + + readyIDs = make(map[string]bool) + for _, issue := range ready { + readyIDs[issue.ID] = true + } + + for i, issue := range issues { + if !readyIDs[issue.ID] { + t.Errorf("Expected level %d (issue %s) to be ready after blocker closed, but it was blocked", i, issue.ID) + } + } +}