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()
|
||||
}
|
||||
Reference in New Issue
Block a user