JuliusBrussee/caveman · error · Error

MCP config target changed while acquiring lock; refusing…

Error message

${agent} ${serverName} MCP config target changed while acquiring lock; refusing mutation

What it means

transactOwnedMcpConfig re-plans the MCP change inside the lock and checks that the resolved config file path still matches the path that was locked (lockedConfigPath). If planning now resolves to a different config file than the one the lock was acquired for, the CLI refuses to mutate, because writing would not be protected by the held lock.

Solutions

  1. Re-run the command; if the path resolution is now stable the transaction will proceed.
  2. Check for and stop processes that mutate HOME/XDG_CONFIG_HOME or move/symlink agent config directories.
  3. Verify the agent config location is stable: remove dangling symlinks or duplicate configs so plan resolution is deterministic.
  4. Ensure only one caveman process operates on MCP config at a time (the lock exists for this — don't bypass it).

Example fix

// before: HOME flips between lock and plan
export XDG_CONFIG_HOME=/tmp/new; caveman mcp install kilo
// after: run in a stable environment
unset XDG_CONFIG_HOME && caveman mcp install kilo
Defensive patterns

Strategy: retry

Validate before calling

const before = resolveMcpConfigPath(agent);
const after = resolveMcpConfigPath(agent); // re-resolve
if (before !== after) console.error('config path resolution is unstable; fix env/symlinks before running caveman mcp');

Type guard

function pathResolutionIsStable(resolve: () => string): boolean {
  return resolve() === resolve();
}

Try / catch

try {
  runMcpCommand();
} catch (e) {
  if (String(e.message).includes('target changed while acquiring lock')) {
    // retry in a stable environment
    runMcpCommand();
  }
}

Prevention

When it happens

Trigger: Thrown when lockedConfigPath is set and plan.path !== canonicalMcpConfigPath(lockedConfigPath) — the plan (planMcpKiloJson/planMcpQwenJson or the remove variants) resolves to a different config path between lock acquisition and execution, e.g. because HOME, XDG env vars, or agent config discovery changed mid-run.

Common situations: Concurrent process changed an env var the config resolution depends on; the agent's config was created/moved at a new location while the command was waiting for the lock; symlink retargeting of a config directory during the wait; test harnesses overriding HOME mid-flight.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/e4742aaea618b300. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:12880

  }
  removePendingCopies();
  process.stderr.write(`${mark("warn")} rolled back interrupted ${agent} ${serverName} MCP transaction\n`);
  return "rolled-back";
}

function transactOwnedMcpConfig(
  agent: "kilo" | "qwen",
  serverName: string,
  action: "install" | "uninstall",
  mcp?: { command: string; args: string[] },
  lockedConfigPath?: string,
): boolean {
  const plan = action === "install"
    ? agent === "kilo" ? planMcpKiloJson(mcp!, serverName) : planMcpQwenJson(mcp!, serverName)
    : agent === "kilo" ? planRemoveMcpKiloJson(serverName) : planRemoveMcpQwenJson(serverName);
  if (!plan) return false;
  if (lockedConfigPath && plan.path !== canonicalMcpConfigPath(lockedConfigPath)) {
    throw new Error(`${agent} ${serverName} MCP config target changed while acquiring lock; refusing mutation`);
  }

  const markerPath = canonicalOwnedMcpMarkerPath(agent, serverName);
  const markerBefore = fileBytes(markerPath);
  if (markerBefore !== null && !validMcpMarkerBytes(markerBefore, agent, serverName)) {
    throw new Error(`${markerPath} is not a valid Caveman ownership journal; refusing overwrite`);
  }
  const markerBeforeMode = markerBefore === null ? 0o600 : statSync(markerPath).mode & 0o777;
  if (!optionalBytesEqual(fileBytes(markerPath), markerBefore)) {
    throw new Error(`${markerPath} changed while planning MCP update; refusing overwrite`);
  }
  const markerAfter = action === "install" ? mcpMarkerBytes(mcp!, mcpServerToolName(serverName)!, plan.path) : null;
  if (!plan.changed && optionalBytesEqual(markerBefore, markerAfter)) return true;

  const journal: OwnedMcpPendingJournal = {
    schema_version: 1,
    transaction_id: randomUUID(),
    agent,

View on GitHub (pinned to 3ee70a1026)