9d145865dd
Gives each lane its own slot-derived runtime (ports, detached process lifecycle, profile-driven hooks) and its own database/Redis logical index/.env file, so two lanes running the same repo's stack at once no longer share state. Machine-level DB/Redis credentials live at ~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's output is redacted of that password (raw and URL-encoded forms) before it reaches a log file or the lane_hook_output websocket broadcast. Wired into provision/up/reset/remove; reset accepts --keep-db to skip the drop/recreate/migrate/reseed block entirely.
170 lines
10 KiB
Markdown
170 lines
10 KiB
Markdown
# 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 | `<repo>/.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 <hook> <args…>` 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_<name> + 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 <hook>` 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`, `<NAME>_PORT` per declared port), `harness_spawn` exported as a bash function writing the same `$RUN_DIR/<name>.pid` + `$LOG_DIR/<name>.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<slot>/{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.
|