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>
316 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|