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:
Steve Yegge
2025-12-20 02:00:37 -08:00
parent a37fac2c3c
commit 348a7d0525
3 changed files with 125 additions and 4 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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 {