feat: add gt daemon for town-level background service

Implements the town daemon (gt-99m) that handles:
- Periodic heartbeat to poke Mayor and Witnesses
- Lifecycle request processing (cycle, restart, shutdown)
- Session management for agent restarts

Commands:
- gt daemon start: Start daemon in background
- gt daemon stop: Stop running daemon
- gt daemon status: Show daemon status and stats
- gt daemon logs: View daemon log file

The daemon is a "dumb scheduler" - all intelligence remains in agents.
It simply pokes them on schedule and executes lifecycle requests.

🤖 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-18 13:06:20 -08:00
parent 7f29a048a5
commit 1265df70f9
4 changed files with 835 additions and 0 deletions

274
internal/daemon/daemon.go Normal file
View File

@@ -0,0 +1,274 @@
package daemon
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/steveyegge/gastown/internal/tmux"
)
// Daemon is the town-level background service.
type Daemon struct {
config *Config
tmux *tmux.Tmux
logger *log.Logger
ctx context.Context
cancel context.CancelFunc
}
// New creates a new daemon instance.
func New(config *Config) (*Daemon, error) {
// Ensure daemon directory exists
daemonDir := filepath.Dir(config.LogFile)
if err := os.MkdirAll(daemonDir, 0755); err != nil {
return nil, fmt.Errorf("creating daemon directory: %w", err)
}
// Open log file
logFile, err := os.OpenFile(config.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("opening log file: %w", err)
}
logger := log.New(logFile, "", log.LstdFlags)
ctx, cancel := context.WithCancel(context.Background())
return &Daemon{
config: config,
tmux: tmux.NewTmux(),
logger: logger,
ctx: ctx,
cancel: cancel,
}, nil
}
// Run starts the daemon main loop.
func (d *Daemon) Run() error {
d.logger.Printf("Daemon starting (PID %d)", os.Getpid())
// Write PID file
if err := os.WriteFile(d.config.PidFile, []byte(strconv.Itoa(os.Getpid())), 0644); err != nil {
return fmt.Errorf("writing PID file: %w", err)
}
defer os.Remove(d.config.PidFile)
// Update state
state := &State{
Running: true,
PID: os.Getpid(),
StartedAt: time.Now(),
}
if err := SaveState(d.config.TownRoot, state); err != nil {
d.logger.Printf("Warning: failed to save state: %v", err)
}
// Handle signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Heartbeat ticker
ticker := time.NewTicker(d.config.HeartbeatInterval)
defer ticker.Stop()
d.logger.Printf("Daemon running, heartbeat every %v", d.config.HeartbeatInterval)
// Initial heartbeat
d.heartbeat(state)
for {
select {
case <-d.ctx.Done():
d.logger.Println("Daemon context cancelled, shutting down")
return d.shutdown(state)
case sig := <-sigChan:
d.logger.Printf("Received signal %v, shutting down", sig)
return d.shutdown(state)
case <-ticker.C:
d.heartbeat(state)
}
}
}
// heartbeat performs one heartbeat cycle.
func (d *Daemon) heartbeat(state *State) {
d.logger.Println("Heartbeat starting")
// 1. Poke Mayor
d.pokeMayor()
// 2. Poke Witnesses (for each rig)
d.pokeWitnesses()
// 3. Process lifecycle requests
d.processLifecycleRequests()
// Update state
state.LastHeartbeat = time.Now()
state.HeartbeatCount++
if err := SaveState(d.config.TownRoot, state); err != nil {
d.logger.Printf("Warning: failed to save state: %v", err)
}
d.logger.Printf("Heartbeat complete (#%d)", state.HeartbeatCount)
}
// pokeMayor sends a heartbeat to the Mayor session.
func (d *Daemon) pokeMayor() {
const mayorSession = "gt-mayor"
running, err := d.tmux.HasSession(mayorSession)
if err != nil {
d.logger.Printf("Error checking Mayor session: %v", err)
return
}
if !running {
d.logger.Println("Mayor session not running, skipping poke")
return
}
// Send heartbeat message via tmux
msg := "HEARTBEAT: check your rigs"
if err := d.tmux.SendKeys(mayorSession, msg); err != nil {
d.logger.Printf("Error poking Mayor: %v", err)
return
}
d.logger.Println("Poked Mayor")
}
// pokeWitnesses sends heartbeats to all Witness sessions.
func (d *Daemon) pokeWitnesses() {
// Find all rigs by looking for witness sessions
// Session naming: gt-<rig>-witness
sessions, err := d.tmux.ListSessions()
if err != nil {
d.logger.Printf("Error listing sessions: %v", err)
return
}
for _, session := range sessions {
// Check if it's a witness session
if !isWitnessSession(session) {
continue
}
msg := "HEARTBEAT: check your workers"
if err := d.tmux.SendKeys(session, msg); err != nil {
d.logger.Printf("Error poking Witness %s: %v", session, err)
continue
}
d.logger.Printf("Poked Witness: %s", session)
}
}
// isWitnessSession checks if a session name is a witness session.
func isWitnessSession(name string) bool {
// Pattern: gt-<rig>-witness
if len(name) < 12 { // "gt-x-witness" minimum
return false
}
return name[:3] == "gt-" && name[len(name)-8:] == "-witness"
}
// processLifecycleRequests checks for and processes lifecycle requests.
func (d *Daemon) processLifecycleRequests() {
d.ProcessLifecycleRequests()
}
// shutdown performs graceful shutdown.
func (d *Daemon) shutdown(state *State) error {
d.logger.Println("Daemon shutting down")
state.Running = false
if err := SaveState(d.config.TownRoot, state); err != nil {
d.logger.Printf("Warning: failed to save final state: %v", err)
}
d.logger.Println("Daemon stopped")
return nil
}
// Stop signals the daemon to stop.
func (d *Daemon) Stop() {
d.cancel()
}
// IsRunning checks if a daemon is running for the given town.
func IsRunning(townRoot string) (bool, int, error) {
pidFile := filepath.Join(townRoot, "daemon", "daemon.pid")
data, err := os.ReadFile(pidFile)
if err != nil {
if os.IsNotExist(err) {
return false, 0, nil
}
return false, 0, err
}
pid, err := strconv.Atoi(string(data))
if err != nil {
return false, 0, nil
}
// Check if process is running
process, err := os.FindProcess(pid)
if err != nil {
return false, 0, nil
}
// On Unix, FindProcess always succeeds. Send signal 0 to check if alive.
err = process.Signal(syscall.Signal(0))
if err != nil {
// Process not running, clean up stale PID file
os.Remove(pidFile)
return false, 0, nil
}
return true, pid, nil
}
// StopDaemon stops the running daemon for the given town.
func StopDaemon(townRoot string) error {
running, pid, err := IsRunning(townRoot)
if err != nil {
return err
}
if !running {
return fmt.Errorf("daemon is not running")
}
process, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("finding process: %w", err)
}
// Send SIGTERM for graceful shutdown
if err := process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("sending SIGTERM: %w", err)
}
// Wait a bit for graceful shutdown
time.Sleep(500 * time.Millisecond)
// Check if still running
if err := process.Signal(syscall.Signal(0)); err == nil {
// Still running, force kill
process.Signal(syscall.SIGKILL)
}
// Clean up PID file
pidFile := filepath.Join(townRoot, "daemon", "daemon.pid")
os.Remove(pidFile)
return nil
}

View File

@@ -0,0 +1,202 @@
package daemon
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// BeadsMessage represents a message from beads mail.
type BeadsMessage struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Sender string `json:"sender"`
Assignee string `json:"assignee"`
Priority int `json:"priority"`
Status string `json:"status"`
}
// ProcessLifecycleRequests checks for and processes lifecycle requests from the daemon inbox.
func (d *Daemon) ProcessLifecycleRequests() {
// Get mail for daemon identity
cmd := exec.Command("bd", "mail", "inbox", "--identity", "daemon", "--json")
cmd.Dir = d.config.TownRoot
output, err := cmd.Output()
if err != nil {
// bd mail might not be available or inbox empty
return
}
if len(output) == 0 || string(output) == "[]" || string(output) == "[]\n" {
return
}
var messages []BeadsMessage
if err := json.Unmarshal(output, &messages); err != nil {
d.logger.Printf("Error parsing mail: %v", err)
return
}
for _, msg := range messages {
if msg.Status == "closed" {
continue // Already processed
}
request := d.parseLifecycleRequest(&msg)
if request == nil {
continue // Not a lifecycle request
}
d.logger.Printf("Processing lifecycle request from %s: %s", request.From, request.Action)
if err := d.executeLifecycleAction(request); err != nil {
d.logger.Printf("Error executing lifecycle action: %v", err)
continue
}
// Mark message as read (close the issue)
if err := d.closeMessage(msg.ID); err != nil {
d.logger.Printf("Warning: failed to close message %s: %v", msg.ID, err)
}
}
}
// parseLifecycleRequest extracts a lifecycle request from a message.
func (d *Daemon) parseLifecycleRequest(msg *BeadsMessage) *LifecycleRequest {
// Look for lifecycle keywords in subject/title
title := strings.ToLower(msg.Title)
var action LifecycleAction
if strings.Contains(title, "cycle") || strings.Contains(title, "cycling") {
action = ActionCycle
} else if strings.Contains(title, "restart") {
action = ActionRestart
} else if strings.Contains(title, "shutdown") || strings.Contains(title, "stop") {
action = ActionShutdown
} else {
// Not a lifecycle request
return nil
}
return &LifecycleRequest{
From: msg.Sender,
Action: action,
Timestamp: time.Now(),
}
}
// executeLifecycleAction performs the requested lifecycle action.
func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
// Determine session name from sender identity
sessionName := d.identityToSession(request.From)
if sessionName == "" {
return fmt.Errorf("unknown agent identity: %s", request.From)
}
d.logger.Printf("Executing %s for session %s", request.Action, sessionName)
// Check if session exists
running, err := d.tmux.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
switch request.Action {
case ActionShutdown:
if running {
if err := d.tmux.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
d.logger.Printf("Killed session %s", sessionName)
}
return nil
case ActionCycle, ActionRestart:
if running {
// Kill the session first
if err := d.tmux.KillSession(sessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
d.logger.Printf("Killed session %s for restart", sessionName)
// Wait a moment
time.Sleep(500 * time.Millisecond)
}
// Restart the session
if err := d.restartSession(sessionName, request.From); err != nil {
return fmt.Errorf("restarting session: %w", err)
}
d.logger.Printf("Restarted session %s", sessionName)
return nil
default:
return fmt.Errorf("unknown action: %s", request.Action)
}
}
// identityToSession converts a beads identity to a tmux session name.
func (d *Daemon) identityToSession(identity string) string {
// Handle known identities
switch identity {
case "mayor":
return "gt-mayor"
default:
// Pattern: <rig>-witness → gt-<rig>-witness
if strings.HasSuffix(identity, "-witness") {
return "gt-" + identity
}
// Unknown identity
return ""
}
}
// restartSession starts a new session for the given agent.
func (d *Daemon) restartSession(sessionName, identity string) error {
// Determine working directory and startup command based on agent type
var workDir, startCmd string
if identity == "mayor" {
workDir = d.config.TownRoot
startCmd = "exec claude --dangerously-skip-permissions"
} else if strings.HasSuffix(identity, "-witness") {
// Extract rig name: <rig>-witness → <rig>
rigName := strings.TrimSuffix(identity, "-witness")
workDir = d.config.TownRoot + "/" + rigName
startCmd = "exec claude --dangerously-skip-permissions"
} else {
return fmt.Errorf("don't know how to restart %s", identity)
}
// Create session
if err := d.tmux.NewSession(sessionName, workDir); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment
d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity)
// Send startup command
if err := d.tmux.SendKeys(sessionName, startCmd); err != nil {
return fmt.Errorf("sending startup command: %w", err)
}
// Prime after delay
if err := d.tmux.SendKeysDelayed(sessionName, "gt prime", 2000); err != nil {
d.logger.Printf("Warning: could not send prime: %v", err)
}
return nil
}
// closeMessage marks a mail message as read by closing the beads issue.
func (d *Daemon) closeMessage(id string) error {
cmd := exec.Command("bd", "close", id)
cmd.Dir = d.config.TownRoot
return cmd.Run()
}

126
internal/daemon/types.go Normal file
View File

@@ -0,0 +1,126 @@
// Package daemon provides the town-level background service for Gas Town.
//
// The daemon is a simple Go process (not a Claude agent) that:
// 1. Pokes agents periodically (heartbeat)
// 2. Processes lifecycle requests (cycle, restart, shutdown)
// 3. Restarts sessions when agents request cycling
//
// The daemon is a "dumb scheduler" - all intelligence is in agents.
package daemon
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// Config holds daemon configuration.
type Config struct {
// HeartbeatInterval is how often to poke agents.
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
// TownRoot is the Gas Town workspace root.
TownRoot string `json:"town_root"`
// LogFile is the path to the daemon log file.
LogFile string `json:"log_file"`
// PidFile is the path to the PID file.
PidFile string `json:"pid_file"`
}
// DefaultConfig returns the default daemon configuration.
func DefaultConfig(townRoot string) *Config {
daemonDir := filepath.Join(townRoot, "daemon")
return &Config{
HeartbeatInterval: 60 * time.Second,
TownRoot: townRoot,
LogFile: filepath.Join(daemonDir, "daemon.log"),
PidFile: filepath.Join(daemonDir, "daemon.pid"),
}
}
// State represents the daemon's runtime state.
type State struct {
// Running indicates if the daemon is running.
Running bool `json:"running"`
// PID is the process ID of the daemon.
PID int `json:"pid"`
// StartedAt is when the daemon started.
StartedAt time.Time `json:"started_at"`
// LastHeartbeat is when the last heartbeat completed.
LastHeartbeat time.Time `json:"last_heartbeat"`
// HeartbeatCount is how many heartbeats have completed.
HeartbeatCount int64 `json:"heartbeat_count"`
}
// StateFile returns the path to the state file.
func StateFile(townRoot string) string {
return filepath.Join(townRoot, "daemon", "state.json")
}
// LoadState loads daemon state from disk.
func LoadState(townRoot string) (*State, error) {
stateFile := StateFile(townRoot)
data, err := os.ReadFile(stateFile)
if err != nil {
if os.IsNotExist(err) {
return &State{}, nil
}
return nil, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
// SaveState saves daemon state to disk.
func SaveState(townRoot string, state *State) error {
stateFile := StateFile(townRoot)
// Ensure daemon directory exists
if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(stateFile, data, 0644)
}
// LifecycleAction represents a lifecycle request action.
type LifecycleAction string
const (
// ActionCycle restarts the session with handoff.
ActionCycle LifecycleAction = "cycle"
// ActionRestart does a fresh restart without handoff.
ActionRestart LifecycleAction = "restart"
// ActionShutdown terminates without restart.
ActionShutdown LifecycleAction = "shutdown"
)
// LifecycleRequest represents a request from an agent to the daemon.
type LifecycleRequest struct {
// From is the agent requesting the action (e.g., "mayor/", "gastown/witness").
From string `json:"from"`
// Action is what lifecycle action to perform.
Action LifecycleAction `json:"action"`
// Timestamp is when the request was made.
Timestamp time.Time `json:"timestamp"`
}