Add Dog infrastructure package for Deacon helper workers (gt-0x5og.2)

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/<name>/
  - <rig>/ (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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-30 10:35:34 -08:00
parent 5204dd0eb4
commit ddf5b5a9f4
3 changed files with 706 additions and 0 deletions

562
internal/dog/manager.go Normal file
View File

@@ -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/<dog-name>-<rig>-<timestamp> 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
}

View File

@@ -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))
}
}
}

40
internal/dog/types.go Normal file
View File

@@ -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/<name>)
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"`
}