# A1 — Lane runtime isolation: slots, ports, profile hooks, detached lifecycle **Status:** approved 2026-08-03. Roadmap it belongs to: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`. ## Problem A lane isolates **code** and nothing else. `server/lib/worktree.js` gives each lane its own git worktree and branch, so two agents never overwrite each other's files — but they share every other resource: the same ports, the same database, the same `.env`, the same upload directory. Booting two lanes' stacks at once does not work. Shipyard (`AgentWorkflow/`) solved this with per-lane ports, database, Redis logical index and `.env`. That is **not** containerization — every lane runs on the host as the same user. Its isolation is *resource namespacing at the application layer*. ## Scope Shipyard parity is six independent subsystems (see the roadmap). A — runtime isolation — goes first because it blocks the rest, and splits into three: - **A1 (this spec)** — a lane can boot its stack on isolated ports and directories, and the stack survives a dashboard restart. Profiles are hand-written. - **A2** — data isolation: `.env` seeding, database create/drop/migrate/seed, Redis index. - **A3** — stack detection + `ccam lane profile init` scaffolding, designed **from** the real profiles written during A1/A2. The split point matters. Shipyard was genericized but its `profiles/` is still empty (`PROFILE=_template`, `SOURCE_REPO=""`, `state/` empty) — a clean seam that never ran against a real app. Designing presets before a real profile exists repeats exactly that. **A1 done means:** `ccam lane up` boots a lane's stack on ports no other lane uses, and `ccam update` (or any dashboard restart) leaves running stacks alone. ## Decisions | | Choice | Rationale | |---|---|---| | Profile location | `/.ccam/profile/` | CCAM is multi-repo (lanes keyed by `cwd`); one global `PROFILE` is wrong the moment two repos have lanes. A profile that lives in the repo is already present in a fresh worktree. | | Engine | Node, `server/lib/` | `execFile` with argument arrays (repo rule: never build a git/shell command as a string). Shares the SQLite DB and the websocket. Hooks stay shell scripts; the engine spawns them. | | Resource model | Declared per repo | `PORTS="api fe worker"`, `LANE_DIRS="…"`. An app with no database declares none, so A2 generates no database hooks — no dead code. | | Process ownership | Fully detached | pid files outside the worktree; a dashboard restart does not touch a running stack. | | Port allocation | Base + slot, step aside on collision, record the real number | Predictable in the common case, does not hard-fail when a port is busy. | | Adopted lanes | May `up`/`down`; every write path refused | `up` only runs what the user can already run themselves. Writes into their `cwd` (A2) are refused outright. | | Runtime state | DB owns allocation, disk owns handles, up/down **computed on read** | A process dies to OOM, `kill`, or a reboot — caching a truth you do not control buys ghost state. Slot allocation is the opposite: fully controlled, needs to be race-free, so it belongs in the DB. | ## Invariants 1. **The runtime never writes `stage`, `status`, or `notes`** — only `slot` and `ports`. In CCAM `status=running` means *an agent is working*, not *a stack is up*; conflating the two breaks `classifyLiveness`. Boot failures surface through `GET /runtime` and the hook log. Same reasoning as "the console never writes a lane's stage". 2. **CCAM still does not orchestrate.** A1 adds *primitives* (`up`/`down`/`hook`) that a session calls. The dashboard chains nothing, evaluates no gate, retries nothing. 3. **No shell command built as a string.** `execFile` + argv arrays everywhere. Hooks are the deliberate exception — they *are* scripts, spawned as `bash ` with a fixed argv, never interpolated. 4. **`profile.env` is parsed, never sourced.** Sourcing arbitrary shell from a repo into the dashboard process is a code-execution path. Hooks are executed on purpose; config is only read. 5. **Adopted lanes stay non-destroyable** (`assertManaged` in `worktree.js`). A1 adds no bypass. ## Design ### Schema (`server/db.js`) ```sql ALTER TABLE lanes ADD COLUMN slot INTEGER; ALTER TABLE lanes ADD COLUMN ports TEXT NOT NULL DEFAULT '{}'; CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL; ``` Added with the existing probe-each-column-independently migration pattern. `ports` is parsed by the same `hydrate()` branch that already handles `stages`/`links`, corrupt-blob fallback included. `slot` and `ports` join `PROVISIONING_FIELDS` and stay **out of** `PATCHABLE`, for the reason `kind` is excluded: a client that can set `slot` can change the slot-derived `DB_NAME` A2 will use — which aims a drop at another database. No column records "is up". Deliberate. ### Modules | File | Responsibility | |---|---| | `server/lib/ports.js` | `isListening(port)` via a short-timeout `net.connect`; `listenerPids(port)` via `lsof`, falling back to `ss`, returning `[]` with a one-time warning when neither exists. | | `server/lib/lane-slots.js` | `allocateSlot()` — lowest free in `[1, LANE_MAX_SLOTS]` (default 9), called inside the existing `withLaneLock`, with the unique index as the backstop. `releaseSlot()` on `remove` only — **not** on `reset`, or a lane's ports and database move under a running session. `resolvePorts(slot, profile)` — prefers `PORT_BASE_ + slot`, then `+100`, `+200`… (the last digit stays the slot, so the number still reads as "lane 3"), up to 10 tries before `EPORTBUSY` naming the occupying pid. | | `server/lib/lane-profile.js` | `resolveProfile(lane)` — looks in `lane.cwd/.ccam/profile/` **first**, `lane.source_repo/.ccam/profile/` second, because the profile lives in the repo and a branch that edits its hooks must run its own. `runHook(lane, name, args, {onLine})` — fixed hook-name allowlist, `bash ` with an argv array, Shipyard's env contract verbatim so its profiles port unchanged (`LANE` = the **slot**, `LANE_DIR`, `RUN_DIR`, `LOG_DIR`, `PROFILE_DIR`, `SOURCE_REPO`, `_PORT` per declared port), `harness_spawn` exported as a bash function writing the same `$RUN_DIR/.pid` + `$LOG_DIR/.log`, the `GIT_*` scrub from `worktree.js:git()`, and output streamed both to the log file and to a new `lane_hook_output` websocket message. | | `server/lib/lane-runtime.js` | `upLane` (defensive down → mkdir run/log/`LANE_DIRS` → `boot` → `health`; no `bootstrap`, which belongs to provision/reset as in `lane-up.sh`), `downLane` (recursive pid-tree kill from the pid files — ported from `lane-down.sh:kill_tree`, which exists because detached uvicorn/celery prefork children were being missed — then a port-listener backstop; idempotent, sleep-free), `runtimeFacts` (**computed**: `kill -0` per pid file, `isListening` per port, plus `last-error.json`). | State lives at `LANES_ROOT/.state/lane/{run,logs}/`, **outside** the worktree: `lane reset` runs `git clean -fd`, which would sweep pid files mid-flight. **Adopt-after-restart costs no code.** Pid files are on disk, detached processes keep running, and `runtimeFacts` recomputes on read. That is the payoff of making up/down computed rather than stored. ### Routes (`server/routes/lanes.js`) ``` POST /api/lanes/:id/up { build? } 202 → background → broadcastLane POST /api/lanes/:id/down { mcp? } 200 GET /api/lanes/:id/runtime 200 POST /api/lanes/:id/hook/:name { args? } 202 GET /api/lanes/:id/logs/:svc ?tail=N 200 ``` All behind `sameOriginGuard` and the existing `withLaneLock`. `GET /runtime` follows the contract of `GET /:id/git`: a lane with no profile returns `{available:false}` with HTTP 200 — a normal state, not a fault. It probes ports and stats pid files, which is why it is its own endpoint rather than part of the polled `GET /api/lanes`. `:name` and `:svc` come from allowlists, never from the request; log paths are `realpath`-confined to `LOG_DIR`; `args` is an array of strings passed straight to argv, never joined. ### Errors | Condition | Code | Behaviour | |---|---|---| | No `.ccam/profile/` | `ENOPROFILE` | 400, naming both paths searched | | Unknown hook | `ENOHOOK` | 400 | | Hook exits non-zero | — | `lane_hook_output` + `last-error.json`; reflected by `/runtime` | | Every candidate port busy | `EPORTBUSY` | names the occupying pid | | `health` fails | `EUNHEALTHY` | **leaves the processes running** — killing them destroys the evidence — and points at the log | | Slot pool exhausted | `ESLOTS` | 409 | | Name outside an allowlist | — | 400, nothing spawned | ## Verification `npm run test:server` (required) and `npm run test:client` (review the snapshot diff before regenerating). A fixture repo with a three-line profile: `boot.sh` starts `python3 -m http.server $FE_PORT` through `harness_spawn`, `health.sh` curls it with retries. Covered: - **Real lifecycle** — `up` → `/runtime` healthy with the port listening → simulate a dashboard restart (rebuild the module) → still healthy, not rebooted → `down` → port free, pid files gone. - **Slots** — nine allocations yield 1..9; releasing 4 makes the next allocation 4; concurrent allocations through `withLaneLock` never collide. - **Port step-aside** — occupy the preferred port, assert `+100` is chosen and recorded in `ports`. - **Profile parsing** — missing keys fall back to defaults; no profile returns `null` without throwing; a value containing `$(id)` stays literal and is **not** executed. - **Guards** — `PATCH /api/lanes/:id` ignores `slot`/`ports`; a hook name outside the allowlist returns 400 and spawns nothing. - **Env** — with `GIT_DIR` set on the parent, the hook does not see it. Manual: create a lane on ccam-lanes itself, write a three-line profile, `ccam lane up`, open the port, restart the server, confirm the stack survived. ## Out of scope `.env` seeding and databases (A2). Detection and scaffolding (A3). Per-feature state, proof gallery, cross-lane locks, the `ship-feature` skill, integrations (subsystems B–F). Container isolation is a separate design, not a later phase: hooks and MCP servers would have to run *inside* the container, so the `cwd` a hook reports becomes a container path and no longer matches the lane's host `cwd` — breaking `resolveLaneByCwd`, which is what binds a session to a lane at all.