docs(lanes): add plan docs for B, D, C (per-feature state, named locks, proof gallery)
B and D plans were written and executed but never staged. C is new, not yet implemented.
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
# Cross-Lane Named Locks (D) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** serialize machine-thrashing steps (builds, e2e runs) across ALL lanes with a named lock — independent of `server/lib/lane-lock.js`'s per-lane serialization, which is a different axis (one lane, one process) and must not be touched.
|
||||
|
||||
**Architecture:** A new `server/lib/named-lock.js` (mkdir-based, filesystem-atomic, no `flock` dependency) holding lock directories under `LANES_ROOT/.locks/<name>/`; a thin `server/routes/locks.js` exposing single-shot acquire/release/status (the server never blocks — "CCAM does not orchestrate" — the CLI does the waiting loop); `ccam lock status|acquire|release` CLI subcommands; a lane-card UI badge polling the same endpoint `LaneCard.tsx`'s runtime hook already polls.
|
||||
|
||||
**Tech Stack:** Node.js `fs` (mkdir/rmdir for atomicity — no new dependency), existing Express/route conventions, existing React hook + WebSocket broadcast conventions.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Every applicable source file MUST start with the project's authorship header (file overview + `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`) — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`.
|
||||
- `named-lock.js` is a **separate module** from `server/lib/lane-lock.js`. Do not touch `lane-lock.js`, do not import it, do not conflate the two axes (in-process-per-lane serialization vs cross-lane named locks).
|
||||
- **Time-based staleness with a floor.** `LOCK_MAX_HOLD` env var, default `2700` (45 min) seconds. The *effective* break threshold is `Math.max(LOCK_MAX_HOLD, 300)` — a holder can never be force-broken by setting `LOCK_MAX_HOLD` below 300 seconds. This floor is the whole point: it stops an impatient env-var edit from breaking a genuinely live holder.
|
||||
- Atomicity comes from `fs.mkdirSync` throwing `EEXIST` when the directory already exists — never a check-then-create race (`existsSync` then `mkdirSync` is NOT atomic and must not be used for the acquire decision).
|
||||
- The server-side route layer is a single-shot primitive (try once, answer immediately) — it never loops or blocks waiting for a lock to free up. The CLI's `acquire` command owns the polling loop. This mirrors the project's standing rule: CCAM offers primitives, the caller sequences them.
|
||||
- Run `npm run test:server` (full suite) and `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit — both must stay green/passing. This repo's pre-commit hook enforces the full suite (backend + frontend) already; a clean run here avoids a blocked commit.
|
||||
- Never use `git add -A`. Stage exactly the files each task names.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `server/lib/named-lock.js` — core lock primitives
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/named-lock.js`
|
||||
- Test: `server/__tests__/named-lock.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `LOCK_MAX_HOLD_FLOOR_SEC` (const, `300`), `locksRoot()` → `string` (`<LANES_ROOT>/.locks`), `effectiveMaxHoldSec()` → `number` (reads `process.env.LOCK_MAX_HOLD` per call, floors at 300), `tryAcquire(name, holder)` → `{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}`, `release(name, holder)` → `void`, throws `Object.assign(new Error(...), {code: "ENOTHOLDER"})` when `holder` doesn't match the current owner, `Object.assign(new Error(...), {code: "ENOLOCK"})` when the lock doesn't exist at all, `status(name)` → `{held: false} | {held: true, holder: string, since: number, ageSec: number}`, `listLocks()` → `Array<{name: string, holder: string, since: number, ageSec: number}>` (every currently-held lock under `locksRoot()`).
|
||||
- `since` is a Unix seconds integer (`Math.floor(Date.now() / 1000)`), matching the plan's `lane<slot> <epoch>` owner-file format.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `server/__tests__/named-lock.test.js`:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @file Tests for server/lib/named-lock.js: cross-lane named locks — mkdir
|
||||
* atomicity, the LOCK_MAX_HOLD staleness floor, holder-checked release, and
|
||||
* status/listing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { describe, it, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-named-lock-"));
|
||||
process.env.LANES_ROOT = SUITE_ROOT;
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
const namedLock = require("../lib/named-lock");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(namedLock.locksRoot(), { recursive: true, force: true });
|
||||
delete process.env.LOCK_MAX_HOLD;
|
||||
});
|
||||
|
||||
describe("tryAcquire / release", () => {
|
||||
it("acquires a free lock and reports it held afterward", () => {
|
||||
const result = namedLock.tryAcquire("build", "lane1");
|
||||
assert.deepEqual(result, { acquired: true });
|
||||
const status = namedLock.status("build");
|
||||
assert.equal(status.held, true);
|
||||
assert.equal(status.holder, "lane1");
|
||||
});
|
||||
|
||||
it("a second acquire on the same name reports not-acquired with the current holder", () => {
|
||||
namedLock.tryAcquire("build", "lane1");
|
||||
const second = namedLock.tryAcquire("build", "lane2");
|
||||
assert.equal(second.acquired, false);
|
||||
assert.equal(second.holder, "lane1");
|
||||
});
|
||||
|
||||
it("a second acquire succeeds after release", () => {
|
||||
namedLock.tryAcquire("build", "lane1");
|
||||
namedLock.release("build", "lane1");
|
||||
const second = namedLock.tryAcquire("build", "lane2");
|
||||
assert.equal(second.acquired, true);
|
||||
assert.equal(namedLock.status("build").holder, "lane2");
|
||||
});
|
||||
|
||||
it("release from a non-holder is refused and leaves the lock held", () => {
|
||||
namedLock.tryAcquire("build", "lane1");
|
||||
assert.throws(() => namedLock.release("build", "lane2"), (err) => err.code === "ENOTHOLDER");
|
||||
assert.equal(namedLock.status("build").held, true);
|
||||
assert.equal(namedLock.status("build").holder, "lane1");
|
||||
});
|
||||
|
||||
it("releasing a lock that doesn't exist throws ENOLOCK", () => {
|
||||
assert.throws(() => namedLock.release("never-held", "lane1"), (err) => err.code === "ENOLOCK");
|
||||
});
|
||||
});
|
||||
|
||||
describe("staleness with a floor", () => {
|
||||
/** Back-date a lock's owner file by writing it directly, bypassing tryAcquire's "now". */
|
||||
function backdate(name, holder, ageSec) {
|
||||
const dir = path.join(namedLock.locksRoot(), name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const since = Math.floor(Date.now() / 1000) - ageSec;
|
||||
fs.writeFileSync(path.join(dir, "owner"), `${holder} ${since}`);
|
||||
}
|
||||
|
||||
it("a holder older than LOCK_MAX_HOLD is broken on the next acquire", () => {
|
||||
backdate("stale", "lane1", 2701); // just past the 2700s default
|
||||
const result = namedLock.tryAcquire("stale", "lane2");
|
||||
assert.equal(result.acquired, true);
|
||||
assert.equal(namedLock.status("stale").holder, "lane2");
|
||||
});
|
||||
|
||||
it("a holder backdated within the 300s floor is NOT broken, even with LOCK_MAX_HOLD=1", () => {
|
||||
process.env.LOCK_MAX_HOLD = "1";
|
||||
backdate("floor-test", "lane1", 100); // within the 300s floor
|
||||
const result = namedLock.tryAcquire("floor-test", "lane2");
|
||||
assert.equal(result.acquired, false);
|
||||
assert.equal(result.holder, "lane1");
|
||||
});
|
||||
|
||||
it("a holder older than a LOCK_MAX_HOLD set above the floor is broken", () => {
|
||||
process.env.LOCK_MAX_HOLD = "400";
|
||||
backdate("above-floor", "lane1", 401);
|
||||
const result = namedLock.tryAcquire("above-floor", "lane2");
|
||||
assert.equal(result.acquired, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLocks", () => {
|
||||
it("lists every currently-held lock", () => {
|
||||
namedLock.tryAcquire("build", "lane1");
|
||||
namedLock.tryAcquire("e2e", "lane2");
|
||||
const locks = namedLock.listLocks().sort((a, b) => a.name.localeCompare(b.name));
|
||||
assert.deepEqual(
|
||||
locks.map((l) => [l.name, l.holder]),
|
||||
[
|
||||
["build", "lane1"],
|
||||
["e2e", "lane2"],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty array when no locks exist", () => {
|
||||
assert.deepEqual(namedLock.listLocks(), []);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/named-lock.test.js`
|
||||
Expected: FAIL — `Cannot find module '../lib/named-lock'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `server/lib/named-lock.js`:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @file Cross-lane named locks: serialize a machine-thrashing step (a build,
|
||||
* an e2e run) across ALL lanes, as opposed to `lane-lock.js`'s per-lane,
|
||||
* in-process serialization (a different axis — a separate module on purpose,
|
||||
* so the two are never conflated).
|
||||
*
|
||||
* `mkdir` is the atomicity primitive: it throws `EEXIST` when the directory
|
||||
* already exists, so "is it free" and "claim it" are one syscall, never a
|
||||
* check-then-create race. No `flock` dependency, unlike Shipyard's original
|
||||
* (`brew install flock` on macOS).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { LANES_ROOT } = require("./worktree");
|
||||
|
||||
/**
|
||||
* However low `LOCK_MAX_HOLD` is set, a holder younger than this can never be
|
||||
* broken. This is what stops an impatient env-var edit from force-breaking a
|
||||
* genuinely live holder — the floor is the actual safety property, not the
|
||||
* configurable default.
|
||||
*/
|
||||
const LOCK_MAX_HOLD_FLOOR_SEC = 300;
|
||||
|
||||
/** Where every named lock's directory lives. */
|
||||
function locksRoot() {
|
||||
return path.join(LANES_ROOT, ".locks");
|
||||
}
|
||||
|
||||
/** `Math.max(LOCK_MAX_HOLD env, the 300s floor)`, read per call so a test (or an
|
||||
* operator) can change it without a restart. */
|
||||
function effectiveMaxHoldSec() {
|
||||
const raw = Number(process.env.LOCK_MAX_HOLD);
|
||||
const configured = Number.isFinite(raw) && raw > 0 ? raw : 2700;
|
||||
return Math.max(configured, LOCK_MAX_HOLD_FLOOR_SEC);
|
||||
}
|
||||
|
||||
function ownerPath(name) {
|
||||
return path.join(locksRoot(), name, "owner");
|
||||
}
|
||||
|
||||
/** Parse an owner file's `"<holder> <epochSeconds>"` contents. */
|
||||
function readOwner(name) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(ownerPath(name), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = /^(\S+) (\d+)$/.exec(text.trim());
|
||||
if (!match) return null;
|
||||
const since = Number(match[2]);
|
||||
return { holder: match[1], since, ageSec: Math.floor(Date.now() / 1000) - since };
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire a named lock once. Never blocks, never retries — the caller
|
||||
* (the CLI) owns the polling loop; CCAM's server-side primitives are always
|
||||
* single-shot.
|
||||
*
|
||||
* A stale holder (older than `effectiveMaxHoldSec()`) is broken automatically:
|
||||
* its lock directory is removed and the acquire is retried once before
|
||||
* answering.
|
||||
*
|
||||
* @param {string} name - Lock name.
|
||||
* @param {string} holder - Identity to record as the owner (e.g. `"lane3"`).
|
||||
* @returns {{acquired: true} | {acquired: false, holder: string, since: number, ageSec: number}}
|
||||
*/
|
||||
function tryAcquire(name, holder) {
|
||||
const dir = path.join(locksRoot(), name);
|
||||
fs.mkdirSync(locksRoot(), { recursive: true });
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") throw err;
|
||||
const owner = readOwner(name);
|
||||
if (!owner || owner.ageSec <= effectiveMaxHoldSec()) {
|
||||
return owner
|
||||
? { acquired: false, holder: owner.holder, since: owner.since, ageSec: owner.ageSec }
|
||||
: { acquired: false, holder: "unknown", since: 0, ageSec: Infinity };
|
||||
}
|
||||
// Stale — break it and retry once.
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(dir);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(dir, "owner"), `${holder} ${Math.floor(Date.now() / 1000)}`);
|
||||
return { acquired: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a lock. Refuses (throws `ENOTHOLDER`) when `holder` does not match
|
||||
* the recorded owner — a release never trusts its caller, the same rule every
|
||||
* other destructive path in this project follows.
|
||||
*
|
||||
* @param {string} name - Lock name.
|
||||
* @param {string} holder - Must match the current owner.
|
||||
*/
|
||||
function release(name, holder) {
|
||||
const owner = readOwner(name);
|
||||
if (!owner) {
|
||||
throw Object.assign(new Error(`no such lock: ${name}`), { code: "ENOLOCK" });
|
||||
}
|
||||
if (owner.holder !== holder) {
|
||||
throw Object.assign(
|
||||
new Error(`lock "${name}" is held by ${owner.holder}, not ${holder}`),
|
||||
{ code: "ENOTHOLDER", currentHolder: owner.holder }
|
||||
);
|
||||
}
|
||||
fs.rmSync(path.join(locksRoot(), name), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Current state of one named lock, read-only. */
|
||||
function status(name) {
|
||||
const owner = readOwner(name);
|
||||
return owner ? { held: true, ...owner } : { held: false };
|
||||
}
|
||||
|
||||
/** Every currently-held lock. */
|
||||
function listLocks() {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(locksRoot());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((name) => ({ name, ...status(name) }))
|
||||
.filter((lock) => lock.held)
|
||||
.map(({ held, ...rest }) => rest);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOCK_MAX_HOLD_FLOOR_SEC,
|
||||
locksRoot,
|
||||
effectiveMaxHoldSec,
|
||||
tryAcquire,
|
||||
release,
|
||||
status,
|
||||
listLocks,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test server/__tests__/named-lock.test.js`
|
||||
Expected: PASS (11 tests)
|
||||
|
||||
- [ ] **Step 5: Verify the header audit passes**
|
||||
|
||||
Run: `bash .claude/skills/file-headers/scripts/check-headers.sh`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/named-lock.js server/__tests__/named-lock.test.js
|
||||
git commit -m "feat(locks): add cross-lane named locks (mkdir-atomic, staleness floor) (D)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `GET/POST /api/locks` routes
|
||||
|
||||
**Files:**
|
||||
- Create: `server/routes/locks.js`
|
||||
- Modify: `server/index.js` (mount the router)
|
||||
- Test: `server/__tests__/locks-api.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `server/lib/named-lock.js`'s `tryAcquire`, `release`, `status`, `listLocks` (Task 1, already committed).
|
||||
- Produces: the three HTTP routes below, mounted at `/api/locks`.
|
||||
|
||||
Routes:
|
||||
```
|
||||
GET /api/locks -> { locks: [{name, holder, since, ageSec}, ...] }
|
||||
POST /api/locks/:name/acquire body: { holder: string }
|
||||
-> 200 { acquired: true }
|
||||
-> 409 { acquired: false, holder, since, ageSec }
|
||||
-> 400 { error: { code: "EBADHOLDER", message } } when holder is missing/empty
|
||||
POST /api/locks/:name/release body: { holder: string }
|
||||
-> 200 { ok: true }
|
||||
-> 409 { error: { code: "ENOTHOLDER", message, currentHolder } }
|
||||
-> 404 { error: { code: "ENOLOCK", message } }
|
||||
```
|
||||
|
||||
Mutating routes (`acquire`, `release`) require the same same-origin guard every other mutating lane route uses — reuse `sameOriginGuard` from `server/routes/run.js` (already imported this way in `server/routes/lanes.js`: `const { sameOriginGuard } = require("./run");`). `GET /api/locks` is read-only, no guard, matching `GET /api/lanes/branches`'s precedent.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `server/__tests__/locks-api.test.js`:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @file Tests for GET/POST /api/locks — the HTTP surface over
|
||||
* server/lib/named-lock.js. Boots a real Express app + in-memory-ish SQLite
|
||||
* (temp file) like the other route test files in this suite.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-locks-api-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
||||
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const namedLock = require("../lib/named-lock");
|
||||
|
||||
let server;
|
||||
let PORT;
|
||||
|
||||
before(async () => {
|
||||
const app = createApp();
|
||||
server = http.createServer(app);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
PORT = server.address().port;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(SUITE_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function request(method, urlPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const req = http.request(
|
||||
{
|
||||
method,
|
||||
hostname: "127.0.0.1",
|
||||
port: PORT,
|
||||
path: urlPath,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(data ? { "Content-Length": Buffer.byteLength(data) } : {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let raw = "";
|
||||
res.on("data", (chunk) => (raw += chunk));
|
||||
res.on("end", () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch {
|
||||
/* empty body is fine for some responses */
|
||||
}
|
||||
resolve({ status: res.statusCode, body: json });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/locks", () => {
|
||||
it("lists currently-held locks", async () => {
|
||||
namedLock.tryAcquire("api-build", "lane1");
|
||||
const res = await request("GET", "/api/locks");
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.locks.some((l) => l.name === "api-build" && l.holder === "lane1"));
|
||||
namedLock.release("api-build", "lane1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/locks/:name/acquire", () => {
|
||||
it("acquires a free lock", async () => {
|
||||
const res = await request("POST", "/api/locks/api-acquire/acquire", { holder: "lane1" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body, { acquired: true });
|
||||
namedLock.release("api-acquire", "lane1");
|
||||
});
|
||||
|
||||
it("409s with the current holder when already held", async () => {
|
||||
namedLock.tryAcquire("api-busy", "lane1");
|
||||
const res = await request("POST", "/api/locks/api-busy/acquire", { holder: "lane2" });
|
||||
assert.equal(res.status, 409);
|
||||
assert.equal(res.body.acquired, false);
|
||||
assert.equal(res.body.holder, "lane1");
|
||||
namedLock.release("api-busy", "lane1");
|
||||
});
|
||||
|
||||
it("400s when holder is missing", async () => {
|
||||
const res = await request("POST", "/api/locks/api-nobody/acquire", {});
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EBADHOLDER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/locks/:name/release", () => {
|
||||
it("releases a held lock", async () => {
|
||||
namedLock.tryAcquire("api-release", "lane1");
|
||||
const res = await request("POST", "/api/locks/api-release/release", { holder: "lane1" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(namedLock.status("api-release").held, false);
|
||||
});
|
||||
|
||||
it("409s when the holder doesn't match", async () => {
|
||||
namedLock.tryAcquire("api-release-2", "lane1");
|
||||
const res = await request("POST", "/api/locks/api-release-2/release", { holder: "lane2" });
|
||||
assert.equal(res.status, 409);
|
||||
assert.equal(res.body.error.code, "ENOTHOLDER");
|
||||
namedLock.release("api-release-2", "lane1");
|
||||
});
|
||||
|
||||
it("404s when the lock doesn't exist", async () => {
|
||||
const res = await request("POST", "/api/locks/never-held/release", { holder: "lane1" });
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(res.body.error.code, "ENOLOCK");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/__tests__/locks-api.test.js`
|
||||
Expected: FAIL — 404s across the board (route not mounted).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `server/routes/locks.js`:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @file HTTP surface for cross-lane named locks (`server/lib/named-lock.js`).
|
||||
* Every route is single-shot — this layer never blocks waiting for a lock to
|
||||
* free up. The CLI (`ccam lock acquire`) owns the polling loop; CCAM's
|
||||
* server-side primitives stay non-orchestrating, same as every other lane
|
||||
* route in this project.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const namedLock = require("../lib/named-lock");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ locks: namedLock.listLocks() });
|
||||
});
|
||||
|
||||
router.post("/:name/acquire", sameOriginGuard, (req, res) => {
|
||||
const holder = typeof req.body?.holder === "string" ? req.body.holder.trim() : "";
|
||||
if (!holder) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADHOLDER", message: "holder is required" } });
|
||||
}
|
||||
const result = namedLock.tryAcquire(req.params.name, holder);
|
||||
if (result.acquired) return res.json(result);
|
||||
res.status(409).json(result);
|
||||
});
|
||||
|
||||
router.post("/:name/release", sameOriginGuard, (req, res) => {
|
||||
const holder = typeof req.body?.holder === "string" ? req.body.holder.trim() : "";
|
||||
if (!holder) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADHOLDER", message: "holder is required" } });
|
||||
}
|
||||
try {
|
||||
namedLock.release(req.params.name, holder);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err.code === "ENOLOCK") {
|
||||
return res.status(404).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code === "ENOTHOLDER") {
|
||||
return res.status(409).json({
|
||||
error: { code: err.code, message: err.message, currentHolder: err.currentHolder },
|
||||
});
|
||||
}
|
||||
res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
In `server/index.js`, find the block of `app.use("/api/...", ...Router)` lines (search for `app.use("/api/remote-sources", remoteSourcesRouter);` — the last one in the list) and add, right after the `lanesRouter` require line (search for `const lanesRouter = require("./routes/lanes");`):
|
||||
|
||||
```js
|
||||
const locksRouter = require("./routes/locks");
|
||||
```
|
||||
|
||||
And right after `app.use("/api/lanes", lanesRouter);`:
|
||||
|
||||
```js
|
||||
app.use("/api/locks", locksRouter);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test server/__tests__/locks-api.test.js`
|
||||
Expected: PASS (7 tests)
|
||||
|
||||
- [ ] **Step 5: Run the full suite and header audit**
|
||||
|
||||
Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/locks.js server/index.js server/__tests__/locks-api.test.js
|
||||
git commit -m "feat(locks): expose GET/POST /api/locks over named-lock.js (D)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ccam lock status|acquire|release` CLI
|
||||
|
||||
**Files:**
|
||||
- Modify: `bin/ccam.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /api/locks`, `POST /api/locks/:name/acquire`, `POST /api/locks/:name/release` (Task 2, already committed) via the existing `get`/`post` helpers in `bin/ccam.js`.
|
||||
- Consumes: `resolveLaneArg` (already defined in `bin/ccam.js`, used by `cmdLanesRuntime`/`cmdStage`) to resolve which lane is acting, when no explicit `--holder` is given.
|
||||
|
||||
- [ ] **Step 1: Add the command implementations**
|
||||
|
||||
In `bin/ccam.js`, near `cmdLanesRuntime` (search for that function), add:
|
||||
|
||||
```js
|
||||
/**
|
||||
* The default lock holder identity for the calling lane: `lane<slot>` when
|
||||
* the lane has one allocated, else `lane<id>` — a lane's own row id — as a
|
||||
* fallback for a lane that has never brought its runtime up. `--holder`
|
||||
* always overrides both.
|
||||
*
|
||||
* @param {string[]} argsAfterName - Args AFTER the lock name has already been
|
||||
* consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))`
|
||||
* call) — `resolveLaneArg` treats a leading all-digits positional as a lane
|
||||
* id, so the lock name itself must never reach it (a lock literally named
|
||||
* e.g. "3" would otherwise be misread as lane 3).
|
||||
*/
|
||||
async function defaultHolder(argsAfterName) {
|
||||
const explicit = (() => {
|
||||
const i = argsAfterName.indexOf("--holder");
|
||||
return i > -1 ? argsAfterName[i + 1] : undefined;
|
||||
})();
|
||||
if (explicit) return explicit;
|
||||
|
||||
const resolved = await resolveLaneArg(argsAfterName);
|
||||
if (!resolved) return null;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||
}
|
||||
|
||||
function fmtLockRow(lock) {
|
||||
const mins = Math.floor(lock.ageSec / 60);
|
||||
return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`;
|
||||
}
|
||||
|
||||
/** `ccam lock status [<name>]` — one lock, or every held lock. */
|
||||
async function cmdLockStatus(args) {
|
||||
const name = args.find((arg) => !arg.startsWith("--"));
|
||||
if (name) {
|
||||
const { locks } = await get("/api/locks");
|
||||
const lock = locks.find((l) => l.name === name);
|
||||
console.log(lock ? fmtLockRow(lock) : `${name}: free`);
|
||||
return;
|
||||
}
|
||||
const { locks } = await get("/api/locks");
|
||||
if (!locks.length) {
|
||||
console.log("no locks held");
|
||||
return;
|
||||
}
|
||||
for (const lock of locks) console.log(fmtLockRow(lock));
|
||||
}
|
||||
|
||||
/**
|
||||
* `ccam lock acquire <name> [--holder X] [--timeout N]` — polls until the
|
||||
* lock is free (or `--timeout` seconds elapse). Prints a status line every
|
||||
* ~60s of continued waiting so a long wait never reads as a hung command —
|
||||
* this is the CLI-side "heartbeat" the design calls for; it is terminal
|
||||
* output, not a dashboard liveness signal.
|
||||
*/
|
||||
async function cmdLockAcquire(args) {
|
||||
const name = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!name) {
|
||||
console.error("usage: ccam lock acquire <name> [--holder X] [--timeout seconds]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Strip the lock name before handing args to defaultHolder/resolveLaneArg —
|
||||
// see defaultHolder's doc comment for why the name must never reach it.
|
||||
const holder = await defaultHolder(args.filter((a) => a !== name));
|
||||
if (!holder) return; // resolveLaneArg already printed an error
|
||||
|
||||
const timeoutIdx = args.indexOf("--timeout");
|
||||
const timeoutMs =
|
||||
timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null;
|
||||
const deadline = timeoutMs ? Date.now() + timeoutMs : null;
|
||||
const startedAt = Date.now();
|
||||
let lastPrinted = 0;
|
||||
|
||||
for (;;) {
|
||||
const result = await post(`/api/locks/${encodeURIComponent(name)}/acquire`, { holder }, {
|
||||
allowError: true,
|
||||
});
|
||||
if (result.status === undefined || result.data?.acquired) {
|
||||
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastPrinted >= 60_000) {
|
||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||
console.log(
|
||||
`… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)`
|
||||
);
|
||||
lastPrinted = Date.now();
|
||||
}
|
||||
if (deadline && Date.now() >= deadline) {
|
||||
console.error(`✖ timed out waiting for lock "${name}"`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
/** `ccam lock release <name> [--holder X]`. */
|
||||
async function cmdLockRelease(args) {
|
||||
const name = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!name) {
|
||||
console.error("usage: ccam lock release <name> [--holder X]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const holder = await defaultHolder(args.filter((a) => a !== name));
|
||||
if (!holder) return;
|
||||
const result = await post(`/api/locks/${encodeURIComponent(name)}/release`, { holder }, {
|
||||
allowError: true,
|
||||
});
|
||||
if (result.status) {
|
||||
console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`${c.green("✔")} released lock "${name}"`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the subcommand dispatch**
|
||||
|
||||
In `bin/ccam.js`'s `runCommand` switch (search for `case "stage":`), add a new case right after the `case "lanes":` block:
|
||||
|
||||
```js
|
||||
case "lock": {
|
||||
const sub = rest[0];
|
||||
if (sub === "status") return cmdLockStatus(rest.slice(1));
|
||||
if (sub === "acquire") return cmdLockAcquire(rest.slice(1));
|
||||
if (sub === "release") return cmdLockRelease(rest.slice(1));
|
||||
console.error("usage: ccam lock status [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the help-table entry**
|
||||
|
||||
In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group, add right after the `lanes hook` row:
|
||||
|
||||
```js
|
||||
[
|
||||
"lock status|acquire|release",
|
||||
"[<name>] [--holder X] [--timeout N]",
|
||||
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Manual smoke test**
|
||||
|
||||
```bash
|
||||
ccam lock status
|
||||
ccam lock acquire smoke-test --holder test-a
|
||||
ccam lock status smoke-test
|
||||
ccam lock acquire smoke-test --holder test-b --timeout 2 # should time out, exit 1
|
||||
ccam lock release smoke-test --holder test-a
|
||||
ccam lock status smoke-test
|
||||
```
|
||||
|
||||
Expected: first acquire succeeds; status shows it held by `test-a`; second acquire (different holder, 2s timeout) prints a timeout error and exits 1; release succeeds; final status shows free.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add bin/ccam.js
|
||||
git commit -m "feat(locks): add ccam lock status/acquire/release CLI (D)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Lane card lock indicator (client)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/lib/api.ts`
|
||||
- Modify: `client/src/lib/types.ts`
|
||||
- Modify: `client/src/components/lanes/LaneCard.tsx`
|
||||
- Modify: `client/src/i18n/locales/en/lanes.json`, `client/src/i18n/locales/vi/lanes.json`
|
||||
- Test: `client/src/components/lanes/__tests__/LaneCard.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /api/locks` (Task 2, already committed), returning `{ locks: NamedLock[] }` where `NamedLock = { name: string, holder: string, since: number, ageSec: number }`.
|
||||
- Produces: a small badge on `LaneCard` shown when any lock's `holder` matches this lane's identity (`lane${lane.slot}` — read `lane.slot` off the existing `Lane` type, already present from A1).
|
||||
|
||||
- [ ] **Step 1: Add the type and API client method**
|
||||
|
||||
In `client/src/lib/types.ts`, find the `LaneRuntime` type definitions (search for `export type LaneRuntime`) and add nearby:
|
||||
|
||||
```ts
|
||||
export interface NamedLock {
|
||||
name: string;
|
||||
holder: string;
|
||||
since: number;
|
||||
ageSec: number;
|
||||
}
|
||||
```
|
||||
|
||||
In `client/src/lib/api.ts`, find the `lanes` API object (search for `runtime: (id: number` inside it, the method `cmdLanesRuntime`'s client-side twin) and add a sibling top-level export near it:
|
||||
|
||||
```ts
|
||||
export const locks = {
|
||||
list: (): Promise<{ locks: NamedLock[] }> => apiFetch("/api/locks"),
|
||||
};
|
||||
```
|
||||
|
||||
(Match the exact `apiFetch` helper name and import style already used by neighboring exports in this file — read the file to confirm the helper's real name before writing this, it may not be literally `apiFetch`.)
|
||||
|
||||
- [ ] **Step 2: Add a polling hook and the badge**
|
||||
|
||||
In `client/src/components/lanes/LaneCard.tsx`, near the existing `useLaneRuntime` hook (search for `RUNTIME_REFRESH_MS`), add a sibling hook following the exact same pattern (state, `useEffect` with `setInterval`, cleanup):
|
||||
|
||||
```tsx
|
||||
const LOCKS_REFRESH_MS = 30_000;
|
||||
|
||||
/** Locks held by THIS lane, polled the same way runtime/git facts are. */
|
||||
function useLaneLocks(laneSlot: number | null) {
|
||||
const [locks, setLocks] = useState<NamedLock[]>([]);
|
||||
useEffect(() => {
|
||||
if (!laneSlot) return;
|
||||
let cancelled = false;
|
||||
const holder = `lane${laneSlot}`;
|
||||
const read = () => {
|
||||
api.locks
|
||||
.list()
|
||||
.then((data) => {
|
||||
if (!cancelled) setLocks(data.locks.filter((l) => l.holder === holder));
|
||||
})
|
||||
.catch(() => {
|
||||
/* fails silently, same contract as the git/runtime pollers */
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneSlot]);
|
||||
return locks;
|
||||
}
|
||||
```
|
||||
|
||||
Then, inside the `LaneCard` component function, call `const locks = useLaneLocks(lane.slot);` and render a small badge — find where the existing runtime badge renders (search for `data-testid={\`lane-runtime-${lane.id}\`}`) and add, as a sibling conditional block right after it:
|
||||
|
||||
```tsx
|
||||
{locks.length > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-status-warning"
|
||||
data-testid={`lane-locks-${lane.id}`}
|
||||
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
||||
>
|
||||
🔒 {t("locks.held", { count: locks.length })}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Read the file first to match the exact className tokens (`text-status-warning` may not be the real token name — use whatever this file already uses for an "attention" color elsewhere, e.g. the same class the `needs_action` banner uses) and the exact `t()` i18n call convention already used in this file.
|
||||
|
||||
- [ ] **Step 3: Add the i18n strings**
|
||||
|
||||
In `client/src/i18n/locales/en/lanes.json`, find the `"runtime"` key's object and add a sibling `"locks"` key:
|
||||
|
||||
```json
|
||||
"locks": {
|
||||
"held": "{{count}} lock held"
|
||||
}
|
||||
```
|
||||
|
||||
(Add the pluralized `"locks_other"` or `"held_other"` form too, matching whatever pluralization convention this repo's i18next setup already uses elsewhere in the same file — check an existing count-based string for the exact key suffix convention before adding this.)
|
||||
|
||||
In `client/src/i18n/locales/vi/lanes.json`, add the Vietnamese translation at the same key path: `"locks": { "held": "Đang giữ {{count}} khóa" }` (plus its plural-form key if Vietnamese needs one per this repo's i18next config — Vietnamese has no grammatical plural, so it may only need the base key; check the convention).
|
||||
|
||||
- [ ] **Step 4: Write a test**
|
||||
|
||||
In `client/src/components/lanes/__tests__/LaneCard.test.tsx`, find an existing test that mocks `api.lanes.runtime` and follow the same mocking pattern for `api.locks.list`. Add:
|
||||
|
||||
```tsx
|
||||
it("shows a lock badge when this lane holds a named lock", async () => {
|
||||
vi.mocked(api.locks.list).mockResolvedValue({
|
||||
locks: [{ name: "build", holder: "lane3", since: 0, ageSec: 120 }],
|
||||
});
|
||||
// render a lane with slot: 3, matching the mocked lock's holder
|
||||
// ... follow this file's existing render + waitFor pattern for the runtime badge test ...
|
||||
// assert screen.getByTestId(`lane-locks-${lane.id}`) is present
|
||||
});
|
||||
|
||||
it("shows no lock badge when locks belong to a different lane", async () => {
|
||||
vi.mocked(api.locks.list).mockResolvedValue({
|
||||
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
|
||||
});
|
||||
// render a lane with slot: 3 (does not match "lane99")
|
||||
// assert screen.queryByTestId(`lane-locks-${lane.id}`) is null
|
||||
});
|
||||
```
|
||||
|
||||
Read the existing runtime-badge test in this same file first and mirror its exact render/mock/waitFor structure — don't guess at the test utilities this file imports.
|
||||
|
||||
- [ ] **Step 5: Run the client test suite**
|
||||
|
||||
Run: `npm run test:client`
|
||||
Expected: green, including the two new tests.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/lib/api.ts client/src/lib/types.ts client/src/components/lanes/LaneCard.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json client/src/components/lanes/__tests__/LaneCard.test.tsx
|
||||
git commit -m "feat(locks): show a lock indicator on the lane card (D)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/LANES.md`
|
||||
- Modify: `docs/CLI.md`
|
||||
- Modify: `docs/API.md`
|
||||
- Modify: `ARCHITECTURE.md`
|
||||
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark D done)
|
||||
|
||||
**Interfaces:** none — documentation only.
|
||||
|
||||
- [ ] **Step 1: `docs/LANES.md`**
|
||||
|
||||
Add a new top-level section (after the "Lane actions" section, before "Orchestration: what CCAM does NOT do" — search for both headings to place it precisely) titled `## Cross-lane named locks`, covering:
|
||||
- What they're for (serializing a machine-thrashing step like a build or e2e run across every lane, not just within one lane — the other axis from the per-lane `withLaneLock` used internally by destructive actions).
|
||||
- The `LOCK_MAX_HOLD` env var (default 2700s / 45 min) and its 300s floor — a holder can never be force-broken by setting `LOCK_MAX_HOLD` below 300, even to `1`.
|
||||
- The CLI: `ccam lock status [<name>]`, `ccam lock acquire <name> [--holder X] [--timeout N]`, `ccam lock release <name> [--holder X]` — holder defaults to `lane<slot>` for the calling lane.
|
||||
- **The etiquette text, verbatim in spirit from Shipyard**: waiting for a lock is normal, not a failure; never kill a holder to "fix" a stuck wait; never delete the lock directory (`LANES_ROOT/.locks/<name>/`) by hand; never shrink `LOCK_MAX_HOLD` to force through a wait — the 300s floor exists specifically to stop that.
|
||||
- The lane card's lock badge (client-side, polled every 30s like the git/runtime facts).
|
||||
|
||||
- [ ] **Step 2: `docs/CLI.md`**
|
||||
|
||||
Add to the `### Lanes` table, after the `lanes hook` row:
|
||||
|
||||
```markdown
|
||||
| `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 |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `docs/API.md`**
|
||||
|
||||
Add a new section documenting `GET /api/locks`, `POST /api/locks/:name/acquire`, `POST /api/locks/:name/release` — request/response bodies and status codes exactly as specified in Task 2 above. Follow this doc's existing style (a `#### <verb> <route>` heading, an ` ```http ` block, then prose, matching the pattern used for the lanes runtime routes just above it).
|
||||
|
||||
- [ ] **Step 4: `ARCHITECTURE.md`**
|
||||
|
||||
Add a new row to the module responsibility table (search for the `lib/lane-lock.js` row if one exists, or place alphabetically near the other `lib/lane-*` rows):
|
||||
|
||||
```markdown
|
||||
| `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 |
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Mark D done in the parent plan**
|
||||
|
||||
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row for `**D**` to `✅ **done** <today's date>`, and update the `## Order` diagram/prose the same way A1/A2/A3 were marked done (search for `| **D** |` and for the `## Order` section's ASCII diagram).
|
||||
|
||||
- [ ] **Step 6: Verify and commit**
|
||||
|
||||
```bash
|
||||
bash .claude/skills/file-headers/scripts/check-headers.sh
|
||||
npm run test:server
|
||||
```
|
||||
|
||||
```bash
|
||||
git add docs/LANES.md docs/CLI.md docs/API.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
|
||||
git commit -m "docs(locks): document cross-lane named locks (D)"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user