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

200 lines (199 loc) • 8.63 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/. /** * Asset-mutation lease — serializes writes to real, authored user content * (source updates, `akm remember`, proposal apply, ...) so two concurrent * writers cannot both pass a git exact-path preflight check before either * commits. * * This module previously also gated full index REBUILDS behind the same * lease with a 12-hour age-based stale-reclaim window (#872). That guard was * removed: the index is a regenerable cache, concurrent rebuilds only waste * work rather than corrupt anything, and a live-but-wedged holder passed the * PID-liveness check forever, so only the 12h clock could ever free it — * which cost one real install a half-day indexing outage. Index rebuilds no * longer take any lease. * * What remains here guards actual data loss (a lost or conflicting git * commit), so per AGENTS.md `## Defensive Code` it stays — but with the same * fix applied: no age-based stale-reclaim. A holder is only ever reclaimed * once its PID is verifiably dead; a live-but-wedged holder makes an * acquisition attempt wait (bounded by `maxWaitMs`) or fail, never silently * override a lease someone might still be using. */ import { AsyncLocalStorage } from "node:async_hooks"; import fs from "node:fs"; import path from "node:path"; import { TransientError } from "../core/errors.js"; import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../core/file-lock.js"; import { getDbPath, getIndexWriterLockPath } from "../core/paths.js"; import { warn } from "../core/warn.js"; import { sleepSync } from "../runtime.js"; const ASSET_MUTATION_WAIT_MS = 100; const DEFAULT_ASSET_MUTATION_MAX_WAIT_MS = 10 * 60 * 1000; /** How often a blocked sync waiter re-announces that it is still waiting. Mirrors the async path's cadence. */ const ASSET_MUTATION_WAIT_NOTICE_INTERVAL_MS = 15_000; /** TEST-ONLY. Shrinks the sync path's max-wait/notice-cadence so a test doesn't block for real minutes/seconds. */ let syncTimingOverridesForTests; export function _setAssetMutationLeaseSyncTimingForTests(overrides) { syncTimingOverridesForTests = overrides; } const leaseContext = new AsyncLocalStorage(); function buildPayload(purpose) { return createLockPayload({ purpose, dbPath: getDbPath(), startedAt: new Date().toISOString(), }); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Describe who currently holds `lockPath`, from `probeLock`'s own payload * (the same `purpose`/`pid`/`startedAt` fields `buildPayload` writes) — * never the waiting caller's own `purpose`, which names what SELF is trying * to do, not who is in the way. */ function describeAssetMutationHolder(lockPath) { const probe = probeLock(lockPath); const rawContent = probe.state === "held" || probe.state === "stale" ? probe.rawContent : undefined; if (!rawContent) return "an asset write"; try { const payload = JSON.parse(rawContent); const who = payload.purpose ?? "an asset write"; return `${who} (pid ${payload.pid ?? "?"}, started ${payload.startedAt ?? "?"})`; } catch { return "an asset write"; } } function throwIfAborted(signal) { if (!signal?.aborted) return; throw signal.reason instanceof Error ? signal.reason : new Error("asset mutation lease wait aborted"); } function createLease(lockPath, ownership) { const exitHandler = () => releaseLock(ownership); process.on("exit", exitHandler); let released = false; return { lockPath, release: () => { if (released) return; released = true; process.off("exit", exitHandler); releaseLock(ownership); }, }; } function tryAcquireAssetMutationLease(lockPath, purpose) { while (true) { const ownership = tryAcquireLockSync(lockPath, buildPayload(purpose)); if (ownership) return createLease(lockPath, ownership); const probe = probeLock(lockPath); if (probe.state !== "stale" || !reclaimStaleLock(lockPath, probe)) return undefined; } } export async function acquireAssetMutationLease(options) { const mode = options.mode ?? "wait"; const lockPath = getIndexWriterLockPath(); const startedAt = Date.now(); const maxWaitMs = options.maxWaitMs ?? DEFAULT_ASSET_MUTATION_MAX_WAIT_MS; fs.mkdirSync(path.dirname(lockPath), { recursive: true }); let lastWaitNoticeMs = 0; while (true) { throwIfAborted(options.signal); const lease = tryAcquireAssetMutationLease(lockPath, options.purpose); if (lease) { options.onAcquired?.({ waitedMs: Date.now() - startedAt }); return lease; } if (mode === "try") return undefined; // Held by another live process. Time out only *after* a real acquisition // attempt, so a caller with maxWaitMs:0 still gets one chance at a free lock // instead of throwing before it ever tries. if (maxWaitMs >= 0 && Date.now() - startedAt >= maxWaitMs) { throw new TransientError(`timed out waiting for asset mutation lease for ${options.purpose}`, "ASSET_MUTATION_LEASE_HELD"); } const waitedMs = Date.now() - startedAt; if (waitedMs - lastWaitNoticeMs >= 15000) { options.onWait?.({ waitedMs }); lastWaitNoticeMs = waitedMs; } await delay(ASSET_MUTATION_WAIT_MS); } } /** Asset writes share one lease so two concurrent writers cannot both pass preflight before either commits. */ export async function withAssetMutationLease(purpose, run) { const lockPath = getIndexWriterLockPath(); const inherited = leaseContext.getStore(); if (inherited?.has(lockPath)) return run(); const context = inherited ?? new Set(); const execute = async () => { const lease = await acquireAssetMutationLease({ purpose }); if (!lease) throw new Error(`asset mutation lease unavailable for ${purpose}`); context.add(lockPath); try { return await run(); } finally { context.delete(lockPath); lease.release(); } }; return inherited ? execute() : leaseContext.run(context, execute); } /** Synchronous asset-write boundary over the same interprocess lease. */ export function withAssetMutationLeaseSync(purpose, run) { const lockPath = getIndexWriterLockPath(); const inherited = leaseContext.getStore(); if (inherited?.has(lockPath)) return run(); const context = inherited ?? new Set(); const execute = () => { const startedAt = Date.now(); const maxWaitMs = syncTimingOverridesForTests?.maxWaitMs ?? DEFAULT_ASSET_MUTATION_MAX_WAIT_MS; const waitNoticeIntervalMs = syncTimingOverridesForTests?.waitNoticeIntervalMs ?? ASSET_MUTATION_WAIT_NOTICE_INTERVAL_MS; fs.mkdirSync(path.dirname(lockPath), { recursive: true }); let lease; let lastWaitNoticeMs = 0; while (!lease) { lease = tryAcquireAssetMutationLease(lockPath, purpose); if (!lease) { const waitedMs = Date.now() - startedAt; if (waitedMs >= maxWaitMs) { throw new TransientError(`timed out waiting for asset mutation lease for ${purpose}`, "ASSET_MUTATION_LEASE_HELD"); } // #956: the sync path used to wait up to 10 minutes with zero // progress feedback. Mirror the async path's 15s onWait cadence, but // actually warn by default — no caller wires onWait on this path. if (waitedMs - lastWaitNoticeMs >= waitNoticeIntervalMs) { warn(`waiting for ${describeAssetMutationHolder(lockPath)} — ${Math.round(waitedMs / 1000)}s`); lastWaitNoticeMs = waitedMs; } sleepSync(ASSET_MUTATION_WAIT_MS); } } context.add(lockPath); try { return run(); } finally { context.delete(lockPath); lease.release(); } }; return inherited ? execute() : leaseContext.run(context, execute); } export function probeAssetMutationLease() { return probeLock(getIndexWriterLockPath()); }