Files
gastown/internal/polecat/namepool.go
Advaya Krishna 3afd1a1dcd fix(polecat): exclude reserved infrastructure agent names from allocator (#837)
The polecat name allocator was assigning reserved infrastructure agent
names like 'witness' to polecats. Added ReservedInfraAgentNames map
containing witness, mayor, deacon, and refinery. Modified getNames()
to filter these from all themes and custom name lists.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:30:53 -08:00

438 lines
12 KiB
Go

package polecat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"github.com/steveyegge/gastown/internal/util"
)
const (
// DefaultPoolSize is the number of name slots in the pool.
// NOTE: This is a pool of NAMES, not polecats. Polecats are spawned fresh
// for each task and nuked when done - there is no idle pool of polecats.
// Only the name slots are reused when a polecat is nuked and a new one spawned.
DefaultPoolSize = 50
// DefaultTheme is the default theme for new rigs.
DefaultTheme = "mad-max"
)
// ReservedInfraAgentNames contains names reserved for infrastructure agents.
// These names must never be allocated to polecats.
var ReservedInfraAgentNames = map[string]bool{
"witness": true,
"mayor": true,
"deacon": true,
"refinery": true,
}
// Built-in themes with themed polecat names.
var BuiltinThemes = map[string][]string{
"mad-max": {
"furiosa", "nux", "slit", "rictus", "dementus",
"capable", "toast", "dag", "cheedo", "valkyrie",
"keeper", "morsov", "ace", "warboy", "imperator",
"organic", "coma", "splendid", "angharad", "max",
"immortan", "bullet", "toecutter", "goose", "nightrider",
"glory", "scrotus", "chumbucket", "corpus", "dinki",
"prime", "vuvalini", "rockryder", "wretched", "buzzard",
"gastown", "bullet-farmer", "citadel", "wasteland", "fury",
"road-warrior", "interceptor", "blackfinger", "wraith", "witness",
"chrome", "shiny", "mediocre", "guzzoline", "aqua-cola",
},
"minerals": {
"obsidian", "quartz", "jasper", "onyx", "opal",
"topaz", "garnet", "ruby", "amber", "jade",
"pearl", "flint", "granite", "basalt", "marble",
"shale", "slate", "pyrite", "mica", "agate",
"malachite", "turquoise", "lapis", "emerald", "sapphire",
"diamond", "amethyst", "citrine", "zircon", "peridot",
"coral", "jet", "moonstone", "sunstone", "bloodstone",
"rhodonite", "sodalite", "hematite", "magnetite", "calcite",
"fluorite", "selenite", "kyanite", "labradorite", "amazonite",
"chalcedony", "carnelian", "aventurine", "chrysoprase", "heliodor",
},
"wasteland": {
"rust", "chrome", "nitro", "guzzle", "witness",
"shiny", "fury", "thunder", "dust", "scavenger",
"radrat", "ghoul", "mutant", "raider", "vault",
"pipboy", "nuka", "brahmin", "deathclaw", "mirelurk",
"synth", "institute", "enclave", "brotherhood", "minuteman",
"railroad", "atom", "crater", "foundation", "refuge",
"settler", "wanderer", "courier", "lone", "chosen",
"tribal", "khan", "legion", "ncr", "ranger",
"overseer", "sentinel", "paladin", "scribe", "initiate",
"elder", "lancer", "knight", "squire", "proctor",
},
}
// NamePool manages a bounded pool of reusable polecat NAME SLOTS.
// IMPORTANT: This pools NAMES, not polecats. Polecats are spawned fresh for each
// task and nuked when done - there is no idle pool of polecat instances waiting
// for work. When a polecat is nuked, its name slot becomes available for the next
// freshly-spawned polecat.
//
// Names are drawn from a themed pool (mad-max by default).
// 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"`
// Theme is the current theme name (e.g., "mad-max", "minerals").
Theme string `json:"theme"`
// CustomNames allows overriding the built-in theme names.
CustomNames []string `json:"custom_names,omitempty"`
// InUse tracks which pool names are currently in use.
// Key is the name itself, value is true if in use.
// ZFC: This is transient state derived from filesystem via Reconcile().
// Never persist - always discover from existing polecat directories.
InUse map[string]bool `json:"-"`
// OverflowNext is the next overflow sequence number.
// Starts at MaxSize+1 and increments.
OverflowNext int `json:"overflow_next"`
// MaxSize is the maximum number of themed names before overflow.
MaxSize int `json:"max_size"`
// 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,
Theme: ThemeForRig(rigName),
InUse: make(map[string]bool),
OverflowNext: DefaultPoolSize + 1,
MaxSize: DefaultPoolSize,
stateFile: filepath.Join(rigPath, ".runtime", "namepool-state.json"),
}
}
// NewNamePoolWithConfig creates a name pool with specific configuration.
func NewNamePoolWithConfig(rigPath, rigName, theme string, customNames []string, maxSize int) *NamePool {
if theme == "" {
theme = DefaultTheme
}
if maxSize <= 0 {
maxSize = DefaultPoolSize
}
return &NamePool{
RigName: rigName,
Theme: theme,
CustomNames: customNames,
InUse: make(map[string]bool),
OverflowNext: maxSize + 1,
MaxSize: maxSize,
stateFile: filepath.Join(rigPath, ".runtime", "namepool-state.json"),
}
}
// getNames returns the list of names to use for the pool.
// Reserved infrastructure agent names are filtered out.
func (p *NamePool) getNames() []string {
var names []string
// Custom names take precedence
if len(p.CustomNames) > 0 {
names = p.CustomNames
} else if themeNames, ok := BuiltinThemes[p.Theme]; ok {
// Look up built-in theme
names = themeNames
} else {
// Fall back to default theme
names = BuiltinThemes[DefaultTheme]
}
// Filter out reserved infrastructure agent names
return filterReservedNames(names)
}
// filterReservedNames removes reserved infrastructure agent names from a name list.
func filterReservedNames(names []string) []string {
filtered := make([]string, 0, len(names))
for _, name := range names {
if !ReservedInfraAgentNames[name] {
filtered = append(filtered, name)
}
}
return filtered
}
// 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[string]bool)
p.OverflowNext = p.MaxSize + 1
return nil
}
return err
}
// Load only runtime state - Theme and CustomNames come from settings/config.json.
// ZFC: InUse is NEVER loaded from disk - it's transient state derived
// from filesystem via Reconcile(). Always start with empty map.
var loaded namePoolState
if err := json.Unmarshal(data, &loaded); err != nil {
return err
}
p.InUse = make(map[string]bool)
p.OverflowNext = loaded.OverflowNext
if p.OverflowNext < p.MaxSize+1 {
p.OverflowNext = p.MaxSize + 1
}
if loaded.MaxSize > 0 {
p.MaxSize = loaded.MaxSize
}
return nil
}
// namePoolState is the subset of NamePool that is persisted to the state file.
// Only runtime state is saved, not configuration (Theme, CustomNames come from settings).
type namePoolState struct {
RigName string `json:"rig_name"`
OverflowNext int `json:"overflow_next"`
MaxSize int `json:"max_size"`
}
// Save persists the pool state to disk using atomic write.
// Only runtime state (OverflowNext, MaxSize) is saved - configuration like
// Theme and CustomNames come from settings/config.json and are not persisted here.
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
}
// Only save runtime state, not configuration
state := namePoolState{
RigName: p.RigName,
OverflowNext: p.OverflowNext,
MaxSize: p.MaxSize,
}
return util.AtomicWriteJSON(p.stateFile, state)
}
// Allocate returns a name from the pool.
// It prefers names in order from the theme list, and falls back to overflow names
// when the pool is exhausted.
func (p *NamePool) Allocate() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
names := p.getNames()
// Try to find first available name from the theme
for i := 0; i < len(names) && i < p.MaxSize; i++ {
name := names[i]
if !p.InUse[name] {
p.InUse[name] = true
return name, nil
}
}
// Pool exhausted, use overflow naming
name := p.formatOverflowName(p.OverflowNext)
p.OverflowNext++
return name, nil
}
// Release returns a name slot to the available pool.
// Called when a polecat is nuked - the name becomes available for new polecats.
// NOTE: This releases the NAME, not the polecat. The polecat is gone (nuked).
// 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()
// Check if it's a themed name
if p.isThemedName(name) {
delete(p.InUse, name)
}
// Overflow names are not reusable, so we don't track them
}
// isThemedName checks if a name is in the theme pool.
func (p *NamePool) isThemedName(name string) bool {
names := p.getNames()
for _, n := range names {
if n == name {
return true
}
}
return false
}
// IsPoolName returns true if the name is a pool name (themed or numbered).
func (p *NamePool) IsPoolName(name string) bool {
return p.isThemedName(name)
}
// 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 name := range p.InUse {
names = append(names, name)
}
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()
if p.isThemedName(name) {
p.InUse[name] = 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[string]bool)
// Mark all existing polecats as in use
for _, name := range existingPolecats {
if p.isThemedName(name) {
p.InUse[name] = true
}
}
}
// formatOverflowName formats an overflow sequence number as a name.
func (p *NamePool) formatOverflowName(seq int) string {
return fmt.Sprintf("%s-%d", p.RigName, seq)
}
// GetTheme returns the current theme name.
func (p *NamePool) GetTheme() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.Theme
}
// SetTheme sets the theme and resets the pool.
// Existing in-use names are preserved if they exist in the new theme.
func (p *NamePool) SetTheme(theme string) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := BuiltinThemes[theme]; !ok {
return fmt.Errorf("unknown theme: %s (available: mad-max, minerals, wasteland)", theme)
}
// Preserve names that exist in both themes
newNames := BuiltinThemes[theme]
newInUse := make(map[string]bool)
for name := range p.InUse {
for _, n := range newNames {
if n == name {
newInUse[name] = true
break
}
}
}
p.Theme = theme
p.InUse = newInUse
p.CustomNames = nil
return nil
}
// ListThemes returns the list of available built-in themes.
func ListThemes() []string {
themes := make([]string, 0, len(BuiltinThemes))
for theme := range BuiltinThemes {
themes = append(themes, theme)
}
sort.Strings(themes)
return themes
}
// ThemeForRig returns a deterministic theme for a rig based on its name.
// This provides variety across rigs without requiring manual configuration.
func ThemeForRig(rigName string) string {
themes := ListThemes()
if len(themes) == 0 {
return DefaultTheme
}
// Hash using prime multiplier for better distribution
var hash uint32
for _, b := range []byte(rigName) {
hash = hash*31 + uint32(b)
}
return themes[hash%uint32(len(themes))] //nolint:gosec // len(themes) is small constant
}
// GetThemeNames returns the names in a specific theme.
func GetThemeNames(theme string) ([]string, error) {
if names, ok := BuiltinThemes[theme]; ok {
return names, nil
}
return nil, fmt.Errorf("unknown theme: %s", theme)
}
// AddCustomName adds a custom name to the pool.
func (p *NamePool) AddCustomName(name string) {
p.mu.Lock()
defer p.mu.Unlock()
// Check if already in custom names
for _, n := range p.CustomNames {
if n == name {
return
}
}
p.CustomNames = append(p.CustomNames, name)
}
// Reset clears the pool state, releasing all names.
func (p *NamePool) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.InUse = make(map[string]bool)
p.OverflowNext = p.MaxSize + 1
}