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:
202
internal/daemon/lifecycle.go
Normal file
202
internal/daemon/lifecycle.go
Normal file
@@ -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: <rig>-witness → gt-<rig>-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: <rig>-witness → <rig>
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user