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

@@ -1,26 +1,14 @@
package cmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
// getMayorSessionName returns the Mayor session name.
func getMayorSessionName() string {
return session.MayorSessionName()
}
var mayorCmd = &cobra.Command{
Use: "mayor",
Aliases: []string{"may"},
@@ -95,21 +83,31 @@ func init() {
rootCmd.AddCommand(mayorCmd)
}
func runMayorStart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName := getMayorSessionName()
// Check if session already exists
running, err := t.HasSession(sessionName)
// getMayorManager returns a mayor manager for the current workspace.
func getMayorManager() (*mayor.Manager, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("checking session: %w", err)
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
if running {
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
return mayor.NewManager(townRoot), nil
}
// getMayorSessionName returns the Mayor session name.
func getMayorSessionName() string {
return mayor.SessionName()
}
func runMayorStart(cmd *cobra.Command, args []string) error {
mgr, err := getMayorManager()
if err != nil {
return err
}
if err := startMayorSession(t, sessionName, mayorAgentOverride); err != nil {
fmt.Println("Starting Mayor session...")
if err := mgr.Start(mayorAgentOverride); err != nil {
if err == mayor.ErrAlreadyRunning {
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
}
return err
}
@@ -120,93 +118,18 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
return nil
}
// startMayorSession creates and initializes the Mayor tmux session.
func startMayorSession(t *tmux.Tmux, sessionName, agentOverride string) error {
// Find workspace root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Mayor runs in mayor/ subdirectory to keep its files (CLAUDE.md, settings)
// separate from child agents that inherit the working directory
mayorDir := filepath.Join(townRoot, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("creating mayor directory: %w", err)
}
// Create session in mayor directory
fmt.Println("Starting Mayor session...")
if err := t.NewSession(sessionName, mayorDir); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "mayor")
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "mayor")
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
// Note: ConfigureGasTownSession includes cycle bindings
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
// Launch Claude - the startup hook handles 'gt prime' automatically
// Use SendKeysDelayed to allow shell initialization after NewSession
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
// Mayor uses default runtime config (empty rigPath) since it's not rig-specific
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", agentOverride)
if err != nil {
return fmt.Errorf("building startup command: %w", err)
}
if err := t.SendKeysDelayed(sessionName, startupCmd, 200); err != nil {
return fmt.Errorf("sending command: %w", 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
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
Recipient: "mayor",
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous coordination.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("mayor", mayorDir)) // Non-fatal
return nil
}
func runMayorStop(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName := getMayorSessionName()
// Check if session exists
running, err := t.HasSession(sessionName)
mgr, err := getMayorManager()
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return errors.New("Mayor session is not running")
return err
}
fmt.Println("Stopping Mayor session...")
// Try graceful shutdown first (best-effort interrupt)
_ = t.SendKeysRaw(sessionName, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
if err := mgr.Stop(); err != nil {
if err == mayor.ErrNotRunning {
return fmt.Errorf("Mayor session is not running")
}
return err
}
fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓"))
@@ -214,84 +137,68 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
}
func runMayorAttach(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
mgr, err := getMayorManager()
if err != nil {
return err
}
sessionName := getMayorSessionName()
// Check if session exists
running, err := t.HasSession(sessionName)
running, err := mgr.IsRunning()
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
// Auto-start if not running
fmt.Println("Mayor session not running, starting...")
if err := startMayorSession(t, sessionName, mayorAgentOverride); err != nil {
if err := mgr.Start(mayorAgentOverride); err != nil {
return err
}
}
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
return attachToTmuxSession(sessionName)
return attachToTmuxSession(mgr.SessionName())
}
func runMayorStatus(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName := getMayorSessionName()
running, err := t.HasSession(sessionName)
mgr, err := getMayorManager()
if err != nil {
return fmt.Errorf("checking session: %w", err)
return err
}
if running {
// Get session info for more details
info, err := t.GetSessionInfo(sessionName)
if err == nil {
status := "detached"
if info.Attached {
status = "attached"
}
info, err := mgr.Status()
if err != nil {
if err == mayor.ErrNotRunning {
fmt.Printf("%s Mayor session is %s\n",
style.Bold.Render(""),
style.Bold.Render("running"))
fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Created: %s\n", info.Created)
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach"))
} else {
fmt.Printf("%s Mayor session is %s\n",
style.Bold.Render("●"),
style.Bold.Render("running"))
style.Dim.Render(""),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
return nil
}
} else {
fmt.Printf("%s Mayor session is %s\n",
style.Dim.Render("○"),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt mayor start"))
return fmt.Errorf("checking status: %w", err)
}
status := "detached"
if info.Attached {
status = "attached"
}
fmt.Printf("%s Mayor session is %s\n",
style.Bold.Render("●"),
style.Bold.Render("running"))
fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Created: %s\n", info.Created)
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt mayor attach"))
return nil
}
func runMayorRestart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
sessionName := getMayorSessionName()
running, err := t.HasSession(sessionName)
mgr, err := getMayorManager()
if err != nil {
return fmt.Errorf("checking session: %w", err)
return err
}
if running {
// Stop the current session (best-effort interrupt before kill)
fmt.Println("Stopping Mayor session...")
_ = t.SendKeysRaw(sessionName, "C-c")
time.Sleep(100 * time.Millisecond)
if err := t.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
// Stop if running (ignore not-running error)
if err := mgr.Stop(); err != nil && err != mayor.ErrNotRunning {
return fmt.Errorf("stopping session: %w", err)
}
// Start fresh

View File

@@ -15,7 +15,6 @@ import (
"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/style"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -361,7 +360,7 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
for _, r := range rigs {
polecatGit := git.NewGit(r.Path)
mgr := polecat.NewManager(r, polecatGit)
sessMgr := session.NewManager(t, r)
polecatMgr := polecat.NewSessionManager(t, r)
polecats, err := mgr.List()
if err != nil {
@@ -370,7 +369,7 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
}
for _, p := range polecats {
running, _ := sessMgr.IsRunning(p.Name)
running, _ := polecatMgr.IsRunning(p.Name)
allPolecats = append(allPolecats, PolecatListItem{
Rig: r.Name,
Name: p.Name,
@@ -525,8 +524,8 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
for _, p := range toRemove {
// Check if session is running
if !polecatForce {
sessMgr := session.NewManager(t, p.r)
running, _ := sessMgr.IsRunning(p.polecatName)
polecatMgr := polecat.NewSessionManager(t, p.r)
running, _ := polecatMgr.IsRunning(p.polecatName)
if running {
removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName))
continue
@@ -682,11 +681,11 @@ func runPolecatStatus(cmd *cobra.Command, args []string) error {
// Get session info
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
sessInfo, err := sessMgr.Status(polecatName)
polecatMgr := polecat.NewSessionManager(t, r)
sessInfo, err := polecatMgr.Status(polecatName)
if err != nil {
// Non-fatal - continue without session info
sessInfo = &session.Info{
sessInfo = &polecat.SessionInfo{
Polecat: polecatName,
Running: false,
}
@@ -1415,10 +1414,10 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error {
}
// Step 1: Kill session (force mode - no graceful shutdown)
sessMgr := session.NewManager(t, p.r)
running, _ := sessMgr.IsRunning(p.polecatName)
polecatMgr := polecat.NewSessionManager(t, p.r)
running, _ := polecatMgr.IsRunning(p.polecatName)
if running {
if err := sessMgr.Stop(p.polecatName, true); err != nil {
if err := polecatMgr.Stop(p.polecatName, true); err != nil {
fmt.Printf(" %s session kill failed: %v\n", style.Warning.Render("⚠"), err)
// Continue anyway - worktree removal will still work
} else {

View File

@@ -12,7 +12,6 @@ import (
"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/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
@@ -152,13 +151,13 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
// Start session
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
polecatSessMgr := polecat.NewSessionManager(t, r)
// Check if already running
running, _ := sessMgr.IsRunning(polecatName)
running, _ := polecatSessMgr.IsRunning(polecatName)
if !running {
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
startOpts := session.StartOptions{
startOpts := polecat.SessionStartOptions{
ClaudeConfigDir: claudeConfigDir,
}
if opts.Agent != "" {
@@ -168,13 +167,13 @@ func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolec
}
startOpts.Command = cmd
}
if err := sessMgr.Start(polecatName, startOpts); err != nil {
if err := polecatSessMgr.Start(polecatName, startOpts); err != nil {
return nil, fmt.Errorf("starting session: %w", err)
}
}
// Get session name and pane
sessionName := sessMgr.SessionName(polecatName)
sessionName := polecatSessMgr.SessionName(polecatName)
pane, err := getSessionPane(sessionName)
if err != nil {
return nil, fmt.Errorf("getting pane for %s: %w", sessionName, err)

View File

@@ -17,7 +17,6 @@ import (
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/wisp"
@@ -958,11 +957,11 @@ func runRigShutdown(cmd *cobra.Command, args []string) error {
// 1. Stop all polecat sessions
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
infos, err := sessMgr.List()
polecatMgr := polecat.NewSessionManager(t, r)
infos, err := polecatMgr.List()
if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigShutdownForce); err != nil {
if err := polecatMgr.StopAll(rigShutdownForce); err != nil {
errors = append(errors, fmt.Sprintf("polecat sessions: %v", err))
}
}
@@ -1235,11 +1234,11 @@ func runRigStop(cmd *cobra.Command, args []string) error {
// 1. Stop all polecat sessions
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
infos, err := sessMgr.List()
polecatMgr := polecat.NewSessionManager(t, r)
infos, err := polecatMgr.List()
if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigStopForce); err != nil {
if err := polecatMgr.StopAll(rigStopForce); err != nil {
errors = append(errors, fmt.Sprintf("polecat sessions: %v", err))
}
}
@@ -1368,11 +1367,11 @@ func runRigRestart(cmd *cobra.Command, args []string) error {
fmt.Printf(" Stopping...\n")
// 1. Stop all polecat sessions
sessMgr := session.NewManager(t, r)
infos, err := sessMgr.List()
polecatMgr := polecat.NewSessionManager(t, r)
infos, err := polecatMgr.List()
if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigRestartForce); err != nil {
if err := polecatMgr.StopAll(rigRestartForce); err != nil {
stopErrors = append(stopErrors, fmt.Sprintf("polecat sessions: %v", err))
}
}

View File

@@ -12,8 +12,8 @@ import (
"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/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux"
@@ -224,16 +224,16 @@ func parseAddress(addr string) (rigName, polecatName string, err error) {
}
// getSessionManager creates a session manager for the given rig.
func getSessionManager(rigName string) (*session.Manager, *rig.Rig, error) {
func getSessionManager(rigName string) (*polecat.SessionManager, *rig.Rig, error) {
_, r, err := getRig(rigName)
if err != nil {
return nil, nil, err
}
t := tmux.NewTmux()
mgr := session.NewManager(t, r)
polecatMgr := polecat.NewSessionManager(t, r)
return mgr, r, nil
return polecatMgr, r, nil
}
func runSessionStart(cmd *cobra.Command, args []string) error {
@@ -242,7 +242,7 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
return err
}
mgr, r, err := getSessionManager(rigName)
polecatMgr, r, err := getSessionManager(rigName)
if err != nil {
return err
}
@@ -261,12 +261,12 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
return fmt.Errorf("%s", suggest.FormatSuggestion("Polecat", polecatName, suggestions, hint))
}
opts := session.StartOptions{
opts := polecat.SessionStartOptions{
Issue: sessionIssue,
}
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
if err := mgr.Start(polecatName, opts); err != nil {
if err := polecatMgr.Start(polecatName, opts); err != nil {
return fmt.Errorf("starting session: %w", err)
}
@@ -290,7 +290,7 @@ func runSessionStop(cmd *cobra.Command, args []string) error {
return err
}
mgr, _, err := getSessionManager(rigName)
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
@@ -300,7 +300,7 @@ func runSessionStop(cmd *cobra.Command, args []string) error {
} else {
fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName)
}
if err := mgr.Stop(polecatName, sessionForce); err != nil {
if err := polecatMgr.Stop(polecatName, sessionForce); err != nil {
return fmt.Errorf("stopping session: %w", err)
}
@@ -326,13 +326,13 @@ func runSessionAttach(cmd *cobra.Command, args []string) error {
return err
}
mgr, _, err := getSessionManager(rigName)
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Attach (this replaces the process)
return mgr.Attach(polecatName)
return polecatMgr.Attach(polecatName)
}
// SessionListItem represents a session in list output.
@@ -381,8 +381,8 @@ func runSessionList(cmd *cobra.Command, args []string) error {
var allSessions []SessionListItem
for _, r := range rigs {
mgr := session.NewManager(t, r)
infos, err := mgr.List()
polecatMgr := polecat.NewSessionManager(t, r)
infos, err := polecatMgr.List()
if err != nil {
continue
}
@@ -428,7 +428,7 @@ func runSessionCapture(cmd *cobra.Command, args []string) error {
return err
}
mgr, _, err := getSessionManager(rigName)
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
@@ -446,7 +446,7 @@ func runSessionCapture(cmd *cobra.Command, args []string) error {
lines = n
}
output, err := mgr.Capture(polecatName, lines)
output, err := polecatMgr.Capture(polecatName, lines)
if err != nil {
return fmt.Errorf("capturing output: %w", err)
}
@@ -475,12 +475,12 @@ func runSessionInject(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no message provided (use -m or -f)")
}
mgr, _, err := getSessionManager(rigName)
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
if err := mgr.Inject(polecatName, message); err != nil {
if err := polecatMgr.Inject(polecatName, message); err != nil {
return fmt.Errorf("injecting message: %w", err)
}
@@ -495,13 +495,13 @@ func runSessionRestart(cmd *cobra.Command, args []string) error {
return err
}
mgr, _, err := getSessionManager(rigName)
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Check if running
running, err := mgr.IsRunning(polecatName)
running, err := polecatMgr.IsRunning(polecatName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
@@ -513,15 +513,15 @@ func runSessionRestart(cmd *cobra.Command, args []string) error {
} else {
fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName)
}
if err := mgr.Stop(polecatName, sessionForce); err != nil {
if err := polecatMgr.Stop(polecatName, sessionForce); err != nil {
return fmt.Errorf("stopping session: %w", err)
}
}
// Start fresh session
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
opts := session.StartOptions{}
if err := mgr.Start(polecatName, opts); err != nil {
opts := polecat.SessionStartOptions{}
if err := polecatMgr.Start(polecatName, opts); err != nil {
return fmt.Errorf("starting session: %w", err)
}
@@ -537,13 +537,13 @@ func runSessionStatus(cmd *cobra.Command, args []string) error {
return err
}
mgr, _, err := getSessionManager(rigName)
polecatMgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Get session info
info, err := mgr.Status(polecatName)
info, err := polecatMgr.Status(polecatName)
if err != nil {
return fmt.Errorf("getting status: %w", err)
}

View File

@@ -14,7 +14,9 @@ import (
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mayor"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
@@ -160,7 +162,7 @@ func runStart(cmd *cobra.Command, args []string) error {
fmt.Printf("Starting Gas Town from %s\n\n", style.Dim.Render(townRoot))
// Start core agents (Mayor and Deacon)
if err := startCoreAgents(t, startAgentOverride); err != nil {
if err := startCoreAgents(townRoot, startAgentOverride); err != nil {
return err
}
@@ -186,33 +188,29 @@ func runStart(cmd *cobra.Command, args []string) error {
return nil
}
// startCoreAgents starts Mayor and Deacon sessions.
func startCoreAgents(t *tmux.Tmux, agentOverride string) error {
// Get session names
mayorSession := getMayorSessionName()
deaconSession := getDeaconSessionName()
// startCoreAgents starts Mayor and Deacon sessions using the Manager pattern.
func startCoreAgents(townRoot string, agentOverride string) error {
// Start Mayor first (so Deacon sees it as up)
mayorRunning, _ := t.HasSession(mayorSession)
if mayorRunning {
fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○"))
} else {
fmt.Printf(" %s Starting Mayor...\n", style.Bold.Render("→"))
if err := startMayorSession(t, mayorSession, agentOverride); err != nil {
mayorMgr := mayor.NewManager(townRoot)
if err := mayorMgr.Start(agentOverride); err != nil {
if err == mayor.ErrAlreadyRunning {
fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○"))
} else {
return fmt.Errorf("starting Mayor: %w", err)
}
} else {
fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓"))
}
// Start Deacon (health monitor)
deaconRunning, _ := t.HasSession(deaconSession)
if deaconRunning {
fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
} else {
fmt.Printf(" %s Starting Deacon...\n", style.Bold.Render("→"))
if err := startDeaconSession(t, deaconSession, agentOverride); err != nil {
deaconMgr := deacon.NewManager(townRoot)
if err := deaconMgr.Start(); err != nil {
if err == deacon.ErrAlreadyRunning {
fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
} else {
return fmt.Errorf("starting Deacon: %w", err)
}
} else {
fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓"))
}

View File

@@ -8,8 +8,8 @@ import (
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/events"
"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/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
@@ -111,8 +111,8 @@ func runStop(cmd *cobra.Command, args []string) error {
stopped := 0
for _, r := range rigs {
mgr := session.NewManager(t, r)
infos, err := mgr.List()
polecatMgr := polecat.NewSessionManager(t, r)
infos, err := polecatMgr.List()
if err != nil {
continue
}
@@ -125,10 +125,10 @@ func runStop(cmd *cobra.Command, args []string) error {
}
// Capture output before stopping (best effort)
output, _ := mgr.Capture(info.Polecat, 50)
output, _ := polecatMgr.Capture(info.Polecat, 50)
// Stop the session
err := mgr.Stop(info.Polecat, force)
err := polecatMgr.Stop(info.Polecat, force)
if err != nil {
result.Success = false
result.Error = err.Error()

View File

@@ -15,7 +15,6 @@ import (
"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/style"
"github.com/steveyegge/gastown/internal/swarm"
"github.com/steveyegge/gastown/internal/tmux"
@@ -528,7 +527,7 @@ func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, wor
Title string `json:"title"`
}) error { //nolint:unparam // error return kept for future use
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
polecatSessMgr := polecat.NewSessionManager(t, r)
polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit)
@@ -556,12 +555,12 @@ func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, wor
}
// Check if already running
running, _ := sessMgr.IsRunning(worker)
running, _ := polecatSessMgr.IsRunning(worker)
if running {
fmt.Printf(" %s already running, injecting task...\n", worker)
} else {
fmt.Printf(" Starting %s...\n", worker)
if err := sessMgr.Start(worker, session.StartOptions{}); err != nil {
if err := polecatSessMgr.Start(worker, polecat.SessionStartOptions{}); err != nil {
style.PrintWarning(" couldn't start %s: %v", worker, err)
continue
}
@@ -572,7 +571,7 @@ func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, wor
// Inject work assignment
context := fmt.Sprintf("[SWARM] You are part of swarm %s.\n\nAssigned task: %s\nTitle: %s\n\nWork on this task. When complete, commit and signal DONE.",
swarmID, task.ID, task.Title)
if err := sessMgr.Inject(worker, context); err != nil {
if err := polecatSessMgr.Inject(worker, context); err != nil {
style.PrintWarning(" couldn't inject to %s: %v", worker, err)
} else {
fmt.Printf(" %s → %s ✓\n", worker, task.ID)

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
}

170
internal/deacon/manager.go Normal file
View File

@@ -0,0 +1,170 @@
package deacon
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
// Common errors
var (
ErrNotRunning = errors.New("deacon not running")
ErrAlreadyRunning = errors.New("deacon already running")
)
// Manager handles deacon lifecycle operations.
type Manager struct {
townRoot string
}
// NewManager creates a new deacon manager for a town.
func NewManager(townRoot string) *Manager {
return &Manager{
townRoot: townRoot,
}
}
// SessionName returns the tmux session name for the deacon.
// This is a package-level function for convenience.
func SessionName() string {
return session.DeaconSessionName()
}
// SessionName returns the tmux session name for the deacon.
func (m *Manager) SessionName() string {
return SessionName()
}
// deaconDir returns the working directory for the deacon.
func (m *Manager) deaconDir() string {
return filepath.Join(m.townRoot, "deacon")
}
// Start starts the deacon session.
// The deacon runs in a respawn loop for automatic recovery.
func (m *Manager) Start() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session already exists
running, _ := t.HasSession(sessionID)
if running {
// Session exists - check if Claude is actually running (healthy vs zombie)
if t.IsClaudeRunning(sessionID) {
return ErrAlreadyRunning
}
// Zombie - tmux alive but Claude dead. Kill and recreate.
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing zombie session: %w", err)
}
}
// Ensure deacon directory exists
deaconDir := m.deaconDir()
if err := os.MkdirAll(deaconDir, 0755); err != nil {
return fmt.Errorf("creating deacon directory: %w", err)
}
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionID, deaconDir); err != nil {
return fmt.Errorf("creating tmux session: %w", err)
}
// Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "deacon")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "deacon")
// Apply Deacon theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.DeaconTheme()
_ = t.ConfigureGasTownSession(sessionID, theme, "", "Deacon", "health-check")
// Launch Claude in a respawn loop for automatic recovery
// The respawn loop ensures the deacon restarts if Claude crashes
runtimeCmd := config.GetRuntimeCommand("")
respawnCmd := fmt.Sprintf(
`export GT_ROLE=deacon BD_ACTOR=deacon GIT_AUTHOR_NAME=deacon && while true; do echo "⛪ Starting Deacon session..."; %s; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`,
runtimeCmd,
)
if err := t.SendKeysDelayed(sessionID, respawnCmd, 200); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err)
}
// Wait for Claude to start (non-fatal)
// Note: Deacon respawn loop makes this tricky - Claude restarts multiple times
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal - try to continue anyway
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionID)
time.Sleep(constants.ShutdownNotifyDelay)
// Note: Deacon doesn't get startup nudge due to respawn loop complexity
// The deacon uses its own patrol pattern defined in its CLAUDE.md/prime
return nil
}
// Stop stops the deacon session.
func (m *Manager) Stop() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session exists
running, err := t.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return ErrNotRunning
}
// Try graceful shutdown first (best-effort interrupt)
_ = t.SendKeysRaw(sessionID, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing session: %w", err)
}
return nil
}
// IsRunning checks if the deacon session is active.
func (m *Manager) IsRunning() (bool, error) {
t := tmux.NewTmux()
return t.HasSession(m.SessionName())
}
// Status returns information about the deacon session.
func (m *Manager) Status() (*tmux.SessionInfo, error) {
t := tmux.NewTmux()
sessionID := m.SessionName()
running, err := t.HasSession(sessionID)
if err != nil {
return nil, fmt.Errorf("checking session: %w", err)
}
if !running {
return nil, ErrNotRunning
}
return t.GetSessionInfo(sessionID)
}

178
internal/mayor/manager.go Normal file
View File

@@ -0,0 +1,178 @@
package mayor
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
// Common errors
var (
ErrNotRunning = errors.New("mayor not running")
ErrAlreadyRunning = errors.New("mayor already running")
)
// Manager handles mayor lifecycle operations.
type Manager struct {
townRoot string
}
// NewManager creates a new mayor manager for a town.
func NewManager(townRoot string) *Manager {
return &Manager{
townRoot: townRoot,
}
}
// SessionName returns the tmux session name for the mayor.
// This is a package-level function for convenience.
func SessionName() string {
return session.MayorSessionName()
}
// SessionName returns the tmux session name for the mayor.
func (m *Manager) SessionName() string {
return SessionName()
}
// mayorDir returns the working directory for the mayor.
func (m *Manager) mayorDir() string {
return filepath.Join(m.townRoot, "mayor")
}
// Start starts the mayor session.
// agentOverride optionally specifies a different agent alias to use.
func (m *Manager) Start(agentOverride string) error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session already exists
running, _ := t.HasSession(sessionID)
if running {
// Session exists - check if Claude is actually running (healthy vs zombie)
if t.IsClaudeRunning(sessionID) {
return ErrAlreadyRunning
}
// Zombie - tmux alive but Claude dead. Kill and recreate.
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing zombie session: %w", err)
}
}
// Ensure mayor directory exists
mayorDir := m.mayorDir()
if err := os.MkdirAll(mayorDir, 0755); err != nil {
return fmt.Errorf("creating mayor directory: %w", err)
}
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(mayorDir, "mayor"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create new tmux session
if err := t.NewSession(sessionID, mayorDir); err != nil {
return fmt.Errorf("creating tmux session: %w", err)
}
// Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "mayor")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "mayor")
// Apply Mayor theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(sessionID, theme, "", "Mayor", "coordinator")
// Launch Claude - the startup hook handles 'gt prime' automatically
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("mayor", "mayor", "", "", agentOverride)
if err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("building startup command: %w", err)
}
if err := t.SendKeysDelayed(sessionID, startupCmd, 200); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err)
}
// Wait for Claude to start (non-fatal)
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Non-fatal - try to continue anyway
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionID)
time.Sleep(constants.ShutdownNotifyDelay)
// Inject startup nudge for predecessor discovery via /resume
_ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{
Recipient: "mayor",
Sender: "human",
Topic: "cold-start",
}) // Non-fatal
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous coordination.
// Wait for beacon to be fully processed (needs to be separate prompt)
time.Sleep(2 * time.Second)
_ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("mayor", mayorDir)) // Non-fatal
return nil
}
// Stop stops the mayor session.
func (m *Manager) Stop() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if session exists
running, err := t.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return ErrNotRunning
}
// Try graceful shutdown first (best-effort interrupt)
_ = t.SendKeysRaw(sessionID, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing session: %w", err)
}
return nil
}
// IsRunning checks if the mayor session is active.
func (m *Manager) IsRunning() (bool, error) {
t := tmux.NewTmux()
return t.HasSession(m.SessionName())
}
// Status returns information about the mayor session.
func (m *Manager) Status() (*tmux.SessionInfo, error) {
t := tmux.NewTmux()
sessionID := m.SessionName()
running, err := t.HasSession(sessionID)
if err != nil {
return nil, fmt.Errorf("checking session: %w", err)
}
if !running {
return nil, ErrNotRunning
}
return t.GetSessionInfo(sessionID)
}

View File

@@ -1,5 +1,5 @@
// Package session provides polecat session lifecycle management.
package session
// Package polecat provides polecat workspace and session management.
package polecat
import (
"errors"
@@ -14,41 +14,39 @@ import (
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
// debugSession logs non-fatal errors during session startup when GT_DEBUG_SESSION=1.
// These errors are intentionally suppressed because they don't prevent session operation,
// but logging them helps diagnose issues when needed.
func debugSession(context string, err error) {
if os.Getenv("GT_DEBUG_SESSION") != "" && err != nil {
fmt.Fprintf(os.Stderr, "[session-debug] %s: %v\n", context, err)
}
}
// Common errors
// Session errors
var (
ErrSessionRunning = errors.New("session already running")
ErrSessionNotFound = errors.New("session not found")
ErrPolecatNotFound = errors.New("polecat not found")
)
// Manager handles polecat session lifecycle.
type Manager struct {
// SessionManager handles polecat session lifecycle.
type SessionManager struct {
tmux *tmux.Tmux
rig *rig.Rig
}
// NewManager creates a new session manager for a rig.
func NewManager(t *tmux.Tmux, r *rig.Rig) *Manager {
return &Manager{
// NewSessionManager creates a new polecat session manager for a rig.
func NewSessionManager(t *tmux.Tmux, r *rig.Rig) *SessionManager {
return &SessionManager{
tmux: t,
rig: r,
}
}
// StartOptions configures session startup.
type StartOptions struct {
// SessionStartOptions configures polecat session startup.
type SessionStartOptions struct {
// WorkDir overrides the default working directory (polecat clone dir).
WorkDir string
@@ -66,8 +64,8 @@ type StartOptions struct {
ClaudeConfigDir string
}
// Info contains information about a running session.
type Info struct {
// SessionInfo contains information about a running polecat session.
type SessionInfo struct {
// Polecat is the polecat name.
Polecat string `json:"polecat"`
@@ -94,18 +92,17 @@ type Info struct {
}
// SessionName generates the tmux session name for a polecat.
func (m *Manager) SessionName(polecat string) string {
func (m *SessionManager) SessionName(polecat string) string {
return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat)
}
// polecatDir returns the working directory for a polecat.
func (m *Manager) polecatDir(polecat string) string {
func (m *SessionManager) polecatDir(polecat string) string {
return filepath.Join(m.rig.Path, "polecats", polecat)
}
// hasPolecat checks if the polecat exists in this rig.
func (m *Manager) hasPolecat(polecat string) bool {
// Check filesystem directly to handle newly-created polecats
func (m *SessionManager) hasPolecat(polecat string) bool {
polecatPath := m.polecatDir(polecat)
info, err := os.Stat(polecatPath)
if err != nil {
@@ -115,7 +112,7 @@ func (m *Manager) hasPolecat(polecat string) bool {
}
// Start creates and starts a new session for a polecat.
func (m *Manager) Start(polecat string, opts StartOptions) error {
func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
if !m.hasPolecat(polecat) {
return fmt.Errorf("%w: %s", ErrPolecatNotFound, polecat)
}
@@ -139,7 +136,6 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
// write into the source repo. Claude walks up the tree to find settings.
// All polecats share the same settings file.
polecatsDir := filepath.Join(m.rig.Path, "polecats")
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
@@ -159,12 +155,8 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
debugSession("SetEnvironment CLAUDE_CONFIG_DIR", m.tmux.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir))
}
// CRITICAL: Set beads environment for worktree polecats (non-fatal: session works without)
// Polecats need access to TOWN-level beads (parent of rig) for hooks and convoys.
// Town beads use hq- prefix and store hooks, mail, and cross-rig coordination.
// BEADS_NO_DAEMON=1 prevents daemon from committing to wrong branch.
// Using town-level beads ensures gt prime and bd commands can find hooked work.
townRoot := filepath.Dir(m.rig.Path) // Town root is parent of rig directory
// Set beads environment for worktree polecats (non-fatal)
townRoot := filepath.Dir(m.rig.Path)
beadsDir := filepath.Join(townRoot, ".beads")
debugSession("SetEnvironment BEADS_DIR", m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir))
debugSession("SetEnvironment BEADS_NO_DAEMON", m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1"))
@@ -174,12 +166,11 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
if opts.Issue != "" {
agentID := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
if err := m.hookIssue(opts.Issue, agentID, workDir); err != nil {
// Non-fatal - warn but continue (session can still start)
fmt.Printf("Warning: could not hook issue %s: %v\n", opts.Issue, err)
}
}
// Apply theme (non-fatal: theming failure doesn't affect operation)
// Apply theme (non-fatal)
theme := tmux.AssignTheme(m.rig.Name)
debugSession("ConfigureGasTownSession", m.tmux.ConfigureGasTownSession(sessionID, theme, m.rig.Name, polecat, "polecat"))
@@ -188,61 +179,43 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
debugSession("SetPaneDiedHook", m.tmux.SetPaneDiedHook(sessionID, agentID))
// Send initial command with env vars exported inline
// NOTE: tmux SetEnvironment only affects NEW panes, not the current shell.
// We must export GT_ROLE, GT_RIG, GT_POLECAT inline for Claude to detect identity.
command := opts.Command
if command == "" {
// Polecats run with full permissions - Gas Town is for grownups
// Export env vars inline so Claude's role detection works
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
}
if err := m.tmux.SendKeys(sessionID, command); err != nil {
return fmt.Errorf("sending command: %w", err)
}
// Wait for Claude to start (non-fatal: session continues even if this times out)
// Wait for Claude to start (non-fatal)
debugSession("WaitForCommand", m.tmux.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout))
// Accept bypass permissions warning dialog if it appears.
// When Claude starts with --dangerously-skip-permissions, it shows a warning that
// requires pressing Down to select "Yes, I accept" and Enter to confirm.
// This is needed for automated polecat startup.
// Accept bypass permissions warning dialog if it appears
debugSession("AcceptBypassPermissionsWarning", m.tmux.AcceptBypassPermissionsWarning(sessionID))
// Wait for Claude to be fully ready at the prompt (not just started)
// PRAGMATIC APPROACH: Use fixed delay rather than detection.
// WaitForClaudeReady has false positives (detects > in various contexts).
// Claude startup takes ~5-8 seconds on typical machines.
// Reduced from 10s to 8s since AcceptBypassPermissionsWarning already adds ~1.2s.
// Wait for Claude to be fully ready
time.Sleep(8 * time.Second)
// Inject startup nudge for predecessor discovery via /resume
// This becomes the session title in Claude Code's session picker
address := fmt.Sprintf("%s/polecats/%s", m.rig.Name, polecat)
debugSession("StartupNudge", StartupNudge(m.tmux, sessionID, StartupNudgeConfig{
debugSession("StartupNudge", session.StartupNudge(m.tmux, sessionID, session.StartupNudgeConfig{
Recipient: address,
Sender: "witness",
Topic: "assigned",
MolID: opts.Issue,
}))
// GUPP: Gas Town Universal Propulsion Principle
// Send the propulsion nudge to trigger autonomous work execution.
// The beacon alone is just metadata - this nudge is the actual instruction
// that triggers Claude to check the hook and begin work.
// Wait for beacon to be fully processed (needs to be separate prompt)
// GUPP: Send propulsion nudge to trigger autonomous work execution
time.Sleep(2 * time.Second)
debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, PropulsionNudge()))
debugSession("NudgeSession PropulsionNudge", m.tmux.NudgeSession(sessionID, session.PropulsionNudge()))
return nil
}
// Stop terminates a polecat session.
// If force is true, skips graceful shutdown and kills immediately.
func (m *Manager) Stop(polecat string, force bool) error {
func (m *SessionManager) Stop(polecat string, force bool) error {
sessionID := m.SessionName(polecat)
// Check if session exists
running, err := m.tmux.HasSession(sessionID)
if err != nil {
return fmt.Errorf("checking session: %w", err)
@@ -251,23 +224,20 @@ func (m *Manager) Stop(polecat string, force bool) error {
return ErrSessionNotFound
}
// Sync beads before shutdown to preserve any changes
// Run in the polecat's worktree directory
// Sync beads before shutdown (non-fatal)
if !force {
polecatDir := m.polecatDir(polecat)
if err := m.syncBeads(polecatDir); err != nil {
// Non-fatal - log and continue with shutdown
fmt.Printf("Warning: beads sync failed: %v\n", err)
}
}
// Try graceful shutdown first (unless forced, best-effort interrupt)
// Try graceful shutdown first
if !force {
_ = m.tmux.SendKeysRaw(sessionID, "C-c")
time.Sleep(100 * time.Millisecond)
}
// Kill the session
if err := m.tmux.KillSession(sessionID); err != nil {
return fmt.Errorf("killing session: %w", err)
}
@@ -276,20 +246,20 @@ func (m *Manager) Stop(polecat string, force bool) error {
}
// syncBeads runs bd sync in the given directory.
func (m *Manager) syncBeads(workDir string) error {
func (m *SessionManager) syncBeads(workDir string) error {
cmd := exec.Command("bd", "sync")
cmd.Dir = workDir
return cmd.Run()
}
// IsRunning checks if a polecat session is active.
func (m *Manager) IsRunning(polecat string) (bool, error) {
func (m *SessionManager) IsRunning(polecat string) (bool, error) {
sessionID := m.SessionName(polecat)
return m.tmux.HasSession(sessionID)
}
// Status returns detailed status for a polecat session.
func (m *Manager) Status(polecat string) (*Info, error) {
func (m *SessionManager) Status(polecat string) (*SessionInfo, error) {
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
@@ -297,7 +267,7 @@ func (m *Manager) Status(polecat string) (*Info, error) {
return nil, fmt.Errorf("checking session: %w", err)
}
info := &Info{
info := &SessionInfo{
Polecat: polecat,
SessionID: sessionID,
Running: running,
@@ -308,19 +278,15 @@ func (m *Manager) Status(polecat string) (*Info, error) {
return info, nil
}
// Get detailed session info
tmuxInfo, err := m.tmux.GetSessionInfo(sessionID)
if err != nil {
// Non-fatal - return basic info
return info, nil
}
info.Attached = tmuxInfo.Attached
info.Windows = tmuxInfo.Windows
// Parse created time from tmux format (e.g., "Thu Dec 19 10:30:00 2025")
if tmuxInfo.Created != "" {
// Try common tmux date formats
formats := []string{
"Mon Jan 2 15:04:05 2006",
"Mon Jan _2 15:04:05 2006",
@@ -335,7 +301,6 @@ func (m *Manager) Status(polecat string) (*Info, error) {
}
}
// Parse activity time (unix timestamp from tmux)
if tmuxInfo.Activity != "" {
var activityUnix int64
if _, err := fmt.Sscanf(tmuxInfo.Activity, "%d", &activityUnix); err == nil && activityUnix > 0 {
@@ -346,15 +311,15 @@ func (m *Manager) Status(polecat string) (*Info, error) {
return info, nil
}
// List returns information about all sessions for this rig.
func (m *Manager) List() ([]Info, error) {
// List returns information about all polecat sessions for this rig.
func (m *SessionManager) List() ([]SessionInfo, error) {
sessions, err := m.tmux.ListSessions()
if err != nil {
return nil, err
}
prefix := fmt.Sprintf("gt-%s-", m.rig.Name)
var infos []Info
var infos []SessionInfo
for _, sessionID := range sessions {
if !strings.HasPrefix(sessionID, prefix) {
@@ -362,7 +327,7 @@ func (m *Manager) List() ([]Info, error) {
}
polecat := strings.TrimPrefix(sessionID, prefix)
infos = append(infos, Info{
infos = append(infos, SessionInfo{
Polecat: polecat,
SessionID: sessionID,
Running: true,
@@ -374,7 +339,7 @@ func (m *Manager) List() ([]Info, error) {
}
// Attach attaches to a polecat session.
func (m *Manager) Attach(polecat string) error {
func (m *SessionManager) Attach(polecat string) error {
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
@@ -389,7 +354,7 @@ func (m *Manager) Attach(polecat string) error {
}
// Capture returns the recent output from a polecat session.
func (m *Manager) Capture(polecat string, lines int) (string, error) {
func (m *SessionManager) Capture(polecat string, lines int) (string, error) {
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
@@ -404,9 +369,7 @@ func (m *Manager) Capture(polecat string, lines int) (string, error) {
}
// CaptureSession returns the recent output from a session by raw session ID.
// Use this for crew workers or other non-polecat sessions where the session
// name doesn't follow the standard gt-{rig}-{polecat} pattern.
func (m *Manager) CaptureSession(sessionID string, lines int) (string, error) {
func (m *SessionManager) CaptureSession(sessionID string, lines int) (string, error) {
running, err := m.tmux.HasSession(sessionID)
if err != nil {
return "", fmt.Errorf("checking session: %w", err)
@@ -419,8 +382,7 @@ func (m *Manager) CaptureSession(sessionID string, lines int) (string, error) {
}
// Inject sends a message to a polecat session.
// Uses a longer debounce delay for large messages to ensure paste completes.
func (m *Manager) Inject(polecat, message string) error {
func (m *SessionManager) Inject(polecat, message string) error {
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
@@ -431,19 +393,16 @@ func (m *Manager) Inject(polecat, message string) error {
return ErrSessionNotFound
}
// Use longer debounce for large messages (spawn context can be 1KB+)
// Claude needs time to process paste before Enter is sent
// Scale delay based on message size: 200ms base + 100ms per KB
debounceMs := 200 + (len(message)/1024)*100
if debounceMs > 1500 {
debounceMs = 1500 // Cap at 1.5s for large pastes
debounceMs = 1500
}
return m.tmux.SendKeysDebounced(sessionID, message, debounceMs)
}
// StopAll terminates all sessions for this rig.
func (m *Manager) StopAll(force bool) error {
// StopAll terminates all polecat sessions for this rig.
func (m *SessionManager) StopAll(force bool) error {
infos, err := m.List()
if err != nil {
return err
@@ -460,10 +419,8 @@ func (m *Manager) StopAll(force bool) error {
}
// hookIssue pins an issue to a polecat's hook using bd update.
// This makes the work visible via 'gt hook' when the session starts.
func (m *Manager) hookIssue(issueID, agentID, workDir string) error {
// Use bd update to set status=hooked and assign to the polecat
cmd := exec.Command("bd", "update", issueID, "--status=hooked", "--assignee="+agentID) //nolint:gosec // G204: bd is a trusted internal tool
func (m *SessionManager) hookIssue(issueID, agentID, workDir string) error {
cmd := exec.Command("bd", "update", issueID, "--status=hooked", "--assignee="+agentID) //nolint:gosec
cmd.Dir = workDir
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {

View File

@@ -1,4 +1,4 @@
package session
package polecat
import (
"os"
@@ -15,7 +15,7 @@ func TestSessionName(t *testing.T) {
Name: "gastown",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
name := m.SessionName("Toast")
if name != "gt-gastown-Toast" {
@@ -23,13 +23,13 @@ func TestSessionName(t *testing.T) {
}
}
func TestPolecatDir(t *testing.T) {
func TestSessionManagerPolecatDir(t *testing.T) {
r := &rig.Rig{
Name: "gastown",
Path: "/home/user/ai/gastown",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
dir := m.polecatDir("Toast")
expected := "/home/user/ai/gastown/polecats/Toast"
@@ -52,7 +52,7 @@ func TestHasPolecat(t *testing.T) {
Path: root,
Polecats: []string{"Toast", "Cheedo"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
if !m.hasPolecat("Toast") {
t.Error("expected hasPolecat(Toast) = true")
@@ -70,9 +70,9 @@ func TestStartPolecatNotFound(t *testing.T) {
Name: "gastown",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
err := m.Start("Unknown", StartOptions{})
err := m.Start("Unknown", SessionStartOptions{})
if err == nil {
t.Error("expected error for unknown polecat")
}
@@ -83,7 +83,7 @@ func TestIsRunningNoSession(t *testing.T) {
Name: "gastown",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
running, err := m.IsRunning("Toast")
if err != nil {
@@ -94,12 +94,12 @@ func TestIsRunningNoSession(t *testing.T) {
}
}
func TestListEmpty(t *testing.T) {
func TestSessionManagerListEmpty(t *testing.T) {
r := &rig.Rig{
Name: "test-rig-unlikely-name",
Polecats: []string{},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
infos, err := m.List()
if err != nil {
@@ -115,7 +115,7 @@ func TestStopNotFound(t *testing.T) {
Name: "test-rig",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
err := m.Stop("Toast", false)
if err != ErrSessionNotFound {
@@ -128,7 +128,7 @@ func TestCaptureNotFound(t *testing.T) {
Name: "test-rig",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
_, err := m.Capture("Toast", 50)
if err != ErrSessionNotFound {
@@ -141,7 +141,7 @@ func TestInjectNotFound(t *testing.T) {
Name: "test-rig",
Polecats: []string{"Toast"},
}
m := NewManager(tmux.NewTmux(), r)
m := NewSessionManager(tmux.NewTmux(), r)
err := m.Inject("Toast", "hello")
if err != ErrSessionNotFound {

View File

@@ -58,8 +58,8 @@ func (m *Manager) stateFile() string {
return filepath.Join(m.rig.Path, ".runtime", "refinery.json")
}
// sessionName returns the tmux session name for this refinery.
func (m *Manager) sessionName() string {
// SessionName returns the tmux session name for this refinery.
func (m *Manager) SessionName() string {
return fmt.Sprintf("gt-%s-refinery", m.rig.Name)
}
@@ -111,7 +111,7 @@ func (m *Manager) Start(foreground bool) error {
}
t := tmux.NewTmux()
sessionID := m.sessionName()
sessionID := m.SessionName()
if foreground {
// In foreground mode, we're likely running inside the tmux session
@@ -253,7 +253,7 @@ func (m *Manager) Stop() error {
// Check if tmux session exists
t := tmux.NewTmux()
sessionID := m.sessionName()
sessionID := m.SessionName()
sessionRunning, _ := t.HasSession(sessionID)
// If neither state nor session indicates running, it's not running

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -59,12 +59,12 @@ func (m *Manager) ExecuteLanding(swarmID string, config LandingConfig) (*Landing
// Phase 1: Stop all polecat sessions
t := tmux.NewTmux()
sessMgr := session.NewManager(t, m.rig)
polecatMgr := polecat.NewSessionManager(t, m.rig)
for _, worker := range swarm.Workers {
running, _ := sessMgr.IsRunning(worker)
running, _ := polecatMgr.IsRunning(worker)
if running {
err := sessMgr.Stop(worker, config.ForceKill)
err := polecatMgr.Stop(worker, config.ForceKill)
if err != nil {
// Continue anyway
} else {

View File

@@ -59,8 +59,8 @@ func (m *Manager) saveState(w *Witness) error {
return m.stateManager.Save(w)
}
// sessionName returns the tmux session name for this witness.
func (m *Manager) sessionName() string {
// SessionName returns the tmux session name for this witness.
func (m *Manager) SessionName() string {
return fmt.Sprintf("gt-%s-witness", m.rig.Name)
}
@@ -105,7 +105,7 @@ func (m *Manager) Start(foreground bool) error {
}
t := tmux.NewTmux()
sessionID := m.sessionName()
sessionID := m.SessionName()
if foreground {
// Foreground mode is deprecated - patrol logic moved to mol-witness-patrol
@@ -224,7 +224,7 @@ func (m *Manager) Stop() error {
// Check if tmux session exists
t := tmux.NewTmux()
sessionID := m.sessionName()
sessionID := m.SessionName()
sessionRunning, _ := t.HasSession(sessionID)
// If neither state nor session indicates running, it's not running