From 5e620bd8ab23245273f8662983435fb2cd19e748 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 5 Aug 2026 17:44:46 +0700 Subject: [PATCH] =?UTF-8?q?feat(lanes):=20add=20ccam=20lanes=20gc=20?= =?UTF-8?q?=E2=80=94=20orphan=20MCP=20reap=20+=20log=20capping=20(E,=20F3c?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/ccam.js | 38 +++++++++ server/__tests__/lane-gc.test.js | 72 +++++++++++++++++ server/lib/lane-gc.js | 129 +++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 server/__tests__/lane-gc.test.js create mode 100644 server/lib/lane-gc.js diff --git a/bin/ccam.js b/bin/ccam.js index 47c4403..a78e714 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -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 ` — 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 = [ " []", "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(); diff --git a/server/__tests__/lane-gc.test.js b/server/__tests__/lane-gc.test.js new file mode 100644 index 0000000..9fa56b4 --- /dev/null +++ b/server/__tests__/lane-gc.test.js @@ -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ĩ + */ + +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); + }); +}); diff --git a/server/lib/lane-gc.js b/server/lib/lane-gc.js new file mode 100644 index 0000000..e5b9fb3 --- /dev/null +++ b/server/lib/lane-gc.js @@ -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ĩ + */ + +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 `, 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/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 };