From 84bed3c978277b27c93bf8a8c3f877b6dca1016a Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 24 Apr 2026 23:57:23 -0700 Subject: [PATCH 01/12] docs: spec for port-drift allocation in wt new / wt setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the current behavior where slots are skipped (auto path) or errors are thrown (--slot N path) when ports are already in use. Per- service drift forward by 1 until a port is both OS-free and not in the registry, with a stderr line identifying the listener via lsof. Also fixes a latent bug in setup.ts where re-running setup on an existing allocation would overwrite registered ports with formula values — invisible today but harmful once drift exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-04-24-port-drift-allocation-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-24-port-drift-allocation-design.md diff --git a/docs/superpowers/specs/2026-04-24-port-drift-allocation-design.md b/docs/superpowers/specs/2026-04-24-port-drift-allocation-design.md new file mode 100644 index 0000000..7bf5bb4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-port-drift-allocation-design.md @@ -0,0 +1,194 @@ +# Port-drift allocation for `wt new` / `wt setup` + +## Problem + +When `wt` creates or sets up a worktree, it computes ports as +`slot * portStride + service.defaultPort`. If any of those ports is already +bound by another process — including another `wt` worktree's services — the +current behavior either: + +1. **Auto-slot path** (`findAvailablePortSafeSlot`): silently *skips* that + slot and tries the next one. The user ends up in a higher slot for no + visible reason, and the skipped slots are never reused even after the + conflict goes away. +2. **Explicit `--slot N` path** (`findUnavailableServicePorts`): hard-errors + with `Slot N has ports already in use: …`. The user has to free the port + manually before retrying. + +Both behaviors are surprising. The user wants the simpler rule: **accept the +slot, drift the conflicting ports forward by 1 until they're free, and tell +the user which process held the original port.** + +## Solution + +Replace the two existing port-availability paths with a single +**per-service drift** allocator. + +### Slot selection + +Slots are picked purely from the registry — first slot in `1..maxSlots` that +has no allocation. Port availability no longer affects slot selection. +`findAvailablePortSafeSlot` is removed; `findAvailableSlot` (already exists) +is used in both `new.ts` and `setup.ts`. + +### Port allocation + +For each configured service, starting from the natural port +`slot * portStride + service.defaultPort`, probe sequentially upward until a +port satisfies both: + +- **OS-free**: a transient `net.createServer().listen(port, '127.0.0.1')` + succeeds, AND +- **Not already in the registry**: not present in any + `registry.allocations[*].ports[*]` value. + +Drift is **per-service**. If `web`'s natural port is taken but `api`'s is +free, only `web` drifts. The result is a `Record` where most +services usually sit at their natural port and only the conflicting ones +move. + +The reserved-set is built once before allocation. Reserved ports are +**skipped without probing the OS** — we already know they're ours. + +### Drift cap + +Drift is unbounded up to **65535**. If a service exhausts the port space +without finding a free port, allocation fails with: + +``` +No available port for service '' starting from ; reached 65535. +``` + +This is a hard failure; the create/setup is rolled back as it would be for +any other allocation error. + +### Reporting + +Every drift produces one line on stderr **before** the "Creating worktree…" +log. Two formats: + +- **OS conflict**: + `Port 3200 (web) in use by []; using 3201 instead.` +- **Internal conflict** (port already in registry): + `Port 3200 (web) reserved by slot (); using 3201 instead.` + +When listener detection itself fails or the platform is unsupported: + +`Port 3200 (web) in use by unknown process; using 3201 instead.` + +Detection is best-effort and never throws. Implementation: `lsof -nP -iTCP: -sTCP:LISTEN -F pcn` on darwin and linux. We parse the `p` +(pid), `c` (command), `n` (name) fields. On any other platform, or if +`lsof` is missing/errors, we fall back to `unknown process`. + +The drift list is also threaded out for `--json` output, attached to the +result under `data.portDrifts`: + +```json +"portDrifts": [ + { + "service": "web", + "requested": 3200, + "assigned": 3201, + "conflict": { "kind": "os", "description": "node[12345]" } + } +] +``` + +When there are no drifts, `portDrifts` is an empty array. + +## Scope + +### Files changed + +- `src/core/slot-allocator.ts` — core change. New + `allocateServicePorts(slot, services, stride, registry)` function. + Internal `describeListener(port)` helper. Remove + `findUnavailableServicePorts` and `findAvailablePortSafeSlot`. Keep + `calculatePorts`, `calculateDbName`, `validatePortPlan`, + `findAvailableSlot`, `isPortAvailable`. + +- `src/commands/new.ts` — replace both the auto-slot + (`findAvailablePortSafeSlot`) and explicit-slot + (`findUnavailableServicePorts`) branches with a single sequence: + 1. Pick slot via `findAvailableSlot` (auto) or validate the explicit one. + 2. Call `allocateServicePorts` to get final ports + drift list. + 3. Log each drift to stderr (suppressed under `--json`/`quiet`). + 4. Carry the drift list into the result and `--json` output. + +- `src/commands/setup.ts` — two changes: + 1. Fresh-allocation branch (no existing registry entry) calls + `allocateServicePorts`, threads drifts into stderr and `--json`, same + as `new.ts`. + 2. Existing-allocation branch (worktree already in registry) uses + `existing[1].ports` directly instead of recomputing via + `calculatePorts`. This preserves a worktree's drifted ports across + re-runs of `wt setup` (e.g., from the `post-checkout` hook). Today + this code path overwrites registered ports with formula values; that + is a latent bug which this change exposes once drift exists, so we + fix it here. + +- `src/types.ts` — add `PortDrift` and `AllocatedPorts` types alongside the + existing `PatchContext` etc. + +### Tests + +- `__tests__/slot-allocator.spec.ts` — new `allocateServicePorts` cases: + - All ports free → no drift, returns natural ports. + - One service's port held by an OS listener (test by binding a real + socket) → drifts by 1; conflict object reports `kind: 'os'`. + - One service's port appears in registry as another slot's allocation → + drifts past it without probing the OS; conflict object reports + `kind: 'internal'` with the owning slot/service. + - Multi-service partial drift: web drifts, api stays. + - Exhaustion: service whose natural port is `65535` and that port is + taken → throws with the expected message. + +- `src/commands/new.spec.ts` — update mocks to replace + `findAvailablePortSafeSlot` and `findUnavailableServicePorts` with + `allocateServicePorts`. Add an assertion that drift lines appear on + stderr when drifts are returned. Add an assertion that + `data.portDrifts` is present in the JSON success output. + +- `src/commands/setup.spec.ts` — new test file (no setup-specific suite + exists yet). Cover: + - Fresh allocation: drift list appears on stderr and in JSON output. + - Re-running setup on an existing allocation reuses the registered ports + verbatim (regression guard for the formula-overwrite bug). + +### Behaviorally removed + +- The `Slot N has ports already in use: …` error from `wt new --slot N`. +- The same error path in `wt setup`. +- The "skip slot when its natural ports are taken" behavior of + `findAvailablePortSafeSlot`. + +## Edge cases + +- **Slot 0 (main worktree)**: not affected. `wt new` and `wt setup` only + allocate slots ≥ 1; the main worktree's ports are managed by the user. +- **Re-running `wt setup` on an already-allocated worktree**: reuses the + registered ports verbatim. Drift only applies on fresh allocation. See + the second `setup.ts` change above for why this is a behavioral fix in + addition to a swap. +- **Two concurrent `wt new` processes**: not in scope. Same race window as + today — two parallel runs can read the same registry, both pick the same + drifted port, then one's docker fails to bind on start. The fix is the + same registry-locking story it would always have been; this change does + not make the race worse. +- **`lsof` returns multiple listeners** (e.g., IPv4 + IPv6): we report the + first listener we see. Good-enough for human diagnosis. +- **Bind check race**: `isPortAvailable` succeeds, then docker binds it a + millisecond later. Outside our control; docker's own error message will + surface and rollback handles cleanup. + +## Non-goals + +- Persisting drift history. The natural port is recoverable from the + formula plus the slot/service; the registry only stores the + actually-assigned port. +- Rebalancing existing allocations when a port frees up. Drifted ports + stay drifted for the life of the worktree. +- Cross-platform parity for listener identification. macOS and Linux both + ship `lsof`; Windows reports `unknown process`. +- Port reservation for *future* slots. We only avoid ports currently in + the registry, not the natural ports of unallocated slots. From f6aa1e8da72f3c6e8d8b8b58fa2dddcbcbdd70e0 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:23:50 -0700 Subject: [PATCH 02/12] docs: implementation plan for port-drift allocation Eight-task plan with TDD steps for each: types, parseLsofOutput, describeListener, allocateServicePorts core, wiring into new.ts, wiring into setup.ts (with the existing-allocation port-reuse fix), removal of dead code, and final verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-25-port-drift-allocation.md | 1314 +++++++++++++++++ 1 file changed, 1314 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-25-port-drift-allocation.md diff --git a/docs/superpowers/plans/2026-04-25-port-drift-allocation.md b/docs/superpowers/plans/2026-04-25-port-drift-allocation.md new file mode 100644 index 0000000..ccb2779 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-port-drift-allocation.md @@ -0,0 +1,1314 @@ +# Port-Drift Allocation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When `wt new` / `wt setup` allocates ports, drift past in-use or +already-registered ports per service rather than failing or silently skipping +slots, and tell the user which process held the original port. + +**Architecture:** A new `allocateServicePorts` function in +`src/core/slot-allocator.ts` replaces both `findAvailablePortSafeSlot` (auto-slot +path) and `findUnavailableServicePorts` (explicit-slot path). It probes upward +from the natural port `slot * stride + defaultPort`, skipping ports already +present in `registry.allocations[*].ports[*]` without OS probing, and binding +to test OS availability for the rest. Listener identification uses `lsof` +(best-effort, never throws). Both `new.ts` and `setup.ts` thread a +`PortDrift[]` list out through stderr logging and `--json` output. As a paired +fix, `setup.ts`'s existing-allocation branch starts reusing the registered +ports instead of recomputing them via the formula — invisible today, harmful +the moment drift exists. + +**Tech Stack:** TypeScript, Node.js (`net`, `child_process`), Jest, lsof +(macOS/Linux). + +**Reference spec:** `docs/superpowers/specs/2026-04-24-port-drift-allocation-design.md` + +--- + +## File Structure + +**Modify:** +- `src/types.ts` — add `PortDrift` and `AllocatedPorts` types. +- `src/core/slot-allocator.ts` — add `parseLsofOutput`, `describeListener`, + `allocateServicePorts`. Remove `findUnavailableServicePorts` and + `findAvailablePortSafeSlot`. +- `src/commands/new.ts` — replace both port-availability branches with + `allocateServicePorts`; thread drifts into stderr + JSON output. +- `src/commands/setup.ts` — same swap on the fresh-allocation branch; on the + existing-allocation branch reuse `existing[1].ports` instead of + recomputing via `calculatePorts`. + +**Modify (tests):** +- `__tests__/slot-allocator.spec.ts` — drop `findUnavailableServicePorts` + test, add `parseLsofOutput` and `allocateServicePorts` tests. +- `src/commands/new.spec.ts` — replace deleted-function mocks with + `allocateServicePorts`; add stderr-drift assertion and JSON-drift assertion. + +**Create:** +- `src/commands/setup.spec.ts` — new file. Cover fresh-allocation drift flow + and existing-allocation port-reuse regression. + +--- + +## Task 1: Add Port-Drift Types + +**Files:** +- Modify: `src/types.ts` + +- [ ] **Step 1: Add `PortDrift` and `AllocatedPorts` types** + +In `src/types.ts`, add the following after the existing `PatchContext` +interface: + +```ts +/** A single service whose port had to drift away from its natural slot port */ +export interface PortDrift { + readonly service: string; + readonly requested: number; + readonly assigned: number; + readonly conflict: + | { readonly kind: 'os'; readonly description: string } + | { readonly kind: 'internal'; readonly slot: number; readonly service: string }; +} + +/** Result of allocating ports for a single slot's services */ +export interface AllocatedPorts { + readonly ports: Record; + readonly drifts: readonly PortDrift[]; +} +``` + +- [ ] **Step 2: Verify the type-only addition compiles** + +Run: `pnpm exec tsc --noEmit` +Expected: exits 0 with no output. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "feat(types): add PortDrift and AllocatedPorts" +``` + +--- + +## Task 2: Pure `parseLsofOutput` Helper + +The drift reporter needs to identify the process holding a port. We split +this into a pure parser (testable without `lsof` on the system) and a thin +wrapper that shells out. Task 2 is the parser; Task 3 is the wrapper. + +`lsof -nP -iTCP: -sTCP:LISTEN -F pcn` produces records like: + +``` +p12345 +cnode +n*:3200 +``` + +where `p` is pid, `c` is command, `n` is the listening address. Multiple +listeners produce multiple `p`/`c`/`n` blocks. We parse the first complete +block. + +**Files:** +- Modify: `src/core/slot-allocator.ts` +- Modify: `__tests__/slot-allocator.spec.ts` + +- [ ] **Step 1: Write failing tests for `parseLsofOutput`** + +Add this block at the end of `__tests__/slot-allocator.spec.ts` (before the +closing `});` of the outer `describe('slot-allocator', () => {`): + +```ts + describe('parseLsofOutput', () => { + it('parses pid and command from a single listener', () => { + const out = 'p12345\ncnode\nn*:3200\n'; + expect(parseLsofOutput(out)).toEqual({ pid: 12345, command: 'node' }); + }); + + it('returns the first listener when multiple are reported', () => { + const out = 'p12345\ncnode\nn127.0.0.1:3200\np67890\ncpython3\nn*:3200\n'; + expect(parseLsofOutput(out)).toEqual({ pid: 12345, command: 'node' }); + }); + + it('returns null on empty output', () => { + expect(parseLsofOutput('')).toBeNull(); + }); + + it('returns null when only a name field is present', () => { + expect(parseLsofOutput('n*:3200\n')).toBeNull(); + }); + }); +``` + +Also add `parseLsofOutput` to the import block at the top of the file: + +```ts +import { + calculatePorts, + calculateDbName, + findAvailableSlot, + findUnavailableServicePorts, + validatePortPlan, + parseLsofOutput, +} from '../src/core/slot-allocator'; +``` + +- [ ] **Step 2: Run tests to confirm failure** + +Run: `pnpm test -- slot-allocator` +Expected: 4 failing tests under `parseLsofOutput`, all with TypeScript or +import errors because `parseLsofOutput` is not exported yet. + +- [ ] **Step 3: Implement `parseLsofOutput`** + +Add to `src/core/slot-allocator.ts` (anywhere among the exports): + +```ts +/** + * Parse the output of `lsof -F pcn`. Returns the first listener's pid + + * command, or null if the input doesn't contain a complete listener record. + */ +export function parseLsofOutput(output: string): { pid: number; command: string } | null { + let pid: number | null = null; + let command: string | null = null; + for (const line of output.split('\n')) { + if (line.startsWith('p')) { + // Encountering a new pid before completing the previous record means + // the previous record was incomplete; reset. + if (pid !== null && command === null) { + pid = null; + } + const parsed = Number(line.slice(1)); + if (Number.isInteger(parsed)) { + pid = parsed; + } + } else if (line.startsWith('c') && pid !== null && command === null) { + command = line.slice(1); + } + if (pid !== null && command !== null) { + return { pid, command }; + } + } + return null; +} +``` + +- [ ] **Step 4: Run tests to confirm pass** + +Run: `pnpm test -- slot-allocator` +Expected: all 4 `parseLsofOutput` tests pass; existing tests continue to pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/slot-allocator.ts __tests__/slot-allocator.spec.ts +git commit -m "feat(slot-allocator): parse lsof -F output for listener identification" +``` + +--- + +## Task 3: `describeListener` Helper + +A tiny wrapper around `lsof` that returns a human-readable string like +`"node[12345]"` or `"unknown process"` on failure. Best-effort, never +throws. + +**Files:** +- Modify: `src/core/slot-allocator.ts` +- Modify: `__tests__/slot-allocator.spec.ts` + +- [ ] **Step 1: Write failing test that binds a real socket and checks + describeListener returns a non-empty, non-"unknown" string** + +Add to `__tests__/slot-allocator.spec.ts` near the other `net`-based test +(inside `describe('slot-allocator', …)`): + +```ts + describe('describeListener', () => { + it('returns a description for a real local listener', async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP address.'); + } + + const description = await describeListener(address.port); + + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + + // Best-effort: on macOS/linux this should match `[]`. + // On platforms without lsof we fall back to "unknown process". + expect(description).toMatch(/^(.+\[\d+\]|unknown process)$/); + }); + + it('returns "unknown process" when no one is listening', async () => { + // Port 1 is reserved/unprivileged and almost certainly free. + // lsof returns a non-zero exit when no match is found; we treat it + // as "unknown process" rather than throwing. + const description = await describeListener(1); + expect(description).toBe('unknown process'); + }); + }); +``` + +Add `describeListener` to the test file's import: + +```ts +import { + calculatePorts, + calculateDbName, + findAvailableSlot, + findUnavailableServicePorts, + validatePortPlan, + parseLsofOutput, + describeListener, +} from '../src/core/slot-allocator'; +``` + +- [ ] **Step 2: Run tests to confirm failure** + +Run: `pnpm test -- slot-allocator` +Expected: 2 failing/erroring tests under `describeListener` due to the +missing export. + +- [ ] **Step 3: Implement `describeListener`** + +Add to `src/core/slot-allocator.ts` near the top of the file (after the +imports): + +```ts +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +``` + +Then export the function: + +```ts +/** + * Best-effort identification of the process listening on `port`. Returns + * `[]` on darwin/linux when lsof finds a listener; returns + * `"unknown process"` on any failure (no listener, lsof missing, parse + * failure, unsupported platform). Never throws. + */ +export async function describeListener(port: number): Promise { + try { + const { stdout } = await execFileAsync( + 'lsof', + ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'pcn'], + { timeout: 2000 }, + ); + const parsed = parseLsofOutput(stdout); + if (parsed) { + return `${parsed.command}[${parsed.pid}]`; + } + } catch { + // lsof returns non-zero when no match is found, or is missing entirely. + } + return 'unknown process'; +} +``` + +- [ ] **Step 4: Run tests to confirm pass** + +Run: `pnpm test -- slot-allocator` +Expected: both `describeListener` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/slot-allocator.ts __tests__/slot-allocator.spec.ts +git commit -m "feat(slot-allocator): describeListener via lsof, best-effort" +``` + +--- + +## Task 4: Core `allocateServicePorts` + +**Files:** +- Modify: `src/core/slot-allocator.ts` +- Modify: `__tests__/slot-allocator.spec.ts` + +- [ ] **Step 1: Write failing tests covering all five cases** + +Add a new `describe` block to `__tests__/slot-allocator.spec.ts` (inside +`describe('slot-allocator', …)`): + +```ts + describe('allocateServicePorts', () => { + const services = [ + { name: 'web', defaultPort: 3000 }, + { name: 'api', defaultPort: 4000 }, + ] as const; + const stride = 100; + + function emptyRegistry(): Registry { + return { version: 1, allocations: {} }; + } + + it('returns natural ports with no drift when everything is free', async () => { + const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + + expect(result.ports).toEqual({ web: 3200, api: 4200 }); + expect(result.drifts).toEqual([]); + }); + + it('drifts a service whose natural port is bound at the OS level', async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(3200, '127.0.0.1', () => resolve())); + + try { + const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + + expect(result.ports.web).toBe(3201); + expect(result.ports.api).toBe(4200); + expect(result.drifts).toHaveLength(1); + expect(result.drifts[0]).toMatchObject({ + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os' }, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('skips ports already in another slot\'s allocation without probing', async () => { + const registry: Registry = { + version: 1, + allocations: { + '1': { + worktreePath: '/tmp/wt1', + branchName: 'feat/a', + dbName: 'db_wt1', + ports: { web: 3200, api: 4100 }, + createdAt: '2026-04-25T00:00:00.000Z', + }, + }, + }; + + const result = await allocateServicePorts(2, services, stride, registry); + + // web's natural 3200 is reserved by slot 1; drift to 3201. + // api's natural 4200 is free. + expect(result.ports).toEqual({ web: 3201, api: 4200 }); + expect(result.drifts).toEqual([ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'internal', slot: 1, service: 'web' }, + }, + ]); + }); + + it('drifts only the conflicting service in a multi-service config', async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(4200, '127.0.0.1', () => resolve())); + + try { + const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + + expect(result.ports.web).toBe(3200); + expect(result.ports.api).toBe(4201); + expect(result.drifts.map((d) => d.service)).toEqual(['api']); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('throws when a service exhausts the port space at 65535', async () => { + // Service whose natural port is 65535, with that port internally + // reserved — drift would have to go to 65536, which we refuse. + const registry: Registry = { + version: 1, + allocations: { + '1': { + worktreePath: '/tmp/wt1', + branchName: 'feat/a', + dbName: 'db_wt1', + ports: { edge: 65535 }, + createdAt: '2026-04-25T00:00:00.000Z', + }, + }, + }; + const edgeServices = [{ name: 'edge', defaultPort: 65535 }] as const; + + await expect( + allocateServicePorts(0, edgeServices, 0, registry), + ).rejects.toThrow(/No available port for service 'edge'/); + }); + }); +``` + +Add `allocateServicePorts` to the imports at the top of the file. + +- [ ] **Step 2: Run tests to confirm failures** + +Run: `pnpm test -- slot-allocator` +Expected: 5 failing tests under `allocateServicePorts` due to missing +export. + +- [ ] **Step 3: Implement `allocateServicePorts`** + +Add to `src/core/slot-allocator.ts` (after `isPortAvailable`): + +```ts +/** + * Allocate ports for each service in the slot, drifting forward by 1 past + * any port that is either already bound at the OS level or already in use + * by another slot's allocation in the registry. + * + * Drift is per-service: only conflicting services move; the rest stay at + * their natural slot port. Internal conflicts (registry collisions) are + * resolved without probing the OS. OS conflicts trigger best-effort + * listener identification via `describeListener`. + * + * Caps at port 65535. Throws if a service can't find a free port before + * the ceiling. + */ +export async function allocateServicePorts( + slot: number, + services: readonly ServiceConfig[], + stride: number, + registry: Registry, +): Promise { + // Build a map: port -> { slot, service } for every port already in the + // registry across all allocations. + const reserved = new Map(); + for (const [slotStr, allocation] of Object.entries(registry.allocations)) { + const owningSlot = Number(slotStr); + for (const [serviceName, port] of Object.entries(allocation.ports)) { + reserved.set(port, { slot: owningSlot, service: serviceName }); + } + } + + const ports: Record = {}; + const drifts: PortDrift[] = []; + + for (const service of services) { + const natural = slot * stride + service.defaultPort; + let candidate = natural; + let conflict: PortDrift['conflict'] | null = null; + + while (candidate <= 65535) { + const internalOwner = reserved.get(candidate); + if (internalOwner) { + if (conflict === null) { + conflict = { + kind: 'internal', + slot: internalOwner.slot, + service: internalOwner.service, + }; + } + candidate++; + continue; + } + + if (await isPortAvailable(candidate)) { + ports[service.name] = candidate; + // Reserve this port for any later service in the same allocation + // so two services in one slot can't pick the same drifted port. + reserved.set(candidate, { slot, service: service.name }); + break; + } + + if (conflict === null) { + const description = await describeListener(candidate); + conflict = { kind: 'os', description }; + } + candidate++; + } + + if (ports[service.name] === undefined) { + throw new Error( + `No available port for service '${service.name}' starting from ${natural}; reached 65535.`, + ); + } + + if (candidate !== natural) { + drifts.push({ + service: service.name, + requested: natural, + assigned: candidate, + conflict: conflict!, + }); + } + } + + return { ports, drifts }; +} +``` + +Also add the type imports at the top of the file: + +```ts +import type { ServiceConfig, Registry, PortDrift, AllocatedPorts } from '../types'; +``` + +(replacing the existing `import type { ServiceConfig, Registry } from '../types';`) + +- [ ] **Step 4: Run tests to confirm pass** + +Run: `pnpm test -- slot-allocator` +Expected: all 5 `allocateServicePorts` tests pass; all prior tests in the +file still pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/slot-allocator.ts __tests__/slot-allocator.spec.ts +git commit -m "feat(slot-allocator): allocateServicePorts with per-service drift" +``` + +--- + +## Task 5: Wire `allocateServicePorts` into `wt new` + +Both branches in `new.ts` (auto-slot and explicit `--slot N`) collapse into +the same sequence: pick a slot, then call `allocateServicePorts`. Drift +lines log to stderr (suppressed under `--json`/`quiet`). The drift list +flows out through `CreateWorktreeResult` into the JSON success payload as +`portDrifts`. + +**Files:** +- Modify: `src/commands/new.ts` +- Modify: `src/commands/new.spec.ts` + +- [ ] **Step 1: Update `CreateWorktreeResult` and rewrite the slot/port + block in `new.ts`** + +In `src/commands/new.ts`: + +Replace the import block: + +```ts +import { readRegistry, writeRegistry, addAllocation } from '../core/registry'; +import { + calculatePorts, + calculateDbName, + findAvailablePortSafeSlot, + findUnavailableServicePorts, +} from '../core/slot-allocator'; +``` + +with: + +```ts +import { readRegistry, writeRegistry, addAllocation } from '../core/registry'; +import { + calculateDbName, + findAvailableSlot, + allocateServicePorts, +} from '../core/slot-allocator'; +``` + +Replace the `CreateWorktreeResult` interface: + +```ts +export interface CreateWorktreeResult { + readonly slot: number; + readonly allocation: Allocation; + readonly branchSelection: WorktreeBranchSelection; + readonly portDrifts: readonly PortDrift[]; +} +``` + +Add `PortDrift` to the type imports at the top of the file: + +```ts +import type { Allocation, PortDrift } from '../types'; +``` + +Replace the entire slot-determination + port-calculation block (lines +67-114 of the current file — the `if (options.slot !== undefined) { … } +else { … }` block, plus `const dbName = …` and `const ports = …`): + +```ts + // Determine slot — port availability no longer affects slot choice. + let slot: number; + if (options.slot !== undefined) { + slot = parseInt(options.slot, 10); + if (isNaN(slot) || slot < 1 || slot > config.maxSlots) { + throw new Error(`Invalid slot: ${options.slot}. Must be 1-${config.maxSlots}.`); + } + if (String(slot) in registry.allocations) { + throw new Error(`Slot ${slot} is already occupied.`); + } + } else { + const available = findAvailableSlot(registry, config.maxSlots); + if (available === null) { + throw new Error( + `All ${config.maxSlots} slots are occupied. ` + + 'Remove a worktree or increase maxSlots.', + ); + } + slot = available; + } + + log(`Creating worktree for '${branchName}' in slot ${slot}...`); + + const basePath = path.join(mainRoot, config.baseWorktreePath); + const branchSelection = resolveWorktreeBranch( + branchName, + (command) => log(`Running: ${command}`), + ); + if (branchSelection.originCheckError) { + warn(`Failed to check origin for '${branchName}': ${branchSelection.originCheckError}`); + } + log(describeBranchSelection(branchSelection)); + + const dbName = calculateDbName(slot, config.baseDatabaseName); + const { ports, drifts: portDrifts } = await allocateServicePorts( + slot, + config.services, + config.portStride, + registry, + ); + for (const drift of portDrifts) { + const detail = + drift.conflict.kind === 'os' + ? `in use by ${drift.conflict.description}` + : `reserved by slot ${drift.conflict.slot} (${drift.conflict.service})`; + warn( + `Port ${drift.requested} (${drift.service}) ${detail}; ` + + `using ${drift.assigned} instead.`, + ); + } + const databaseUrl = readDatabaseUrl(mainRoot); +``` + +Note this also moves `log("Creating worktree for ...")` and the branch +selection block before port allocation. That's intentional — drift logs +read more naturally when slot is announced first. + +Replace the final `return` line: + +```ts + return { slot, allocation, branchSelection, portDrifts }; +``` + +In `newCommand` (the JSON-output branch), update the success payload: + +```ts + if (options.json) { + console.log( + formatJson( + success({ + slot, + ...allocation, + branchSource: branchSelection.source, + branchSourceLabel: branchSelection.sourceLabel, + portDrifts, + }), + ), + ); + } else { +``` + +And destructure `portDrifts` at the top of `newCommand`: + +```ts + const { slot, allocation, branchSelection, portDrifts } = await createNewWorktree(branchName, { + ...options, + quiet: options.json, + }); +``` + +- [ ] **Step 2: Update mocks in `src/commands/new.spec.ts`** + +Replace the slot-allocator mock block (lines 12-17): + +```ts +jest.mock('../core/slot-allocator', () => ({ + calculatePorts: jest.fn(), + calculateDbName: jest.fn(), + findAvailablePortSafeSlot: jest.fn(), + findUnavailableServicePorts: jest.fn(), +})); +``` + +with: + +```ts +jest.mock('../core/slot-allocator', () => ({ + calculateDbName: jest.fn(), + findAvailableSlot: jest.fn(), + allocateServicePorts: jest.fn(), +})); +``` + +Replace the corresponding imports and aliases (lines 50-99). The +relevant section becomes: + +```ts +import { addAllocation, readRegistry, writeRegistry } from '../core/registry'; +import { + calculateDbName, + findAvailableSlot, + allocateServicePorts, +} from '../core/slot-allocator'; +import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; +import { createDatabase, databaseExists, dropDatabase } from '../core/database'; +import { + ensureDockerServices, + removeDockerServices, +} from '../core/docker-services'; +import { + getMainWorktreePath, + createWorktree, + getBranchName, + removeWorktree, + resolveWorktreeBranch, +} from '../core/git'; +import { loadConfig } from './setup'; +import { createNewWorktree, newCommand } from './new'; +import type { Allocation, Registry, WtConfig } from '../types'; +import type { WorktreeBranchSelection } from '../core/git'; + +const mockReadRegistry = readRegistry as jest.MockedFunction; +const mockWriteRegistry = writeRegistry as jest.MockedFunction; +const mockAddAllocation = addAllocation as jest.MockedFunction; +const mockCalculateDbName = calculateDbName as jest.MockedFunction; +const mockFindAvailableSlot = findAvailableSlot as jest.MockedFunction< + typeof findAvailableSlot +>; +const mockAllocateServicePorts = allocateServicePorts as jest.MockedFunction< + typeof allocateServicePorts +>; +const mockCopyAndPatchAllEnvFiles = + copyAndPatchAllEnvFiles as jest.MockedFunction; +const mockCreateDatabase = createDatabase as jest.MockedFunction; +const mockDatabaseExists = databaseExists as jest.MockedFunction; +const mockDropDatabase = dropDatabase as jest.MockedFunction; +const mockEnsureDockerServices = ensureDockerServices as jest.MockedFunction< + typeof ensureDockerServices +>; +const mockRemoveDockerServices = removeDockerServices as jest.MockedFunction< + typeof removeDockerServices +>; +const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction; +const mockCreateWorktree = createWorktree as jest.MockedFunction; +const mockGetBranchName = getBranchName as jest.MockedFunction; +const mockRemoveWorktree = removeWorktree as jest.MockedFunction; +const mockResolveWorktreeBranch = + resolveWorktreeBranch as jest.MockedFunction; +const mockLoadConfig = loadConfig as jest.MockedFunction; +``` + +Replace every existing `mockFindAvailablePortSafeSlot.mockResolvedValue(2);` +with: + +```ts + mockFindAvailableSlot.mockReturnValue(2); + mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3200 }, drifts: [] }); +``` + +(Note: in the rollback test block, the mock should be +`{ ports: { web: 3200, redis: 6579 }, drifts: [] }`.) + +Replace every `mockCalculatePorts.mockReturnValue(...)` with the +appropriate `mockAllocateServicePorts.mockResolvedValue({ ports: ..., drifts: [] })` +form. Remove all references to `mockCalculatePorts`. + +- [ ] **Step 3: Add a new test case asserting drift output** + +Append inside `describe('new command branch selection', () => { … })`, +after the existing `it` blocks: + +```ts + it('logs drift lines to stderr and includes portDrifts in JSON output', async () => { + mockResolveWorktreeBranch.mockReturnValue(originSelection()); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ], + }); + + await newCommand('feat/auth', { json: true, install: false }); + + expect(stderrOutput(stderrSpy)).toContain( + 'Port 3200 (web) in use by node[12345]; using 3201 instead.', + ); + const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { + success: boolean; + data: { portDrifts: unknown[] }; + }; + expect(output.success).toBe(true); + expect(output.data.portDrifts).toEqual([ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ]); + }); +``` + +- [ ] **Step 4: Run new.spec.ts** + +Run: `pnpm test -- new.spec` +Expected: all tests pass, including the new drift test. + +- [ ] **Step 5: Run the full test suite + tsc to catch downstream breakage** + +Run: `pnpm test && pnpm exec tsc --noEmit` +Expected: all tests pass and tsc reports no errors. Note that `setup.ts` +will still compile because `findAvailablePortSafeSlot` and +`findUnavailableServicePorts` are still exported from slot-allocator.ts; +Task 7 removes them after `setup.ts` is also migrated. + +- [ ] **Step 6: Commit** + +```bash +git add src/commands/new.ts src/commands/new.spec.ts +git commit -m "feat(new): use allocateServicePorts for port assignment with drift logging" +``` + +--- + +## Task 6: Wire `allocateServicePorts` into `wt setup` and Fix Existing-Allocation Port Reuse + +Two changes in `setup.ts`: + +1. **Fresh allocation:** swap to `allocateServicePorts`, log drifts, include + `portDrifts` in JSON output. +2. **Existing allocation:** stop recomputing ports via `calculatePorts`; use + the registered ports verbatim. This preserves drifted ports across + re-runs of `wt setup` (e.g., from the `post-checkout` hook) and is the + regression-fix companion to (1). + +**Files:** +- Modify: `src/commands/setup.ts` +- Create: `src/commands/setup.spec.ts` + +- [ ] **Step 1: Rewrite the slot/port block in `setup.ts`** + +In `src/commands/setup.ts`: + +Replace the import block: + +```ts +import { + calculatePorts, + calculateDbName, + findAvailablePortSafeSlot, + findUnavailableServicePorts, + validatePortPlan, +} from '../core/slot-allocator'; +``` + +with: + +```ts +import { + calculateDbName, + findAvailableSlot, + allocateServicePorts, + validatePortPlan, +} from '../core/slot-allocator'; +``` + +Add `PortDrift` to the type imports: + +```ts +import type { Allocation, PortDrift, WtConfig } from '../types'; +``` + +Replace the slot-determination + port-calculation block (the `const +existing = …` through the `if (!existing) { findUnavailableServicePorts… }` +section, lines 111-147): + +```ts + // Reuse existing allocation or allocate a new slot + const existing = findByPath(registry, worktreePath); + let slot: number; + let ports: Record; + let portDrifts: readonly PortDrift[]; + + if (existing) { + slot = existing[0]; + // Preserve any drifted ports the worktree was originally created + // with — formula recomputation would silently overwrite them. + ports = existing[1].ports; + portDrifts = []; + } else { + const available = findAvailableSlot(registry, config.maxSlots); + if (available === null) { + const msg = + `All ${config.maxSlots} slots are occupied. ` + + 'Remove a worktree or increase maxSlots.'; + if (options.json) { + console.log(formatJson(error('NO_SLOTS', msg))); + } else { + console.error(msg); + } + process.exitCode = 1; + return; + } + slot = available; + const allocated = await allocateServicePorts( + slot, + config.services, + config.portStride, + registry, + ); + ports = allocated.ports; + portDrifts = allocated.drifts; + for (const drift of portDrifts) { + const detail = + drift.conflict.kind === 'os' + ? `in use by ${drift.conflict.description}` + : `reserved by slot ${drift.conflict.slot} (${drift.conflict.service})`; + process.stderr.write( + `Port ${drift.requested} (${drift.service}) ${detail}; ` + + `using ${drift.assigned} instead.\n`, + ); + } + } + + const dbName = calculateDbName(slot, config.baseDatabaseName); + const branchName = getBranchName(worktreePath); +``` + +Note: the original code had `const ports = calculatePorts(...)` and +separate `findUnavailableServicePorts` check. Both are now folded into the +fresh-allocation branch via `allocateServicePorts`. + +Update the JSON success output to include `portDrifts`: + +```ts + if (options.json) { + console.log(formatJson(success({ slot, ...allocation, portDrifts }))); + } else { + console.log(formatSetupSummary(slot, allocation)); + } +``` + +- [ ] **Step 2: Create `src/commands/setup.spec.ts`** + +Create the file with this content: + +```ts +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +jest.mock('../core/registry', () => ({ + readRegistry: jest.fn(), + writeRegistry: jest.fn(), + addAllocation: jest.fn(), + findByPath: jest.fn(), +})); + +jest.mock('../core/slot-allocator', () => ({ + calculateDbName: jest.fn(), + findAvailableSlot: jest.fn(), + allocateServicePorts: jest.fn(), + validatePortPlan: jest.fn(), +})); + +jest.mock('../core/env-patcher', () => ({ + copyAndPatchAllEnvFiles: jest.fn(), +})); + +jest.mock('../core/database', () => ({ + createDatabase: jest.fn(), + databaseExists: jest.fn(), +})); + +jest.mock('../core/docker-services', () => ({ + ensureDockerServices: jest.fn(), +})); + +jest.mock('../core/git', () => ({ + getMainWorktreePath: jest.fn(), + isMainWorktree: jest.fn(), + getBranchName: jest.fn(), +})); + +import { readRegistry, writeRegistry, addAllocation, findByPath } from '../core/registry'; +import { + calculateDbName, + findAvailableSlot, + allocateServicePorts, +} from '../core/slot-allocator'; +import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; +import { createDatabase, databaseExists } from '../core/database'; +import { ensureDockerServices } from '../core/docker-services'; +import { getMainWorktreePath, isMainWorktree, getBranchName } from '../core/git'; +import { loadConfig, setupCommand } from './setup'; +import type { Allocation, Registry, WtConfig } from '../types'; + +const mockReadRegistry = readRegistry as jest.MockedFunction; +const mockWriteRegistry = writeRegistry as jest.MockedFunction; +const mockAddAllocation = addAllocation as jest.MockedFunction; +const mockFindByPath = findByPath as jest.MockedFunction; +const mockCalculateDbName = calculateDbName as jest.MockedFunction; +const mockFindAvailableSlot = findAvailableSlot as jest.MockedFunction; +const mockAllocateServicePorts = allocateServicePorts as jest.MockedFunction< + typeof allocateServicePorts +>; +const mockCopyAndPatchAllEnvFiles = + copyAndPatchAllEnvFiles as jest.MockedFunction; +const mockCreateDatabase = createDatabase as jest.MockedFunction; +const mockDatabaseExists = databaseExists as jest.MockedFunction; +const mockEnsureDockerServices = ensureDockerServices as jest.MockedFunction< + typeof ensureDockerServices +>; +const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction; +const mockIsMainWorktree = isMainWorktree as jest.MockedFunction; +const mockGetBranchName = getBranchName as jest.MockedFunction; + +const config: WtConfig = { + baseDatabaseName: 'myapp', + baseWorktreePath: '.worktrees', + portStride: 100, + maxSlots: 50, + services: [{ name: 'web', defaultPort: 3000 }], + dockerServices: [], + envFiles: [], + postSetup: [], + autoInstall: true, +}; + +describe('setup command', () => { + let tmpDir: string; + let worktreeDir: string; + let stderrSpy: jest.SpiedFunction; + let consoleLogSpy: jest.SpiedFunction; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-setup-test-')); + worktreeDir = path.join(tmpDir, '.worktrees', 'feat-auth'); + fs.mkdirSync(worktreeDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.env'), + 'DATABASE_URL=postgresql://user:pw@localhost:5432/myapp\n', + 'utf-8', + ); + fs.writeFileSync( + path.join(tmpDir, 'wt.config.json'), + JSON.stringify(config), + 'utf-8', + ); + + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + mockGetMainWorktreePath.mockReturnValue(tmpDir); + mockIsMainWorktree.mockReturnValue(false); + mockGetBranchName.mockReturnValue('feat/auth'); + mockReadRegistry.mockReturnValue({ version: 1, allocations: {} }); + mockCalculateDbName.mockReturnValue('myapp_wt2'); + mockDatabaseExists.mockResolvedValue(true); + mockCreateDatabase.mockResolvedValue(); + mockEnsureDockerServices.mockReturnValue({ projectName: 'wt-2-myapp', services: [] }); + mockAddAllocation.mockImplementation((registry, slot, allocation) => ({ + ...registry, + allocations: { ...registry.allocations, [String(slot)]: allocation }, + })); + process.exitCode = 0; + }); + + afterEach(() => { + stderrSpy.mockRestore(); + consoleLogSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.clearAllMocks(); + }); + + it('on fresh allocation, logs drift to stderr and includes portDrifts in JSON output', async () => { + mockFindByPath.mockReturnValue(null); + mockFindAvailableSlot.mockReturnValue(2); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ], + }); + + await setupCommand(worktreeDir, { json: true, install: false }); + + const stderr = stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(stderr).toContain( + 'Port 3200 (web) in use by node[12345]; using 3201 instead.', + ); + const payload = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { + success: boolean; + data: { portDrifts: unknown[]; ports: Record }; + }; + expect(payload.success).toBe(true); + expect(payload.data.ports).toEqual({ web: 3201 }); + expect(payload.data.portDrifts).toHaveLength(1); + }); + + it('on existing allocation, reuses registered ports verbatim and reports no drifts', async () => { + const allocation: Allocation = { + worktreePath: worktreeDir, + branchName: 'feat/auth', + dbName: 'myapp_wt2', + ports: { web: 3207 }, // drifted in a previous run + createdAt: '2026-04-25T00:00:00.000Z', + }; + mockFindByPath.mockReturnValue([2, allocation]); + + await setupCommand(worktreeDir, { json: true, install: false }); + + // Crucial: allocateServicePorts must NOT be called for an existing + // allocation — otherwise re-running setup would re-drift. + expect(mockAllocateServicePorts).not.toHaveBeenCalled(); + + // ensureDockerServices receives the registered (drifted) ports, not + // the formula-computed ones (would be 3200 here). + expect(mockEnsureDockerServices).toHaveBeenCalledWith( + expect.objectContaining({ ports: { web: 3207 } }), + ); + + const payload = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { + success: boolean; + data: { ports: Record; portDrifts: unknown[] }; + }; + expect(payload.data.ports).toEqual({ web: 3207 }); + expect(payload.data.portDrifts).toEqual([]); + }); +}); +``` + +- [ ] **Step 3: Run setup tests** + +Run: `pnpm test -- setup.spec` +Expected: both tests pass. + +- [ ] **Step 4: Run the full test suite** + +Run: `pnpm test` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/setup.ts src/commands/setup.spec.ts +git commit -m "feat(setup): use allocateServicePorts; reuse registered ports for existing allocation" +``` + +--- + +## Task 7: Remove Dead Code + +With both `new.ts` and `setup.ts` migrated, the legacy port-availability +helpers are unused. Remove them (and the now-unused +`findUnavailableServicePorts` test) to keep the surface small. + +**Files:** +- Modify: `src/core/slot-allocator.ts` +- Modify: `__tests__/slot-allocator.spec.ts` + +- [ ] **Step 1: Confirm both helpers are unused** + +Run: `pnpm exec grep -rn "findAvailablePortSafeSlot\|findUnavailableServicePorts" src __tests__` +Expected: only matches in `src/core/slot-allocator.ts` and the +corresponding test, no consumers in `src/commands` or other tests. + +- [ ] **Step 2: Delete `findUnavailableServicePorts` and `findAvailablePortSafeSlot` from `src/core/slot-allocator.ts`** + +Remove these two function definitions entirely. Keep `isPortAvailable` — +`allocateServicePorts` uses it. + +- [ ] **Step 3: Delete the `findUnavailableServicePorts` test block from + `__tests__/slot-allocator.spec.ts`** + +Remove the entire `describe('findUnavailableServicePorts', () => { … })` +block and the `findUnavailableServicePorts` import. + +- [ ] **Step 4: Run the full test suite + tsc** + +Run: `pnpm test && pnpm exec tsc --noEmit` +Expected: all tests pass; tsc reports no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/slot-allocator.ts __tests__/slot-allocator.spec.ts +git commit -m "refactor(slot-allocator): drop findUnavailableServicePorts and findAvailablePortSafeSlot" +``` + +--- + +## Task 8: Final Verification + +- [ ] **Step 1: Run lint** + +Run: `pnpm lint` +Expected: no errors. + +- [ ] **Step 2: Run full test suite** + +Run: `pnpm test` +Expected: all tests pass. + +- [ ] **Step 3: Run build** + +Run: `pnpm build` +Expected: clean build, `dist/` produced, no tsc errors. + +- [ ] **Step 4: Smoke-test against a real conflict** + +In a separate terminal, occupy port `3100`: + +```bash +python3 -m http.server 3100 +``` + +In the repo root: + +```bash +node dist/cli.js new test/wt-port-drift-smoke --no-install +``` + +Expected stderr contains a line like: +`Port 3100 (web) in use by Python[]; using 3101 instead.` + +(Substitute a service from the project's actual `wt.config.json`. Repo +without a `wt.config.json` should skip this step.) + +Then clean up: + +```bash +node dist/cli.js remove .worktrees/test-wt-port-drift-smoke +``` + +Stop the python listener. + +- [ ] **Step 5: Final commit (if smoke-test surfaced any tweaks)** + +If steps 1-4 required any small fixes, commit them with a focused message. +Otherwise skip. + +```bash +git status +# if clean, no commit needed +``` From 6cfdd8f7215ba7b79c2eab0c75bfb9c8ed7a3bc2 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:32:58 -0700 Subject: [PATCH 03/12] feat(types): add PortDrift and AllocatedPorts --- src/types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/types.ts b/src/types.ts index 06b7085..21570d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,22 @@ export interface PatchContext { readonly branchName?: string; } +/** A single service whose port had to drift away from its natural slot port */ +export interface PortDrift { + readonly service: string; + readonly requested: number; + readonly assigned: number; + readonly conflict: + | { readonly kind: 'os'; readonly description: string } + | { readonly kind: 'internal'; readonly slot: number; readonly service: string }; +} + +/** Result of allocating ports for a single slot's services */ +export interface AllocatedPorts { + readonly ports: Record; + readonly drifts: readonly PortDrift[]; +} + /** Result of CLI operations for --json output */ export interface CliResult { readonly success: boolean; From caffa5f788c98155a3ecb68cf210142fc83ce6a6 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:35:15 -0700 Subject: [PATCH 04/12] feat(slot-allocator): parse lsof -F output for listener identification --- __tests__/slot-allocator.spec.ts | 21 +++++++++++++++++++++ src/core/slot-allocator.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/__tests__/slot-allocator.spec.ts b/__tests__/slot-allocator.spec.ts index eb4f6ad..c6e4a9d 100644 --- a/__tests__/slot-allocator.spec.ts +++ b/__tests__/slot-allocator.spec.ts @@ -6,6 +6,7 @@ import { findAvailableSlot, findUnavailableServicePorts, validatePortPlan, + parseLsofOutput, } from '../src/core/slot-allocator'; import type { Registry } from '../src/types'; @@ -127,6 +128,26 @@ describe('slot-allocator', () => { expect(unavailable).toEqual([{ service: 'redis', port: address.port }]); }); }); + + describe('parseLsofOutput', () => { + it('parses pid and command from a single listener', () => { + const out = 'p12345\ncnode\nn*:3200\n'; + expect(parseLsofOutput(out)).toEqual({ pid: 12345, command: 'node' }); + }); + + it('returns the first listener when multiple are reported', () => { + const out = 'p12345\ncnode\nn127.0.0.1:3200\np67890\ncpython3\nn*:3200\n'; + expect(parseLsofOutput(out)).toEqual({ pid: 12345, command: 'node' }); + }); + + it('returns null on empty output', () => { + expect(parseLsofOutput('')).toBeNull(); + }); + + it('returns null when only a name field is present', () => { + expect(parseLsofOutput('n*:3200\n')).toBeNull(); + }); + }); }); /** Helper to create a minimal allocation for testing */ diff --git a/src/core/slot-allocator.ts b/src/core/slot-allocator.ts index 8d46ccb..4cc4c1d 100644 --- a/src/core/slot-allocator.ts +++ b/src/core/slot-allocator.ts @@ -130,3 +130,31 @@ export function findAvailableSlot( } return null; } + +/** + * Parse the output of `lsof -F pcn`. Returns the first listener's pid + + * command, or null if the input doesn't contain a complete listener record. + */ +export function parseLsofOutput(output: string): { pid: number; command: string } | null { + let pid: number | null = null; + let command: string | null = null; + for (const line of output.split('\n')) { + if (line.startsWith('p')) { + // Encountering a new pid before completing the previous record means + // the previous record was incomplete; reset. + if (pid !== null && command === null) { + pid = null; + } + const parsed = Number(line.slice(1)); + if (Number.isInteger(parsed)) { + pid = parsed; + } + } else if (line.startsWith('c') && pid !== null && command === null) { + command = line.slice(1); + } + if (pid !== null && command !== null) { + return { pid, command }; + } + } + return null; +} From 6c00d12409d0e9e83c518499ff880bdba1e21c8b Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:39:40 -0700 Subject: [PATCH 05/12] feat(slot-allocator): describeListener via lsof, best-effort --- __tests__/slot-allocator.spec.ts | 30 ++++++++++++++++++++++++++++++ src/core/slot-allocator.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/__tests__/slot-allocator.spec.ts b/__tests__/slot-allocator.spec.ts index c6e4a9d..b3913f4 100644 --- a/__tests__/slot-allocator.spec.ts +++ b/__tests__/slot-allocator.spec.ts @@ -7,6 +7,7 @@ import { findUnavailableServicePorts, validatePortPlan, parseLsofOutput, + describeListener, } from '../src/core/slot-allocator'; import type { Registry } from '../src/types'; @@ -148,6 +149,35 @@ describe('slot-allocator', () => { expect(parseLsofOutput('n*:3200\n')).toBeNull(); }); }); + + describe('describeListener', () => { + it('returns a description for a real local listener', async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP address.'); + } + + const description = await describeListener(address.port); + + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + + // Best-effort: on macOS/linux this should match `[]`. + // On platforms without lsof we fall back to "unknown process". + expect(description).toMatch(/^(.+\[\d+\]|unknown process)$/); + }); + + it('returns "unknown process" when no one is listening', async () => { + // Port 1 is reserved/unprivileged and almost certainly free. + // lsof returns a non-zero exit when no match is found; we treat it + // as "unknown process" rather than throwing. + const description = await describeListener(1); + expect(description).toBe('unknown process'); + }); + }); }); /** Helper to create a minimal allocation for testing */ diff --git a/src/core/slot-allocator.ts b/src/core/slot-allocator.ts index 4cc4c1d..74e11e9 100644 --- a/src/core/slot-allocator.ts +++ b/src/core/slot-allocator.ts @@ -1,6 +1,10 @@ import * as net from 'node:net'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import type { ServiceConfig, Registry } from '../types'; +const execFileAsync = promisify(execFile); + /** * Calculate port assignments for a given slot. * Formula: slot * stride + service.defaultPort @@ -158,3 +162,26 @@ export function parseLsofOutput(output: string): { pid: number; command: string } return null; } + +/** + * Best-effort identification of the process listening on `port`. Returns + * `[]` on darwin/linux when lsof finds a listener; returns + * `"unknown process"` on any failure (no listener, lsof missing, parse + * failure, unsupported platform). Never throws. + */ +export async function describeListener(port: number): Promise { + try { + const { stdout } = await execFileAsync( + 'lsof', + ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'pcn'], + { timeout: 2000 }, + ); + const parsed = parseLsofOutput(stdout); + if (parsed) { + return `${parsed.command}[${parsed.pid}]`; + } + } catch { + // lsof returns non-zero when no match is found, or is missing entirely. + } + return 'unknown process'; +} From 9a9e57106da081768257c77607b3086a504473ea Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:43:44 -0700 Subject: [PATCH 06/12] feat(slot-allocator): allocateServicePorts with per-service drift Co-Authored-By: Claude Sonnet 4.6 --- __tests__/slot-allocator.spec.ts | 111 +++++++++++++++++++++++++++++++ src/core/slot-allocator.ts | 87 +++++++++++++++++++++++- 2 files changed, 197 insertions(+), 1 deletion(-) diff --git a/__tests__/slot-allocator.spec.ts b/__tests__/slot-allocator.spec.ts index b3913f4..7b2809b 100644 --- a/__tests__/slot-allocator.spec.ts +++ b/__tests__/slot-allocator.spec.ts @@ -8,6 +8,7 @@ import { validatePortPlan, parseLsofOutput, describeListener, + allocateServicePorts, } from '../src/core/slot-allocator'; import type { Registry } from '../src/types'; @@ -178,6 +179,116 @@ describe('slot-allocator', () => { expect(description).toBe('unknown process'); }); }); + + describe('allocateServicePorts', () => { + const services = [ + { name: 'web', defaultPort: 3000 }, + { name: 'api', defaultPort: 4000 }, + ] as const; + const stride = 100; + + function emptyRegistry(): Registry { + return { version: 1, allocations: {} }; + } + + it('returns natural ports with no drift when everything is free', async () => { + const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + + expect(result.ports).toEqual({ web: 3200, api: 4200 }); + expect(result.drifts).toEqual([]); + }); + + it('drifts a service whose natural port is bound at the OS level', async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(3200, '127.0.0.1', () => resolve())); + + try { + const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + + expect(result.ports.web).toBe(3201); + expect(result.ports.api).toBe(4200); + expect(result.drifts).toHaveLength(1); + expect(result.drifts[0]).toMatchObject({ + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os' }, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('skips ports already in another slot\'s allocation without probing', async () => { + const registry: Registry = { + version: 1, + allocations: { + '1': { + worktreePath: '/tmp/wt1', + branchName: 'feat/a', + dbName: 'db_wt1', + ports: { web: 3200, api: 4100 }, + createdAt: '2026-04-25T00:00:00.000Z', + }, + }, + }; + + const result = await allocateServicePorts(2, services, stride, registry); + + // web's natural 3200 is reserved by slot 1; drift to 3201. + // api's natural 4200 is free. + expect(result.ports).toEqual({ web: 3201, api: 4200 }); + expect(result.drifts).toEqual([ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'internal', slot: 1, service: 'web' }, + }, + ]); + }); + + it('drifts only the conflicting service in a multi-service config', async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(4200, '127.0.0.1', () => resolve())); + + try { + const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + + expect(result.ports.web).toBe(3200); + expect(result.ports.api).toBe(4201); + expect(result.drifts.map((d) => d.service)).toEqual(['api']); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it('throws when a service exhausts the port space at 65535', async () => { + // Service whose natural port is 65535, with that port internally + // reserved — drift would have to go to 65536, which we refuse. + const registry: Registry = { + version: 1, + allocations: { + '1': { + worktreePath: '/tmp/wt1', + branchName: 'feat/a', + dbName: 'db_wt1', + ports: { edge: 65535 }, + createdAt: '2026-04-25T00:00:00.000Z', + }, + }, + }; + const edgeServices = [{ name: 'edge', defaultPort: 65535 }] as const; + + await expect( + allocateServicePorts(0, edgeServices, 0, registry), + ).rejects.toThrow(/No available port for service 'edge'/); + }); + }); }); /** Helper to create a minimal allocation for testing */ diff --git a/src/core/slot-allocator.ts b/src/core/slot-allocator.ts index 74e11e9..c72cd4a 100644 --- a/src/core/slot-allocator.ts +++ b/src/core/slot-allocator.ts @@ -1,7 +1,7 @@ import * as net from 'node:net'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import type { ServiceConfig, Registry } from '../types'; +import type { ServiceConfig, Registry, PortDrift, AllocatedPorts } from '../types'; const execFileAsync = promisify(execFile); @@ -80,6 +80,91 @@ async function isPortAvailable(port: number): Promise { }); } +/** + * Allocate ports for each service in the slot, drifting forward by 1 past + * any port that is either already bound at the OS level or already in use + * by another slot's allocation in the registry. + * + * Drift is per-service: only conflicting services move; the rest stay at + * their natural slot port. Internal conflicts (registry collisions) are + * resolved without probing the OS. OS conflicts trigger best-effort + * listener identification via `describeListener`. + * + * Caps at port 65535. Throws if a service can't find a free port before + * the ceiling. + */ +export async function allocateServicePorts( + slot: number, + services: readonly ServiceConfig[], + stride: number, + registry: Registry, +): Promise { + // Build a map: port -> { slot, service } for every port already in the + // registry across all allocations. + const reserved = new Map(); + for (const [slotStr, allocation] of Object.entries(registry.allocations)) { + const owningSlot = Number(slotStr); + for (const [serviceName, port] of Object.entries(allocation.ports)) { + reserved.set(port, { slot: owningSlot, service: serviceName }); + } + } + + const ports: Record = {}; + const drifts: PortDrift[] = []; + + for (const service of services) { + const natural = slot * stride + service.defaultPort; + let candidate = natural; + let conflict: PortDrift['conflict'] | null = null; + + while (candidate <= 65535) { + const internalOwner = reserved.get(candidate); + if (internalOwner) { + if (conflict === null) { + conflict = { + kind: 'internal', + slot: internalOwner.slot, + service: internalOwner.service, + }; + } + candidate++; + continue; + } + + if (await isPortAvailable(candidate)) { + ports[service.name] = candidate; + // Reserve this port for any later service in the same allocation + // so two services in one slot can't pick the same drifted port. + reserved.set(candidate, { slot, service: service.name }); + break; + } + + if (conflict === null) { + const description = await describeListener(candidate); + conflict = { kind: 'os', description }; + } + candidate++; + } + + if (ports[service.name] === undefined) { + throw new Error( + `No available port for service '${service.name}' starting from ${natural}; reached 65535.`, + ); + } + + if (candidate !== natural) { + drifts.push({ + service: service.name, + requested: natural, + assigned: candidate, + conflict: conflict!, + }); + } + } + + return { ports, drifts }; +} + export async function findUnavailableServicePorts( ports: Record, ): Promise> { From f5d17dd4a4f96cb549eb586d00fe94a3028717be Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:48:35 -0700 Subject: [PATCH 07/12] test(slot-allocator): use ephemeral ports in clean-allocation test; document conflict invariant Replace hard-coded 3200/4200 with OS-assigned ephemeral ports to prevent spurious test failures when those ports are bound on the developer's machine or CI runner. Add an explanatory comment next to the conflict! non-null assertion to document the invariant that makes it safe. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/slot-allocator.spec.ts | 27 +++++++++++++++++++++++++-- src/core/slot-allocator.ts | 2 ++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/__tests__/slot-allocator.spec.ts b/__tests__/slot-allocator.spec.ts index 7b2809b..867d02b 100644 --- a/__tests__/slot-allocator.spec.ts +++ b/__tests__/slot-allocator.spec.ts @@ -192,9 +192,32 @@ describe('slot-allocator', () => { } it('returns natural ports with no drift when everything is free', async () => { - const result = await allocateServicePorts(2, services, stride, emptyRegistry()); + // Use OS-assigned ephemeral ports so this test doesn't flake on + // machines where the hard-coded 3200/4200 happen to be in use. + const probeA = net.createServer(); + const probeB = net.createServer(); + await new Promise((resolve) => probeA.listen(0, '127.0.0.1', () => resolve())); + await new Promise((resolve) => probeB.listen(0, '127.0.0.1', () => resolve())); + const addrA = probeA.address(); + const addrB = probeB.address(); + if (!addrA || typeof addrA === 'string' || !addrB || typeof addrB === 'string') { + throw new Error('Expected TCP addresses.'); + } + const portA = addrA.port; + const portB = addrB.port; + await new Promise((resolve, reject) => probeA.close((err) => (err ? reject(err) : resolve()))); + await new Promise((resolve, reject) => probeB.close((err) => (err ? reject(err) : resolve()))); + + const slot = 2; + const localStride = 100; + const localServices = [ + { name: 'web', defaultPort: portA - slot * localStride }, + { name: 'api', defaultPort: portB - slot * localStride }, + ] as const; + + const result = await allocateServicePorts(slot, localServices, localStride, emptyRegistry()); - expect(result.ports).toEqual({ web: 3200, api: 4200 }); + expect(result.ports).toEqual({ web: portA, api: portB }); expect(result.drifts).toEqual([]); }); diff --git a/src/core/slot-allocator.ts b/src/core/slot-allocator.ts index c72cd4a..31da5d9 100644 --- a/src/core/slot-allocator.ts +++ b/src/core/slot-allocator.ts @@ -157,6 +157,8 @@ export async function allocateServicePorts( service: service.name, requested: natural, assigned: candidate, + // Non-null: any drift implies at least one blocking iteration + // above, which always sets `conflict` on first occurrence. conflict: conflict!, }); } From bf613f1211e57d60b126e756dfa421022eb74240 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 00:52:37 -0700 Subject: [PATCH 08/12] feat(new): use allocateServicePorts for port assignment with drift logging Co-Authored-By: Claude Sonnet 4.6 --- src/commands/new.spec.ts | 57 +++++++++++++++++++++++++++++++--------- src/commands/new.ts | 51 ++++++++++++++++++----------------- 2 files changed, 72 insertions(+), 36 deletions(-) diff --git a/src/commands/new.spec.ts b/src/commands/new.spec.ts index cd3e381..7d3c786 100644 --- a/src/commands/new.spec.ts +++ b/src/commands/new.spec.ts @@ -10,10 +10,9 @@ jest.mock('../core/registry', () => ({ })); jest.mock('../core/slot-allocator', () => ({ - calculatePorts: jest.fn(), calculateDbName: jest.fn(), - findAvailablePortSafeSlot: jest.fn(), - findUnavailableServicePorts: jest.fn(), + findAvailableSlot: jest.fn(), + allocateServicePorts: jest.fn(), })); jest.mock('../core/env-patcher', () => ({ @@ -49,9 +48,9 @@ jest.mock('node:child_process', () => ({ import { addAllocation, readRegistry, writeRegistry } from '../core/registry'; import { - calculatePorts, calculateDbName, - findAvailablePortSafeSlot, + findAvailableSlot, + allocateServicePorts, } from '../core/slot-allocator'; import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; import { createDatabase, databaseExists, dropDatabase } from '../core/database'; @@ -74,10 +73,10 @@ import type { WorktreeBranchSelection } from '../core/git'; const mockReadRegistry = readRegistry as jest.MockedFunction; const mockWriteRegistry = writeRegistry as jest.MockedFunction; const mockAddAllocation = addAllocation as jest.MockedFunction; -const mockCalculatePorts = calculatePorts as jest.MockedFunction; const mockCalculateDbName = calculateDbName as jest.MockedFunction; -const mockFindAvailablePortSafeSlot = findAvailablePortSafeSlot as jest.MockedFunction< - typeof findAvailablePortSafeSlot +const mockFindAvailableSlot = findAvailableSlot as jest.MockedFunction; +const mockAllocateServicePorts = allocateServicePorts as jest.MockedFunction< + typeof allocateServicePorts >; const mockCopyAndPatchAllEnvFiles = copyAndPatchAllEnvFiles as jest.MockedFunction; @@ -139,8 +138,8 @@ describe('new command branch selection', () => { mockGetMainWorktreePath.mockReturnValue(tmpDir); mockLoadConfig.mockReturnValue(config); mockReadRegistry.mockReturnValue({ version: 1, allocations: {} } satisfies Registry); - mockFindAvailablePortSafeSlot.mockResolvedValue(2); - mockCalculatePorts.mockReturnValue({ web: 3200 }); + mockFindAvailableSlot.mockReturnValue(2); + mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3200 }, drifts: [] }); mockCalculateDbName.mockReturnValue('myapp_wt2'); mockDatabaseExists.mockResolvedValue(false); mockCreateDatabase.mockResolvedValue(); @@ -236,6 +235,40 @@ describe('new command branch selection', () => { expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('Source: origin/feat/auth'); }); + + it('logs drift lines to stderr and includes portDrifts in JSON output', async () => { + mockResolveWorktreeBranch.mockReturnValue(originSelection()); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ], + }); + + await newCommand('feat/auth', { json: true, install: false }); + + expect(stderrOutput(stderrSpy)).toContain( + 'Port 3200 (web) in use by node[12345]; using 3201 instead.', + ); + const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { + success: boolean; + data: { portDrifts: unknown[] }; + }; + expect(output.success).toBe(true); + expect(output.data.portDrifts).toEqual([ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ]); + }); }); describe('new command rollback on failure', () => { @@ -293,8 +326,8 @@ describe('new command rollback on failure', () => { mockGetMainWorktreePath.mockReturnValue(tmpDir); mockLoadConfig.mockReturnValue(configWithDocker); mockReadRegistry.mockReturnValue({ version: 1, allocations: {} } satisfies Registry); - mockFindAvailablePortSafeSlot.mockResolvedValue(2); - mockCalculatePorts.mockReturnValue({ web: 3200, redis: 6579 }); + mockFindAvailableSlot.mockReturnValue(2); + mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3200, redis: 6579 }, drifts: [] }); mockCalculateDbName.mockReturnValue('myapp_wt2'); mockEnsureDockerServices.mockReturnValue({ projectName: 'wt-2-myapp-deadbeef', diff --git a/src/commands/new.ts b/src/commands/new.ts index d31b627..61ac113 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -1,10 +1,9 @@ import * as path from 'node:path'; import { readRegistry, writeRegistry, addAllocation } from '../core/registry'; import { - calculatePorts, calculateDbName, - findAvailablePortSafeSlot, - findUnavailableServicePorts, + findAvailableSlot, + allocateServicePorts, } from '../core/slot-allocator'; import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; import { createDatabase, databaseExists, dropDatabase } from '../core/database'; @@ -22,7 +21,7 @@ import { } from '../core/git'; import { extractErrorMessage, formatJson, formatSetupSummary, success, error } from '../output'; import { loadConfig } from './setup'; -import type { Allocation } from '../types'; +import type { Allocation, PortDrift } from '../types'; import { execSync } from 'node:child_process'; import * as fs from 'node:fs'; @@ -36,6 +35,7 @@ export interface CreateWorktreeResult { readonly slot: number; readonly allocation: Allocation; readonly branchSelection: WorktreeBranchSelection; + readonly portDrifts: readonly PortDrift[]; } /** Read DATABASE_URL from the main worktree's .env file */ @@ -63,7 +63,7 @@ export async function createNewWorktree( const config = loadConfig(mainRoot); let registry = readRegistry(mainRoot); - // Determine slot + // Determine slot — port availability no longer affects slot choice. let slot: number; if (options.slot !== undefined) { slot = parseInt(options.slot, 10); @@ -73,25 +73,12 @@ export async function createNewWorktree( if (String(slot) in registry.allocations) { throw new Error(`Slot ${slot} is already occupied.`); } - const requestedPorts = calculatePorts(slot, config.services, config.portStride); - const unavailablePorts = await findUnavailableServicePorts(requestedPorts); - if (unavailablePorts.length > 0) { - const detail = unavailablePorts - .map(({ service, port }) => `${service}:${port}`) - .join(', '); - throw new Error(`Slot ${slot} has ports already in use: ${detail}`); - } } else { - const available = await findAvailablePortSafeSlot( - registry, - config.maxSlots, - config.services, - config.portStride, - ); + const available = findAvailableSlot(registry, config.maxSlots); if (available === null) { throw new Error( - `All ${config.maxSlots} slots are occupied or blocked by ports already in use. ` + - 'Remove a worktree, stop conflicting services, or increase maxSlots.', + `All ${config.maxSlots} slots are occupied. ` + + 'Remove a worktree or increase maxSlots.', ); } slot = available; @@ -110,7 +97,22 @@ export async function createNewWorktree( log(describeBranchSelection(branchSelection)); const dbName = calculateDbName(slot, config.baseDatabaseName); - const ports = calculatePorts(slot, config.services, config.portStride); + const { ports, drifts: portDrifts } = await allocateServicePorts( + slot, + config.services, + config.portStride, + registry, + ); + for (const drift of portDrifts) { + const detail = + drift.conflict.kind === 'os' + ? `in use by ${drift.conflict.description}` + : `reserved by slot ${drift.conflict.slot} (${drift.conflict.service})`; + warn( + `Port ${drift.requested} (${drift.service}) ${detail}; ` + + `using ${drift.assigned} instead.`, + ); + } const databaseUrl = readDatabaseUrl(mainRoot); // Track what each step has created so we can roll back on failure. Resource @@ -224,7 +226,7 @@ export async function createNewWorktree( } log(`Ready — slot ${slot}, branch '${actualBranch}'.`); - return { slot, allocation, branchSelection }; + return { slot, allocation, branchSelection, portDrifts }; } /** Create a new worktree with full environment isolation */ @@ -233,7 +235,7 @@ export async function newCommand( options: NewOptions, ): Promise { try { - const { slot, allocation, branchSelection } = await createNewWorktree(branchName, { + const { slot, allocation, branchSelection, portDrifts } = await createNewWorktree(branchName, { ...options, quiet: options.json, }); @@ -246,6 +248,7 @@ export async function newCommand( ...allocation, branchSource: branchSelection.source, branchSourceLabel: branchSelection.sourceLabel, + portDrifts, }), ), ); From 0387570569e57fe9c63855d1194a4cb5f88e43b6 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 01:13:38 -0700 Subject: [PATCH 09/12] fix(new): suppress drift stderr in JSON mode and add internal/explicit-slot test coverage Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/new.spec.ts | 68 ++++++++++++++++++++++++++++++++++++++-- src/commands/new.ts | 2 +- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/commands/new.spec.ts b/src/commands/new.spec.ts index 7d3c786..541bbc2 100644 --- a/src/commands/new.spec.ts +++ b/src/commands/new.spec.ts @@ -236,7 +236,7 @@ describe('new command branch selection', () => { expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('Source: origin/feat/auth'); }); - it('logs drift lines to stderr and includes portDrifts in JSON output', async () => { + it('logs drift lines to stderr in human mode', async () => { mockResolveWorktreeBranch.mockReturnValue(originSelection()); mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3201 }, @@ -250,11 +250,51 @@ describe('new command branch selection', () => { ], }); - await newCommand('feat/auth', { json: true, install: false }); + await newCommand('feat/auth', { json: false, install: false }); expect(stderrOutput(stderrSpy)).toContain( 'Port 3200 (web) in use by node[12345]; using 3201 instead.', ); + }); + + it('formats internal-conflict drift lines correctly', async () => { + mockResolveWorktreeBranch.mockReturnValue(originSelection()); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'internal', slot: 1, service: 'web' }, + }, + ], + }); + + await newCommand('feat/auth', { json: false, install: false }); + + expect(stderrOutput(stderrSpy)).toContain( + 'Port 3200 (web) reserved by slot 1 (web); using 3201 instead.', + ); + }); + + it('suppresses drift stderr lines in JSON mode but includes portDrifts in payload', async () => { + mockResolveWorktreeBranch.mockReturnValue(originSelection()); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ], + }); + + await newCommand('feat/auth', { json: true, install: false }); + + expect(stderrOutput(stderrSpy)).not.toContain('Port 3200'); const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { success: boolean; data: { portDrifts: unknown[] }; @@ -269,6 +309,30 @@ describe('new command branch selection', () => { }, ]); }); + + it('uses the explicit slot when --slot is provided and goes through allocateServicePorts', async () => { + mockResolveWorktreeBranch.mockReturnValue(originSelection()); + mockReadRegistry.mockReturnValue({ version: 1, allocations: {} }); + mockCalculateDbName.mockReturnValue('myapp_wt3'); + mockAllocateServicePorts.mockResolvedValue({ ports: { web: 3300 }, drifts: [] }); + mockAddAllocation.mockReturnValue({ + version: 1, + allocations: { + '3': { ...allocation, dbName: 'myapp_wt3', ports: { web: 3300 } }, + }, + }); + + const result = await createNewWorktree('feat/auth', { install: false, slot: '3' }); + + expect(result.slot).toBe(3); + expect(mockFindAvailableSlot).not.toHaveBeenCalled(); + expect(mockAllocateServicePorts).toHaveBeenCalledWith( + 3, + config.services, + config.portStride, + expect.any(Object), + ); + }); }); describe('new command rollback on failure', () => { diff --git a/src/commands/new.ts b/src/commands/new.ts index 61ac113..6cdb945 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -108,7 +108,7 @@ export async function createNewWorktree( drift.conflict.kind === 'os' ? `in use by ${drift.conflict.description}` : `reserved by slot ${drift.conflict.slot} (${drift.conflict.service})`; - warn( + log( `Port ${drift.requested} (${drift.service}) ${detail}; ` + `using ${drift.assigned} instead.`, ); From f02d12ff74bd5cb4cae0a0bf2aa75b21232c4e2b Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 01:18:13 -0700 Subject: [PATCH 10/12] feat(setup): use allocateServicePorts; reuse registered ports for existing allocation --- src/commands/setup.spec.ts | 208 +++++++++++++++++++++++++++++++++++++ src/commands/setup.ts | 60 ++++++----- 2 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 src/commands/setup.spec.ts diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts new file mode 100644 index 0000000..4bba2bb --- /dev/null +++ b/src/commands/setup.spec.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +jest.mock('../core/registry', () => ({ + readRegistry: jest.fn(), + writeRegistry: jest.fn(), + addAllocation: jest.fn(), + findByPath: jest.fn(), +})); + +jest.mock('../core/slot-allocator', () => ({ + calculateDbName: jest.fn(), + findAvailableSlot: jest.fn(), + allocateServicePorts: jest.fn(), + validatePortPlan: jest.fn(), +})); + +jest.mock('../core/env-patcher', () => ({ + copyAndPatchAllEnvFiles: jest.fn(), +})); + +jest.mock('../core/database', () => ({ + createDatabase: jest.fn(), + databaseExists: jest.fn(), +})); + +jest.mock('../core/docker-services', () => ({ + ensureDockerServices: jest.fn(), +})); + +jest.mock('../core/git', () => ({ + getMainWorktreePath: jest.fn(), + isMainWorktree: jest.fn(), + getBranchName: jest.fn(), +})); + +import { readRegistry, writeRegistry, addAllocation, findByPath } from '../core/registry'; +import { + calculateDbName, + findAvailableSlot, + allocateServicePorts, +} from '../core/slot-allocator'; +import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; +import { createDatabase, databaseExists } from '../core/database'; +import { ensureDockerServices } from '../core/docker-services'; +import { getMainWorktreePath, isMainWorktree, getBranchName } from '../core/git'; +import { setupCommand } from './setup'; +import type { Allocation, WtConfig } from '../types'; + +const mockReadRegistry = readRegistry as jest.MockedFunction; +const mockWriteRegistry = writeRegistry as jest.MockedFunction; +const mockAddAllocation = addAllocation as jest.MockedFunction; +const mockFindByPath = findByPath as jest.MockedFunction; +const mockCalculateDbName = calculateDbName as jest.MockedFunction; +const mockFindAvailableSlot = findAvailableSlot as jest.MockedFunction; +const mockAllocateServicePorts = allocateServicePorts as jest.MockedFunction< + typeof allocateServicePorts +>; +const mockCopyAndPatchAllEnvFiles = + copyAndPatchAllEnvFiles as jest.MockedFunction; +const mockCreateDatabase = createDatabase as jest.MockedFunction; +const mockDatabaseExists = databaseExists as jest.MockedFunction; +const mockEnsureDockerServices = ensureDockerServices as jest.MockedFunction< + typeof ensureDockerServices +>; +const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction; +const mockIsMainWorktree = isMainWorktree as jest.MockedFunction; +const mockGetBranchName = getBranchName as jest.MockedFunction; + +const config: WtConfig = { + baseDatabaseName: 'myapp', + baseWorktreePath: '.worktrees', + portStride: 100, + maxSlots: 50, + services: [{ name: 'web', defaultPort: 3000 }], + dockerServices: [], + envFiles: [], + postSetup: [], + autoInstall: true, +}; + +describe('setup command', () => { + let tmpDir: string; + let worktreeDir: string; + let stderrSpy: jest.SpiedFunction; + let consoleLogSpy: jest.SpiedFunction; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-setup-test-')); + worktreeDir = path.join(tmpDir, '.worktrees', 'feat-auth'); + fs.mkdirSync(worktreeDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.env'), + 'DATABASE_URL=postgresql://user:pw@localhost:5432/myapp\n', + 'utf-8', + ); + fs.writeFileSync( + path.join(tmpDir, 'wt.config.json'), + JSON.stringify(config), + 'utf-8', + ); + + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + mockGetMainWorktreePath.mockReturnValue(tmpDir); + mockIsMainWorktree.mockReturnValue(false); + mockGetBranchName.mockReturnValue('feat/auth'); + mockReadRegistry.mockReturnValue({ version: 1, allocations: {} }); + mockCalculateDbName.mockReturnValue('myapp_wt2'); + mockDatabaseExists.mockResolvedValue(true); + mockCreateDatabase.mockResolvedValue(); + mockEnsureDockerServices.mockReturnValue({ projectName: 'wt-2-myapp', services: [] }); + mockAddAllocation.mockImplementation((registry, slot, allocation) => ({ + ...registry, + allocations: { ...registry.allocations, [String(slot)]: allocation }, + })); + mockWriteRegistry.mockImplementation(() => {}); + mockCopyAndPatchAllEnvFiles.mockImplementation(() => {}); + process.exitCode = 0; + }); + + afterEach(() => { + stderrSpy.mockRestore(); + consoleLogSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.clearAllMocks(); + }); + + it('logs drift to stderr in human mode for fresh allocation', async () => { + mockFindByPath.mockReturnValue(null); + mockFindAvailableSlot.mockReturnValue(2); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ], + }); + + await setupCommand(worktreeDir, { json: false, install: false }); + + const stderr = stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(stderr).toContain( + 'Port 3200 (web) in use by node[12345]; using 3201 instead.', + ); + }); + + it('suppresses drift stderr in JSON mode but includes portDrifts in payload', async () => { + mockFindByPath.mockReturnValue(null); + mockFindAvailableSlot.mockReturnValue(2); + mockAllocateServicePorts.mockResolvedValue({ + ports: { web: 3201 }, + drifts: [ + { + service: 'web', + requested: 3200, + assigned: 3201, + conflict: { kind: 'os', description: 'node[12345]' }, + }, + ], + }); + + await setupCommand(worktreeDir, { json: true, install: false }); + + const stderr = stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(stderr).not.toContain('Port 3200'); + + const payload = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { + success: boolean; + data: { portDrifts: unknown[]; ports: Record }; + }; + expect(payload.success).toBe(true); + expect(payload.data.ports).toEqual({ web: 3201 }); + expect(payload.data.portDrifts).toHaveLength(1); + }); + + it('reuses registered ports verbatim for an existing allocation (no re-allocation)', async () => { + const allocation: Allocation = { + worktreePath: worktreeDir, + branchName: 'feat/auth', + dbName: 'myapp_wt2', + ports: { web: 3207 }, // drifted in a previous run + createdAt: '2026-04-25T00:00:00.000Z', + }; + mockFindByPath.mockReturnValue([2, allocation]); + + await setupCommand(worktreeDir, { json: true, install: false }); + + expect(mockAllocateServicePorts).not.toHaveBeenCalled(); + expect(mockEnsureDockerServices).toHaveBeenCalledWith( + expect.objectContaining({ ports: { web: 3207 } }), + ); + + const payload = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { + success: boolean; + data: { ports: Record; portDrifts: unknown[] }; + }; + expect(payload.data.ports).toEqual({ web: 3207 }); + expect(payload.data.portDrifts).toEqual([]); + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 16d4c88..34e6fbd 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -4,10 +4,9 @@ import { execSync } from 'node:child_process'; import { configSchema } from '../schemas/config.schema'; import { readRegistry, writeRegistry, addAllocation, findByPath } from '../core/registry'; import { - calculatePorts, calculateDbName, - findAvailablePortSafeSlot, - findUnavailableServicePorts, + findAvailableSlot, + allocateServicePorts, validatePortPlan, } from '../core/slot-allocator'; import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; @@ -17,7 +16,7 @@ import { } from '../core/docker-services'; import { getMainWorktreePath, isMainWorktree, getBranchName } from '../core/git'; import { extractErrorMessage, formatJson, formatSetupSummary, success, error } from '../output'; -import type { Allocation, WtConfig } from '../types'; +import type { Allocation, PortDrift, WtConfig } from '../types'; interface SetupOptions { readonly json: boolean; @@ -107,22 +106,25 @@ export async function setupCommand( const config = loadConfig(mainRoot); let registry = readRegistry(mainRoot); - // Reuse existing allocation or allocate a new slot + // Reuse existing allocation or allocate a new slot. + // For an existing allocation, reuse the registered ports verbatim so + // re-runs of `wt setup` don't overwrite drifted ports with formula + // values. const existing = findByPath(registry, worktreePath); let slot: number; + let ports: Record; + let portDrifts: readonly PortDrift[]; + if (existing) { slot = existing[0]; + ports = existing[1].ports; + portDrifts = []; } else { - const available = await findAvailablePortSafeSlot( - registry, - config.maxSlots, - config.services, - config.portStride, - ); + const available = findAvailableSlot(registry, config.maxSlots); if (available === null) { const msg = - `All ${config.maxSlots} slots are occupied or blocked by ports already in use. ` + - 'Remove a worktree, stop conflicting services, or increase maxSlots.'; + `All ${config.maxSlots} slots are occupied. ` + + 'Remove a worktree or increase maxSlots.'; if (options.json) { console.log(formatJson(error('NO_SLOTS', msg))); } else { @@ -132,19 +134,29 @@ export async function setupCommand( return; } slot = available; + const allocated = await allocateServicePorts( + slot, + config.services, + config.portStride, + registry, + ); + ports = allocated.ports; + portDrifts = allocated.drifts; + if (!options.json) { + for (const drift of portDrifts) { + const detail = + drift.conflict.kind === 'os' + ? `in use by ${drift.conflict.description}` + : `reserved by slot ${drift.conflict.slot} (${drift.conflict.service})`; + process.stderr.write( + `Port ${drift.requested} (${drift.service}) ${detail}; ` + + `using ${drift.assigned} instead.\n`, + ); + } + } } const dbName = calculateDbName(slot, config.baseDatabaseName); - const ports = calculatePorts(slot, config.services, config.portStride); - if (!existing) { - const unavailablePorts = await findUnavailableServicePorts(ports); - if (unavailablePorts.length > 0) { - const detail = unavailablePorts - .map(({ service, port }) => `${service}:${port}`) - .join(', '); - throw new Error(`Slot ${slot} has ports already in use: ${detail}`); - } - } const branchName = getBranchName(worktreePath); // Create database if it doesn't exist @@ -192,7 +204,7 @@ export async function setupCommand( } if (options.json) { - console.log(formatJson(success({ slot, ...allocation }))); + console.log(formatJson(success({ slot, ...allocation, portDrifts }))); } else { console.log(formatSetupSummary(slot, allocation)); } From b942a285a6cb8326a3fbd641350832a6e230db9d Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 01:23:05 -0700 Subject: [PATCH 11/12] refactor(slot-allocator): drop findUnavailableServicePorts and findAvailablePortSafeSlot --- __tests__/slot-allocator.spec.ts | 23 ------------------- src/core/slot-allocator.ts | 38 -------------------------------- 2 files changed, 61 deletions(-) diff --git a/__tests__/slot-allocator.spec.ts b/__tests__/slot-allocator.spec.ts index 867d02b..7557b1e 100644 --- a/__tests__/slot-allocator.spec.ts +++ b/__tests__/slot-allocator.spec.ts @@ -4,7 +4,6 @@ import { calculatePorts, calculateDbName, findAvailableSlot, - findUnavailableServicePorts, validatePortPlan, parseLsofOutput, describeListener, @@ -109,28 +108,6 @@ describe('slot-allocator', () => { }); }); - describe('findUnavailableServicePorts', () => { - it('detects ports already in use on localhost', async () => { - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('Expected a TCP address.'); - } - - const unavailable = await findUnavailableServicePorts({ redis: address.port }); - - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - - expect(unavailable).toEqual([{ service: 'redis', port: address.port }]); - }); - }); - describe('parseLsofOutput', () => { it('parses pid and command from a single listener', () => { const out = 'p12345\ncnode\nn*:3200\n'; diff --git a/src/core/slot-allocator.ts b/src/core/slot-allocator.ts index 31da5d9..61c16a4 100644 --- a/src/core/slot-allocator.ts +++ b/src/core/slot-allocator.ts @@ -167,44 +167,6 @@ export async function allocateServicePorts( return { ports, drifts }; } -export async function findUnavailableServicePorts( - ports: Record, -): Promise> { - const entries = Object.entries(ports); - const checks = await Promise.all( - entries.map(async ([service, port]) => ({ - service, - port, - available: await isPortAvailable(port), - })), - ); - - return checks - .filter((item) => !item.available) - .map(({ service, port }) => ({ service, port })); -} - -export async function findAvailablePortSafeSlot( - registry: Registry, - maxSlots: number, - services: readonly ServiceConfig[], - stride: number, -): Promise { - for (let slot = 1; slot <= maxSlots; slot++) { - if (String(slot) in registry.allocations) { - continue; - } - - const ports = calculatePorts(slot, services, stride); - const unavailable = await findUnavailableServicePorts(ports); - if (unavailable.length === 0) { - return slot; - } - } - - return null; -} - /** * Find the next available slot in the registry. * Scans slots 1..maxSlots and returns the first unoccupied one. From 5c9b3382d2522faeeaa1bf7e50c5af6e280046ff Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 25 Apr 2026 01:45:46 -0700 Subject: [PATCH 12/12] bump version to 0.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b89b16a..4966c39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tokenbooks/wt", - "version": "0.4.0", + "version": "0.4.1", "description": "Git worktree environment isolation CLI", "license": "MIT", "repository": {