fix(shutdown): fully cleanup polecats on shutdown
gt shutdown now performs full polecat cleanup after killing sessions: - Removes worktrees - Deletes polecat branches from mayor's clone - Protects polecats with uncommitted work (refuses to clean) Added --nuclear flag to force cleanup even with uncommitted work. Closes gt-u1k 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,21 +4,27 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
|
"github.com/steveyegge/gastown/internal/polecat"
|
||||||
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
shutdownGraceful bool
|
shutdownGraceful bool
|
||||||
shutdownWait int
|
shutdownWait int
|
||||||
shutdownAll bool
|
shutdownAll bool
|
||||||
shutdownYes bool
|
shutdownYes bool
|
||||||
shutdownPolecatsOnly bool
|
shutdownPolecatsOnly bool
|
||||||
|
shutdownNuclear bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var startCmd = &cobra.Command{
|
var startCmd = &cobra.Command{
|
||||||
@@ -38,18 +44,24 @@ To stop Gas Town, use 'gt shutdown'.`,
|
|||||||
var shutdownCmd = &cobra.Command{
|
var shutdownCmd = &cobra.Command{
|
||||||
Use: "shutdown",
|
Use: "shutdown",
|
||||||
Short: "Shutdown Gas Town",
|
Short: "Shutdown Gas Town",
|
||||||
Long: `Shutdown Gas Town by stopping agents.
|
Long: `Shutdown Gas Town by stopping agents and cleaning up polecats.
|
||||||
|
|
||||||
By default, preserves crew sessions (your persistent workspaces).
|
By default, preserves crew sessions (your persistent workspaces).
|
||||||
Prompts for confirmation before stopping.
|
Prompts for confirmation before stopping.
|
||||||
|
|
||||||
|
After killing sessions, polecats are cleaned up:
|
||||||
|
- Worktrees are removed
|
||||||
|
- Polecat branches are deleted
|
||||||
|
- Polecats with uncommitted work are SKIPPED (protected)
|
||||||
|
|
||||||
Shutdown levels (progressively more aggressive):
|
Shutdown levels (progressively more aggressive):
|
||||||
(default) - Stop infrastructure (Mayor, Deacon, Witnesses, Refineries, Polecats)
|
(default) - Stop infrastructure (Mayor, Deacon, Witnesses, Refineries, Polecats)
|
||||||
--all - Also stop crew sessions
|
--all - Also stop crew sessions
|
||||||
--polecats-only - Only stop polecats (leaves everything else running)
|
--polecats-only - Only stop polecats (leaves everything else running)
|
||||||
|
|
||||||
Use --graceful to allow agents time to save state before killing.
|
Use --graceful to allow agents time to save state before killing.
|
||||||
Use --yes to skip confirmation prompt.`,
|
Use --yes to skip confirmation prompt.
|
||||||
|
Use --nuclear to force cleanup even if polecats have uncommitted work (DANGER).`,
|
||||||
RunE: runShutdown,
|
RunE: runShutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +76,8 @@ func init() {
|
|||||||
"Skip confirmation prompt")
|
"Skip confirmation prompt")
|
||||||
shutdownCmd.Flags().BoolVar(&shutdownPolecatsOnly, "polecats-only", false,
|
shutdownCmd.Flags().BoolVar(&shutdownPolecatsOnly, "polecats-only", false,
|
||||||
"Only stop polecats (minimal shutdown)")
|
"Only stop polecats (minimal shutdown)")
|
||||||
|
shutdownCmd.Flags().BoolVar(&shutdownNuclear, "nuclear", false,
|
||||||
|
"Force cleanup even if polecats have uncommitted work (DANGER: may lose work)")
|
||||||
|
|
||||||
rootCmd.AddCommand(startCmd)
|
rootCmd.AddCommand(startCmd)
|
||||||
rootCmd.AddCommand(shutdownCmd)
|
rootCmd.AddCommand(shutdownCmd)
|
||||||
@@ -117,6 +131,9 @@ func runStart(cmd *cobra.Command, args []string) error {
|
|||||||
func runShutdown(cmd *cobra.Command, args []string) error {
|
func runShutdown(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
// Find workspace root for polecat cleanup
|
||||||
|
townRoot, _ := workspace.FindFromCwd()
|
||||||
|
|
||||||
// Collect sessions to show what will be stopped
|
// Collect sessions to show what will be stopped
|
||||||
sessions, err := t.ListSessions()
|
sessions, err := t.ListSessions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,9 +174,9 @@ func runShutdown(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if shutdownGraceful {
|
if shutdownGraceful {
|
||||||
return runGracefulShutdown(t, toStop)
|
return runGracefulShutdown(t, toStop, townRoot)
|
||||||
}
|
}
|
||||||
return runImmediateShutdown(t, toStop)
|
return runImmediateShutdown(t, toStop, townRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// categorizeSessions splits sessions into those to stop and those to preserve.
|
// categorizeSessions splits sessions into those to stop and those to preserve.
|
||||||
@@ -207,7 +224,7 @@ func categorizeSessions(sessions []string) (toStop, preserved []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func runGracefulShutdown(t *tmux.Tmux, gtSessions []string) error {
|
func runGracefulShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) error {
|
||||||
fmt.Printf("Graceful shutdown of Gas Town (waiting up to %ds)...\n\n", shutdownWait)
|
fmt.Printf("Graceful shutdown of Gas Town (waiting up to %ds)...\n\n", shutdownWait)
|
||||||
|
|
||||||
// Phase 1: Send ESC to all agents to interrupt them
|
// Phase 1: Send ESC to all agents to interrupt them
|
||||||
@@ -246,16 +263,29 @@ func runGracefulShutdown(t *tmux.Tmux, gtSessions []string) error {
|
|||||||
fmt.Printf("\nPhase 4: Terminating sessions...\n")
|
fmt.Printf("\nPhase 4: Terminating sessions...\n")
|
||||||
stopped := killSessionsInOrder(t, gtSessions)
|
stopped := killSessionsInOrder(t, gtSessions)
|
||||||
|
|
||||||
|
// Phase 5: Cleanup polecat worktrees and branches
|
||||||
|
fmt.Printf("\nPhase 5: Cleaning up polecats...\n")
|
||||||
|
if townRoot != "" {
|
||||||
|
cleanupPolecats(townRoot)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s Graceful shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
|
fmt.Printf("%s Graceful shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runImmediateShutdown(t *tmux.Tmux, gtSessions []string) error {
|
func runImmediateShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) error {
|
||||||
fmt.Println("Shutting down Gas Town...")
|
fmt.Println("Shutting down Gas Town...")
|
||||||
|
|
||||||
stopped := killSessionsInOrder(t, gtSessions)
|
stopped := killSessionsInOrder(t, gtSessions)
|
||||||
|
|
||||||
|
// Cleanup polecat worktrees and branches
|
||||||
|
if townRoot != "" {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Cleaning up polecats...")
|
||||||
|
cleanupPolecats(townRoot)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s Gas Town shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
|
fmt.Printf("%s Gas Town shutdown complete (%d sessions stopped)\n", style.Bold.Render("✓"), stopped)
|
||||||
|
|
||||||
@@ -308,3 +338,98 @@ func killSessionsInOrder(t *tmux.Tmux, sessions []string) int {
|
|||||||
|
|
||||||
return stopped
|
return stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupPolecats removes polecat worktrees and branches for all rigs.
|
||||||
|
// It refuses to clean up polecats with uncommitted work unless --nuclear is set.
|
||||||
|
func cleanupPolecats(townRoot string) {
|
||||||
|
// Load rigs config
|
||||||
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
||||||
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s Could not load rigs config: %v\n", style.Dim.Render("○"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g := git.NewGit(townRoot)
|
||||||
|
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
|
||||||
|
|
||||||
|
// Discover all rigs
|
||||||
|
rigs, err := rigMgr.DiscoverRigs()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s Could not discover rigs: %v\n", style.Dim.Render("○"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCleaned := 0
|
||||||
|
totalSkipped := 0
|
||||||
|
var uncommittedPolecats []string
|
||||||
|
|
||||||
|
for _, r := range rigs {
|
||||||
|
polecatGit := git.NewGit(r.Path)
|
||||||
|
polecatMgr := polecat.NewManager(r, polecatGit)
|
||||||
|
|
||||||
|
polecats, err := polecatMgr.List()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range polecats {
|
||||||
|
// Check for uncommitted work
|
||||||
|
pGit := git.NewGit(p.ClonePath)
|
||||||
|
status, err := pGit.CheckUncommittedWork()
|
||||||
|
if err != nil {
|
||||||
|
// Can't check, be safe and skip unless nuclear
|
||||||
|
if !shutdownNuclear {
|
||||||
|
fmt.Printf(" %s %s/%s: could not check status, skipping\n",
|
||||||
|
style.Dim.Render("○"), r.Name, p.Name)
|
||||||
|
totalSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !status.Clean() {
|
||||||
|
// Has uncommitted work
|
||||||
|
if !shutdownNuclear {
|
||||||
|
uncommittedPolecats = append(uncommittedPolecats,
|
||||||
|
fmt.Sprintf("%s/%s (%s)", r.Name, p.Name, status.String()))
|
||||||
|
totalSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Nuclear mode: warn but proceed
|
||||||
|
fmt.Printf(" %s %s/%s: NUCLEAR - removing despite %s\n",
|
||||||
|
style.Bold.Render("⚠"), r.Name, p.Name, status.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean: remove worktree and branch
|
||||||
|
if err := polecatMgr.RemoveWithOptions(p.Name, true, shutdownNuclear); err != nil {
|
||||||
|
fmt.Printf(" %s %s/%s: cleanup failed: %v\n",
|
||||||
|
style.Dim.Render("○"), r.Name, p.Name, err)
|
||||||
|
totalSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the polecat branch from mayor's clone
|
||||||
|
branchName := fmt.Sprintf("polecat/%s", p.Name)
|
||||||
|
mayorPath := filepath.Join(r.Path, "mayor", "rig")
|
||||||
|
mayorGit := git.NewGit(mayorPath)
|
||||||
|
_ = mayorGit.DeleteBranch(branchName, true) // Ignore errors
|
||||||
|
|
||||||
|
fmt.Printf(" %s %s/%s: cleaned up\n", style.Bold.Render("✓"), r.Name, p.Name)
|
||||||
|
totalCleaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if len(uncommittedPolecats) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" %s Polecats with uncommitted work (use --nuclear to force):\n",
|
||||||
|
style.Bold.Render("⚠"))
|
||||||
|
for _, pc := range uncommittedPolecats {
|
||||||
|
fmt.Printf(" • %s\n", pc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalCleaned > 0 || totalSkipped > 0 {
|
||||||
|
fmt.Printf(" Cleaned: %d, Skipped: %d\n", totalCleaned, totalSkipped)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %s No polecats to clean up\n", style.Dim.Render("○"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user