diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index c103f2d1..0419763a 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -226,9 +226,12 @@ func getManager(role Role) string { case RoleMayor, RoleWitness: return "daemon/" case RolePolecat, RoleRefinery: - // Would need rig context to determine witness address - // For now, use a placeholder pattern - return "/witness" + // Detect rig from environment or working directory + rigName := detectRigName() + if rigName != "" { + return rigName + "/witness" + } + return "witness/" // fallback case RoleCrew: return "human" // Crew is human-managed 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--) + 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. func sendHandoffMail(role Role, townRoot string) error { // Determine our address @@ -286,19 +324,26 @@ func sendLifecycleRequest(manager string, role Role, action HandoffAction, townR return nil } + // Get polecat name for identification + polecatName := detectPolecatName() + rigName := detectRigName() + subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action) body := fmt.Sprintf(`Lifecycle request from %s. Action: %s +Rig: %s +Polecat: %s Time: %s 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 -s -m ) cmd := exec.Command("bd", "mail", "send", manager, "-s", subject, "-m", body, + "--type", "task", // Mark as task requiring action ) cmd.Dir = townRoot @@ -309,6 +354,45 @@ Please verify state and execute lifecycle action. 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--) + 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. func setRequestingState(role Role, action HandoffAction, townRoot string) error { // Determine state file location based on role diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index e73accc3..cb01f51a 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -40,11 +40,11 @@ var polecatListCmd = &cobra.Command{ Short: "List polecats in a rig", Long: `List polecats in a rig or all rigs. -Output: - - Name - - State (idle/active/working/done/stuck) - - Current issue (if any) - - Session status (running/stopped) +In the ephemeral model, polecats exist only while working. The list shows +all currently active polecats with their states: + - working: Actively working on an issue + - done: Completed work, waiting for cleanup + - stuck: Needs assistance Examples: gt polecat list gastown @@ -85,10 +85,13 @@ Example: var polecatWakeCmd = &cobra.Command{ Use: "wake /", - Short: "Mark polecat as active (ready for work)", - Long: `Mark polecat as active (ready for work). + Short: "(Deprecated) Resume a polecat to working state", + 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: gt polecat wake gastown/Toast`, @@ -98,11 +101,14 @@ Example: var polecatSleepCmd = &cobra.Command{ Use: "sleep /", - Short: "Mark polecat as idle (not available)", - Long: `Mark polecat as idle (not available). + Short: "(Deprecated) Mark polecat as done", + Long: `Mark polecat as done. -Transitions: active → idle -Fails if session is running (stop first). +DEPRECATED: In the ephemeral model, polecats use 'gt handoff' when complete, +which triggers automatic cleanup by the Witness. This command is kept for +backward compatibility. + +Transitions: working → done Example: gt polecat sleep gastown/Toast`, @@ -224,11 +230,11 @@ func runPolecatList(cmd *cobra.Command, args []string) error { } if len(allPolecats) == 0 { - fmt.Println("No polecats found.") + fmt.Println("No active polecats found.") 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 { // Session indicator sessionStatus := style.Dim.Render("○") @@ -236,9 +242,15 @@ func runPolecatList(cmd *cobra.Command, args []string) error { 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 - stateStr := string(p.State) - switch p.State { + stateStr := string(displayState) + switch displayState { case polecat.StateWorking: stateStr = style.Info.Render(stateStr) case polecat.StateStuck: @@ -316,6 +328,9 @@ func runPolecatRemove(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]) if err != nil { return err @@ -330,11 +345,14 @@ func runPolecatWake(cmd *cobra.Command, args []string) error { 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 } 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]) if err != nil { return err @@ -350,13 +368,13 @@ func runPolecatSleep(cmd *cobra.Command, args []string) error { sessMgr := session.NewManager(t, r) running, _ := sessMgr.IsRunning(polecatName) 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 { - 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 } diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index dced47d4..7559b8f0 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -32,7 +32,6 @@ var polecatNames = []string{ var ( spawnIssue string spawnMessage string - spawnCreate bool spawnNoStart bool ) @@ -42,14 +41,17 @@ var spawnCmd = &cobra.Command{ Short: "Spawn a polecat with 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 -is specified, auto-selects an idle polecat in the rig. +Creates a fresh polecat worktree, assigns an issue or task, and starts +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: - gt spawn gastown/Toast --issue gt-abc - gt spawn gastown --issue gt-def # auto-select polecat - gt spawn gastown/Nux -m "Fix the tests" # free-form task - gt spawn gastown/Capable --issue gt-xyz --create # create if missing`, + gt spawn gastown --issue gt-abc # auto-generate polecat name + gt spawn gastown/Toast --issue gt-def # use specific name + gt spawn gastown/Nux -m "Fix the tests" # free-form task`, Args: cobra.ExactArgs(1), RunE: runSpawn, } @@ -57,7 +59,6 @@ Examples: func init() { spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign") 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") rootCmd.AddCommand(spawnCmd) @@ -107,42 +108,41 @@ func runSpawn(cmd *cobra.Command, args []string) error { polecatGit := git.NewGit(r.Path) 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 == "" { - polecatName, err = selectIdlePolecat(polecatMgr, r) - if err != nil { - // 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) - } + polecatName = generatePolecatName(polecatMgr) + fmt.Printf("Generated polecat name: %s\n", polecatName) } - // Check/create polecat + // Check if polecat already exists pc, err := polecatMgr.Get(polecatName) - if err != nil { - if err == polecat.ErrPolecatNotFound { - if !spawnCreate { - return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) - } - 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) + if err == nil { + // Polecat exists - check if working + if pc.State == polecat.StateWorking { + return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue) } + // 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 - if pc.State == polecat.StateWorking { - return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue) + // Create fresh polecat with new worktree from main + fmt.Printf("Creating fresh polecat %s...\n", polecatName) + 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 @@ -251,42 +251,23 @@ func generatePolecatName(mgr *polecat.Manager) string { } } -// selectIdlePolecat finds an idle polecat in the rig. -func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) { - polecats, err := mgr.List() - if err != nil { - return "", err +// initBeadsInWorktree initializes beads in a new polecat worktree. +func initBeadsInWorktree(worktreePath string) error { + cmd := exec.Command("bd", "init") + cmd.Dir = worktreePath + + 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 - 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) + return nil } // fetchBeadsIssue gets issue details from beads CLI. diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index dfc41c47..7e0ee258 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -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 } diff --git a/internal/polecat/manager_test.go b/internal/polecat/manager_test.go index 59b979cb..73500628 100644 --- a/internal/polecat/manager_test.go +++ b/internal/polecat/manager_test.go @@ -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) } } diff --git a/internal/polecat/types.go b/internal/polecat/types.go index 1d31a964..03687e9a 100644 --- a/internal/polecat/types.go +++ b/internal/polecat/types.go @@ -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. diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 4f61a80d..c4c9bc61 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -1,14 +1,22 @@ package witness import ( + "bytes" "encoding/json" "errors" "fmt" "os" + "os/exec" "path/filepath" + "regexp" + "strings" "time" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" + "github.com/steveyegge/gastown/internal/tmux" ) // Common errors @@ -157,20 +165,33 @@ func (m *Manager) run(w *Witness) error { fmt.Println("Witness running...") fmt.Println("Press Ctrl+C to stop") + // Initial check immediately + m.checkAndProcess(w) + ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: - // Perform health check - if err := m.healthCheck(w); err != nil { - fmt.Printf("Health check error: %v\n", err) - } + m.checkAndProcess(w) } } } +// 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. func (m *Manager) healthCheck(w *Witness) error { now := time.Now() @@ -178,12 +199,155 @@ func (m *Manager) healthCheck(w *Witness) error { w.Stats.TotalChecks++ w.Stats.TodayChecks++ - // For MVP, just update state - // Future: check keepalive files, nudge idle polecats, escalate stuck ones - 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: " 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. func processExists(pid int) bool { proc, err := os.FindProcess(pid)