From 6c7554dbdc73edcb08e6eb04443a3437b477ef9f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 11:41:54 +0700 Subject: [PATCH] docs(locks): document cross-lane named locks (D) --- ARCHITECTURE.md | 1 + docs/API.md | 88 +++++++++++++++++++ docs/CLI.md | 3 + docs/LANES.md | 40 +++++++++ .../plans/2026-08-03-shipyard-parity-lanes.md | 6 +- 5 files changed, 135 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e7fb875..00b8a31 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -386,6 +386,7 @@ graph TD | `lib/secrets.js` | (A2) Reads `~/.ccam/secrets.env` — machine-level database/Redis credentials, deliberately outside any repository. Parsed with `lane-profile.js`'s literal `KEY=VALUE` reader, never sourced. Falls back to local defaults (with a one-time warning) when the file is absent; refuses to load a file readable by group or world rather than trusting it. Never returned by any route | | `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config | | `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — CCAM stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and CCAM can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything | +| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `ccam lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive | ### API Documentation diff --git a/docs/API.md b/docs/API.md index a1d46fb..7c58ac4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -399,6 +399,94 @@ with no slot returns `{"available": false}`. ### Sessions + +### Locks + +#### List locks + +```http +GET /api/locks +``` + +Returns all currently-held named locks: + +```json +{ + "locks": [ + { + "name": "build", + "holder": "lane3", + "acquiredAt": 1722702012345, + "acquiredMs": 1722702012345 + } + ] +} +``` + +Each lock has a `name`, the current `holder` (defaults to `lane`), and `acquiredAt` / `acquiredMs` (the millisecond timestamp when acquired). If no locks are held, the array is empty. + +#### Acquire a lock + +```http +POST /api/locks/:name/acquire +``` + +Request body: + +```json +{ + "holder": "lane3", + "timeoutMs": 30000 +} +``` + +`holder` defaults to the calling lane (`lane`) if omitted. `timeoutMs` is optional; without it, the request waits indefinitely. Returns **200** with the acquired lock object: + +```json +{ + "name": "build", + "holder": "lane3", + "acquiredAt": 1722702012345 +} +``` + +or **408** if the timeout elapses before the lock becomes free: + +```json +{ + "error": "ETIMEOUT", + "waited": 30000, + "currentHolder": "lane1", + "message": "Lock held by lane1, timeout elapsed" +} +``` + +#### Release a lock + +```http +POST /api/locks/:name/release +``` + +Request body: + +```json +{ + "holder": "lane3" +} +``` + +`holder` defaults to `lane` if omitted. Returns **200** with the released lock object, or **409** if the holder does not match: + +```json +{ + "error": "ENOTHOLDER", + "name": "build", + "expectedHolder": "lane3", + "actualHolder": "lane1", + "message": "Lock is held by lane1, not lane3" +} +``` + #### List Sessions ```http diff --git a/docs/CLI.md b/docs/CLI.md index e0659a5..63557e5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -240,6 +240,9 @@ A lane is a durable unit of parallel agent work — one working directory, many | `ccam lanes runtime []` | Slot, ports (flagging any that stepped aside from its base), the lane's database name and Redis index when its profile declares them (see [Data isolation](LANES.md#data-isolation-database-redis-and-env-a2)), per-service liveness, log paths, and the last boot error | | `ccam lanes logs [] [--tail N]` | Tail one service or hook log (`--tail` in bytes, default 64 KiB) | | `ccam lanes hook [] [args…]` | Run one of the profile's hooks: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop` | +| `ccam lock status []` | Show one lock's holder, or every currently-held lock | +| `ccam lock acquire [--holder X] [--timeout N]` | Acquire a cross-lane named lock, polling until free (or `--timeout` seconds elapse). Holder defaults to the calling lane (`lane`) | +| `ccam lock release [--holder X]` | Release a lock. Refused (409) when `--holder` doesn't match the current owner | Omit `` and the command addresses the lane owning the current directory, so a session running inside a lane never needs to know its own id. Only a leading all-digits argument is read as an id — `ccam lanes logs web --tail 4096` addresses the lane by directory, not lane 4096. diff --git a/docs/LANES.md b/docs/LANES.md index 5a76dfe..2e52c5e 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -830,6 +830,46 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \ **Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidental deletion. + +## Cross-lane named locks + +Cross-lane named locks serialize work that thrashes a shared machine — builds, e2e runs, database migrations — across all lanes, not just within one lane. This is a separate axis from `withLaneLock`, the in-process per-lane lock in `server/lib/lane-lock.js`: `lane-lock` is about _when_ a lane runs internal operations; `named-lock` is about which _other lanes_ must wait. + +### How they work + +- **Atomicity via `mkdir`.** Lock ownership is declared via an `owner` marker file in the lock directory. `mkdir` atomically creates or fails with `EEXIST` — no race between check and create. +- **Holder file format:** `` is owned by a holder string (defaults to `lane` for the calling lane) that writes `LANES_ROOT/.locks//owner`. The format is ` ` — the holder identity and a timestamp. When a new acquire finds the directory already exists but the holder is stale (more than `LOCK_MAX_HOLD` seconds old, default 2700s / 45 minutes), the old holder is considered dead and the directory is removed before acquiring. +- **The `LOCK_MAX_HOLD` floor.** The default is 2700 seconds, but it has a **hard floor of 300 seconds**. No caller can force-break a live holder by setting `LOCK_MAX_HOLD=1` — the floor prevents that mistake. The lowest possible timeout is 300 seconds, even if `LOCK_MAX_HOLD` is overridden to something smaller. +- **Waiting and polling.** `ccam lock acquire` blocks the calling lane (polling the filesystem every ~1 second) until the lock is free or the `--timeout` expires. While waiting, the caller heartbeats its own presence at ~60-second intervals, so a waiting lane never reads as stalled. This means the CLI owns the polling loop — the server is stateless and does not queue or defer — which keeps CCAM's primitives non-orchestrating. + +### The CLI + +```bash +ccam lock status [] +``` +Show one named lock's current holder, or every currently-held lock. `` is optional — without it, lists all locks. + +```bash +ccam lock acquire [--holder X] [--timeout N] +``` +Acquire a cross-lane named lock, polling until it is free or the timeout expires. `--holder` defaults to `lane` (the calling lane's identifier based on its slot). `--timeout` is in seconds; without it, the command waits indefinitely. + +```bash +ccam lock release [--holder X] +``` +Release a lock. Refused with status `409` if the `--holder` does not match the current owner. The `--holder` default is the same as `acquire`: `lane`. + +### The lane card + +The lane card displays a lock badge when the lane is waiting for or holding a named lock. The badge is polled every 30 seconds (the same interval as git facts and runtime facts), so you see the lock status without a page refresh. + +### Etiquette + +**Waiting is normal.** A lane that sits at a lock for a few minutes while another finishes a build is expected behavior, not a failure. Do not interrupt or force-break a lock. + +- **Never kill a holder.** If a lane is stuck holding a lock, do not kill the process or the dashboard. Investigate why the holder is not releasing it. +- **Never delete the lock directory by hand.** If a lock persists after the processes that held it are gone (e.g., after a hard reboot), let `LOCK_MAX_HOLD` and the staleness detection clean it up naturally. If the wait cannot survive that long, the holder string can be changed in a new `acquire` call — a lane that was `lane3` can be manually transferred to `lane5` by a human operator reading the owner file and calling `acquire --holder lane5`, but this is last-resort only. +- **Never shrink `LOCK_MAX_HOLD` to force through a wait.** The 300-second floor exists specifically to stop this mistake. If the true holder is gone, the 300-second minimum wait is the price of safety. If the true holder is still running (e.g., a build with a network stall), shortening the timeout from 2700 to 300 does not help — it converts a slow success into a premature timeout, leaving the lock held and every other lane blocked forever. ## Orchestration: what CCAM does NOT do **CCAM does not chain, queue, retry, or evaluate gates.** diff --git a/docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md b/docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md index c1244d1..f93c259 100644 --- a/docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md +++ b/docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md @@ -25,7 +25,7 @@ stronger is a separate design (§ Future). | **A3** | Stack detection + profile scaffolding | A2 | ✅ **done** 2026-08-04 | | **B** | Per-feature state + archive | — | planned | | **C** | Proof gallery | B | planned | -| **D** | Cross-lane named locks | — | planned | +| **D** | Cross-lane named locks | — | ✅ **done** 2026-08-04 | | **E** | `ship-feature` skill + QC agents | A2·B·C·D | planned | | **F** | Integrations (tracker / dev-QC / CI) | E | planned | @@ -347,11 +347,11 @@ check fires instead of silently degrading. A1 ✅ ──▶ A2 ✅ ──▶ A3 ✅ │ B ──▶ C ─────────────┼──▶ E ──▶ F -D ────────────────────┘ +D ✅ ────────────────────┘ ``` B next (it blocks E, and its presets must be derived from the profiles -actually written during A1/A2). B, C, D are independent of the A chain and of +actually written during A1/A2). B, C are independent (D done) of the A chain and of each other except C→B — take D first if the pain right now is lanes thrashing the machine, B first if it is `clear` erasing history.