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:
Steve Yegge
2025-10-14 13:14:39 -07:00
parent 4479bc41e6
commit 3a60f22b50
2 changed files with 123 additions and 0 deletions

View File

@@ -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

View File

@@ -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)
}
}
}