docs(locks): document cross-lane named locks (D)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+88
@@ -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<slot>`), 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<slot>`) 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<slot>` 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
|
||||
|
||||
@@ -240,6 +240,9 @@ A lane is a durable unit of parallel agent work — one working directory, many
|
||||
| `ccam lanes runtime [<id>]` | 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 [<id>] <service> [--tail N]` | Tail one service or hook log (`--tail` in bytes, default 64 KiB) |
|
||||
| `ccam lanes hook [<id>] <name> [args…]` | Run one of the profile's hooks: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop` |
|
||||
| `ccam lock status [<name>]` | Show one lock's holder, or every currently-held lock |
|
||||
| `ccam lock acquire <name> [--holder X] [--timeout N]` | Acquire a cross-lane named lock, polling until free (or `--timeout` seconds elapse). Holder defaults to the calling lane (`lane<slot>`) |
|
||||
| `ccam lock release <name> [--holder X]` | Release a lock. Refused (409) when `--holder` doesn't match the current owner |
|
||||
|
||||
Omit `<id>` 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.
|
||||
|
||||
|
||||
@@ -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:** `<lock-name>` is owned by a holder string (defaults to `lane<slot>` for the calling lane) that writes `LANES_ROOT/.locks/<lock-name>/owner`. The format is `<holder> <acquired-epoch-ms>` — 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 [<name>]
|
||||
```
|
||||
Show one named lock's current holder, or every currently-held lock. `<name>` is optional — without it, lists all locks.
|
||||
|
||||
```bash
|
||||
ccam lock acquire <name> [--holder X] [--timeout N]
|
||||
```
|
||||
Acquire a cross-lane named lock, polling until it is free or the timeout expires. `--holder` defaults to `lane<slot>` (the calling lane's identifier based on its slot). `--timeout` is in seconds; without it, the command waits indefinitely.
|
||||
|
||||
```bash
|
||||
ccam lock release <name> [--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<slot>`.
|
||||
|
||||
### 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.**
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user