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

@@ -226,9 +226,12 @@ func getManager(role Role) string {
case RoleMayor, RoleWitness: case RoleMayor, RoleWitness:
return "daemon/" return "daemon/"
case RolePolecat, RoleRefinery: case RolePolecat, RoleRefinery:
// Would need rig context to determine witness address // Detect rig from environment or working directory
// For now, use a placeholder pattern rigName := detectRigName()
return "<rig>/witness" if rigName != "" {
return rigName + "/witness"
}
return "witness/" // fallback
case RoleCrew: case RoleCrew:
return "human" // Crew is human-managed return "human" // Crew is human-managed
default: default:
@@ -236,6 +239,41 @@ func getManager(role Role) string {
} }
} }
// detectRigName detects the rig name from environment or directory context.
func detectRigName() string {
// Check environment variable first
if rig := os.Getenv("GT_RIG"); rig != "" {
return rig
}
// Try to detect from tmux session name (format: gt-<rig>-<polecat>)
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
if err == nil {
sessionName := strings.TrimSpace(string(out))
if strings.HasPrefix(sessionName, "gt-") {
parts := strings.SplitN(sessionName, "-", 3)
if len(parts) >= 2 {
return parts[1]
}
}
}
// Try to detect from working directory
cwd, err := os.Getwd()
if err != nil {
return ""
}
// Look for "polecats" in path: .../rig/polecats/polecat/...
if idx := strings.Index(cwd, "/polecats/"); idx != -1 {
// Extract rig name from path before /polecats/
rigPath := cwd[:idx]
return filepath.Base(rigPath)
}
return ""
}
// sendHandoffMail sends a handoff message to ourselves for the successor to read. // sendHandoffMail sends a handoff message to ourselves for the successor to read.
func sendHandoffMail(role Role, townRoot string) error { func sendHandoffMail(role Role, townRoot string) error {
// Determine our address // Determine our address
@@ -286,19 +324,26 @@ func sendLifecycleRequest(manager string, role Role, action HandoffAction, townR
return nil return nil
} }
// Get polecat name for identification
polecatName := detectPolecatName()
rigName := detectRigName()
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action) subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action)
body := fmt.Sprintf(`Lifecycle request from %s. body := fmt.Sprintf(`Lifecycle request from %s.
Action: %s Action: %s
Rig: %s
Polecat: %s
Time: %s Time: %s
Please verify state and execute lifecycle action. Please verify state and execute lifecycle action.
`, role, action, time.Now().Format(time.RFC3339)) `, role, action, rigName, polecatName, time.Now().Format(time.RFC3339))
// Send via bd mail (syntax: bd mail send <recipient> -s <subject> -m <body>) // Send via bd mail (syntax: bd mail send <recipient> -s <subject> -m <body>)
cmd := exec.Command("bd", "mail", "send", manager, cmd := exec.Command("bd", "mail", "send", manager,
"-s", subject, "-s", subject,
"-m", body, "-m", body,
"--type", "task", // Mark as task requiring action
) )
cmd.Dir = townRoot cmd.Dir = townRoot
@@ -309,6 +354,45 @@ Please verify state and execute lifecycle action.
return nil return nil
} }
// detectPolecatName detects the polecat name from environment or directory context.
func detectPolecatName() string {
// Check environment variable first
if polecat := os.Getenv("GT_POLECAT"); polecat != "" {
return polecat
}
// Try to detect from tmux session name (format: gt-<rig>-<polecat>)
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
if err == nil {
sessionName := strings.TrimSpace(string(out))
if strings.HasPrefix(sessionName, "gt-") {
parts := strings.SplitN(sessionName, "-", 3)
if len(parts) >= 3 {
return parts[2]
}
}
}
// Try to detect from working directory
cwd, err := os.Getwd()
if err != nil {
return ""
}
// Look for "polecats" in path: .../rig/polecats/polecat/...
if idx := strings.Index(cwd, "/polecats/"); idx != -1 {
// Extract polecat name from path after /polecats/
remainder := cwd[idx+len("/polecats/"):]
// Take first component
if slashIdx := strings.Index(remainder, "/"); slashIdx != -1 {
return remainder[:slashIdx]
}
return remainder
}
return ""
}
// setRequestingState updates state.json to indicate we're requesting lifecycle action. // setRequestingState updates state.json to indicate we're requesting lifecycle action.
func setRequestingState(role Role, action HandoffAction, townRoot string) error { func setRequestingState(role Role, action HandoffAction, townRoot string) error {
// Determine state file location based on role // Determine state file location based on role

View File

@@ -40,11 +40,11 @@ var polecatListCmd = &cobra.Command{
Short: "List polecats in a rig", Short: "List polecats in a rig",
Long: `List polecats in a rig or all rigs. Long: `List polecats in a rig or all rigs.
Output: In the ephemeral model, polecats exist only while working. The list shows
- Name all currently active polecats with their states:
- State (idle/active/working/done/stuck) - working: Actively working on an issue
- Current issue (if any) - done: Completed work, waiting for cleanup
- Session status (running/stopped) - stuck: Needs assistance
Examples: Examples:
gt polecat list gastown gt polecat list gastown
@@ -85,10 +85,13 @@ Example:
var polecatWakeCmd = &cobra.Command{ var polecatWakeCmd = &cobra.Command{
Use: "wake <rig>/<polecat>", Use: "wake <rig>/<polecat>",
Short: "Mark polecat as active (ready for work)", Short: "(Deprecated) Resume a polecat to working state",
Long: `Mark polecat as active (ready for work). Long: `Resume a polecat to working state.
Transitions: idle → active DEPRECATED: In the ephemeral model, polecats are created fresh for each task
via 'gt spawn'. This command is kept for backward compatibility.
Transitions: done → working
Example: Example:
gt polecat wake gastown/Toast`, gt polecat wake gastown/Toast`,
@@ -98,11 +101,14 @@ Example:
var polecatSleepCmd = &cobra.Command{ var polecatSleepCmd = &cobra.Command{
Use: "sleep <rig>/<polecat>", Use: "sleep <rig>/<polecat>",
Short: "Mark polecat as idle (not available)", Short: "(Deprecated) Mark polecat as done",
Long: `Mark polecat as idle (not available). Long: `Mark polecat as done.
Transitions: active → idle DEPRECATED: In the ephemeral model, polecats use 'gt handoff' when complete,
Fails if session is running (stop first). which triggers automatic cleanup by the Witness. This command is kept for
backward compatibility.
Transitions: working → done
Example: Example:
gt polecat sleep gastown/Toast`, gt polecat sleep gastown/Toast`,
@@ -224,11 +230,11 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
} }
if len(allPolecats) == 0 { if len(allPolecats) == 0 {
fmt.Println("No polecats found.") fmt.Println("No active polecats found.")
return nil return nil
} }
fmt.Printf("%s\n\n", style.Bold.Render("Polecats")) fmt.Printf("%s\n\n", style.Bold.Render("Active Polecats"))
for _, p := range allPolecats { for _, p := range allPolecats {
// Session indicator // Session indicator
sessionStatus := style.Dim.Render("○") sessionStatus := style.Dim.Render("○")
@@ -236,9 +242,15 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
sessionStatus = style.Success.Render("●") sessionStatus = style.Success.Render("●")
} }
// Normalize state for display (legacy idle/active → working)
displayState := p.State
if p.State == polecat.StateIdle || p.State == polecat.StateActive {
displayState = polecat.StateWorking
}
// State color // State color
stateStr := string(p.State) stateStr := string(displayState)
switch p.State { switch displayState {
case polecat.StateWorking: case polecat.StateWorking:
stateStr = style.Info.Render(stateStr) stateStr = style.Info.Render(stateStr)
case polecat.StateStuck: case polecat.StateStuck:
@@ -316,6 +328,9 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
} }
func runPolecatWake(cmd *cobra.Command, args []string) error { func runPolecatWake(cmd *cobra.Command, args []string) error {
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt spawn' to create fresh polecats instead"))
fmt.Println()
rigName, polecatName, err := parseAddress(args[0]) rigName, polecatName, err := parseAddress(args[0])
if err != nil { if err != nil {
return err return err
@@ -330,11 +345,14 @@ func runPolecatWake(cmd *cobra.Command, args []string) error {
return fmt.Errorf("waking polecat: %w", err) return fmt.Errorf("waking polecat: %w", err)
} }
fmt.Printf("%s Polecat %s is now active.\n", style.SuccessPrefix, polecatName) fmt.Printf("%s Polecat %s is now working.\n", style.SuccessPrefix, polecatName)
return nil return nil
} }
func runPolecatSleep(cmd *cobra.Command, args []string) error { func runPolecatSleep(cmd *cobra.Command, args []string) error {
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt handoff' from within a polecat session instead"))
fmt.Println()
rigName, polecatName, err := parseAddress(args[0]) rigName, polecatName, err := parseAddress(args[0])
if err != nil { if err != nil {
return err return err
@@ -350,13 +368,13 @@ func runPolecatSleep(cmd *cobra.Command, args []string) error {
sessMgr := session.NewManager(t, r) sessMgr := session.NewManager(t, r)
running, _ := sessMgr.IsRunning(polecatName) running, _ := sessMgr.IsRunning(polecatName)
if running { if running {
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName) return fmt.Errorf("session is running. Use 'gt handoff' from the polecat session, or stop it with: gt session stop %s/%s", rigName, polecatName)
} }
if err := mgr.Sleep(polecatName); err != nil { if err := mgr.Sleep(polecatName); err != nil {
return fmt.Errorf("sleeping polecat: %w", err) return fmt.Errorf("marking polecat as done: %w", err)
} }
fmt.Printf("%s Polecat %s is now idle.\n", style.SuccessPrefix, polecatName) fmt.Printf("%s Polecat %s is now done.\n", style.SuccessPrefix, polecatName)
return nil return nil
} }

View File

@@ -32,7 +32,6 @@ var polecatNames = []string{
var ( var (
spawnIssue string spawnIssue string
spawnMessage string spawnMessage string
spawnCreate bool
spawnNoStart bool spawnNoStart bool
) )
@@ -42,14 +41,17 @@ var spawnCmd = &cobra.Command{
Short: "Spawn a polecat with work assignment", Short: "Spawn a polecat with work assignment",
Long: `Spawn a polecat with a work assignment. Long: `Spawn a polecat with a work assignment.
Assigns an issue or task to a polecat and starts a session. If no polecat Creates a fresh polecat worktree, assigns an issue or task, and starts
is specified, auto-selects an idle polecat in the rig. a session. Polecats are ephemeral - they exist only while working.
If no polecat name is specified, generates a random name. If the specified
name already exists as a non-working polecat, it will be replaced with
a fresh worktree.
Examples: Examples:
gt spawn gastown/Toast --issue gt-abc gt spawn gastown --issue gt-abc # auto-generate polecat name
gt spawn gastown --issue gt-def # auto-select polecat gt spawn gastown/Toast --issue gt-def # use specific name
gt spawn gastown/Nux -m "Fix the tests" # free-form task gt spawn gastown/Nux -m "Fix the tests" # free-form task`,
gt spawn gastown/Capable --issue gt-xyz --create # create if missing`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runSpawn, RunE: runSpawn,
} }
@@ -57,7 +59,6 @@ Examples:
func init() { func init() {
spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign") spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign")
spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description") spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description")
spawnCmd.Flags().BoolVar(&spawnCreate, "create", false, "Create polecat if it doesn't exist")
spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session") spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session")
rootCmd.AddCommand(spawnCmd) rootCmd.AddCommand(spawnCmd)
@@ -107,42 +108,41 @@ func runSpawn(cmd *cobra.Command, args []string) error {
polecatGit := git.NewGit(r.Path) polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit) polecatMgr := polecat.NewManager(r, polecatGit)
// Auto-select polecat if not specified // Ephemeral model: always create fresh polecat
// If no name specified, generate one
if polecatName == "" { if polecatName == "" {
polecatName, err = selectIdlePolecat(polecatMgr, r) polecatName = generatePolecatName(polecatMgr)
if err != nil { fmt.Printf("Generated polecat name: %s\n", polecatName)
// If --create is set, generate a new polecat name instead of failing
if spawnCreate {
polecatName = generatePolecatName(polecatMgr)
fmt.Printf("Generated polecat name: %s\n", polecatName)
} else {
return fmt.Errorf("auto-select polecat: %w", err)
}
} else {
fmt.Printf("Auto-selected polecat: %s\n", polecatName)
}
} }
// Check/create polecat // Check if polecat already exists
pc, err := polecatMgr.Get(polecatName) pc, err := polecatMgr.Get(polecatName)
if err != nil { if err == nil {
if err == polecat.ErrPolecatNotFound { // Polecat exists - check if working
if !spawnCreate { if pc.State == polecat.StateWorking {
return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue)
}
fmt.Printf("Creating polecat %s...\n", polecatName)
pc, err = polecatMgr.Add(polecatName)
if err != nil {
return fmt.Errorf("creating polecat: %w", err)
}
} else {
return fmt.Errorf("getting polecat: %w", err)
} }
// Existing polecat not working - remove and recreate fresh
fmt.Printf("Removing stale polecat %s for fresh worktree...\n", polecatName)
if err := polecatMgr.Remove(polecatName, true); err != nil {
return fmt.Errorf("removing stale polecat: %w", err)
}
} else if err != polecat.ErrPolecatNotFound {
return fmt.Errorf("checking polecat: %w", err)
} }
// Check polecat state // Create fresh polecat with new worktree from main
if pc.State == polecat.StateWorking { fmt.Printf("Creating fresh polecat %s...\n", polecatName)
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue) pc, err = polecatMgr.Add(polecatName)
if err != nil {
return fmt.Errorf("creating polecat: %w", err)
}
// Initialize beads in the new worktree
fmt.Printf("Initializing beads in worktree...\n")
if err := initBeadsInWorktree(pc.ClonePath); err != nil {
// Non-fatal - beads might already be initialized
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("(beads init: %v)", err)))
} }
// Get issue details if specified // Get issue details if specified
@@ -251,42 +251,23 @@ func generatePolecatName(mgr *polecat.Manager) string {
} }
} }
// selectIdlePolecat finds an idle polecat in the rig. // initBeadsInWorktree initializes beads in a new polecat worktree.
func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) { func initBeadsInWorktree(worktreePath string) error {
polecats, err := mgr.List() cmd := exec.Command("bd", "init")
if err != nil { cmd.Dir = worktreePath
return "", err
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return fmt.Errorf("%s", errMsg)
}
return err
} }
// Prefer idle polecats return nil
for _, pc := range polecats {
if pc.State == polecat.StateIdle {
return pc.Name, nil
}
}
// Accept active polecats without current work
for _, pc := range polecats {
if pc.State == polecat.StateActive && pc.Issue == "" {
return pc.Name, nil
}
}
// Check rig's polecat list for any we haven't loaded yet
for _, name := range r.Polecats {
found := false
for _, pc := range polecats {
if pc.Name == name {
found = true
break
}
}
if !found {
return name, nil
}
}
return "", fmt.Errorf("no available polecats in rig '%s'", r.Name)
} }
// fetchBeadsIssue gets issue details from beads CLI. // fetchBeadsIssue gets issue details from beads CLI.

View File

@@ -80,12 +80,12 @@ func (m *Manager) Add(name string) (*Polecat, error) {
return nil, fmt.Errorf("creating worktree: %w", err) return nil, fmt.Errorf("creating worktree: %w", err)
} }
// Create polecat state // Create polecat state - ephemeral polecats start in working state
now := time.Now() now := time.Now()
polecat := &Polecat{ polecat := &Polecat{
Name: name, Name: name,
Rig: m.rig.Name, Rig: m.rig.Name,
State: StateIdle, State: StateWorking,
ClonePath: polecatPath, ClonePath: polecatPath,
Branch: branchName, Branch: branchName,
CreatedAt: now, CreatedAt: now,
@@ -204,6 +204,7 @@ func (m *Manager) AssignIssue(name, issue string) error {
} }
// ClearIssue removes the issue assignment from a polecat. // 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 { func (m *Manager) ClearIssue(name string) error {
polecat, err := m.Get(name) polecat, err := m.Get(name)
if err != nil { if err != nil {
@@ -211,38 +212,44 @@ func (m *Manager) ClearIssue(name string) error {
} }
polecat.Issue = "" polecat.Issue = ""
polecat.State = StateIdle polecat.State = StateDone
polecat.UpdatedAt = time.Now() polecat.UpdatedAt = time.Now()
return m.saveState(polecat) return m.saveState(polecat)
} }
// Wake transitions a polecat from idle to active. // 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 { func (m *Manager) Wake(name string) error {
polecat, err := m.Get(name) polecat, err := m.Get(name)
if err != nil { if err != nil {
return err 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 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. // 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 { func (m *Manager) Sleep(name string) error {
polecat, err := m.Get(name) polecat, err := m.Get(name)
if err != nil { if err != nil {
return err 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 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. // saveState persists polecat state to disk.
@@ -268,10 +275,11 @@ func (m *Manager) loadState(name string) (*Polecat, error) {
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
// Return minimal polecat if state file missing // Return minimal polecat if state file missing
// Use StateWorking since ephemeral polecats are always working
return &Polecat{ return &Polecat{
Name: name, Name: name,
Rig: m.rig.Name, Rig: m.rig.Name,
State: StateIdle, State: StateWorking,
ClonePath: m.polecatDir(name), ClonePath: m.polecatDir(name),
}, nil }, nil
} }

View File

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

View File

@@ -4,35 +4,39 @@ package polecat
import "time" import "time"
// State represents the current state of a polecat. // State represents the current state of a polecat.
// In the ephemeral model, polecats exist only while working.
type State string type State string
const ( 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 means the polecat is actively working on an issue.
// This is the initial and primary state for ephemeral polecats.
StateWorking State = "working" 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" StateDone State = "done"
// StateStuck means the polecat needs assistance. // StateStuck means the polecat needs assistance.
StateStuck State = "stuck" StateStuck State = "stuck"
)
// IsAvailable returns true if the polecat can be assigned new work. // Legacy states for backward compatibility during transition.
func (s State) IsAvailable() bool { // New code should not use these.
return s == StateIdle || s == StateActive StateIdle State = "idle" // Deprecated: use StateWorking
} StateActive State = "active" // Deprecated: use StateWorking
)
// IsWorking returns true if the polecat is currently working. // IsWorking returns true if the polecat is currently working.
func (s State) IsWorking() bool { func (s State) IsWorking() bool {
return s == StateWorking 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. // Polecat represents a worker agent in a rig.
type Polecat struct { type Polecat struct {
// Name is the polecat identifier. // Name is the polecat identifier.

View File

@@ -1,14 +1,22 @@
package witness package witness
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings"
"time" "time"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
) )
// Common errors // Common errors
@@ -157,20 +165,33 @@ func (m *Manager) run(w *Witness) error {
fmt.Println("Witness running...") fmt.Println("Witness running...")
fmt.Println("Press Ctrl+C to stop") fmt.Println("Press Ctrl+C to stop")
// Initial check immediately
m.checkAndProcess(w)
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
// Perform health check m.checkAndProcess(w)
if err := m.healthCheck(w); err != nil {
fmt.Printf("Health check error: %v\n", err)
}
} }
} }
} }
// checkAndProcess performs health check and processes shutdown requests.
func (m *Manager) checkAndProcess(w *Witness) {
// Perform health check
if err := m.healthCheck(w); err != nil {
fmt.Printf("Health check error: %v\n", err)
}
// Check for shutdown requests
if err := m.processShutdownRequests(w); err != nil {
fmt.Printf("Shutdown request error: %v\n", err)
}
}
// healthCheck performs a health check on all monitored polecats. // healthCheck performs a health check on all monitored polecats.
func (m *Manager) healthCheck(w *Witness) error { func (m *Manager) healthCheck(w *Witness) error {
now := time.Now() now := time.Now()
@@ -178,12 +199,155 @@ func (m *Manager) healthCheck(w *Witness) error {
w.Stats.TotalChecks++ w.Stats.TotalChecks++
w.Stats.TodayChecks++ w.Stats.TodayChecks++
// For MVP, just update state
// Future: check keepalive files, nudge idle polecats, escalate stuck ones
return m.saveState(w) return m.saveState(w)
} }
// processShutdownRequests checks mail for lifecycle requests and handles them.
func (m *Manager) processShutdownRequests(w *Witness) error {
// Get witness mailbox via bd mail inbox
messages, err := m.getWitnessMessages()
if err != nil {
return fmt.Errorf("getting messages: %w", err)
}
for _, msg := range messages {
// Look for LIFECYCLE requests
if strings.Contains(msg.Subject, "LIFECYCLE:") && strings.Contains(msg.Subject, "shutdown") {
fmt.Printf("Processing shutdown request: %s\n", msg.Subject)
// Extract polecat name from message body
polecatName := extractPolecatName(msg.Body)
if polecatName == "" {
fmt.Printf(" Warning: could not extract polecat name from message\n")
m.ackMessage(msg.ID)
continue
}
fmt.Printf(" Polecat: %s\n", polecatName)
// Perform cleanup
if err := m.cleanupPolecat(polecatName); err != nil {
fmt.Printf(" Cleanup error: %v\n", err)
// Don't ack message on error - will retry
continue
}
fmt.Printf(" Cleanup complete\n")
// Acknowledge the message
m.ackMessage(msg.ID)
}
}
return nil
}
// WitnessMessage represents a mail message for the witness.
type WitnessMessage struct {
ID string `json:"id"`
Subject string `json:"subject"`
Body string `json:"body"`
From string `json:"from"`
}
// getWitnessMessages retrieves unread messages for the witness.
func (m *Manager) getWitnessMessages() ([]WitnessMessage, error) {
// Use bd mail inbox --json
cmd := exec.Command("bd", "mail", "inbox", "--json")
cmd.Dir = m.workDir
cmd.Env = append(os.Environ(), "BEADS_AGENT_NAME="+m.rig.Name+"-witness")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// No messages is not an error
if strings.Contains(stderr.String(), "no messages") {
return nil, nil
}
return nil, fmt.Errorf("%s", stderr.String())
}
if stdout.Len() == 0 {
return nil, nil
}
var messages []WitnessMessage
if err := json.Unmarshal(stdout.Bytes(), &messages); err != nil {
// Try parsing as empty array
if strings.TrimSpace(stdout.String()) == "[]" {
return nil, nil
}
return nil, fmt.Errorf("parsing messages: %w", err)
}
return messages, nil
}
// ackMessage acknowledges a message (marks it as read/handled).
func (m *Manager) ackMessage(id string) {
cmd := exec.Command("bd", "mail", "ack", id)
cmd.Dir = m.workDir
cmd.Run() // Ignore errors
}
// extractPolecatName extracts the polecat name from a lifecycle request body.
func extractPolecatName(body string) string {
// Look for "Polecat: <name>" pattern
re := regexp.MustCompile(`Polecat:\s*(\S+)`)
matches := re.FindStringSubmatch(body)
if len(matches) >= 2 {
return matches[1]
}
return ""
}
// cleanupPolecat performs the full cleanup sequence for an ephemeral polecat.
// 1. Kill session
// 2. Remove worktree
// 3. Delete branch
func (m *Manager) cleanupPolecat(polecatName string) error {
fmt.Printf(" Cleaning up polecat %s...\n", polecatName)
// Get managers
t := tmux.NewTmux()
sessMgr := session.NewManager(t, m.rig)
polecatGit := git.NewGit(m.rig.Path)
polecatMgr := polecat.NewManager(m.rig, polecatGit)
// 1. Kill session
running, err := sessMgr.IsRunning(polecatName)
if err == nil && running {
fmt.Printf(" Killing session...\n")
if err := sessMgr.Stop(polecatName, true); err != nil {
fmt.Printf(" Warning: failed to stop session: %v\n", err)
}
}
// 2. Remove worktree (this also removes the directory)
fmt.Printf(" Removing worktree...\n")
if err := polecatMgr.Remove(polecatName, true); err != nil {
// Only error if polecat actually exists
if !errors.Is(err, polecat.ErrPolecatNotFound) {
return fmt.Errorf("removing worktree: %w", err)
}
}
// 3. Delete branch from mayor's clone
branchName := fmt.Sprintf("polecat/%s", polecatName)
mayorPath := filepath.Join(m.rig.Path, "mayor", "rig")
mayorGit := git.NewGit(mayorPath)
fmt.Printf(" Deleting branch %s...\n", branchName)
if err := mayorGit.DeleteBranch(branchName, true); err != nil {
// Branch might already be deleted or merged, not a critical error
fmt.Printf(" Warning: failed to delete branch: %v\n", err)
}
return nil
}
// processExists checks if a process with the given PID exists. // processExists checks if a process with the given PID exists.
func processExists(pid int) bool { func processExists(pid int) bool {
proc, err := os.FindProcess(pid) proc, err := os.FindProcess(pid)