refactor: remove shell respawn loops from witness and deacon (gt-zxgu)
Shell loops bypassed the proper lifecycle architecture. Now:
- Witness: Launches Claude directly, daemon/deacon health-scan handles restart
- Deacon: Launches Claude directly, daemon detects exit and restarts
- Daemon: ensureDeaconRunning() now checks if Claude is running (pane cmd)
and restarts it if session exists but Claude has exited
- gt deacon restart: Now does stop+start instead of Ctrl-C
This enforces proper lifecycle flow through LIFECYCLE mail and daemon
heartbeat rather than bypassing it with shell loops.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,11 +153,10 @@ func startDeaconSession(t *tmux.Tmux) error {
|
|||||||
theme := tmux.DeaconTheme()
|
theme := tmux.DeaconTheme()
|
||||||
_ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check")
|
_ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check")
|
||||||
|
|
||||||
// Launch Claude in a respawn loop - session survives restarts
|
// Launch Claude directly (no shell respawn loop)
|
||||||
|
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
||||||
// The startup hook handles context loading automatically
|
// The startup hook handles context loading automatically
|
||||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
if err := t.SendKeys(DeaconSessionName, "claude --dangerously-skip-permissions"); err != nil {
|
||||||
loopCmd := `while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
|
||||||
if err := t.SendKeysDelayed(DeaconSessionName, loopCmd, 200); err != nil {
|
|
||||||
return fmt.Errorf("sending command: %w", err)
|
return fmt.Errorf("sending command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,16 +256,23 @@ func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("Restarting Deacon...")
|
||||||
|
|
||||||
if running {
|
if running {
|
||||||
// Graceful restart: send Ctrl-C to exit Claude, loop will restart it
|
// Kill existing session
|
||||||
fmt.Println("Restarting Deacon (sending Ctrl-C to trigger respawn loop)...")
|
if err := t.KillSession(DeaconSessionName); err != nil {
|
||||||
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
|
fmt.Printf("%s Warning: failed to kill session: %v\n", style.Dim.Render("⚠"), err)
|
||||||
fmt.Printf("%s Deacon will restart automatically. Session stays attached.\n", style.Bold.Render("✓"))
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not running, start fresh
|
// Start fresh
|
||||||
return runDeaconStart(cmd, args)
|
if err := runDeaconStart(cmd, args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Deacon restarted\n", style.Bold.Render("✓"))
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt deacon attach' to connect"))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDeaconHeartbeat(cmd *cobra.Command, args []string) error {
|
func runDeaconHeartbeat(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -312,10 +312,10 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) {
|
|||||||
theme := tmux.AssignTheme(rigName)
|
theme := tmux.AssignTheme(rigName)
|
||||||
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
|
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
|
||||||
|
|
||||||
// Launch Claude in a respawn loop
|
// Launch Claude directly (no shell respawn loop)
|
||||||
|
// Restarts are handled by daemon via LIFECYCLE mail or deacon health-scan
|
||||||
// NOTE: No gt prime injection needed - SessionStart hook handles it automatically
|
// NOTE: No gt prime injection needed - SessionStart hook handles it automatically
|
||||||
loopCmd := `while true; do echo "👁️ Starting Witness for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Witness exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
if err := t.SendKeys(sessionName, "claude --dangerously-skip-permissions"); err != nil {
|
||||||
if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil {
|
|
||||||
return false, fmt.Errorf("sending command: %w", err)
|
return false, fmt.Errorf("sending command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,20 +186,39 @@ func (d *Daemon) nextMOTD() string {
|
|||||||
return deaconMOTDMessages[nextIdx]
|
return deaconMOTDMessages[nextIdx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureDeaconRunning checks if the Deacon session exists and starts it if not.
|
// ensureDeaconRunning checks if the Deacon session exists and Claude is running.
|
||||||
|
// If the session exists but Claude has exited, it restarts Claude.
|
||||||
|
// If the session doesn't exist, it creates it and starts Claude.
|
||||||
// The Deacon is the system's heartbeat - it must always be running.
|
// The Deacon is the system's heartbeat - it must always be running.
|
||||||
func (d *Daemon) ensureDeaconRunning() {
|
func (d *Daemon) ensureDeaconRunning() {
|
||||||
running, err := d.tmux.HasSession(DeaconSessionName)
|
sessionExists, err := d.tmux.HasSession(DeaconSessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Printf("Error checking Deacon session: %v", err)
|
d.logger.Printf("Error checking Deacon session: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if running {
|
if sessionExists {
|
||||||
return // Deacon is running, nothing to do
|
// Session exists - check if Claude is actually running
|
||||||
|
cmd, err := d.tmux.GetPaneCommand(DeaconSessionName)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Printf("Error checking Deacon pane command: %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deacon is not running - start it
|
// If Claude is running (node process), we're good
|
||||||
|
if cmd == "node" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude has exited (shell is showing) - restart it
|
||||||
|
d.logger.Printf("Deacon session exists but Claude exited (cmd=%s), restarting...", cmd)
|
||||||
|
if err := d.tmux.SendKeys(DeaconSessionName, "claude --dangerously-skip-permissions"); err != nil {
|
||||||
|
d.logger.Printf("Error restarting Claude in Deacon session: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session doesn't exist - create it and start Claude
|
||||||
d.logger.Println("Deacon session not running, starting...")
|
d.logger.Println("Deacon session not running, starting...")
|
||||||
|
|
||||||
// Create session in town root
|
// Create session in town root
|
||||||
@@ -211,9 +230,9 @@ func (d *Daemon) ensureDeaconRunning() {
|
|||||||
// Set environment
|
// Set environment
|
||||||
_ = d.tmux.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
_ = d.tmux.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
||||||
|
|
||||||
// Launch Claude in a respawn loop - session survives restarts
|
// Launch Claude directly (no shell respawn loop)
|
||||||
loopCmd := `while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
|
// The daemon will detect if Claude exits and restart it on next heartbeat
|
||||||
if err := d.tmux.SendKeysDelayed(DeaconSessionName, loopCmd, 200); err != nil {
|
if err := d.tmux.SendKeys(DeaconSessionName, "claude --dangerously-skip-permissions"); err != nil {
|
||||||
d.logger.Printf("Error launching Claude in Deacon session: %v", err)
|
d.logger.Printf("Error launching Claude in Deacon session: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user