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

  1. Wait for the other MCP config change to finish (check for a running caveman process) and retry the command.
  2. Inspect the lock directory next to your MCP config file (`.caveman-mcp-*.lock`) and read its owner.json to see which PID owns it.
  3. If the owning PID is dead but the lock is malformed/ownerless, remove the stale lock directory manually, then re-run the command.
  4. 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

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


AI-assisted analysis of JuliusBrussee/caveman@5184b3d11a (2026-09-06). Data as JSON: /api/errors/50674cfd866a2ea4. Report an issue: GitHub.