From b63df9123086ae25f9cb08ae701c8865a4fa6f0d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 00:04:43 -0800 Subject: [PATCH] feat: add 'convoy' issue type with reactive completion (bd-hj0s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TypeConvoy to issue types for cross-project tracking - Implement reactive completion: when all tracked issues close, convoy auto-closes with reason "All tracked issues completed" - Uses 'tracks' dependency type (non-blocking, cross-prefix capable) - Update help text for --type flag in list/create commands - Add test for convoy reactive completion behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Executed-By: beads/crew/dave Rig: beads Role: crew --- cmd/bd/create.go | 2 +- cmd/bd/list.go | 2 +- internal/storage/sqlite/queries.go | 77 +++++++++++++++++++++ internal/storage/sqlite/sqlite_test.go | 94 ++++++++++++++++++++++++++ internal/storage/sqlite/transaction.go | 72 ++++++++++++++++++++ internal/types/types.go | 3 +- 6 files changed, 247 insertions(+), 3 deletions(-) diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 2b7266c2..bf6114f7 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -499,7 +499,7 @@ func init() { createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)") createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)") registerPriorityFlag(createCmd, "2") - createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role)") + createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role|convoy)") registerCommonIssueFlags(createCmd) createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)") createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels") diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 3cdaaf32..821e8201 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -912,7 +912,7 @@ func init() { listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)") registerPriorityFlag(listCmd, "") listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") - listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)") + listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate, convoy)") listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any") listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label") listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)") diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index b8034f42..8e53382b 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -1100,6 +1100,83 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string return fmt.Errorf("failed to invalidate blocked cache: %w", err) } + // Reactive convoy completion: check if any convoys tracking this issue should auto-close + // Find convoys that track this issue (convoy.issue_id tracks closed_issue.depends_on_id) + convoyRows, err := tx.QueryContext(ctx, ` + SELECT DISTINCT d.issue_id + FROM dependencies d + JOIN issues i ON d.issue_id = i.id + WHERE d.depends_on_id = ? + AND d.type = ? + AND i.issue_type = ? + AND i.status != ? + `, id, types.DepTracks, types.TypeConvoy, types.StatusClosed) + if err != nil { + return fmt.Errorf("failed to find tracking convoys: %w", err) + } + defer func() { _ = convoyRows.Close() }() + + var convoyIDs []string + for convoyRows.Next() { + var convoyID string + if err := convoyRows.Scan(&convoyID); err != nil { + return fmt.Errorf("failed to scan convoy ID: %w", err) + } + convoyIDs = append(convoyIDs, convoyID) + } + if err := convoyRows.Err(); err != nil { + return fmt.Errorf("convoy rows iteration error: %w", err) + } + + // For each convoy, check if all tracked issues are now closed + for _, convoyID := range convoyIDs { + // Count non-closed tracked issues for this convoy + var openCount int + err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM dependencies d + JOIN issues i ON d.depends_on_id = i.id + WHERE d.issue_id = ? + AND d.type = ? + AND i.status != ? + AND i.status != ? + `, convoyID, types.DepTracks, types.StatusClosed, types.StatusTombstone).Scan(&openCount) + if err != nil { + return fmt.Errorf("failed to count open tracked issues for convoy %s: %w", convoyID, err) + } + + // If all tracked issues are closed, auto-close the convoy + if openCount == 0 { + closeReason := "All tracked issues completed" + _, err := tx.ExecContext(ctx, ` + UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ? + WHERE id = ? + `, types.StatusClosed, now, now, closeReason, convoyID) + if err != nil { + return fmt.Errorf("failed to auto-close convoy %s: %w", convoyID, err) + } + + // Record the close event + _, err = tx.ExecContext(ctx, ` + INSERT INTO events (issue_id, event_type, actor, comment) + VALUES (?, ?, ?, ?) + `, convoyID, types.EventClosed, "system:convoy-completion", closeReason) + if err != nil { + return fmt.Errorf("failed to record convoy close event: %w", err) + } + + // Mark convoy as dirty + _, err = tx.ExecContext(ctx, ` + INSERT INTO dirty_issues (issue_id, marked_at) + VALUES (?, ?) + ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at + `, convoyID, now) + if err != nil { + return fmt.Errorf("failed to mark convoy dirty: %w", err) + } + } + } + return tx.Commit() } diff --git a/internal/storage/sqlite/sqlite_test.go b/internal/storage/sqlite/sqlite_test.go index 04aadb7f..87753c72 100644 --- a/internal/storage/sqlite/sqlite_test.go +++ b/internal/storage/sqlite/sqlite_test.go @@ -1469,6 +1469,100 @@ func TestDeleteConfig(t *testing.T) { } } +func TestConvoyReactiveCompletion(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a convoy + convoy := &types.Issue{ + Title: "Test Convoy", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeConvoy, + } + err := store.CreateIssue(ctx, convoy, "test-user") + if err != nil { + t.Fatalf("CreateIssue convoy failed: %v", err) + } + + // Create two issues to track + issue1 := &types.Issue{ + Title: "Tracked Issue 1", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + err = store.CreateIssue(ctx, issue1, "test-user") + if err != nil { + t.Fatalf("CreateIssue issue1 failed: %v", err) + } + + issue2 := &types.Issue{ + Title: "Tracked Issue 2", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + err = store.CreateIssue(ctx, issue2, "test-user") + if err != nil { + t.Fatalf("CreateIssue issue2 failed: %v", err) + } + + // Add tracking dependencies: convoy tracks issue1 and issue2 + dep1 := &types.Dependency{ + IssueID: convoy.ID, + DependsOnID: issue1.ID, + Type: types.DepTracks, + } + err = store.AddDependency(ctx, dep1, "test-user") + if err != nil { + t.Fatalf("AddDependency for issue1 failed: %v", err) + } + + dep2 := &types.Dependency{ + IssueID: convoy.ID, + DependsOnID: issue2.ID, + Type: types.DepTracks, + } + err = store.AddDependency(ctx, dep2, "test-user") + if err != nil { + t.Fatalf("AddDependency for issue2 failed: %v", err) + } + + // Close first issue - convoy should still be open + err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user") + if err != nil { + t.Fatalf("CloseIssue issue1 failed: %v", err) + } + + convoyAfter1, err := store.GetIssue(ctx, convoy.ID) + if err != nil { + t.Fatalf("GetIssue convoy after issue1 closed failed: %v", err) + } + if convoyAfter1.Status == types.StatusClosed { + t.Error("Convoy should NOT be closed after only first tracked issue is closed") + } + + // Close second issue - convoy should auto-close now + err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user") + if err != nil { + t.Fatalf("CloseIssue issue2 failed: %v", err) + } + + convoyAfter2, err := store.GetIssue(ctx, convoy.ID) + if err != nil { + t.Fatalf("GetIssue convoy after issue2 closed failed: %v", err) + } + if convoyAfter2.Status != types.StatusClosed { + t.Errorf("Convoy should be auto-closed when all tracked issues are closed, got status: %v", convoyAfter2.Status) + } + if convoyAfter2.CloseReason != "All tracked issues completed" { + t.Errorf("Convoy close reason should be 'All tracked issues completed', got: %q", convoyAfter2.CloseReason) + } +} + func TestIsClosed(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 1229c7e8..5dd99a4a 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -561,6 +561,78 @@ func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason stri return fmt.Errorf("failed to invalidate blocked cache: %w", err) } + // Reactive convoy completion: check if any convoys tracking this issue should auto-close + // Find convoys that track this issue (convoy.issue_id tracks closed_issue.depends_on_id) + convoyRows, err := t.conn.QueryContext(ctx, ` + SELECT DISTINCT d.issue_id + FROM dependencies d + JOIN issues i ON d.issue_id = i.id + WHERE d.depends_on_id = ? + AND d.type = ? + AND i.issue_type = ? + AND i.status != ? + `, id, types.DepTracks, types.TypeConvoy, types.StatusClosed) + if err != nil { + return fmt.Errorf("failed to find tracking convoys: %w", err) + } + defer func() { _ = convoyRows.Close() }() + + var convoyIDs []string + for convoyRows.Next() { + var convoyID string + if err := convoyRows.Scan(&convoyID); err != nil { + return fmt.Errorf("failed to scan convoy ID: %w", err) + } + convoyIDs = append(convoyIDs, convoyID) + } + if err := convoyRows.Err(); err != nil { + return fmt.Errorf("convoy rows iteration error: %w", err) + } + + // For each convoy, check if all tracked issues are now closed + for _, convoyID := range convoyIDs { + // Count non-closed tracked issues for this convoy + var openCount int + err := t.conn.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM dependencies d + JOIN issues i ON d.depends_on_id = i.id + WHERE d.issue_id = ? + AND d.type = ? + AND i.status != ? + AND i.status != ? + `, convoyID, types.DepTracks, types.StatusClosed, types.StatusTombstone).Scan(&openCount) + if err != nil { + return fmt.Errorf("failed to count open tracked issues for convoy %s: %w", convoyID, err) + } + + // If all tracked issues are closed, auto-close the convoy + if openCount == 0 { + closeReason := "All tracked issues completed" + _, err := t.conn.ExecContext(ctx, ` + UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ? + WHERE id = ? + `, types.StatusClosed, now, now, closeReason, convoyID) + if err != nil { + return fmt.Errorf("failed to auto-close convoy %s: %w", convoyID, err) + } + + // Record the close event + _, err = t.conn.ExecContext(ctx, ` + INSERT INTO events (issue_id, event_type, actor, comment) + VALUES (?, ?, ?, ?) + `, convoyID, types.EventClosed, "system:convoy-completion", closeReason) + if err != nil { + return fmt.Errorf("failed to record convoy close event: %w", err) + } + + // Mark convoy as dirty + if err := markDirty(ctx, t.conn, convoyID); err != nil { + return fmt.Errorf("failed to mark convoy dirty: %w", err) + } + } + } + return nil } diff --git a/internal/types/types.go b/internal/types/types.go index 502a34c3..e4da27a9 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -394,12 +394,13 @@ const ( TypeGate IssueType = "gate" // Async coordination gate TypeAgent IssueType = "agent" // Agent identity bead TypeRole IssueType = "role" // Agent role definition + TypeConvoy IssueType = "convoy" // Cross-project tracking with reactive completion ) // IsValid checks if the issue type value is valid func (t IssueType) IsValid() bool { switch t { - case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole: + case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeConvoy: return true } return false