JuliusBrussee/caveman · error · Error
MCP config change already running for ${canonicalPath}
Error message
MCP config change already running for ${canonicalPath} What it means
withMcpConfigLock serializes MCP config mutations (e.g. `caveman mcp install/uninstall`) with a lock directory published via atomic rename of a claim directory. When the rename hits an existing lock, the code validates the lock's owner.json and probes the owning PID; if the lock is not provably stale (owner process still alive, or owner file malformed/ownerless so it can never be crash residue), it throws this error. The library refuses to delete a lock it cannot prove is dead, so a concurrent or un-cleaned-up mutation blocks a new one.
Source
Thrown at packages/cli/src/index.ts:7792
|| Object.keys(existing).sort().join("\0") !== keys.join("\0")
|| existing.schema_version !== 1
|| typeof existing.pid !== "number" || !Number.isInteger(existing.pid) || existing.pid <= 1
|| typeof existing.token !== "string"
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(existing.token)
|| existing.config_path !== canonicalPath
|| typeof existing.started_at !== "string"
|| new Date(existing.started_at).toISOString() !== existing.started_at
|| process.platform !== "win32" && ((lockStat.mode | ownerStat.mode) & 0o077) !== 0) {
throw new Error("invalid MCP lock owner");
}
try { process.kill(existing.pid, 0); }
catch (probeError) { stale = (probeError as NodeJS.ErrnoException).code === "ESRCH"; }
} catch {
// Populated claim is durable before publication, so malformed or
// ownerless lock can never be our crash residue. Never delete it.
stale = false;
}
if (!stale) throw new Error(`MCP config change already running for ${canonicalPath}`);
const quarantine = `${lock}.stale-${token}`;
try {
renameSync(lock, quarantine);
renameSync(claim, lock);
fsyncParentDirectory(lock);
process.stderr.write(`${mark("warn")} reclaimed stale MCP config lock for ${canonicalPath}\n`);
} catch {
throw new Error(`MCP config change already running for ${canonicalPath}`);
} finally {
try { rmSync(quarantine, { recursive: true, force: true }); } catch { /* isolated stale lock only */ }
}
}
return run();
} finally {
try { rmSync(claim, { recursive: true, force: true }); } catch { /* published or absent */ }
try {
const current = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")) as { token?: unknown };
if (current.token === token) {View on GitHub (pinned to 5184b3d11a)
Solutions
- Wait for the other MCP config change to finish (check for a running caveman process) and retry the command.
- Inspect the lock directory next to your MCP config file (`.caveman-mcp-*.lock`) and read its owner.json to see which PID owns it.
- If the owning PID is dead but the lock is malformed/ownerless, remove the stale lock directory manually, then re-run the command.
- Avoid launching concurrent caveman MCP mutations from scripts/CI; serialize them or use a job-level mutex.
Example fix
// before: racing parallel mutations await Promise.all([caveman(["mcp", "install", "claude"]), caveman(["mcp", "install", "codex"])]); // after: serialize await caveman(["mcp", "install", "claude"]); await caveman(["mcp", "install", "codex"]);
Defensive patterns
Strategy: retry
Validate before calling
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { createHash } from "node:crypto";
import { execSync } from "node:child_process";
function mcpLockIsBusy(configPath: string): boolean {
const canonical = configPath; // simplify: canonicalize as the CLI does
const key = createHash("sha256").update(canonical).digest("hex").slice(0, 20);
const lock = join(dirname(canonical), `.caveman-mcp-${key}.lock`);
if (!existsSync(lock)) return false;
try {
const owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
try { process.kill(owner.pid, 0); return true; } catch (e: any) { return e.code !== "ESRCH"; }
} catch { return true; } // malformed/ownerless lock is treated as authoritative
} Try / catch
try {
runMcpMutation();
} catch (e) {
if (e instanceof Error && e.message.startsWith("MCP config change already running")) {
await sleep(backoffMs); return retryMcpMutation(); // bounded retry with backoff
}
throw e;
} Prevention
- Never run multiple caveman mcp install/uninstall commands concurrently for the same config.
- Serialize MCP mutations in CI with a job-level lock.
- After crashes, check for `.caveman-mcp-*.lock` next to your MCP config and remove it only if its owner PID is dead and owner.json is malformed.
- Keep the config directory on a local filesystem without ACL quirks.
When it happens
Trigger: Calling any MCP config mutation that runs inside withMcpConfigLock(configPath, run) when the canonical lock directory `.caveman-mcp-<sha256-20>.lock` already exists AND either (a) its owner.json validates and process.kill(pid, 0) succeeds (a live concurrent mutation), or (b) the owner data fails validation / is unreadable (malformed or ownerless lock, treated as authoritative and never reclaimed).
Common situations: Running two `caveman mcp install`/`uninstall` commands at once (e.g. two terminals, CI racing a local run); a previous mutation crashed after publishing the lock but with a malformed owner.json; permissions or platform quirks left the lock in a state the validator rejects; a wrapped agent spawned its own caveman mutation while the operator ran one.
Related errors
- target already exists: ${path}
- source does not exist: ${input}
- source file must be named SKILL.md
- SKILL.md missing under ${resolved}
- cave_stale_lock:${checked.stale.join(",")}: run npm run buil
AI-assisted analysis of JuliusBrussee/caveman@5184b3d11a (2026-09-06).
Data as JSON: /api/errors/50674cfd866a2ea4.
Report an issue: GitHub.