/** * @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 };