docs(locks): document cross-lane named locks (D)

This commit is contained in:
2026-08-04 11:41:54 +07:00
parent c068f0cac6
commit 6c7554dbdc
5 changed files with 135 additions and 3 deletions
+88
View File
@@ -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
+3
View File
@@ -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.
+40
View File
@@ -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.