From 4868a09e8ec077f3ee0c0a8f295b59a0e014b747 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 16:29:51 -0800 Subject: [PATCH] feat(polecat): add bounded name pooling for polecats (gt-frs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement reusable name pool for polecat workers: - Pool of 50 names (polecat-01 through polecat-50) - Prefers lower-numbered slots for allocation - Overflow uses rigname-N format when pool exhausted - Pool names are reusable, overflow names are not - State persisted to .gastown/namepool.json Changes: - Add NamePool type with Allocate/Release/Reconcile - Integrate with polecat.Manager (auto-loads/saves) - Update gt spawn to use AllocateName() from pool - Remove legacy polecatNames list and generatePolecatName() - Add comprehensive tests for name pooling Benefits: - Tmux sessions survive polecat restarts (same name) - Users can stay attached and see work continue - Bounded resource usage for common case - Scales beyond 50 with overflow naming 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/spawn.go | 48 +---- internal/polecat/manager.go | 71 ++++++- internal/polecat/namepool.go | 217 ++++++++++++++++++++ internal/polecat/namepool_test.go | 315 ++++++++++++++++++++++++++++++ 4 files changed, 603 insertions(+), 48 deletions(-) create mode 100644 internal/polecat/namepool.go create mode 100644 internal/polecat/namepool_test.go diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 4811db81..96395900 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "math/rand" "os/exec" "path/filepath" "strings" @@ -22,13 +21,6 @@ import ( "github.com/steveyegge/gastown/internal/workspace" ) -// polecatNames are Mad Max: Fury Road themed names for auto-generated polecats. -var polecatNames = []string{ - "Nux", "Toast", "Capable", "Cheedo", "Dag", "Rictus", "Slit", "Morsov", - "Ace", "Coma", "Valkyrie", "Keeper", "Vuvalini", "Organic", "Immortan", - "Corpus", "Doof", "Scabrous", "Splendid", "Fragile", -} - // Spawn command flags var ( spawnIssue string @@ -150,10 +142,13 @@ func runSpawn(cmd *cobra.Command, args []string) error { if polecatName == "" { polecatName, err = selectIdlePolecat(polecatMgr, r) if err != nil { - // If --create is set, generate a new polecat name instead of failing + // If --create is set, allocate a name from the pool if spawnCreate { - polecatName = generatePolecatName(polecatMgr) - fmt.Printf("Generated polecat name: %s\n", polecatName) + polecatName, err = polecatMgr.AllocateName() + if err != nil { + return fmt.Errorf("allocating polecat name: %w", err) + } + fmt.Printf("Allocated polecat name: %s\n", polecatName) } else { return fmt.Errorf("auto-select polecat: %w", err) } @@ -336,37 +331,6 @@ func parseSpawnAddress(addr string) (rigName, polecatName string, err error) { return addr, "", nil } -// generatePolecatName generates a unique polecat name that doesn't conflict with existing ones. -func generatePolecatName(mgr *polecat.Manager) string { - existing, _ := mgr.List() - existingNames := make(map[string]bool) - for _, p := range existing { - existingNames[p.Name] = true - } - - // Try to find an unused name from the list - // Shuffle to avoid always picking the same name - shuffled := make([]string, len(polecatNames)) - copy(shuffled, polecatNames) - rand.Shuffle(len(shuffled), func(i, j int) { - shuffled[i], shuffled[j] = shuffled[j], shuffled[i] - }) - - for _, name := range shuffled { - if !existingNames[name] { - return name - } - } - - // All names taken, generate one with a number suffix - base := shuffled[0] - for i := 2; ; i++ { - name := fmt.Sprintf("%s%d", base, i) - if !existingNames[name] { - return name - } - } -} // selectIdlePolecat finds an idle polecat in the rig. func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) { diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 45f6b9b8..bcc4ce62 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -21,19 +21,26 @@ var ( // Manager handles polecat lifecycle. type Manager struct { - rig *rig.Rig - git *git.Git - beads *beads.Beads + rig *rig.Rig + git *git.Git + beads *beads.Beads + namePool *NamePool } // NewManager creates a new polecat manager. func NewManager(r *rig.Rig, g *git.Git) *Manager { // Use the mayor's rig directory for beads operations (rig-level beads) mayorRigPath := filepath.Join(r.Path, "mayor", "rig") + + // Initialize name pool + pool := NewNamePool(r.Path, r.Name) + _ = pool.Load() // Load existing state, ignore errors for new rigs + return &Manager{ - rig: r, - git: g, - beads: beads.New(mayorRigPath), + rig: r, + git: g, + beads: beads.New(mayorRigPath), + namePool: pool, } } @@ -150,9 +157,61 @@ func (m *Manager) Remove(name string, force bool) error { // Prune any stale worktree entries _ = mayorGit.WorktreePrune() + // Release name back to pool if it's a pooled name + m.namePool.Release(name) + _ = m.namePool.Save() + return nil } +// AllocateName allocates a name from the name pool. +// Returns a pooled name (polecat-01 through polecat-50) if available, +// otherwise returns an overflow name (rigname-N). +func (m *Manager) AllocateName() (string, error) { + // First reconcile pool with existing polecats to handle stale state + m.ReconcilePool() + + name, err := m.namePool.Allocate() + if err != nil { + return "", err + } + + if err := m.namePool.Save(); err != nil { + return "", fmt.Errorf("saving pool state: %w", err) + } + + return name, nil +} + +// ReleaseName releases a name back to the pool. +// This is called when a polecat is removed. +func (m *Manager) ReleaseName(name string) { + m.namePool.Release(name) + _ = m.namePool.Save() +} + +// ReconcilePool syncs pool state with existing polecat directories. +// This should be called to recover from crashes or stale state. +func (m *Manager) ReconcilePool() { + polecats, err := m.List() + if err != nil { + return + } + + var names []string + for _, p := range polecats { + names = append(names, p.Name) + } + + m.namePool.Reconcile(names) + _ = m.namePool.Save() +} + +// PoolStatus returns information about the name pool. +func (m *Manager) PoolStatus() (active int, names []string) { + return m.namePool.ActiveCount(), m.namePool.ActiveNames() +} + // List returns all polecats in the rig. func (m *Manager) List() ([]*Polecat, error) { polecatsDir := filepath.Join(m.rig.Path, "polecats") diff --git a/internal/polecat/namepool.go b/internal/polecat/namepool.go new file mode 100644 index 00000000..77df26b6 --- /dev/null +++ b/internal/polecat/namepool.go @@ -0,0 +1,217 @@ +package polecat + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" +) + +const ( + // PoolSize is the number of reusable names in the pool. + PoolSize = 50 + + // NamePrefix is the prefix for pooled polecat names. + NamePrefix = "polecat-" +) + +// NamePool manages a bounded pool of reusable polecat names. +// Names in the pool are polecat-01 through polecat-50. +// When the pool is exhausted, overflow names use rigname-N format. +type NamePool struct { + mu sync.RWMutex + + // RigName is the rig this pool belongs to. + RigName string `json:"rig_name"` + + // InUse tracks which pool indices are currently in use. + // Key is the pool index (1-50), value is true if in use. + InUse map[int]bool `json:"in_use"` + + // OverflowNext is the next overflow sequence number. + // Starts at PoolSize+1 (51) and increments. + OverflowNext int `json:"overflow_next"` + + // stateFile is the path to persist pool state. + stateFile string +} + +// NewNamePool creates a new name pool for a rig. +func NewNamePool(rigPath, rigName string) *NamePool { + return &NamePool{ + RigName: rigName, + InUse: make(map[int]bool), + OverflowNext: PoolSize + 1, + stateFile: filepath.Join(rigPath, ".gastown", "namepool.json"), + } +} + +// Load loads the pool state from disk. +func (p *NamePool) Load() error { + p.mu.Lock() + defer p.mu.Unlock() + + data, err := os.ReadFile(p.stateFile) + if err != nil { + if os.IsNotExist(err) { + // Initialize with empty state + p.InUse = make(map[int]bool) + p.OverflowNext = PoolSize + 1 + return nil + } + return err + } + + var loaded NamePool + if err := json.Unmarshal(data, &loaded); err != nil { + return err + } + + p.InUse = loaded.InUse + if p.InUse == nil { + p.InUse = make(map[int]bool) + } + p.OverflowNext = loaded.OverflowNext + if p.OverflowNext < PoolSize+1 { + p.OverflowNext = PoolSize + 1 + } + + return nil +} + +// Save persists the pool state to disk. +func (p *NamePool) Save() error { + p.mu.RLock() + defer p.mu.RUnlock() + + dir := filepath.Dir(p.stateFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + + return os.WriteFile(p.stateFile, data, 0644) +} + +// Allocate returns a name from the pool. +// It prefers lower-numbered pool slots, and falls back to overflow names +// when the pool is exhausted. +func (p *NamePool) Allocate() (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + // Try to find first available slot in pool (prefer low numbers) + for i := 1; i <= PoolSize; i++ { + if !p.InUse[i] { + p.InUse[i] = true + return p.formatPoolName(i), nil + } + } + + // Pool exhausted, use overflow naming + name := p.formatOverflowName(p.OverflowNext) + p.OverflowNext++ + return name, nil +} + +// Release returns a pooled name to the pool. +// For overflow names, this is a no-op (they are not reusable). +func (p *NamePool) Release(name string) { + p.mu.Lock() + defer p.mu.Unlock() + + idx := p.parsePoolIndex(name) + if idx > 0 && idx <= PoolSize { + delete(p.InUse, idx) + } + // Overflow names are not reusable, so we don't track them +} + +// IsPoolName returns true if the name is a pool name (polecat-NN format). +func (p *NamePool) IsPoolName(name string) bool { + idx := p.parsePoolIndex(name) + return idx > 0 && idx <= PoolSize +} + +// ActiveCount returns the number of names currently in use from the pool. +func (p *NamePool) ActiveCount() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.InUse) +} + +// ActiveNames returns a sorted list of names currently in use from the pool. +func (p *NamePool) ActiveNames() []string { + p.mu.RLock() + defer p.mu.RUnlock() + + var names []string + for idx := range p.InUse { + names = append(names, p.formatPoolName(idx)) + } + sort.Strings(names) + return names +} + +// MarkInUse marks a name as in use (for reconciling with existing polecats). +func (p *NamePool) MarkInUse(name string) { + p.mu.Lock() + defer p.mu.Unlock() + + idx := p.parsePoolIndex(name) + if idx > 0 && idx <= PoolSize { + p.InUse[idx] = true + } +} + +// Reconcile updates the pool state based on existing polecat directories. +// This should be called on startup to sync pool state with reality. +func (p *NamePool) Reconcile(existingPolecats []string) { + p.mu.Lock() + defer p.mu.Unlock() + + // Clear current state + p.InUse = make(map[int]bool) + + // Mark all existing polecats as in use + for _, name := range existingPolecats { + idx := p.parsePoolIndex(name) + if idx > 0 && idx <= PoolSize { + p.InUse[idx] = true + } + } +} + +// formatPoolName formats a pool index as a name. +func (p *NamePool) formatPoolName(idx int) string { + return fmt.Sprintf("%s%02d", NamePrefix, idx) +} + +// formatOverflowName formats an overflow sequence number as a name. +func (p *NamePool) formatOverflowName(seq int) string { + return fmt.Sprintf("%s-%d", p.RigName, seq) +} + +// parsePoolIndex extracts the pool index from a pool name. +// Returns 0 if not a valid pool name. +func (p *NamePool) parsePoolIndex(name string) int { + if len(name) < len(NamePrefix)+2 { + return 0 + } + if name[:len(NamePrefix)] != NamePrefix { + return 0 + } + + var idx int + _, err := fmt.Sscanf(name[len(NamePrefix):], "%d", &idx) + if err != nil { + return 0 + } + return idx +} diff --git a/internal/polecat/namepool_test.go b/internal/polecat/namepool_test.go new file mode 100644 index 00000000..c37a4866 --- /dev/null +++ b/internal/polecat/namepool_test.go @@ -0,0 +1,315 @@ +package polecat + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNamePool_Allocate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + // First allocation should be polecat-01 + name, err := pool.Allocate() + if err != nil { + t.Fatalf("Allocate error: %v", err) + } + if name != "polecat-01" { + t.Errorf("expected polecat-01, got %s", name) + } + + // Second allocation should be polecat-02 + name, err = pool.Allocate() + if err != nil { + t.Fatalf("Allocate error: %v", err) + } + if name != "polecat-02" { + t.Errorf("expected polecat-02, got %s", name) + } +} + +func TestNamePool_Release(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + // Allocate first two + name1, _ := pool.Allocate() + name2, _ := pool.Allocate() + + if name1 != "polecat-01" || name2 != "polecat-02" { + t.Fatalf("unexpected allocations: %s, %s", name1, name2) + } + + // Release first one + pool.Release("polecat-01") + + // Next allocation should reuse polecat-01 + name, _ := pool.Allocate() + if name != "polecat-01" { + t.Errorf("expected polecat-01 to be reused, got %s", name) + } +} + +func TestNamePool_PrefersLowNumbers(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + // Allocate first 5 + for i := 0; i < 5; i++ { + pool.Allocate() + } + + // Release 03 and 01 + pool.Release("polecat-03") + pool.Release("polecat-01") + + // Next allocation should be 01 (lowest available) + name, _ := pool.Allocate() + if name != "polecat-01" { + t.Errorf("expected polecat-01 (lowest), got %s", name) + } + + // Next should be 03 + name, _ = pool.Allocate() + if name != "polecat-03" { + t.Errorf("expected polecat-03, got %s", name) + } +} + +func TestNamePool_Overflow(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "gastown") + + // Exhaust the pool + for i := 0; i < PoolSize; i++ { + pool.Allocate() + } + + // Next allocation should be overflow format + name, err := pool.Allocate() + if err != nil { + t.Fatalf("Allocate error: %v", err) + } + expected := "gastown-51" + if name != expected { + t.Errorf("expected overflow name %s, got %s", expected, name) + } + + // Next overflow + name, _ = pool.Allocate() + if name != "gastown-52" { + t.Errorf("expected gastown-52, got %s", name) + } +} + +func TestNamePool_OverflowNotReusable(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "gastown") + + // Exhaust the pool + for i := 0; i < PoolSize; i++ { + pool.Allocate() + } + + // Get overflow name + overflow1, _ := pool.Allocate() + if overflow1 != "gastown-51" { + t.Fatalf("expected gastown-51, got %s", overflow1) + } + + // Release it - should not be reused + pool.Release(overflow1) + + // Next allocation should be gastown-52, not gastown-51 + name, _ := pool.Allocate() + if name != "gastown-52" { + t.Errorf("expected gastown-52 (overflow increments), got %s", name) + } +} + +func TestNamePool_SaveLoad(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + // Allocate some names + pool.Allocate() // 01 + pool.Allocate() // 02 + pool.Allocate() // 03 + pool.Release("polecat-02") + + // Save state + if err := pool.Save(); err != nil { + t.Fatalf("Save error: %v", err) + } + + // Create new pool and load + pool2 := NewNamePool(tmpDir, "testrig") + if err := pool2.Load(); err != nil { + t.Fatalf("Load error: %v", err) + } + + // Should have 01 and 03 in use + if pool2.ActiveCount() != 2 { + t.Errorf("expected 2 active, got %d", pool2.ActiveCount()) + } + + // Next allocation should be 02 (released slot) + name, _ := pool2.Allocate() + if name != "polecat-02" { + t.Errorf("expected polecat-02, got %s", name) + } +} + +func TestNamePool_Reconcile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + // Simulate existing polecats from filesystem + existing := []string{"polecat-03", "polecat-07", "some-other-name"} + + pool.Reconcile(existing) + + if pool.ActiveCount() != 2 { + t.Errorf("expected 2 active after reconcile, got %d", pool.ActiveCount()) + } + + // Should allocate 01 first (not 03 or 07) + name, _ := pool.Allocate() + if name != "polecat-01" { + t.Errorf("expected polecat-01, got %s", name) + } +} + +func TestNamePool_IsPoolName(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + tests := []struct { + name string + expected bool + }{ + {"polecat-01", true}, + {"polecat-50", true}, + {"polecat-51", false}, // > PoolSize + {"gastown-51", false}, // overflow format + {"Nux", false}, // legacy name + {"polecat-", false}, // invalid + {"polecat-abc", false}, + } + + for _, tc := range tests { + result := pool.IsPoolName(tc.name) + if result != tc.expected { + t.Errorf("IsPoolName(%q) = %v, expected %v", tc.name, result, tc.expected) + } + } +} + +func TestNamePool_ActiveNames(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + pool.Allocate() // 01 + pool.Allocate() // 02 + pool.Allocate() // 03 + pool.Release("polecat-02") + + names := pool.ActiveNames() + if len(names) != 2 { + t.Errorf("expected 2 active names, got %d", len(names)) + } + if names[0] != "polecat-01" || names[1] != "polecat-03" { + t.Errorf("expected [polecat-01, polecat-03], got %v", names) + } +} + +func TestNamePool_MarkInUse(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + + // Mark some slots as in use + pool.MarkInUse("polecat-05") + pool.MarkInUse("polecat-10") + + // Allocate should skip those + name, _ := pool.Allocate() + if name != "polecat-01" { + t.Errorf("expected polecat-01, got %s", name) + } + + // Mark more and verify count + if pool.ActiveCount() != 3 { // 01, 05, 10 + t.Errorf("expected 3 active, got %d", pool.ActiveCount()) + } +} + +func TestNamePool_StateFilePath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "namepool-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + pool := NewNamePool(tmpDir, "testrig") + pool.Allocate() + if err := pool.Save(); err != nil { + t.Fatalf("Save error: %v", err) + } + + // Verify file was created in expected location + expectedPath := filepath.Join(tmpDir, ".gastown", "namepool.json") + if _, err := os.Stat(expectedPath); err != nil { + t.Errorf("state file not found at expected path: %v", err) + } +}