UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

373 lines (372 loc) • 16.4 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Git worktree lifecycle for `isolation: worktree` units: each attempt of a * file-mutating agent/sdk unit gets a fresh detached worktree of the run's base * repository under `<tmp>/akm-worktrees/<runId>/<attemptId>`. * * 1. {@link assertGitWorkTree} — once per step, before dispatch. * 2. {@link createUnitWorktree} — `git worktree add --detach`; the path is * journaled on the unit row and becomes the unit's cwd. * 3. {@link cleanupUnitWorktree} — a clean tree is removed, a dirty one kept. * "Clean" ignores `.gitignore`d files (build outputs, `node_modules`), * which the repo already declares disposable. * 4. {@link sweepStaleWorktrees} — at most once per process, removes trees * and run roots older than a week. * * Every repo-mutating git call is async and serialized per base repository * ({@link withRepoWorktreeLock}), so parallel units never race on * `.git/worktrees`. Nothing here throws: results are objects the executor maps * onto its failure vocabulary (the fire-and-forget GC sweep reports via `warn`). */ import { spawnSync } from "node:child_process"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { isWithinAsync, safeRealpathAsync } from "../../core/common.js"; import { serializeByKey } from "../../core/concurrent.js"; import { runManagedSubprocess } from "../../core/subprocess.js"; import { warn } from "../../core/warn.js"; /** Timeout for each worktree git call (#891: a busy machine can push a healthy one past 30s). */ const GIT_TIMEOUT_MS = 120_000; /** Directory under `os.tmpdir()` that owns every run's worktree roots. */ export const WORKTREES_DIR_NAME = "akm-worktrees"; /** Age after which an orphaned or retained worktree is swept: long enough to triage a failed run. */ export const STALE_WORKTREE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; function gitExitError(args, code, stderr, stdout) { const detail = (stderr || stdout || "").trim(); return `git ${args.join(" ")} exited ${code}${detail ? `: ${detail}` : ""}`; } /** * Run one git command asynchronously via the real `git` binary; `ok` = exit * 0. Never throws (spawn errors and the timeout → ok: false). */ async function realGitExecutor(cwd, args) { const result = await runManagedSubprocess(["git", "-C", cwd, ...args], { capture: true, timeoutMs: GIT_TIMEOUT_MS, }); if (result.spawnError) { return { ok: false, stdout: "", error: `git ${args[0]} failed to spawn: ${result.spawnError.message}` }; } if (result.timedOut) { return { ok: false, stdout: result.stdout, error: `git ${args.join(" ")} timed out after ${GIT_TIMEOUT_MS}ms` }; } if (result.exitCode !== 0) { return { ok: false, stdout: result.stdout, error: gitExitError(args, result.exitCode, result.stderr, result.stdout), }; } return { ok: true, stdout: result.stdout }; } let gitExecutor = realGitExecutor; /** Test seam: swap the git executor for repo-mutating calls (`undefined` restores the real one). */ export function setGitExecutorForTesting(executor) { gitExecutor = executor ?? realGitExecutor; } /** * Run one git command asynchronously; `ok` = exit 0. Never throws. Async so a * git lock wait parks a promise instead of blocking the event loop; * repo-mutating callers must hold {@link withRepoWorktreeLock}. */ async function git(cwd, args) { return gitExecutor(cwd, args); } /** * Synchronous git for the two read-only probes that run before any unit is in * flight ({@link isGitAvailable}, {@link assertGitWorkTree}). They take no * repository lock, so blocking here cannot stall another unit's git call. */ function gitSync(cwd, args) { const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: GIT_TIMEOUT_MS }); if (result.error) { return { ok: false, stdout: "", error: `git ${args[0]} failed to spawn: ${result.error.message}` }; } if (result.status !== 0) { return { ok: false, stdout: result.stdout ?? "", error: gitExitError(args, result.status, result.stderr ?? "", result.stdout ?? ""), }; } return { ok: true, stdout: result.stdout ?? "" }; } /** True when a usable `git` binary is on PATH (tests skip gracefully without one). */ export function isGitAvailable() { const result = spawnSync("git", ["--version"], { encoding: "utf8", timeout: 5_000 }); return !result.error && result.status === 0; } /** * Preflight for worktree isolation: `dir` must be inside a git work tree. * Returns an error message (for a clean step failure) or undefined when ok. * A missing git binary reports as the same clean failure — a workflow that * declares isolation cannot run without git. */ export function assertGitWorkTree(dir) { const result = gitSync(dir, ["rev-parse", "--is-inside-work-tree"]); if (!result.ok) { return `"${dir}" is not a git repository (isolation: worktree requires one): ${result.error}`; } if (result.stdout.trim() !== "true") { return `"${dir}" is not inside a git work tree (isolation: worktree requires one).`; } return undefined; } // ── Per-repository serialization ──────────────────────────────────────────── /** In-flight tail of each base repository's serialized git-worktree chain. */ const repoOperationTails = new Map(); /** Base repos already pruned in this process, per run id (dropped with the run's drained root). */ const prunedRuns = new Map(); /** * Serialize `fn` against every other repo-mutating worktree operation on the * same base repository ({@link serializeByKey}). Keyed by the RESOLVED repo * path so two spellings of one repo (symlinked tmpdir, relative cwd) share a * chain. A failure rejects its own caller but never wedges the chain. */ function withRepoWorktreeLock(repoKey, fn) { return serializeByKey(repoOperationTails, repoKey, fn); } /** Journal-safe directory name for a unit attempt id (ids carry `:` / `~`). */ function sanitizeAttemptId(attemptId) { return attemptId.replace(/[^A-Za-z0-9._-]/g, "-"); } /** Parent directory of every run's worktree root (`<tmp>/akm-worktrees`). */ export function worktreesRoot() { return path.join(os.tmpdir(), WORKTREES_DIR_NAME); } /** Run-scoped parent directory for all of one run's unit worktrees. */ export function runWorktreeRoot(runId) { return path.join(worktreesRoot(), runId); } /** * Move a leftover attempt directory aside to `<dest>.retained-<ts>[-n]` * (never overwriting an earlier retained copy). Throws on fs errors — the * caller maps them onto its result object. */ async function moveLeftoverAside(dest) { const base = `${dest}.retained-${Date.now()}`; let aside = base; for (let n = 1; await pathExists(aside); n++) aside = `${base}-${n}`; await fsp.rename(dest, aside); return aside; } async function pathExists(p) { return fsp.access(p).then(() => true, () => false); } /** * Create a fresh detached worktree at `<tmp>/akm-worktrees/<runId>/<attemptId>`. * A leftover at that path is removed when clean, otherwise moved aside to * `<dest>.retained-<ts>` (`preservedLeftover`). Probe, prune, and add form one * critical section under {@link withRepoWorktreeLock}. */ export async function createUnitWorktree(baseDir, runId, attemptId, commitOid) { // Opportunistic, at most once per process, never awaited — GC must never sit // on the dispatch path. sweepStaleWorktreesOnce(); const repoKey = await safeRealpathAsync(baseDir); const dest = path.join(runWorktreeRoot(runId), sanitizeAttemptId(attemptId)); return withRepoWorktreeLock(repoKey, async () => { let preservedLeftover; let leftoverHandled = false; try { if (await pathExists(dest)) { const status = await git(dest, ["status", "--porcelain"]); if (status.ok && status.stdout.trim() === "") { // Async on purpose: a recursive delete of a whole leftover checkout // inside this critical section would otherwise block the event loop // (every other in-flight unit, abort handling). await fsp.rm(dest, { recursive: true, force: true }); } else { preservedLeftover = await moveLeftoverAside(dest); } leftoverHandled = true; } await fsp.mkdir(path.dirname(dest), { recursive: true }); } catch (err) { return { ok: false, error: `could not prepare worktree directory ${dest}: ${message(err)}`, ...(preservedLeftover !== undefined ? { preservedLeftover } : {}), }; } // Prune (never touches a live worktree) after a leftover was removed, and // on the first worktree of this (repo, run) to reap orphaned registrations. const prunedRepos = prunedRuns.get(runId); if (leftoverHandled || !prunedRepos?.has(repoKey)) { if (prunedRepos) prunedRepos.add(repoKey); else prunedRuns.set(runId, new Set([repoKey])); await git(baseDir, ["worktree", "prune"]); } const added = await git(baseDir, ["worktree", "add", "--detach", dest, ...(commitOid ? [commitOid] : [])]); if (!added.ok) { return { ok: false, error: `could not create isolation worktree at ${dest}: ${added.error}`, ...(preservedLeftover !== undefined ? { preservedLeftover } : {}), }; } return { ok: true, path: dest, ...(preservedLeftover !== undefined ? { preservedLeftover } : {}) }; }); } /** * Post-unit cleanup: remove a clean worktree; retain (dirty: true) one with * uncommitted work or whose state could not be verified. `.gitignore`d files do * not count as work. */ export async function cleanupUnitWorktree(baseDir, worktreePath) { // The removal (without --force) is itself the cleanliness check: one git process per clean unit. const removed = await withRepoWorktreeLock(await safeRealpathAsync(baseDir), () => git(baseDir, ["worktree", "remove", worktreePath])); if (removed.ok) { await removeRunRootIfEmpty(worktreePath); return { removed: true, dirty: false }; } // It refused, so the tree stays on disk. Ask the probe WHY it refused rather // than parsing git's message, whose wording varies with version and locale — // and which the caller's warn text has never been written against. const status = await git(worktreePath, ["status", "--porcelain"]); if (!status.ok) { return { removed: false, dirty: false, error: status.error }; } if (status.stdout.trim() !== "") { return { removed: false, dirty: true }; } return { removed: false, dirty: false, error: removed.error }; } // ── Garbage collection ────────────────────────────────────────────────────── /** Drop the run-scoped root once empty (`rmdir` keeps any retained tree). */ async function removeRunRootIfEmpty(worktreePath) { const root = worktreesRoot(); const runRoot = path.dirname(path.resolve(worktreePath)); if (!(await isWithinAsync(runRoot, root))) return; if ((await safeRealpathAsync(path.dirname(runRoot))) !== (await safeRealpathAsync(root))) return; try { await fsp.rmdir(runRoot); } catch { // ENOTEMPTY (retained work) / ENOENT (already gone) — both fine. return; } // The run's worktrees are fully drained — drop its prune bookkeeping so // `prunedRuns` never outgrows the live runs. (A later worktree of the same // run simply prunes once more; the guard is an optimization, not a // correctness gate.) prunedRuns.delete(path.basename(runRoot)); } /** * Age-based GC of the worktrees root: removes `<root>/<runId>/<entry>` * directories older than `maxAgeMs`, and empty stale run roots. Descends two * levels only, never follows a symlink, and re-checks containment before each * removal. Returns the paths removed; never throws. */ export async function sweepStaleWorktrees(opts = {}) { const root = path.resolve(opts.root ?? worktreesRoot()); const removed = []; if (path.basename(root) !== WORKTREES_DIR_NAME) return removed; const maxAgeMs = opts.maxAgeMs ?? STALE_WORKTREE_MAX_AGE_MS; const now = opts.now ?? Date.now(); let runRoots; try { runRoots = await fsp.readdir(root, { withFileTypes: true, encoding: "utf8" }); } catch { return removed; // No root yet (or unreadable) — nothing to sweep. } for (const runRootEntry of runRoots) { if (!runRootEntry.isDirectory()) continue; const runRoot = path.join(root, runRootEntry.name); if (!(await isWithinAsync(runRoot, root))) continue; let entries; try { entries = await fsp.readdir(runRoot, { withFileTypes: true, encoding: "utf8" }); } catch { continue; } let emptiedHere = false; for (const entry of entries) { if (!entry.isDirectory()) continue; const candidate = path.join(runRoot, entry.name); if (!(await isWithinAsync(candidate, root))) continue; if (now - (await lastActivityMs(candidate, entry.name)) < maxAgeMs) continue; try { await fsp.rm(candidate, { recursive: true, force: true }); removed.push(candidate); emptiedHere = true; } catch { /* leave it for the next sweep */ } } try { if ((await fsp.readdir(runRoot)).length > 0) continue; if (!emptiedHere && now - (await lastActivityMs(runRoot, runRootEntry.name)) < maxAgeMs) continue; await fsp.rmdir(runRoot); removed.push(runRoot); } catch { /* leave it for the next sweep */ } } return removed; } /** * Newest evidence of activity for `p`: its mtime, or the timestamp embedded in * a `.retained-<ts>[-n]` name when that is newer. Taking the max is the * conservative direction — a sweep never deletes something that looks recent * by either measure. An unstattable entry reports as "now" so it survives. */ async function lastActivityMs(p, name) { let mtimeMs; try { mtimeMs = (await fsp.stat(p)).mtimeMs; } catch { return Date.now(); } const stamped = /\.retained-(\d{10,})(?:-\d+)?$/.exec(name); return stamped ? Math.max(mtimeMs, Number(stamped[1])) : mtimeMs; } let sweepStarted = false; /** * Kick off the GC sweep at most once per process, fire-and-forget. Called from * {@link createUnitWorktree} so the cost is paid by a run that is already * doing worktree work, and never awaited so dispatch does not wait on it. */ function sweepStaleWorktreesOnce() { if (sweepStarted) return; sweepStarted = true; void sweepStaleWorktrees() .then((removed) => { if (removed.length === 0) return; const shown = removed.slice(0, 10).join(", "); const rest = removed.length > 10 ? ` (+${removed.length - 10} more)` : ""; warn(`Workflow worktree GC: removed ${removed.length} stale entr${removed.length === 1 ? "y" : "ies"} ` + `older than ${STALE_WORKTREE_MAX_AGE_MS / (24 * 60 * 60 * 1000)}d under ${worktreesRoot()}: ${shown}${rest}`); }) .catch(() => { // GC is best-effort observability; a failed sweep never affects a run. }); } function message(err) { return err instanceof Error ? err.message : String(err); }