feat(deacon): make deacon the system heartbeat with auto-start
Robustness improvements for the Deacon: - Add DeaconTheme (purple/silver ecclesiastical theme) - Apply theme to deacon sessions like mayor/witness - Daemon now auto-starts deacon if not running - Daemon pokes deacon instead of directly poking mayor/witnesses - Deacon is responsible for monitoring mayor and witnesses The daemon is a "dumb scheduler" that keeps the deacon alive. The deacon (Claude agent) has the intelligence to understand context and take remedial action when agents are unhealthy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,10 @@ func startDeaconSession(t *tmux.Tmux) error {
|
||||
// Set environment
|
||||
_ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
||||
|
||||
// Apply Deacon theme
|
||||
theme := tmux.DeaconTheme()
|
||||
_ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check")
|
||||
|
||||
// Launch Claude in a respawn loop - session survives restarts
|
||||
// The startup hook handles context loading automatically
|
||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||
|
||||
@@ -2,6 +2,7 @@ package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -109,11 +110,11 @@ func (d *Daemon) Run() error {
|
||||
func (d *Daemon) heartbeat(state *State) {
|
||||
d.logger.Println("Heartbeat starting")
|
||||
|
||||
// 1. Poke Mayor
|
||||
d.pokeMayor()
|
||||
// 1. Ensure Deacon is running (the Deacon is the heartbeat of the system)
|
||||
d.ensureDeaconRunning()
|
||||
|
||||
// 2. Poke Witnesses (for each rig)
|
||||
d.pokeWitnesses()
|
||||
// 2. Poke Deacon - the Deacon monitors Mayor and Witnesses
|
||||
d.pokeDeacon()
|
||||
|
||||
// 3. Process lifecycle requests
|
||||
d.processLifecycleRequests()
|
||||
@@ -128,6 +129,116 @@ func (d *Daemon) heartbeat(state *State) {
|
||||
d.logger.Printf("Heartbeat complete (#%d)", state.HeartbeatCount)
|
||||
}
|
||||
|
||||
// DeaconSessionName is the tmux session name for the Deacon.
|
||||
const DeaconSessionName = "gt-deacon"
|
||||
|
||||
// ensureDeaconRunning checks if the Deacon session exists and starts it if not.
|
||||
// The Deacon is the system's heartbeat - it must always be running.
|
||||
func (d *Daemon) ensureDeaconRunning() {
|
||||
running, err := d.tmux.HasSession(DeaconSessionName)
|
||||
if err != nil {
|
||||
d.logger.Printf("Error checking Deacon session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if running {
|
||||
return // Deacon is running, nothing to do
|
||||
}
|
||||
|
||||
// Deacon is not running - start it
|
||||
d.logger.Println("Deacon session not running, starting...")
|
||||
|
||||
// Create session in town root
|
||||
if err := d.tmux.NewSession(DeaconSessionName, d.config.TownRoot); err != nil {
|
||||
d.logger.Printf("Error creating Deacon session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set environment
|
||||
_ = d.tmux.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
||||
|
||||
// Launch Claude in a respawn loop - session survives restarts
|
||||
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 := d.tmux.SendKeysDelayed(DeaconSessionName, loopCmd, 200); err != nil {
|
||||
d.logger.Printf("Error launching Claude in Deacon session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
d.logger.Println("Deacon session started successfully")
|
||||
}
|
||||
|
||||
// pokeDeacon sends a heartbeat message to the Deacon session.
|
||||
// The Deacon is responsible for monitoring Mayor and Witnesses.
|
||||
func (d *Daemon) pokeDeacon() {
|
||||
const agentID = "deacon"
|
||||
|
||||
running, err := d.tmux.HasSession(DeaconSessionName)
|
||||
if err != nil {
|
||||
d.logger.Printf("Error checking Deacon session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !running {
|
||||
d.logger.Println("Deacon session not running after ensure, skipping poke")
|
||||
return
|
||||
}
|
||||
|
||||
// Check deacon heartbeat to see if it's active
|
||||
deaconHeartbeatFile := filepath.Join(d.config.TownRoot, "deacon", "heartbeat.json")
|
||||
var isFresh, isStale, isVeryStale bool
|
||||
|
||||
data, err := os.ReadFile(deaconHeartbeatFile)
|
||||
if err == nil {
|
||||
var hb struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
if json.Unmarshal(data, &hb) == nil {
|
||||
age := time.Since(hb.Timestamp)
|
||||
isFresh = age < 2*time.Minute
|
||||
isStale = age >= 2*time.Minute && age < 5*time.Minute
|
||||
isVeryStale = age >= 5*time.Minute
|
||||
} else {
|
||||
isVeryStale = true
|
||||
}
|
||||
} else {
|
||||
isVeryStale = true // No heartbeat file
|
||||
}
|
||||
|
||||
if isFresh {
|
||||
// Deacon is actively working, reset backoff
|
||||
d.backoff.RecordActivity(agentID)
|
||||
d.logger.Println("Deacon is fresh, skipping poke")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we should poke based on backoff interval
|
||||
if !d.backoff.ShouldPoke(agentID) {
|
||||
interval := d.backoff.GetInterval(agentID)
|
||||
d.logger.Printf("Deacon backoff in effect (interval: %v), skipping poke", interval)
|
||||
return
|
||||
}
|
||||
|
||||
// Send heartbeat message via tmux
|
||||
msg := "HEARTBEAT: check Mayor and Witnesses"
|
||||
if err := d.tmux.SendKeys(DeaconSessionName, msg); err != nil {
|
||||
d.logger.Printf("Error poking Deacon: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
d.backoff.RecordPoke(agentID)
|
||||
|
||||
// Adjust backoff based on staleness
|
||||
if isVeryStale {
|
||||
d.backoff.RecordMiss(agentID)
|
||||
interval := d.backoff.GetInterval(agentID)
|
||||
d.logger.Printf("Poked Deacon (very stale, backoff now: %v)", interval)
|
||||
} else if isStale {
|
||||
d.logger.Println("Poked Deacon (stale)")
|
||||
} else {
|
||||
d.logger.Println("Poked Deacon")
|
||||
}
|
||||
}
|
||||
|
||||
// pokeMayor sends a heartbeat to the Mayor session.
|
||||
func (d *Daemon) pokeMayor() {
|
||||
const mayorSession = "gt-mayor"
|
||||
|
||||
@@ -34,6 +34,12 @@ func MayorTheme() Theme {
|
||||
return Theme{Name: "mayor", BG: "#3d3200", FG: "#ffd700"}
|
||||
}
|
||||
|
||||
// DeaconTheme returns the special theme for the Deacon session.
|
||||
// Purple/silver - ecclesiastical, distinct from Mayor's gold.
|
||||
func DeaconTheme() Theme {
|
||||
return Theme{Name: "deacon", BG: "#2d1f3d", FG: "#c0b0d0"}
|
||||
}
|
||||
|
||||
// GetThemeByName finds a theme by name from the default palette.
|
||||
// Returns nil if not found.
|
||||
func GetThemeByName(name string) *Theme {
|
||||
|
||||
Reference in New Issue
Block a user