Files
gastown/internal/polecat/namepool_test.go
Steve Yegge 4868a09e8e feat(polecat): add bounded name pooling for polecats (gt-frs)
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 <noreply@anthropic.com>
2025-12-19 16:29:51 -08:00

316 lines
7.1 KiB
Go

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