diff --git a/.beans/beans-ocss--persist-workspace-port-assignments-across-restarts.md b/.beans/beans-ocss--persist-workspace-port-assignments-across-restarts.md new file mode 100644 index 00000000..22217f42 --- /dev/null +++ b/.beans/beans-ocss--persist-workspace-port-assignments-across-restarts.md @@ -0,0 +1,21 @@ +--- +# beans-ocss +title: Persist workspace port assignments across restarts +status: completed +type: task +priority: normal +created_at: 2026-03-21T09:29:08Z +updated_at: 2026-03-21T09:31:11Z +--- + +Add Port field to worktreeMeta so port assignments survive beans serve restarts. Also document worktree metadata pattern in CLAUDE.md. + + +## Summary of Changes + +- Added `AllocateSpecific(workspaceID, port)` to `portalloc.Allocator` — restores a specific port for a workspace, falling back to normal allocation on conflict +- Added `Port` field to `worktreeMeta` struct, with `SavePort`/`GetPort` methods on `worktree.Manager` +- Updated `serve.go` startup to restore persisted ports from worktree metadata before falling back to fresh allocation +- Updated `CreateWorktree` resolver to persist allocated port to metadata +- Added tests for `AllocateSpecific` (happy path, idempotency, conflict, nextIndex advancement) +- Documented the worktree metadata file pattern in CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 08c19248..d707fed8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ Key packages: - `beans-serve` watches active worktrees' `.beans/` dirs and merges file changes into runtime state as "dirty" (not persisted to main disk). - The `startWork` mutation uses `WithPersist(false)` — status changes are runtime-only until the PR merges. - When a PR merges and the bean file lands on main, the main watcher picks it up and the dirty flag clears. +- Each worktree has a **metadata file** (`.meta.json`) stored as a sibling in the worktree root directory (e.g. `~/.beans/worktrees//.meta.json`). This file persists per-worktree state that must survive server restarts: name, description, allocated port, and last-active timestamp. Use `worktree.Manager.SavePort`/`GetPort` etc. to read and write fields — don't access the file directly. # Agent Architecture diff --git a/internal/commands/serve.go b/internal/commands/serve.go index ad1bb6f5..80b647a0 100644 --- a/internal/commands/serve.go +++ b/internal/commands/serve.go @@ -148,10 +148,20 @@ func runServer(port int, origins []string) error { portAlloc := portalloc.NewDefault() portAlloc.Allocate(graph.CentralSessionID) - // Allocate ports for existing worktrees + // Restore persisted ports for existing worktrees (or allocate new ones) if existingWTs, err := wtManager.List(); err == nil { for _, wt := range existingWTs { - portAlloc.Allocate(wt.ID) + var port int + if savedPort := wtManager.GetPort(wt.ID); savedPort > 0 { + port = portAlloc.AllocateSpecific(wt.ID, savedPort) + } else { + port = portAlloc.Allocate(wt.ID) + } + // Persist the port (writes back the actual port, which may differ + // from the saved one if there was a conflict). + if err := wtManager.SavePort(wt.ID, port); err != nil { + log.Printf("[beans] warning: failed to save port for %s: %v", wt.ID, err) + } } } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index b9edd76d..989e5e07 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -9,6 +9,7 @@ import ( "context" "encoding/base64" "fmt" + "log" "os/exec" "path/filepath" "strings" @@ -211,9 +212,12 @@ func (r *mutationResolver) CreateWorktree(ctx context.Context, name string) (*mo return nil, err } - // Allocate a workspace port for this worktree + // Allocate a workspace port for this worktree and persist it if r.PortAlloc != nil { - r.PortAlloc.Allocate(wt.ID) + port := r.PortAlloc.Allocate(wt.ID) + if err := r.WorktreeMgr.SavePort(wt.ID, port); err != nil { + log.Printf("[worktree] warning: failed to save port for %s: %v", wt.ID, err) + } } // Start watching the worktree's .beans/ directory for bean changes diff --git a/internal/portalloc/portalloc.go b/internal/portalloc/portalloc.go index 2c86a34f..b7f5eb8e 100644 --- a/internal/portalloc/portalloc.go +++ b/internal/portalloc/portalloc.go @@ -14,7 +14,8 @@ const ( ) // Allocator assigns unique ports to workspace IDs. -// Ports are managed in RAM only — no persistence. +// The allocator itself is in-memory; persistence is handled externally +// via worktree metadata files. type Allocator struct { mu sync.Mutex basePort int @@ -49,15 +50,7 @@ func (a *Allocator) Allocate(workspaceID string) int { return port } - var port int - if len(a.freed) > 0 { - port = a.freed[len(a.freed)-1] - a.freed = a.freed[:len(a.freed)-1] - } else { - port = a.basePort + a.nextIndex*a.step - a.nextIndex++ - } - + port := a.allocateNext() a.assigned[workspaceID] = port return port } @@ -77,6 +70,63 @@ func (a *Allocator) Free(workspaceID string) { a.freed = append(a.freed, port) } +// AllocateSpecific assigns a specific port to the given workspace ID. +// If the workspace already has a port, the existing one is returned unchanged. +// If the requested port is already taken by another workspace, a new port is +// allocated instead. Returns the actually assigned port. +func (a *Allocator) AllocateSpecific(workspaceID string, port int) int { + a.mu.Lock() + defer a.mu.Unlock() + + // Already allocated — return existing. + if existing, ok := a.assigned[workspaceID]; ok { + return existing + } + + // Check if the requested port is taken by another workspace. + taken := false + for _, p := range a.assigned { + if p == port { + taken = true + break + } + } + + if !taken { + a.assigned[workspaceID] = port + // Remove from freed list if present. + for i, p := range a.freed { + if p == port { + a.freed = append(a.freed[:i], a.freed[i+1:]...) + break + } + } + // Advance nextIndex past this port if needed, to avoid future collisions. + idx := (port - a.basePort) / a.step + if idx+1 > a.nextIndex { + a.nextIndex = idx + 1 + } + return port + } + + // Port taken — fall back to normal allocation (lock already held). + port = a.allocateNext() + a.assigned[workspaceID] = port + return port +} + +// allocateNext assigns the next available port. Must be called with a.mu held. +func (a *Allocator) allocateNext() int { + if len(a.freed) > 0 { + port := a.freed[len(a.freed)-1] + a.freed = a.freed[:len(a.freed)-1] + return port + } + port := a.basePort + a.nextIndex*a.step + a.nextIndex++ + return port +} + // Get returns the port assigned to the given workspace ID. // Returns 0 and an error if no port is assigned. func (a *Allocator) Get(workspaceID string) (int, error) { diff --git a/internal/portalloc/portalloc_test.go b/internal/portalloc/portalloc_test.go index 81aaef12..38f032f0 100644 --- a/internal/portalloc/portalloc_test.go +++ b/internal/portalloc/portalloc_test.go @@ -95,6 +95,71 @@ func TestFreeAndGetReturnsError(t *testing.T) { } } +func TestAllocateSpecific(t *testing.T) { + a := New(44000, 10) + + // Allocate a specific port + port := a.AllocateSpecific("ws-1", 44050) + if port != 44050 { + t.Errorf("specific port = %d, want 44050", port) + } + + // Verify it's retrievable + got, err := a.Get("ws-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != 44050 { + t.Errorf("Get = %d, want 44050", got) + } +} + +func TestAllocateSpecificIdempotent(t *testing.T) { + a := New(44000, 10) + + port1 := a.AllocateSpecific("ws-1", 44050) + port2 := a.AllocateSpecific("ws-1", 44060) // different port requested + + if port1 != port2 { + t.Errorf("same workspace got different ports: %d vs %d", port1, port2) + } + if port1 != 44050 { + t.Errorf("port = %d, want 44050 (original)", port1) + } +} + +func TestAllocateSpecificConflict(t *testing.T) { + a := New(44000, 10) + + a.Allocate("ws-1") // takes 44000 + port := a.AllocateSpecific("ws-2", 44000) // conflict + + if port == 44000 { + t.Errorf("conflicting port should not be 44000") + } + // Should get the next available port + if port != 44010 { + t.Errorf("fallback port = %d, want 44010", port) + } +} + +func TestAllocateSpecificAdvancesNextIndex(t *testing.T) { + a := New(44000, 10) + + // Allocate a port well ahead of the current nextIndex + a.AllocateSpecific("ws-1", 44050) + + // Next sequential allocation should not collide + port := a.Allocate("ws-2") + if port == 44050 { + t.Errorf("sequential allocation collided with specific allocation") + } + // nextIndex should have advanced past index 5 (44050) + if port != 44060 { + t.Errorf("next port = %d, want 44060", port) + } +} + func TestNewDefault(t *testing.T) { a := NewDefault() diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 32613bab..af8b5cb0 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -459,6 +459,7 @@ func (m *Manager) Create(name string) (*Worktree, error) { type worktreeMeta struct { Name string `json:"name"` Description string `json:"description,omitempty"` + Port int `json:"port,omitempty"` LastActiveAt *time.Time `json:"last_active_at,omitempty"` } @@ -494,6 +495,29 @@ func (m *Manager) removeMeta(id string) { os.Remove(m.metaPath(id)) } +// SavePort persists the allocated port for a worktree into its metadata file. +func (m *Manager) SavePort(id string, port int) error { + m.mu.Lock() + defer m.mu.Unlock() + + meta := m.loadMeta(id) + if meta == nil { + meta = &worktreeMeta{} + } + meta.Port = port + return m.saveMeta(id, meta) +} + +// GetPort returns the persisted port for a worktree, or 0 if none is stored. +func (m *Manager) GetPort(id string) int { + m.mu.RLock() + defer m.mu.RUnlock() + + if meta := m.loadMeta(id); meta != nil { + return meta.Port + } + return 0 +} // TouchLastActive updates the LastActiveAt timestamp for a worktree to now // and notifies subscribers. Called when an agent completes a turn.