feat(lanes): add ccam lanes gc — orphan MCP reap + log capping (E, F3c)
Ports the two pieces of Shipyard's lane-gc.sh that match CCAM's actual architecture: kill Playwright MCP processes reparented to pid 1 (owning session died), cap hook logs over 10MB back to their last 2MB in place. Drops auto-removing stale worktrees by age (conflicts with the never-automatic-destroy rule), state archiving, and scratch-debris sweep (different storage architecture / files CCAM doesn't generate) — see docs/superpowers/specs/2026-08-05-lane-gc-design.md.
This commit is contained in:
+38
@@ -1541,6 +1541,36 @@ function cmdSkillsInstall() {
|
||||
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${dest}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `ccam lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||
* action, no HTTP round-trip.
|
||||
*/
|
||||
function cmdLanesGc(args) {
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const laneGc = require(path.join(REPO_ROOT, "server", "lib", "lane-gc.js"));
|
||||
|
||||
const reaped = laneGc.reapOrphanMcp({ dryRun });
|
||||
if (reaped.length) {
|
||||
console.log(
|
||||
`${dryRun ? "would reap" : "reaped"} ${reaped.length} orphaned MCP process(es): ${reaped.join(", ")}`
|
||||
);
|
||||
} else {
|
||||
console.log("no orphaned MCP processes found");
|
||||
}
|
||||
|
||||
const capped = laneGc.capOversizedLogs({ dryRun });
|
||||
if (capped.length) {
|
||||
for (const { path: logPath, sizeBefore } of capped) {
|
||||
const mb = (sizeBefore / (1024 * 1024)).toFixed(1);
|
||||
console.log(`${dryRun ? "would cap" : "capped"} ${logPath} (${mb}MB -> 2MB)`);
|
||||
}
|
||||
} else {
|
||||
console.log("no oversized logs found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ccam lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||
@@ -2433,6 +2463,11 @@ const COMMAND_GROUPS = [
|
||||
"<name> [<id>]",
|
||||
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
|
||||
],
|
||||
[
|
||||
"lanes gc",
|
||||
"[--dry-run]",
|
||||
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
|
||||
],
|
||||
[
|
||||
"skills install",
|
||||
"",
|
||||
@@ -3265,6 +3300,9 @@ async function runCommand(argv) {
|
||||
if (rest[0] === "integration") {
|
||||
return cmdLanesIntegration(rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "gc") {
|
||||
return cmdLanesGc(rest.slice(1));
|
||||
}
|
||||
return cmdLanes();
|
||||
case "skills": {
|
||||
if (rest[0] === "install") return cmdSkillsInstall();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file Tests for server/lib/lane-gc.js's capOversizedLogs — pure fs, safe to
|
||||
* test directly. reapOrphanMcp shells out to pgrep/ps against real OS
|
||||
* processes; no automated test for that here (same reasoning routes with no
|
||||
* HTTP test harness already use in this repo) — verified via manual smoke
|
||||
* check per the F3c design spec.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
// LANES_ROOT is read once at require time (from worktree.js) — set it before
|
||||
// the first require, then use it as a fixed root every test writes under.
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gc-"));
|
||||
process.env.LANES_ROOT = SUITE_ROOT;
|
||||
|
||||
const laneGc = require("../lib/lane-gc");
|
||||
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
function writeLog(laneSlot, name, sizeBytes, fill = "x") {
|
||||
const dir = path.join(SUITE_ROOT, ".state", `lane${laneSlot}`, "logs");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, name);
|
||||
fs.writeFileSync(filePath, fill.repeat(sizeBytes));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("capOversizedLogs", () => {
|
||||
it("leaves a log under the 10MB cap untouched", () => {
|
||||
const filePath = writeLog(1, "boot.log", 1024);
|
||||
const before = laneGc.capOversizedLogs();
|
||||
assert.deepEqual(
|
||||
before.filter((c) => c.path === filePath),
|
||||
[]
|
||||
);
|
||||
assert.equal(fs.statSync(filePath).size, 1024);
|
||||
});
|
||||
|
||||
it("caps a log over 10MB to its last 2MB, in place", () => {
|
||||
const dir = path.join(SUITE_ROOT, ".state", "lane2", "logs");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, "e2e.log");
|
||||
const fd = fs.openSync(filePath, "w");
|
||||
// Distinguishable content: 9MB of 'a', then a 2MB tail of 'b's we can
|
||||
// assert survived, sized to land over the 10MB cap.
|
||||
fs.writeSync(fd, "a".repeat(9 * 1024 * 1024));
|
||||
fs.writeSync(fd, "b".repeat(2 * 1024 * 1024));
|
||||
fs.closeSync(fd);
|
||||
|
||||
const result = laneGc.capOversizedLogs();
|
||||
const entry = result.find((c) => c.path === filePath);
|
||||
assert.ok(entry, "expected e2e.log to be reported as capped");
|
||||
assert.equal(entry.sizeBefore, 11 * 1024 * 1024);
|
||||
|
||||
const capped = fs.readFileSync(filePath, "utf8");
|
||||
assert.equal(capped.length, 2 * 1024 * 1024);
|
||||
assert.ok(capped.split("").every((ch) => ch === "b"));
|
||||
});
|
||||
|
||||
it("--dry-run reports what would be capped without touching the file", () => {
|
||||
const filePath = writeLog(3, "boot.log", 11 * 1024 * 1024);
|
||||
const result = laneGc.capOversizedLogs({ dryRun: true });
|
||||
const entry = result.find((c) => c.path === filePath);
|
||||
assert.ok(entry, "expected boot.log to be reported as would-be-capped");
|
||||
assert.equal(fs.statSync(filePath).size, 11 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file Housekeeping across every lane on this machine: reap orphaned
|
||||
* Playwright MCP processes (their owning Claude Code session died, so they
|
||||
* got reparented to pid 1 — a live session's MCP keeps its real parent and
|
||||
* is left alone) and cap hook logs that have grown past 10MB back to their
|
||||
* last 2MB. Port of the two pieces of Shipyard's `lane-gc.sh` that match
|
||||
* CCAM's actual architecture — see docs/superpowers/specs/2026-08-05-lane-gc-design.md
|
||||
* for why the other three (stale-worktree auto-removal, state archiving,
|
||||
* scratch-debris sweep) are out of scope. Machine-wide, not lane-scoped —
|
||||
* no route, same local-only shape as `ccam skills install`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { LANES_ROOT } = require("./worktree");
|
||||
|
||||
const LOG_CAP_BYTES = 10 * 1024 * 1024;
|
||||
const LOG_TAIL_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** `pgrep -f <pattern>`, returning matched pids. Exit 1 (no match) is a
|
||||
* normal empty result, not an error. */
|
||||
function pgrepF(pattern) {
|
||||
try {
|
||||
const out = execFileSync("pgrep", ["-f", pattern], { encoding: "utf8" });
|
||||
return out
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
} catch (err) {
|
||||
if (err.status === 1) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct children of a pid, or empty if it has none / is already gone. */
|
||||
function childPids(pid) {
|
||||
try {
|
||||
const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
|
||||
return out
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A process's parent pid, or null if it's already gone by the time we ask. */
|
||||
function ppidOf(pid) {
|
||||
try {
|
||||
const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
|
||||
const n = parseInt(out.trim(), 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill a process and every descendant, children first (a parent that dies
|
||||
* first can orphan its own children into the exact state this function
|
||||
* exists to clean up). */
|
||||
function killTree(pid, dryRun) {
|
||||
for (const child of childPids(pid)) killTree(child, dryRun);
|
||||
if (dryRun) return;
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill every Playwright-MCP-scoped process under this machine's LANES_ROOT
|
||||
* whose parent is pid 1 (orphaned — the session that spawned it died).
|
||||
* @param {{dryRun?: boolean}} [options]
|
||||
* @returns {number[]} pids reaped (or that would be, under --dry-run)
|
||||
*/
|
||||
function reapOrphanMcp(options = {}) {
|
||||
const dryRun = !!options.dryRun;
|
||||
const pattern = `${LANES_ROOT}.*\\.playwright-mcp`;
|
||||
const reaped = [];
|
||||
for (const pid of pgrepF(pattern)) {
|
||||
if (ppidOf(pid) === 1) {
|
||||
reaped.push(pid);
|
||||
killTree(pid, dryRun);
|
||||
}
|
||||
}
|
||||
return reaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap every hook log under `LANES_ROOT/.state/lane<N>/logs/` over 10MB to its last 2MB,
|
||||
* written in place (same inode — a concurrent append-mode writer's fd stays
|
||||
* valid, it just resumes past a shorter file).
|
||||
* @param {{dryRun?: boolean}} [options]
|
||||
* @returns {{path: string, sizeBefore: number}[]}
|
||||
*/
|
||||
function capOversizedLogs(options = {}) {
|
||||
const dryRun = !!options.dryRun;
|
||||
const stateDir = path.join(LANES_ROOT, ".state");
|
||||
const capped = [];
|
||||
if (!fs.existsSync(stateDir)) return capped;
|
||||
|
||||
for (const laneDir of fs.readdirSync(stateDir)) {
|
||||
const logsDir = path.join(stateDir, laneDir, "logs");
|
||||
if (!fs.existsSync(logsDir)) continue;
|
||||
for (const name of fs.readdirSync(logsDir)) {
|
||||
if (!name.endsWith(".log")) continue;
|
||||
const filePath = path.join(logsDir, name);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size <= LOG_CAP_BYTES) continue;
|
||||
capped.push({ path: filePath, sizeBefore: stat.size });
|
||||
if (dryRun) continue;
|
||||
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(LOG_TAIL_BYTES);
|
||||
fs.readSync(fd, buf, 0, LOG_TAIL_BYTES, stat.size - LOG_TAIL_BYTES);
|
||||
fs.closeSync(fd);
|
||||
fs.writeFileSync(filePath, buf); // truncate-in-place, same inode
|
||||
}
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
module.exports = { reapOrphanMcp, capOversizedLogs };
|
||||
Reference in New Issue
Block a user