Fixes #525: gt up reports deacon success but session doesn't actually start Previously, WaitForCommand failures were marked as "non-fatal" in the manager Start() methods used by gt up. This caused gt up to report success even when Claude failed to start, because the error was silently ignored. Now when WaitForCommand or WaitForRuntimeReady times out: 1. The zombie tmux session is killed 2. An error is returned to the caller 3. gt up properly reports the failure This aligns the manager Start() behavior with the cmd start functions (e.g., gt deacon start) which already had fatal WaitForCommand behavior. Changed files: - internal/deacon/manager.go - internal/mayor/manager.go - internal/witness/manager.go - internal/refinery/manager.go Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
182 lines
5.3 KiB
Go
182 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 (for Claude settings)
|
|
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)
|
|
}
|
|
|
|
// Build startup beacon with explicit instructions (matches gt handoff behavior)
|
|
// This ensures the agent has clear context immediately, not after nudges arrive
|
|
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
|
Recipient: "mayor",
|
|
Sender: "human",
|
|
Topic: "cold-start",
|
|
})
|
|
|
|
// Build startup command WITH the beacon prompt - 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", "", m.townRoot, "", beacon, agentOverride)
|
|
if err != nil {
|
|
return fmt.Errorf("building startup command: %w", err)
|
|
}
|
|
|
|
// Create session in townRoot (not mayorDir) to match gt handoff behavior
|
|
// This ensures Mayor works from the town root where all tools work correctly
|
|
// See: https://github.com/anthropics/gastown/issues/280
|
|
if err := t.NewSessionWithCommand(sessionID, m.townRoot, startupCmd); err != nil {
|
|
return fmt.Errorf("creating tmux session: %w", err)
|
|
}
|
|
|
|
// Set environment variables (non-fatal: session works without these)
|
|
// Use centralized AgentEnv for consistency across all role startup paths
|
|
envVars := config.AgentEnv(config.AgentEnvConfig{
|
|
Role: "mayor",
|
|
TownRoot: m.townRoot,
|
|
})
|
|
for k, v := range envVars {
|
|
_ = t.SetEnvironment(sessionID, k, v)
|
|
}
|
|
|
|
// Apply Mayor theming (non-fatal: theming failure doesn't affect operation)
|
|
theme := tmux.MayorTheme()
|
|
_ = t.ConfigureGasTownSession(sessionID, theme, "", "Mayor", "coordinator")
|
|
|
|
// Wait for Claude to start - fatal if Claude fails to launch
|
|
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
|
// Kill the zombie session before returning error
|
|
_ = t.KillSessionWithProcesses(sessionID)
|
|
return fmt.Errorf("waiting for mayor to start: %w", err)
|
|
}
|
|
|
|
// Accept bypass permissions warning dialog if it appears.
|
|
_ = t.AcceptBypassPermissionsWarning(sessionID)
|
|
|
|
time.Sleep(constants.ShutdownNotifyDelay)
|
|
|
|
// Startup beacon with instructions is now included in the initial command,
|
|
// so no separate nudge needed. The agent starts with full context immediately.
|
|
|
|
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)
|
|
}
|