Files
gastown/internal/cmd/daemon.go
cheedo 49116f2deb feat(daemon): Add binary age detection to status command
Shows binary modification time in gt daemon status and warns when
the binary is newer than the running process, suggesting a restart.
This helps detect when bug fixes or new features aren't active because
the daemon is running old code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:53:13 -08:00

259 lines
6.5 KiB
Go

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",
GroupID: GroupServices,
Short: "Manage the Gas Town daemon",
RunE: requireSubcommand,
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)
}
// Check if binary is newer than process
if binaryModTime, err := getBinaryModTime(); err == nil {
fmt.Printf(" Binary: %s\n", binaryModTime.Format("2006-01-02 15:04:05"))
if binaryModTime.After(state.StartedAt) {
fmt.Printf(" %s Binary is newer than process - consider '%s'\n",
style.Bold.Render("⚠"),
style.Dim.Render("gt daemon stop && gt daemon start"))
}
}
}
} 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
}
// getBinaryModTime returns the modification time of the current executable
func getBinaryModTime() (time.Time, error) {
exePath, err := os.Executable()
if err != nil {
return time.Time{}, err
}
info, err := os.Stat(exePath)
if err != nil {
return time.Time{}, err
}
return info.ModTime(), 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()
}