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>
This commit is contained in:
315
internal/polecat/namepool_test.go
Normal file
315
internal/polecat/namepool_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user