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:
@@ -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)")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
Reference in New Issue
Block a user