feat: add waits-for dependency type for fanout gates (bd-xo1o.2)

Adds 'waits-for' dependency type for dynamic molecule bonding:
- DepWaitsFor blocks an issue until spawner's children are closed
- Two gate types: all-children (wait for all) or any-children (first)
- Updated blocked_cache.go CTE to handle waits-for dependencies
- Added --waits-for and --waits-for-gate flags to bd create command
- Added WaitsForMeta struct for gate metadata storage
- Full test coverage for all gate types and dynamic child scenarios

This enables patrol molecules to wait for dynamically-bonded arms to
complete before proceeding (Christmas Ornament pattern).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 03:59:43 -08:00
parent 37fd1ce614
commit fa9a88e2a3
7 changed files with 328 additions and 5 deletions

View File

@@ -13,14 +13,18 @@
// - It has a 'blocks' dependency on an open/in_progress/blocked issue (direct blocking)
// - It has a 'blocks' dependency on an external:* reference (cross-project blocking, bd-om4a)
// - It has a 'conditional-blocks' dependency where the blocker hasn't failed (bd-kzda)
// - It has a 'waits-for' dependency on a spawner with unclosed children (bd-xo1o.2)
// - Its parent is blocked and it's connected via 'parent-child' dependency (transitive blocking)
//
// WaitsFor gates (bd-xo1o.2): B waits for spawner A's dynamically-bonded children.
// Gate types: "all-children" (default, blocked until ALL close) or "any-children" (until ANY closes).
//
// Conditional blocks (bd-kzda): B runs only if A fails. B is blocked until A is closed
// with a failure close reason (failed, rejected, wontfix, cancelled, abandoned, etc.).
// If A succeeds (closed without failure), B stays blocked.
//
// The cache is maintained automatically by invalidating and rebuilding whenever:
// - A 'blocks', 'conditional-blocks', or 'parent-child' dependency is added or removed
// - A 'blocks', 'conditional-blocks', 'waits-for', or 'parent-child' dependency is added or removed
// - Any issue's status changes (affects whether it blocks others)
// - An issue is closed (closed issues don't block others; conditional-blocks checks close_reason)
//
@@ -121,9 +125,10 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
// Only includes local blockers (open issues) - external refs are resolved
// lazily at query time by GetReadyWork (bd-zmmy supersedes bd-om4a)
//
// Handles three blocking types:
// Handles four blocking types:
// - 'blocks': B is blocked until A is closed (any close reason)
// - 'conditional-blocks': B is blocked until A is closed with failure (bd-kzda)
// - 'waits-for': B is blocked until all children of spawner A are closed (bd-xo1o.2)
// - 'parent-child': Propagates blockage to children
//
// Failure close reasons are detected by matching keywords in close_reason:
@@ -171,6 +176,43 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%aborted%'
))
)
UNION
-- 'waits-for' dependencies: B blocked until all children of spawner closed (bd-xo1o.2)
-- This is a fanout gate for dynamic molecule bonding
-- B waits for A (spawner), blocked while ANY child of A is not closed
-- Gate type from metadata: "all-children" (default) or "any-children"
SELECT DISTINCT d.issue_id
FROM dependencies d
WHERE d.type = 'waits-for'
AND (
-- Default gate: "all-children" - blocked while ANY child is open
COALESCE(json_extract(d.metadata, '$.gate'), 'all-children') = 'all-children'
AND EXISTS (
SELECT 1 FROM dependencies child_dep
JOIN issues child ON child_dep.issue_id = child.id
WHERE child_dep.type = 'parent-child'
AND child_dep.depends_on_id = COALESCE(
json_extract(d.metadata, '$.spawner_id'),
d.depends_on_id
)
AND child.status NOT IN ('closed', 'tombstone')
)
OR
-- Alternative gate: "any-children" - blocked until ANY child closes
COALESCE(json_extract(d.metadata, '$.gate'), 'all-children') = 'any-children'
AND NOT EXISTS (
SELECT 1 FROM dependencies child_dep
JOIN issues child ON child_dep.issue_id = child.id
WHERE child_dep.type = 'parent-child'
AND child_dep.depends_on_id = COALESCE(
json_extract(d.metadata, '$.spawner_id'),
d.depends_on_id
)
AND child.status IN ('closed', 'tombstone')
)
)
),
-- Step 2: Propagate blockage to all descendants via parent-child

View File

@@ -460,3 +460,186 @@ func TestConditionalBlocksVariousFailureKeywords(t *testing.T) {
})
}
}
// TestWaitsForAllChildren tests the waits-for dependency with all-children gate (bd-xo1o.2)
// B waits for spawner A's children. B is blocked until ALL children of A are closed.
func TestWaitsForAllChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create spawner (A) with two children (C1, C2), and waiter (B) that waits for A's children
spawner := &types.Issue{Title: "Spawner", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
child1 := &types.Issue{Title: "Child 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
child2 := &types.Issue{Title: "Child 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
waiter := &types.Issue{Title: "Waiter", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, spawner, "test-user")
store.CreateIssue(ctx, child1, "test-user")
store.CreateIssue(ctx, child2, "test-user")
store.CreateIssue(ctx, waiter, "test-user")
// Add parent-child relationships: C1, C2 are children of spawner
depChild1 := &types.Dependency{IssueID: child1.ID, DependsOnID: spawner.ID, Type: types.DepParentChild}
depChild2 := &types.Dependency{IssueID: child2.ID, DependsOnID: spawner.ID, Type: types.DepParentChild}
store.AddDependency(ctx, depChild1, "test-user")
store.AddDependency(ctx, depChild2, "test-user")
// Add waits-for dependency: waiter waits for spawner's children (default: all-children gate)
depWaits := &types.Dependency{
IssueID: waiter.ID,
DependsOnID: spawner.ID,
Type: types.DepWaitsFor,
Metadata: `{"gate":"all-children"}`,
}
store.AddDependency(ctx, depWaits, "test-user")
// Initially: both children open, waiter should be blocked
cached := getCachedBlockedIssues(t, store)
if !cached[waiter.ID] {
t.Errorf("Expected waiter to be blocked (children still open)")
}
// Close first child - waiter should still be blocked (second child still open)
store.CloseIssue(ctx, child1.ID, "Done", "test-user")
cached = getCachedBlockedIssues(t, store)
if !cached[waiter.ID] {
t.Errorf("Expected waiter to still be blocked (one child still open)")
}
// Close second child - waiter should now be unblocked
store.CloseIssue(ctx, child2.ID, "Done", "test-user")
cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
t.Errorf("Expected waiter to be unblocked (all children closed)")
}
}
// TestWaitsForAnyChildren tests the waits-for dependency with any-children gate
// B waits for spawner A's children. B is blocked until ANY child of A is closed.
func TestWaitsForAnyChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create spawner with two children, and waiter that waits for any child to close
spawner := &types.Issue{Title: "Spawner", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
child1 := &types.Issue{Title: "Child 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
child2 := &types.Issue{Title: "Child 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
waiter := &types.Issue{Title: "Waiter", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, spawner, "test-user")
store.CreateIssue(ctx, child1, "test-user")
store.CreateIssue(ctx, child2, "test-user")
store.CreateIssue(ctx, waiter, "test-user")
// Add parent-child relationships
depChild1 := &types.Dependency{IssueID: child1.ID, DependsOnID: spawner.ID, Type: types.DepParentChild}
depChild2 := &types.Dependency{IssueID: child2.ID, DependsOnID: spawner.ID, Type: types.DepParentChild}
store.AddDependency(ctx, depChild1, "test-user")
store.AddDependency(ctx, depChild2, "test-user")
// Add waits-for dependency with any-children gate
depWaits := &types.Dependency{
IssueID: waiter.ID,
DependsOnID: spawner.ID,
Type: types.DepWaitsFor,
Metadata: `{"gate":"any-children"}`,
}
store.AddDependency(ctx, depWaits, "test-user")
// Initially: both children open, waiter should be blocked
cached := getCachedBlockedIssues(t, store)
if !cached[waiter.ID] {
t.Errorf("Expected waiter to be blocked (no children closed yet)")
}
// Close first child - waiter should now be unblocked (any-children gate satisfied)
store.CloseIssue(ctx, child1.ID, "Done", "test-user")
cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
t.Errorf("Expected waiter to be unblocked (any-children gate: one child closed)")
}
}
// TestWaitsForNoChildren tests waits-for when spawner has no children
// Should be unblocked immediately (vacuous truth: all 0 children are closed)
func TestWaitsForNoChildren(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create spawner with no children
spawner := &types.Issue{Title: "Spawner", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
waiter := &types.Issue{Title: "Waiter", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, spawner, "test-user")
store.CreateIssue(ctx, waiter, "test-user")
// Add waits-for dependency (no children to wait for)
depWaits := &types.Dependency{
IssueID: waiter.ID,
DependsOnID: spawner.ID,
Type: types.DepWaitsFor,
Metadata: `{"gate":"all-children"}`,
}
store.AddDependency(ctx, depWaits, "test-user")
// Waiter should NOT be blocked (no children means condition is satisfied)
cached := getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
t.Errorf("Expected waiter to NOT be blocked (spawner has no children)")
}
}
// TestWaitsForDynamicChildrenAdded tests waits-for when children are added dynamically
func TestWaitsForDynamicChildrenAdded(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create spawner with no children initially
spawner := &types.Issue{Title: "Spawner", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
waiter := &types.Issue{Title: "Waiter", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, spawner, "test-user")
store.CreateIssue(ctx, waiter, "test-user")
// Add waits-for dependency
depWaits := &types.Dependency{
IssueID: waiter.ID,
DependsOnID: spawner.ID,
Type: types.DepWaitsFor,
Metadata: `{"gate":"all-children"}`,
}
store.AddDependency(ctx, depWaits, "test-user")
// Initially: no children, waiter should be unblocked
cached := getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
t.Errorf("Expected waiter to be unblocked (no children yet)")
}
// Dynamically add a child
child := &types.Issue{Title: "Dynamic Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, child, "test-user")
depChild := &types.Dependency{IssueID: child.ID, DependsOnID: spawner.ID, Type: types.DepParentChild}
store.AddDependency(ctx, depChild, "test-user")
// Now waiter should be blocked (child is open)
cached = getCachedBlockedIssues(t, store)
if !cached[waiter.ID] {
t.Errorf("Expected waiter to be blocked (dynamic child added)")
}
// Close the child - waiter should be unblocked again
store.CloseIssue(ctx, child.ID, "Done", "test-user")
cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
t.Errorf("Expected waiter to be unblocked (dynamic child closed)")
}
}