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:
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,13 +21,6 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"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
|
// Spawn command flags
|
||||||
var (
|
var (
|
||||||
spawnIssue string
|
spawnIssue string
|
||||||
@@ -150,10 +142,13 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
if polecatName == "" {
|
if polecatName == "" {
|
||||||
polecatName, err = selectIdlePolecat(polecatMgr, r)
|
polecatName, err = selectIdlePolecat(polecatMgr, r)
|
||||||
if err != nil {
|
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 {
|
if spawnCreate {
|
||||||
polecatName = generatePolecatName(polecatMgr)
|
polecatName, err = polecatMgr.AllocateName()
|
||||||
fmt.Printf("Generated polecat name: %s\n", polecatName)
|
if err != nil {
|
||||||
|
return fmt.Errorf("allocating polecat name: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Allocated polecat name: %s\n", polecatName)
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("auto-select polecat: %w", err)
|
return fmt.Errorf("auto-select polecat: %w", err)
|
||||||
}
|
}
|
||||||
@@ -336,37 +331,6 @@ func parseSpawnAddress(addr string) (rigName, polecatName string, err error) {
|
|||||||
return addr, "", nil
|
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.
|
// selectIdlePolecat finds an idle polecat in the rig.
|
||||||
func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) {
|
func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) {
|
||||||
|
|||||||
@@ -21,19 +21,26 @@ var (
|
|||||||
|
|
||||||
// Manager handles polecat lifecycle.
|
// Manager handles polecat lifecycle.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
rig *rig.Rig
|
rig *rig.Rig
|
||||||
git *git.Git
|
git *git.Git
|
||||||
beads *beads.Beads
|
beads *beads.Beads
|
||||||
|
namePool *NamePool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new polecat manager.
|
// NewManager creates a new polecat manager.
|
||||||
func NewManager(r *rig.Rig, g *git.Git) *Manager {
|
func NewManager(r *rig.Rig, g *git.Git) *Manager {
|
||||||
// Use the mayor's rig directory for beads operations (rig-level beads)
|
// Use the mayor's rig directory for beads operations (rig-level beads)
|
||||||
mayorRigPath := filepath.Join(r.Path, "mayor", "rig")
|
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{
|
return &Manager{
|
||||||
rig: r,
|
rig: r,
|
||||||
git: g,
|
git: g,
|
||||||
beads: beads.New(mayorRigPath),
|
beads: beads.New(mayorRigPath),
|
||||||
|
namePool: pool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +157,61 @@ func (m *Manager) Remove(name string, force bool) error {
|
|||||||
// Prune any stale worktree entries
|
// Prune any stale worktree entries
|
||||||
_ = mayorGit.WorktreePrune()
|
_ = mayorGit.WorktreePrune()
|
||||||
|
|
||||||
|
// Release name back to pool if it's a pooled name
|
||||||
|
m.namePool.Release(name)
|
||||||
|
_ = m.namePool.Save()
|
||||||
|
|
||||||
return nil
|
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.
|
// List returns all polecats in the rig.
|
||||||
func (m *Manager) List() ([]*Polecat, error) {
|
func (m *Manager) List() ([]*Polecat, error) {
|
||||||
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
||||||
|
|||||||
217
internal/polecat/namepool.go
Normal file
217
internal/polecat/namepool.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
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