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

@@ -103,6 +103,8 @@ var createCmd = &cobra.Command{
parentID, _ := cmd.Flags().GetString("parent") parentID, _ := cmd.Flags().GetString("parent")
externalRef, _ := cmd.Flags().GetString("external-ref") externalRef, _ := cmd.Flags().GetString("external-ref")
deps, _ := cmd.Flags().GetStringSlice("deps") deps, _ := cmd.Flags().GetStringSlice("deps")
waitsFor, _ := cmd.Flags().GetString("waits-for")
waitsForGate, _ := cmd.Flags().GetString("waits-for-gate")
forceCreate, _ := cmd.Flags().GetBool("force") forceCreate, _ := cmd.Flags().GetBool("force")
repoOverride, _ := cmd.Flags().GetString("repo") repoOverride, _ := cmd.Flags().GetString("repo")
@@ -217,6 +219,8 @@ var createCmd = &cobra.Command{
EstimatedMinutes: estimatedMinutes, EstimatedMinutes: estimatedMinutes,
Labels: labels, Labels: labels,
Dependencies: deps, Dependencies: deps,
WaitsFor: waitsFor,
WaitsForGate: waitsForGate,
} }
resp, err := daemonClient.Create(createArgs) 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 // Schedule auto-flush
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
@@ -403,6 +438,8 @@ func init() {
createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)") 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().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().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().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().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)") createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")

View File

@@ -72,6 +72,9 @@ type CreateArgs struct {
EstimatedMinutes *int `json:"estimated_minutes,omitempty"` // Time estimate in minutes EstimatedMinutes *int `json:"estimated_minutes,omitempty"` // Time estimate in minutes
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
Dependencies []string `json:"dependencies,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) // Messaging fields (bd-kwro)
Sender string `json:"sender,omitempty"` // Who sent this (for messages) 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 Wisp bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed

View File

@@ -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 // Emit mutation event for event-driven daemon
s.emitMutation(MutationCreate, issue.ID) s.emitMutation(MutationCreate, issue.ID)

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 open/in_progress/blocked issue (direct blocking)
// - It has a 'blocks' dependency on an external:* reference (cross-project blocking, bd-om4a) // - 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 '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) // - 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 // 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.). // with a failure close reason (failed, rejected, wontfix, cancelled, abandoned, etc.).
// If A succeeds (closed without failure), B stays blocked. // If A succeeds (closed without failure), B stays blocked.
// //
// The cache is maintained automatically by invalidating and rebuilding whenever: // 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) // - 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) // - 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 // Only includes local blockers (open issues) - external refs are resolved
// lazily at query time by GetReadyWork (bd-zmmy supersedes bd-om4a) // 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) // - 'blocks': B is blocked until A is closed (any close reason)
// - 'conditional-blocks': B is blocked until A is closed with failure (bd-kzda) // - '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 // - 'parent-child': Propagates blockage to children
// //
// Failure close reasons are detected by matching keywords in close_reason: // 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%' 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 -- 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)")
}
}

View File

@@ -368,6 +368,7 @@ const (
DepBlocks DependencyType = "blocks" DepBlocks DependencyType = "blocks"
DepParentChild DependencyType = "parent-child" DepParentChild DependencyType = "parent-child"
DepConditionalBlocks DependencyType = "conditional-blocks" // B runs only if A fails (bd-kzda) 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 // Association types
DepRelated DependencyType = "related" DepRelated DependencyType = "related"
@@ -396,7 +397,7 @@ func (d DependencyType) IsValid() bool {
// Returns false for custom/user-defined types (which are still valid). // Returns false for custom/user-defined types (which are still valid).
func (d DependencyType) IsWellKnown() bool { func (d DependencyType) IsWellKnown() bool {
switch d { switch d {
case DepBlocks, DepParentChild, DepConditionalBlocks, DepRelated, DepDiscoveredFrom, case DepBlocks, DepParentChild, DepConditionalBlocks, DepWaitsFor, DepRelated, DepDiscoveredFrom,
DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes, DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes,
DepAuthoredBy, DepAssignedTo, DepApprovedBy: DepAuthoredBy, DepAssignedTo, DepApprovedBy:
return true return true
@@ -405,11 +406,27 @@ func (d DependencyType) IsWellKnown() bool {
} }
// AffectsReadyWork returns true if this dependency type blocks work. // 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 { 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. // FailureCloseKeywords are keywords that indicate an issue was closed due to failure.
// Used by conditional-blocks dependencies to determine if the condition is met. // Used by conditional-blocks dependencies to determine if the condition is met.
var FailureCloseKeywords = []string{ var FailureCloseKeywords = []string{

View File

@@ -483,6 +483,7 @@ func TestDependencyTypeAffectsReadyWork(t *testing.T) {
{DepBlocks, true}, {DepBlocks, true},
{DepParentChild, true}, {DepParentChild, true},
{DepConditionalBlocks, true}, {DepConditionalBlocks, true},
{DepWaitsFor, true},
{DepRelated, false}, {DepRelated, false},
{DepDiscoveredFrom, false}, {DepDiscoveredFrom, false},
{DepRepliesTo, false}, {DepRepliesTo, false},