From 1265df70f98d655c576bb9e942c69e3154feb100 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 13:06:20 -0800 Subject: [PATCH] feat: add gt daemon for town-level background service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cmd/daemon.go | 233 +++++++++++++++++++++++++++++ internal/daemon/daemon.go | 274 +++++++++++++++++++++++++++++++++++ internal/daemon/lifecycle.go | 202 ++++++++++++++++++++++++++ internal/daemon/types.go | 126 ++++++++++++++++ 4 files changed, 835 insertions(+) create mode 100644 internal/cmd/daemon.go create mode 100644 internal/daemon/daemon.go create mode 100644 internal/daemon/lifecycle.go create mode 100644 internal/daemon/types.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go new file mode 100644 index 00000000..1bc71ac9 --- /dev/null +++ b/internal/cmd/daemon.go @@ -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() +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 00000000..7db2e662 --- /dev/null +++ b/internal/daemon/daemon.go @@ -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--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--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 +} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go new file mode 100644 index 00000000..9df41b56 --- /dev/null +++ b/internal/daemon/lifecycle.go @@ -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: -witness → gt--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: -witness → + 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() +} diff --git a/internal/daemon/types.go b/internal/daemon/types.go new file mode 100644 index 00000000..2b0d9068 --- /dev/null +++ b/internal/daemon/types.go @@ -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"` +}