From 452c649ce7684b2c9f733608bc4e027007459a7d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 13:37:34 -0800 Subject: [PATCH] feat: add polecat lifecycle management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types: - Polecat: worker agent with state, clone, branch, issue - State: idle/active/working/done/stuck - Summary: concise status view Manager operations: - Add: clone rig, create branch, init state - Remove: delete polecat (checks for uncommitted changes) - List: enumerate all polecats - Get: retrieve specific polecat State management: - SetState: update lifecycle state - AssignIssue: assign work (sets StateWorking) - ClearIssue: remove assignment (sets StateIdle) - Wake: idle → active - Sleep: active → idle State persisted to polecats//state.json Closes gt-u1j.8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/polecat/manager.go | 272 +++++++++++++++++++++++++++ internal/polecat/manager_test.go | 313 +++++++++++++++++++++++++++++++ internal/polecat/types.go | 77 ++++++++ 3 files changed, 662 insertions(+) create mode 100644 internal/polecat/manager.go create mode 100644 internal/polecat/manager_test.go create mode 100644 internal/polecat/types.go diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go new file mode 100644 index 00000000..63634400 --- /dev/null +++ b/internal/polecat/manager.go @@ -0,0 +1,272 @@ +package polecat + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" +) + +// Common errors +var ( + ErrPolecatExists = errors.New("polecat already exists") + ErrPolecatNotFound = errors.New("polecat not found") + ErrHasChanges = errors.New("polecat has uncommitted changes") +) + +// Manager handles polecat lifecycle. +type Manager struct { + rig *rig.Rig + git *git.Git +} + +// NewManager creates a new polecat manager. +func NewManager(r *rig.Rig, g *git.Git) *Manager { + return &Manager{ + rig: r, + git: g, + } +} + +// polecatDir returns the directory for a polecat. +func (m *Manager) polecatDir(name string) string { + return filepath.Join(m.rig.Path, "polecats", name) +} + +// stateFile returns the state file path for a polecat. +func (m *Manager) stateFile(name string) string { + return filepath.Join(m.polecatDir(name), "state.json") +} + +// exists checks if a polecat exists. +func (m *Manager) exists(name string) bool { + _, err := os.Stat(m.polecatDir(name)) + return err == nil +} + +// Add creates a new polecat with a clone of the rig. +func (m *Manager) Add(name string) (*Polecat, error) { + if m.exists(name) { + return nil, ErrPolecatExists + } + + polecatPath := m.polecatDir(name) + + // Create polecats directory if needed + polecatsDir := filepath.Join(m.rig.Path, "polecats") + if err := os.MkdirAll(polecatsDir, 0755); err != nil { + return nil, fmt.Errorf("creating polecats dir: %w", err) + } + + // Clone the rig repo + if err := m.git.Clone(m.rig.GitURL, polecatPath); err != nil { + return nil, fmt.Errorf("cloning rig: %w", err) + } + + // Create working branch + polecatGit := git.NewGit(polecatPath) + branchName := fmt.Sprintf("polecat/%s", name) + if err := polecatGit.CreateBranch(branchName); err != nil { + os.RemoveAll(polecatPath) + return nil, fmt.Errorf("creating branch: %w", err) + } + if err := polecatGit.Checkout(branchName); err != nil { + os.RemoveAll(polecatPath) + return nil, fmt.Errorf("checking out branch: %w", err) + } + + // Create polecat state + now := time.Now() + polecat := &Polecat{ + Name: name, + Rig: m.rig.Name, + State: StateIdle, + ClonePath: polecatPath, + Branch: branchName, + CreatedAt: now, + UpdatedAt: now, + } + + // Save state + if err := m.saveState(polecat); err != nil { + os.RemoveAll(polecatPath) + return nil, fmt.Errorf("saving state: %w", err) + } + + return polecat, nil +} + +// Remove deletes a polecat. +func (m *Manager) Remove(name string) error { + if !m.exists(name) { + return ErrPolecatNotFound + } + + polecatPath := m.polecatDir(name) + polecatGit := git.NewGit(polecatPath) + + // Check for uncommitted changes + hasChanges, err := polecatGit.HasUncommittedChanges() + if err == nil && hasChanges { + return ErrHasChanges + } + + // Remove directory + if err := os.RemoveAll(polecatPath); err != nil { + return fmt.Errorf("removing polecat dir: %w", err) + } + + return nil +} + +// List returns all polecats in the rig. +func (m *Manager) List() ([]*Polecat, error) { + polecatsDir := filepath.Join(m.rig.Path, "polecats") + + entries, err := os.ReadDir(polecatsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading polecats dir: %w", err) + } + + var polecats []*Polecat + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + polecat, err := m.Get(entry.Name()) + if err != nil { + continue // Skip invalid polecats + } + polecats = append(polecats, polecat) + } + + return polecats, nil +} + +// Get returns a specific polecat by name. +func (m *Manager) Get(name string) (*Polecat, error) { + if !m.exists(name) { + return nil, ErrPolecatNotFound + } + + return m.loadState(name) +} + +// SetState updates a polecat's state. +func (m *Manager) SetState(name string, state State) error { + polecat, err := m.Get(name) + if err != nil { + return err + } + + polecat.State = state + polecat.UpdatedAt = time.Now() + + return m.saveState(polecat) +} + +// AssignIssue assigns an issue to a polecat. +func (m *Manager) AssignIssue(name, issue string) error { + polecat, err := m.Get(name) + if err != nil { + return err + } + + polecat.Issue = issue + polecat.State = StateWorking + polecat.UpdatedAt = time.Now() + + return m.saveState(polecat) +} + +// ClearIssue removes the issue assignment from a polecat. +func (m *Manager) ClearIssue(name string) error { + polecat, err := m.Get(name) + if err != nil { + return err + } + + polecat.Issue = "" + polecat.State = StateIdle + polecat.UpdatedAt = time.Now() + + return m.saveState(polecat) +} + +// Wake transitions a polecat from idle to active. +func (m *Manager) Wake(name string) error { + polecat, err := m.Get(name) + if err != nil { + return err + } + + if polecat.State != StateIdle { + return fmt.Errorf("polecat is not idle (state: %s)", polecat.State) + } + + return m.SetState(name, StateActive) +} + +// Sleep transitions a polecat from active to idle. +func (m *Manager) Sleep(name string) error { + polecat, err := m.Get(name) + if err != nil { + return err + } + + if polecat.State != StateActive { + return fmt.Errorf("polecat is not active (state: %s)", polecat.State) + } + + return m.SetState(name, StateIdle) +} + +// saveState persists polecat state to disk. +func (m *Manager) saveState(polecat *Polecat) error { + data, err := json.MarshalIndent(polecat, "", " ") + if err != nil { + return fmt.Errorf("marshaling state: %w", err) + } + + stateFile := m.stateFile(polecat.Name) + if err := os.WriteFile(stateFile, data, 0644); err != nil { + return fmt.Errorf("writing state: %w", err) + } + + return nil +} + +// loadState reads polecat state from disk. +func (m *Manager) loadState(name string) (*Polecat, error) { + stateFile := m.stateFile(name) + + data, err := os.ReadFile(stateFile) + if err != nil { + if os.IsNotExist(err) { + // Return minimal polecat if state file missing + return &Polecat{ + Name: name, + Rig: m.rig.Name, + State: StateIdle, + ClonePath: m.polecatDir(name), + }, nil + } + return nil, fmt.Errorf("reading state: %w", err) + } + + var polecat Polecat + if err := json.Unmarshal(data, &polecat); err != nil { + return nil, fmt.Errorf("parsing state: %w", err) + } + + return &polecat, nil +} diff --git a/internal/polecat/manager_test.go b/internal/polecat/manager_test.go new file mode 100644 index 00000000..e8b97a3b --- /dev/null +++ b/internal/polecat/manager_test.go @@ -0,0 +1,313 @@ +package polecat + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" +) + +func TestStateIsAvailable(t *testing.T) { + tests := []struct { + state State + available bool + }{ + {StateIdle, true}, + {StateActive, true}, + {StateWorking, false}, + {StateDone, false}, + {StateStuck, false}, + } + + for _, tt := range tests { + if got := tt.state.IsAvailable(); got != tt.available { + t.Errorf("%s.IsAvailable() = %v, want %v", tt.state, got, tt.available) + } + } +} + +func TestStateIsWorking(t *testing.T) { + tests := []struct { + state State + working bool + }{ + {StateIdle, false}, + {StateActive, false}, + {StateWorking, true}, + {StateDone, false}, + {StateStuck, false}, + } + + for _, tt := range tests { + if got := tt.state.IsWorking(); got != tt.working { + t.Errorf("%s.IsWorking() = %v, want %v", tt.state, got, tt.working) + } + } +} + +func TestPolecatSummary(t *testing.T) { + p := &Polecat{ + Name: "Toast", + State: StateWorking, + Issue: "gt-abc", + } + + summary := p.Summary() + if summary.Name != "Toast" { + t.Errorf("Name = %q, want Toast", summary.Name) + } + if summary.State != StateWorking { + t.Errorf("State = %v, want StateWorking", summary.State) + } + if summary.Issue != "gt-abc" { + t.Errorf("Issue = %q, want gt-abc", summary.Issue) + } +} + +func TestListEmpty(t *testing.T) { + root := t.TempDir() + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + polecats, err := m.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(polecats) != 0 { + t.Errorf("polecats count = %d, want 0", len(polecats)) + } +} + +func TestGetNotFound(t *testing.T) { + root := t.TempDir() + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + _, err := m.Get("nonexistent") + if err != ErrPolecatNotFound { + t.Errorf("Get = %v, want ErrPolecatNotFound", err) + } +} + +func TestRemoveNotFound(t *testing.T) { + root := t.TempDir() + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + err := m.Remove("nonexistent") + if err != ErrPolecatNotFound { + t.Errorf("Remove = %v, want ErrPolecatNotFound", err) + } +} + +func TestPolecatDir(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/home/user/ai/test-rig", + } + m := NewManager(r, git.NewGit(r.Path)) + + dir := m.polecatDir("Toast") + expected := "/home/user/ai/test-rig/polecats/Toast" + if dir != expected { + t.Errorf("polecatDir = %q, want %q", dir, expected) + } +} + +func TestStateFile(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/home/user/ai/test-rig", + } + m := NewManager(r, git.NewGit(r.Path)) + + file := m.stateFile("Toast") + expected := "/home/user/ai/test-rig/polecats/Toast/state.json" + if file != expected { + t.Errorf("stateFile = %q, want %q", file, expected) + } +} + +func TestStatePersistence(t *testing.T) { + root := t.TempDir() + polecatDir := filepath.Join(root, "polecats", "Test") + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + // Save state + polecat := &Polecat{ + Name: "Test", + Rig: "test-rig", + State: StateWorking, + ClonePath: polecatDir, + Issue: "gt-xyz", + } + if err := m.saveState(polecat); err != nil { + t.Fatalf("saveState: %v", err) + } + + // Load state + loaded, err := m.loadState("Test") + if err != nil { + t.Fatalf("loadState: %v", err) + } + + if loaded.Name != "Test" { + t.Errorf("Name = %q, want Test", loaded.Name) + } + if loaded.State != StateWorking { + t.Errorf("State = %v, want StateWorking", loaded.State) + } + if loaded.Issue != "gt-xyz" { + t.Errorf("Issue = %q, want gt-xyz", loaded.Issue) + } +} + +func TestListWithPolecats(t *testing.T) { + root := t.TempDir() + + // Create some polecat directories with state files + for _, name := range []string{"Toast", "Cheedo"} { + polecatDir := filepath.Join(root, "polecats", name) + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + polecats, err := m.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(polecats) != 2 { + t.Errorf("polecats count = %d, want 2", len(polecats)) + } +} + +func TestSetState(t *testing.T) { + root := t.TempDir() + polecatDir := filepath.Join(root, "polecats", "Test") + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + // Initial state + if err := m.saveState(&Polecat{Name: "Test", State: StateIdle}); err != nil { + t.Fatalf("saveState: %v", err) + } + + // Update state + if err := m.SetState("Test", StateActive); err != nil { + t.Fatalf("SetState: %v", err) + } + + // Verify + polecat, err := m.Get("Test") + if err != nil { + t.Fatalf("Get: %v", err) + } + if polecat.State != StateActive { + t.Errorf("State = %v, want StateActive", polecat.State) + } +} + +func TestAssignIssue(t *testing.T) { + root := t.TempDir() + polecatDir := filepath.Join(root, "polecats", "Test") + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + // Initial state + if err := m.saveState(&Polecat{Name: "Test", State: StateIdle}); err != nil { + t.Fatalf("saveState: %v", err) + } + + // Assign issue + if err := m.AssignIssue("Test", "gt-abc"); err != nil { + t.Fatalf("AssignIssue: %v", err) + } + + // Verify + polecat, err := m.Get("Test") + if err != nil { + t.Fatalf("Get: %v", err) + } + if polecat.Issue != "gt-abc" { + t.Errorf("Issue = %q, want gt-abc", polecat.Issue) + } + if polecat.State != StateWorking { + t.Errorf("State = %v, want StateWorking", polecat.State) + } +} + +func TestClearIssue(t *testing.T) { + root := t.TempDir() + polecatDir := filepath.Join(root, "polecats", "Test") + if err := os.MkdirAll(polecatDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: root, + } + m := NewManager(r, git.NewGit(root)) + + // Initial state with issue + if err := m.saveState(&Polecat{Name: "Test", State: StateWorking, Issue: "gt-abc"}); err != nil { + t.Fatalf("saveState: %v", err) + } + + // Clear issue + if err := m.ClearIssue("Test"); err != nil { + t.Fatalf("ClearIssue: %v", err) + } + + // Verify + polecat, err := m.Get("Test") + if err != nil { + t.Fatalf("Get: %v", err) + } + if polecat.Issue != "" { + t.Errorf("Issue = %q, want empty", polecat.Issue) + } + if polecat.State != StateIdle { + t.Errorf("State = %v, want StateIdle", polecat.State) + } +} diff --git a/internal/polecat/types.go b/internal/polecat/types.go new file mode 100644 index 00000000..1d31a964 --- /dev/null +++ b/internal/polecat/types.go @@ -0,0 +1,77 @@ +// Package polecat provides polecat lifecycle management. +package polecat + +import "time" + +// State represents the current state of a polecat. +type State string + +const ( + // StateIdle means the polecat is not actively working. + StateIdle State = "idle" + + // StateActive means the polecat session is running but not assigned work. + StateActive State = "active" + + // StateWorking means the polecat is actively working on an issue. + StateWorking State = "working" + + // StateDone means the polecat has completed its assigned work. + StateDone State = "done" + + // StateStuck means the polecat needs assistance. + StateStuck State = "stuck" +) + +// IsAvailable returns true if the polecat can be assigned new work. +func (s State) IsAvailable() bool { + return s == StateIdle || s == StateActive +} + +// IsWorking returns true if the polecat is currently working. +func (s State) IsWorking() bool { + return s == StateWorking +} + +// Polecat represents a worker agent in a rig. +type Polecat struct { + // Name is the polecat identifier. + Name string `json:"name"` + + // Rig is the rig this polecat belongs to. + Rig string `json:"rig"` + + // State is the current lifecycle state. + State State `json:"state"` + + // ClonePath is the path to the polecat's clone of the rig. + ClonePath string `json:"clone_path"` + + // Branch is the current git branch. + Branch string `json:"branch"` + + // Issue is the currently assigned issue ID (if any). + Issue string `json:"issue,omitempty"` + + // CreatedAt is when the polecat was created. + CreatedAt time.Time `json:"created_at"` + + // UpdatedAt is when the polecat was last updated. + UpdatedAt time.Time `json:"updated_at"` +} + +// Summary provides a concise view of polecat status. +type Summary struct { + Name string `json:"name"` + State State `json:"state"` + Issue string `json:"issue,omitempty"` +} + +// Summary returns a Summary for this polecat. +func (p *Polecat) Summary() Summary { + return Summary{ + Name: p.Name, + State: p.State, + Issue: p.Issue, + } +}