Files
beads/cmd/bd/swarm.go
Steve Yegge 71e2f22849 fix: Add nolint comments for gosec/errcheck/unparam warnings
Fixes CI lint failures by adding appropriate nolint directives for:
- G204 (subprocess with variable) - git commands with trusted inputs
- G304 (file inclusion via variable) - paths from internal helpers
- G302/G306 (file permissions) - .gitignore needs 0644
- errcheck (unchecked return values) - fmt.Fprint* to stdout/stderr
- unparam (unused parameters) - reserved for future use
2025-12-29 14:39:43 -08:00

1222 lines
35 KiB
Go

// Package main implements the bd CLI swarm management commands.
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
var swarmCmd = &cobra.Command{
Use: "swarm",
GroupID: "deps",
Short: "Swarm management for structured epics",
Long: `Swarm management commands for coordinating parallel work on epics.
A swarm is a structured body of work defined by an epic and its children,
with dependencies forming a DAG (directed acyclic graph) of work.`,
}
// SwarmAnalysis holds the results of analyzing an epic's structure for swarming.
type SwarmAnalysis struct {
EpicID string `json:"epic_id"`
EpicTitle string `json:"epic_title"`
TotalIssues int `json:"total_issues"`
ClosedIssues int `json:"closed_issues"`
ReadyFronts []ReadyFront `json:"ready_fronts"`
MaxParallelism int `json:"max_parallelism"`
EstimatedSessions int `json:"estimated_sessions"`
Warnings []string `json:"warnings"`
Errors []string `json:"errors"`
Swarmable bool `json:"swarmable"`
Issues map[string]*IssueNode `json:"issues,omitempty"` // Only included with --verbose
}
// ReadyFront represents a group of issues that can be worked on in parallel.
type ReadyFront struct {
Wave int `json:"wave"`
Issues []string `json:"issues"`
Titles []string `json:"titles,omitempty"` // Only for human output
}
// IssueNode represents an issue in the dependency graph.
type IssueNode struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
DependsOn []string `json:"depends_on"` // What this issue depends on
DependedOnBy []string `json:"depended_on_by"` // What depends on this issue
Wave int `json:"wave"` // Which ready front this belongs to (-1 if blocked by cycle)
}
// SwarmStorage defines the storage interface needed by swarm commands.
type SwarmStorage interface {
GetIssue(context.Context, string) (*types.Issue, error)
GetDependents(context.Context, string) ([]*types.Issue, error)
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
}
// findExistingSwarm returns the swarm molecule for an epic, if one exists.
// Returns nil if no swarm molecule is linked to the epic.
func findExistingSwarm(ctx context.Context, s SwarmStorage, epicID string) (*types.Issue, error) {
// Get all issues that depend on the epic
dependents, err := s.GetDependents(ctx, epicID)
if err != nil {
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
}
// Find a swarm molecule with relates-to dependency to this epic
for _, dep := range dependents {
// Only consider molecules (GetDependents doesn't populate mol_type, so we fetch full issue)
if dep.IssueType != types.TypeMolecule {
continue
}
// Get full issue to check mol_type
fullIssue, err := s.GetIssue(ctx, dep.ID)
if err != nil || fullIssue == nil {
continue
}
if fullIssue.MolType != types.MolTypeSwarm {
continue
}
// Verify it's linked via relates-to
deps, err := s.GetDependencyRecords(ctx, dep.ID)
if err != nil {
continue
}
for _, d := range deps {
if d.DependsOnID == epicID && d.Type == types.DepRelatesTo {
return fullIssue, nil
}
}
}
return nil, nil
}
// getEpicChildren returns all child issues of an epic (via parent-child dependencies).
func getEpicChildren(ctx context.Context, s SwarmStorage, epicID string) ([]*types.Issue, error) {
// Get all issues that depend on the epic
allDependents, err := s.GetDependents(ctx, epicID)
if err != nil {
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
}
// Filter to only parent-child relationships by checking each dependent's dependency records
var children []*types.Issue
for _, dependent := range allDependents {
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
if err != nil {
continue // Skip issues we can't query
}
for _, dep := range deps {
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
children = append(children, dependent)
break
}
}
}
return children, nil
}
var swarmValidateCmd = &cobra.Command{
Use: "validate [epic-id]",
Short: "Validate epic structure for swarming",
Long: `Validate an epic's structure to ensure it's ready for swarm execution.
Checks for:
- Correct dependency direction (requirement-based, not temporal)
- Orphaned issues (roots with no dependents)
- Missing dependencies (leaves that should depend on something)
- Cycles (impossible to resolve)
- Disconnected subgraphs
Reports:
- Ready fronts (waves of parallel work)
- Estimated worker-sessions
- Maximum parallelism
- Warnings for potential issues
Examples:
bd swarm validate gt-epic-123 # Validate epic structure
bd swarm validate gt-epic-123 --verbose # Include detailed issue graph`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
verbose, _ := cmd.Flags().GetBool("verbose")
// Swarm commands require direct store access
if store == nil {
if daemonClient != nil {
var err error
store, err = sqlite.New(ctx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
} else {
FatalErrorRespectJSON("no database connection")
}
}
// Resolve epic ID
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("epic '%s' not found: %v", args[0], err)
}
// Get the epic
epic, err := store.GetIssue(ctx, epicID)
if err != nil {
FatalErrorRespectJSON("failed to get epic: %v", err)
}
if epic == nil {
FatalErrorRespectJSON("epic '%s' not found", epicID)
}
// Verify it's an epic
if epic.IssueType != types.TypeEpic && epic.IssueType != types.TypeMolecule {
FatalErrorRespectJSON("'%s' is not an epic or molecule (type: %s)", epicID, epic.IssueType)
}
// Analyze the epic structure
analysis, err := analyzeEpicForSwarm(ctx, store, epic)
if err != nil {
FatalErrorRespectJSON("failed to analyze epic: %v", err)
}
// Include detailed graph only in verbose mode
if !verbose {
analysis.Issues = nil
}
if jsonOutput {
outputJSON(analysis)
if !analysis.Swarmable {
os.Exit(1)
}
return
}
// Human-readable output
renderSwarmAnalysis(analysis)
if !analysis.Swarmable {
os.Exit(1)
}
},
}
// analyzeEpicForSwarm performs structural analysis of an epic for swarm execution.
func analyzeEpicForSwarm(ctx context.Context, s SwarmStorage, epic *types.Issue) (*SwarmAnalysis, error) {
analysis := &SwarmAnalysis{
EpicID: epic.ID,
EpicTitle: epic.Title,
Swarmable: true,
Issues: make(map[string]*IssueNode),
}
// Get all child issues of the epic
childIssues, err := getEpicChildren(ctx, s, epic.ID)
if err != nil {
return nil, err
}
if len(childIssues) == 0 {
analysis.Warnings = append(analysis.Warnings, "Epic has no children")
return analysis, nil
}
analysis.TotalIssues = len(childIssues)
// Build the issue graph
for _, issue := range childIssues {
node := &IssueNode{
ID: issue.ID,
Title: issue.Title,
Status: string(issue.Status),
Priority: issue.Priority,
DependsOn: []string{},
DependedOnBy: []string{},
Wave: -1, // Will be set later
}
analysis.Issues[issue.ID] = node
if issue.Status == types.StatusClosed {
analysis.ClosedIssues++
}
}
// Build dependency relationships (only within the epic's children)
childIDSet := make(map[string]bool)
for _, issue := range childIssues {
childIDSet[issue.ID] = true
}
for _, issue := range childIssues {
deps, err := s.GetDependencyRecords(ctx, issue.ID)
if err != nil {
return nil, fmt.Errorf("failed to get dependencies for %s: %w", issue.ID, err)
}
node := analysis.Issues[issue.ID]
for _, dep := range deps {
// Only consider dependencies within the epic (not parent-child to epic itself)
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
continue // Skip the parent relationship to the epic
}
// Only track blocking dependencies
if !dep.Type.AffectsReadyWork() {
continue
}
// Only track dependencies within the epic's children
if childIDSet[dep.DependsOnID] {
node.DependsOn = append(node.DependsOn, dep.DependsOnID)
if targetNode, ok := analysis.Issues[dep.DependsOnID]; ok {
targetNode.DependedOnBy = append(targetNode.DependedOnBy, issue.ID)
}
}
// External dependencies to issues outside the epic
if !childIDSet[dep.DependsOnID] && dep.DependsOnID != epic.ID {
// Check if it's an external ref
if strings.HasPrefix(dep.DependsOnID, "external:") {
analysis.Warnings = append(analysis.Warnings,
fmt.Sprintf("%s has external dependency: %s", issue.ID, dep.DependsOnID))
} else {
analysis.Warnings = append(analysis.Warnings,
fmt.Sprintf("%s depends on %s (outside epic)", issue.ID, dep.DependsOnID))
}
}
}
}
// Detect structural issues
detectStructuralIssues(analysis, childIssues)
// Compute ready fronts (waves of parallel work)
computeReadyFronts(analysis)
// Set swarmable based on errors
analysis.Swarmable = len(analysis.Errors) == 0
return analysis, nil
}
// detectStructuralIssues looks for common problems in the dependency graph.
//
//nolint:unparam // issues reserved for future use
func detectStructuralIssues(analysis *SwarmAnalysis, _ []*types.Issue) {
// 1. Find roots (issues with no dependencies within the epic)
// These are the starting points. Having multiple roots is normal.
var roots []string
for id, node := range analysis.Issues {
if len(node.DependsOn) == 0 {
roots = append(roots, id)
}
}
// 2. Find leaves (issues that nothing depends on within the epic)
// Multiple leaves might indicate missing dependencies or just multiple end points.
var leaves []string
for id, node := range analysis.Issues {
if len(node.DependedOnBy) == 0 {
leaves = append(leaves, id)
}
}
// 3. Detect potential dependency inversions
// Heuristic: If a "foundation" or "setup" issue has no dependents, it might be inverted.
// Heuristic: If an "integration" or "final" issue depends on nothing, it might be inverted.
for id, node := range analysis.Issues {
lowerTitle := strings.ToLower(node.Title)
// Foundation-like issues should have dependents
if len(node.DependedOnBy) == 0 {
if strings.Contains(lowerTitle, "foundation") ||
strings.Contains(lowerTitle, "setup") ||
strings.Contains(lowerTitle, "base") ||
strings.Contains(lowerTitle, "core") {
analysis.Warnings = append(analysis.Warnings,
fmt.Sprintf("%s (%s) has no dependents - should other issues depend on it?",
id, node.Title))
}
}
// Integration-like issues should have dependencies
if len(node.DependsOn) == 0 {
if strings.Contains(lowerTitle, "integration") ||
strings.Contains(lowerTitle, "final") ||
strings.Contains(lowerTitle, "test") {
analysis.Warnings = append(analysis.Warnings,
fmt.Sprintf("%s (%s) has no dependencies - should it depend on implementation?",
id, node.Title))
}
}
}
// 4. Check for disconnected subgraphs
// Start from roots and see if we can reach all nodes
visited := make(map[string]bool)
var dfs func(id string)
dfs = func(id string) {
if visited[id] {
return
}
visited[id] = true
if node, ok := analysis.Issues[id]; ok {
for _, depID := range node.DependedOnBy {
dfs(depID)
}
}
}
// Visit from all roots
for _, root := range roots {
dfs(root)
}
// Check for unvisited nodes (disconnected from roots)
var disconnected []string
for id := range analysis.Issues {
if !visited[id] {
disconnected = append(disconnected, id)
}
}
if len(disconnected) > 0 {
analysis.Warnings = append(analysis.Warnings,
fmt.Sprintf("Disconnected issues (not reachable from roots): %v", disconnected))
}
// 5. Detect cycles using simple DFS
// (The main DetectCycles in storage is more sophisticated, but we do a simple check here)
inProgress := make(map[string]bool)
completed := make(map[string]bool)
var cyclePath []string
hasCycle := false
var detectCycle func(id string) bool
detectCycle = func(id string) bool {
if completed[id] {
return false
}
if inProgress[id] {
hasCycle = true
return true
}
inProgress[id] = true
cyclePath = append(cyclePath, id)
if node, ok := analysis.Issues[id]; ok {
for _, depID := range node.DependsOn {
if detectCycle(depID) {
return true
}
}
}
cyclePath = cyclePath[:len(cyclePath)-1]
inProgress[id] = false
completed[id] = true
return false
}
for id := range analysis.Issues {
if !completed[id] {
if detectCycle(id) {
break
}
}
}
if hasCycle {
analysis.Errors = append(analysis.Errors,
fmt.Sprintf("Dependency cycle detected involving: %v", cyclePath))
}
}
// computeReadyFronts calculates the waves of parallel work.
func computeReadyFronts(analysis *SwarmAnalysis) {
if len(analysis.Errors) > 0 {
// Can't compute ready fronts if there are cycles
return
}
// Use Kahn's algorithm for topological sort with level tracking
inDegree := make(map[string]int)
for id, node := range analysis.Issues {
inDegree[id] = len(node.DependsOn)
}
// Start with all nodes that have no dependencies (wave 0)
var currentWave []string
for id, degree := range inDegree {
if degree == 0 {
currentWave = append(currentWave, id)
analysis.Issues[id].Wave = 0
}
}
wave := 0
for len(currentWave) > 0 {
// Sort for deterministic output
sort.Strings(currentWave)
// Build titles for this wave
var titles []string
for _, id := range currentWave {
if node, ok := analysis.Issues[id]; ok {
titles = append(titles, node.Title)
}
}
front := ReadyFront{
Wave: wave,
Issues: currentWave,
Titles: titles,
}
analysis.ReadyFronts = append(analysis.ReadyFronts, front)
// Track max parallelism
if len(currentWave) > analysis.MaxParallelism {
analysis.MaxParallelism = len(currentWave)
}
// Find next wave
var nextWave []string
for _, id := range currentWave {
if node, ok := analysis.Issues[id]; ok {
for _, dependentID := range node.DependedOnBy {
inDegree[dependentID]--
if inDegree[dependentID] == 0 {
nextWave = append(nextWave, dependentID)
analysis.Issues[dependentID].Wave = wave + 1
}
}
}
}
currentWave = nextWave
wave++
}
// Estimated sessions = total issues (each issue is roughly one session)
analysis.EstimatedSessions = analysis.TotalIssues
}
// renderSwarmAnalysis outputs human-readable analysis.
func renderSwarmAnalysis(analysis *SwarmAnalysis) {
fmt.Printf("\n%s Swarm Analysis: %s\n", ui.RenderAccent("🐝"), analysis.EpicTitle)
fmt.Printf(" Epic ID: %s\n", analysis.EpicID)
fmt.Printf(" Total issues: %d (%d closed)\n", analysis.TotalIssues, analysis.ClosedIssues)
if analysis.TotalIssues == 0 {
fmt.Printf("\n%s Epic has no children to swarm\n\n", ui.RenderWarn("⚠"))
return
}
// Ready fronts
if len(analysis.ReadyFronts) > 0 {
fmt.Printf("\n%s Ready Fronts (waves of parallel work):\n", ui.RenderPass("📊"))
for _, front := range analysis.ReadyFronts {
fmt.Printf(" Wave %d: %d issues\n", front.Wave+1, len(front.Issues))
for i, id := range front.Issues {
title := ""
if i < len(front.Titles) {
title = front.Titles[i]
}
fmt.Printf(" • %s: %s\n", ui.RenderID(id), title)
}
}
}
// Summary stats
fmt.Printf("\n%s Summary:\n", ui.RenderAccent("📈"))
fmt.Printf(" Estimated worker-sessions: %d\n", analysis.EstimatedSessions)
fmt.Printf(" Max parallelism: %d\n", analysis.MaxParallelism)
fmt.Printf(" Total waves: %d\n", len(analysis.ReadyFronts))
// Warnings
if len(analysis.Warnings) > 0 {
fmt.Printf("\n%s Warnings:\n", ui.RenderWarn("⚠"))
for _, warning := range analysis.Warnings {
fmt.Printf(" • %s\n", warning)
}
}
// Errors
if len(analysis.Errors) > 0 {
fmt.Printf("\n%s Errors:\n", ui.RenderFail("❌"))
for _, err := range analysis.Errors {
fmt.Printf(" • %s\n", err)
}
}
// Final verdict
fmt.Println()
if analysis.Swarmable {
fmt.Printf("%s Swarmable: YES\n\n", ui.RenderPass("✓"))
} else {
fmt.Printf("%s Swarmable: NO (fix errors first)\n\n", ui.RenderFail("✗"))
}
}
// SwarmStatus holds the current status of a swarm (computed from beads).
type SwarmStatus struct {
EpicID string `json:"epic_id"`
EpicTitle string `json:"epic_title"`
TotalIssues int `json:"total_issues"`
Completed []StatusIssue `json:"completed"`
Active []StatusIssue `json:"active"`
Ready []StatusIssue `json:"ready"`
Blocked []StatusIssue `json:"blocked"`
Progress float64 `json:"progress_percent"`
ActiveCount int `json:"active_count"`
ReadyCount int `json:"ready_count"`
BlockedCount int `json:"blocked_count"`
}
// StatusIssue represents an issue in swarm status output.
type StatusIssue struct {
ID string `json:"id"`
Title string `json:"title"`
Assignee string `json:"assignee,omitempty"`
BlockedBy []string `json:"blocked_by,omitempty"`
ClosedAt string `json:"closed_at,omitempty"`
}
var swarmStatusCmd = &cobra.Command{
Use: "status [epic-or-swarm-id]",
Short: "Show current swarm status",
Long: `Show the current status of a swarm, computed from beads.
Accepts either:
- An epic ID (shows status for that epic's children)
- A swarm molecule ID (follows the link to find the epic)
Displays issues grouped by state:
- Completed: Closed issues
- Active: Issues currently in_progress (with assignee)
- Ready: Open issues with all dependencies satisfied
- Blocked: Open issues waiting on dependencies
The status is COMPUTED from beads, not stored separately.
If beads changes, status changes.
Examples:
bd swarm status gt-epic-123 # Show swarm status by epic
bd swarm status gt-swarm-456 # Show status via swarm molecule
bd swarm status gt-epic-123 --json # Machine-readable output`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Swarm commands require direct store access
if store == nil {
if daemonClient != nil {
var err error
store, err = sqlite.New(ctx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
} else {
FatalErrorRespectJSON("no database connection")
}
}
// Resolve ID
issueID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("issue '%s' not found: %v", args[0], err)
}
// Get the issue
issue, err := store.GetIssue(ctx, issueID)
if err != nil {
FatalErrorRespectJSON("failed to get issue: %v", err)
}
if issue == nil {
FatalErrorRespectJSON("issue '%s' not found", issueID)
}
var epic *types.Issue
// Check if it's a swarm molecule - if so, follow the link to the epic
if issue.IssueType == types.TypeMolecule && issue.MolType == types.MolTypeSwarm {
// Find linked epic via relates-to dependency
deps, err := store.GetDependencyRecords(ctx, issue.ID)
if err != nil {
FatalErrorRespectJSON("failed to get swarm dependencies: %v", err)
}
for _, dep := range deps {
if dep.Type == types.DepRelatesTo {
epic, err = store.GetIssue(ctx, dep.DependsOnID)
if err != nil {
FatalErrorRespectJSON("failed to get linked epic: %v", err)
}
break
}
}
if epic == nil {
FatalErrorRespectJSON("swarm molecule '%s' has no linked epic", issueID)
}
} else if issue.IssueType == types.TypeEpic || issue.IssueType == types.TypeMolecule {
epic = issue
} else {
FatalErrorRespectJSON("'%s' is not an epic or swarm molecule (type: %s)", issueID, issue.IssueType)
}
// Get swarm status
status, err := getSwarmStatus(ctx, store, epic)
if err != nil {
FatalErrorRespectJSON("failed to get swarm status: %v", err)
}
if jsonOutput {
outputJSON(status)
return
}
// Human-readable output
renderSwarmStatus(status)
},
}
// getSwarmStatus computes current swarm status from beads.
func getSwarmStatus(ctx context.Context, s SwarmStorage, epic *types.Issue) (*SwarmStatus, error) {
status := &SwarmStatus{
EpicID: epic.ID,
EpicTitle: epic.Title,
Completed: []StatusIssue{},
Active: []StatusIssue{},
Ready: []StatusIssue{},
Blocked: []StatusIssue{},
}
// Get all child issues of the epic
childIssues, err := getEpicChildren(ctx, s, epic.ID)
if err != nil {
return nil, err
}
status.TotalIssues = len(childIssues)
if len(childIssues) == 0 {
return status, nil
}
// Build set of child IDs for filtering
childIDSet := make(map[string]bool)
for _, issue := range childIssues {
childIDSet[issue.ID] = true
}
// Build dependency map (within epic children only)
dependsOn := make(map[string][]string)
for _, issue := range childIssues {
deps, err := s.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
for _, dep := range deps {
// Skip parent-child to epic itself
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
continue
}
// Only track blocking dependencies within children
if !dep.Type.AffectsReadyWork() {
continue
}
if childIDSet[dep.DependsOnID] {
dependsOn[issue.ID] = append(dependsOn[issue.ID], dep.DependsOnID)
}
}
}
// Categorize each issue
for _, issue := range childIssues {
si := StatusIssue{
ID: issue.ID,
Title: issue.Title,
Assignee: issue.Assignee,
}
switch issue.Status {
case types.StatusClosed:
if issue.ClosedAt != nil {
si.ClosedAt = issue.ClosedAt.Format("2006-01-02 15:04")
}
status.Completed = append(status.Completed, si)
case types.StatusInProgress:
status.Active = append(status.Active, si)
default: // open or other
// Check if blocked by open dependencies
deps := dependsOn[issue.ID]
var blockers []string
for _, depID := range deps {
depIssue, _ := s.GetIssue(ctx, depID)
if depIssue != nil && depIssue.Status != types.StatusClosed {
blockers = append(blockers, depID)
}
}
if len(blockers) > 0 {
si.BlockedBy = blockers
status.Blocked = append(status.Blocked, si)
} else {
status.Ready = append(status.Ready, si)
}
}
}
// Sort each category by ID for consistent output
sort.Slice(status.Completed, func(i, j int) bool {
return status.Completed[i].ID < status.Completed[j].ID
})
sort.Slice(status.Active, func(i, j int) bool {
return status.Active[i].ID < status.Active[j].ID
})
sort.Slice(status.Ready, func(i, j int) bool {
return status.Ready[i].ID < status.Ready[j].ID
})
sort.Slice(status.Blocked, func(i, j int) bool {
return status.Blocked[i].ID < status.Blocked[j].ID
})
// Compute counts and progress
status.ActiveCount = len(status.Active)
status.ReadyCount = len(status.Ready)
status.BlockedCount = len(status.Blocked)
if status.TotalIssues > 0 {
status.Progress = float64(len(status.Completed)) / float64(status.TotalIssues) * 100
}
return status, nil
}
// renderSwarmStatus outputs human-readable swarm status.
func renderSwarmStatus(status *SwarmStatus) {
fmt.Printf("\n%s Ready Front Analysis: %s\n\n", ui.RenderAccent("🐝"), status.EpicTitle)
// Completed
fmt.Printf("Completed: ")
if len(status.Completed) == 0 {
fmt.Printf("(none)\n")
} else {
for i, issue := range status.Completed {
if i > 0 {
fmt.Printf(" ")
}
fmt.Printf("%s %s\n", ui.RenderPass("✓"), ui.RenderID(issue.ID))
}
}
// Active
fmt.Printf("Active: ")
if len(status.Active) == 0 {
fmt.Printf("(none)\n")
} else {
var parts []string
for _, issue := range status.Active {
part := fmt.Sprintf("⟳ %s", issue.ID)
if issue.Assignee != "" {
part += fmt.Sprintf(" [%s]", issue.Assignee)
}
parts = append(parts, part)
}
fmt.Printf("%s\n", strings.Join(parts, ", "))
}
// Ready
fmt.Printf("Ready: ")
if len(status.Ready) == 0 {
if len(status.Blocked) > 0 {
// Find what's blocking
needed := make(map[string]bool)
for _, b := range status.Blocked {
for _, dep := range b.BlockedBy {
needed[dep] = true
}
}
var neededList []string
for dep := range needed {
neededList = append(neededList, dep)
}
sort.Strings(neededList)
fmt.Printf("(none - waiting for %s)\n", strings.Join(neededList, ", "))
} else {
fmt.Printf("(none)\n")
}
} else {
var parts []string
for _, issue := range status.Ready {
parts = append(parts, fmt.Sprintf("○ %s", issue.ID))
}
fmt.Printf("%s\n", strings.Join(parts, ", "))
}
// Blocked
fmt.Printf("Blocked: ")
if len(status.Blocked) == 0 {
fmt.Printf("(none)\n")
} else {
for i, issue := range status.Blocked {
if i > 0 {
fmt.Printf(" ")
}
blockerStr := strings.Join(issue.BlockedBy, ", ")
fmt.Printf("◌ %s (needs %s)\n", issue.ID, blockerStr)
}
}
// Progress summary
fmt.Printf("\nProgress: %d/%d complete", len(status.Completed), status.TotalIssues)
if status.ActiveCount > 0 {
fmt.Printf(", %d/%d active", status.ActiveCount, status.TotalIssues)
}
fmt.Printf(" (%.0f%%)\n\n", status.Progress)
}
var swarmCreateCmd = &cobra.Command{
Use: "create [epic-id]",
Short: "Create a swarm molecule from an epic",
Long: `Create a swarm molecule to orchestrate parallel work on an epic.
The swarm molecule:
- Links to the epic it orchestrates
- Has mol_type=swarm for discovery
- Specifies a coordinator (optional)
- Can be picked up by any coordinator agent
If given a single issue (not an epic), it will be auto-wrapped:
- Creates an epic with that issue as its only child
- Then creates the swarm molecule for that epic
Examples:
bd swarm create gt-epic-123 # Create swarm for epic
bd swarm create gt-epic-123 --coordinator=witness/ # With specific coordinator
bd swarm create gt-task-456 # Auto-wrap single issue`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("swarm create")
ctx := rootCtx
coordinator, _ := cmd.Flags().GetString("coordinator")
force, _ := cmd.Flags().GetBool("force")
// Swarm commands require direct store access
if store == nil {
if daemonClient != nil {
var err error
store, err = sqlite.New(ctx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
} else {
FatalErrorRespectJSON("no database connection")
}
}
// Resolve the input ID
inputID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("issue '%s' not found: %v", args[0], err)
}
// Get the issue
issue, err := store.GetIssue(ctx, inputID)
if err != nil {
FatalErrorRespectJSON("failed to get issue: %v", err)
}
if issue == nil {
FatalErrorRespectJSON("issue '%s' not found", inputID)
}
var epicID string
var epicTitle string
// Check if it's an epic or single issue that needs wrapping
if issue.IssueType == types.TypeEpic || issue.IssueType == types.TypeMolecule {
epicID = issue.ID
epicTitle = issue.Title
} else {
// Auto-wrap: create an epic with this issue as child
if !jsonOutput {
fmt.Printf("Auto-wrapping single issue as epic...\n")
}
wrapperEpic := &types.Issue{
Title: fmt.Sprintf("Swarm Epic: %s", issue.Title),
Description: fmt.Sprintf("Auto-generated epic to wrap single issue %s for swarm execution.", issue.ID),
Status: types.StatusOpen,
Priority: issue.Priority,
IssueType: types.TypeEpic,
CreatedBy: actor,
}
if err := store.CreateIssue(ctx, wrapperEpic, actor); err != nil {
FatalErrorRespectJSON("failed to create wrapper epic: %v", err)
}
// Add parent-child dependency: issue depends on epic (epic is parent)
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: wrapperEpic.ID,
Type: types.DepParentChild,
CreatedBy: actor,
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
FatalErrorRespectJSON("failed to link issue to epic: %v", err)
}
epicID = wrapperEpic.ID
epicTitle = wrapperEpic.Title
if !jsonOutput {
fmt.Printf("Created wrapper epic: %s\n", epicID)
}
}
// Check for existing swarm molecule
existingSwarm, err := findExistingSwarm(ctx, store, epicID)
if err != nil {
FatalErrorRespectJSON("failed to check for existing swarm: %v", err)
}
if existingSwarm != nil && !force {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "swarm already exists",
"existing_id": existingSwarm.ID,
"existing_title": existingSwarm.Title,
})
} else {
fmt.Printf("%s Swarm already exists: %s\n", ui.RenderWarn("⚠"), ui.RenderID(existingSwarm.ID))
fmt.Printf(" Use --force to create another.\n")
}
os.Exit(1)
}
// Validate the epic structure
epic, err := store.GetIssue(ctx, epicID)
if err != nil {
FatalErrorRespectJSON("failed to get epic: %v", err)
}
analysis, err := analyzeEpicForSwarm(ctx, store, epic)
if err != nil {
FatalErrorRespectJSON("failed to analyze epic: %v", err)
}
if !analysis.Swarmable {
if jsonOutput {
outputJSON(map[string]interface{}{
"error": "epic is not swarmable",
"analysis": analysis,
})
} else {
fmt.Printf("\n%s Epic is not swarmable. Fix errors first:\n", ui.RenderFail("✗"))
for _, e := range analysis.Errors {
fmt.Printf(" • %s\n", e)
}
}
os.Exit(1)
}
// Create the swarm molecule
swarmMol := &types.Issue{
Title: fmt.Sprintf("Swarm: %s", epicTitle),
Description: fmt.Sprintf("Swarm molecule orchestrating epic %s.\n\nEpic: %s\nCoordinator: %s", epicID, epicID, coordinator),
Status: types.StatusOpen,
Priority: epic.Priority,
IssueType: types.TypeMolecule,
MolType: types.MolTypeSwarm,
Assignee: coordinator,
CreatedBy: actor,
}
if err := store.CreateIssue(ctx, swarmMol, actor); err != nil {
FatalErrorRespectJSON("failed to create swarm molecule: %v", err)
}
// Link swarm molecule to epic with relates-to dependency
dep := &types.Dependency{
IssueID: swarmMol.ID,
DependsOnID: epicID,
Type: types.DepRelatesTo,
CreatedBy: actor,
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
FatalErrorRespectJSON("failed to link swarm to epic: %v", err)
}
if jsonOutput {
outputJSON(map[string]interface{}{
"swarm_id": swarmMol.ID,
"epic_id": epicID,
"coordinator": coordinator,
"analysis": analysis,
})
} else {
fmt.Printf("\n%s Created swarm molecule: %s\n", ui.RenderPass("✓"), ui.RenderID(swarmMol.ID))
fmt.Printf(" Epic: %s (%s)\n", epicID, epicTitle)
if coordinator != "" {
fmt.Printf(" Coordinator: %s\n", coordinator)
}
fmt.Printf(" Total issues: %d\n", analysis.TotalIssues)
fmt.Printf(" Max parallelism: %d\n", analysis.MaxParallelism)
fmt.Printf(" Waves: %d\n", len(analysis.ReadyFronts))
}
},
}
var swarmListCmd = &cobra.Command{
Use: "list",
Short: "List all swarm molecules",
Long: `List all swarm molecules with their status.
Shows each swarm molecule with:
- Progress (completed/total issues)
- Active workers
- Epic ID and title
Examples:
bd swarm list # List all swarms
bd swarm list --json # Machine-readable output`,
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Swarm commands require direct store access
if store == nil {
if daemonClient != nil {
var err error
store, err = sqlite.New(ctx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
} else {
FatalErrorRespectJSON("no database connection")
}
}
// Query for all swarm molecules
swarmType := types.MolTypeSwarm
filter := types.IssueFilter{
MolType: &swarmType,
}
swarms, err := store.SearchIssues(ctx, "", filter)
if err != nil {
FatalErrorRespectJSON("failed to list swarms: %v", err)
}
if len(swarms) == 0 {
if jsonOutput {
outputJSON(map[string]interface{}{"swarms": []interface{}{}})
} else {
fmt.Printf("No swarm molecules found.\n")
}
return
}
// Build output with status for each swarm
type SwarmListItem struct {
ID string `json:"id"`
Title string `json:"title"`
EpicID string `json:"epic_id"`
EpicTitle string `json:"epic_title"`
Status string `json:"status"`
Coordinator string `json:"coordinator"`
Total int `json:"total_issues"`
Completed int `json:"completed_issues"`
Active int `json:"active_issues"`
Progress float64 `json:"progress_percent"`
}
var items []SwarmListItem
for _, swarm := range swarms {
item := SwarmListItem{
ID: swarm.ID,
Title: swarm.Title,
Status: string(swarm.Status),
Coordinator: swarm.Assignee,
}
// Find linked epic via relates-to dependency
deps, err := store.GetDependencyRecords(ctx, swarm.ID)
if err == nil {
for _, dep := range deps {
if dep.Type == types.DepRelatesTo {
item.EpicID = dep.DependsOnID
epic, err := store.GetIssue(ctx, dep.DependsOnID)
if err == nil && epic != nil {
item.EpicTitle = epic.Title
// Get swarm status for this epic
status, err := getSwarmStatus(ctx, store, epic)
if err == nil {
item.Total = status.TotalIssues
item.Completed = len(status.Completed)
item.Active = status.ActiveCount
item.Progress = status.Progress
}
}
break
}
}
}
items = append(items, item)
}
if jsonOutput {
outputJSON(map[string]interface{}{"swarms": items})
return
}
// Human-readable output
fmt.Printf("\n%s Active Swarms (%d)\n\n", ui.RenderAccent("🐝"), len(items))
for _, item := range items {
// Progress indicator
progressStr := fmt.Sprintf("%d/%d", item.Completed, item.Total)
if item.Active > 0 {
progressStr += fmt.Sprintf(", %d active", item.Active)
}
fmt.Printf("%s %s\n", ui.RenderID(item.ID), item.Title)
if item.EpicID != "" {
fmt.Printf(" Epic: %s (%s)\n", item.EpicID, item.EpicTitle)
}
fmt.Printf(" Progress: %s (%.0f%%)\n", progressStr, item.Progress)
if item.Coordinator != "" {
fmt.Printf(" Coordinator: %s\n", item.Coordinator)
}
fmt.Println()
}
},
}
func init() {
swarmValidateCmd.Flags().Bool("verbose", false, "Include detailed issue graph in output")
swarmCreateCmd.Flags().String("coordinator", "", "Coordinator address (e.g., gastown/witness)")
swarmCreateCmd.Flags().Bool("force", false, "Create new swarm even if one already exists")
swarmCmd.AddCommand(swarmValidateCmd)
swarmCmd.AddCommand(swarmStatusCmd)
swarmCmd.AddCommand(swarmCreateCmd)
swarmCmd.AddCommand(swarmListCmd)
rootCmd.AddCommand(swarmCmd)
}