From 348a7d05250360679b291d82f6c8d75587588613 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 20 Dec 2025 02:00:37 -0800 Subject: [PATCH] feat(deacon): make deacon the system heartbeat with auto-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cmd/deacon.go | 4 ++ internal/daemon/daemon.go | 119 ++++++++++++++++++++++++++++++++++++-- internal/tmux/theme.go | 6 ++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index a585dbe8..9dd0b5ad 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -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 diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f85d4c1a..a9f1c41f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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" diff --git a/internal/tmux/theme.go b/internal/tmux/theme.go index 7b8bd9fb..2d974f61 100644 --- a/internal/tmux/theme.go +++ b/internal/tmux/theme.go @@ -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 {