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

233
internal/cmd/daemon.go Normal file
View File

@@ -0,0 +1,233 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Manage the Gas Town daemon",
Long: `Manage the Gas Town background daemon.
The daemon is a simple Go process that:
- Pokes agents periodically (heartbeat)
- Processes lifecycle requests (cycle, restart, shutdown)
- Restarts sessions when agents request cycling
The daemon is a "dumb scheduler" - all intelligence is in agents.`,
}
var daemonStartCmd = &cobra.Command{
Use: "start",
Short: "Start the daemon",
Long: `Start the Gas Town daemon in the background.
The daemon will run until stopped with 'gt daemon stop'.`,
RunE: runDaemonStart,
}
var daemonStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the daemon",
Long: `Stop the running Gas Town daemon.`,
RunE: runDaemonStop,
}
var daemonStatusCmd = &cobra.Command{
Use: "status",
Short: "Show daemon status",
Long: `Show the current status of the Gas Town daemon.`,
RunE: runDaemonStatus,
}
var daemonLogsCmd = &cobra.Command{
Use: "logs",
Short: "View daemon logs",
Long: `View the daemon log file.`,
RunE: runDaemonLogs,
}
var daemonRunCmd = &cobra.Command{
Use: "run",
Short: "Run daemon in foreground (internal)",
Hidden: true,
RunE: runDaemonRun,
}
var (
daemonLogLines int
daemonLogFollow bool
)
func init() {
daemonCmd.AddCommand(daemonStartCmd)
daemonCmd.AddCommand(daemonStopCmd)
daemonCmd.AddCommand(daemonStatusCmd)
daemonCmd.AddCommand(daemonLogsCmd)
daemonCmd.AddCommand(daemonRunCmd)
daemonLogsCmd.Flags().IntVarP(&daemonLogLines, "lines", "n", 50, "Number of lines to show")
daemonLogsCmd.Flags().BoolVarP(&daemonLogFollow, "follow", "f", false, "Follow log output")
rootCmd.AddCommand(daemonCmd)
}
func runDaemonStart(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Check if already running
running, pid, err := daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if running {
return fmt.Errorf("daemon already running (PID %d)", pid)
}
// Start daemon in background
// We use 'gt daemon run' as the actual daemon process
gtPath, err := os.Executable()
if err != nil {
return fmt.Errorf("finding executable: %w", err)
}
daemonCmd := exec.Command(gtPath, "daemon", "run")
daemonCmd.Dir = townRoot
// Detach from terminal
daemonCmd.Stdin = nil
daemonCmd.Stdout = nil
daemonCmd.Stderr = nil
if err := daemonCmd.Start(); err != nil {
return fmt.Errorf("starting daemon: %w", err)
}
// Wait a moment for the daemon to initialize
time.Sleep(200 * time.Millisecond)
// Verify it started
running, pid, err = daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if !running {
return fmt.Errorf("daemon failed to start (check logs with 'gt daemon logs')")
}
fmt.Printf("%s Daemon started (PID %d)\n", style.Bold.Render("✓"), pid)
return nil
}
func runDaemonStop(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
running, pid, err := daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if !running {
return fmt.Errorf("daemon is not running")
}
if err := daemon.StopDaemon(townRoot); err != nil {
return fmt.Errorf("stopping daemon: %w", err)
}
fmt.Printf("%s Daemon stopped (was PID %d)\n", style.Bold.Render("✓"), pid)
return nil
}
func runDaemonStatus(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
running, pid, err := daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if running {
fmt.Printf("%s Daemon is %s (PID %d)\n",
style.Bold.Render("●"),
style.Bold.Render("running"),
pid)
// Load state for more details
state, err := daemon.LoadState(townRoot)
if err == nil && !state.StartedAt.IsZero() {
fmt.Printf(" Started: %s\n", state.StartedAt.Format("2006-01-02 15:04:05"))
if !state.LastHeartbeat.IsZero() {
fmt.Printf(" Last heartbeat: %s (#%d)\n",
state.LastHeartbeat.Format("15:04:05"),
state.HeartbeatCount)
}
}
} else {
fmt.Printf("%s Daemon is %s\n",
style.Dim.Render("○"),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt daemon start"))
}
return nil
}
func runDaemonLogs(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
logFile := filepath.Join(townRoot, "daemon", "daemon.log")
if _, err := os.Stat(logFile); os.IsNotExist(err) {
return fmt.Errorf("no log file found at %s", logFile)
}
if daemonLogFollow {
// Use tail -f for following
tailCmd := exec.Command("tail", "-f", logFile)
tailCmd.Stdout = os.Stdout
tailCmd.Stderr = os.Stderr
return tailCmd.Run()
}
// Use tail -n for last N lines
tailCmd := exec.Command("tail", "-n", fmt.Sprintf("%d", daemonLogLines), logFile)
tailCmd.Stdout = os.Stdout
tailCmd.Stderr = os.Stderr
return tailCmd.Run()
}
func runDaemonRun(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
config := daemon.DefaultConfig(townRoot)
d, err := daemon.New(config)
if err != nil {
return fmt.Errorf("creating daemon: %w", err)
}
return d.Run()
}

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"`
}