Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-04-cross-lane-named-locks.md
T
nntrivi2001 e9193ae4be 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.
2026-08-04 15:47:18 +07:00

38 KiB

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:

/**
 * @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:

/**
 * @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
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:

/**
 * @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:

/**
 * @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");):

const locksRouter = require("./routes/locks");

And right after app.use("/api/lanes", lanesRouter);:

  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
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:

/**
 * 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:

    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:

      [
        "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
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
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:

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:

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):

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:

{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:

"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:

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
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:

| `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):

| `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 .claude/skills/file-headers/scripts/check-headers.sh
npm run test:server
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)"