feat: add swarm package with core types
Types: - Swarm: coordinated multi-agent work unit - SwarmTask: single task assigned to worker - SwarmState: lifecycle states (created/active/merging/landed/failed/cancelled) - TaskState: task states (pending/assigned/in_progress/review/merged/failed) - SwarmSummary: high-level progress overview Methods: - SwarmState.IsTerminal(), IsActive() - TaskState.IsComplete() - Swarm.Summary(), Progress() Closes gt-kmn.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
180
internal/swarm/types.go
Normal file
180
internal/swarm/types.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Package swarm provides types and management for multi-agent swarms.
|
||||||
|
package swarm
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SwarmState represents the lifecycle state of a swarm.
|
||||||
|
type SwarmState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SwarmCreated is the initial state after swarm creation.
|
||||||
|
SwarmCreated SwarmState = "created"
|
||||||
|
|
||||||
|
// SwarmActive means workers are actively working on tasks.
|
||||||
|
SwarmActive SwarmState = "active"
|
||||||
|
|
||||||
|
// SwarmMerging means all work is done and merging is in progress.
|
||||||
|
SwarmMerging SwarmState = "merging"
|
||||||
|
|
||||||
|
// SwarmLanded means all work has been merged to the target branch.
|
||||||
|
SwarmLanded SwarmState = "landed"
|
||||||
|
|
||||||
|
// SwarmFailed means the swarm failed and cannot be recovered.
|
||||||
|
SwarmFailed SwarmState = "failed"
|
||||||
|
|
||||||
|
// SwarmCancelled means the swarm was explicitly cancelled.
|
||||||
|
SwarmCancelled SwarmState = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsTerminal returns true if the swarm is in a terminal state.
|
||||||
|
func (s SwarmState) IsTerminal() bool {
|
||||||
|
return s == SwarmLanded || s == SwarmFailed || s == SwarmCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if the swarm is actively running.
|
||||||
|
func (s SwarmState) IsActive() bool {
|
||||||
|
return s == SwarmCreated || s == SwarmActive || s == SwarmMerging
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swarm represents a coordinated multi-agent work unit.
|
||||||
|
// The swarm references a beads epic that tracks all swarm work.
|
||||||
|
type Swarm struct {
|
||||||
|
// ID is the unique swarm identifier (matches beads epic ID).
|
||||||
|
ID string `json:"id"`
|
||||||
|
|
||||||
|
// RigName is the rig this swarm operates in.
|
||||||
|
RigName string `json:"rig_name"`
|
||||||
|
|
||||||
|
// EpicID is the beads epic tracking this swarm's work.
|
||||||
|
EpicID string `json:"epic_id"`
|
||||||
|
|
||||||
|
// BaseCommit is the git SHA all workers branch from.
|
||||||
|
BaseCommit string `json:"base_commit"`
|
||||||
|
|
||||||
|
// Integration is the integration branch name for merging work.
|
||||||
|
Integration string `json:"integration"`
|
||||||
|
|
||||||
|
// TargetBranch is the branch to merge into when complete (e.g., "main").
|
||||||
|
TargetBranch string `json:"target_branch"`
|
||||||
|
|
||||||
|
// State is the current lifecycle state.
|
||||||
|
State SwarmState `json:"state"`
|
||||||
|
|
||||||
|
// CreatedAt is when the swarm was created.
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// UpdatedAt is when the swarm was last updated.
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Workers is the list of polecat names assigned to this swarm.
|
||||||
|
Workers []string `json:"workers"`
|
||||||
|
|
||||||
|
// Tasks is the list of tasks in this swarm.
|
||||||
|
Tasks []SwarmTask `json:"tasks"`
|
||||||
|
|
||||||
|
// Error contains error details if State is SwarmFailed.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwarmTask represents a single task in the swarm.
|
||||||
|
// Each task maps to a beads issue and is assigned to a worker.
|
||||||
|
type SwarmTask struct {
|
||||||
|
// IssueID is the beads issue ID for this task.
|
||||||
|
IssueID string `json:"issue_id"`
|
||||||
|
|
||||||
|
// Title is the task title (copied from beads issue).
|
||||||
|
Title string `json:"title"`
|
||||||
|
|
||||||
|
// Assignee is the polecat name working on this task.
|
||||||
|
Assignee string `json:"assignee,omitempty"`
|
||||||
|
|
||||||
|
// Branch is the worker's branch name for this task.
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
|
||||||
|
// State mirrors the beads issue status.
|
||||||
|
State TaskState `json:"state"`
|
||||||
|
|
||||||
|
// MergedAt is when the task branch was merged (if merged).
|
||||||
|
MergedAt *time.Time `json:"merged_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskState represents the state of a swarm task.
|
||||||
|
type TaskState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TaskPending means the task is not yet started.
|
||||||
|
TaskPending TaskState = "pending"
|
||||||
|
|
||||||
|
// TaskAssigned means the task is assigned but not started.
|
||||||
|
TaskAssigned TaskState = "assigned"
|
||||||
|
|
||||||
|
// TaskInProgress means the task is actively being worked on.
|
||||||
|
TaskInProgress TaskState = "in_progress"
|
||||||
|
|
||||||
|
// TaskReview means the task is ready for review/merge.
|
||||||
|
TaskReview TaskState = "review"
|
||||||
|
|
||||||
|
// TaskMerged means the task has been merged.
|
||||||
|
TaskMerged TaskState = "merged"
|
||||||
|
|
||||||
|
// TaskFailed means the task failed.
|
||||||
|
TaskFailed TaskState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsComplete returns true if the task is in a terminal state.
|
||||||
|
func (s TaskState) IsComplete() bool {
|
||||||
|
return s == TaskMerged || s == TaskFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwarmSummary provides a high-level overview of swarm progress.
|
||||||
|
type SwarmSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State SwarmState `json:"state"`
|
||||||
|
TotalTasks int `json:"total_tasks"`
|
||||||
|
PendingTasks int `json:"pending_tasks"`
|
||||||
|
ActiveTasks int `json:"active_tasks"`
|
||||||
|
MergedTasks int `json:"merged_tasks"`
|
||||||
|
FailedTasks int `json:"failed_tasks"`
|
||||||
|
WorkerCount int `json:"worker_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns a SwarmSummary for this swarm.
|
||||||
|
func (s *Swarm) Summary() SwarmSummary {
|
||||||
|
summary := SwarmSummary{
|
||||||
|
ID: s.ID,
|
||||||
|
State: s.State,
|
||||||
|
TotalTasks: len(s.Tasks),
|
||||||
|
WorkerCount: len(s.Workers),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, task := range s.Tasks {
|
||||||
|
switch task.State {
|
||||||
|
case TaskPending, TaskAssigned:
|
||||||
|
summary.PendingTasks++
|
||||||
|
case TaskInProgress, TaskReview:
|
||||||
|
summary.ActiveTasks++
|
||||||
|
case TaskMerged:
|
||||||
|
summary.MergedTasks++
|
||||||
|
case TaskFailed:
|
||||||
|
summary.FailedTasks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress returns the completion percentage (0-100).
|
||||||
|
func (s *Swarm) Progress() int {
|
||||||
|
if len(s.Tasks) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := 0
|
||||||
|
for _, task := range s.Tasks {
|
||||||
|
if task.State == TaskMerged {
|
||||||
|
merged++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (merged * 100) / len(s.Tasks)
|
||||||
|
}
|
||||||
196
internal/swarm/types_test.go
Normal file
196
internal/swarm/types_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package swarm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSwarmStateIsTerminal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
state SwarmState
|
||||||
|
terminal bool
|
||||||
|
}{
|
||||||
|
{SwarmCreated, false},
|
||||||
|
{SwarmActive, false},
|
||||||
|
{SwarmMerging, false},
|
||||||
|
{SwarmLanded, true},
|
||||||
|
{SwarmFailed, true},
|
||||||
|
{SwarmCancelled, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.state.IsTerminal(); got != tt.terminal {
|
||||||
|
t.Errorf("%s.IsTerminal() = %v, want %v", tt.state, got, tt.terminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwarmStateIsActive(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
state SwarmState
|
||||||
|
active bool
|
||||||
|
}{
|
||||||
|
{SwarmCreated, true},
|
||||||
|
{SwarmActive, true},
|
||||||
|
{SwarmMerging, true},
|
||||||
|
{SwarmLanded, false},
|
||||||
|
{SwarmFailed, false},
|
||||||
|
{SwarmCancelled, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.state.IsActive(); got != tt.active {
|
||||||
|
t.Errorf("%s.IsActive() = %v, want %v", tt.state, got, tt.active)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStateIsComplete(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
state TaskState
|
||||||
|
complete bool
|
||||||
|
}{
|
||||||
|
{TaskPending, false},
|
||||||
|
{TaskAssigned, false},
|
||||||
|
{TaskInProgress, false},
|
||||||
|
{TaskReview, false},
|
||||||
|
{TaskMerged, true},
|
||||||
|
{TaskFailed, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.state.IsComplete(); got != tt.complete {
|
||||||
|
t.Errorf("%s.IsComplete() = %v, want %v", tt.state, got, tt.complete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwarmSummary(t *testing.T) {
|
||||||
|
swarm := &Swarm{
|
||||||
|
ID: "test-swarm",
|
||||||
|
State: SwarmActive,
|
||||||
|
Workers: []string{"worker1", "worker2"},
|
||||||
|
Tasks: []SwarmTask{
|
||||||
|
{IssueID: "1", State: TaskPending},
|
||||||
|
{IssueID: "2", State: TaskAssigned},
|
||||||
|
{IssueID: "3", State: TaskInProgress},
|
||||||
|
{IssueID: "4", State: TaskReview},
|
||||||
|
{IssueID: "5", State: TaskMerged},
|
||||||
|
{IssueID: "6", State: TaskFailed},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := swarm.Summary()
|
||||||
|
|
||||||
|
if summary.ID != "test-swarm" {
|
||||||
|
t.Errorf("ID = %q, want test-swarm", summary.ID)
|
||||||
|
}
|
||||||
|
if summary.TotalTasks != 6 {
|
||||||
|
t.Errorf("TotalTasks = %d, want 6", summary.TotalTasks)
|
||||||
|
}
|
||||||
|
if summary.PendingTasks != 2 {
|
||||||
|
t.Errorf("PendingTasks = %d, want 2", summary.PendingTasks)
|
||||||
|
}
|
||||||
|
if summary.ActiveTasks != 2 {
|
||||||
|
t.Errorf("ActiveTasks = %d, want 2", summary.ActiveTasks)
|
||||||
|
}
|
||||||
|
if summary.MergedTasks != 1 {
|
||||||
|
t.Errorf("MergedTasks = %d, want 1", summary.MergedTasks)
|
||||||
|
}
|
||||||
|
if summary.FailedTasks != 1 {
|
||||||
|
t.Errorf("FailedTasks = %d, want 1", summary.FailedTasks)
|
||||||
|
}
|
||||||
|
if summary.WorkerCount != 2 {
|
||||||
|
t.Errorf("WorkerCount = %d, want 2", summary.WorkerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwarmProgress(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tasks []SwarmTask
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
tasks: nil,
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "none merged",
|
||||||
|
tasks: []SwarmTask{
|
||||||
|
{State: TaskPending},
|
||||||
|
{State: TaskInProgress},
|
||||||
|
},
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "half merged",
|
||||||
|
tasks: []SwarmTask{
|
||||||
|
{State: TaskMerged},
|
||||||
|
{State: TaskInProgress},
|
||||||
|
},
|
||||||
|
expected: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all merged",
|
||||||
|
tasks: []SwarmTask{
|
||||||
|
{State: TaskMerged},
|
||||||
|
{State: TaskMerged},
|
||||||
|
{State: TaskMerged},
|
||||||
|
},
|
||||||
|
expected: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one of three",
|
||||||
|
tasks: []SwarmTask{
|
||||||
|
{State: TaskMerged},
|
||||||
|
{State: TaskPending},
|
||||||
|
{State: TaskPending},
|
||||||
|
},
|
||||||
|
expected: 33, // 1/3 = 33%
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
swarm := &Swarm{Tasks: tt.tasks}
|
||||||
|
if got := swarm.Progress(); got != tt.expected {
|
||||||
|
t.Errorf("Progress() = %d, want %d", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwarmJSON(t *testing.T) {
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
swarm := &Swarm{
|
||||||
|
ID: "swarm-123",
|
||||||
|
RigName: "gastown",
|
||||||
|
EpicID: "gt-abc",
|
||||||
|
BaseCommit: "abc123",
|
||||||
|
Integration: "swarm-123-integration",
|
||||||
|
TargetBranch: "main",
|
||||||
|
State: SwarmActive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Workers: []string{"Toast", "Cheedo"},
|
||||||
|
Tasks: []SwarmTask{
|
||||||
|
{
|
||||||
|
IssueID: "gt-def",
|
||||||
|
Title: "Test task",
|
||||||
|
Assignee: "Toast",
|
||||||
|
Branch: "swarm-123-Toast",
|
||||||
|
State: TaskInProgress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just verify it doesn't panic and has expected values
|
||||||
|
if swarm.ID != "swarm-123" {
|
||||||
|
t.Errorf("ID = %q, want swarm-123", swarm.ID)
|
||||||
|
}
|
||||||
|
if len(swarm.Workers) != 2 {
|
||||||
|
t.Errorf("Workers count = %d, want 2", len(swarm.Workers))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user