fix(docs): correct fabricated API/behavior claims in the locks docs

docs/API.md's Locks section described a timeoutMs request param, a
408-timeout response, and field names (acquiredAt/acquiredMs) that
don't exist anywhere in the actual routes.js — the server is single-shot
and never blocks; timing out is a CLI-only concept. Also fixes the
section being spliced into the middle of the pre-existing Sessions
heading and its content.

docs/LANES.md said the owner file stores an epoch in milliseconds
(it's seconds), that the CLI polls every ~1s (it's ~2s), that the lane
card shows a "waiting" state (no such server-side concept exists, only
who currently holds), and included a fabricated "manually transfer a
lock's holder identity" procedure that also contradicted the
never-touch-the-lock-directory etiquette rule stated right above it.
This commit is contained in:
2026-08-04 14:02:40 +07:00
parent 6c7554dbdc
commit 465dca35e5
2 changed files with 33 additions and 66 deletions
+26 -59
View File
@@ -397,95 +397,62 @@ resolved path is confined to the lane's log directory after `realpath`, so a
name from the request can never escape it; anything else is `404 ENOLOG`. A lane name from the request can never escape it; anything else is `404 ENOLOG`. A lane
with no slot returns `{"available": false}`. with no slot returns `{"available": false}`.
### Sessions
### Locks ### Locks
Cross-lane named locks (`server/lib/named-lock.js`) — the OTHER axis from a
lane's own internal serialization: a name held by one lane at a time, across
ALL lanes, for a step that thrashes the shared machine (a build, an e2e run).
Every route here is **single-shot** — it never blocks waiting for a lock to
free up. A caller that wants to wait polls `POST /:name/acquire` itself (the
`ccam lock acquire` CLI does exactly this, printing a status line every ~60s
of continued waiting); the server never orchestrates that loop.
#### List locks #### List locks
```http ```http
GET /api/locks GET /api/locks
``` ```
Returns all currently-held named locks: Every currently-held lock:
```json ```json
{ {
"locks": [ "locks": [{ "name": "build", "holder": "lane3", "since": 1722702012, "ageSec": 41 }]
{
"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. `since` is a Unix **seconds** timestamp; `ageSec` is derived at read time. An
empty array means nothing is held.
#### Acquire a lock #### Acquire a lock
```http ```http
POST /api/locks/:name/acquire POST /api/locks/:name/acquire
{ "holder": "lane3" }
``` ```
Request body: `holder` is **required** — this route does not default it; `ccam lock
acquire` resolves `lane<slot>` for the calling lane and sends it explicitly.
A holder older than `LOCK_MAX_HOLD` (default 2700s, floored at 300s — see
`docs/LANES.md#cross-lane-named-locks`) is broken automatically before the
attempt.
```json - **200** `{ "acquired": true }` — lock claimed.
{ - **409** `{ "acquired": false, "holder": "lane1", "since": 1722701900, "ageSec": 150 }` — already held by someone else (fresh enough not to be broken).
"holder": "lane3", - **400** `{ "error": { "code": "EBADHOLDER", "message": "holder is required" } }` — missing/empty `holder`.
"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 #### Release a lock
```http ```http
POST /api/locks/:name/release POST /api/locks/:name/release
{ "holder": "lane3" }
``` ```
Request body: - **200** `{ "ok": true }` — released.
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
```json ### Sessions
{
"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 #### List Sessions
+7 -7
View File
@@ -830,17 +830,16 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidental deletion. **Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidental deletion.
## Cross-lane named locks ## 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. 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 ### 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. - **Atomicity via `mkdir`.** Lock ownership is declared via an `owner` marker file inside `LANES_ROOT/.locks/<lock-name>/`. `mkdir` atomically creates the lock directory 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. - **Owner file format:** `<holder> <acquired-epoch-seconds>` — the holder identity (defaults to `lane<slot>` for the calling lane) and a Unix **seconds** timestamp. When a new acquire finds the directory already exists but the holder is stale (older than `LOCK_MAX_HOLD` seconds, default 2700s / 45 minutes), the old holder is considered dead, its directory is removed, and the acquire is retried once before answering.
- **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. - **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 break threshold 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. - **Waiting and polling.** `ccam lock acquire` blocks the calling lane (polling the server every ~2 seconds via a single-shot `POST /:name/acquire` — the server itself never blocks or queues) until the lock is free or `--timeout` expires. While waiting, it prints a status line every ~60 seconds, so a long wait reads as "still waiting", not a hung command — this is terminal output for whoever is watching, not a dashboard liveness signal.
### The CLI ### The CLI
@@ -861,15 +860,16 @@ Release a lock. Refused with status `409` if the `--holder` does not match the c
### The lane card ### 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. The lane card shows a lock badge when the lane **holds** a named lock (its `lane<slot>` identity matches a held lock's `holder`) — polled every 30 seconds, the same interval as git and runtime facts. There is no server-side concept of "who is waiting" to display; only who currently holds.
### Etiquette ### 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. **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 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 delete the lock directory by hand.** A lock that outlives its holder (e.g. after a hard reboot) is cleaned up automatically — the next `acquire` on that name finds it stale (older than `LOCK_MAX_HOLD`) and breaks it. Deleting `LANES_ROOT/.locks/<name>/` yourself only races whoever else's `acquire` is about to do the same check safely.
- **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. - **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 ## Orchestration: what CCAM does NOT do
**CCAM does not chain, queue, retry, or evaluate gates.** **CCAM does not chain, queue, retry, or evaluate gates.**