feat: add SwarmManager for swarm lifecycle operations
- Create/Start/Cancel swarm lifecycle methods - State transition validation (Created->Active->Merging->Landed) - Task assignment and state tracking - GetReadyTasks/GetActiveTasks queries - IsComplete check for swarm completion - Integration with beads CLI for task loading Closes gt-kmn.2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
337
internal/swarm/manager.go
Normal file
337
internal/swarm/manager.go
Normal file
@@ -0,0 +1,337 @@
|
||||
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 list --parent <epicID> --json
|
||||
cmd := exec.Command("bd", "list", "--parent", 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 list: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||
}
|
||||
|
||||
var tasks []SwarmTask
|
||||
for _, issue := range issues {
|
||||
state := TaskPending
|
||||
switch issue.Status {
|
||||
case "in_progress":
|
||||
state = TaskInProgress
|
||||
case "closed":
|
||||
state = TaskMerged
|
||||
}
|
||||
|
||||
tasks = append(tasks, SwarmTask{
|
||||
IssueID: issue.ID,
|
||||
Title: issue.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
|
||||
}
|
||||
187
internal/swarm/manager_test.go
Normal file
187
internal/swarm/manager_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package swarm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
)
|
||||
|
||||
func TestManagerCreate(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
m := NewManager(r)
|
||||
|
||||
swarm, err := m.Create("epic-1", []string{"Toast", "Nux"}, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
if swarm.ID != "epic-1" {
|
||||
t.Errorf("ID = %q, want %q", swarm.ID, "epic-1")
|
||||
}
|
||||
if swarm.State != SwarmCreated {
|
||||
t.Errorf("State = %q, want %q", swarm.State, SwarmCreated)
|
||||
}
|
||||
if len(swarm.Workers) != 2 {
|
||||
t.Errorf("Workers = %d, want 2", len(swarm.Workers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCreateDuplicate(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
m := NewManager(r)
|
||||
|
||||
_, err := m.Create("epic-1", []string{"Toast"}, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("First Create failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = m.Create("epic-1", []string{"Nux"}, "main")
|
||||
if err != ErrSwarmExists {
|
||||
t.Errorf("Create duplicate = %v, want ErrSwarmExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStateTransitions(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
m := NewManager(r)
|
||||
|
||||
swarm, _ := m.Create("epic-1", []string{"Toast"}, "main")
|
||||
|
||||
// Start
|
||||
if err := m.Start(swarm.ID); err != nil {
|
||||
t.Errorf("Start failed: %v", err)
|
||||
}
|
||||
s, _ := m.GetSwarm(swarm.ID)
|
||||
if s.State != SwarmActive {
|
||||
t.Errorf("State after Start = %q, want %q", s.State, SwarmActive)
|
||||
}
|
||||
|
||||
// Can't start again
|
||||
if err := m.Start(swarm.ID); err == nil {
|
||||
t.Error("Start from Active should fail")
|
||||
}
|
||||
|
||||
// Transition to Merging
|
||||
if err := m.UpdateState(swarm.ID, SwarmMerging); err != nil {
|
||||
t.Errorf("UpdateState to Merging failed: %v", err)
|
||||
}
|
||||
|
||||
// Transition to Landed
|
||||
if err := m.UpdateState(swarm.ID, SwarmLanded); err != nil {
|
||||
t.Errorf("UpdateState to Landed failed: %v", err)
|
||||
}
|
||||
|
||||
// Can't transition from terminal
|
||||
if err := m.UpdateState(swarm.ID, SwarmActive); err == nil {
|
||||
t.Error("UpdateState from Landed should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCancel(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
m := NewManager(r)
|
||||
|
||||
swarm, _ := m.Create("epic-1", []string{"Toast"}, "main")
|
||||
m.Start(swarm.ID)
|
||||
|
||||
if err := m.Cancel(swarm.ID, "user requested"); err != nil {
|
||||
t.Errorf("Cancel failed: %v", err)
|
||||
}
|
||||
|
||||
s, _ := m.GetSwarm(swarm.ID)
|
||||
if s.State != SwarmCancelled {
|
||||
t.Errorf("State after Cancel = %q, want %q", s.State, SwarmCancelled)
|
||||
}
|
||||
if s.Error != "user requested" {
|
||||
t.Errorf("Error = %q, want %q", s.Error, "user requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerTaskOperations(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
m := NewManager(r)
|
||||
|
||||
swarm, _ := m.Create("epic-1", []string{"Toast"}, "main")
|
||||
|
||||
// Manually add tasks (normally loaded from beads)
|
||||
swarm.Tasks = []SwarmTask{
|
||||
{IssueID: "task-1", Title: "Task 1", State: TaskPending},
|
||||
{IssueID: "task-2", Title: "Task 2", State: TaskPending},
|
||||
}
|
||||
|
||||
// Get ready tasks
|
||||
ready, err := m.GetReadyTasks(swarm.ID)
|
||||
if err != nil {
|
||||
t.Errorf("GetReadyTasks failed: %v", err)
|
||||
}
|
||||
if len(ready) != 2 {
|
||||
t.Errorf("GetReadyTasks = %d, want 2", len(ready))
|
||||
}
|
||||
|
||||
// Assign task
|
||||
if err := m.AssignTask(swarm.ID, "task-1", "Toast"); err != nil {
|
||||
t.Errorf("AssignTask failed: %v", err)
|
||||
}
|
||||
|
||||
// Check assignment
|
||||
s, _ := m.GetSwarm(swarm.ID)
|
||||
if s.Tasks[0].Assignee != "Toast" {
|
||||
t.Errorf("Assignee = %q, want %q", s.Tasks[0].Assignee, "Toast")
|
||||
}
|
||||
if s.Tasks[0].State != TaskAssigned {
|
||||
t.Errorf("State = %q, want %q", s.Tasks[0].State, TaskAssigned)
|
||||
}
|
||||
|
||||
// Update state
|
||||
if err := m.UpdateTaskState(swarm.ID, "task-1", TaskMerged); err != nil {
|
||||
t.Errorf("UpdateTaskState failed: %v", err)
|
||||
}
|
||||
s, _ = m.GetSwarm(swarm.ID)
|
||||
if s.Tasks[0].State != TaskMerged {
|
||||
t.Errorf("State = %q, want %q", s.Tasks[0].State, TaskMerged)
|
||||
}
|
||||
if s.Tasks[0].MergedAt == nil {
|
||||
t.Error("MergedAt should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerIsComplete(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
m := NewManager(r)
|
||||
|
||||
swarm, _ := m.Create("epic-1", []string{"Toast"}, "main")
|
||||
swarm.Tasks = []SwarmTask{
|
||||
{IssueID: "task-1", State: TaskPending},
|
||||
{IssueID: "task-2", State: TaskMerged},
|
||||
}
|
||||
|
||||
complete, _ := m.IsComplete(swarm.ID)
|
||||
if complete {
|
||||
t.Error("IsComplete should be false with pending task")
|
||||
}
|
||||
|
||||
// Complete the pending task
|
||||
m.UpdateTaskState(swarm.ID, "task-1", TaskMerged)
|
||||
complete, _ = m.IsComplete(swarm.ID)
|
||||
if !complete {
|
||||
t.Error("IsComplete should be true when all tasks merged")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user