feat: add polecat lifecycle management
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/<name>/state.json Closes gt-u1j.8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
272
internal/polecat/manager.go
Normal file
272
internal/polecat/manager.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
313
internal/polecat/manager_test.go
Normal file
313
internal/polecat/manager_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/polecat/types.go
Normal file
77
internal/polecat/types.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user