paperclipai/paperclip · error · Error

ACPX package directory changed during snapshot

Error message

ACPX package directory changed during snapshot

What it means

After recursively copying a package directory, copy() re-stats the source and compares it (bigint identity: mode, size, mtime, etc.) with the stat taken before the copy. If the directory changed during the copy, the snapshot is inconsistent and the library throws. It is a TOCTOU integrity check guaranteeing the snapshot is a point-in-time consistent image.

Source

Thrown at packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts:116

    if (++filesCopied > 30_000)
      throw new Error("ACPX package snapshot exceeds its file bound");
    const before = await lstat(source, { bigint: true });
    if (before.isSymbolicLink()) {
      const canonical = await realpath(source);
      const mapped = mapPath(canonical);
      // Package-manager links to unqualified packages do not grant import authority.
      if (mapped) await symlink(mapped, target);
      return;
    }
    if (!within(root, await realpath(source)))
      throw new Error("ACPX snapshot escaped its package");
    if (before.isDirectory()) {
      await mkdir(target, { mode: 0o700 });
      directories.push(target);
      for (const entry of await readdir(source))
        await copy(join(source, entry), join(target, entry), root);
      if (!same(before, await lstat(source, { bigint: true })))
        throw new Error("ACPX package directory changed during snapshot");
      return;
    }
    if (!before.isFile() || before.size > 16n * 1024n * 1024n)
      throw new Error("ACPX module must be a bounded regular file");
    bytesCopied += Number(before.size);
    if (bytesCopied > MAX_PACKAGE_SNAPSHOT_BYTES)
      throw new Error("ACPX package snapshot exceeds its byte bound");
    const handle = await open(
      source,
      constants.O_RDONLY | constants.O_NOFOLLOW,
    );
    try {
      if (!same(before, await handle.stat({ bigint: true })))
        throw new Error("ACPX module changed before snapshot");
      const bytes = await readSnapshotBytes(handle, Number(before.size));
      if (
        !same(before, await handle.stat({ bigint: true })) ||
        !same(before, await lstat(source, { bigint: true }))

View on GitHub (pinned to 01ad858492)

Solutions

  1. Quiesce all writers (installs, watchers, generators) before creating the snapshot and re-run it
  2. Exclude volatile directories (caches, .cache, tmp output) from the package root
  3. Re-run the snapshot after the tree stabilizes — the check is intentionally conservative
  4. On CI, snapshot from a clean checkout rather than a live workspace

Example fix

// before: watcher active during snapshot
const watcher = startWatcher(pkgRoot);
await snapshot([pkgRoot]);
// after
watcher.stop();
await snapshot([pkgRoot]);
Defensive patterns

Strategy: retry

Validate before calling

const before = await fs.stat(dir);
await new Promise(r => setTimeout(r, 250));
const after = await fs.stat(dir);
if (before.mtimeMs !== after.mtimeMs) throw new Error('directory is actively changing');

Try / catch

try {
  await createAcpxPrivateSnapshot({ roots });
} catch (e) {
  if (e.message.includes('directory changed during snapshot')) {
    await waitForWritersToFinish(roots);
    await createAcpxPrivateSnapshot({ roots });
  } else throw e;
}

Prevention

When it happens

Trigger: Any mutation (file added/removed/renamed, or metadata change) inside a source package directory between the initial lstat and the post-copy lstat while createAcpxPrivateSnapshot walks it.

Common situations: Running `npm/pnpm install`, a bundler watcher, a test runner emitting artifacts, or an editor/linter writing cache files inside the package tree while a session snapshot is taken.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/cb4d8c689dbec028. Report an issue: GitHub.