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:
233
internal/cmd/daemon.go
Normal file
233
internal/cmd/daemon.go
Normal 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
274
internal/daemon/daemon.go
Normal 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
|
||||||
|
}
|
||||||
202
internal/daemon/lifecycle.go
Normal file
202
internal/daemon/lifecycle.go
Normal 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
126
internal/daemon/types.go
Normal 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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user