From 62bd8739d60bee99c435e8c57a756a73099fb206 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 20:03:37 -0800 Subject: [PATCH 1/2] feat: keepalive signal from gt commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gt command now touches .gastown/keepalive.json with the last command and timestamp. This enables smarter daemon backoff: - Fresh (< 2 min): agent is working, skip heartbeat - Stale (2-5 min): might be thinking, gentle poke - Very stale (> 5 min): likely idle, safe to interrupt Uses PersistentPreRun hook to capture all commands including subcommands. Closes gt-bfd 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/root.go | 18 +++++ internal/keepalive/keepalive.go | 111 +++++++++++++++++++++++++++ internal/keepalive/keepalive_test.go | 97 +++++++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 internal/keepalive/keepalive.go create mode 100644 internal/keepalive/keepalive_test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6e603444..9d4bad9c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -3,8 +3,10 @@ package cmd import ( "os" + "strings" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/keepalive" ) var rootCmd = &cobra.Command{ @@ -14,6 +16,12 @@ 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) + }, } // Execute runs the root command @@ -27,3 +35,13 @@ func init() { // Global flags can be added here // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file") } + +// buildCommandPath walks the command hierarchy to build the full command path. +// For example: "gt mail send", "gt status", etc. +func buildCommandPath(cmd *cobra.Command) string { + var parts []string + for c := cmd; c != nil; c = c.Parent() { + parts = append([]string{c.Name()}, parts...) + } + return strings.Join(parts, " ") +} diff --git a/internal/keepalive/keepalive.go b/internal/keepalive/keepalive.go new file mode 100644 index 00000000..6ff4a5de --- /dev/null +++ b/internal/keepalive/keepalive.go @@ -0,0 +1,111 @@ +// Package keepalive provides agent activity signaling via file touch. +package keepalive + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/steveyegge/gastown/internal/workspace" +) + +// State represents the keepalive file contents. +type State struct { + LastCommand string `json:"last_command"` + Timestamp time.Time `json:"timestamp"` +} + +// Touch updates the keepalive file in the workspace's .gastown directory. +// It silently ignores errors (best-effort signaling). +func Touch(command string) { + TouchWithArgs(command, nil) +} + +// TouchWithArgs updates the keepalive file with the full command including args. +// It silently ignores errors (best-effort signaling). +func TouchWithArgs(command string, args []string) { + root, err := workspace.FindFromCwd() + if err != nil || root == "" { + return // Not in a workspace, nothing to do + } + + // Build full command string + fullCmd := command + if len(args) > 0 { + fullCmd = command + " " + strings.Join(args, " ") + } + + TouchInWorkspace(root, fullCmd) +} + +// TouchInWorkspace updates the keepalive file in a specific workspace. +// It silently ignores errors (best-effort signaling). +func TouchInWorkspace(workspaceRoot, command string) { + gastown := filepath.Join(workspaceRoot, ".gastown") + + // Ensure .gastown directory exists + if err := os.MkdirAll(gastown, 0755); err != nil { + return + } + + state := State{ + LastCommand: command, + Timestamp: time.Now().UTC(), + } + + data, err := json.Marshal(state) + if err != nil { + return + } + + keepalivePath := filepath.Join(gastown, "keepalive.json") + _ = os.WriteFile(keepalivePath, data, 0644) +} + +// 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 { + keepalivePath := filepath.Join(workspaceRoot, ".gastown", "keepalive.json") + + data, err := os.ReadFile(keepalivePath) + if err != nil { + return nil + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + + return &state +} + +// Age returns how old the keepalive signal is. +// Returns a very large duration if the state is nil. +func (s *State) Age() time.Duration { + if s == nil { + return 24 * time.Hour * 365 // Very stale + } + return time.Since(s.Timestamp) +} + +// IsFresh returns true if the keepalive is less than 2 minutes old. +func (s *State) IsFresh() bool { + return s != nil && s.Age() < 2*time.Minute +} + +// IsStale returns true if the keepalive is 2-5 minutes old. +func (s *State) IsStale() bool { + if s == nil { + return false + } + age := s.Age() + return age >= 2*time.Minute && age < 5*time.Minute +} + +// IsVeryStale returns true if the keepalive is more than 5 minutes old. +func (s *State) IsVeryStale() bool { + return s == nil || s.Age() >= 5*time.Minute +} diff --git a/internal/keepalive/keepalive_test.go b/internal/keepalive/keepalive_test.go new file mode 100644 index 00000000..10e3e7ad --- /dev/null +++ b/internal/keepalive/keepalive_test.go @@ -0,0 +1,97 @@ +package keepalive + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestTouchInWorkspace(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + // Touch the keepalive + TouchInWorkspace(tmpDir, "gt status") + + // Read back + state := Read(tmpDir) + if state == nil { + t.Fatal("expected state to be non-nil") + } + + if state.LastCommand != "gt status" { + t.Errorf("expected last_command 'gt status', got %q", state.LastCommand) + } + + // Check timestamp is recent + if time.Since(state.Timestamp) > time.Minute { + t.Errorf("timestamp too old: %v", state.Timestamp) + } +} + +func TestReadNonExistent(t *testing.T) { + tmpDir := t.TempDir() + state := Read(tmpDir) + if state != nil { + t.Error("expected nil state for non-existent file") + } +} + +func TestStateAge(t *testing.T) { + // Test nil state + var nilState *State + if nilState.Age() < 24*time.Hour { + t.Error("nil state should have very large age") + } + + // Test fresh state + freshState := &State{Timestamp: time.Now().Add(-30 * time.Second)} + if !freshState.IsFresh() { + t.Error("30-second-old state should be fresh") + } + if freshState.IsStale() { + t.Error("30-second-old state should not be stale") + } + if freshState.IsVeryStale() { + t.Error("30-second-old state should not be very stale") + } + + // Test stale state (3 minutes) + staleState := &State{Timestamp: time.Now().Add(-3 * time.Minute)} + if staleState.IsFresh() { + t.Error("3-minute-old state should not be fresh") + } + if !staleState.IsStale() { + t.Error("3-minute-old state should be stale") + } + if staleState.IsVeryStale() { + t.Error("3-minute-old state should not be very stale") + } + + // Test very stale state (10 minutes) + veryStaleState := &State{Timestamp: time.Now().Add(-10 * time.Minute)} + if veryStaleState.IsFresh() { + t.Error("10-minute-old state should not be fresh") + } + if veryStaleState.IsStale() { + t.Error("10-minute-old state should not be stale (it's very stale)") + } + if !veryStaleState.IsVeryStale() { + t.Error("10-minute-old state should be very stale") + } +} + +func TestDirectoryCreation(t *testing.T) { + tmpDir := t.TempDir() + workDir := filepath.Join(tmpDir, "some", "nested", "workspace") + + // Touch should create .gastown directory + TouchInWorkspace(workDir, "gt test") + + // Verify directory was created + gastown := filepath.Join(workDir, ".gastown") + if _, err := os.Stat(gastown); os.IsNotExist(err) { + t.Error("expected .gastown directory to be created") + } +} From 93b57b9acb371f2cbb1de5a58bbaad76edd0b268 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 20:25:44 -0800 Subject: [PATCH 2/2] chore: ignore state.json in .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8e117c6a..815a7622 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ coverage.out config.toml !config.example.toml gt + +# Runtime state +state.json