abhigyanpatwari/GitNexus · error · ValueError

{label} must be a real non-symlink directory: {path}

Error message

{label} must be a real non-symlink directory: {path}

What it means

Thrown by _require_real_directory() in eval/workflow_bench/evolution.py when lstat() succeeds but the entry is a symlink (S_ISLNK) or not a directory (not S_ISDIR). The skill/promotion pipeline refuses symlinks and non-directories because they could redirect outside the sandbox, and refuses to follow links (no resolve()) to avoid erasing link semantics.

Source

Thrown at eval/workflow_bench/evolution.py:71

    "subagents), or sum usage from the digest-bound transcript_artifacts in "
    "each run output, deduplicating events "
    "that share one message.id."
)
EVIDENCE_MAX_AGE_DAYS = 90
MAX_CANDIDATE_OVERLAY_BYTES = 4 * 1024 * 1024
MAX_SKILL_FINGERPRINT_BYTES = 4 * 1024 * 1024
MAX_CANDIDATE_ENTRIES = 256
MAX_CANDIDATE_FILES = 64
MAX_CANDIDATE_PATH_BYTES = 512


def _require_real_directory(path: Path, *, label: str) -> None:
    try:
        metadata = path.lstat()
    except OSError as exc:
        raise ValueError(f"{label} is unavailable: {path}: {exc}") from exc
    if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
        raise ValueError(f"{label} must be a real non-symlink directory: {path}")


def _require_directory_chain(root: Path, relative: Path, *, label: str) -> None:
    """Validate each lexical directory without erasing links via resolve()."""

    _require_real_directory(root, label=label)
    current = root
    for part in relative.parts:
        if part in {"", ".", ".."}:
            raise ValueError(f"{label} contains an unsafe path component: {relative}")
        current /= part
        _require_real_directory(current, label=label)


def _bounded_regular_bytes(path: Path, *, limit: int, label: str) -> bytes:
    """Read one bounded regular file without following its leaf link."""

    try:

View on GitHub (pinned to d540b00184)

Solutions

  1. Replace the symlink with the real directory: `cp -rL` to dereference, or point directly at the target directory.
  2. Re-extract any tarball with --no-same-owner and dereference, or filter symlinks out of the candidate set.
  3. Point the argument at the directory itself, not a symlink or a file inside it.
  4. Audit the candidate-generation step to ensure it writes real directories.

Example fix

# before
ln -s /home/user/real-overlay /tmp/overlay
candidate_overlay_payload(Path('/tmp/overlay'))
# ValueError: candidate overlay directory must be a real non-symlink directory

# after
cp -rL /home/user/real-overlay /tmp/overlay
candidate_overlay_payload(Path('/tmp/overlay'))
Defensive patterns

Strategy: validation

Validate before calling

import os, stat
def ensure_real_dir(p: Path, label: str) -> None:
    st = p.lstat()
    if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
        raise SystemExit(f'{label} must be a real directory, not a symlink/file: {p}')
# call before _require_real_directory

Type guard

def is_not_real_directory_error(exc: ValueError) -> bool:
    return 'must be a real non-symlink directory' in str(exc)

Try / catch

try:
    candidate_overlay_payload(overlay)
except ValueError as e:
    if is_not_real_directory_error(e):
        # re-stage with cp -rL to dereference, then retry
        raise
    raise

Prevention

When it happens

Trigger: An overlay or skill-directory path that is actually a symlink (e.g. `ln -s /elsewhere /tmp/overlay`), or a path that points at a regular file rather than a directory. _require_directory_chain calls this for the root and for each lexical path component.

Common situations: User created a convenience symlink to their candidate overlay; a tool packed the overlay as a tarball that extracted symlinks; path points at a file (e.g. a yaml) instead of its parent dir; CI set up a symlinked workspace.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12). Data as JSON: /api/errors/601b9353f0908158. Report an issue: GitHub.