5e620bd8ab
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.
130 lines
4.2 KiB
JavaScript
130 lines
4.2 KiB
JavaScript
/**
|
|
* @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 };
|