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