abhigyanpatwari/GitNexus · error · ValueError
workspace snapshot root must be a real directory: {root}
Error message
workspace snapshot root must be a real directory: {root} What it means
Raised by workspace_snapshot() (runner_artifacts.py:130) when the worktree root is a symlink, not a directory, or is not resolve-stable (root.resolve(strict=True) != root). The snapshot walker refuses to follow links at the root so that a symlinked workspace cannot redirect hashing outside the benchmark boundary. This is the entry guard for the tamper-evident workspace diff used by phase-boundary checks.
Source
Thrown at eval/workflow_bench/runner_artifacts.py:130
def _is_bootstrap_noise(relative: PurePosixPath) -> bool:
"""Report whether a walked entry is harness noise rather than workspace change."""
parts = relative.parts
if parts[0] == ".git" or parts[0] in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE:
return True
return len(parts) >= 2 and parts[-2] == CLAUDE_BOOTSTRAP_DIR and parts[-1] in CLAUDE_BOOTSTRAP_ENTRIES
def workspace_snapshot(worktree: Path) -> dict[str, str]:
"""Hash the workspace without following links, excluding Git internals
and Claude Code's own sandbox-bootstrap noise (see
WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE)."""
root = worktree.expanduser().absolute()
mode = root.lstat().st_mode
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode) or root.resolve(strict=True) != root:
raise ValueError(f"workspace snapshot root must be a real directory: {root}")
snapshot: dict[str, str] = {}
pending: list[tuple[Path, PurePosixPath]] = [(root, PurePosixPath())]
entry_count = 0
path_bytes = 0
file_bytes = 0
nofollow = getattr(os, "O_NOFOLLOW", 0)
while pending:
directory, relative_dir = pending.pop()
try:
children = sorted(os.scandir(directory), key=lambda entry: entry.name, reverse=True)
except OSError as exc:
raise ValueError(f"workspace snapshot directory is unreadable: {directory}: {exc}") from exc
for entry in children:
relative = relative_dir / entry.name
if _is_bootstrap_noise(relative):
continue
entry_count += 1View on GitHub (pinned to d540b00184)
Solutions
- Pass the real (resolved) absolute path of the worktree: Path(...).resolve() before calling workspace_snapshot or before passing --repo.
- Remove any symlink at the worktree root and re-create it as a real directory (make_worktree already does rmdir + clone, so ensure no leftover symlink).
- On macOS, avoid /tmp; use a path under /Users/<user>/ or the project dir where realpath is stable.
Example fix
# before
root = Path("~/repos/proj").expanduser().absolute() # may be a symlink
# after
root = Path("~/repos/proj").expanduser().resolve(strict=True) Defensive patterns
Strategy: validation
Validate before calling
import os, stat
from pathlib import Path
root = Path(worktree).expanduser().resolve(strict=True)
mode = root.lstat().st_mode
assert not stat.S_ISLNK(mode), f"root is a symlink: {root}"
assert stat.S_ISDIR(mode), f"root is not a directory: {root}"
# resolve-stable: resolve(strict=True) must equal the absolute path
assert root == Path(worktree).expanduser().absolute(), (
f"root is not resolve-stable: {worktree} -> {root}")
# Pass `root` (resolved) to workspace_snapshot, not the original path. Type guard
import os, stat
from pathlib import Path
def is_real_directory(path: os.PathLike | str) -> bool:
p = Path(path).expanduser()
try:
st = p.lstat()
except OSError:
return False
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
return False
try:
return p.resolve(strict=True) == p.absolute()
except OSError:
return False Prevention
- Always pass Path(...).resolve() for worktree roots, especially under /tmp or ~.
- On macOS, prefer paths under /Users or the project dir over /tmp (a symlink to /private/tmp).
- Assert the root is a real directory before invoking the harness.
When it happens
Trigger: workspace_snapshot(worktree) is called with a worktree that lstat() reports as S_ISLNK, S_ISDIR false (e.g. a regular file or device node), or whose realpath differs from the absolute path (symlink loop, bind-mount whose resolution differs). Happens when make_worktree's clone target was replaced by a symlink, or the caller passed a ~/ symlinked path.
Common situations: User passes --repo ~/repos/project where ~/repos is a symlink to /mnt/repos; a prior failed run left a symlink where the worktree directory should be; running on macOS where /tmp is a symlink to /private/tmp and expanduser().absolute() does not resolve it.
Related errors
- Analyzer runtime payload scan exceeded ${limits.runtimePaylo
- Refusing to delete ${dbPath}: resolved path ${realPath} is o
- candidate destination must be a regular non-symlink file: {r
- candidate overlay cannot traverse symlinks: {overlay}
- skill fingerprint input must be a regular non-symlink file:
AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12).
Data as JSON: /api/errors/9b3be0bd0d086b90.
Report an issue: GitHub.