diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 2a95bdb0..83c9aab2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/keepalive" ) var rootCmd = &cobra.Command{ @@ -17,16 +16,6 @@ var rootCmd = &cobra.Command{ It coordinates agent spawning, work distribution, and communication across distributed teams of AI agents working on shared codebases.`, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - // Signal agent activity by touching keepalive file - // Build command path: gt status, gt mail send, etc. - cmdPath := buildCommandPath(cmd) - keepalive.TouchWithArgs(cmdPath, args) - - // Also signal town-level activity for daemon exponential backoff - // This resets the backoff when any gt command runs - keepalive.TouchTownActivity(cmdPath) - }, } // Execute runs the root command and returns an exit code. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 497d7a6c..9aca3b6e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -17,7 +17,6 @@ import ( "github.com/steveyegge/gastown/internal/boot" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/feed" - "github.com/steveyegge/gastown/internal/keepalive" "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/tmux" ) @@ -85,13 +84,12 @@ func (d *Daemon) Run() error { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) - // Dynamic heartbeat timer with exponential backoff based on activity - // Start with base interval - nextInterval := d.config.HeartbeatInterval - timer := time.NewTimer(nextInterval) + // Fixed recovery-focused heartbeat (no activity-based backoff) + // Normal wake is handled by feed subscription (bd activity --follow) + timer := time.NewTimer(recoveryHeartbeatInterval) defer timer.Stop() - d.logger.Printf("Daemon running, initial heartbeat interval %v", nextInterval) + d.logger.Printf("Daemon running, recovery heartbeat interval %v", recoveryHeartbeatInterval) // Start feed curator goroutine d.curator = feed.NewCurator(d.config.TownRoot) @@ -123,62 +121,17 @@ func (d *Daemon) Run() error { case <-timer.C: d.heartbeat(state) - // Calculate next interval based on activity - nextInterval = d.calculateHeartbeatInterval() - timer.Reset(nextInterval) - d.logger.Printf("Next heartbeat in %v", nextInterval) + // Fixed recovery interval (no activity-based backoff) + timer.Reset(recoveryHeartbeatInterval) } } } -// Backoff thresholds for exponential slowdown when idle -const ( - // Base interval when there's recent activity - baseInterval = 5 * time.Minute - - // Tier thresholds for backoff - tier1Threshold = 5 * time.Minute // 0-5 min idle → 5 min interval - tier2Threshold = 15 * time.Minute // 5-15 min idle → 10 min interval - tier3Threshold = 45 * time.Minute // 15-45 min idle → 30 min interval - // 45+ min idle → 60 min interval (max) - - // Corresponding intervals - tier1Interval = 5 * time.Minute - tier2Interval = 10 * time.Minute - tier3Interval = 30 * time.Minute - tier4Interval = 60 * time.Minute // max -) - -// calculateHeartbeatInterval determines the next heartbeat interval based on activity. -// Reads ~/gt/daemon/activity.json to determine how long since the last gt/bd command. -// Returns exponentially increasing intervals as idle time grows. -// -// | Idle Duration | Next Heartbeat | -// |---------------|----------------| -// | 0-5 min | 5 min (base) | -// | 5-15 min | 10 min | -// | 15-45 min | 30 min | -// | 45+ min | 60 min (max) | -func (d *Daemon) calculateHeartbeatInterval() time.Duration { - activity := keepalive.ReadTownActivity() - if activity == nil { - // No activity file - assume recent activity (might be first run) - return baseInterval - } - - idleDuration := activity.Age() - - switch { - case idleDuration < tier1Threshold: - return tier1Interval - case idleDuration < tier2Threshold: - return tier2Interval - case idleDuration < tier3Threshold: - return tier3Interval - default: - return tier4Interval - } -} +// recoveryHeartbeatInterval is the fixed interval for recovery-focused daemon. +// Normal wake is handled by feed subscription (bd activity --follow). +// The daemon is a safety net for dead sessions, GUPP violations, and orphaned work. +// 10 minutes is long enough to avoid unnecessary overhead, short enough to catch issues. +const recoveryHeartbeatInterval = 10 * time.Minute // heartbeat performs one heartbeat cycle. // The daemon is recovery-focused: it ensures agents are running and detects failures. diff --git a/internal/keepalive/keepalive.go b/internal/keepalive/keepalive.go index a9bec16f..69d79716 100644 --- a/internal/keepalive/keepalive.go +++ b/internal/keepalive/keepalive.go @@ -75,69 +75,6 @@ func TouchInWorkspace(workspaceRoot, command string) { _ = os.WriteFile(keepalivePath, data, 0644) // non-fatal: status file for debugging } -// TouchTownActivity writes a town-level activity signal to ~/gt/daemon/activity.json. -// This is used by the daemon to implement exponential backoff when the town is idle. -// Any gt command activity resets the backoff to the base heartbeat interval. -// It silently ignores errors (best-effort signaling). -func TouchTownActivity(command string) { - // Get town root from GT_TOWN_ROOT or default to ~/gt - townRoot := os.Getenv("GT_TOWN_ROOT") - if townRoot == "" { - home, err := os.UserHomeDir() - if err != nil { - return - } - townRoot = filepath.Join(home, "gt") - } - - daemonDir := filepath.Join(townRoot, "daemon") - - // Ensure daemon directory exists - if err := os.MkdirAll(daemonDir, 0755); err != nil { - return - } - - state := State{ - LastCommand: command, - Timestamp: time.Now().UTC(), - } - - data, err := json.Marshal(state) - if err != nil { - return - } - - activityPath := filepath.Join(daemonDir, "activity.json") - _ = os.WriteFile(activityPath, data, 0644) // non-fatal: activity signal for daemon -} - -// ReadTownActivity returns the current town-level activity state. -// Returns nil if the file doesn't exist or can't be read. -func ReadTownActivity() *State { - townRoot := os.Getenv("GT_TOWN_ROOT") - if townRoot == "" { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - townRoot = filepath.Join(home, "gt") - } - - activityPath := filepath.Join(townRoot, "daemon", "activity.json") - - data, err := os.ReadFile(activityPath) - if err != nil { - return nil - } - - var state State - if err := json.Unmarshal(data, &state); err != nil { - return nil - } - - return &state -} - // Read returns the current keepalive state for the workspace. // Returns nil if the file doesn't exist or can't be read. func Read(workspaceRoot string) *State {