From f0192c8b3d021375135bc917d876ed0919f66583 Mon Sep 17 00:00:00 2001 From: gus Date: Mon, 12 Jan 2026 23:10:11 -0800 Subject: [PATCH] fix(zfc): NamePool.InUse is transient, not persisted (hq-lng09) ZFC violation: InUse was being persisted to JSON and loaded from disk, but Reconcile() immediately overwrites it with filesystem-derived state. Changes: - Mark InUse with json:"-" to exclude from serialization - Load() now initializes InUse as empty (derived via Reconcile) - Updated test to verify OverflowNext persists but InUse does not Per ZFC "Discover, Don't Track", InUse should always be derived from existing polecat directories, not tracked as separate state. Co-Authored-By: Claude Opus 4.5 --- internal/polecat/namepool.go | 14 +++++++------ internal/polecat/namepool_test.go | 33 ++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/internal/polecat/namepool.go b/internal/polecat/namepool.go index bdefa797..23209289 100644 --- a/internal/polecat/namepool.go +++ b/internal/polecat/namepool.go @@ -84,7 +84,9 @@ type NamePool struct { // InUse tracks which pool names are currently in use. // Key is the name itself, value is true if in use. - InUse map[string]bool `json:"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. @@ -168,12 +170,12 @@ func (p *NamePool) Load() error { // Note: Theme and CustomNames are NOT loaded from state file. // They are configuration (from settings/config.json), not runtime state. - // The state file only persists InUse, OverflowNext, and MaxSize. + // The state file only persists OverflowNext and MaxSize. + // + // ZFC: InUse is NEVER loaded from disk - it's transient state derived + // from filesystem via Reconcile(). Always start with empty map. + p.InUse = make(map[string]bool) - p.InUse = loaded.InUse - if p.InUse == nil { - p.InUse = make(map[string]bool) - } p.OverflowNext = loaded.OverflowNext if p.OverflowNext < p.MaxSize+1 { p.OverflowNext = p.MaxSize + 1 diff --git a/internal/polecat/namepool_test.go b/internal/polecat/namepool_test.go index e67198d0..85814b91 100644 --- a/internal/polecat/namepool_test.go +++ b/internal/polecat/namepool_test.go @@ -160,13 +160,18 @@ func TestNamePool_SaveLoad(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - pool := NewNamePool(tmpDir, "testrig") + // Use config to set MaxSize from the start (affects OverflowNext initialization) + pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, 3) - // Allocate some names + // Exhaust the pool to trigger overflow, which increments OverflowNext pool.Allocate() // furiosa pool.Allocate() // nux pool.Allocate() // slit - pool.Release("nux") + overflowName, _ := pool.Allocate() // testrig-4 (overflow) + + if overflowName != "testrig-4" { + t.Errorf("expected testrig-4 for first overflow, got %s", overflowName) + } // Save state if err := pool.Save(); err != nil { @@ -174,20 +179,26 @@ func TestNamePool_SaveLoad(t *testing.T) { } // Create new pool and load - pool2 := NewNamePool(tmpDir, "testrig") + pool2 := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, 3) if err := pool2.Load(); err != nil { t.Fatalf("Load error: %v", err) } - // Should have furiosa and slit in use - if pool2.ActiveCount() != 2 { - t.Errorf("expected 2 active, got %d", pool2.ActiveCount()) + // ZFC: InUse is NOT persisted - it's transient state derived from filesystem. + // After Load(), InUse should be empty (0 active). + if pool2.ActiveCount() != 0 { + t.Errorf("expected 0 active after Load (ZFC: InUse is transient), got %d", pool2.ActiveCount()) } - // Next allocation should be nux (released slot) - name, _ := pool2.Allocate() - if name != "nux" { - t.Errorf("expected nux, got %s", name) + // OverflowNext SHOULD persist - it's the one piece of state that can't be derived. + // Next overflow should be testrig-5, not testrig-4. + pool2.Allocate() // furiosa (InUse empty, so starts from beginning) + pool2.Allocate() // nux + pool2.Allocate() // slit + overflowName2, _ := pool2.Allocate() // Should be testrig-5 + + if overflowName2 != "testrig-5" { + t.Errorf("expected testrig-5 (OverflowNext persisted), got %s", overflowName2) } }