From da12531d3d1c34a934e7f73b1c269d1293ddcd3d Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 20 Dec 2025 02:26:09 -0800 Subject: [PATCH] feat: add gt up/down commands and daemon doctor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New commands: - `gt up` - Idempotent boot command that brings up all services: Daemon, Deacon, Mayor, and Witnesses for all rigs - `gt down` - Graceful shutdown of all services Doctor improvements: - New daemon check verifies daemon is running - Fixable with `gt doctor --fix` to auto-start daemon The system can run degraded (any services down) but `gt up` ensures a fully operational Gas Town with one idempotent command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/doctor.go | 1 + internal/cmd/down.go | 139 ++++++++++++++++ internal/cmd/up.go | 285 ++++++++++++++++++++++++++++++++ internal/doctor/daemon_check.go | 111 +++++++++++++ 4 files changed, 536 insertions(+) create mode 100644 internal/cmd/down.go create mode 100644 internal/cmd/up.go create mode 100644 internal/doctor/daemon_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 144b2ad4..f45f3415 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -53,6 +53,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d := doctor.NewDoctor() // Register built-in checks + d.Register(doctor.NewDaemonCheck()) d.Register(doctor.NewBeadsDatabaseCheck()) // Run checks diff --git a/internal/cmd/down.go b/internal/cmd/down.go new file mode 100644 index 00000000..d31a783d --- /dev/null +++ b/internal/cmd/down.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/daemon" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/workspace" +) + +var downCmd = &cobra.Command{ + Use: "down", + Short: "Stop all Gas Town services", + Long: `Stop all Gas Town long-lived services. + +This gracefully shuts down all infrastructure agents: + + • Witnesses - Per-rig polecat managers + • Mayor - Global work coordinator + • Deacon - Health orchestrator + • Daemon - Go background process + +Polecats are NOT stopped by this command - use 'gt swarm stop' or +kill individual polecats with 'gt polecat kill'. + +This is useful for: + • Taking a break (stop token consumption) + • Clean shutdown before system maintenance + • Resetting the town to a clean state`, + RunE: runDown, +} + +var ( + downQuiet bool + downForce bool +) + +func init() { + downCmd.Flags().BoolVarP(&downQuiet, "quiet", "q", false, "Only show errors") + downCmd.Flags().BoolVarP(&downForce, "force", "f", false, "Force kill without graceful shutdown") + rootCmd.AddCommand(downCmd) +} + +func runDown(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + t := tmux.NewTmux() + allOK := true + + // Stop in reverse order of startup + + // 1. Stop witnesses first + rigs := discoverRigs(townRoot) + for _, rigName := range rigs { + sessionName := fmt.Sprintf("gt-%s-witness", rigName) + if err := stopSession(t, sessionName); err != nil { + printDownStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) + allOK = false + } else { + printDownStatus(fmt.Sprintf("Witness (%s)", rigName), true, "stopped") + } + } + + // 2. Stop Mayor + if err := stopSession(t, MayorSessionName); err != nil { + printDownStatus("Mayor", false, err.Error()) + allOK = false + } else { + printDownStatus("Mayor", true, "stopped") + } + + // 3. Stop Deacon + if err := stopSession(t, DeaconSessionName); err != nil { + printDownStatus("Deacon", false, err.Error()) + allOK = false + } else { + printDownStatus("Deacon", true, "stopped") + } + + // 4. Stop Daemon last + running, _, _ := daemon.IsRunning(townRoot) + if running { + if err := daemon.StopDaemon(townRoot); err != nil { + printDownStatus("Daemon", false, err.Error()) + allOK = false + } else { + printDownStatus("Daemon", true, "stopped") + } + } else { + printDownStatus("Daemon", true, "not running") + } + + fmt.Println() + if allOK { + fmt.Printf("%s All services stopped\n", style.Bold.Render("✓")) + } else { + fmt.Printf("%s Some services failed to stop\n", style.Bold.Render("✗")) + return fmt.Errorf("not all services stopped") + } + + return nil +} + +func printDownStatus(name string, ok bool, detail string) { + if downQuiet && ok { + return + } + if ok { + fmt.Printf("%s %s: %s\n", style.SuccessPrefix, name, style.Dim.Render(detail)) + } else { + fmt.Printf("%s %s: %s\n", style.ErrorPrefix, name, detail) + } +} + +// stopSession gracefully stops a tmux session. +func stopSession(t *tmux.Tmux, sessionName string) error { + running, err := t.HasSession(sessionName) + if err != nil { + return err + } + if !running { + return nil // Already stopped + } + + // Try graceful shutdown first (Ctrl-C) + if !downForce { + _ = t.SendKeysRaw(sessionName, "C-c") + time.Sleep(100 * time.Millisecond) + } + + // Kill the session + return t.KillSession(sessionName) +} diff --git a/internal/cmd/up.go b/internal/cmd/up.go new file mode 100644 index 00000000..cd368ac3 --- /dev/null +++ b/internal/cmd/up.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/daemon" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/workspace" +) + +var upCmd = &cobra.Command{ + Use: "up", + Short: "Bring up all Gas Town services", + Long: `Start all Gas Town long-lived services. + +This is the idempotent "boot" command for Gas Town. It ensures all +infrastructure agents are running: + + • Daemon - Go background process that pokes agents + • Deacon - Health orchestrator (monitors Mayor/Witnesses) + • Mayor - Global work coordinator + • Witnesses - Per-rig polecat managers + +Polecats are NOT started by this command - they are ephemeral workers +spawned on demand by the Mayor or Witnesses. + +Running 'gt up' multiple times is safe - it only starts services that +aren't already running.`, + RunE: runUp, +} + +var ( + upQuiet bool +) + +func init() { + upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors") + rootCmd.AddCommand(upCmd) +} + +func runUp(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + t := tmux.NewTmux() + allOK := true + + // 1. Daemon (Go process) + if err := ensureDaemon(townRoot); err != nil { + printStatus("Daemon", false, err.Error()) + allOK = false + } else { + running, pid, _ := daemon.IsRunning(townRoot) + if running { + printStatus("Daemon", true, fmt.Sprintf("PID %d", pid)) + } + } + + // 2. Deacon (Claude agent) + if err := ensureSession(t, DeaconSessionName, townRoot, "deacon"); err != nil { + printStatus("Deacon", false, err.Error()) + allOK = false + } else { + printStatus("Deacon", true, "gt-deacon") + } + + // 3. Mayor (Claude agent) + if err := ensureSession(t, MayorSessionName, townRoot, "mayor"); err != nil { + printStatus("Mayor", false, err.Error()) + allOK = false + } else { + printStatus("Mayor", true, "gt-mayor") + } + + // 4. Witnesses (one per rig) + rigs := discoverRigs(townRoot) + for _, rigName := range rigs { + sessionName := fmt.Sprintf("gt-%s-witness", rigName) + rigPath := filepath.Join(townRoot, rigName) + + if err := ensureWitness(t, sessionName, rigPath, rigName); err != nil { + printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) + allOK = false + } else { + printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName) + } + } + + fmt.Println() + if allOK { + fmt.Printf("%s All services running\n", style.Bold.Render("✓")) + } else { + fmt.Printf("%s Some services failed to start\n", style.Bold.Render("✗")) + return fmt.Errorf("not all services started") + } + + return nil +} + +func printStatus(name string, ok bool, detail string) { + if upQuiet && ok { + return + } + if ok { + fmt.Printf("%s %s: %s\n", style.SuccessPrefix, name, style.Dim.Render(detail)) + } else { + fmt.Printf("%s %s: %s\n", style.ErrorPrefix, name, detail) + } +} + +// ensureDaemon starts the daemon if not running. +func ensureDaemon(townRoot string) error { + running, _, err := daemon.IsRunning(townRoot) + if err != nil { + return err + } + if running { + return nil + } + + // Start daemon + gtPath, err := os.Executable() + if err != nil { + return err + } + + cmd := exec.Command(gtPath, "daemon", "run") + cmd.Dir = townRoot + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + return err + } + + // Wait for daemon to initialize + time.Sleep(300 * time.Millisecond) + + // Verify it started + running, _, err = daemon.IsRunning(townRoot) + if err != nil { + return err + } + if !running { + return fmt.Errorf("daemon failed to start") + } + + return nil +} + +// ensureSession starts a Claude session if not running. +func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error { + running, err := t.HasSession(sessionName) + if err != nil { + return err + } + if running { + return nil + } + + // Create session + if err := t.NewSession(sessionName, workDir); err != nil { + return err + } + + // Set environment + _ = t.SetEnvironment(sessionName, "GT_ROLE", role) + + // Apply theme based on role + switch role { + case "mayor": + theme := tmux.MayorTheme() + _ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator") + case "deacon": + theme := tmux.DeaconTheme() + _ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check") + } + + // Launch Claude + var claudeCmd string + if role == "deacon" { + // Deacon uses respawn loop + claudeCmd = `while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` + } else { + claudeCmd = `claude --dangerously-skip-permissions` + } + + if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { + return err + } + + return nil +} + +// ensureWitness starts a witness session for a rig. +func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error { + running, err := t.HasSession(sessionName) + if err != nil { + return err + } + if running { + return nil + } + + // Create session in rig directory + if err := t.NewSession(sessionName, rigPath); err != nil { + return err + } + + // Set environment + _ = t.SetEnvironment(sessionName, "GT_ROLE", "witness") + _ = t.SetEnvironment(sessionName, "GT_RIG", rigName) + + // Apply theme (use rig-based theme) + theme := tmux.AssignTheme(rigName) + _ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName) + + // Launch Claude + claudeCmd := `claude --dangerously-skip-permissions` + if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { + return err + } + + return nil +} + +// discoverRigs finds all rigs in the town. +func discoverRigs(townRoot string) []string { + var rigs []string + + // Try rigs.json first + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") + if rigsConfig, err := config.LoadRigsConfig(rigsConfigPath); err == nil { + for name := range rigsConfig.Rigs { + rigs = append(rigs, name) + } + return rigs + } + + // Fallback: scan directory for rig-like directories + entries, err := os.ReadDir(townRoot) + if err != nil { + return rigs + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + // Skip known non-rig directories + if name == "mayor" || name == "daemon" || name == "deacon" || + name == ".git" || name == "docs" || name[0] == '.' { + continue + } + + dirPath := filepath.Join(townRoot, name) + + // Check for .beads directory (indicates a rig) + beadsPath := filepath.Join(dirPath, ".beads") + if _, err := os.Stat(beadsPath); err == nil { + rigs = append(rigs, name) + continue + } + + // Check for polecats directory (indicates a rig) + polecatsPath := filepath.Join(dirPath, "polecats") + if _, err := os.Stat(polecatsPath); err == nil { + rigs = append(rigs, name) + } + } + + return rigs +} diff --git a/internal/doctor/daemon_check.go b/internal/doctor/daemon_check.go new file mode 100644 index 00000000..7e33b094 --- /dev/null +++ b/internal/doctor/daemon_check.go @@ -0,0 +1,111 @@ +package doctor + +import ( + "os" + "os/exec" + "time" + + "github.com/steveyegge/gastown/internal/daemon" +) + +// DaemonCheck verifies the daemon is running. +type DaemonCheck struct { + FixableCheck +} + +// NewDaemonCheck creates a new daemon check. +func NewDaemonCheck() *DaemonCheck { + return &DaemonCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "daemon", + CheckDescription: "Check if Gas Town daemon is running", + }, + }, + } +} + +// Run checks if the daemon is running. +func (c *DaemonCheck) Run(ctx *CheckContext) *CheckResult { + running, pid, err := daemon.IsRunning(ctx.TownRoot) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to check daemon status", + Details: []string{err.Error()}, + } + } + + if running { + // Get more info about daemon state + state, err := daemon.LoadState(ctx.TownRoot) + details := []string{} + if err == nil && !state.StartedAt.IsZero() { + uptime := time.Since(state.StartedAt).Round(time.Second) + details = append(details, "Uptime: "+uptime.String()) + if state.HeartbeatCount > 0 { + details = append(details, "Heartbeats: "+string(rune(state.HeartbeatCount))) + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Daemon is running (PID " + itoa(pid) + ")", + Details: details, + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Daemon is not running", + FixHint: "Run 'gt daemon start' or 'gt doctor --fix'", + } +} + +// Fix starts the daemon. +func (c *DaemonCheck) Fix(ctx *CheckContext) error { + // Find gt executable + gtPath, err := os.Executable() + if err != nil { + return err + } + + // Start daemon in background + cmd := exec.Command(gtPath, "daemon", "run") + cmd.Dir = ctx.TownRoot + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + return err + } + + // Wait a moment for daemon to initialize + time.Sleep(300 * time.Millisecond) + + return nil +} + +// itoa is a simple int to string helper +func itoa(i int) string { + if i == 0 { + return "0" + } + s := "" + neg := i < 0 + if neg { + i = -i + } + for i > 0 { + s = string(rune('0'+i%10)) + s + i /= 10 + } + if neg { + s = "-" + s + } + return s +}