docs: Document hierarchical blocking and add deep hierarchy test [fixes bd-62, bd-61]
- Add "Hierarchical Blocking" section to README explaining blocking propagation through parent-child hierarchies - Clarify that 'blocks' + 'parent-child' create transitive blocking up to 50 levels deep - Note that 'related' and 'discovered-from' do NOT propagate blocking - Add TestDeepHierarchyBlocking to verify 50-level deep hierarchy works correctly - All tests pass successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
30
README.md
30
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user