Add syscall.Flock() exclusive lock in daemon.Run() to prevent TOCTOU race condition where concurrent 'gt daemon start' commands could spawn multiple daemons. Only the first to acquire the lock succeeds; others exit cleanly. Lock is per-town (in townRoot/daemon/daemon.lock) so multiple GT instances from different directories work independently. Also detect race losers in runDaemonStart() by comparing spawned PID with PID file, reporting 'already running' instead of false success.
268 lines
7.0 KiB
Go
268 lines
7.0 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 and acquire the lock
|
|
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')")
|
|
}
|
|
|
|
// Check if our spawned process is the one that won the race.
|
|
// If another concurrent start won, our process would have exited after
|
|
// failing to acquire the lock, and the PID file would have a different PID.
|
|
if pid != daemonCmd.Process.Pid {
|
|
// Another daemon won the race - that's fine, report it
|
|
fmt.Printf("%s Daemon already running (PID %d)\n", style.Bold.Render("●"), pid)
|
|
return nil
|
|
}
|
|
|
|
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()
|
|
}
|