paperclipai/paperclip · error · Error

Attempt source is not a real directory: ${source}

Error message

Attempt source is not a real directory: ${source}

What it means

copyAttempt copies a runner attempt directory into the campaign destination. It first lstats the source and requires it to be a real directory that is not a symlink. This guards against copying symlinks or files as attempts, which would break the per-attempt file enumeration that follows.

Source

Thrown at packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs:234

    maxParallelPerShard: Math.floor(maxParallel / 2),
  };
}

async function writeGithubOutput(entries) {
  const output = process.env.GITHUB_OUTPUT;
  if (!output) return;
  await appendFile(
    output,
    Object.entries(entries)
      .map(([key, value]) => `${key}=${value}\n`)
      .join(""),
  );
}

async function copyAttempt(source, destination) {
  const metadata = await lstat(source);
  if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
    throw new Error(`Attempt source is not a real directory: ${source}`);
  }
  await mkdir(destination, { recursive: false, mode: 0o700 });
  for (const entry of await readdir(source, { withFileTypes: true })) {
    if (
      !entry.isFile() ||
      entry.isSymbolicLink() ||
      !ATTEMPT_FILES.has(entry.name)
    ) {
      throw new Error(`Unexpected attempt path ${join(source, entry.name)}`);
    }
    await cp(join(source, entry.name), join(destination, entry.name), {
      errorOnExist: true,
    });
  }
  for (const required of [
    "artifact.json",
    "score.json",
    "case.json",

View on GitHub (pinned to 01ad858492)

Solutions

  1. Verify the source attempt path exists and is an actual directory (ls -la).
  2. Recreate the attempt run if the directory was deleted or moved.
  3. Replace any symlink to the attempt directory with the real directory (or copy the real content).

Example fix

// before
copyAttempt("runs/latest", dest); // latest is a symlink
// after
copyAttempt(await fs.realpath("runs/latest"), dest);
Defensive patterns

Strategy: validation

Validate before calling

const st = await lstat(source);
if (!st.isDirectory() || st.isSymbolicLink()) {
  throw new Error(`Attempt source must be a real directory: ${source}`);
}

Type guard

function isRealDirectoryStat(st) {
  return st.isDirectory() && !st.isSymbolicLink();
}

Try / catch

try {
  await copyAttempt(source, dest);
} catch (err) {
  if (err.message.startsWith("Attempt source is not a real directory")) {
    console.error(`Fix or recreate the attempt at ${source}`);
    process.exit(1);
  }
  throw err;
}

Prevention

When it happens

Trigger: Passing a path to copyAttempt that does not exist, is a regular file, or is a symbolic link rather than a real directory.

Common situations: A stale attempt path left after cleanup; the attempt directory being symlinked (common in workspace setups); passing a parent directory that was moved; a partially failed run that left a file where a directory was expected.

Related errors


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