refactor: unify agent startup with Manager pattern

- Create mayor.Manager for mayor lifecycle (Start/Stop/IsRunning/Status)
- Create deacon.Manager for deacon lifecycle with respawn loop
- Move session.Manager to polecat.SessionManager (clearer naming)
- Add zombie session detection for mayor/deacon (kills tmux if Claude dead)
- Remove duplicate session startup code from up.go, start.go, mayor.go
- Rename sessMgr -> polecatMgr for consistency
- Make witness/refinery SessionName() public for status display

All agent types now follow the same Manager pattern:
  mgr := agent.NewManager(...)
  mgr.Start(...)
  mgr.Stop()
  mgr.IsRunning()
  mgr.Status()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-06 22:32:35 -08:00
parent 432d14d9df
commit ea8bef2029
16 changed files with 609 additions and 584 deletions

View File

@@ -10,13 +10,14 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/polecat"
"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/witness"
@@ -67,7 +68,6 @@ func runUp(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
t := tmux.NewTmux()
allOK := true
// 1. Daemon (Go process)
@@ -81,36 +81,35 @@ func runUp(cmd *cobra.Command, args []string) error {
}
}
// Get session names
deaconSession := getDeaconSessionName()
mayorSession := getMayorSessionName()
// 2. Deacon (Claude agent) - runs from townRoot/deacon/
deaconDir := filepath.Join(townRoot, "deacon")
if err := ensureSession(t, deaconSession, deaconDir, "deacon"); err != nil {
printStatus("Deacon", false, err.Error())
allOK = false
// 2. Deacon (Claude agent)
deaconMgr := deacon.NewManager(townRoot)
if err := deaconMgr.Start(); err != nil {
if err == deacon.ErrAlreadyRunning {
printStatus("Deacon", true, deaconMgr.SessionName())
} else {
printStatus("Deacon", false, err.Error())
allOK = false
}
} else {
printStatus("Deacon", true, deaconSession)
printStatus("Deacon", true, deaconMgr.SessionName())
}
// 3. Mayor (Claude agent) - runs from townRoot/mayor/
// IMPORTANT: Both settings.json and CLAUDE.md must be in ~/gt/mayor/, NOT ~/gt/
// Files at town root would be inherited by ALL agents via directory traversal,
// causing crew/polecat/etc to receive Mayor-specific context.
mayorDir := filepath.Join(townRoot, "mayor")
if err := ensureSession(t, mayorSession, mayorDir, "mayor"); err != nil {
printStatus("Mayor", false, err.Error())
allOK = false
// 3. Mayor (Claude agent)
mayorMgr := mayor.NewManager(townRoot)
if err := mayorMgr.Start(""); err != nil {
if err == mayor.ErrAlreadyRunning {
printStatus("Mayor", true, mayorMgr.SessionName())
} else {
printStatus("Mayor", false, err.Error())
allOK = false
}
} else {
printStatus("Mayor", true, mayorSession)
printStatus("Mayor", true, mayorMgr.SessionName())
}
// 4. Witnesses (one per rig)
rigs := discoverRigs(townRoot)
for _, rigName := range rigs {
sessionName := fmt.Sprintf("gt-%s-witness", rigName)
_, r, err := getRig(rigName)
if err != nil {
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
@@ -121,13 +120,13 @@ func runUp(cmd *cobra.Command, args []string) error {
mgr := witness.NewManager(r)
if err := mgr.Start(false); err != nil {
if err == witness.ErrAlreadyRunning {
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName)
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName())
} else {
printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
allOK = false
}
} else {
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName)
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, mgr.SessionName())
}
}
@@ -143,22 +142,20 @@ func runUp(cmd *cobra.Command, args []string) error {
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)
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, mgr.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)
printStatus(fmt.Sprintf("Refinery (%s)", rigName), true, mgr.SessionName())
}
}
// 6. Crew (if --restore)
if upRestore {
for _, rigName := range rigs {
crewStarted, crewErrors := startCrewFromSettings(t, townRoot, rigName)
crewStarted, crewErrors := startCrewFromSettings(townRoot, rigName)
for _, name := range crewStarted {
printStatus(fmt.Sprintf("Crew (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-crew-%s", rigName, name))
}
@@ -170,7 +167,7 @@ func runUp(cmd *cobra.Command, args []string) error {
// 7. Polecats with pinned work (if --restore)
for _, rigName := range rigs {
polecatsStarted, polecatErrors := startPolecatsWithWork(t, townRoot, rigName)
polecatsStarted, polecatErrors := startPolecatsWithWork(townRoot, rigName)
for _, name := range polecatsStarted {
printStatus(fmt.Sprintf("Polecat (%s/%s)", rigName, name), true, fmt.Sprintf("gt-%s-polecat-%s", rigName, name))
}
@@ -252,79 +249,6 @@ func ensureDaemon(townRoot string) error {
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
}
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(workDir, role); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// 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
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionName)
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
}
// discoverRigs finds all rigs in the town.
func discoverRigs(townRoot string) []string {
var rigs []string
@@ -377,7 +301,7 @@ func discoverRigs(townRoot string) []string {
// 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) {
func startCrewFromSettings(townRoot, rigName string) ([]string, map[string]error) {
started := []string{}
errors := map[string]error{}
@@ -420,24 +344,14 @@ func startCrewFromSettings(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
// Parse startup preference and determine which crew to start
toStart := parseCrewStartupPreference(settings.Crew.Startup, crewNames)
// Start each crew member
// Start each crew member using Manager
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
if err := crewMgr.Start(crewName, crew.StartOptions{}); err != nil {
if err == crew.ErrSessionRunning {
started = append(started, crewName)
} else {
errors[crewName] = err
}
} else {
started = append(started, crewName)
}
@@ -509,56 +423,9 @@ func parseCrewStartupPreference(pref string, available []string) []string {
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
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionName)
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) {
func startPolecatsWithWork(townRoot, rigName string) ([]string, map[string]error) {
started := []string{}
errors := map[string]error{}
@@ -572,6 +439,14 @@ func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
return started, errors
}
// Get polecat session manager
_, r, err := getRig(rigName)
if err != nil {
return started, errors
}
t := tmux.NewTmux()
polecatMgr := polecat.NewSessionManager(t, r)
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -593,22 +468,13 @@ func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
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
// This polecat has work - start it using SessionManager
if err := polecatMgr.Start(polecatName, polecat.SessionStartOptions{}); err != nil {
if err == polecat.ErrSessionRunning {
started = append(started, polecatName)
} else {
errors[polecatName] = err
}
} else {
started = append(started, polecatName)
}
@@ -616,50 +482,3 @@ func startPolecatsWithWork(t *tmux.Tmux, townRoot, rigName string) ([]string, ma
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
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionName)
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
}