From ddf5b5a9f4e3964fb14ef01da472924a07dfdd26 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 10:35:34 -0800 Subject: [PATCH] Add Dog infrastructure package for Deacon helper workers (gt-0x5og.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogs are reusable workers managed by the Deacon for cross-rig infrastructure tasks. Unlike polecats (single-rig, ephemeral), dogs have worktrees into multiple rigs and persist between tasks. Key components: - internal/dog/types.go: Dog struct, State enum, DogState JSON schema - internal/dog/manager.go: Manager with Add/Remove/List/Get/Refresh operations - internal/dog/manager_test.go: Unit tests Features: - Multi-rig worktrees: Each dog gets a worktree per configured rig - State tracking: .dog.json with idle/working state, last-active, work assignment - Worktree refresh: Recreate stale worktrees with fresh branches - Branch cleanup: Remove orphaned dog branches across all rigs Directory structure: ~/gt/deacon/dogs// - / (worktree into each rig: gastown/, beads/, etc.) - .dog.json (state file) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/dog/manager.go | 562 +++++++++++++++++++++++++++++++++++ internal/dog/manager_test.go | 104 +++++++ internal/dog/types.go | 40 +++ 3 files changed, 706 insertions(+) create mode 100644 internal/dog/manager.go create mode 100644 internal/dog/manager_test.go create mode 100644 internal/dog/types.go diff --git a/internal/dog/manager.go b/internal/dog/manager.go new file mode 100644 index 00000000..11a3d187 --- /dev/null +++ b/internal/dog/manager.go @@ -0,0 +1,562 @@ +package dog + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/git" +) + +// Common errors +var ( + ErrDogExists = errors.New("dog already exists") + ErrDogNotFound = errors.New("dog not found") + ErrNoRigs = errors.New("no rigs configured") +) + +// Manager handles dog lifecycle in the kennel. +type Manager struct { + townRoot string + kennelPath string // ~/gt/deacon/dogs/ + rigsConfig *config.RigsConfig +} + +// NewManager creates a new dog manager. +func NewManager(townRoot string, rigsConfig *config.RigsConfig) *Manager { + return &Manager{ + townRoot: townRoot, + kennelPath: filepath.Join(townRoot, "deacon", "dogs"), + rigsConfig: rigsConfig, + } +} + +// dogDir returns the directory for a dog. +func (m *Manager) dogDir(name string) string { + return filepath.Join(m.kennelPath, name) +} + +// exists checks if a dog exists. +func (m *Manager) exists(name string) bool { + _, err := os.Stat(m.dogDir(name)) + return err == nil +} + +// stateFilePath returns the path to a dog's state file. +func (m *Manager) stateFilePath(name string) string { + return filepath.Join(m.dogDir(name), ".dog.json") +} + +// Add creates a new dog in the kennel with worktrees into each rig. +// Each dog gets a worktree per rig (e.g., dogs/alpha/gastown/, dogs/alpha/beads/). +// Worktrees are created from each rig's bare repo (.repo.git) or mayor/rig. +func (m *Manager) Add(name string) (*Dog, error) { + if m.exists(name) { + return nil, ErrDogExists + } + + // Verify we have rigs to create worktrees into + if len(m.rigsConfig.Rigs) == 0 { + return nil, ErrNoRigs + } + + dogPath := m.dogDir(name) + + // Create kennel dir if needed + if err := os.MkdirAll(m.kennelPath, 0755); err != nil { + return nil, fmt.Errorf("creating kennel dir: %w", err) + } + + // Create dog directory + if err := os.MkdirAll(dogPath, 0755); err != nil { + return nil, fmt.Errorf("creating dog dir: %w", err) + } + + // Track cleanup on failure + cleanup := func() { _ = os.RemoveAll(dogPath) } + success := false + defer func() { + if !success { + cleanup() + } + }() + + // Create worktrees into each rig + worktrees := make(map[string]string) + for rigName := range m.rigsConfig.Rigs { + worktreePath, err := m.createRigWorktree(dogPath, name, rigName) + if err != nil { + return nil, fmt.Errorf("creating worktree for rig %s: %w", rigName, err) + } + worktrees[rigName] = worktreePath + } + + // Create initial state file + now := time.Now() + state := &DogState{ + Name: name, + State: StateIdle, + LastActive: now, + Worktrees: worktrees, + CreatedAt: now, + UpdatedAt: now, + } + + if err := m.saveState(name, state); err != nil { + return nil, fmt.Errorf("saving state: %w", err) + } + + success = true + return &Dog{ + Name: name, + State: StateIdle, + Path: dogPath, + Worktrees: worktrees, + LastActive: now, + CreatedAt: now, + }, nil +} + +// createRigWorktree creates a worktree for a dog into a specific rig. +// Uses the rig's bare repo (.repo.git) if available, otherwise mayor/rig. +// Branch naming: dog/-- for uniqueness. +func (m *Manager) createRigWorktree(dogPath, dogName, rigName string) (string, error) { + rigPath := filepath.Join(m.townRoot, rigName) + worktreePath := filepath.Join(dogPath, rigName) + + // Find the repo base (bare repo or mayor/rig) + repoGit, err := m.findRepoBase(rigPath) + if err != nil { + return "", fmt.Errorf("finding repo base for %s: %w", rigName, err) + } + + // Unique branch per dog-rig combination + branchName := fmt.Sprintf("dog/%s-%s-%d", dogName, rigName, time.Now().UnixMilli()) + + // Create worktree with new branch + if err := repoGit.WorktreeAdd(worktreePath, branchName); err != nil { + return "", fmt.Errorf("creating worktree: %w", err) + } + + return worktreePath, nil +} + +// findRepoBase locates the git repo base for a rig. +// Prefers .repo.git (bare repo), falls back to mayor/rig. +func (m *Manager) findRepoBase(rigPath string) (*git.Git, error) { + // Check for shared bare repo + bareRepoPath := filepath.Join(rigPath, ".repo.git") + if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() { + return git.NewGitWithDir(bareRepoPath, ""), nil + } + + // Fall back to mayor/rig + mayorPath := filepath.Join(rigPath, "mayor", "rig") + if _, err := os.Stat(mayorPath); os.IsNotExist(err) { + return nil, fmt.Errorf("no repo base found (neither .repo.git nor mayor/rig exists)") + } + return git.NewGit(mayorPath), nil +} + +// Remove deletes a dog from the kennel. +// Removes all worktrees and the dog directory. +func (m *Manager) Remove(name string) error { + if !m.exists(name) { + return ErrDogNotFound + } + + dogPath := m.dogDir(name) + + // Load state to get worktree paths + state, err := m.loadState(name) + if err != nil { + // State file may be missing, proceed with cleanup + state = &DogState{Worktrees: make(map[string]string)} + } + + // Remove worktrees from each rig + for rigName, worktreePath := range state.Worktrees { + rigPath := filepath.Join(m.townRoot, rigName) + repoGit, err := m.findRepoBase(rigPath) + if err != nil { + // Log but continue with other rigs + fmt.Printf("Warning: could not find repo base for %s: %v\n", rigName, err) + continue + } + + // Try to remove worktree properly + if err := repoGit.WorktreeRemove(worktreePath, true); err != nil { + // Log but continue - will remove directory below + fmt.Printf("Warning: could not remove worktree %s: %v\n", worktreePath, err) + } + + // Prune stale entries + _ = repoGit.WorktreePrune() + } + + // Remove dog directory + if err := os.RemoveAll(dogPath); err != nil { + return fmt.Errorf("removing dog dir: %w", err) + } + + return nil +} + +// List returns all dogs in the kennel. +func (m *Manager) List() ([]*Dog, error) { + entries, err := os.ReadDir(m.kennelPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading kennel: %w", err) + } + + var dogs []*Dog + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + dog, err := m.Get(entry.Name()) + if err != nil { + continue // Skip invalid dogs + } + dogs = append(dogs, dog) + } + + return dogs, nil +} + +// Get returns a specific dog by name. +func (m *Manager) Get(name string) (*Dog, error) { + if !m.exists(name) { + return nil, ErrDogNotFound + } + + state, err := m.loadState(name) + if err != nil { + // Return minimal dog if state file is missing + return &Dog{ + Name: name, + State: StateIdle, + Path: m.dogDir(name), + }, nil + } + + return &Dog{ + Name: name, + State: state.State, + Path: m.dogDir(name), + Worktrees: state.Worktrees, + LastActive: state.LastActive, + Work: state.Work, + CreatedAt: state.CreatedAt, + }, nil +} + +// SetState updates a dog's state and last-active timestamp. +func (m *Manager) SetState(name string, state State) error { + if !m.exists(name) { + return ErrDogNotFound + } + + dogState, err := m.loadState(name) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + dogState.State = state + dogState.LastActive = time.Now() + dogState.UpdatedAt = time.Now() + + return m.saveState(name, dogState) +} + +// AssignWork assigns work to a dog and sets it to working state. +func (m *Manager) AssignWork(name, work string) error { + if !m.exists(name) { + return ErrDogNotFound + } + + state, err := m.loadState(name) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + state.State = StateWorking + state.Work = work + state.LastActive = time.Now() + state.UpdatedAt = time.Now() + + return m.saveState(name, state) +} + +// ClearWork clears a dog's work assignment and sets it to idle. +func (m *Manager) ClearWork(name string) error { + if !m.exists(name) { + return ErrDogNotFound + } + + state, err := m.loadState(name) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + state.State = StateIdle + state.Work = "" + state.LastActive = time.Now() + state.UpdatedAt = time.Now() + + return m.saveState(name, state) +} + +// Refresh recreates all worktrees for a dog with fresh branches. +// This is useful when worktrees have drifted or become stale. +func (m *Manager) Refresh(name string) error { + if !m.exists(name) { + return ErrDogNotFound + } + + state, err := m.loadState(name) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + dogPath := m.dogDir(name) + newWorktrees := make(map[string]string) + + // Recreate each worktree + for rigName := range m.rigsConfig.Rigs { + rigPath := filepath.Join(m.townRoot, rigName) + oldWorktreePath := state.Worktrees[rigName] + + // Find repo base + repoGit, err := m.findRepoBase(rigPath) + if err != nil { + return fmt.Errorf("finding repo base for %s: %w", rigName, err) + } + + // Remove old worktree if it exists + if oldWorktreePath != "" { + _ = repoGit.WorktreeRemove(oldWorktreePath, true) + _ = os.RemoveAll(oldWorktreePath) + _ = repoGit.WorktreePrune() + } + + // Fetch latest from origin + _ = repoGit.Fetch("origin") + + // Create fresh worktree + worktreePath, err := m.createRigWorktree(dogPath, name, rigName) + if err != nil { + return fmt.Errorf("creating worktree for %s: %w", rigName, err) + } + newWorktrees[rigName] = worktreePath + } + + // Update state + state.Worktrees = newWorktrees + state.LastActive = time.Now() + state.UpdatedAt = time.Now() + + return m.saveState(name, state) +} + +// RefreshRig recreates the worktree for a specific rig. +func (m *Manager) RefreshRig(name, rigName string) error { + if !m.exists(name) { + return ErrDogNotFound + } + + if _, ok := m.rigsConfig.Rigs[rigName]; !ok { + return fmt.Errorf("rig %s not found in config", rigName) + } + + state, err := m.loadState(name) + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + dogPath := m.dogDir(name) + rigPath := filepath.Join(m.townRoot, rigName) + oldWorktreePath := state.Worktrees[rigName] + + // Find repo base + repoGit, err := m.findRepoBase(rigPath) + if err != nil { + return fmt.Errorf("finding repo base: %w", err) + } + + // Remove old worktree if it exists + if oldWorktreePath != "" { + _ = repoGit.WorktreeRemove(oldWorktreePath, true) + _ = os.RemoveAll(oldWorktreePath) + _ = repoGit.WorktreePrune() + } + + // Fetch latest + _ = repoGit.Fetch("origin") + + // Create fresh worktree + worktreePath, err := m.createRigWorktree(dogPath, name, rigName) + if err != nil { + return fmt.Errorf("creating worktree: %w", err) + } + + // Update state + state.Worktrees[rigName] = worktreePath + state.LastActive = time.Now() + state.UpdatedAt = time.Now() + + return m.saveState(name, state) +} + +// CleanupStaleBranches removes orphaned dog branches from all rigs. +// Returns total branches deleted across all rigs. +func (m *Manager) CleanupStaleBranches() (int, error) { + totalDeleted := 0 + + for rigName := range m.rigsConfig.Rigs { + rigPath := filepath.Join(m.townRoot, rigName) + repoGit, err := m.findRepoBase(rigPath) + if err != nil { + continue + } + + deleted, err := m.cleanupStaleBranchesForRig(repoGit, rigName) + if err != nil { + fmt.Printf("Warning: cleanup failed for rig %s: %v\n", rigName, err) + continue + } + totalDeleted += deleted + } + + return totalDeleted, nil +} + +// cleanupStaleBranchesForRig removes orphaned dog branches in a specific rig. +func (m *Manager) cleanupStaleBranchesForRig(repoGit *git.Git, rigName string) (int, error) { + // List all dog branches + branches, err := repoGit.ListBranches("dog/*") + if err != nil { + return 0, err + } + + if len(branches) == 0 { + return 0, nil + } + + // Get list of current dogs + dogs, err := m.List() + if err != nil { + return 0, err + } + + // Build set of current dog branches for this rig + currentBranches := make(map[string]bool) + for _, dog := range dogs { + if dog.Worktrees != nil { + if worktreePath, ok := dog.Worktrees[rigName]; ok { + // Get branch name for this worktree + worktreeGit := git.NewGit(worktreePath) + if branch, err := worktreeGit.CurrentBranch(); err == nil { + currentBranches[branch] = true + } + } + } + } + + // Delete orphaned branches + deleted := 0 + for _, branch := range branches { + if currentBranches[branch] { + continue + } + if err := repoGit.DeleteBranch(branch, true); err != nil { + fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err) + continue + } + deleted++ + } + + return deleted, nil +} + +// loadState loads a dog's state from .dog.json. +func (m *Manager) loadState(name string) (*DogState, error) { + data, err := os.ReadFile(m.stateFilePath(name)) + if err != nil { + return nil, err + } + + var state DogState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// saveState saves a dog's state to .dog.json. +func (m *Manager) saveState(name string, state *DogState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(m.stateFilePath(name), data, 0644) +} + +// GetIdleDog returns an idle dog suitable for work assignment. +// Returns nil if no idle dogs are available. +func (m *Manager) GetIdleDog() (*Dog, error) { + dogs, err := m.List() + if err != nil { + return nil, err + } + + for _, dog := range dogs { + if dog.State == StateIdle { + return dog, nil + } + } + + return nil, nil // No idle dogs +} + +// IdleCount returns the number of idle dogs. +func (m *Manager) IdleCount() (int, error) { + dogs, err := m.List() + if err != nil { + return 0, err + } + + count := 0 + for _, dog := range dogs { + if dog.State == StateIdle { + count++ + } + } + return count, nil +} + +// WorkingCount returns the number of working dogs. +func (m *Manager) WorkingCount() (int, error) { + dogs, err := m.List() + if err != nil { + return 0, err + } + + count := 0 + for _, dog := range dogs { + if dog.State == StateWorking { + count++ + } + } + return count, nil +} diff --git a/internal/dog/manager_test.go b/internal/dog/manager_test.go new file mode 100644 index 00000000..531756b1 --- /dev/null +++ b/internal/dog/manager_test.go @@ -0,0 +1,104 @@ +package dog + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/gastown/internal/config" +) + +// TestDogStateJSON verifies DogState JSON serialization. +func TestDogStateJSON(t *testing.T) { + now := time.Now() + state := &DogState{ + Name: "alpha", + State: StateIdle, + LastActive: now, + Work: "", + Worktrees: map[string]string{ + "gastown": "/path/to/gastown", + "beads": "/path/to/beads", + }, + CreatedAt: now, + UpdatedAt: now, + } + + // Create temp file + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, ".dog.json") + + // Write and read back + data, err := os.ReadFile(statePath) + if err == nil { + t.Logf("Data already exists: %s", data) + } + + // Test state values + if state.Name != "alpha" { + t.Errorf("expected name 'alpha', got %q", state.Name) + } + if state.State != StateIdle { + t.Errorf("expected state 'idle', got %q", state.State) + } + if len(state.Worktrees) != 2 { + t.Errorf("expected 2 worktrees, got %d", len(state.Worktrees)) + } +} + +// TestManagerCreation verifies Manager initialization. +func TestManagerCreation(t *testing.T) { + rigsConfig := &config.RigsConfig{ + Version: 1, + Rigs: map[string]config.RigEntry{ + "gastown": { + GitURL: "git@github.com:test/gastown.git", + }, + "beads": { + GitURL: "git@github.com:test/beads.git", + }, + }, + } + + m := NewManager("/tmp/test-town", rigsConfig) + + if m.townRoot != "/tmp/test-town" { + t.Errorf("expected townRoot '/tmp/test-town', got %q", m.townRoot) + } + if m.kennelPath != "/tmp/test-town/deacon/dogs" { + t.Errorf("expected kennelPath '/tmp/test-town/deacon/dogs', got %q", m.kennelPath) + } +} + +// TestDogDir verifies dogDir path construction. +func TestDogDir(t *testing.T) { + rigsConfig := &config.RigsConfig{ + Version: 1, + Rigs: map[string]config.RigEntry{}, + } + m := NewManager("/home/user/gt", rigsConfig) + + path := m.dogDir("alpha") + expected := "/home/user/gt/deacon/dogs/alpha" + if path != expected { + t.Errorf("expected %q, got %q", expected, path) + } +} + +// TestStateConstants verifies state constants. +func TestStateConstants(t *testing.T) { + tests := []struct { + state State + expected string + }{ + {StateIdle, "idle"}, + {StateWorking, "working"}, + } + + for _, tc := range tests { + if string(tc.state) != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, string(tc.state)) + } + } +} diff --git a/internal/dog/types.go b/internal/dog/types.go new file mode 100644 index 00000000..e124e19e --- /dev/null +++ b/internal/dog/types.go @@ -0,0 +1,40 @@ +// Package dog manages Dogs - Deacon's helper workers for infrastructure tasks. +// Dogs are reusable workers with multi-rig worktrees, managed by the Deacon. +// Unlike polecats (single-rig, ephemeral), dogs handle cross-rig infrastructure work. +package dog + +import ( + "time" +) + +// State represents a dog's operational state. +type State string + +const ( + // StateIdle means the dog is available for work. + StateIdle State = "idle" + // StateWorking means the dog is executing a task. + StateWorking State = "working" +) + +// Dog represents a Deacon helper worker. +type Dog struct { + Name string // Dog name (e.g., "alpha") + State State // Current state + Path string // Path to kennel dir (~/gt/deacon/dogs/) + Worktrees map[string]string // Rig name -> worktree path + LastActive time.Time // Last activity timestamp + Work string // Current work assignment (bead ID or molecule) + CreatedAt time.Time // When dog was added to kennel +} + +// DogState is the persistent state stored in .dog.json. +type DogState struct { + Name string `json:"name"` + State State `json:"state"` + LastActive time.Time `json:"last_active"` + Work string `json:"work,omitempty"` // Current work assignment + Worktrees map[string]string `json:"worktrees,omitempty"` // Rig -> path (for verification) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +}