Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** (`<id>.meta.json`) stored as a sibling in the worktree root directory (e.g. `~/.beans/worktrees/<project>/<id>.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

Expand Down
14 changes: 12 additions & 2 deletions internal/commands/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
8 changes: 6 additions & 2 deletions internal/graph/schema.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 60 additions & 10 deletions internal/portalloc/portalloc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
65 changes: 65 additions & 0 deletions internal/portalloc/portalloc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
24 changes: 24 additions & 0 deletions internal/worktree/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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.
Expand Down
Loading