refactor: Remove in-memory swarms map, make Manager stateless
The swarm Manager was maintaining an in-memory map of swarms that was never persisted and duplicated state from beads. This caused stale state after restarts and confusion about source of truth. Changes: - Remove swarms map from Manager (now stateless) - Add LoadSwarm() that queries beads for swarm state - Refactor all methods to use LoadSwarm() instead of in-memory lookup - Discover workers from assigned tasks in beads - Remove obsolete unit tests that tested in-memory behavior - Keep type/state tests that do not need beads The E2E test (gt-kc7yj.4) now covers the beads integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -658,14 +658,9 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Use swarm manager for the actual landing (git operations)
|
// Use swarm manager for the actual landing (git operations)
|
||||||
mgr := swarm.NewManager(foundRig)
|
mgr := swarm.NewManager(foundRig)
|
||||||
sw, err := mgr.Create(swarmID, nil, "main")
|
sw, err := mgr.LoadSwarm(swarmID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("loading swarm for landing: %w", err)
|
return fmt.Errorf("loading swarm from beads: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Execute landing to main
|
|
||||||
if err := mgr.LandToMain(swarmID); err != nil {
|
|
||||||
return fmt.Errorf("landing swarm: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute full landing protocol
|
// Execute full landing protocol
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ var (
|
|||||||
// CreateIntegrationBranch creates the integration branch for a swarm.
|
// CreateIntegrationBranch creates the integration branch for a swarm.
|
||||||
// The branch is created from the swarm's BaseCommit and pushed to origin.
|
// The branch is created from the swarm's BaseCommit and pushed to origin.
|
||||||
func (m *Manager) CreateIntegrationBranch(swarmID string) error {
|
func (m *Manager) CreateIntegrationBranch(swarmID string) error {
|
||||||
swarm, ok := m.swarms[swarmID]
|
swarm, err := m.LoadSwarm(swarmID)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrSwarmNotFound
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
branchName := swarm.Integration
|
branchName := swarm.Integration
|
||||||
@@ -45,9 +45,9 @@ func (m *Manager) CreateIntegrationBranch(swarmID string) error {
|
|||||||
// MergeToIntegration merges a worker branch into the integration branch.
|
// MergeToIntegration merges a worker branch into the integration branch.
|
||||||
// Returns ErrMergeConflict if the merge has conflicts.
|
// Returns ErrMergeConflict if the merge has conflicts.
|
||||||
func (m *Manager) MergeToIntegration(swarmID, workerBranch string) error {
|
func (m *Manager) MergeToIntegration(swarmID, workerBranch string) error {
|
||||||
swarm, ok := m.swarms[swarmID]
|
swarm, err := m.LoadSwarm(swarmID)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrSwarmNotFound
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we're on the integration branch
|
// Ensure we're on the integration branch
|
||||||
@@ -87,9 +87,9 @@ func (m *Manager) AbortMerge() error {
|
|||||||
|
|
||||||
// LandToMain merges the integration branch to the target branch (usually main).
|
// LandToMain merges the integration branch to the target branch (usually main).
|
||||||
func (m *Manager) LandToMain(swarmID string) error {
|
func (m *Manager) LandToMain(swarmID string) error {
|
||||||
swarm, ok := m.swarms[swarmID]
|
swarm, err := m.LoadSwarm(swarmID)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrSwarmNotFound
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checkout target branch
|
// Checkout target branch
|
||||||
@@ -101,7 +101,7 @@ func (m *Manager) LandToMain(swarmID string) error {
|
|||||||
_ = m.gitRun("pull", "origin", swarm.TargetBranch)
|
_ = m.gitRun("pull", "origin", swarm.TargetBranch)
|
||||||
|
|
||||||
// Merge integration branch
|
// Merge integration branch
|
||||||
err := m.gitRun("merge", "--no-ff", "-m",
|
err = m.gitRun("merge", "--no-ff", "-m",
|
||||||
fmt.Sprintf("Land swarm %s", swarmID),
|
fmt.Sprintf("Land swarm %s", swarmID),
|
||||||
swarm.Integration)
|
swarm.Integration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,9 +121,9 @@ func (m *Manager) LandToMain(swarmID string) error {
|
|||||||
|
|
||||||
// CleanupBranches removes all branches associated with a swarm.
|
// CleanupBranches removes all branches associated with a swarm.
|
||||||
func (m *Manager) CleanupBranches(swarmID string) error {
|
func (m *Manager) CleanupBranches(swarmID string) error {
|
||||||
swarm, ok := m.swarms[swarmID]
|
swarm, err := m.LoadSwarm(swarmID)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return ErrSwarmNotFound
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -151,9 +151,9 @@ func (m *Manager) CleanupBranches(swarmID string) error {
|
|||||||
|
|
||||||
// GetIntegrationBranch returns the integration branch name for a swarm.
|
// GetIntegrationBranch returns the integration branch name for a swarm.
|
||||||
func (m *Manager) GetIntegrationBranch(swarmID string) (string, error) {
|
func (m *Manager) GetIntegrationBranch(swarmID string) (string, error) {
|
||||||
swarm, ok := m.swarms[swarmID]
|
swarm, err := m.LoadSwarm(swarmID)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return "", ErrSwarmNotFound
|
return "", err
|
||||||
}
|
}
|
||||||
return swarm.Integration, nil
|
return swarm.Integration, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,39 +6,6 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetIntegrationBranch(t *testing.T) {
|
|
||||||
r := &rig.Rig{
|
|
||||||
Name: "test-rig",
|
|
||||||
Path: "/tmp/test-rig",
|
|
||||||
}
|
|
||||||
m := NewManager(r)
|
|
||||||
|
|
||||||
swarm, _ := m.Create("epic-1", []string{"Toast"}, "main")
|
|
||||||
|
|
||||||
branch, err := m.GetIntegrationBranch(swarm.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetIntegrationBranch failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := "swarm/epic-1"
|
|
||||||
if branch != expected {
|
|
||||||
t.Errorf("branch = %q, want %q", branch, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetIntegrationBranchNotFound(t *testing.T) {
|
|
||||||
r := &rig.Rig{
|
|
||||||
Name: "test-rig",
|
|
||||||
Path: "/tmp/test-rig",
|
|
||||||
}
|
|
||||||
m := NewManager(r)
|
|
||||||
|
|
||||||
_, err := m.GetIntegrationBranch("nonexistent")
|
|
||||||
if err != ErrSwarmNotFound {
|
|
||||||
t.Errorf("GetIntegrationBranch = %v, want ErrSwarmNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetWorkerBranch(t *testing.T) {
|
func TestGetWorkerBranch(t *testing.T) {
|
||||||
r := &rig.Rig{
|
r := &rig.Rig{
|
||||||
Name: "test-rig",
|
Name: "test-rig",
|
||||||
@@ -53,54 +20,5 @@ func TestGetWorkerBranch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateIntegrationBranchSwarmNotFound(t *testing.T) {
|
// Note: Integration tests that require git operations and beads
|
||||||
r := &rig.Rig{
|
// are covered by the E2E test (gt-kc7yj.4).
|
||||||
Name: "test-rig",
|
|
||||||
Path: "/tmp/test-rig",
|
|
||||||
}
|
|
||||||
m := NewManager(r)
|
|
||||||
|
|
||||||
err := m.CreateIntegrationBranch("nonexistent")
|
|
||||||
if err != ErrSwarmNotFound {
|
|
||||||
t.Errorf("CreateIntegrationBranch = %v, want ErrSwarmNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeToIntegrationSwarmNotFound(t *testing.T) {
|
|
||||||
r := &rig.Rig{
|
|
||||||
Name: "test-rig",
|
|
||||||
Path: "/tmp/test-rig",
|
|
||||||
}
|
|
||||||
m := NewManager(r)
|
|
||||||
|
|
||||||
err := m.MergeToIntegration("nonexistent", "branch")
|
|
||||||
if err != ErrSwarmNotFound {
|
|
||||||
t.Errorf("MergeToIntegration = %v, want ErrSwarmNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLandToMainSwarmNotFound(t *testing.T) {
|
|
||||||
r := &rig.Rig{
|
|
||||||
Name: "test-rig",
|
|
||||||
Path: "/tmp/test-rig",
|
|
||||||
}
|
|
||||||
m := NewManager(r)
|
|
||||||
|
|
||||||
err := m.LandToMain("nonexistent")
|
|
||||||
if err != ErrSwarmNotFound {
|
|
||||||
t.Errorf("LandToMain = %v, want ErrSwarmNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupBranchesSwarmNotFound(t *testing.T) {
|
|
||||||
r := &rig.Rig{
|
|
||||||
Name: "test-rig",
|
|
||||||
Path: "/tmp/test-rig",
|
|
||||||
}
|
|
||||||
m := NewManager(r)
|
|
||||||
|
|
||||||
err := m.CleanupBranches("nonexistent")
|
|
||||||
if err != ErrSwarmNotFound {
|
|
||||||
t.Errorf("CleanupBranches = %v, want ErrSwarmNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ type GitAuditResult struct {
|
|||||||
|
|
||||||
// ExecuteLanding performs the witness landing protocol for a swarm.
|
// ExecuteLanding performs the witness landing protocol for a swarm.
|
||||||
func (m *Manager) ExecuteLanding(swarmID string, config LandingConfig) (*LandingResult, error) {
|
func (m *Manager) ExecuteLanding(swarmID string, config LandingConfig) (*LandingResult, error) {
|
||||||
swarm, ok := m.swarms[swarmID]
|
swarm, err := m.LoadSwarm(swarmID)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return nil, ErrSwarmNotFound
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &LandingResult{
|
result := &LandingResult{
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
)
|
)
|
||||||
@@ -22,9 +21,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Manager handles swarm lifecycle operations.
|
// Manager handles swarm lifecycle operations.
|
||||||
|
// Manager is stateless - all swarm state is discovered from beads.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
rig *rig.Rig
|
rig *rig.Rig
|
||||||
swarms map[string]*Swarm
|
|
||||||
workDir string
|
workDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,224 +31,158 @@ type Manager struct {
|
|||||||
func NewManager(r *rig.Rig) *Manager {
|
func NewManager(r *rig.Rig) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
rig: r,
|
rig: r,
|
||||||
swarms: make(map[string]*Swarm),
|
|
||||||
workDir: r.Path,
|
workDir: r.Path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create creates a new swarm from an epic.
|
// LoadSwarm loads swarm state from beads by querying the epic.
|
||||||
func (m *Manager) Create(epicID string, workers []string, targetBranch string) (*Swarm, error) {
|
// This is the canonical way to get swarm state - no in-memory caching.
|
||||||
if _, exists := m.swarms[epicID]; exists {
|
func (m *Manager) LoadSwarm(epicID string) (*Swarm, error) {
|
||||||
return nil, ErrSwarmExists
|
// Query beads for the epic
|
||||||
|
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()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current git commit as base (optional - may not have git)
|
// Parse the epic
|
||||||
|
var epic struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MolType string `json:"mol_type"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &epic); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing epic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a swarm molecule
|
||||||
|
if epic.MolType != "swarm" {
|
||||||
|
return nil, fmt.Errorf("epic %s is not a swarm (mol_type=%s)", epicID, epic.MolType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current git commit as base
|
||||||
baseCommit, _ := m.getGitHead()
|
baseCommit, _ := m.getGitHead()
|
||||||
if baseCommit == "" {
|
if baseCommit == "" {
|
||||||
baseCommit = "unknown"
|
baseCommit = "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
// Map status to swarm state
|
||||||
|
state := SwarmActive
|
||||||
|
if epic.Status == "closed" {
|
||||||
|
state = SwarmLanded
|
||||||
|
}
|
||||||
|
|
||||||
swarm := &Swarm{
|
swarm := &Swarm{
|
||||||
ID: epicID,
|
ID: epicID,
|
||||||
RigName: m.rig.Name,
|
RigName: m.rig.Name,
|
||||||
EpicID: epicID,
|
EpicID: epicID,
|
||||||
BaseCommit: baseCommit,
|
BaseCommit: baseCommit,
|
||||||
Integration: fmt.Sprintf("swarm/%s", epicID),
|
Integration: fmt.Sprintf("swarm/%s", epicID),
|
||||||
TargetBranch: targetBranch,
|
TargetBranch: "main",
|
||||||
State: SwarmCreated,
|
State: state,
|
||||||
CreatedAt: now,
|
Workers: []string{}, // Discovered from active tasks
|
||||||
UpdatedAt: now,
|
|
||||||
Workers: workers,
|
|
||||||
Tasks: []SwarmTask{},
|
Tasks: []SwarmTask{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tasks from beads
|
// Load tasks from beads (children of the epic)
|
||||||
tasks, err := m.loadTasksFromBeads(epicID)
|
tasks, err := m.loadTasksFromBeads(epicID)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
// Non-fatal - swarm can start without tasks loaded
|
|
||||||
} else {
|
|
||||||
swarm.Tasks = tasks
|
swarm.Tasks = tasks
|
||||||
}
|
// Discover workers from assigned tasks
|
||||||
|
for _, task := range tasks {
|
||||||
m.swarms[epicID] = swarm
|
if task.Assignee != "" {
|
||||||
return swarm, nil
|
swarm.Workers = appendUnique(swarm.Workers, task.Assignee)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 swarm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendUnique appends s to slice if not already present.
|
||||||
|
func appendUnique(slice []string, s string) []string {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == s {
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(slice, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSwarm loads a swarm from beads. Alias for LoadSwarm for compatibility.
|
||||||
|
func (m *Manager) GetSwarm(id string) (*Swarm, error) {
|
||||||
|
return m.LoadSwarm(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadyTasks returns tasks ready to be assigned by querying beads.
|
||||||
|
func (m *Manager) GetReadyTasks(swarmID string) ([]SwarmTask, error) {
|
||||||
|
// Use bd swarm status to get ready front
|
||||||
|
cmd := exec.Command("bd", "swarm", "status", swarmID, "--json")
|
||||||
|
cmd.Dir = m.workDir
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, ErrSwarmNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
var status struct {
|
||||||
|
Ready []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"ready"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(status.Ready) == 0 {
|
||||||
return nil, ErrNoReadyTasks
|
return nil, ErrNoReadyTasks
|
||||||
}
|
}
|
||||||
return ready, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetActiveTasks returns tasks currently in progress.
|
tasks := make([]SwarmTask, len(status.Ready))
|
||||||
func (m *Manager) GetActiveTasks(swarmID string) ([]SwarmTask, error) {
|
for i, r := range status.Ready {
|
||||||
swarm, ok := m.swarms[swarmID]
|
tasks[i] = SwarmTask{
|
||||||
if !ok {
|
IssueID: r.ID,
|
||||||
return nil, ErrSwarmNotFound
|
Title: r.Title,
|
||||||
}
|
State: TaskPending,
|
||||||
|
|
||||||
var active []SwarmTask
|
|
||||||
for _, task := range swarm.Tasks {
|
|
||||||
if task.State == TaskInProgress || task.State == TaskAssigned {
|
|
||||||
active = append(active, task)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return active, nil
|
return tasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsComplete checks if all tasks are in terminal states.
|
// IsComplete checks if all tasks are closed by querying beads.
|
||||||
func (m *Manager) IsComplete(swarmID string) (bool, error) {
|
func (m *Manager) IsComplete(swarmID string) (bool, error) {
|
||||||
swarm, ok := m.swarms[swarmID]
|
cmd := exec.Command("bd", "swarm", "status", swarmID, "--json")
|
||||||
if !ok {
|
cmd.Dir = m.workDir
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
return false, ErrSwarmNotFound
|
return false, ErrSwarmNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(swarm.Tasks) == 0 {
|
var status struct {
|
||||||
return false, nil
|
Ready []struct{ ID string } `json:"ready"`
|
||||||
|
Active []struct{ ID string } `json:"active"`
|
||||||
|
Blocked []struct{ ID string } `json:"blocked"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil {
|
||||||
|
return false, fmt.Errorf("parsing status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, task := range swarm.Tasks {
|
// Complete if nothing is ready, active, or blocked
|
||||||
if !task.State.IsComplete() {
|
return len(status.Ready) == 0 && len(status.Active) == 0 && len(status.Blocked) == 0, nil
|
||||||
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.
|
// isValidTransition checks if a state transition is allowed.
|
||||||
|
|||||||
@@ -6,182 +6,23 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestManagerCreate(t *testing.T) {
|
func TestNewManager(t *testing.T) {
|
||||||
r := &rig.Rig{
|
r := &rig.Rig{
|
||||||
Name: "test-rig",
|
Name: "test-rig",
|
||||||
Path: "/tmp/test-rig",
|
Path: "/tmp/test-rig",
|
||||||
}
|
}
|
||||||
m := NewManager(r)
|
m := NewManager(r)
|
||||||
|
|
||||||
swarm, err := m.Create("epic-1", []string{"Toast", "Nux"}, "main")
|
if m == nil {
|
||||||
if err != nil {
|
t.Fatal("NewManager returned nil")
|
||||||
t.Fatalf("Create failed: %v", err)
|
|
||||||
}
|
}
|
||||||
|
if m.rig != r {
|
||||||
if swarm.ID != "epic-1" {
|
t.Error("Manager rig not set correctly")
|
||||||
t.Errorf("ID = %q, want %q", swarm.ID, "epic-1")
|
|
||||||
}
|
}
|
||||||
if swarm.State != SwarmCreated {
|
if m.workDir != r.Path {
|
||||||
t.Errorf("State = %q, want %q", swarm.State, SwarmCreated)
|
t.Errorf("workDir = %q, want %q", m.workDir, r.Path)
|
||||||
}
|
|
||||||
if len(swarm.Workers) != 2 {
|
|
||||||
t.Errorf("Workers = %d, want 2", len(swarm.Workers))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManagerCreateDuplicate(t *testing.T) {
|
// Note: Most swarm tests require integration with beads.
|
||||||
r := &rig.Rig{
|
// See gt-kc7yj.4 for the E2E integration test.
|
||||||
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