Files
gastown/internal/cmd/up.go
gastown/crew/gus b7c26d52e1 Migrate startup paths from SessionBeacon to StartupNudge (gt-7pp3l)
All 12 startup paths now use session.StartupNudge instead of the
older SessionBeacon pattern. The key difference is that StartupNudge
includes sender information, enabling better predecessor discovery
via gt seance.

Files updated:
- crew_lifecycle.go: 3 locations (refresh, restart)
- start.go: 2 locations (crew start)
- deacon.go: 1 location (deacon start)
- witness.go: 1 location (witness start)
- up.go: 4 locations (mayor, witness, crew, polecat)
- mayor.go: 1 location (mayor start)
- session/manager.go: 1 location (polecat spawn)

Format change:
Old: [GAS TOWN] address • molID • timestamp
New: [GAS TOWN] recipient <- sender • timestamp • topic[:mol-id]

This completes the Agent Startup Consolidation epic (gt-85whr).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:52:46 -08:00

676 lines
19 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
var upCmd = &cobra.Command{
Use: "up",
GroupID: GroupServices,
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
• Refineries - Per-rig merge queue processors
Polecats are NOT started by this command - they are transient workers
spawned on demand by the Mayor or Witnesses.
Use --restore to also start:
• Crew - Per rig settings (settings/config.json crew.startup)
• Polecats - Those with pinned beads (work attached)
Running 'gt up' multiple times is safe - it only starts services that
aren't already running.`,
RunE: runUp,
}
var (
upQuiet bool
upRestore bool
)
func init() {
upCmd.Flags().BoolVarP(&upQuiet, "quiet", "q", false, "Only show errors")
upCmd.Flags().BoolVar(&upRestore, "restore", false, "Also restore crew (from settings) and polecats (from hooks)")
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)
}
}
// 5. Refineries (one per rig)
for _, rigName := range rigs {
_, r, err := getRig(rigName)
if err != nil {
printStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
allOK = false
continue
}
mgr := refinery.NewManager(r)
if err := mgr.Start(false); err != nil {
if err == refinery.ErrAlreadyRunning {
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, sessionName)
} else {
printStatus(fmt.Sprintf("Refinery (%s)", rigName), false, err.Error())
allOK = false
}
} else {
sessionName := fmt.Sprintf("gt-%s-refinery", rigName)
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, sessionName)
}
}
// 6. Crew (if --restore)
if upRestore {
for _, rigName := range rigs {
crewStarted, crewErrors := startCrewFromSettings(t, townRoot, rigName)
for _, name := range crewStarted {
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name))
}
for name, err := range crewErrors {
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), false, err.Error())
allOK = false
}
}
// 7. Polecats with pinned work (if --restore)
for _, rigName := range rigs {
polecatsStarted, polecatErrors := startPolecatsWithWork(t, townRoot, rigName)
for _, name := range polecatsStarted {
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name))
}
for name, err := range polecatErrors {
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), false, err.Error())
allOK = false
}
}
}
fmt.Println()
if allOK {
fmt.Printf("%s All services running\n", style.Bold.Render("✓"))
// Log boot event with started services
startedServices := []string{"daemon", "deacon", "mayor"}
for _, rigName := range rigs {
startedServices = append(startedServices, fmt.Sprintf("%s/witness", rigName))
startedServices = append(startedServices, fmt.Sprintf("%s/refinery", rigName))
}
_ = events.LogFeed(events.TypeBoot, "gt", events.BootPayload("town", startedServices))
} 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
// Detach from parent I/O for background daemon (uses its own logging)
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 (non-fatal: session works without these)
_ = t.SetEnvironment(sessionName, "GT_ROLE", role)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", role)
// Apply theme based on role (non-fatal: theming failure doesn't affect operation)
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
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
var claudeCmd string
runtimeCmd := config.GetRuntimeCommand("")
if role == "deacon" {
// Deacon uses respawn loop
claudeCmd = `export GT_ROLE=deacon BD_ACTOR=deacon GIT_AUTHOR_NAME=deacon && while true; do echo "⛪ Starting Deacon session..."; ` + runtimeCmd + `; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
} else {
claudeCmd = config.BuildAgentStartupCommand(role, role, "", "")
}
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
// Note: Deacon respawn loop makes beacon tricky - Claude restarts multiple times
// For non-respawn (mayor), inject beacon
if role != "deacon" {
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: role,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
}
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 (non-fatal: session works without these)
bdActor := fmt.Sprintf("%s/witness", rigName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "witness")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply theme (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName)
// Launch Claude using runtime config
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
claudeCmd := config.BuildAgentStartupCommand("witness", bdActor, rigPath, "")
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/witness", rigName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "deacon",
Topic: "patrol",
}) // Non-fatal
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
}
// startCrewFromSettings starts crew members based on rig settings.
// Returns list of started crew names and map of errors.
func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) {
started := []string{}
errors := map[string]error{}
rigPath := filepath.Join(townRoot, rigName)
// Load rig settings
settingsPath := filepath.Join(rigPath, "settings", "config.json")
settings, err := config.LoadRigSettings(settingsPath)
if err != nil {
// No settings file or error - skip crew startup
return started, errors
}
if settings.Crew == nil || settings.Crew.Startup == "" {
// No crew startup preference
return started, errors
}
// Get available crew members using helper
crewMgr, _, err := getCrewManager(rigName)
if err != nil {
return started, errors
}
crewWorkers, err := crewMgr.List()
if err != nil {
return started, errors
}
if len(crewWorkers) == 0 {
return started, errors
}
// Extract crew names
crewNames := make([]string, len(crewWorkers))
for i, w := range crewWorkers {
crewNames[i] = w.Name
}
// Parse startup preference and determine which crew to start
toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames)
// Start each crew member
for _, crewName := range toStart {
sessionName := fmt.Sprintf("gt-%s-crew-%s", rigName, crewName)
running, err := t.HasSession(sessionName)
if err != nil {
errors[crewName] = err
continue
}
if running {
started = append(started, crewName)
continue
}
// Start the crew member
crewPath := filepath.Join(rigPath, "crew", crewName)
if err := ensureCrewSession(t, sessionName, crewPath, rigName, crewName); err != nil {
errors[crewName] = err
} else {
started = append(started, crewName)
}
}
return started, errors
}
// parseCrewStartupPreference parses the natural language crew startup preference.
// Examples: "max", "joe and max", "all", "none", "pick one"
func parseCrewStartupPreference(pref string, available []string) []string {
pref = strings.ToLower(strings.TrimSpace(pref))
// Special keywords
switch pref {
case "none", "":
return []string{}
case "all":
return available
case "pick one", "any", "any one":
if len(available) > 0 {
return []string{available[0]}
}
return []string{}
}
// Parse comma/and-separated list
// "joe and max" -> ["joe", "max"]
// "joe, max" -> ["joe", "max"]
// "max" -> ["max"]
pref = strings.ReplaceAll(pref, " and ", ",")
pref = strings.ReplaceAll(pref, ", but not ", ",-")
pref = strings.ReplaceAll(pref, " but not ", ",-")
parts := strings.Split(pref, ",")
include := []string{}
exclude := map[string]bool{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.HasPrefix(part, "-") {
// Exclusion
exclude[strings.TrimPrefix(part, "-")] = true
} else {
include = append(include, part)
}
}
// Filter to only available crew members
result := []string{}
for _, name := range include {
if exclude[name] {
continue
}
// Check if this crew exists
for _, avail := range available {
if avail == name {
result = append(result, name)
break
}
}
}
return result
}
// ensureCrewSession starts a crew session.
func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName string) error {
// Create session in crew directory
if err := t.NewSession(sessionName, crewPath); err != nil {
return err
}
// Set environment
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "crew")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "GT_CREW", crewName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply theme (use rig-based theme)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Crew", crewName)
// Launch Claude using runtime config
// crewPath is like ~/gt/gastown/crew/max, so rig path is two dirs up
rigPath := filepath.Dir(filepath.Dir(crewPath))
claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, rigPath, "")
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/crew/%s", rigName, crewName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
return nil
}
// startPolecatsWithWork starts polecats that have pinned beads (work attached).
// Returns list of started polecat names and map of errors.
func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, map[string]error) {
started := []string{}
errors := map[string]error{}
rigPath := filepath.Join(townRoot, rigName)
polecatsDir := filepath.Join(rigPath, "polecats")
// List polecat directories
entries, err := os.ReadDir(polecatsDir)
if err != nil {
// No polecats directory
return started, errors
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
polecatName := entry.Name()
polecatPath := filepath.Join(polecatsDir, polecatName)
// Check if this polecat has a pinned bead (work attached)
agentID := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
b := beads.New(polecatPath)
pinnedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusPinned,
Assignee: agentID,
Priority: -1,
})
if err != nil || len(pinnedBeads) == 0 {
// No pinned beads - skip
continue
}
// This polecat has work - start it
sessionName := fmt.Sprintf("gt-%s-polecat-%s", rigName, polecatName)
running, err := t.HasSession(sessionName)
if err != nil {
errors[polecatName] = err
continue
}
if running {
started = append(started, polecatName)
continue
}
// Start the polecat
if err := ensurePolecatSession(t, sessionName, polecatPath, rigName, polecatName); err != nil {
errors[polecatName] = err
} else {
started = append(started, polecatName)
}
}
return started, errors
}
// ensurePolecatSession starts a polecat session.
func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polecatName string) error {
// Create session in polecat directory
if err := t.NewSession(sessionName, polecatPath); err != nil {
return err
}
// Set environment
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "polecat")
_ = t.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = t.SetEnvironment(sessionName, "GT_POLECAT", polecatName)
_ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Apply theme (use rig-based theme)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Polecat", polecatName)
// Launch Claude using runtime config
// polecatPath is like ~/gt/gastown/polecats/toast, so rig path is two dirs up
rigPath := filepath.Dir(filepath.Dir(polecatPath))
claudeCmd := config.BuildPolecatStartupCommand(rigName, polecatName, rigPath, "")
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
return err
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal
}
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
address := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: address,
Sender: "witness",
Topic: "dispatch",
}) // Non-fatal
return nil
}