feat: mayor respawn loop - session survives restarts
This commit is contained in:
@@ -122,17 +122,13 @@ func startMayorSession(t *tmux.Tmux) error {
|
||||
// Set environment
|
||||
t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
|
||||
|
||||
// Launch Claude with full permissions (Mayor is trusted)
|
||||
// Use exec to replace shell - when Claude exits, session closes
|
||||
if err := t.SendKeys(MayorSessionName, "exec claude --dangerously-skip-permissions"); err != nil {
|
||||
// Launch Claude in a respawn loop - session survives restarts
|
||||
// The startup hook handles 'gt prime' automatically
|
||||
loopCmd := `while true; do echo "🏛️ Starting Mayor session..."; claude --dangerously-skip-permissions; echo ""; echo "Mayor exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
||||
if err := t.SendKeys(MayorSessionName, loopCmd); err != nil {
|
||||
return fmt.Errorf("sending command: %w", err)
|
||||
}
|
||||
|
||||
// Prime after a delay
|
||||
if err := t.SendKeysDelayed(MayorSessionName, "gt prime", 2000); err != nil {
|
||||
fmt.Printf("Warning: Could not send prime command: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,27 +168,13 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
// Auto-start if not running (matches Python behavior)
|
||||
// Auto-start if not running
|
||||
fmt.Println("Mayor session not running, starting...")
|
||||
if err := startMayorSession(t); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Session exists - check if Claude is still running
|
||||
// (With exec this rarely triggers, but handles edge cases)
|
||||
paneCmd, err := t.GetPaneCommand(MayorSessionName)
|
||||
if err == nil && isMayorShellCommand(paneCmd) {
|
||||
// Claude has exited, restart it with exec
|
||||
fmt.Println("Claude exited, restarting...")
|
||||
if err := t.SendKeys(MayorSessionName, "exec claude --dangerously-skip-permissions"); err != nil {
|
||||
return fmt.Errorf("restarting claude: %w", err)
|
||||
}
|
||||
// Prime after restart
|
||||
if err := t.SendKeysDelayed(MayorSessionName, "gt prime", 2000); err != nil {
|
||||
fmt.Printf("Warning: Could not send prime command: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Session uses a respawn loop, so Claude restarts automatically if it exits
|
||||
|
||||
// Use exec to replace current process with tmux attach
|
||||
tmuxPath, err := exec.LookPath("tmux")
|
||||
@@ -203,17 +185,6 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
|
||||
return execCommand(tmuxPath, "attach-session", "-t", MayorSessionName)
|
||||
}
|
||||
|
||||
// isMayorShellCommand checks if the command is a shell (meaning Claude has exited).
|
||||
func isMayorShellCommand(cmd string) bool {
|
||||
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
|
||||
for _, shell := range shells {
|
||||
if cmd == shell {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// execCommand replaces the current process with the given command.
|
||||
// This is used for attaching to tmux sessions.
|
||||
func execCommand(name string, args ...string) error {
|
||||
@@ -266,21 +237,19 @@ func runMayorStatus(cmd *cobra.Command, args []string) error {
|
||||
func runMayorRestart(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Stop if running
|
||||
running, err := t.HasSession(MayorSessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
|
||||
if running {
|
||||
fmt.Println("Stopping Mayor session...")
|
||||
// Graceful restart: send Ctrl-C to exit Claude, loop will restart it
|
||||
fmt.Println("Restarting Mayor (sending Ctrl-C to trigger respawn loop)...")
|
||||
t.SendKeysRaw(MayorSessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := t.KillSession(MayorSessionName); err != nil {
|
||||
return fmt.Errorf("killing session: %w", err)
|
||||
}
|
||||
fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓"))
|
||||
fmt.Printf("%s Mayor will restart automatically. Session stays attached.\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start fresh
|
||||
// Not running, start fresh
|
||||
return runMayorStart(cmd, args)
|
||||
}
|
||||
|
||||
164
scripts/mayor-respawn-daemon.sh
Executable file
164
scripts/mayor-respawn-daemon.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
# Mayor Respawn Daemon
|
||||
# Watches for restart requests and respawns the mayor session
|
||||
#
|
||||
# Usage: mayor-respawn-daemon.sh [start|stop|status]
|
||||
#
|
||||
# The daemon monitors for mail to "daemon/" with subject containing "RESTART".
|
||||
# When found, it:
|
||||
# 1. Acknowledges the mail
|
||||
# 2. Waits 5 seconds (for handoff mail to be sent)
|
||||
# 3. Runs `gt mayor restart`
|
||||
|
||||
DAEMON_NAME="gt-mayor-respawn"
|
||||
PID_FILE="/tmp/${DAEMON_NAME}.pid"
|
||||
LOG_FILE="/tmp/${DAEMON_NAME}.log"
|
||||
CHECK_INTERVAL=10 # seconds between mail checks
|
||||
TOWN_ROOT="${GT_TOWN_ROOT:-/Users/stevey/gt}"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
check_for_restart() {
|
||||
cd "$TOWN_ROOT" || return 1
|
||||
|
||||
# Check inbox for daemon identity - look for RESTART subject
|
||||
# Set BD_IDENTITY=daemon so bd mail knows which inbox to check
|
||||
local inbox
|
||||
inbox=$(BD_IDENTITY=daemon bd mail inbox --json 2>/dev/null)
|
||||
|
||||
if [ -z "$inbox" ] || [ "$inbox" = "null" ] || [ "$inbox" = "[]" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse JSON to find RESTART messages
|
||||
# Note: bd mail returns "title" not "subject" (beads uses title for message subjects)
|
||||
local msg_id
|
||||
msg_id=$(echo "$inbox" | jq -r '.[] | select(.title | test("RESTART"; "i")) | .id' 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$msg_id" ] && [ "$msg_id" != "null" ]; then
|
||||
log "Found restart request: $msg_id"
|
||||
|
||||
# Acknowledge the message
|
||||
BD_IDENTITY=daemon bd mail ack "$msg_id" 2>/dev/null
|
||||
log "Acknowledged restart request"
|
||||
|
||||
# Wait for handoff to complete
|
||||
sleep 5
|
||||
|
||||
# Restart mayor (just sends Ctrl-C, loop handles respawn)
|
||||
log "Triggering mayor respawn..."
|
||||
gt mayor restart 2>&1 | while read -r line; do log "$line"; done
|
||||
log "Mayor respawn triggered"
|
||||
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
daemon_loop() {
|
||||
log "Daemon starting, watching for restart requests..."
|
||||
|
||||
while true; do
|
||||
if check_for_restart; then
|
||||
log "Restart handled, continuing watch..."
|
||||
fi
|
||||
sleep "$CHECK_INTERVAL"
|
||||
done
|
||||
}
|
||||
|
||||
start_daemon() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Daemon already running (PID $pid)"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# Start daemon in background using the script itself
|
||||
nohup "$0" run > /dev/null 2>&1 &
|
||||
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
echo "Started mayor respawn daemon (PID $pid)"
|
||||
echo "Log: $LOG_FILE"
|
||||
}
|
||||
|
||||
run_daemon() {
|
||||
# Called when script is invoked with "run"
|
||||
echo $$ > "$PID_FILE"
|
||||
daemon_loop
|
||||
}
|
||||
|
||||
stop_daemon() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "Daemon not running (no PID file)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
rm -f "$PID_FILE"
|
||||
echo "Stopped daemon (PID $pid)"
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
echo "Daemon was not running (stale PID file removed)"
|
||||
fi
|
||||
}
|
||||
|
||||
daemon_status() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "Daemon not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Daemon running (PID $pid)"
|
||||
echo "Log: $LOG_FILE"
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo ""
|
||||
echo "Recent log entries:"
|
||||
tail -5 "$LOG_FILE"
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
echo "Daemon not running (stale PID file removed)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start)
|
||||
start_daemon
|
||||
;;
|
||||
stop)
|
||||
stop_daemon
|
||||
;;
|
||||
status)
|
||||
daemon_status
|
||||
;;
|
||||
restart)
|
||||
stop_daemon 2>/dev/null
|
||||
start_daemon
|
||||
;;
|
||||
run)
|
||||
# Internal: called when daemon starts itself in background
|
||||
run_daemon
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user