feat: implement ephemeral polecat model

This implements the ephemeral polecat model where polecats are spawned
fresh for each task and deleted upon completion.

Key changes:

**Spawn (internal/cmd/spawn.go):**
- Always create fresh worktree from main branch
- Run bd init in new worktree to initialize beads
- Remove --create flag (now implicit)
- Replace stale polecats with fresh worktrees

**Handoff (internal/cmd/handoff.go):**
- Add rig/polecat detection from environment and tmux session
- Send shutdown requests to correct witness (rig/witness)
- Include polecat name in lifecycle request body

**Witness (internal/witness/manager.go):**
- Add mail checking in monitoring loop
- Process LIFECYCLE shutdown requests
- Implement full cleanup sequence:
  - Kill tmux session
  - Remove git worktree
  - Delete polecat branch

**Polecat state machine (internal/polecat/types.go):**
- Primary states: working, done, stuck
- Deprecate idle/active (kept for backward compatibility)
- New polecats start in working state
- ClearIssue transitions to done (not idle)

**Polecat commands (internal/cmd/polecat.go):**
- Update list to show "Active Polecats"
- Normalize legacy states for display
- Add deprecation warnings to wake/sleep commands

Closes gt-7ik

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 01:48:59 -08:00
parent 717bc89132
commit 231d6e92e0
7 changed files with 393 additions and 133 deletions

View File

@@ -80,12 +80,12 @@ func (m *Manager) Add(name string) (*Polecat, error) {
return nil, fmt.Errorf("creating worktree: %w", err)
}
// Create polecat state
// Create polecat state - ephemeral polecats start in working state
now := time.Now()
polecat := &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateIdle,
State: StateWorking,
ClonePath: polecatPath,
Branch: branchName,
CreatedAt: now,
@@ -204,6 +204,7 @@ func (m *Manager) AssignIssue(name, issue string) error {
}
// ClearIssue removes the issue assignment from a polecat.
// In the ephemeral model, this transitions to Done state for cleanup.
func (m *Manager) ClearIssue(name string) error {
polecat, err := m.Get(name)
if err != nil {
@@ -211,38 +212,44 @@ func (m *Manager) ClearIssue(name string) error {
}
polecat.Issue = ""
polecat.State = StateIdle
polecat.State = StateDone
polecat.UpdatedAt = time.Now()
return m.saveState(polecat)
}
// Wake transitions a polecat from idle to active.
// Deprecated: In the ephemeral model, polecats start in working state.
// This method is kept for backward compatibility with existing polecats.
func (m *Manager) Wake(name string) error {
polecat, err := m.Get(name)
if err != nil {
return err
}
if polecat.State != StateIdle {
// Accept both idle and done states for legacy compatibility
if polecat.State != StateIdle && polecat.State != StateDone {
return fmt.Errorf("polecat is not idle (state: %s)", polecat.State)
}
return m.SetState(name, StateActive)
return m.SetState(name, StateWorking)
}
// Sleep transitions a polecat from active to idle.
// Deprecated: In the ephemeral model, polecats are deleted when done.
// This method is kept for backward compatibility.
func (m *Manager) Sleep(name string) error {
polecat, err := m.Get(name)
if err != nil {
return err
}
if polecat.State != StateActive {
// Accept working state as well for legacy compatibility
if polecat.State != StateActive && polecat.State != StateWorking {
return fmt.Errorf("polecat is not active (state: %s)", polecat.State)
}
return m.SetState(name, StateIdle)
return m.SetState(name, StateDone)
}
// saveState persists polecat state to disk.
@@ -268,10 +275,11 @@ func (m *Manager) loadState(name string) (*Polecat, error) {
if err != nil {
if os.IsNotExist(err) {
// Return minimal polecat if state file missing
// Use StateWorking since ephemeral polecats are always working
return &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateIdle,
State: StateWorking,
ClonePath: m.polecatDir(name),
}, nil
}

View File

@@ -9,21 +9,22 @@ import (
"github.com/steveyegge/gastown/internal/rig"
)
func TestStateIsAvailable(t *testing.T) {
func TestStateIsActive(t *testing.T) {
tests := []struct {
state State
available bool
state State
active bool
}{
{StateIdle, true},
{StateActive, true},
{StateWorking, false},
{StateWorking, true},
{StateDone, false},
{StateStuck, false},
// Legacy states are treated as active
{StateIdle, true},
{StateActive, true},
}
for _, tt := range tests {
if got := tt.state.IsAvailable(); got != tt.available {
t.Errorf("%s.IsAvailable() = %v, want %v", tt.state, got, tt.available)
if got := tt.state.IsActive(); got != tt.active {
t.Errorf("%s.IsActive() = %v, want %v", tt.state, got, tt.active)
}
}
}
@@ -299,7 +300,7 @@ func TestClearIssue(t *testing.T) {
t.Fatalf("ClearIssue: %v", err)
}
// Verify
// Verify - in ephemeral model, ClearIssue transitions to Done
polecat, err := m.Get("Test")
if err != nil {
t.Fatalf("Get: %v", err)
@@ -307,7 +308,7 @@ func TestClearIssue(t *testing.T) {
if polecat.Issue != "" {
t.Errorf("Issue = %q, want empty", polecat.Issue)
}
if polecat.State != StateIdle {
t.Errorf("State = %v, want StateIdle", polecat.State)
if polecat.State != StateDone {
t.Errorf("State = %v, want StateDone", polecat.State)
}
}

View File

@@ -4,35 +4,39 @@ package polecat
import "time"
// State represents the current state of a polecat.
// In the ephemeral model, polecats exist only while working.
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.
// This is the initial and primary state for ephemeral polecats.
StateWorking State = "working"
// StateDone means the polecat has completed its assigned work.
// StateDone means the polecat has completed its assigned work
// and is ready for cleanup by the Witness.
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
}
// Legacy states for backward compatibility during transition.
// New code should not use these.
StateIdle State = "idle" // Deprecated: use StateWorking
StateActive State = "active" // Deprecated: use StateWorking
)
// IsWorking returns true if the polecat is currently working.
func (s State) IsWorking() bool {
return s == StateWorking
}
// IsActive returns true if the polecat session is actively working.
// For ephemeral polecats, this is true for working state and
// legacy idle/active states (treated as working).
func (s State) IsActive() bool {
return s == StateWorking || s == StateIdle || s == StateActive
}
// Polecat represents a worker agent in a rig.
type Polecat struct {
// Name is the polecat identifier.