Add WaitForShellReady call before SendKeys in all agent managers (deacon, mayor, witness, refinery). This prevents intermittent "can't find pane" errors that occur when the tmux session is created but the shell isn't ready to receive input yet. The issue manifests under load (e.g., during `gt up` when multiple agents start in sequence) where the 200ms delay in SendKeysDelayed isn't sufficient for the pane to be fully initialized. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
184 lines
5.3 KiB
Go
184 lines
5.3 KiB
Go
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)
|
|
}
|
|
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
|
|
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
|
|
_ = t.KillSession(sessionID)
|
|
return fmt.Errorf("waiting for shell: %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)
|
|
}
|