- 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>
171 lines
4.8 KiB
Go
171 lines
4.8 KiB
Go
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)
|
|
}
|