Files
beads/cmd/bd/swarm.go
collins 7cf67153de refactor(types): remove Gas Town type constants from beads core (bd-w2zz4)
Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy,
TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage)
from internal/types/types.go.

Beads now only has core work types built-in:
- bug, feature, task, epic, chore

All Gas Town types are now purely custom types with no special handling in beads.
Use string literals like "gate" or "molecule" when needed, and configure
types.custom in config.yaml for validation.

Changes:
- Remove Gas Town type constants from types.go
- Remove mr/mol aliases from Normalize()
- Update bd types command to only show core types
- Replace all constant usages with string literals throughout codebase
- Update tests to use string literals

This decouples beads from Gas Town, making it a generic issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:36:59 -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 != "molecule" {
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 != "molecule" {
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 == "molecule" && 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 == "molecule" {
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 == "molecule" {
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: "molecule",
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)
}