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:
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