From fa9a88e2a3e087eecd2f43ad8d8a1d86d059a574 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 03:59:43 -0800 Subject: [PATCH] feat: add waits-for dependency type for fanout gates (bd-xo1o.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/create.go | 37 ++++ internal/rpc/protocol.go | 3 + internal/rpc/server_issues_epics.go | 40 ++++ internal/storage/sqlite/blocked_cache.go | 46 ++++- internal/storage/sqlite/blocked_cache_test.go | 183 ++++++++++++++++++ internal/types/types.go | 23 ++- internal/types/types_test.go | 1 + 7 files changed, 328 insertions(+), 5 deletions(-) diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 952394ab..6d18635f 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -103,6 +103,8 @@ var createCmd = &cobra.Command{ parentID, _ := cmd.Flags().GetString("parent") externalRef, _ := cmd.Flags().GetString("external-ref") deps, _ := cmd.Flags().GetStringSlice("deps") + waitsFor, _ := cmd.Flags().GetString("waits-for") + waitsForGate, _ := cmd.Flags().GetString("waits-for-gate") forceCreate, _ := cmd.Flags().GetBool("force") repoOverride, _ := cmd.Flags().GetString("repo") @@ -217,6 +219,8 @@ var createCmd = &cobra.Command{ EstimatedMinutes: estimatedMinutes, Labels: labels, Dependencies: deps, + WaitsFor: waitsFor, + WaitsForGate: waitsForGate, } resp, err := daemonClient.Create(createArgs) @@ -366,6 +370,37 @@ var createCmd = &cobra.Command{ } } + // Add waits-for dependency if specified (bd-xo1o.2) + if waitsFor != "" { + // Validate gate type + gate := waitsForGate + if gate == "" { + gate = types.WaitsForAllChildren + } + if gate != types.WaitsForAllChildren && gate != types.WaitsForAnyChildren { + FatalError("invalid --waits-for-gate value '%s' (valid: all-children, any-children)", gate) + } + + // Create metadata JSON + meta := types.WaitsForMeta{ + Gate: gate, + } + metaJSON, err := json.Marshal(meta) + if err != nil { + FatalError("failed to serialize waits-for metadata: %v", err) + } + + dep := &types.Dependency{ + IssueID: issue.ID, + DependsOnID: waitsFor, + Type: types.DepWaitsFor, + Metadata: string(metaJSON), + } + if err := store.AddDependency(ctx, dep, actor); err != nil { + WarnError("failed to add waits-for dependency %s -> %s: %v", issue.ID, waitsFor, err) + } + } + // Schedule auto-flush markDirtyAndScheduleFlush() @@ -403,6 +438,8 @@ func init() { createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)") createCmd.Flags().String("parent", "", "Parent issue ID for hierarchical child (e.g., 'bd-a3f8e9')") createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')") + createCmd.Flags().String("waits-for", "", "Spawner issue ID to wait for (creates waits-for dependency for fanout gate)") + createCmd.Flags().String("waits-for-gate", "all-children", "Gate type: all-children (wait for all) or any-children (wait for first)") createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix") createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)") createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)") diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 41eafc26..c92d92de 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -72,6 +72,9 @@ type CreateArgs struct { EstimatedMinutes *int `json:"estimated_minutes,omitempty"` // Time estimate in minutes Labels []string `json:"labels,omitempty"` Dependencies []string `json:"dependencies,omitempty"` + // Waits-for dependencies (bd-xo1o.2) + WaitsFor string `json:"waits_for,omitempty"` // Spawner issue ID to wait for + WaitsForGate string `json:"waits_for_gate,omitempty"` // Gate type: all-children or any-children // Messaging fields (bd-kwro) Sender string `json:"sender,omitempty"` // Who sent this (for messages) Wisp bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 1c788dd7..78bbb141 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -309,6 +309,46 @@ func (s *Server) handleCreate(req *Request) Response { } } + // Add waits-for dependency if specified (bd-xo1o.2) + if createArgs.WaitsFor != "" { + // Validate gate type + gate := createArgs.WaitsForGate + if gate == "" { + gate = types.WaitsForAllChildren + } + if gate != types.WaitsForAllChildren && gate != types.WaitsForAnyChildren { + return Response{ + Success: false, + Error: fmt.Sprintf("invalid waits_for_gate value '%s' (valid: all-children, any-children)", gate), + } + } + + // Create metadata JSON + meta := types.WaitsForMeta{ + Gate: gate, + } + metaJSON, err := json.Marshal(meta) + if err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to serialize waits-for metadata: %v", err), + } + } + + dep := &types.Dependency{ + IssueID: issue.ID, + DependsOnID: createArgs.WaitsFor, + Type: types.DepWaitsFor, + Metadata: string(metaJSON), + } + if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil { + return Response{ + Success: false, + Error: fmt.Sprintf("failed to add waits-for dependency %s -> %s: %v", issue.ID, createArgs.WaitsFor, err), + } + } + } + // Emit mutation event for event-driven daemon s.emitMutation(MutationCreate, issue.ID) diff --git a/internal/storage/sqlite/blocked_cache.go b/internal/storage/sqlite/blocked_cache.go index 8fa56e4b..1fbaab58 100644 --- a/internal/storage/sqlite/blocked_cache.go +++ b/internal/storage/sqlite/blocked_cache.go @@ -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 diff --git a/internal/storage/sqlite/blocked_cache_test.go b/internal/storage/sqlite/blocked_cache_test.go index 1401ab84..4d05854c 100644 --- a/internal/storage/sqlite/blocked_cache_test.go +++ b/internal/storage/sqlite/blocked_cache_test.go @@ -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)") + } +} diff --git a/internal/types/types.go b/internal/types/types.go index fdfad2ef..adaf8660 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -368,6 +368,7 @@ const ( DepBlocks DependencyType = "blocks" DepParentChild DependencyType = "parent-child" DepConditionalBlocks DependencyType = "conditional-blocks" // B runs only if A fails (bd-kzda) + DepWaitsFor DependencyType = "waits-for" // Fanout gate: wait for dynamic children (bd-xo1o.2) // Association types DepRelated DependencyType = "related" @@ -396,7 +397,7 @@ func (d DependencyType) IsValid() bool { // Returns false for custom/user-defined types (which are still valid). func (d DependencyType) IsWellKnown() bool { switch d { - case DepBlocks, DepParentChild, DepConditionalBlocks, DepRelated, DepDiscoveredFrom, + case DepBlocks, DepParentChild, DepConditionalBlocks, DepWaitsFor, DepRelated, DepDiscoveredFrom, DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes, DepAuthoredBy, DepAssignedTo, DepApprovedBy: return true @@ -405,11 +406,27 @@ func (d DependencyType) IsWellKnown() bool { } // AffectsReadyWork returns true if this dependency type blocks work. -// Only "blocks", "parent-child", and "conditional-blocks" affect the ready work calculation. +// Only blocking types affect the ready work calculation. func (d DependencyType) AffectsReadyWork() bool { - return d == DepBlocks || d == DepParentChild || d == DepConditionalBlocks + return d == DepBlocks || d == DepParentChild || d == DepConditionalBlocks || d == DepWaitsFor } +// WaitsForMeta holds metadata for waits-for dependencies (fanout gates). +// Stored as JSON in the Dependency.Metadata field. +type WaitsForMeta struct { + // Gate type: "all-children" (wait for all), "any-children" (wait for first) + Gate string `json:"gate"` + // SpawnerID identifies which step/issue spawns the children to wait for. + // If empty, waits for all direct children of the depends_on_id issue. + SpawnerID string `json:"spawner_id,omitempty"` +} + +// WaitsForGate constants +const ( + WaitsForAllChildren = "all-children" // Wait for all dynamic children to complete + WaitsForAnyChildren = "any-children" // Proceed when first child completes (future) +) + // FailureCloseKeywords are keywords that indicate an issue was closed due to failure. // Used by conditional-blocks dependencies to determine if the condition is met. var FailureCloseKeywords = []string{ diff --git a/internal/types/types_test.go b/internal/types/types_test.go index b43ab05f..9d48cb2c 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -483,6 +483,7 @@ func TestDependencyTypeAffectsReadyWork(t *testing.T) { {DepBlocks, true}, {DepParentChild, true}, {DepConditionalBlocks, true}, + {DepWaitsFor, true}, {DepRelated, false}, {DepDiscoveredFrom, false}, {DepRepliesTo, false},