package swarm import ( "bytes" "encoding/json" "errors" "fmt" "os/exec" "strings" "time" "github.com/steveyegge/gastown/internal/rig" ) // Common errors var ( ErrSwarmNotFound = errors.New("swarm not found") ErrSwarmExists = errors.New("swarm already exists") ErrInvalidState = errors.New("invalid state transition") ErrNoReadyTasks = errors.New("no ready tasks") ErrBeadsNotFound = errors.New("beads not available") ) // Manager handles swarm lifecycle operations. type Manager struct { rig *rig.Rig swarms map[string]*Swarm workDir string } // NewManager creates a new swarm manager for a rig. func NewManager(r *rig.Rig) *Manager { return &Manager{ rig: r, swarms: make(map[string]*Swarm), workDir: r.Path, } } // Create creates a new swarm from an epic. func (m *Manager) Create(epicID string, workers []string, targetBranch string) (*Swarm, error) { if _, exists := m.swarms[epicID]; exists { return nil, ErrSwarmExists } // Get current git commit as base (optional - may not have git) baseCommit, _ := m.getGitHead() if baseCommit == "" { baseCommit = "unknown" } now := time.Now() swarm := &Swarm{ ID: epicID, RigName: m.rig.Name, EpicID: epicID, BaseCommit: baseCommit, Integration: fmt.Sprintf("swarm/%s", epicID), TargetBranch: targetBranch, State: SwarmCreated, CreatedAt: now, UpdatedAt: now, Workers: workers, Tasks: []SwarmTask{}, } // Load tasks from beads tasks, err := m.loadTasksFromBeads(epicID) if err != nil { // Non-fatal - swarm can start without tasks loaded } else { swarm.Tasks = tasks } m.swarms[epicID] = swarm return swarm, nil } // Start activates a swarm, transitioning from Created to Active. func (m *Manager) Start(swarmID string) error { swarm, ok := m.swarms[swarmID] if !ok { return ErrSwarmNotFound } if swarm.State != SwarmCreated { return fmt.Errorf("%w: cannot start from state %s", ErrInvalidState, swarm.State) } swarm.State = SwarmActive swarm.UpdatedAt = time.Now() return nil } // UpdateState transitions the swarm to a new state. func (m *Manager) UpdateState(swarmID string, state SwarmState) error { swarm, ok := m.swarms[swarmID] if !ok { return ErrSwarmNotFound } // Validate state transition if !isValidTransition(swarm.State, state) { return fmt.Errorf("%w: cannot transition from %s to %s", ErrInvalidState, swarm.State, state) } swarm.State = state swarm.UpdatedAt = time.Now() return nil } // Cancel cancels a swarm with a reason. func (m *Manager) Cancel(swarmID string, reason string) error { swarm, ok := m.swarms[swarmID] if !ok { return ErrSwarmNotFound } if swarm.State.IsTerminal() { return fmt.Errorf("%w: swarm already in terminal state %s", ErrInvalidState, swarm.State) } swarm.State = SwarmCancelled swarm.Error = reason swarm.UpdatedAt = time.Now() return nil } // GetSwarm returns a swarm by ID. func (m *Manager) GetSwarm(id string) (*Swarm, error) { swarm, ok := m.swarms[id] if !ok { return nil, ErrSwarmNotFound } return swarm, nil } // GetReadyTasks returns tasks ready to be assigned. func (m *Manager) GetReadyTasks(swarmID string) ([]SwarmTask, error) { swarm, ok := m.swarms[swarmID] if !ok { return nil, ErrSwarmNotFound } var ready []SwarmTask for _, task := range swarm.Tasks { if task.State == TaskPending { ready = append(ready, task) } } if len(ready) == 0 { return nil, ErrNoReadyTasks } return ready, nil } // GetActiveTasks returns tasks currently in progress. func (m *Manager) GetActiveTasks(swarmID string) ([]SwarmTask, error) { swarm, ok := m.swarms[swarmID] if !ok { return nil, ErrSwarmNotFound } var active []SwarmTask for _, task := range swarm.Tasks { if task.State == TaskInProgress || task.State == TaskAssigned { active = append(active, task) } } return active, nil } // IsComplete checks if all tasks are in terminal states. func (m *Manager) IsComplete(swarmID string) (bool, error) { swarm, ok := m.swarms[swarmID] if !ok { return false, ErrSwarmNotFound } if len(swarm.Tasks) == 0 { return false, nil } for _, task := range swarm.Tasks { if !task.State.IsComplete() { return false, nil } } return true, nil } // AssignTask assigns a task to a worker. func (m *Manager) AssignTask(swarmID, taskID, worker string) error { swarm, ok := m.swarms[swarmID] if !ok { return ErrSwarmNotFound } for i, task := range swarm.Tasks { if task.IssueID == taskID { swarm.Tasks[i].Assignee = worker swarm.Tasks[i].State = TaskAssigned swarm.Tasks[i].Branch = fmt.Sprintf("polecat/%s/%s", worker, taskID) swarm.UpdatedAt = time.Now() return nil } } return fmt.Errorf("task %s not found in swarm", taskID) } // UpdateTaskState updates a task's state. func (m *Manager) UpdateTaskState(swarmID, taskID string, state TaskState) error { swarm, ok := m.swarms[swarmID] if !ok { return ErrSwarmNotFound } for i, task := range swarm.Tasks { if task.IssueID == taskID { swarm.Tasks[i].State = state if state == TaskMerged { now := time.Now() swarm.Tasks[i].MergedAt = &now } swarm.UpdatedAt = time.Now() return nil } } return fmt.Errorf("task %s not found in swarm", taskID) } // ListSwarms returns all swarms in the manager. func (m *Manager) ListSwarms() []*Swarm { swarms := make([]*Swarm, 0, len(m.swarms)) for _, s := range m.swarms { swarms = append(swarms, s) } return swarms } // ListActiveSwarms returns non-terminal swarms. func (m *Manager) ListActiveSwarms() []*Swarm { var active []*Swarm for _, s := range m.swarms { if s.State.IsActive() { active = append(active, s) } } return active } // isValidTransition checks if a state transition is allowed. func isValidTransition(from, to SwarmState) bool { transitions := map[SwarmState][]SwarmState{ SwarmCreated: {SwarmActive, SwarmCancelled}, SwarmActive: {SwarmMerging, SwarmFailed, SwarmCancelled}, SwarmMerging: {SwarmLanded, SwarmFailed, SwarmCancelled}, SwarmLanded: {}, // Terminal SwarmFailed: {}, // Terminal SwarmCancelled: {}, // Terminal } allowed, ok := transitions[from] if !ok { return false } for _, s := range allowed { if s == to { return true } } return false } // loadTasksFromBeads loads child issues from beads CLI. func (m *Manager) loadTasksFromBeads(epicID string) ([]SwarmTask, error) { // Run: bd show --json to get epic with children cmd := exec.Command("bd", "show", epicID, "--json") cmd.Dir = m.workDir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("bd show: %s", strings.TrimSpace(stderr.String())) } // Parse JSON output - bd show returns an array var issues []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Dependents []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` DependencyType string `json:"dependency_type"` } `json:"dependents"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { return nil, fmt.Errorf("parsing bd output: %w", err) } if len(issues) == 0 { return nil, fmt.Errorf("epic not found: %s", epicID) } // Extract dependents as tasks (issues that depend on/are blocked by this epic) // Accept both "parent-child" and "blocks" relationships var tasks []SwarmTask for _, dep := range issues[0].Dependents { if dep.DependencyType != "parent-child" && dep.DependencyType != "blocks" { continue } state := TaskPending switch dep.Status { case "in_progress": state = TaskInProgress case "closed": state = TaskMerged } tasks = append(tasks, SwarmTask{ IssueID: dep.ID, Title: dep.Title, State: state, }) } return tasks, nil } // getGitHead returns the current HEAD commit. func (m *Manager) getGitHead() (string, error) { cmd := exec.Command("git", "rev-parse", "HEAD") cmd.Dir = m.workDir var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return "", err } return strings.TrimSpace(stdout.String()), nil }