from __future__ import annotations

import argparse
import difflib
import hashlib
import json
import os
import platform
import queue
import re
import shutil
import subprocess
import sys
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any

_REPO_ROOT = Path(__file__).resolve().parents[1]
_SRC_ROOT = _REPO_ROOT / "src"
if str(_SRC_ROOT) not in sys.path:
    sys.path.insert(0, str(_SRC_ROOT))


class Condition(str, Enum):
    TRACEBACK_ONLY = "traceback_only"
    WITH_SNAPSHOT = "with_snapshot"
    WITH_SNAPSHOT_STRUCTURED = "with_snapshot_structured"


VALID_CATEGORIES = {
    "hidden_state",
    "mutability_aliasing",
    "async_temporal",
    "cross_file_contract",
    "data_shape_runtime",
    "parse_value",
    "path_import_env",
    "traceback_controls",
    # Extended categories for imported datasets.
    "syntax_error",
    "reference_error",
    "logic_error",
    "type_coercion",
}
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
VALID_SNAPSHOT_DEPENDENCIES = {"required", "helpful", "none"}
PATCH_APPLY_TIMEOUT_SEC = 10
PYTEST_TIMEOUT_RETURNCODE = 124
EVAL_RECORD_SCHEMA_VERSION = 3
RECORD_MODES = {"full", "hash", "none"}
CONTEXT_PROTOCOLS = {"monolithic", "staged"}
RETRY_PROMPT_MODES = {"full", "delta", "auto"}
RETRY_CONTEXT_MODES = {"fresh", "history"}
OPENAI_API_KEY_ENV_FALLBACKS = ("Z_API", "OPENAI_API_KEY", "LLMDEBUG_EVAL_OPENAI_API_KEY")
VALID_EXECUTORS = {"local", "testcontainers"}
CONTAINER_NETWORK_MODES = {"none", "bridge"}
DEFAULT_EXECUTOR = "testcontainers"
DEFAULT_CONTAINER_IMAGE = os.getenv("LLMDEBUG_EVAL_CONTAINER_IMAGE", "llmdebug-eval-runner:latest")
DEFAULT_CONTAINER_NETWORK = "none"
DEFAULT_CONTAINER_CPUS = 1.0
DEFAULT_CONTAINER_MEMORY = "1g"
DEFAULT_CONTAINER_PIDS_LIMIT = 256
DEFAULT_CONTAINER_TMPFS_SIZE_MB = 128
DEFAULT_CONTAINER_CLEANUP_STALE_GRACE_SEC = 300
EVAL_CONTAINER_LABEL_KEY = "io.llmdebug.eval.managed"
EVAL_CONTAINER_LABEL_VALUE = "1"
_CONTAINER_ACTIVE_STATES = {"running", "restarting", "paused"}


def _default_container_user() -> str:
    try:
        uid = getattr(os, "getuid", None)
        gid = getattr(os, "getgid", None)
        if callable(uid) and callable(gid):
            return f"{int(uid())}:{int(gid())}"
    except Exception:
        pass
    return "0:0"


DEFAULT_CONTAINER_USER = _default_container_user()


@dataclass(frozen=True)
class StageAContextData:
    blocks: list[dict[str, Any]]
    text: str
    used_fallback: bool
    requests_count: int
    chars_returned: int
    request_success: bool | None
    request_payload: dict[str, Any] | None
    request_raw: str | None
    reason: str
    evidence_items: list[dict[str, Any]] = field(default_factory=list)


def _empty_stage_a_context(*, reason: str = "not_used") -> StageAContextData:
    return StageAContextData(
        blocks=[],
        text="",
        used_fallback=False,
        requests_count=0,
        chars_returned=0,
        request_success=None,
        request_payload=None,
        request_raw=None,
        reason=reason,
        evidence_items=[],
    )


@dataclass
class _ActiveRunSettings:
    deterministic: bool = False
    seed: int | None = None


_ACTIVE_RUN_SETTINGS = _ActiveRunSettings()


def _first_nonempty_env(*keys: str) -> str:
    for key in keys:
        value = os.getenv(str(key), "")
        if isinstance(value, str) and value.strip():
            return value
    return ""


def _default_openai_api_key() -> str:
    return _first_nonempty_env(*OPENAI_API_KEY_ENV_FALLBACKS)


def add_execution_arguments(
    parser: argparse.ArgumentParser,
    *,
    default_executor: str = DEFAULT_EXECUTOR,
    include_defaults: bool = True,
) -> None:
    def _default(value: Any) -> Any:
        return value if include_defaults else argparse.SUPPRESS

    parser.add_argument(
        "--executor",
        choices=["local", "testcontainers"],
        default=_default(default_executor),
        help="Test execution backend.",
    )
    parser.add_argument(
        "--container-image",
        default=_default(DEFAULT_CONTAINER_IMAGE),
        help="Container image for --executor testcontainers.",
    )
    parser.add_argument(
        "--container-network",
        choices=["none", "bridge"],
        default=_default(DEFAULT_CONTAINER_NETWORK),
        help="Container network mode for --executor testcontainers.",
    )
    parser.add_argument(
        "--container-cpus",
        type=float,
        default=_default(DEFAULT_CONTAINER_CPUS),
        help="CPU limit for containerized pytest execution.",
    )
    parser.add_argument(
        "--container-memory",
        default=_default(DEFAULT_CONTAINER_MEMORY),
        help="Memory limit for containerized pytest execution (e.g. 1g).",
    )
    parser.add_argument(
        "--container-pids-limit",
        type=int,
        default=_default(DEFAULT_CONTAINER_PIDS_LIMIT),
        help="PID limit for containerized pytest execution.",
    )
    parser.add_argument(
        "--container-user",
        default=_default(DEFAULT_CONTAINER_USER),
        help="Container user for pytest execution (uid:gid).",
    )
    parser.add_argument(
        "--container-tmpfs-size-mb",
        type=int,
        default=_default(DEFAULT_CONTAINER_TMPFS_SIZE_MB),
        help="Size in MB for /tmp tmpfs in the container.",
    )
    parser.add_argument(
        "--container-mount-repo-src",
        dest="container_mount_repo_src",
        action="store_true",
        default=_default(True),
        help="Mount local src/ into container as read-only for llmdebug plugin imports.",
    )
    parser.add_argument(
        "--container-no-mount-repo-src",
        dest="container_mount_repo_src",
        action="store_false",
        default=_default(True),
        help="Do not mount local src/ into container.",
    )
    parser.add_argument(
        "--container-env",
        action="append",
        default=_default([]),
        help="Environment variable to inject in container (KEY=VALUE, repeatable).",
    )


def _parse_container_env(entries: list[str]) -> dict[str, str]:
    out: dict[str, str] = {}
    for raw in entries:
        item = str(raw).strip()
        if not item:
            continue
        if "=" not in item:
            raise ValueError(f"invalid --container-env entry {raw!r}: expected KEY=VALUE")
        key, value = item.split("=", 1)
        key = key.strip()
        if not key:
            raise ValueError(f"invalid --container-env entry {raw!r}: empty key")
        out[key] = value
    return out


def build_execution_config_from_args(args: argparse.Namespace) -> ExecutionConfig:
    executor = str(getattr(args, "executor", DEFAULT_EXECUTOR)).strip().lower()
    if executor not in VALID_EXECUTORS:
        raise ValueError(f"invalid --executor: {executor!r}")
    container_network = (
        str(getattr(args, "container_network", DEFAULT_CONTAINER_NETWORK)).strip().lower()
    )
    if container_network not in CONTAINER_NETWORK_MODES:
        raise ValueError(f"invalid --container-network: {container_network!r}")
    container_image = str(getattr(args, "container_image", DEFAULT_CONTAINER_IMAGE)).strip()
    if not container_image:
        raise ValueError("--container-image must be non-empty")
    container_cpus = float(getattr(args, "container_cpus", DEFAULT_CONTAINER_CPUS))
    if container_cpus <= 0:
        raise ValueError("--container-cpus must be > 0")
    container_pids_limit = int(getattr(args, "container_pids_limit", DEFAULT_CONTAINER_PIDS_LIMIT))
    if container_pids_limit <= 0:
        raise ValueError("--container-pids-limit must be > 0")
    container_tmpfs_size_mb = int(
        getattr(args, "container_tmpfs_size_mb", DEFAULT_CONTAINER_TMPFS_SIZE_MB)
    )
    if container_tmpfs_size_mb <= 0:
        raise ValueError("--container-tmpfs-size-mb must be > 0")
    container_user = str(getattr(args, "container_user", DEFAULT_CONTAINER_USER)).strip()
    if not container_user:
        raise ValueError("--container-user must be non-empty")
    container_memory = str(getattr(args, "container_memory", DEFAULT_CONTAINER_MEMORY)).strip()
    if not container_memory:
        raise ValueError("--container-memory must be non-empty")
    env_raw = getattr(args, "container_env", [])
    container_env = _parse_container_env(env_raw if isinstance(env_raw, list) else [])
    return ExecutionConfig(
        executor=executor,
        container_image=container_image,
        container_network=container_network,
        container_cpus=container_cpus,
        container_memory=container_memory,
        container_pids_limit=container_pids_limit,
        container_user=container_user,
        container_tmpfs_size_mb=container_tmpfs_size_mb,
        container_mount_repo_src=bool(getattr(args, "container_mount_repo_src", True)),
        container_env=container_env,
    )


def get_active_execution_config() -> ExecutionConfig:
    return _ACTIVE_EXECUTION_CONFIG


def set_active_execution_config(config: ExecutionConfig) -> ExecutionConfig:
    global _ACTIVE_EXECUTION_CONFIG
    previous = _ACTIVE_EXECUTION_CONFIG
    _ACTIVE_EXECUTION_CONFIG = config
    return previous


def _execution_config_payload(config: ExecutionConfig) -> dict[str, Any]:
    return {
        "executor": config.executor,
        "container_image": config.container_image,
        "container_network": config.container_network,
        "container_cpus": config.container_cpus,
        "container_memory": config.container_memory,
        "container_pids_limit": config.container_pids_limit,
        "container_user": config.container_user,
        "container_tmpfs_size_mb": config.container_tmpfs_size_mb,
        "container_mount_repo_src": config.container_mount_repo_src,
        "container_env_keys": sorted(config.container_env),
    }


def check_testcontainers_runtime_available() -> tuple[bool, str]:
    try:
        import docker  # type: ignore[import-not-found]
        from testcontainers.core.container import DockerContainer  # noqa: F401
    except Exception as exc:
        return (
            False,
            "testcontainers runtime is not available "
            f"({type(exc).__name__}: {exc}). Install llmdebug[evals].",
        )
    try:
        client = docker.from_env()
        client.ping()
        client.close()
    except Exception as exc:
        return False, f"docker daemon is not reachable ({type(exc).__name__}: {exc})."
    return True, "ok"


def _docker_client_from_env() -> Any:
    import docker  # type: ignore[import-not-found]

    return docker.from_env()


def _parse_docker_created_timestamp_utc(raw: Any) -> datetime | None:
    text = str(raw).strip()
    if not text:
        return None
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    if "." in text:
        head, tail = text.split(".", 1)
        frac = tail
        suffix = ""
        tz_pos = max(frac.find("+"), frac.find("-"))
        if tz_pos > 0:
            suffix = frac[tz_pos:]
            frac = frac[:tz_pos]
        if frac:
            frac = frac[:6]
            text = f"{head}.{frac}{suffix}"
        else:
            text = f"{head}{suffix}"
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _container_created_at_utc(container: Any) -> datetime | None:
    attrs = getattr(container, "attrs", None)
    if not isinstance(attrs, dict):
        return None
    return _parse_docker_created_timestamp_utc(attrs.get("Created"))


def cleanup_stale_eval_containers(
    *,
    grace_sec: int = DEFAULT_CONTAINER_CLEANUP_STALE_GRACE_SEC,
    now_utc: datetime | None = None,
) -> tuple[int, int, str | None]:
    grace = max(0, int(grace_sec))
    try:
        client = _docker_client_from_env()
    except Exception as exc:
        return 0, 0, f"docker client unavailable ({type(exc).__name__}: {exc})"

    removed = 0
    failed = 0
    now_value = (
        now_utc.astimezone(timezone.utc) if now_utc is not None else datetime.now(timezone.utc)
    )
    label_filter = f"{EVAL_CONTAINER_LABEL_KEY}={EVAL_CONTAINER_LABEL_VALUE}"
    try:
        containers = client.containers.list(
            all=True,
            filters={"label": [label_filter]},
        )
        for container in containers:
            status = str(getattr(container, "status", "")).strip().lower()
            if status in _CONTAINER_ACTIVE_STATES:
                continue
            created_at = _container_created_at_utc(container)
            if created_at is not None and grace > 0:
                age_sec = (now_value - created_at).total_seconds()
                if age_sec < float(grace):
                    continue
            try:
                container.remove(force=True, v=True)
                removed += 1
            except Exception:
                failed += 1
    except Exception as exc:
        return 0, 0, f"unable to inspect containers ({type(exc).__name__}: {exc})"
    finally:
        try:
            client.close()
        except Exception:
            pass
    return removed, failed, None


class _EvalContainer:
    """Wraps a running docker-py container for reuse via ``exec_run``."""

    def __init__(self, container: Any, workdirs_host: Path) -> None:
        self._container = container
        self._workdirs_host = workdirs_host

    def exec_pytest(
        self,
        *,
        host_cwd: Path,
        command: list[str],
        env: dict[str, str],
        timeout_sec: int,
    ) -> RunResult:
        """Run *command* inside the container via ``exec_run``."""
        try:
            guest_rel = host_cwd.relative_to(self._workdirs_host)
        except ValueError as exc:
            raise RuntimeError(
                f"host_cwd {host_cwd} is not under workdirs_host {self._workdirs_host}"
            ) from exc
        guest_workdir = str(PurePosixPath("/workdirs") / guest_rel.as_posix())

        full_cmd = ["timeout", "-k", "5", str(timeout_sec), *command]

        start = time.perf_counter()
        exit_code, output = self._container.exec_run(
            cmd=full_cmd,
            environment=env,
            workdir=guest_workdir,
            demux=True,
        )
        end = time.perf_counter()

        if isinstance(output, tuple):
            stdout_raw, stderr_raw = output
        else:
            stdout_raw, stderr_raw = output, b""
        stdout = _coerce_subprocess_output(stdout_raw)
        stderr = _coerce_subprocess_output(stderr_raw)

        timed_out = exit_code == PYTEST_TIMEOUT_RETURNCODE
        if timed_out:
            timeout_message = f"pytest timed out after {timeout_sec}s"
            stderr = timeout_message if not stderr else f"{stderr}\n{timeout_message}"

        return RunResult(
            returncode=int(exit_code),
            duration_sec=float(end - start),
            stdout=stdout,
            stderr=stderr,
            timed_out=timed_out,
            timeout_sec=timeout_sec if timed_out else None,
        )

    def stop(self) -> None:
        """Stop and remove the container."""
        try:
            self._container.stop(timeout=5)
        except Exception:
            pass
        try:
            self._container.remove(force=True, v=True)
        except Exception:
            pass


class _EvalContainerPool:
    """Thread-safe pool of reusable Docker containers for eval pytest runs."""

    def __init__(
        self,
        *,
        size: int,
        execution: ExecutionConfig,
        workdirs_host: Path,
    ) -> None:
        import docker  # type: ignore[import-not-found]

        self._workdirs_host = workdirs_host
        workdirs_host.mkdir(parents=True, exist_ok=True)

        client: Any = docker.from_env()
        tmpfs_size = max(1, int(execution.container_tmpfs_size_mb))

        volumes: dict[str, dict[str, str]] = {
            str(workdirs_host): {"bind": "/workdirs", "mode": "rw"},
        }
        if execution.container_mount_repo_src:
            volumes[str(_SRC_ROOT)] = {"bind": "/repo_src", "mode": "ro"}

        self._containers: list[_EvalContainer] = []
        self._queue: queue.Queue[_EvalContainer] = queue.Queue()

        try:
            for _ in range(size):
                raw = client.containers.run(
                    execution.container_image,
                    command=["sleep", "infinity"],
                    detach=True,
                    read_only=True,
                    network_mode=execution.container_network,
                    tmpfs={"/tmp": f"rw,noexec,nosuid,nodev,size={tmpfs_size}m"},
                    cap_drop=["ALL"],
                    security_opt=["no-new-privileges:true"],
                    pids_limit=int(execution.container_pids_limit),
                    mem_limit=execution.container_memory,
                    nano_cpus=int(float(execution.container_cpus) * 1_000_000_000),
                    user=execution.container_user,
                    labels={EVAL_CONTAINER_LABEL_KEY: EVAL_CONTAINER_LABEL_VALUE},
                    volumes=volumes,
                )
                wrapper = _EvalContainer(raw, workdirs_host)
                self._containers.append(wrapper)
                self._queue.put(wrapper)

            # Health check: verify GNU timeout is available in the image.
            first = self._containers[0]
            exit_code, _ = first._container.exec_run(["timeout", "--version"])
            if exit_code != 0:
                raise RuntimeError(
                    f"Container image {execution.container_image!r} does not have GNU "
                    "'timeout' command. Install coreutils in the image."
                )
        except Exception:
            self.shutdown()
            raise
        finally:
            client.close()

    def acquire(self, timeout: float = 300) -> _EvalContainer:
        """Get a container from the pool (blocks until one is available)."""
        try:
            return self._queue.get(timeout=timeout)
        except queue.Empty:
            raise RuntimeError(
                f"Timed out waiting for a container from pool after {timeout}s"
            ) from None

    def release(self, container: _EvalContainer) -> None:
        """Return a container to the pool."""
        self._queue.put(container)

    def shutdown(self) -> None:
        """Stop and remove all containers in the pool."""
        for c in self._containers:
            c.stop()
        self._containers.clear()


@dataclass(frozen=True)
class CaseSpec:
    case_id: str
    path: Path
    category: str
    difficulty: str
    snapshot_dependency: str
    expected_exception: str
    tags: list[str]
    python_args: list[str]
    timeout_sec: int
    description: str | None = None
    # Extended fields for imported cases (all optional, backward-compatible).
    bug_source: str | None = None
    contamination_risk: str | None = None
    expected_fix_lines: int | None = None
    source_id: str | None = None
    difficulty_rationale: str | None = None


@dataclass(frozen=True)
class RunResult:
    returncode: int
    duration_sec: float
    stdout: str
    stderr: str
    timed_out: bool = False
    timeout_sec: int | None = None


@dataclass(frozen=True)
class UnitExecutionOutcome:
    case: CaseSpec
    condition: Condition
    case_records: list[dict[str, Any]] | None
    unit_duration_sec: float
    runtime_error: str | None = None


@dataclass(frozen=True)
class ExecutionConfig:
    executor: str = DEFAULT_EXECUTOR
    container_image: str = DEFAULT_CONTAINER_IMAGE
    container_network: str = DEFAULT_CONTAINER_NETWORK
    container_cpus: float = DEFAULT_CONTAINER_CPUS
    container_memory: str = DEFAULT_CONTAINER_MEMORY
    container_pids_limit: int = DEFAULT_CONTAINER_PIDS_LIMIT
    container_user: str = DEFAULT_CONTAINER_USER
    container_tmpfs_size_mb: int = DEFAULT_CONTAINER_TMPFS_SIZE_MB
    container_mount_repo_src: bool = True
    container_env: dict[str, str] = field(default_factory=dict)


_ACTIVE_EXECUTION_CONFIG = ExecutionConfig()
_ACTIVE_CONTAINER_POOL: _EvalContainerPool | None = None


def main(argv: list[str] | None = None) -> int:
    global _ACTIVE_RUN_SETTINGS, _ACTIVE_CONTAINER_POOL
    parser = argparse.ArgumentParser(description="Run llmdebug A/B eval harness.")
    parser.add_argument("--cases-dir", default="evals/cases", help="Cases directory.")
    parser.add_argument("--case", action="append", default=[], help="Case id(s) to run.")
    parser.add_argument(
        "--category", action="append", default=[], help="Category filter (repeatable)."
    )
    parser.add_argument(
        "--difficulty", action="append", default=[], help="Difficulty filter (repeatable)."
    )
    parser.add_argument(
        "--snapshot-dependency",
        action="append",
        default=[],
        help="Snapshot dependency filter (repeatable): required|helpful|none.",
    )
    parser.add_argument(
        "--bug-source", action="append", default=[], help="Bug source filter (repeatable)."
    )
    parser.add_argument(
        "--max-cases", type=int, default=0, help="Max number of cases to run (0 = all)."
    )
    parser.add_argument(
        "--jobs",
        type=int,
        default=1,
        help="Number of case/condition units to execute concurrently.",
    )
    parser.add_argument(
        "--conditions",
        default="traceback_only,with_snapshot",
        help="Comma-separated list: traceback_only,with_snapshot,with_snapshot_structured",
    )
    parser.add_argument(
        "--run-id",
        default="",
        help="Optional run identifier. If the matching results file exists, unfinished units are resumed.",
    )
    parser.add_argument("--k", type=int, default=3, help="Max patch attempts per condition.")
    parser.add_argument(
        "--patcher",
        default="command",
        choices=["command", "heuristic", "null", "openai"],
        help="Patch strategy.",
    )
    parser.add_argument(
        "--patch-cmd",
        default="",
        help="Command to run for command patcher (overrides LLMDEBUG_EVAL_PATCH_CMD).",
    )
    parser.add_argument(
        "--output-format", default="json_compact", choices=["json", "json_compact", "toon"]
    )
    parser.add_argument("--keep-workdirs", action="store_true", help="Keep copied case workdirs.")
    parser.add_argument(
        "--openai-base-url", default=os.getenv("OPENAI_BASE_URL", "http://127.0.0.1:1234")
    )
    parser.add_argument("--openai-model", default=os.getenv("OPENAI_MODEL", ""))
    parser.add_argument("--openai-api-key", default=_default_openai_api_key())
    parser.add_argument("--openai-timeout-sec", type=float, default=120.0)
    parser.add_argument("--openai-max-tokens", type=int, default=2048)
    parser.add_argument("--openai-temperature", type=float, default=0.0)
    parser.add_argument("--openai-response-mode", choices=["edits", "diff"], default="edits")
    parser.add_argument(
        "--openai-force-patch-attempt",
        action="store_true",
        help="Force a minimal patch attempt when model output cannot be parsed into a diff.",
    )
    parser.add_argument(
        "--force-patch-attempt",
        dest="force_patch_attempt",
        action="store_true",
        default=True,
        help="Force a deterministic runner-side patch attempt when patcher returns no diff (default: enabled).",
    )
    parser.add_argument(
        "--no-force-patch-attempt",
        dest="force_patch_attempt",
        action="store_false",
        help="Disable runner-side forced patch attempts when no diff is proposed.",
    )
    parser.add_argument(
        "--no-files-context", action="store_true", help="Do not include full file text in prompt."
    )
    parser.add_argument(
        "--max-context-chars",
        type=int,
        default=200_000,
        help="Max characters for project file context in prompt (default: 200000).",
    )
    parser.add_argument(
        "--context-protocol",
        choices=["monolithic", "staged"],
        default="monolithic",
        help="Context protocol for prompting: monolithic|staged (default: monolithic).",
    )
    parser.add_argument(
        "--context-stage-a-max-requests",
        type=int,
        default=6,
        help="Max Stage-A context requests when --context-protocol=staged (default: 6).",
    )
    parser.add_argument(
        "--context-stage-a-max-chars",
        type=int,
        default=120_000,
        help="Max characters returned from Stage-A context retrieval (default: 120000).",
    )
    parser.add_argument(
        "--retry-prompt-mode",
        choices=["full", "delta", "auto"],
        default="auto",
        help="Retry prompt protocol: full|delta|auto (default: auto).",
    )
    parser.add_argument(
        "--retry-context-mode",
        choices=["fresh", "history"],
        default="history",
        help="Retry context mode: fresh|history (default: history).",
    )
    parser.add_argument(
        "--retry-history-max-chars",
        type=int,
        default=48_000,
        help="Max characters of retry history transcript to include when --retry-context-mode=history (default: 48000).",
    )
    parser.add_argument(
        "--prompt-budget-total-chars",
        type=int,
        default=180_000,
        help="Stage-B prompt total character budget (0 disables total budget cap).",
    )
    parser.add_argument(
        "--prompt-budget-retry-delta-chars",
        type=int,
        default=16_000,
        help="Per-section character cap for retry delta packet in Stage-B prompts.",
    )
    parser.add_argument(
        "--prompt-budget-stdout-chars",
        type=int,
        default=32_000,
        help="Per-section character cap for pytest stdout/stderr in Stage-B prompts.",
    )
    parser.add_argument(
        "--prompt-budget-snapshot-chars",
        type=int,
        default=32_000,
        help="Per-section character cap for snapshot summary in Stage-B prompts.",
    )
    parser.add_argument(
        "--prompt-budget-evidence-chars",
        type=int,
        default=64_000,
        help="Per-section character cap for Stage-A evidence blocks in Stage-B prompts.",
    )
    parser.add_argument(
        "--prompt-budget-retry-history-chars",
        type=int,
        default=12_000,
        help="Per-section character cap for retry-history transcript in Stage-B prompts.",
    )
    parser.add_argument(
        "--deterministic",
        action="store_true",
        help="Enable deterministic mode (requires --seed; fails fast if guarantees are missing).",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=None,
        help="Deterministic seed used for patch generation and subprocess hash randomization.",
    )
    parser.add_argument(
        "--record-prompt",
        choices=["full", "hash", "none"],
        default="hash",
        help="How to persist prompt material into attempt metadata.",
    )
    parser.add_argument(
        "--record-evidence",
        choices=["full", "hash", "none"],
        default="hash",
        help="How to persist evidence material into attempt metadata.",
    )
    parser.add_argument(
        "--record-env",
        action="append",
        default=[],
        help="Environment variable key to include in run_metadata (repeatable).",
    )
    parser.add_argument(
        "--record-deps",
        choices=["none", "minimal", "runtime"],
        default="minimal",
        help="Dependency version capture mode for run_metadata.",
    )
    add_execution_arguments(parser, default_executor=DEFAULT_EXECUTOR)
    args = parser.parse_args(argv)

    if args.deterministic and args.seed is None:
        print("Invalid arguments: --seed is required when --deterministic is enabled.")
        return 2
    if args.record_prompt not in RECORD_MODES:
        print(f"Invalid --record-prompt mode: {args.record_prompt}")
        return 2
    if args.record_evidence not in RECORD_MODES:
        print(f"Invalid --record-evidence mode: {args.record_evidence}")
        return 2
    if args.context_protocol not in CONTEXT_PROTOCOLS:
        print(f"Invalid --context-protocol: {args.context_protocol}")
        return 2
    if int(args.context_stage_a_max_requests) < 1:
        print("Invalid --context-stage-a-max-requests: must be >= 1")
        return 2
    if int(args.context_stage_a_max_chars) < 1:
        print("Invalid --context-stage-a-max-chars: must be >= 1")
        return 2
    if args.retry_prompt_mode not in RETRY_PROMPT_MODES:
        print(f"Invalid --retry-prompt-mode: {args.retry_prompt_mode}")
        return 2
    if args.retry_context_mode not in RETRY_CONTEXT_MODES:
        print(f"Invalid --retry-context-mode: {args.retry_context_mode}")
        return 2
    if int(args.retry_history_max_chars) < 0:
        print("Invalid --retry-history-max-chars: must be >= 0")
        return 2
    if int(args.prompt_budget_total_chars) < 0:
        print("Invalid --prompt-budget-total-chars: must be >= 0")
        return 2
    if int(args.prompt_budget_retry_delta_chars) < 0:
        print("Invalid --prompt-budget-retry-delta-chars: must be >= 0")
        return 2
    if int(args.prompt_budget_stdout_chars) < 0:
        print("Invalid --prompt-budget-stdout-chars: must be >= 0")
        return 2
    if int(args.prompt_budget_snapshot_chars) < 0:
        print("Invalid --prompt-budget-snapshot-chars: must be >= 0")
        return 2
    if int(args.prompt_budget_evidence_chars) < 0:
        print("Invalid --prompt-budget-evidence-chars: must be >= 0")
        return 2
    if int(args.prompt_budget_retry_history_chars) < 0:
        print("Invalid --prompt-budget-retry-history-chars: must be >= 0")
        return 2
    if int(args.jobs) < 1:
        print("Invalid --jobs: must be >= 1")
        return 2
    try:
        execution_config = build_execution_config_from_args(args)
    except ValueError as exc:
        print(f"Invalid arguments: {exc}")
        return 2
    if args.deterministic and args.patcher == "openai" and float(args.openai_temperature) != 0.0:
        print("Deterministic mode enabled: forcing --openai-temperature=0.0")
        args.openai_temperature = 0.0

    cases_dir = _REPO_ROOT / args.cases_dir
    try:
        cases = load_cases(cases_dir)
    except ValueError as e:
        print(f"Invalid case metadata: {e}")
        return 2

    if args.case:
        wanted = set(args.case)
        cases = [c for c in cases if c.case_id in wanted]
    if args.category:
        wanted_categories = {x.strip() for x in args.category if x.strip()}
        cases = [c for c in cases if c.category in wanted_categories]
    if args.difficulty:
        wanted_difficulties = {x.strip() for x in args.difficulty if x.strip()}
        cases = [c for c in cases if c.difficulty in wanted_difficulties]
    if args.snapshot_dependency:
        wanted_snapshot_dependency = {x.strip() for x in args.snapshot_dependency if x.strip()}
        cases = [c for c in cases if c.snapshot_dependency in wanted_snapshot_dependency]
    if args.bug_source:
        wanted_bug_sources = {x.strip() for x in args.bug_source if x.strip()}
        cases = [c for c in cases if (c.bug_source or "hand_crafted") in wanted_bug_sources]
    if args.max_cases > 0:
        cases = cases[: args.max_cases]

    if not cases:
        print("No cases found.")
        return 2

    conditions = [Condition(c.strip()) for c in args.conditions.split(",") if c.strip()]
    run_id = str(args.run_id).strip() or (
        time.strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:8]
    )

    artifacts_root = _REPO_ROOT / "evals" / "artifacts" / run_id
    results_root = _REPO_ROOT / "evals" / "results"
    results_root.mkdir(parents=True, exist_ok=True)
    results_path = results_root / f"{run_id}.jsonl"
    execution_units = [(case, condition) for case in cases for condition in conditions]
    total_units = len(execution_units)
    run_settings_fingerprint = _build_run_settings_fingerprint(
        args=args, cases=cases, conditions=conditions
    )
    try:
        (
            existing_completed_units,
            existing_run_metadata,
            existing_run_settings_fingerprint,
            existing_records_count,
        ) = _load_existing_run_state(
            results_path=results_path,
            run_id=run_id,
            max_attempts_per_unit=int(args.k),
        )
    except ValueError as exc:
        print(f"Resume aborted: {exc}")
        return 2
    if existing_records_count > 0:
        if not existing_run_settings_fingerprint:
            print(
                "Resume aborted: existing results are missing run_settings_fingerprint; "
                "cannot safely resume."
            )
            return 2
        if existing_run_settings_fingerprint != run_settings_fingerprint:
            print("Resume aborted: run settings fingerprint mismatch for existing run_id.")
            return 2

    planned_unit_keys = {(case.case_id, condition.value) for case, condition in execution_units}
    resumed_completed_units = {key for key in existing_completed_units if key in planned_unit_keys}
    skipped_units = len(resumed_completed_units)
    remaining_units = total_units - skipped_units

    if remaining_units > 0 and execution_config.executor == "testcontainers":
        ok, message = check_testcontainers_runtime_available()
        if not ok:
            print(f"Environment check failed: {message}")
            return 2
        removed, failed, cleanup_error = cleanup_stale_eval_containers(
            grace_sec=DEFAULT_CONTAINER_CLEANUP_STALE_GRACE_SEC
        )
        if cleanup_error is not None:
            print(f"Container cleanup skipped: {cleanup_error}")
        elif removed > 0 or failed > 0:
            print(f"Container cleanup: removed={removed}, failed={failed}")

    print(f"Run: {run_id}")
    print(f"Patcher: {args.patcher}")
    print(f"Executor: {execution_config.executor}")
    print(f"Jobs: {int(args.jobs)}")
    print(f"Runner force patch attempt: {'on' if args.force_patch_attempt else 'off'}")
    print(f"Results: {results_path}")
    print(f"Progress: total={total_units}, completed={skipped_units}, remaining={remaining_units}")
    if remaining_units <= 0:
        print("Nothing to do: all planned case/condition units are already complete.")
        return 0

    jobs = int(args.jobs)
    patcher = make_patcher(args)
    patcher_name = str(getattr(patcher, "name", str(args.patcher)))
    if patcher_name != str(args.patcher):
        print(f"Patcher resolved: {patcher_name}")

    if isinstance(existing_run_metadata, dict):
        run_metadata = dict(existing_run_metadata)
        run_metadata.setdefault("run_id", run_id)
        run_metadata.setdefault("run_settings_fingerprint", run_settings_fingerprint)
    else:
        started_at_utc = (
            datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
        )
        run_metadata = _build_run_metadata(
            args=args,
            patcher_name=patcher_name,
            run_id=run_id,
            started_at_utc=started_at_utc,
            run_settings_fingerprint=run_settings_fingerprint,
        )

    previous_run_settings = _ACTIVE_RUN_SETTINGS
    previous_execution_config = set_active_execution_config(execution_config)
    _ACTIVE_RUN_SETTINGS = _ActiveRunSettings(
        deterministic=bool(args.deterministic),
        seed=args.seed if args.deterministic else None,
    )
    run_started = time.monotonic()
    completed_units = 0
    executed_units = 0
    observed_unit_durations: list[float] = []
    runtime_errors: list[tuple[str, str, str]] = []

    def _extract_progress_metrics(
        case_records: list[dict[str, Any]],
    ) -> tuple[int, int, float, float]:
        """Return (attempts_used, max_attempts, total_llm_sec, total_test_sec).

        For test time: count the initial ``before_duration_sec`` once, then
        only ``after_duration_sec`` for each attempt (the next attempt's
        "before" is the previous attempt's "after", so this avoids
        double-counting).
        """
        attempts_used = len(case_records)
        max_attempts = 0
        if case_records:
            meta = case_records[0].get("attempt_metadata")
            if isinstance(meta, dict):
                max_attempts = int(meta.get("max_attempts", 0) or 0)

        total_llm_sec = 0.0
        total_test_sec = (
            float(case_records[0].get("before_duration_sec", 0) or 0) if case_records else 0.0
        )
        for r in case_records:
            pm = r.get("patch_meta")
            if isinstance(pm, dict):
                total_llm_sec += float(pm.get("propose_duration_sec", 0) or 0)
            total_test_sec += float(r.get("after_duration_sec", 0) or 0)

        return attempts_used, max_attempts, total_llm_sec, total_test_sec

    def _handle_outcome(outcome: UnitExecutionOutcome) -> None:
        nonlocal completed_units
        nonlocal executed_units
        unit_duration = max(0.0, float(outcome.unit_duration_sec))
        observed_unit_durations.append(unit_duration)
        completed_units += 1
        executed_units += 1
        elapsed = time.monotonic() - run_started
        eta = None
        if observed_unit_durations and total_units > completed_units:
            mean_duration = sum(observed_unit_durations) / len(observed_unit_durations)
            eta = mean_duration * (total_units - completed_units)
        progress_pct = 100.0 * (completed_units / total_units) if total_units else 100.0
        case_id = outcome.case.case_id
        condition_value = outcome.condition.value
        if outcome.runtime_error is not None:
            reason = _single_line_error(outcome.runtime_error)
            runtime_errors.append((case_id, condition_value, reason))
            print(
                f"[{completed_units}/{total_units} {progress_pct:.1f}%] "
                f"ERROR {case_id}/{condition_value} reason={reason} "
                f"unit={_format_duration(unit_duration)} "
                f"elapsed={_format_duration(elapsed)} "
                f"eta={_format_duration(eta)}"
            )
            return
        case_records = outcome.case_records or []
        _append_result_records(results_path, case_records)
        unit_success = any(bool(rec.get("success")) for rec in case_records)
        status = "success" if unit_success else "failed"
        attempts_used, max_attempts, total_llm_sec, total_test_sec = _extract_progress_metrics(
            case_records
        )
        first_note = str(case_records[0].get("note") or "") if case_records else ""
        k_str = "k=0" if first_note == "no_patch_needed" else f"k={attempts_used}/{max_attempts}"
        print(
            f"[{completed_units}/{total_units} {progress_pct:.1f}%] "
            f"DONE {case_id}/{condition_value} status={status} "
            f"{k_str} llm={_format_duration(total_llm_sec)} test={_format_duration(total_test_sec)} "
            f"unit={_format_duration(unit_duration)} "
            f"elapsed={_format_duration(elapsed)} "
            f"eta={_format_duration(eta)}"
        )

    pending_units: list[tuple[CaseSpec, Condition]] = []
    for case, condition in execution_units:
        unit_key = (case.case_id, condition.value)
        if unit_key in resumed_completed_units:
            completed_units += 1
            progress_pct = 100.0 * (completed_units / total_units) if total_units else 100.0
            print(
                f"[{completed_units}/{total_units} {progress_pct:.1f}%] "
                f"SKIP (resumed) {case.case_id}/{condition.value}"
            )
            continue
        pending_units.append((case, condition))

    shared_unit_kwargs: dict[str, Any] = {
        "artifacts_root": artifacts_root,
        "patcher_args": args,
        "k": int(args.k),
        "output_format": str(args.output_format),
        "run_id": run_id,
        "include_files_context": not bool(args.no_files_context),
        "max_context_chars": int(args.max_context_chars),
        "force_patch_attempt": bool(args.force_patch_attempt),
        "context_protocol": str(args.context_protocol),
        "context_stage_a_max_requests": int(args.context_stage_a_max_requests),
        "context_stage_a_max_chars": int(args.context_stage_a_max_chars),
        "retry_prompt_mode": str(args.retry_prompt_mode),
        "retry_context_mode": str(args.retry_context_mode),
        "retry_history_max_chars": int(args.retry_history_max_chars),
        "prompt_budget_total_chars": int(args.prompt_budget_total_chars),
        "prompt_budget_retry_delta_chars": int(args.prompt_budget_retry_delta_chars),
        "prompt_budget_stdout_chars": int(args.prompt_budget_stdout_chars),
        "prompt_budget_snapshot_chars": int(args.prompt_budget_snapshot_chars),
        "prompt_budget_evidence_chars": int(args.prompt_budget_evidence_chars),
        "prompt_budget_retry_history_chars": int(args.prompt_budget_retry_history_chars),
        "run_metadata": run_metadata,
        "record_prompt": str(args.record_prompt),
        "record_evidence": str(args.record_evidence),
        "deterministic": bool(args.deterministic),
        "seed": args.seed if args.deterministic else None,
        "keep_workdirs": bool(args.keep_workdirs),
    }

    if execution_config.executor == "testcontainers" and pending_units:
        pool_size = max(1, jobs)
        print(f"Container pool: starting {pool_size} container(s)...")
        try:
            _ACTIVE_CONTAINER_POOL = _EvalContainerPool(
                size=pool_size,
                execution=execution_config,
                workdirs_host=artifacts_root / "workdirs",
            )
            print(f"Container pool: {pool_size} container(s) ready")
        except Exception as pool_exc:
            print(
                f"Container pool: failed to start ({type(pool_exc).__name__}: {pool_exc}), "
                "falling back to per-call containers"
            )

    try:
        if jobs == 1:
            for case, condition in pending_units:
                progress_pct = 100.0 * (completed_units / total_units) if total_units else 100.0
                print(
                    f"[{completed_units + 1}/{total_units} {progress_pct:.1f}%] "
                    f"START {case.case_id}/{condition.value}"
                )
                outcome = _execute_eval_unit(
                    case=case,
                    condition=condition,
                    patcher_override=patcher,
                    **shared_unit_kwargs,
                )
                _handle_outcome(outcome)
        else:
            future_map: dict[Any, tuple[CaseSpec, Condition]] = {}
            pending_iter = iter(pending_units)

            def _submit_next(executor: ThreadPoolExecutor) -> bool:
                try:
                    case, condition = next(pending_iter)
                except StopIteration:
                    return False
                progress_pct = 100.0 * (completed_units / total_units) if total_units else 100.0
                ordinal = completed_units + len(future_map) + 1
                print(
                    f"[{ordinal}/{total_units} {progress_pct:.1f}%] "
                    f"START {case.case_id}/{condition.value}"
                )
                future = executor.submit(
                    _execute_eval_unit,
                    case=case,
                    condition=condition,
                    patcher_override=None,
                    **shared_unit_kwargs,
                )
                future_map[future] = (case, condition)
                return True

            with ThreadPoolExecutor(max_workers=jobs) as executor:
                while len(future_map) < jobs and _submit_next(executor):
                    pass
                while future_map:
                    future = next(as_completed(tuple(future_map.keys())))
                    case, condition = future_map[future]
                    del future_map[future]
                    try:
                        outcome = future.result()
                    except Exception as exc:
                        outcome = UnitExecutionOutcome(
                            case=case,
                            condition=condition,
                            case_records=None,
                            unit_duration_sec=0.0,
                            runtime_error=f"{type(exc).__name__}: {exc}",
                        )
                    _handle_outcome(outcome)
                    while len(future_map) < jobs and _submit_next(executor):
                        pass
    finally:
        if _ACTIVE_CONTAINER_POOL is not None:
            _ACTIVE_CONTAINER_POOL.shutdown()
            _ACTIVE_CONTAINER_POOL = None
        _ACTIVE_RUN_SETTINGS = previous_run_settings
        set_active_execution_config(previous_execution_config)

    total_elapsed = time.monotonic() - run_started
    errors_suffix = f", errors={len(runtime_errors)}" if runtime_errors else ""
    print(
        "Progress: finished "
        f"executed={executed_units}, skipped={skipped_units}, total={total_units}, "
        f"elapsed={_format_duration(total_elapsed)}{errors_suffix}"
    )
    if runtime_errors:
        print(
            "Eval finished with "
            f"{len(runtime_errors)} runtime error(s). "
            "Rerun with the same --run-id to retry unfinished units."
        )
        return 2
    return 0


def _append_result_records(results_path: Path, case_records: list[dict[str, Any]]) -> None:
    if not case_records:
        return
    with results_path.open("a", encoding="utf-8") as handle:
        for rec in case_records:
            handle.write(json.dumps(rec, ensure_ascii=False) + "\n")


def _single_line_error(raw: str, *, max_chars: int = 240) -> str:
    text = " | ".join(segment.strip() for segment in str(raw).splitlines() if segment.strip())
    if not text:
        text = str(raw).strip() or "unknown runtime error"
    if len(text) <= max_chars:
        return text
    return text[: max_chars - 3] + "..."


def _execute_eval_unit(
    *,
    case: CaseSpec,
    condition: Condition,
    artifacts_root: Path,
    patcher_args: argparse.Namespace,
    patcher_override: Any | None,
    k: int,
    output_format: str,
    run_id: str,
    include_files_context: bool,
    max_context_chars: int,
    force_patch_attempt: bool,
    context_protocol: str,
    context_stage_a_max_requests: int,
    context_stage_a_max_chars: int,
    retry_prompt_mode: str,
    retry_context_mode: str,
    retry_history_max_chars: int,
    prompt_budget_total_chars: int,
    prompt_budget_retry_delta_chars: int,
    prompt_budget_stdout_chars: int,
    prompt_budget_snapshot_chars: int,
    prompt_budget_evidence_chars: int,
    prompt_budget_retry_history_chars: int,
    run_metadata: dict[str, Any],
    record_prompt: str,
    record_evidence: str,
    deterministic: bool,
    seed: int | None,
    keep_workdirs: bool,
) -> UnitExecutionOutcome:
    unit_started = time.monotonic()
    workdir = artifacts_root / "workdirs" / case.case_id / condition.value
    context_dir_base = artifacts_root / "contexts" / case.case_id / condition.value

    runtime_error: str | None = None
    case_records: list[dict[str, Any]] | None = None
    try:
        workdir.parent.mkdir(parents=True, exist_ok=True)
        context_dir_base.mkdir(parents=True, exist_ok=True)

        if workdir.exists():
            shutil.rmtree(workdir)
        shutil.copytree(
            case.path,
            workdir,
            dirs_exist_ok=True,
            ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
        )

        patcher = patcher_override if patcher_override is not None else make_patcher(patcher_args)
        case_records = run_one_condition(
            case=case,
            condition=condition,
            workdir=workdir,
            context_dir_base=context_dir_base,
            patcher=patcher,
            k=k,
            output_format=output_format,
            run_id=run_id,
            include_files_context=include_files_context,
            max_context_chars=max_context_chars,
            force_patch_attempt=force_patch_attempt,
            context_protocol=context_protocol,
            context_stage_a_max_requests=context_stage_a_max_requests,
            context_stage_a_max_chars=context_stage_a_max_chars,
            retry_prompt_mode=retry_prompt_mode,
            retry_context_mode=retry_context_mode,
            retry_history_max_chars=retry_history_max_chars,
            prompt_budget_total_chars=prompt_budget_total_chars,
            prompt_budget_retry_delta_chars=prompt_budget_retry_delta_chars,
            prompt_budget_stdout_chars=prompt_budget_stdout_chars,
            prompt_budget_snapshot_chars=prompt_budget_snapshot_chars,
            prompt_budget_evidence_chars=prompt_budget_evidence_chars,
            prompt_budget_retry_history_chars=prompt_budget_retry_history_chars,
            run_metadata=run_metadata,
            record_prompt=record_prompt,
            record_evidence=record_evidence,
            deterministic=deterministic,
            seed=seed,
        )
    except RuntimeError as exc:
        runtime_error = str(exc)
    except Exception as exc:
        runtime_error = f"{type(exc).__name__}: {exc}"
    finally:
        if not keep_workdirs:
            shutil.rmtree(workdir, ignore_errors=True)

    return UnitExecutionOutcome(
        case=case,
        condition=condition,
        case_records=case_records,
        unit_duration_sec=time.monotonic() - unit_started,
        runtime_error=runtime_error,
    )


def _format_duration(seconds: float | None) -> str:
    if seconds is None:
        return "unknown"
    whole_seconds = max(0, round(seconds))
    hours, rem = divmod(whole_seconds, 3600)
    minutes, secs = divmod(rem, 60)
    if hours:
        return f"{hours}h{minutes:02d}m{secs:02d}s"
    if minutes:
        return f"{minutes}m{secs:02d}s"
    return f"{secs}s"


def _build_run_settings_fingerprint(
    *,
    args: argparse.Namespace,
    cases: list[CaseSpec],
    conditions: list[Condition],
) -> str:
    payload = {
        "cases_dir": str(args.cases_dir),
        "selected_case_ids": [case.case_id for case in cases],
        "conditions": [condition.value for condition in conditions],
        "patcher_config": _build_patcher_config(args=args, patcher_name=str(args.patcher)),
        "record_prompt": str(args.record_prompt),
        "record_evidence": str(args.record_evidence),
        "record_env": sorted(key.strip() for key in args.record_env if str(key).strip()),
        "record_deps": str(args.record_deps),
        "retry_context_mode": str(args.retry_context_mode),
        "retry_history_max_chars": int(args.retry_history_max_chars),
        "prompt_budget_retry_history_chars": int(args.prompt_budget_retry_history_chars),
    }
    return _sha256_jsonlike(payload)


def _load_existing_run_state(
    *,
    results_path: Path,
    run_id: str,
    max_attempts_per_unit: int,
) -> tuple[set[tuple[str, str]], dict[str, Any] | None, str | None, int]:
    if not results_path.exists():
        return set(), None, None, 0

    per_unit_stats: dict[tuple[str, str], dict[str, Any]] = {}
    first_run_metadata: dict[str, Any] | None = None
    settings_fingerprint: str | None = None
    records_count = 0
    with results_path.open(encoding="utf-8") as handle:
        for line_no, line in enumerate(handle, start=1):
            if not line.strip():
                continue
            try:
                record = json.loads(line)
            except json.JSONDecodeError as exc:
                raise ValueError(
                    f"{results_path}:{line_no}: invalid JSON ({type(exc).__name__}: {exc})"
                ) from exc
            if not isinstance(record, dict):
                raise ValueError(f"{results_path}:{line_no}: record must be a JSON object")

            record_run_id = record.get("run_id")
            if not isinstance(record_run_id, str) or not record_run_id:
                raise ValueError(f"{results_path}:{line_no}: run_id must be a non-empty string")
            if record_run_id != run_id:
                raise ValueError(
                    f"{results_path}:{line_no}: run_id mismatch ({record_run_id!r} != {run_id!r})"
                )

            case_id = record.get("case_id")
            if not isinstance(case_id, str) or not case_id:
                raise ValueError(f"{results_path}:{line_no}: case_id must be a non-empty string")
            condition = record.get("condition")
            if not isinstance(condition, str) or not condition:
                raise ValueError(f"{results_path}:{line_no}: condition must be a non-empty string")
            attempt = record.get("attempt")
            if not isinstance(attempt, int) or attempt < 0:
                raise ValueError(
                    f"{results_path}:{line_no}: attempt must be a non-negative integer"
                )
            success = record.get("success")
            if not isinstance(success, bool):
                raise ValueError(f"{results_path}:{line_no}: success must be a boolean")

            run_metadata = record.get("run_metadata")
            if not isinstance(run_metadata, dict):
                raise ValueError(f"{results_path}:{line_no}: run_metadata must be an object")
            if run_metadata.get("run_id") != run_id:
                raise ValueError(f"{results_path}:{line_no}: run_metadata.run_id must match run_id")

            current_fingerprint = run_metadata.get("run_settings_fingerprint")
            if current_fingerprint is not None and (
                not isinstance(current_fingerprint, str) or not current_fingerprint
            ):
                raise ValueError(
                    f"{results_path}:{line_no}: run_settings_fingerprint must be a non-empty string"
                )
            if isinstance(current_fingerprint, str):
                if settings_fingerprint is None:
                    settings_fingerprint = current_fingerprint
                elif settings_fingerprint != current_fingerprint:
                    raise ValueError(
                        f"{results_path}:{line_no}: inconsistent run_settings_fingerprint values"
                    )

            if first_run_metadata is None:
                first_run_metadata = dict(run_metadata)

            unit_key = (case_id, condition)
            unit_stats = per_unit_stats.setdefault(
                unit_key,
                {"max_attempt": -1, "any_success": False},
            )
            unit_stats["max_attempt"] = max(int(unit_stats["max_attempt"]), attempt)
            if success:
                unit_stats["any_success"] = True
            records_count += 1

    required_attempts = max(1, int(max_attempts_per_unit))
    completed_units = {
        unit_key
        for unit_key, stats in per_unit_stats.items()
        if bool(stats.get("any_success")) or int(stats.get("max_attempt", -1)) >= required_attempts
    }
    return completed_units, first_run_metadata, settings_fingerprint, records_count


def load_cases(cases_dir: Path) -> list[CaseSpec]:
    if not cases_dir.exists():
        return []

    out: list[CaseSpec] = []
    for child in sorted(cases_dir.iterdir()):
        if not child.is_dir():
            continue
        case_id = child.name
        meta_path = child / "case.json"
        meta: dict[str, Any] = {}
        if meta_path.exists():
            try:
                meta = json.loads(meta_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError as e:
                raise ValueError(f"{case_id}: invalid JSON in case.json: {e}") from e
            except OSError as e:
                raise ValueError(f"{case_id}: cannot read case.json: {e}") from e

        category = meta.get("category")
        if not isinstance(category, str) or category not in VALID_CATEGORIES:
            raise ValueError(f"{case_id}: category must be one of {sorted(VALID_CATEGORIES)}")

        difficulty = meta.get("difficulty")
        if not isinstance(difficulty, str) or difficulty not in VALID_DIFFICULTIES:
            raise ValueError(f"{case_id}: difficulty must be one of {sorted(VALID_DIFFICULTIES)}")

        snapshot_dependency = meta.get("snapshot_dependency")
        if (
            not isinstance(snapshot_dependency, str)
            or snapshot_dependency not in VALID_SNAPSHOT_DEPENDENCIES
        ):
            raise ValueError(
                f"{case_id}: snapshot_dependency must be one of {sorted(VALID_SNAPSHOT_DEPENDENCIES)}"
            )

        expected_exception = meta.get("expected_exception")
        if not isinstance(expected_exception, str) or not expected_exception:
            raise ValueError(f"{case_id}: expected_exception must be a non-empty string")

        tags_raw = meta.get("tags")
        if tags_raw is None:
            tags = []
        elif isinstance(tags_raw, list) and all(isinstance(x, str) and x for x in tags_raw):
            tags = list(tags_raw)
        else:
            raise ValueError(f"{case_id}: tags must be an array of non-empty strings")

        python_args_raw = meta.get("python_args")
        if (
            not isinstance(python_args_raw, list)
            or not python_args_raw
            or not all(isinstance(x, str) and x for x in python_args_raw)
        ):
            python_args = ["-m", "pytest", "-q"]
        else:
            python_args = list(python_args_raw)

        timeout_sec_raw = meta.get("timeout_sec")
        if not isinstance(timeout_sec_raw, int) or timeout_sec_raw <= 0:
            timeout_sec = 120
        else:
            timeout_sec = timeout_sec_raw

        description = meta.get("description") if isinstance(meta.get("description"), str) else None

        # Extended optional fields for imported cases.
        bug_source = meta.get("bug_source") if isinstance(meta.get("bug_source"), str) else None
        contamination_risk = (
            meta.get("contamination_risk")
            if isinstance(meta.get("contamination_risk"), str)
            else None
        )
        expected_fix_lines_raw = meta.get("expected_fix_lines")
        expected_fix_lines = (
            expected_fix_lines_raw
            if isinstance(expected_fix_lines_raw, int) and expected_fix_lines_raw > 0
            else None
        )
        source_id = meta.get("source_id") if isinstance(meta.get("source_id"), str) else None
        difficulty_rationale = (
            meta.get("difficulty_rationale")
            if isinstance(meta.get("difficulty_rationale"), str)
            else None
        )

        out.append(
            CaseSpec(
                case_id=case_id,
                path=child,
                category=category,
                difficulty=difficulty,
                snapshot_dependency=snapshot_dependency,
                expected_exception=expected_exception,
                tags=tags,
                python_args=python_args,
                timeout_sec=timeout_sec,
                description=description,
                bug_source=bug_source,
                contamination_risk=contamination_risk,
                expected_fix_lines=expected_fix_lines,
                source_id=source_id,
                difficulty_rationale=difficulty_rationale,
            )
        )
    return out


def make_patcher(args: argparse.Namespace):
    from evals.patchers import CommandPatcher, HeuristicSnapshotPatcher, NullPatcher
    from evals.patchers.command_patcher import patcher_from_env

    if args.patcher == "openai":
        from evals.patchers import OpenAICompatPatcher

        model = (args.openai_model or "").strip()
        if not model:
            raise SystemExit(
                "--openai-model is required for --patcher openai (or set OPENAI_MODEL)."
            )
        api_key = (args.openai_api_key or "").strip() or None
        return OpenAICompatPatcher(
            base_url=str(args.openai_base_url),
            model=model,
            api_key=api_key,
            timeout_sec=float(args.openai_timeout_sec),
            max_tokens=int(args.openai_max_tokens),
            temperature=float(args.openai_temperature),
            response_mode=str(args.openai_response_mode),
            force_patch_attempt=bool(args.openai_force_patch_attempt),
            seed=args.seed if bool(args.deterministic) else None,
        )

    if args.patcher == "heuristic":
        return HeuristicSnapshotPatcher()
    if args.patcher == "null":
        return NullPatcher()

    if args.patch_cmd:
        return CommandPatcher(cmd=args.patch_cmd)
    env_patcher = patcher_from_env()
    if env_patcher is None:
        raise SystemExit(
            "Command patcher selected but no command configured. "
            "Set --patch-cmd or LLMDEBUG_EVAL_PATCH_CMD."
        )
    return env_patcher


def _build_run_metadata(
    *,
    args: argparse.Namespace,
    patcher_name: str,
    run_id: str,
    started_at_utc: str,
    run_settings_fingerprint: str,
) -> dict[str, Any]:
    patcher_config = _build_patcher_config(args=args, patcher_name=patcher_name)
    metadata: dict[str, Any] = {
        "run_id": run_id,
        "started_at_utc": started_at_utc,
        "deterministic": bool(args.deterministic),
        "seed": args.seed if bool(args.deterministic) else None,
        "python_version": platform.python_version(),
        "platform": platform.platform(),
        "llmdebug_version": _resolve_llmdebug_version(),
        "patcher_name": patcher_name,
        "patcher_config_fingerprint": _sha256_jsonlike(patcher_config),
        "run_settings_fingerprint": run_settings_fingerprint,
        "record_policy": {
            "prompt": str(args.record_prompt),
            "evidence": str(args.record_evidence),
        },
    }
    env_capture = _collect_allowed_env(args.record_env)
    if env_capture:
        metadata["env"] = env_capture
    dependency_versions = _collect_dependency_versions(mode=str(args.record_deps))
    if dependency_versions:
        metadata["dependency_versions"] = dependency_versions
    return metadata


def _build_patcher_config(*, args: argparse.Namespace, patcher_name: str) -> dict[str, Any]:
    config: dict[str, Any] = {
        "patcher": str(args.patcher),
        "patcher_name": patcher_name,
        "k": int(args.k),
        "output_format": str(args.output_format),
        "force_patch_attempt": bool(args.force_patch_attempt),
        "deterministic": bool(args.deterministic),
        "seed": args.seed if bool(args.deterministic) else None,
        "no_files_context": bool(args.no_files_context),
        "max_context_chars": int(args.max_context_chars),
        "context_protocol": str(args.context_protocol),
        "context_stage_a_max_requests": int(args.context_stage_a_max_requests),
        "context_stage_a_max_chars": int(args.context_stage_a_max_chars),
        "retry_prompt_mode": str(args.retry_prompt_mode),
        "retry_context_mode": str(args.retry_context_mode),
        "retry_history_max_chars": int(args.retry_history_max_chars),
        "prompt_budget_total_chars": int(args.prompt_budget_total_chars),
        "prompt_budget_retry_delta_chars": int(args.prompt_budget_retry_delta_chars),
        "prompt_budget_stdout_chars": int(args.prompt_budget_stdout_chars),
        "prompt_budget_snapshot_chars": int(args.prompt_budget_snapshot_chars),
        "prompt_budget_evidence_chars": int(args.prompt_budget_evidence_chars),
        "prompt_budget_retry_history_chars": int(args.prompt_budget_retry_history_chars),
    }
    try:
        config["execution"] = _execution_config_payload(build_execution_config_from_args(args))
    except ValueError:
        pass
    if str(args.patcher) == "openai":
        config.update(
            {
                "openai_base_url": str(args.openai_base_url),
                "openai_model": str(args.openai_model),
                "openai_timeout_sec": float(args.openai_timeout_sec),
                "openai_max_tokens": int(args.openai_max_tokens),
                "openai_temperature": float(args.openai_temperature),
                "openai_response_mode": str(args.openai_response_mode),
                "openai_force_patch_attempt": bool(args.openai_force_patch_attempt),
            }
        )
    elif str(args.patcher) == "command":
        config["patch_cmd"] = str(args.patch_cmd or os.getenv("LLMDEBUG_EVAL_PATCH_CMD", ""))
    return config


def _resolve_llmdebug_version() -> str:
    try:
        import llmdebug  # type: ignore

        value = getattr(llmdebug, "__version__", None)
        if isinstance(value, str) and value:
            return value
    except Exception:
        pass
    return "unknown"


def _collect_allowed_env(keys: list[str]) -> dict[str, str]:
    out: dict[str, str] = {}
    for raw in keys:
        key = raw.strip()
        if not key:
            continue
        value = os.environ.get(key)
        if value is not None:
            out[key] = value
    return out


def _collect_dependency_versions(*, mode: str) -> dict[str, str]:
    if mode == "none":
        return {}

    try:
        from importlib import metadata as importlib_metadata
    except ImportError:
        return {}

    if mode == "runtime":
        out: dict[str, str] = {}
        for dist in importlib_metadata.distributions():
            try:
                name = dist.metadata.get("Name")
                version = dist.version
            except Exception:
                continue
            if not name or not version:
                continue
            out[str(name)] = str(version)
        return dict(sorted(out.items()))

    names = (
        "llmdebug",
        "pytest",
        "mcp",
        "openai",
        "numpy",
        "torch",
        "jax",
        "tensorflow",
    )
    out: dict[str, str] = {}
    for name in names:
        try:
            out[name] = importlib_metadata.version(name)
        except importlib_metadata.PackageNotFoundError:
            continue
        except Exception:
            continue
    return out


def _sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def _sha256_jsonlike(data: Any) -> str:
    payload = json.dumps(
        data, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
    )
    return _sha256_text(payload)


def _normalize_openai_base_url(base_url: str) -> str:
    base = base_url.rstrip("/")
    if base.endswith(("/v1", "/v2", "/v3", "/v4")):
        return base
    return base + "/v1"


def _default_run_metadata(*, run_id: str, patcher_name: str) -> dict[str, Any]:
    return {
        "run_id": run_id,
        "started_at_utc": datetime.now(timezone.utc)
        .replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z"),
        "deterministic": False,
        "seed": None,
        "python_version": platform.python_version(),
        "platform": platform.platform(),
        "llmdebug_version": _resolve_llmdebug_version(),
        "patcher_name": patcher_name,
        "patcher_config_fingerprint": _sha256_text("default"),
        "run_settings_fingerprint": _sha256_text("default-run-settings"),
        "record_policy": {"prompt": "hash", "evidence": "hash"},
    }


def _build_base_record(
    *,
    case: CaseSpec,
    condition: Condition,
    run_id: str,
    attempt: int,
    patcher_name: str,
    run_metadata: dict[str, Any],
    attempt_metadata: dict[str, Any],
    model_metadata: dict[str, Any],
    context_protocol: str = "monolithic",
) -> dict[str, Any]:
    return {
        "schema_version": EVAL_RECORD_SCHEMA_VERSION,
        "run_id": run_id,
        "case_id": case.case_id,
        "category": case.category,
        "difficulty": case.difficulty,
        "snapshot_dependency": case.snapshot_dependency,
        "expected_exception": case.expected_exception,
        "tags": case.tags,
        "bug_source": case.bug_source,
        "contamination_risk": case.contamination_risk,
        "condition": condition.value,
        "attempt": attempt,
        "patcher": patcher_name,
        "context_protocol": context_protocol,
        "run_metadata": dict(run_metadata),
        "attempt_metadata": dict(attempt_metadata),
        "model_metadata": dict(model_metadata),
    }


def _empty_attempt_metadata(*, attempt: int) -> dict[str, Any]:
    return {
        "attempt": attempt,
        "max_attempts": 0,
        "attempt_ordinal_label": "0/0",
        "prompt_sha256": None,
        "prompt_path": None,
        "evidence_sha256": None,
        "evidence_path": None,
        "stage_a_prompt_sha256": None,
        "stage_a_response_sha256": None,
        "stage_a_requests_count": 0,
        "stage_a_chars_returned": 0,
        "stage_a_request_success": None,
        "stage_b_prompt_chars": None,
        "llm_call_count": 0,
        "tokens_prompt": 0,
        "tokens_completion": 0,
        "tokens_total": 0,
        "context_protocol": "monolithic",
        "retry_prompt_mode": "full",
        "retry_context_mode": "history",
        "retry_history_chars": 0,
        "retry_history_truncated": False,
        "retry_history_turns_included": 0,
        "retry_history_budget_chars": 0,
        "retry_history_budget_applied": False,
        "retry_delta_prompt": False,
        "retry_delta_chars": 0,
        "evidence_ids_new_count": 0,
        "evidence_ids_reused_count": 0,
        "evidence_ids_sent_count": 0,
        "prompt_budget_used_chars": None,
        "prompt_budget_overflow_chars": 0,
        "prompt_budget_dropped_sections": [],
    }


def _materialize_attempt_metadata(
    *,
    attempt_dir: Path,
    attempt: int,
    max_attempts: int,
    evidence: dict[str, Any],
    prompt: str,
    record_prompt: str,
    record_evidence: str,
    context_protocol: str,
    stage_a_prompt: str | None = None,
    stage_a_raw_response: str | None = None,
    stage_a_request_payload: dict[str, Any] | None = None,
    stage_a_requests_count: int = 0,
    stage_a_chars_returned: int = 0,
    stage_a_request_success: bool | None = None,
    retry_prompt_mode: str = "full",
    retry_context_mode: str = "history",
    retry_history_chars: int = 0,
    retry_history_truncated: bool = False,
    retry_history_turns_included: int = 0,
    retry_history_budget_chars: int = 0,
    retry_history_budget_applied: bool = False,
    retry_delta_prompt: bool = False,
    retry_delta_chars: int = 0,
    llm_call_count: int = 0,
    tokens_prompt: int = 0,
    tokens_completion: int = 0,
    tokens_total: int = 0,
    evidence_ids_new_count: int = 0,
    evidence_ids_reused_count: int = 0,
    evidence_ids_sent_count: int = 0,
    prompt_budget_used_chars: int | None = None,
    prompt_budget_overflow_chars: int = 0,
    prompt_budget_dropped_sections: list[str] | None = None,
) -> dict[str, Any]:
    metadata = _empty_attempt_metadata(attempt=attempt)
    metadata["max_attempts"] = max(0, int(max_attempts))
    metadata["attempt_ordinal_label"] = f"{attempt}/{max(0, int(max_attempts))}"
    metadata["context_protocol"] = context_protocol
    metadata["stage_a_requests_count"] = int(stage_a_requests_count)
    metadata["stage_a_chars_returned"] = int(stage_a_chars_returned)
    metadata["stage_a_request_success"] = stage_a_request_success
    metadata["stage_b_prompt_chars"] = len(prompt)
    metadata["llm_call_count"] = max(0, int(llm_call_count))
    metadata["tokens_prompt"] = max(0, int(tokens_prompt))
    metadata["tokens_completion"] = max(0, int(tokens_completion))
    metadata["tokens_total"] = max(0, int(tokens_total))
    metadata["retry_prompt_mode"] = retry_prompt_mode
    metadata["retry_context_mode"] = retry_context_mode
    metadata["retry_history_chars"] = int(retry_history_chars)
    metadata["retry_history_truncated"] = bool(retry_history_truncated)
    metadata["retry_history_turns_included"] = int(retry_history_turns_included)
    metadata["retry_history_budget_chars"] = int(retry_history_budget_chars)
    metadata["retry_history_budget_applied"] = bool(retry_history_budget_applied)
    metadata["retry_delta_prompt"] = bool(retry_delta_prompt)
    metadata["retry_delta_chars"] = int(retry_delta_chars)
    metadata["evidence_ids_new_count"] = int(evidence_ids_new_count)
    metadata["evidence_ids_reused_count"] = int(evidence_ids_reused_count)
    metadata["evidence_ids_sent_count"] = int(evidence_ids_sent_count)
    metadata["prompt_budget_used_chars"] = prompt_budget_used_chars
    metadata["prompt_budget_overflow_chars"] = int(prompt_budget_overflow_chars)
    metadata["prompt_budget_dropped_sections"] = list(prompt_budget_dropped_sections or [])

    if record_evidence != "none":
        metadata["evidence_sha256"] = _sha256_jsonlike(evidence)
    if record_evidence == "full":
        evidence_path = attempt_dir / "evidence.json"
        evidence_path.write_text(
            json.dumps(evidence, indent=2, ensure_ascii=False), encoding="utf-8"
        )
        metadata["evidence_path"] = str(evidence_path)

    if record_prompt != "none":
        metadata["prompt_sha256"] = _sha256_text(prompt)
    if record_prompt == "full":
        prompt_path = attempt_dir / "prompt.txt"
        prompt_path.write_text(prompt, encoding="utf-8")
        metadata["prompt_path"] = str(prompt_path)

    if stage_a_prompt:
        metadata["stage_a_prompt_sha256"] = _sha256_text(stage_a_prompt)
    stage_a_response_text = ""
    if stage_a_raw_response:
        stage_a_response_text = stage_a_raw_response
    elif stage_a_request_payload is not None:
        stage_a_response_text = json.dumps(stage_a_request_payload, ensure_ascii=False, default=str)
    if stage_a_response_text:
        metadata["stage_a_response_sha256"] = _sha256_text(stage_a_response_text)

    return metadata


def _nonnegative_int(value: Any, *, default: int = 0) -> int:
    try:
        coerced = int(value)
    except (TypeError, ValueError):
        return int(default)
    return max(0, coerced)


def _usage_token_counts_from_usage_payload(usage: Any) -> tuple[int, int, int]:
    if not isinstance(usage, dict):
        return 0, 0, 0

    prompt_tokens = _nonnegative_int(usage.get("prompt_tokens"), default=0)
    completion_tokens = _nonnegative_int(usage.get("completion_tokens"), default=0)
    # Anthropic-compatible payloads often use input/output token fields.
    if prompt_tokens == 0 and completion_tokens == 0:
        prompt_tokens = _nonnegative_int(usage.get("input_tokens"), default=0)
        completion_tokens = _nonnegative_int(usage.get("output_tokens"), default=0)

    total_tokens_raw = usage.get("total_tokens")
    if total_tokens_raw is None:
        total_tokens = prompt_tokens + completion_tokens
    else:
        total_tokens = _nonnegative_int(total_tokens_raw, default=prompt_tokens + completion_tokens)
    return prompt_tokens, completion_tokens, total_tokens


def _usage_token_counts_from_patch_meta(patch_meta: dict[str, Any] | None) -> tuple[int, int, int]:
    if not isinstance(patch_meta, dict):
        return 0, 0, 0

    usage_prompt, usage_completion, usage_total = _usage_token_counts_from_usage_payload(
        patch_meta.get("usage")
    )
    prompt_tokens = _nonnegative_int(patch_meta.get("tokens_prompt"), default=usage_prompt)
    completion_tokens = _nonnegative_int(
        patch_meta.get("tokens_completion"),
        default=usage_completion,
    )
    total_tokens = _nonnegative_int(
        patch_meta.get("tokens_total"),
        default=usage_total if usage_total > 0 else (prompt_tokens + completion_tokens),
    )
    return prompt_tokens, completion_tokens, total_tokens


def _llm_call_count_from_meta(meta: dict[str, Any] | None, *, default: int = 0) -> int:
    if not isinstance(meta, dict):
        return max(0, int(default))
    value = meta.get("llm_call_count")
    if isinstance(value, int) and value >= 0:
        return value
    return max(0, int(default))


def _aggregate_attempt_llm_usage(
    *,
    stage_a_meta: dict[str, Any] | None,
    patch_meta: dict[str, Any] | None,
    stage_a_default_llm_calls: int,
    patch_default_llm_calls: int,
) -> tuple[int, int, int, int]:
    stage_calls = _llm_call_count_from_meta(stage_a_meta, default=stage_a_default_llm_calls)
    patch_calls = _llm_call_count_from_meta(patch_meta, default=patch_default_llm_calls)
    stage_prompt, stage_completion, stage_total = _usage_token_counts_from_patch_meta(stage_a_meta)
    patch_prompt, patch_completion, patch_total = _usage_token_counts_from_patch_meta(patch_meta)
    return (
        stage_calls + patch_calls,
        stage_prompt + patch_prompt,
        stage_completion + patch_completion,
        stage_total + patch_total,
    )


def _apply_usage_totals_to_patch_meta(
    *,
    patch_meta: dict[str, Any],
    llm_call_count: int,
    tokens_prompt: int,
    tokens_completion: int,
    tokens_total: int,
) -> None:
    normalized_calls = _nonnegative_int(llm_call_count, default=0)
    normalized_prompt = _nonnegative_int(tokens_prompt, default=0)
    normalized_completion = _nonnegative_int(tokens_completion, default=0)
    normalized_total = _nonnegative_int(
        tokens_total,
        default=normalized_prompt + normalized_completion,
    )
    patch_meta["llm_call_count"] = normalized_calls
    patch_meta["tokens_prompt"] = normalized_prompt
    patch_meta["tokens_completion"] = normalized_completion
    patch_meta["tokens_total"] = normalized_total
    patch_meta["usage"] = {
        "prompt_tokens": normalized_prompt,
        "completion_tokens": normalized_completion,
        "total_tokens": normalized_total,
    }


def _sync_attempt_usage_metadata(
    *,
    attempt_metadata: dict[str, Any],
    patch_meta: dict[str, Any],
    llm_call_count: int,
) -> None:
    prompt_tokens, completion_tokens, total_tokens = _usage_token_counts_from_patch_meta(patch_meta)
    normalized_calls = _nonnegative_int(llm_call_count, default=0)
    attempt_metadata["llm_call_count"] = normalized_calls
    attempt_metadata["tokens_prompt"] = prompt_tokens
    attempt_metadata["tokens_completion"] = completion_tokens
    attempt_metadata["tokens_total"] = total_tokens
    patch_meta["llm_call_count"] = normalized_calls
    patch_meta["tokens_prompt"] = prompt_tokens
    patch_meta["tokens_completion"] = completion_tokens
    patch_meta["tokens_total"] = total_tokens


def _append_retry_history_turn_if_enabled(
    *,
    retry_context_mode: str,
    retry_history_turns: list[str],
    attempt: int,
    note: str,
    failure_signature: str,
    patch_meta: dict[str, Any],
) -> None:
    if retry_context_mode != "history":
        return
    retry_history_turns.append(
        _render_retry_history_turn(
            attempt=attempt,
            note=note,
            failure_signature=failure_signature,
            patch_meta=patch_meta,
        )
    )


def _build_attempt_outcome_record(
    *,
    case: CaseSpec,
    condition: Condition,
    run_id: str,
    attempt: int,
    patcher_name: str,
    run_metadata: dict[str, Any],
    attempt_metadata: dict[str, Any],
    patcher: Any,
    patch_meta: dict[str, Any] | None,
    context_protocol: str,
    success: bool,
    note: str,
    before: RunResult,
    cumulative_runtime_sec: float,
    after: RunResult | None = None,
) -> dict[str, Any]:
    record = _build_base_record(
        case=case,
        condition=condition,
        run_id=run_id,
        attempt=attempt,
        patcher_name=patcher_name,
        run_metadata=run_metadata,
        attempt_metadata=attempt_metadata,
        model_metadata=_build_model_metadata(patcher=patcher, patch_meta=patch_meta),
        context_protocol=context_protocol,
    )
    payload: dict[str, Any] = {
        "success": success,
        "before_returncode": before.returncode,
        "before_duration_sec": before.duration_sec,
        "cumulative_runtime_sec": cumulative_runtime_sec,
        "note": note,
    }
    if after is not None:
        payload["after_returncode"] = after.returncode
        payload["after_duration_sec"] = after.duration_sec
    if isinstance(patch_meta, dict):
        payload["patch_meta"] = patch_meta
    record.update(payload)
    record.update(_timeout_fields(after if after is not None else before))
    return record


def _build_model_metadata(*, patcher: Any, patch_meta: dict[str, Any] | None) -> dict[str, Any]:
    patcher_name = str(getattr(patcher, "name", "unknown"))
    metadata: dict[str, Any] = {"backend": patcher_name, "name": patcher_name}
    if patcher_name != "openai":
        return metadata

    patch_meta_dict = patch_meta if isinstance(patch_meta, dict) else {}
    requested_seed = patch_meta_dict.get("api_compat_seed_requested")
    if requested_seed is None:
        requested_seed = getattr(patcher, "seed", None)
    acknowledged = patch_meta_dict.get("api_compat_seed_acknowledged")
    metadata.update(
        {
            "backend": "openai",
            "base_url": str(
                patch_meta_dict.get("base_url")
                or _normalize_openai_base_url(str(getattr(patcher, "base_url", "")))
            ),
            "model": str(patch_meta_dict.get("model") or getattr(patcher, "model", "")),
            "temperature": patch_meta_dict.get(
                "temperature", getattr(patcher, "temperature", None)
            ),
            "max_tokens": patch_meta_dict.get("max_tokens", getattr(patcher, "max_tokens", None)),
            "response_mode": str(
                patch_meta_dict.get("response_mode") or getattr(patcher, "response_mode", "")
            ),
            "api_compat_seed_requested": requested_seed,
            "api_compat_seed_acknowledged": bool(acknowledged)
            if acknowledged is not None
            else False,
            "api_compat_seed_response": patch_meta_dict.get("api_compat_seed_response"),
        }
    )
    return metadata


def _ensure_deterministic_seed_ack(
    *,
    deterministic: bool,
    seed: int | None,
    patcher_name: str,
    patch_meta: dict[str, Any],
    case_id: str,
    condition: str,
    attempt: int,
) -> None:
    if not deterministic or patcher_name != "openai":
        return

    requested = patch_meta.get("api_compat_seed_requested")
    acknowledged = patch_meta.get("api_compat_seed_acknowledged")
    if requested != seed:
        raise RuntimeError(
            "deterministic seed mismatch for "
            f"{case_id}/{condition}/attempt={attempt}: requested={requested!r}, expected={seed!r}"
        )
    if acknowledged is not True:
        error = patch_meta.get("error")
        body = patch_meta.get("body")
        exc = patch_meta.get("exc")
        details = error or body or exc or "backend did not acknowledge seed support"
        raise RuntimeError(
            f"deterministic mode failed for {case_id}/{condition}/attempt={attempt}: {details}"
        )


def _is_snapshot_condition(condition: Condition) -> bool:
    return condition in {
        Condition.WITH_SNAPSHOT,
        Condition.WITH_SNAPSHOT_STRUCTURED,
    }


def run_one_condition(
    *,
    case: CaseSpec,
    condition: Condition,
    workdir: Path,
    context_dir_base: Path,
    patcher,
    k: int,
    output_format: str,
    run_id: str,
    include_files_context: bool,
    max_context_chars: int = 200_000,
    force_patch_attempt: bool,
    context_protocol: str = "monolithic",
    context_stage_a_max_requests: int = 6,
    context_stage_a_max_chars: int = 120_000,
    retry_prompt_mode: str = "auto",
    retry_context_mode: str = "history",
    retry_history_max_chars: int = 48_000,
    prompt_budget_total_chars: int = 180_000,
    prompt_budget_retry_delta_chars: int = 16_000,
    prompt_budget_stdout_chars: int = 32_000,
    prompt_budget_snapshot_chars: int = 32_000,
    prompt_budget_evidence_chars: int = 64_000,
    prompt_budget_retry_history_chars: int = 12_000,
    run_metadata: dict[str, Any] | None = None,
    record_prompt: str = "hash",
    record_evidence: str = "hash",
    deterministic: bool = False,
    seed: int | None = None,
) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    normalized_context_protocol = (
        context_protocol if context_protocol in CONTEXT_PROTOCOLS else "monolithic"
    )
    is_structured_condition = condition == Condition.WITH_SNAPSHOT_STRUCTURED
    effective_context_protocol = (
        "staged" if is_structured_condition else normalized_context_protocol
    )
    normalized_retry_prompt_mode = (
        retry_prompt_mode if retry_prompt_mode in RETRY_PROMPT_MODES else "auto"
    )
    if normalized_retry_prompt_mode == "auto":
        effective_retry_prompt_mode = "delta" if is_structured_condition else "full"
    else:
        effective_retry_prompt_mode = normalized_retry_prompt_mode
    normalized_retry_context_mode = (
        retry_context_mode if retry_context_mode in RETRY_CONTEXT_MODES else "history"
    )
    effective_retry_context_mode = normalized_retry_context_mode
    effective_retry_history_max_chars = max(0, int(retry_history_max_chars))
    stage_a_max_requests = max(1, int(context_stage_a_max_requests))
    stage_a_max_chars = max(1, int(context_stage_a_max_chars))
    prompt_budget = {
        "total_chars": max(0, int(prompt_budget_total_chars)),
        "retry_delta_chars": max(0, int(prompt_budget_retry_delta_chars)),
        "stdout_chars": max(0, int(prompt_budget_stdout_chars)),
        "snapshot_chars": max(0, int(prompt_budget_snapshot_chars)),
        "evidence_chars": max(0, int(prompt_budget_evidence_chars)),
        "retry_history_chars": max(0, int(prompt_budget_retry_history_chars)),
    }
    effective_run_metadata = (
        dict(run_metadata)
        if isinstance(run_metadata, dict)
        else _default_run_metadata(
            run_id=run_id,
            patcher_name=str(getattr(patcher, "name", "unknown")),
        )
    )
    patcher_name = str(getattr(patcher, "name", "unknown"))
    is_openai_patcher = patcher_name == "openai"

    llmdebug_dir = workdir / ".llmdebug"
    previous_failure_context: dict[str, Any] | None = None
    seen_evidence_ids: set[str] = set()
    retry_history_turns: list[str] = []

    # Initial run (attempt 0, no patch yet).
    shutil.rmtree(llmdebug_dir, ignore_errors=True)
    current = run_pytest(workdir, case.python_args, condition, case.timeout_sec, output_format)
    cumulative_runtime_sec = current.duration_sec
    if current.returncode == 0:
        attempt_dir = context_dir_base / "attempt_00"
        attempt_dir.mkdir(parents=True, exist_ok=True)
        _write_run_outputs(attempt_dir, current, prefix="pytest_before")
        record = _build_base_record(
            case=case,
            condition=condition,
            run_id=run_id,
            attempt=0,
            patcher_name=patcher_name,
            run_metadata=effective_run_metadata,
            attempt_metadata={
                **_empty_attempt_metadata(attempt=0),
                "max_attempts": int(k),
                "attempt_ordinal_label": f"0/{int(k)}",
                "context_protocol": effective_context_protocol,
                "retry_prompt_mode": effective_retry_prompt_mode,
                "retry_context_mode": effective_retry_context_mode,
                "retry_history_budget_chars": int(prompt_budget.get("retry_history_chars", 0)),
            },
            model_metadata=_build_model_metadata(patcher=patcher, patch_meta=None),
            context_protocol=effective_context_protocol,
        )
        record.update(
            {
                "success": True,
                "before_returncode": current.returncode,
                "before_duration_sec": current.duration_sec,
                "cumulative_runtime_sec": cumulative_runtime_sec,
                "note": "no_patch_needed",
            }
        )
        record.update(_timeout_fields(current))
        records.append(record)
        return records

    for attempt in range(1, k + 1):
        attempt_start = time.perf_counter()
        attempt_dir = context_dir_base / f"attempt_{attempt:02d}"
        attempt_dir.mkdir(parents=True, exist_ok=True)

        _write_run_outputs(attempt_dir, current, prefix="pytest_before")

        snapshot = None
        snapshot_error = None
        if _is_snapshot_condition(condition):
            snapshot, snapshot_error = read_latest_snapshot(llmdebug_dir)
            if snapshot is not None:
                (attempt_dir / "snapshot.json").write_text(
                    json.dumps(snapshot, indent=2, ensure_ascii=False),
                    encoding="utf-8",
                )

        failure_signature = _build_failure_signature_for_attempt(run=current, snapshot=snapshot)
        current_failure_context: dict[str, Any] = {
            "attempt": attempt,
            "failure_signature": failure_signature,
            "stdout": current.stdout,
            "stderr": current.stderr,
            "snapshot": snapshot,
        }
        previous_attempt_diff = _build_structured_previous_attempt_diff(
            previous_failure_context,
            current_failure_context=current_failure_context,
        )

        file_index = collect_file_index(workdir)
        project_files_context = (
            collect_text_context(workdir, max_total_chars=max_context_chars)
            if include_files_context and effective_context_protocol == "monolithic"
            else ""
        )
        evidence = {
            "case_id": case.case_id,
            "condition": condition.value,
            "attempt": attempt,
            "repro": [sys.executable, *case.python_args],
            "stdout": current.stdout,
            "stderr": current.stderr,
            "failure_signature": failure_signature,
            "previous_failure_signature": (
                previous_failure_context.get("failure_signature")
                if isinstance(previous_failure_context, dict)
                else None
            ),
            "failure_signature_repeat": (
                bool(previous_failure_context)
                and previous_failure_context.get("failure_signature") == failure_signature
            ),
            "previous_attempt_diff": previous_attempt_diff,
            "file_index": file_index,
            "files": project_files_context,
            "snapshot_available": snapshot is not None,
            "snapshot_error": snapshot_error,
        }
        stage_a_prompt: str | None = None
        stage_a_data = _empty_stage_a_context()
        stage_a_meta: dict[str, Any] = {}
        stage_a_request_invoked = False
        if effective_context_protocol == "staged":
            stage_a_prompt = render_stage_a_request_prompt(evidence=evidence, snapshot=snapshot)
            request_context_fn = getattr(patcher, "request_context", None)
            if callable(request_context_fn):
                stage_a_request_invoked = True
                context_result = request_context_fn(
                    prompt=stage_a_prompt,
                    context_dir=attempt_dir,
                    cwd=workdir,
                )
            else:
                context_result = None
            stage_a_request_payload: dict[str, Any] | None = None
            stage_a_raw_response: str | None = None
            if context_result is not None:
                if hasattr(context_result, "meta") and isinstance(context_result.meta, dict):
                    stage_a_meta.update(context_result.meta)
                request_candidate = (
                    context_result.request_json if hasattr(context_result, "request_json") else None
                )
                if isinstance(request_candidate, dict):
                    stage_a_request_payload = request_candidate
                if hasattr(context_result, "raw_response"):
                    raw_candidate = context_result.raw_response
                    if isinstance(raw_candidate, str):
                        stage_a_raw_response = raw_candidate
            if stage_a_request_payload is not None:
                stage_a_data = execute_context_requests(
                    request_payload=stage_a_request_payload,
                    root=workdir,
                    snapshot=snapshot,
                    file_index=file_index,
                    stdout=current.stdout,
                    stderr=current.stderr,
                    max_requests=stage_a_max_requests,
                    max_total_chars=stage_a_max_chars,
                )
            else:
                stage_a_data = build_fallback_context_bundle(
                    root=workdir,
                    snapshot=snapshot,
                    stdout=current.stdout,
                    stderr=current.stderr,
                    max_total_chars=stage_a_max_chars,
                    reason="invalid_or_missing_stage_a_response",
                    request_raw=stage_a_raw_response,
                )
            if stage_a_data.request_payload is None:
                stage_a_data = StageAContextData(
                    blocks=stage_a_data.blocks,
                    text=stage_a_data.text,
                    used_fallback=stage_a_data.used_fallback,
                    requests_count=stage_a_data.requests_count,
                    chars_returned=stage_a_data.chars_returned,
                    request_success=stage_a_data.request_success,
                    request_payload=stage_a_request_payload,
                    request_raw=stage_a_raw_response,
                    reason=stage_a_data.reason,
                    evidence_items=stage_a_data.evidence_items,
                )
        stage_a_default_llm_calls = 1 if (is_openai_patcher and stage_a_request_invoked) else 0

        structured_seed_data = _empty_stage_a_context()
        if is_structured_condition:
            structured_seed_data = build_fallback_context_bundle(
                root=workdir,
                snapshot=snapshot,
                stdout=current.stdout,
                stderr=current.stderr,
                max_total_chars=stage_a_max_chars,
                reason="structured_seed_bundle",
            )

        combined_evidence_items = _merge_evidence_items(
            structured_seed_data.evidence_items if is_structured_condition else [],
            stage_a_data.evidence_items,
        )
        combined_evidence_ids = _evidence_ids_from_items(combined_evidence_items)
        new_evidence_items = [
            item
            for item in combined_evidence_items
            if str(item.get("id") or "") not in seen_evidence_ids
        ]
        new_evidence_ids = _evidence_ids_from_items(new_evidence_items)
        reused_evidence_ids = [
            evidence_id for evidence_id in combined_evidence_ids if evidence_id in seen_evidence_ids
        ]
        seen_evidence_ids.update(combined_evidence_ids)

        stage_a_context_full = _render_evidence_items(combined_evidence_items)
        is_delta_retry = effective_retry_prompt_mode == "delta" and attempt > 1
        stage_a_context_text = (
            _render_evidence_items(new_evidence_items) if is_delta_retry else stage_a_context_full
        )
        retry_delta_chars = len(stage_a_context_text) if is_delta_retry else 0

        retry_packet = ""
        if is_structured_condition or is_delta_retry:
            retry_packet = _render_retry_packet(
                failure_signature=failure_signature,
                previous_failure_signature=evidence.get("previous_failure_signature"),
                failure_signature_repeat=bool(evidence.get("failure_signature_repeat")),
                previous_attempt_diff=previous_attempt_diff,
            )
        evidence_fingerprint = _compute_evidence_fingerprint(
            failure_signature=failure_signature,
            stage_a_blocks=stage_a_data.blocks,
            stage_a_context=stage_a_context_full,
            structured_seed_blocks=structured_seed_data.blocks if is_structured_condition else [],
        )
        current_failure_context["evidence_fingerprint"] = evidence_fingerprint

        (
            retry_history_text,
            retry_history_chars,
            retry_history_turns_included,
            retry_history_truncated,
        ) = (
            _build_retry_history_section(
                prior_turns=retry_history_turns,
                max_chars=effective_retry_history_max_chars,
            )
            if effective_retry_context_mode == "history" and attempt > 1
            else ("", 0, 0, False)
        )
        prompt_sections = _build_stage_b_prompt_sections(
            evidence=evidence,
            snapshot=snapshot,
            stage_a_context=stage_a_context_text,
            include_project_files=(effective_context_protocol == "monolithic"),
            retry_packet=retry_packet,
            retry_prompt_mode=effective_retry_prompt_mode,
            retry_history=retry_history_text,
            attempt=attempt,
            known_evidence_refs=reused_evidence_ids,
            new_evidence_ids=new_evidence_ids,
        )
        prompt_budget_result = _apply_prompt_budget(
            sections=prompt_sections,
            budget=prompt_budget,
            is_delta_retry=is_delta_retry,
        )
        prompt = prompt_budget_result.prompt
        retry_history_budget_chars = int(prompt_budget.get("retry_history_chars", 0))
        retry_history_budget_applied = bool(
            retry_history_text
            and (
                retry_history_text not in prompt
                or (
                    retry_history_budget_chars > 0
                    and retry_history_chars > retry_history_budget_chars
                )
            )
        )
        attempt_metadata = _materialize_attempt_metadata(
            attempt_dir=attempt_dir,
            attempt=attempt,
            max_attempts=k,
            evidence=evidence,
            prompt=prompt,
            record_prompt=record_prompt,
            record_evidence=record_evidence,
            context_protocol=effective_context_protocol,
            stage_a_prompt=stage_a_prompt,
            stage_a_raw_response=stage_a_data.request_raw,
            stage_a_request_payload=stage_a_data.request_payload,
            stage_a_requests_count=stage_a_data.requests_count,
            stage_a_chars_returned=stage_a_data.chars_returned,
            stage_a_request_success=stage_a_data.request_success,
            retry_prompt_mode=effective_retry_prompt_mode,
            retry_context_mode=effective_retry_context_mode,
            retry_history_chars=retry_history_chars,
            retry_history_truncated=retry_history_truncated,
            retry_history_turns_included=retry_history_turns_included,
            retry_history_budget_chars=retry_history_budget_chars,
            retry_history_budget_applied=retry_history_budget_applied,
            retry_delta_prompt=is_delta_retry,
            retry_delta_chars=retry_delta_chars,
            llm_call_count=0,
            tokens_prompt=0,
            tokens_completion=0,
            tokens_total=0,
            evidence_ids_new_count=len(new_evidence_ids),
            evidence_ids_reused_count=len(reused_evidence_ids),
            evidence_ids_sent_count=len(combined_evidence_ids),
            prompt_budget_used_chars=prompt_budget_result.used_chars,
            prompt_budget_overflow_chars=prompt_budget_result.overflow_chars,
            prompt_budget_dropped_sections=prompt_budget_result.dropped_sections,
        )

        patch_meta: dict[str, Any] = {}
        patch_meta["context_protocol"] = effective_context_protocol
        patch_meta["evidence_fingerprint"] = evidence_fingerprint
        patch_meta["retry_prompt_mode"] = effective_retry_prompt_mode
        patch_meta["retry_context_mode"] = effective_retry_context_mode
        patch_meta["retry_history_chars"] = retry_history_chars
        patch_meta["retry_history_truncated"] = retry_history_truncated
        patch_meta["retry_history_turns_included"] = retry_history_turns_included
        patch_meta["retry_history_budget_chars"] = retry_history_budget_chars
        patch_meta["retry_history_budget_applied"] = retry_history_budget_applied
        patch_meta["retry_delta_prompt"] = is_delta_retry
        patch_meta["retry_delta_chars"] = retry_delta_chars
        patch_meta["evidence_ids_new_count"] = len(new_evidence_ids)
        patch_meta["evidence_ids_reused_count"] = len(reused_evidence_ids)
        patch_meta["evidence_ids_sent_count"] = len(combined_evidence_ids)
        patch_meta["evidence_ids_new"] = new_evidence_ids[:20]
        patch_meta["evidence_ids_reused"] = reused_evidence_ids[:20]
        patch_meta["prompt_budget_used_chars"] = prompt_budget_result.used_chars
        patch_meta["prompt_budget_overflow_chars"] = prompt_budget_result.overflow_chars
        patch_meta["prompt_budget_dropped_sections"] = list(prompt_budget_result.dropped_sections)
        if effective_context_protocol == "staged":
            patch_meta["stage_a_request_success"] = stage_a_data.request_success
            patch_meta["stage_a_used_fallback"] = stage_a_data.used_fallback
            patch_meta["stage_a_reason"] = stage_a_data.reason
            patch_meta["stage_a_requests_count"] = stage_a_data.requests_count
            patch_meta["stage_a_chars_returned"] = stage_a_data.chars_returned
            if stage_a_meta:
                patch_meta["stage_a_meta"] = stage_a_meta
        (
            attempt_llm_calls,
            attempt_tokens_prompt,
            attempt_tokens_completion,
            attempt_tokens_total,
        ) = _aggregate_attempt_llm_usage(
            stage_a_meta=stage_a_meta,
            patch_meta=None,
            stage_a_default_llm_calls=stage_a_default_llm_calls,
            patch_default_llm_calls=0,
        )
        _apply_usage_totals_to_patch_meta(
            patch_meta=patch_meta,
            llm_call_count=attempt_llm_calls,
            tokens_prompt=attempt_tokens_prompt,
            tokens_completion=attempt_tokens_completion,
            tokens_total=attempt_tokens_total,
        )
        _sync_attempt_usage_metadata(
            attempt_metadata=attempt_metadata,
            patch_meta=patch_meta,
            llm_call_count=attempt_llm_calls,
        )

        if is_structured_condition and bool(evidence.get("failure_signature_repeat")):
            patch_meta["retry_gate_evaluated"] = True
            patch_meta["retry_gate_blocked"] = False
            patch_meta["retry_gate_reason"] = "new_evidence_present"
            prev_evidence_fingerprint = ""
            if isinstance(previous_failure_context, dict):
                prev_evidence_fingerprint = str(
                    previous_failure_context.get("evidence_fingerprint") or ""
                )
            if prev_evidence_fingerprint and prev_evidence_fingerprint == evidence_fingerprint:
                patch_meta["retry_gate_blocked"] = True
                patch_meta["retry_gate_reason"] = "same_signature_without_new_evidence"
                patch_meta["retry_gate_previous_evidence_fingerprint"] = prev_evidence_fingerprint
                cumulative_runtime_sec += time.perf_counter() - attempt_start
                record = _build_attempt_outcome_record(
                    case=case,
                    condition=condition,
                    run_id=run_id,
                    attempt=attempt,
                    patcher_name=patcher_name,
                    run_metadata=effective_run_metadata,
                    attempt_metadata=attempt_metadata,
                    patcher=patcher,
                    patch_meta=patch_meta,
                    context_protocol=effective_context_protocol,
                    success=False,
                    note="retry_gate_no_new_evidence",
                    before=current,
                    cumulative_runtime_sec=cumulative_runtime_sec,
                )
                records.append(record)
                _append_retry_history_turn_if_enabled(
                    retry_context_mode=effective_retry_context_mode,
                    retry_history_turns=retry_history_turns,
                    attempt=attempt,
                    note="retry_gate_no_new_evidence",
                    failure_signature=failure_signature,
                    patch_meta=patch_meta,
                )
                previous_failure_context = current_failure_context
                continue

        patch_start = time.perf_counter()
        patch = patcher.propose_patch(prompt=prompt, context_dir=attempt_dir, cwd=workdir)
        patch_duration_sec = time.perf_counter() - patch_start
        patch_usage_meta: dict[str, Any] | None = None
        for key in (
            "llm_call_count",
            "tokens_prompt",
            "tokens_completion",
            "tokens_total",
            "usage",
        ):
            patch_meta.pop(key, None)
        if isinstance(patch.meta, dict):
            patch_usage_meta = dict(patch.meta)
            patch_meta.update(patch_usage_meta)
        elif patch.meta is not None:
            patch_meta["raw_meta"] = patch.meta
        patch_meta["propose_duration_sec"] = patch_duration_sec
        _ensure_deterministic_seed_ack(
            deterministic=deterministic,
            seed=seed,
            patcher_name=patcher_name,
            patch_meta=patch_meta,
            case_id=case.case_id,
            condition=condition.value,
            attempt=attempt,
        )
        (
            attempt_llm_calls,
            attempt_tokens_prompt,
            attempt_tokens_completion,
            attempt_tokens_total,
        ) = _aggregate_attempt_llm_usage(
            stage_a_meta=stage_a_meta,
            patch_meta=patch_usage_meta,
            stage_a_default_llm_calls=stage_a_default_llm_calls,
            patch_default_llm_calls=1 if is_openai_patcher else 0,
        )
        _apply_usage_totals_to_patch_meta(
            patch_meta=patch_meta,
            llm_call_count=attempt_llm_calls,
            tokens_prompt=attempt_tokens_prompt,
            tokens_completion=attempt_tokens_completion,
            tokens_total=attempt_tokens_total,
        )
        _sync_attempt_usage_metadata(
            attempt_metadata=attempt_metadata,
            patch_meta=patch_meta,
            llm_call_count=attempt_llm_calls,
        )

        diff_to_apply = patch.diff
        if diff_to_apply is None and force_patch_attempt:
            force_start = time.perf_counter()
            forced = _build_runner_forced_attempt_diff(
                cwd=workdir, file_index=file_index, attempt=attempt
            )
            patch_meta["runner_forced_patch_build_sec"] = time.perf_counter() - force_start
            if forced is not None:
                forced_target, diff_to_apply = forced
                patch_meta["runner_forced_patch_attempt"] = True
                patch_meta["runner_forced_patch_target"] = forced_target
                patch_meta["runner_forced_patch_reason"] = "no_patch_proposed"
        if diff_to_apply is None:
            cumulative_runtime_sec += time.perf_counter() - attempt_start
            record = _build_attempt_outcome_record(
                case=case,
                condition=condition,
                run_id=run_id,
                attempt=attempt,
                patcher_name=patcher_name,
                run_metadata=effective_run_metadata,
                attempt_metadata=attempt_metadata,
                patcher=patcher,
                patch_meta=patch_meta,
                context_protocol=effective_context_protocol,
                success=False,
                note="no_patch_proposed",
                before=current,
                cumulative_runtime_sec=cumulative_runtime_sec,
            )
            records.append(record)
            _append_retry_history_turn_if_enabled(
                retry_context_mode=effective_retry_context_mode,
                retry_history_turns=retry_history_turns,
                attempt=attempt,
                note="no_patch_proposed",
                failure_signature=failure_signature,
                patch_meta=patch_meta,
            )
            previous_failure_context = current_failure_context
            continue

        patch_meta["patch_diff_sha256"] = _sha256_text(diff_to_apply)
        patch_meta["patch_diff_chars"] = len(diff_to_apply)
        (attempt_dir / "patch.diff").write_text(diff_to_apply, encoding="utf-8")

        apply_start = time.perf_counter()
        apply_ok, apply_msg = apply_diff(workdir, diff_to_apply)
        patch_meta["apply_duration_sec"] = time.perf_counter() - apply_start
        if not apply_ok and force_patch_attempt:
            patch_meta["initial_apply_error"] = apply_msg
            recovery_build_start = time.perf_counter()
            recovery_patch = _build_runner_forced_attempt_diff(
                cwd=workdir,
                file_index=file_index,
                attempt=attempt,
            )
            patch_meta["runner_forced_patch_recovery_build_sec"] = (
                time.perf_counter() - recovery_build_start
            )
            if recovery_patch is not None:
                recovery_target, recovery_diff = recovery_patch
                (attempt_dir / "patch_recovery.diff").write_text(recovery_diff, encoding="utf-8")
                recovery_apply_start = time.perf_counter()
                recovery_ok, recovery_msg = apply_diff(workdir, recovery_diff)
                patch_meta["runner_forced_patch_recovery_apply_sec"] = (
                    time.perf_counter() - recovery_apply_start
                )
                patch_meta["runner_forced_patch_recovery"] = True
                patch_meta["runner_forced_patch_recovery_target"] = recovery_target
                patch_meta["runner_forced_patch_recovery_reason"] = "patch_apply_failed"
                if recovery_ok:
                    apply_ok = True
                    apply_msg = recovery_msg
                else:
                    patch_meta["runner_forced_patch_recovery_error"] = recovery_msg
        if not apply_ok:
            (attempt_dir / "apply_error.txt").write_text(apply_msg, encoding="utf-8")
            cumulative_runtime_sec += time.perf_counter() - attempt_start
            record = _build_attempt_outcome_record(
                case=case,
                condition=condition,
                run_id=run_id,
                attempt=attempt,
                patcher_name=patcher_name,
                run_metadata=effective_run_metadata,
                attempt_metadata=attempt_metadata,
                patcher=patcher,
                patch_meta=patch_meta,
                context_protocol=effective_context_protocol,
                success=False,
                note="patch_apply_failed",
                before=current,
                cumulative_runtime_sec=cumulative_runtime_sec,
            )
            records.append(record)
            _append_retry_history_turn_if_enabled(
                retry_context_mode=effective_retry_context_mode,
                retry_history_turns=retry_history_turns,
                attempt=attempt,
                note="patch_apply_failed",
                failure_signature=failure_signature,
                patch_meta=patch_meta,
            )
            previous_failure_context = current_failure_context
            continue

        # Re-run after patch.
        shutil.rmtree(llmdebug_dir, ignore_errors=True)
        after = run_pytest(workdir, case.python_args, condition, case.timeout_sec, output_format)
        _write_run_outputs(attempt_dir, after, prefix="pytest_after")
        cumulative_runtime_sec += time.perf_counter() - attempt_start

        success = after.returncode == 0
        note = "success" if success else "tests_still_failing"
        record = _build_attempt_outcome_record(
            case=case,
            condition=condition,
            run_id=run_id,
            attempt=attempt,
            patcher_name=patcher_name,
            run_metadata=effective_run_metadata,
            attempt_metadata=attempt_metadata,
            patcher=patcher,
            patch_meta=patch_meta,
            context_protocol=effective_context_protocol,
            success=success,
            note=note,
            before=current,
            cumulative_runtime_sec=cumulative_runtime_sec,
            after=after,
        )
        records.append(record)
        _append_retry_history_turn_if_enabled(
            retry_context_mode=effective_retry_context_mode,
            retry_history_turns=retry_history_turns,
            attempt=attempt,
            note=note,
            failure_signature=failure_signature,
            patch_meta=patch_meta,
        )

        if success:
            break

        # Continue: next attempt uses this failure as evidence.
        previous_failure_context = current_failure_context
        current = after

    return records


def _run_pytest_pooled(
    *,
    cwd: Path,
    python_args: list[str],
    condition: Condition,
    timeout_sec: int,
    output_format: str,
    execution: ExecutionConfig,
    pool: _EvalContainerPool,
) -> RunResult:
    """Run pytest in a pooled container via ``exec_run``."""
    env = _base_pytest_env(
        output_format=output_format,
        snapshot_condition=_is_snapshot_condition(condition),
    )
    if execution.container_mount_repo_src:
        env["PYTHONPATH"] = "/repo_src"
    env.update(execution.container_env)

    command = _build_pytest_args(
        python_cmd="python",
        python_args=python_args,
        condition=condition,
    )

    container = pool.acquire()
    try:
        return container.exec_pytest(
            host_cwd=cwd,
            command=command,
            env=env,
            timeout_sec=timeout_sec,
        )
    finally:
        pool.release(container)


def run_pytest(
    cwd: Path,
    python_args: list[str],
    condition: Condition,
    timeout_sec: int,
    output_format: str,
    *,
    execution: ExecutionConfig | None = None,
) -> RunResult:
    active_execution = execution if execution is not None else get_active_execution_config()
    if active_execution.executor == "local":
        return _run_pytest_local(
            cwd=cwd,
            python_args=python_args,
            condition=condition,
            timeout_sec=timeout_sec,
            output_format=output_format,
        )
    if active_execution.executor == "testcontainers":
        pool = _ACTIVE_CONTAINER_POOL
        if pool is not None:
            return _run_pytest_pooled(
                cwd=cwd,
                python_args=python_args,
                condition=condition,
                timeout_sec=timeout_sec,
                output_format=output_format,
                execution=active_execution,
                pool=pool,
            )
        return _run_pytest_testcontainers(
            cwd=cwd,
            python_args=python_args,
            condition=condition,
            timeout_sec=timeout_sec,
            output_format=output_format,
            execution=active_execution,
        )
    raise RuntimeError(f"unsupported executor: {active_execution.executor!r}")


def _build_pytest_args(
    *, python_cmd: str, python_args: list[str], condition: Condition
) -> list[str]:
    args = [python_cmd, *python_args]
    if _is_snapshot_condition(condition):
        if "-m" in python_args and "pytest" in python_args:
            idx = python_args.index("pytest")
            args = [
                python_cmd,
                *python_args[: idx + 1],
                "-p",
                "llmdebug.pytest_plugin",
                *python_args[idx + 1 :],
            ]
        else:
            args = [python_cmd, *python_args, "-p", "llmdebug.pytest_plugin"]
    return args


def _base_pytest_env(*, output_format: str, snapshot_condition: bool) -> dict[str, str]:
    env = {
        "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
        "PYTHONDONTWRITEBYTECODE": "1",
        "LLMDEBUG_OUTPUT_FORMAT": output_format,
    }
    if _ACTIVE_RUN_SETTINGS.deterministic and _ACTIVE_RUN_SETTINGS.seed is not None:
        env["PYTHONHASHSEED"] = str(_ACTIVE_RUN_SETTINGS.seed)
        env.setdefault("TZ", "UTC")
    if snapshot_condition:
        env.setdefault("LLMDEBUG_DEBUG", "0")
        env.setdefault("LLMDEBUG_INCLUDE_GIT", "false")
    return env


def _run_pytest_local(
    *,
    cwd: Path,
    python_args: list[str],
    condition: Condition,
    timeout_sec: int,
    output_format: str,
) -> RunResult:
    env = os.environ.copy()
    env["PYTHONPATH"] = os.pathsep.join([str(_SRC_ROOT), env.get("PYTHONPATH", "")]).strip(
        os.pathsep
    )
    env.update(
        _base_pytest_env(
            output_format=output_format,
            snapshot_condition=_is_snapshot_condition(condition),
        )
    )
    args = _build_pytest_args(
        python_cmd=sys.executable,
        python_args=python_args,
        condition=condition,
    )

    start = time.perf_counter()
    try:
        proc = subprocess.run(
            args,
            cwd=cwd,
            env=env,
            capture_output=True,
            text=True,
            timeout=timeout_sec,
        )
    except subprocess.TimeoutExpired as exc:
        end = time.perf_counter()
        partial_stdout = _coerce_subprocess_output(exc.stdout)
        partial_stderr = _coerce_subprocess_output(exc.stderr)
        timeout_message = f"pytest timed out after {timeout_sec}s"
        stderr = timeout_message if not partial_stderr else f"{partial_stderr}\n{timeout_message}"
        return RunResult(
            returncode=PYTEST_TIMEOUT_RETURNCODE,
            duration_sec=float(end - start),
            stdout=partial_stdout,
            stderr=stderr,
            timed_out=True,
            timeout_sec=timeout_sec,
        )
    end = time.perf_counter()
    return RunResult(
        returncode=int(proc.returncode),
        duration_sec=float(end - start),
        stdout=proc.stdout,
        stderr=proc.stderr,
        timed_out=False,
        timeout_sec=None,
    )


def _status_code_from_wait_result(result: Any) -> int:
    if isinstance(result, int):
        return result
    if isinstance(result, dict):
        raw = result.get("StatusCode")
        if isinstance(raw, int):
            return raw
        try:
            return int(raw)
        except Exception:
            return 1
    return 1


def _is_container_wait_timeout(exc: Exception) -> bool:
    name = type(exc).__name__
    if "ReadTimeout" in name:
        return True
    text = str(exc).lower()
    return "read timed out" in text or "timed out" in text


def _run_pytest_testcontainers(
    *,
    cwd: Path,
    python_args: list[str],
    condition: Condition,
    timeout_sec: int,
    output_format: str,
    execution: ExecutionConfig,
) -> RunResult:
    try:
        from testcontainers.core.container import DockerContainer
    except Exception as exc:
        raise RuntimeError(
            "testcontainers execution requested but dependency is missing "
            f"({type(exc).__name__}: {exc}). Install llmdebug[evals]."
        ) from exc

    container_env = _base_pytest_env(
        output_format=output_format,
        snapshot_condition=_is_snapshot_condition(condition),
    )
    if execution.container_mount_repo_src:
        container_env["PYTHONPATH"] = "/repo_src"
    container_env.update(execution.container_env)

    command = _build_pytest_args(
        python_cmd="python",
        python_args=python_args,
        condition=condition,
    )
    tmpfs_size = max(1, int(execution.container_tmpfs_size_mb))
    kwargs: dict[str, Any] = {
        "working_dir": "/workspace",
        "network_mode": execution.container_network,
        "read_only": True,
        "tmpfs": {"/tmp": f"rw,noexec,nosuid,nodev,size={tmpfs_size}m"},
        "cap_drop": ["ALL"],
        "security_opt": ["no-new-privileges:true"],
        "pids_limit": int(execution.container_pids_limit),
        "mem_limit": execution.container_memory,
        "nano_cpus": int(float(execution.container_cpus) * 1_000_000_000),
        "user": execution.container_user,
        "labels": {
            EVAL_CONTAINER_LABEL_KEY: EVAL_CONTAINER_LABEL_VALUE,
        },
    }

    container = (
        DockerContainer(execution.container_image)
        .with_volume_mapping(str(cwd), "/workspace", mode="rw")
        .with_command(command)
        .with_kwargs(**kwargs)
    )
    if execution.container_mount_repo_src:
        container = container.with_volume_mapping(str(_SRC_ROOT), "/repo_src", mode="ro")
    for key, value in sorted(container_env.items()):
        container = container.with_env(key, value)

    start = time.perf_counter()
    stdout = ""
    stderr = ""
    timed_out = False
    returncode = 1
    try:
        container.start()
        wrapped = container.get_wrapped_container()
        if wrapped is None:
            raise RuntimeError("testcontainer started without wrapped container handle")
        try:
            wait_result = wrapped.wait(timeout=timeout_sec)
            returncode = _status_code_from_wait_result(wait_result)
        except Exception as exc:
            if _is_container_wait_timeout(exc):
                timed_out = True
                returncode = PYTEST_TIMEOUT_RETURNCODE
                try:
                    wrapped.kill()
                except Exception:
                    pass
            else:
                raise RuntimeError(f"container wait failed: {type(exc).__name__}: {exc}") from exc

        out_raw, err_raw = container.get_logs()
        stdout = _coerce_subprocess_output(out_raw)
        stderr = _coerce_subprocess_output(err_raw)
    except RuntimeError:
        raise
    except Exception as exc:
        raise RuntimeError(
            "testcontainers execution failed: "
            f"{type(exc).__name__}: {exc} "
            f"(image={execution.container_image!r}, network={execution.container_network!r})"
        ) from exc
    finally:
        try:
            container.stop()
        except Exception:
            pass

    end = time.perf_counter()
    if timed_out:
        timeout_message = f"pytest timed out after {timeout_sec}s"
        stderr = timeout_message if not stderr else f"{stderr}\n{timeout_message}"
        return RunResult(
            returncode=PYTEST_TIMEOUT_RETURNCODE,
            duration_sec=float(end - start),
            stdout=stdout,
            stderr=stderr,
            timed_out=True,
            timeout_sec=timeout_sec,
        )
    return RunResult(
        returncode=int(returncode),
        duration_sec=float(end - start),
        stdout=stdout,
        stderr=stderr,
        timed_out=False,
        timeout_sec=None,
    )


def _write_run_outputs(out_dir: Path, run: RunResult, *, prefix: str) -> None:
    (out_dir / f"{prefix}_stdout.txt").write_text(run.stdout or "", encoding="utf-8")
    (out_dir / f"{prefix}_stderr.txt").write_text(run.stderr or "", encoding="utf-8")
    combined = run.stdout + ("\n" if run.stdout and run.stderr else "") + run.stderr
    (out_dir / f"{prefix}_combined.txt").write_text(combined, encoding="utf-8")


def _coerce_subprocess_output(value: str | bytes | None) -> str:
    if value is None:
        return ""
    if isinstance(value, bytes):
        return value.decode("utf-8", errors="replace")
    return value


def _timeout_fields(run: RunResult) -> dict[str, Any]:
    if run.timed_out:
        return {
            "timed_out": True,
            "timeout_sec": run.timeout_sec,
        }
    return {}


def read_latest_snapshot(out_dir: Path) -> tuple[dict[str, Any] | None, str | None]:
    try:
        from llmdebug.output import get_latest_snapshot

        snap = get_latest_snapshot(str(out_dir))
        return snap, None
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"


def _build_failure_signature_for_attempt(*, run: RunResult, snapshot: dict[str, Any] | None) -> str:
    try:
        from llmdebug.rca_state import build_failure_signature

        return build_failure_signature(snapshot, stdout=run.stdout, stderr=run.stderr)
    except Exception as e:
        sys.stderr.write(
            f"llmdebug eval: build_failure_signature failed, using text fallback: "
            f"{type(e).__name__}: {e}\n"
        )
        text = (run.stderr or "").strip() or (run.stdout or "").strip() or "unknown_failure"
        head = "\n".join([line for line in text.splitlines() if line.strip()][-3:])
        digest = hashlib.sha256(head.encode("utf-8")).hexdigest()[:16]
        return f"TextFailure:{digest}"


def _build_structured_previous_attempt_diff(
    previous_failure_context: dict[str, Any] | None,
    *,
    current_failure_context: dict[str, Any],
) -> dict[str, Any] | None:
    if not previous_failure_context:
        return None

    prev_attempt = int(previous_failure_context.get("attempt", 0))
    curr_attempt = int(current_failure_context.get("attempt", 0))
    prev_signature = str(previous_failure_context.get("failure_signature") or "")
    curr_signature = str(current_failure_context.get("failure_signature") or "")

    prev_snapshot = previous_failure_context.get("snapshot")
    curr_snapshot = current_failure_context.get("snapshot")
    if isinstance(prev_snapshot, dict) and isinstance(curr_snapshot, dict):
        try:
            from llmdebug.snapshot_diff import diff_snapshots, structured_diff_summary

            diff = diff_snapshots(prev_snapshot, curr_snapshot)
            structured = structured_diff_summary(diff)
            structured["attempt_prev"] = f"attempt_{prev_attempt:02d}"
            structured["attempt_curr"] = f"attempt_{curr_attempt:02d}"
            return structured
        except Exception as e:
            sys.stderr.write(
                f"llmdebug eval: structured diff failed, using text fallback: "
                f"{type(e).__name__}: {e}\n"
            )
            pass

    prev_output = (
        str(previous_failure_context.get("stderr") or "")
        + "\n"
        + str(previous_failure_context.get("stdout") or "")
    ).strip()
    curr_output = (
        str(current_failure_context.get("stderr") or "")
        + "\n"
        + str(current_failure_context.get("stdout") or "")
    ).strip()

    return {
        "attempt_prev": f"attempt_{prev_attempt:02d}",
        "attempt_curr": f"attempt_{curr_attempt:02d}",
        "failure_signature_same": prev_signature == curr_signature,
        "evidence_delta": {
            "new_frames": [],
            "dropped_frames": [],
            "message_changed": prev_output != curr_output,
        },
        "change_delta": {
            "touched_files": [],
            "touched_functions": [],
        },
        "hypothesis_delta": "unknown",
        "verification_delta": {
            "old_status": "unknown",
            "new_status": "unknown",
            "status_changed": False,
        },
    }


def _format_local_value(name: str, value: Any, meta: dict[str, Any] | None) -> str:
    """Format a single local variable with type/shape annotation."""
    meta = meta or {}
    type_str = str(meta.get("type", ""))
    # Strip 'builtins.' prefix for readability.
    type_str = type_str.removeprefix("builtins.")

    # Array-like: show shape and dtype prominently.
    if isinstance(value, dict) and "__array__" in value:
        shape = value.get("shape")
        dtype = value.get("dtype", "")
        arr_type = value.get("__array__", "ndarray")
        shape_str = f"({', '.join(str(s) for s in shape)})" if isinstance(shape, list) else "?"
        return f"    {name} = <{arr_type}, shape={shape_str}, dtype={dtype}>"

    # Scalar or small repr.
    try:
        repr_str = json.dumps(value, ensure_ascii=False, default=str)
    except Exception:
        repr_str = repr(value)
    # Truncate long values.
    if len(repr_str) > 120:
        repr_str = repr_str[:117] + "..."

    # Add type annotation.
    annotation = ""
    length = meta.get("len")
    shape = meta.get("shape")
    if shape is not None and isinstance(shape, list):
        annotation = f"  ({type_str}, shape=({', '.join(str(s) for s in shape)}))"
    elif length is not None:
        annotation = f"  ({type_str}, len={length})"
    elif type_str:
        annotation = f"  ({type_str})"

    return f"    {name} = {repr_str}{annotation}"


def _format_snapshot_section(snapshot: dict[str, Any], *, max_frames: int = 3) -> str:
    """Format a snapshot into readable text for the LLM prompt."""
    lines: list[str] = []
    lines.append("=== llmdebug Debug Snapshot ===")
    lines.append("")

    # Exception header.
    exc = snapshot.get("exception", {})
    exc_type = exc.get("type", "Unknown")
    exc_msg = exc.get("message", "")
    lines.append(f"Exception: {exc_type} — {exc_msg}")

    error_cat = exc.get("error_category")
    if isinstance(error_cat, dict):
        cat = error_cat.get("category", "")
        suggestion = error_cat.get("suggestion", "")
        if cat:
            lines.append(f"Category: {cat}")
        if suggestion:
            lines.append(f"Suggestion: {suggestion}")

    lines.append("")

    # Frames — only user code (file_rel is not None), up to max_frames.
    frames = snapshot.get("frames", [])
    shown = 0
    for i, frame in enumerate(frames):
        if shown >= max_frames:
            break
        # Skip framework internals (file_rel is None for site-packages).
        file_rel = frame.get("file_rel")
        if file_rel is None:
            continue

        label = "crash site" if i == 0 else "caller"
        func = frame.get("function", "?")
        line_no = frame.get("line", "?")
        code = frame.get("code", "")
        lines.append(f"Frame {shown} ({label}): {file_rel}:{line_no} in {func}()")
        if code:
            lines.append(f"  Code: {code}")

        # Locals with metadata.
        frame_locals = frame.get("locals", {})
        locals_meta = frame.get("locals_meta", {})
        if frame_locals:
            lines.append("  Locals:")
            for var_name, var_value in frame_locals.items():
                # Skip pytest-internal variables.
                if var_name.startswith("@"):
                    continue
                var_meta = locals_meta.get(var_name)
                lines.append(_format_local_value(var_name, var_value, var_meta))
        lines.append("")
        shown += 1

    lines.append("===")
    return "\n".join(lines)


def render_stage_a_request_prompt(
    *, evidence: dict[str, Any], snapshot: dict[str, Any] | None
) -> str:
    repro = evidence.get("repro")
    stdout = evidence.get("stdout", "")
    stderr = evidence.get("stderr", "")
    failure_signature = evidence.get("failure_signature", "")
    previous_failure_signature = evidence.get("previous_failure_signature")
    failure_signature_repeat = bool(evidence.get("failure_signature_repeat"))
    previous_attempt_diff = evidence.get("previous_attempt_diff")
    file_index = evidence.get("file_index", "")

    parts: list[str] = []
    parts.append(
        "You are selecting additional debugging evidence before proposing a patch.\n"
        "Return STRICT JSON only with this schema:\n"
        "{\n"
        '  "need_more_context": true,\n'
        '  "requests": [\n'
        '    {"type":"file","path":"relative/path.py"},\n'
        '    {"type":"search","path":"relative/path.py","pattern":"literal_text"},\n'
        '    {"type":"frame","index":1}\n'
        "  ],\n"
        '  "sufficiency_rationale":"one short sentence"\n'
        "}\n"
        "Allowed request types: file, search, frame."
    )
    parts.append(f"Repro command:\n{repro}\n")
    parts.append("Pytest output (stdout):\n" + (stdout or "").rstrip() + "\n")
    if stderr:
        parts.append("Pytest output (stderr):\n" + stderr.rstrip() + "\n")
    if snapshot is not None:
        parts.append(_format_snapshot_section(snapshot))
    if failure_signature:
        parts.append(f"Current failure signature:\n{failure_signature}\n")
    if previous_failure_signature is not None:
        parts.append(f"Previous failure signature:\n{previous_failure_signature}\n")
        parts.append(f"Failure signature repeated:\n{failure_signature_repeat}\n")
    if previous_attempt_diff is not None:
        parts.append(
            "Structured diff vs previous failed attempt:\n"
            + json.dumps(previous_attempt_diff, indent=2, ensure_ascii=False)
            + "\n"
        )
    if file_index:
        parts.append(
            "Available files (use exact relative paths):\n" + str(file_index).rstrip() + "\n"
        )
    parts.append(
        "Do NOT propose code edits in this step. "
        "Return JSON only. Use need_more_context=false with requests=[] when evidence is sufficient."
    )
    return "\n".join(parts).rstrip() + "\n"


def render_stage_b_patch_prompt(
    *,
    evidence: dict[str, Any],
    snapshot: dict[str, Any] | None,
    stage_a_context: str = "",
    include_project_files: bool = True,
    retry_packet: str = "",
    retry_history: str = "",
    retry_prompt_mode: str = "full",
    attempt: int = 1,
    known_evidence_refs: list[str] | None = None,
    new_evidence_ids: list[str] | None = None,
) -> str:
    sections = _build_stage_b_prompt_sections(
        evidence=evidence,
        snapshot=snapshot,
        stage_a_context=stage_a_context,
        include_project_files=include_project_files,
        retry_packet=retry_packet,
        retry_history=retry_history,
        retry_prompt_mode=retry_prompt_mode,
        attempt=attempt,
        known_evidence_refs=known_evidence_refs,
        new_evidence_ids=new_evidence_ids,
    )
    return _join_prompt_sections(sections)


def _build_stage_b_prompt_sections(
    *,
    evidence: dict[str, Any],
    snapshot: dict[str, Any] | None,
    stage_a_context: str,
    include_project_files: bool,
    retry_packet: str,
    retry_history: str,
    retry_prompt_mode: str,
    attempt: int,
    known_evidence_refs: list[str] | None,
    new_evidence_ids: list[str] | None,
) -> list[dict[str, Any]]:
    repro = evidence.get("repro")
    stdout = evidence.get("stdout", "")
    stderr = evidence.get("stderr", "")
    failure_signature = evidence.get("failure_signature", "")
    previous_failure_signature = evidence.get("previous_failure_signature")
    failure_signature_repeat = bool(evidence.get("failure_signature_repeat"))
    previous_attempt_diff = evidence.get("previous_attempt_diff")
    file_index = evidence.get("file_index", "")
    files = evidence.get("files") or ""
    has_previous = previous_failure_signature is not None
    is_delta_retry = retry_prompt_mode == "delta" and attempt > 1

    sections: list[dict[str, Any]] = []
    if is_delta_retry:
        sections.append(
            {
                "key": "instructions",
                "priority": 3,
                "text": (
                    "You are a coding assistant. This is a retry with delta-first protocol.\n"
                    "Use the retry packet and new evidence blocks first. Avoid equivalent edits "
                    "unless evidence changed."
                ),
            }
        )
    else:
        sections.append(
            {
                "key": "instructions",
                "priority": 3,
                "text": (
                    "You are a coding assistant. Fix the failing tests with root-cause analysis discipline.\n"
                    "Make the smallest possible change (avoid extra refactors or added features).\n"
                    "Do not repeat an equivalent patch when the failure signature is unchanged."
                ),
            }
        )

    rca_lines = [
        "RCA checklist (internal reasoning only, do not print these sections):",
        "1) Observed failure with concrete evidence",
        "2) Falsifiable root-cause hypothesis",
        "3) Why evidence supports that hypothesis",
        "4) Minimal fix plan",
        "5) Verification plan",
    ]
    if has_previous:
        rca_lines.append("6) Diff vs previous failed attempt")
    sections.append({"key": "rca_checklist", "priority": 3, "text": "\n".join(rca_lines)})

    if retry_packet:
        sections.append(
            {"key": "retry_packet", "priority": 3, "text": retry_packet.rstrip() + "\n"}
        )
    if retry_history:
        sections.append(
            {
                "key": "retry_history",
                "priority": 2,
                "text": "Retry history from previous attempts:\n" + retry_history.rstrip() + "\n",
            }
        )

    sections.append({"key": "repro", "priority": 3, "text": f"Repro command:\n{repro}\n"})

    if not is_delta_retry:
        pytest_block = "Pytest output (stdout):\n" + (stdout or "").rstrip() + "\n"
        if stderr:
            pytest_block += "\nPytest output (stderr):\n" + stderr.rstrip() + "\n"
        sections.append({"key": "pytest_output", "priority": 2, "text": pytest_block})

    if not is_delta_retry and snapshot is not None:
        sections.append(
            {"key": "snapshot", "priority": 2, "text": _format_snapshot_section(snapshot)}
        )

    if failure_signature:
        sections.append(
            {
                "key": "failure_signatures",
                "priority": 3,
                "text": f"Current failure signature:\n{failure_signature}\n",
            }
        )
    if has_previous:
        sections.append(
            {
                "key": "failure_signatures",
                "priority": 3,
                "text": (
                    f"Previous failure signature:\n{previous_failure_signature}\n"
                    f"Failure signature repeated:\n{failure_signature_repeat}\n"
                ),
            }
        )

    if (not is_delta_retry) and previous_attempt_diff is not None:
        sections.append(
            {
                "key": "previous_attempt_diff",
                "priority": 2,
                "text": (
                    "Structured diff vs previous failed attempt:\n"
                    + json.dumps(previous_attempt_diff, indent=2, ensure_ascii=False)
                    + "\n"
                ),
            }
        )
    if failure_signature_repeat:
        sections.append(
            {
                "key": "loop_breaker",
                "priority": 3,
                "text": (
                    "Loop breaker: The signature repeated. "
                    "Change either hypothesis or evidence basis before retrying.\n"
                ),
            }
        )

    if not is_delta_retry and file_index:
        sections.append(
            {
                "key": "file_index",
                "priority": 1,
                "text": "Available files (use exact relative paths):\n"
                + str(file_index).rstrip()
                + "\n",
            }
        )
    if stage_a_context:
        sections.append(
            {
                "key": "stage_a_context",
                "priority": 2,
                "text": "Stage-A requested context:\n" + stage_a_context.rstrip() + "\n",
            }
        )

    known_refs = [item for item in (known_evidence_refs or []) if item]
    if is_delta_retry and known_refs:
        sections.append(
            {
                "key": "known_evidence_refs",
                "priority": 1,
                "text": "Known evidence references:\n"
                + "\n".join(f"- {item}" for item in known_refs),
            }
        )
    if is_delta_retry and new_evidence_ids:
        sections.append(
            {
                "key": "new_evidence_ids",
                "priority": 2,
                "text": "New evidence IDs in this retry:\n"
                + "\n".join(f"- {item}" for item in new_evidence_ids),
            }
        )
    if include_project_files and files and not is_delta_retry:
        sections.append(
            {
                "key": "project_files",
                "priority": 0,
                "text": "Project files:\n" + str(files).rstrip() + "\n",
            }
        )

    sections.append(
        {
            "key": "closing",
            "priority": 2,
            "text": (
                "If evidence is insufficient for a confident fix, "
                "prefer a minimal observability change that increases signal."
            ),
        }
    )
    return sections


@dataclass(frozen=True)
class PromptBudgetResult:
    prompt: str
    used_chars: int
    overflow_chars: int
    dropped_sections: list[str]


def _join_prompt_sections(sections: list[dict[str, Any]]) -> str:
    parts = [
        str(section.get("text") or "").strip("\n") for section in sections if section.get("text")
    ]
    if not parts:
        return ""
    return "\n\n".join(parts).rstrip() + "\n"


def _truncate_text_for_section(*, key: str, text: str, max_chars: int) -> str:
    if max_chars <= 0:
        return ""
    if len(text) <= max_chars:
        return text
    if max_chars <= 64:
        return text[:max_chars]

    marker = "\n...[truncated]...\n"
    if len(marker) >= max_chars:
        return text[:max_chars]

    if key in {"pytest_output", "retry_packet"}:
        tail_chars = max_chars // 2
        head_chars = max_chars - tail_chars - len(marker)
        if head_chars < 0:
            return text[-max_chars:]
        return text[:head_chars] + marker + text[-tail_chars:]

    head_chars = max_chars - len(marker)
    return text[:head_chars] + marker


def _apply_prompt_budget(
    *,
    sections: list[dict[str, Any]],
    budget: dict[str, int],
    is_delta_retry: bool,
) -> PromptBudgetResult:
    capped_sections = [dict(section) for section in sections]
    per_section_limits = {
        "retry_packet": int(budget.get("retry_delta_chars", 0)),
        "pytest_output": int(budget.get("stdout_chars", 0)),
        "snapshot": int(budget.get("snapshot_chars", 0)),
        "stage_a_context": int(budget.get("evidence_chars", 0)),
        "retry_history": int(budget.get("retry_history_chars", 0)),
    }

    for section in capped_sections:
        key = str(section.get("key") or "")
        text = str(section.get("text") or "")
        limit = per_section_limits.get(key, 0)
        if limit > 0:
            section["text"] = _truncate_text_for_section(key=key, text=text, max_chars=limit)

    prompt = _join_prompt_sections(capped_sections)
    total_budget = int(budget.get("total_chars", 0))
    if total_budget <= 0 or len(prompt) <= total_budget:
        return PromptBudgetResult(
            prompt=prompt,
            used_chars=len(prompt),
            overflow_chars=max(0, len(prompt) - max(total_budget, 0)),
            dropped_sections=[],
        )

    overflow = len(prompt) - total_budget
    dropped_sections: list[str] = []
    drop_order = [
        "project_files",
        "file_index",
        "known_evidence_refs",
        "retry_history",
        "snapshot",
        "pytest_output",
        "stage_a_context",
        "previous_attempt_diff",
    ]
    min_keep_by_key = {
        "snapshot": 240,
        "pytest_output": 240,
        "previous_attempt_diff": 240,
        "retry_history": 160,
        "stage_a_context": 512 if is_delta_retry else 240,
    }

    for key in drop_order:
        if overflow <= 0:
            break
        for section in capped_sections:
            if str(section.get("key") or "") != key:
                continue
            text = str(section.get("text") or "")
            if not text:
                continue
            min_keep = min(min_keep_by_key.get(key, 0), len(text))
            target = max(min_keep, len(text) - overflow)
            next_text = _truncate_text_for_section(key=key, text=text, max_chars=target)
            overflow -= max(0, len(text) - len(next_text))
            section["text"] = next_text
            if not next_text.strip():
                dropped_sections.append(key)
            break

    prompt = _join_prompt_sections(capped_sections)
    if len(prompt) > total_budget:
        prompt = _truncate_text_for_section(key="final", text=prompt, max_chars=total_budget)
    overflow_chars = max(0, len(prompt) - total_budget)
    return PromptBudgetResult(
        prompt=prompt,
        used_chars=len(prompt),
        overflow_chars=overflow_chars,
        dropped_sections=sorted(set(dropped_sections)),
    )


def render_prompt(*, evidence: dict[str, Any], snapshot: dict[str, Any] | None) -> str:
    """Backward-compatible prompt renderer (monolithic context protocol)."""
    return render_stage_b_patch_prompt(
        evidence=evidence,
        snapshot=snapshot,
        stage_a_context="",
        include_project_files=True,
    )


def _build_retry_history_section(
    *,
    prior_turns: list[str],
    max_chars: int,
) -> tuple[str, int, int, bool]:
    if not prior_turns:
        return "", 0, 0, False
    if max_chars <= 0:
        return "", 0, 0, True

    cleaned_turns = [str(turn).strip() for turn in prior_turns if str(turn).strip()]
    if not cleaned_turns:
        return "", 0, 0, False

    selected_turns: list[str] = []
    used_chars = 0
    truncated = False
    for turn in reversed(cleaned_turns):
        candidate = turn if not selected_turns else "\n\n" + turn
        if used_chars + len(candidate) <= max_chars:
            selected_turns.append(turn)
            used_chars += len(candidate)
            continue

        truncated = True
        if not selected_turns:
            clipped = turn[:max_chars].rstrip()
            if clipped:
                selected_turns.append(clipped)
                used_chars = len(clipped)
        break

    if not selected_turns:
        return "", 0, 0, truncated

    rendered = "\n\n".join(selected_turns).rstrip()
    return rendered, len(rendered), len(selected_turns), truncated


def _render_retry_history_turn(
    *,
    attempt: int,
    note: str,
    failure_signature: str,
    patch_meta: dict[str, Any] | None,
) -> str:
    def _compact(value: Any, *, max_chars: int = 220) -> str:
        text = re.sub(r"\s+", " ", str(value)).strip()
        if len(text) <= max_chars:
            return text
        suffix = "...[truncated]"
        keep = max(0, max_chars - len(suffix))
        return text[:keep].rstrip() + suffix

    def _safe_int(value: Any) -> int:
        try:
            return int(value)
        except (TypeError, ValueError):
            return 0

    meta = patch_meta if isinstance(patch_meta, dict) else {}
    lines = [
        f"Attempt {attempt}",
        f"Outcome: {note}",
    ]
    if failure_signature:
        lines.append(f"Failure signature: {_compact(failure_signature)}")

    retry_gate_reason = str(meta.get("retry_gate_reason") or "").strip()
    if retry_gate_reason:
        if meta.get("retry_gate_blocked") is True:
            lines.append(f"Retry gate: blocked ({_compact(retry_gate_reason)})")
        else:
            lines.append(f"Retry gate: {_compact(retry_gate_reason)}")

    patcher_reason = str(meta.get("reason") or "").strip()
    if patcher_reason:
        lines.append(f"Patcher reason: {_compact(patcher_reason)}")

    patch_digest = str(meta.get("patch_diff_sha256") or "").strip()
    if patch_digest:
        patch_chars = _safe_int(meta.get("patch_diff_chars"))
        lines.append(f"Patch digest: {patch_digest} (chars={patch_chars})")

    apply_error = str(meta.get("initial_apply_error") or "").strip()
    if apply_error:
        lines.append(f"Initial apply error: {_compact(apply_error)}")

    if meta.get("runner_forced_patch_attempt"):
        lines.append("Runner forced patch attempt: true")
    if meta.get("runner_forced_patch_recovery"):
        lines.append("Runner forced patch recovery: true")
    if meta.get("retry_delta_prompt"):
        lines.append("Retry delta prompt: true")

    new_count = _safe_int(meta.get("evidence_ids_new_count"))
    reused_count = _safe_int(meta.get("evidence_ids_reused_count"))
    sent_count = _safe_int(meta.get("evidence_ids_sent_count"))
    if new_count or reused_count or sent_count:
        lines.append(f"Evidence IDs: new={new_count}, reused={reused_count}, sent={sent_count}")

    propose_sec = meta.get("propose_duration_sec")
    if isinstance(propose_sec, (int, float)):
        lines.append(f"Propose duration sec: {float(propose_sec):.3f}")
    apply_sec = meta.get("apply_duration_sec")
    if isinstance(apply_sec, (int, float)):
        lines.append(f"Apply duration sec: {float(apply_sec):.3f}")

    return "\n".join(lines).strip()


def _render_retry_packet(
    *,
    failure_signature: str,
    previous_failure_signature: Any,
    failure_signature_repeat: bool,
    previous_attempt_diff: dict[str, Any] | None,
) -> str:
    packet: dict[str, Any] = {
        "failure_signature": failure_signature,
        "previous_failure_signature": previous_failure_signature,
        "failure_signature_repeat": failure_signature_repeat,
        "previous_attempt_diff": previous_attempt_diff,
    }
    return (
        "Retry packet (delta-first, structured):\n"
        + json.dumps(packet, indent=2, ensure_ascii=False, default=str)
        + "\n"
    )


def _compute_evidence_fingerprint(
    *,
    failure_signature: str,
    stage_a_blocks: list[dict[str, Any]],
    stage_a_context: str,
    structured_seed_blocks: list[dict[str, Any]],
) -> str:
    payload = {
        "failure_signature": failure_signature,
        "stage_a_blocks": stage_a_blocks,
        "stage_a_block_ids": [
            str(block.get("id") or "")
            for block in stage_a_blocks
            if isinstance(block, dict) and block.get("id")
        ],
        "structured_seed_blocks": structured_seed_blocks,
        "stage_a_context_sha256": _sha256_text(stage_a_context or ""),
    }
    return _sha256_jsonlike(payload)


def _build_evidence_item_id(*, kind: str, locator: str, text: str) -> str:
    payload = f"{kind}|{locator}|{_sha256_text(text)}"
    return "ev_" + _sha256_text(payload)[:12]


def _build_evidence_item(
    *,
    kind: str,
    locator: str,
    text: str,
    meta: dict[str, Any],
) -> dict[str, Any]:
    evidence_id = _build_evidence_item_id(kind=kind, locator=locator, text=text)
    return {
        "id": evidence_id,
        "type": kind,
        "locator": locator,
        "text": text,
        "chars": len(text),
        **meta,
    }


def _render_evidence_items(items: list[dict[str, Any]]) -> str:
    parts: list[str] = []
    for item in items:
        text = str(item.get("text") or "")
        if text:
            parts.append(text.rstrip())
    if not parts:
        return ""
    return "\n\n".join(parts).rstrip() + "\n"


def _merge_evidence_items(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    seen: set[str] = set()
    for group in groups:
        for item in group:
            if not isinstance(item, dict):
                continue
            evidence_id = str(item.get("id") or "")
            if not evidence_id or evidence_id in seen:
                continue
            seen.add(evidence_id)
            out.append(item)
    return out


def _evidence_ids_from_items(items: list[dict[str, Any]]) -> list[str]:
    return [
        str(item.get("id") or "") for item in items if isinstance(item, dict) and item.get("id")
    ]


def execute_context_requests(
    *,
    request_payload: dict[str, Any],
    root: Path,
    snapshot: dict[str, Any] | None,
    file_index: str,
    stdout: str,
    stderr: str,
    max_requests: int,
    max_total_chars: int,
) -> StageAContextData:
    requests_raw = request_payload.get("requests")
    if not isinstance(requests_raw, list):
        return build_fallback_context_bundle(
            root=root,
            snapshot=snapshot,
            stdout=stdout,
            stderr=stderr,
            max_total_chars=max_total_chars,
            reason="malformed_stage_a_requests",
        )

    need_more_context = bool(request_payload.get("need_more_context"))
    if not need_more_context:
        return StageAContextData(
            blocks=[],
            text="",
            used_fallback=False,
            requests_count=0,
            chars_returned=0,
            request_success=True,
            request_payload=request_payload,
            request_raw=None,
            reason="sufficient_context",
        )

    allowed_paths = {line.strip() for line in file_index.splitlines() if line.strip()}
    capped = requests_raw[: max(1, max_requests)]
    blocks: list[dict[str, Any]] = []
    evidence_items: list[dict[str, Any]] = []
    seen_evidence_ids: set[str] = set()
    total_chars = 0
    accepted = 0
    max_per_block = 40_000

    for req in capped:
        if not isinstance(req, dict):
            continue
        kind = str(req.get("type") or "").strip()
        if kind == "file":
            rel_path = str(req.get("path") or "").strip()
            if not rel_path or rel_path not in allowed_paths:
                continue
            path = root / rel_path
            if not path.exists() or not path.is_file():
                continue
            try:
                text = path.read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue
            text = text[:max_per_block]
            block_text = f"--- {rel_path} ---\n{text.rstrip()}\n"
            if total_chars + len(block_text) > max_total_chars:
                break
            evidence_item = _build_evidence_item(
                kind="file",
                locator=rel_path,
                text=block_text,
                meta={"path": rel_path},
            )
            evidence_id = str(evidence_item.get("id") or "")
            if evidence_id in seen_evidence_ids:
                continue
            seen_evidence_ids.add(evidence_id)
            blocks.append(
                {
                    "id": evidence_id,
                    "type": "file",
                    "path": rel_path,
                    "chars": len(block_text),
                }
            )
            evidence_items.append(evidence_item)
            total_chars += len(block_text)
            accepted += 1
            continue

        if kind == "search":
            rel_path = str(req.get("path") or "").strip()
            pattern = str(req.get("pattern") or "")
            if not rel_path or not pattern or rel_path not in allowed_paths:
                continue
            path = root / rel_path
            if not path.exists() or not path.is_file():
                continue
            try:
                text = path.read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue
            matches = _literal_search_matches(text, pattern, max_matches=20)
            lines = [f"--- search {rel_path!s} pattern={pattern!r} ---"]
            if matches:
                lines.extend(matches)
            else:
                lines.append("(no literal matches)")
            block_text = "\n".join(lines).rstrip() + "\n"
            block_text = block_text[:max_per_block]
            if total_chars + len(block_text) > max_total_chars:
                break
            evidence_item = _build_evidence_item(
                kind="search",
                locator=f"{rel_path}:{pattern}",
                text=block_text,
                meta={"path": rel_path, "pattern": pattern, "matches": len(matches)},
            )
            evidence_id = str(evidence_item.get("id") or "")
            if evidence_id in seen_evidence_ids:
                continue
            seen_evidence_ids.add(evidence_id)
            blocks.append(
                {
                    "id": evidence_id,
                    "type": "search",
                    "path": rel_path,
                    "pattern": pattern,
                    "matches": len(matches),
                    "chars": len(block_text),
                }
            )
            evidence_items.append(evidence_item)
            total_chars += len(block_text)
            accepted += 1
            continue

        if kind == "frame":
            if not isinstance(snapshot, dict):
                continue
            idx = req.get("index")
            if not isinstance(idx, int):
                continue
            frames_obj = snapshot.get("frames")
            frames = frames_obj if isinstance(frames_obj, list) else []
            if idx < 0 or idx >= len(frames):
                continue
            frame = frames[idx]
            if not isinstance(frame, dict):
                continue
            file_rel = frame.get("file_rel", frame.get("file", "?"))
            line_no = frame.get("line", "?")
            func = frame.get("function", "?")
            code = frame.get("code", "")
            block_lines = [f"--- frame[{idx}] {file_rel}:{line_no} in {func}() ---"]
            if code:
                block_lines.append(f"code: {code}")
            locals_dict = frame.get("locals")
            if isinstance(locals_dict, dict) and locals_dict:
                block_lines.append("locals:")
                for key, value in list(locals_dict.items())[:20]:
                    if str(key).startswith("@"):
                        continue
                    try:
                        rendered_value = json.dumps(value, ensure_ascii=False, default=str)
                    except Exception:
                        rendered_value = repr(value)
                    if len(rendered_value) > 160:
                        rendered_value = rendered_value[:157] + "..."
                    block_lines.append(f"  {key} = {rendered_value}")
            block_text = "\n".join(block_lines).rstrip() + "\n"
            block_text = block_text[:max_per_block]
            if total_chars + len(block_text) > max_total_chars:
                break
            evidence_item = _build_evidence_item(
                kind="frame",
                locator=f"frame:{idx}",
                text=block_text,
                meta={"index": idx},
            )
            evidence_id = str(evidence_item.get("id") or "")
            if evidence_id in seen_evidence_ids:
                continue
            seen_evidence_ids.add(evidence_id)
            blocks.append(
                {"id": evidence_id, "type": "frame", "index": idx, "chars": len(block_text)}
            )
            evidence_items.append(evidence_item)
            total_chars += len(block_text)
            accepted += 1
            continue

    if accepted == 0:
        return build_fallback_context_bundle(
            root=root,
            snapshot=snapshot,
            stdout=stdout,
            stderr=stderr,
            max_total_chars=max_total_chars,
            reason="no_valid_stage_a_requests",
            request_payload=request_payload,
        )

    return StageAContextData(
        blocks=blocks,
        text=_render_evidence_items(evidence_items),
        used_fallback=False,
        requests_count=accepted,
        chars_returned=total_chars,
        request_success=True,
        request_payload=request_payload,
        request_raw=None,
        reason="ok",
        evidence_items=evidence_items,
    )


def build_fallback_context_bundle(
    *,
    root: Path,
    snapshot: dict[str, Any] | None,
    stdout: str,
    stderr: str,
    max_total_chars: int,
    reason: str,
    request_payload: dict[str, Any] | None = None,
    request_raw: str | None = None,
) -> StageAContextData:
    candidate_paths: list[str] = []
    crash_file = _crash_file_from_snapshot(snapshot)
    if crash_file is not None:
        candidate_paths.append(crash_file)

    failing_test = _detect_failing_test_file(stdout=stdout, stderr=stderr, root=root)
    if failing_test is not None and failing_test not in candidate_paths:
        candidate_paths.append(failing_test)

    neighbors = _import_neighbors(root=root, rel_path=crash_file, limit=8)
    for neighbor in neighbors:
        if neighbor not in candidate_paths:
            candidate_paths.append(neighbor)

    if not candidate_paths:
        candidate_paths = collect_file_index(root).splitlines()[:3]

    blocks: list[dict[str, Any]] = []
    evidence_items: list[dict[str, Any]] = []
    seen_evidence_ids: set[str] = set()
    total_chars = 0
    for rel_path in candidate_paths:
        rel = rel_path.strip()
        if not rel:
            continue
        target = root / rel
        if not target.exists() or not target.is_file():
            continue
        try:
            text = target.read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue
        block = f"--- {rel} ---\n{text.rstrip()}\n"
        if total_chars + len(block) > max_total_chars:
            break
        evidence_item = _build_evidence_item(
            kind="file",
            locator=rel,
            text=block,
            meta={"path": rel},
        )
        evidence_id = str(evidence_item.get("id") or "")
        if evidence_id in seen_evidence_ids:
            continue
        seen_evidence_ids.add(evidence_id)
        evidence_items.append(evidence_item)
        blocks.append({"id": evidence_id, "type": "file", "path": rel, "chars": len(block)})
        total_chars += len(block)

    return StageAContextData(
        blocks=blocks,
        text=_render_evidence_items(evidence_items),
        used_fallback=True,
        requests_count=len(blocks),
        chars_returned=total_chars,
        request_success=False,
        request_payload=request_payload,
        request_raw=request_raw,
        reason=reason,
        evidence_items=evidence_items,
    )


def _crash_file_from_snapshot(snapshot: dict[str, Any] | None) -> str | None:
    if not isinstance(snapshot, dict):
        return None
    frames_obj = snapshot.get("frames")
    frames = frames_obj if isinstance(frames_obj, list) else []
    for frame in frames:
        if not isinstance(frame, dict):
            continue
        rel = frame.get("file_rel")
        if isinstance(rel, str) and rel.strip():
            return rel.strip()
    return None


def _detect_failing_test_file(*, stdout: str, stderr: str, root: Path) -> str | None:
    combined = f"{stdout}\n{stderr}"
    for match in re.finditer(r"([A-Za-z0-9_./\\-]*test[A-Za-z0-9_./\\-]*\.py)", combined):
        candidate = match.group(1).replace("\\", "/")
        if not candidate:
            continue
        candidate_path = root / candidate
        if candidate_path.exists() and candidate_path.is_file():
            return candidate
    # Fallback: first local test_*.py file.
    for rel in collect_file_index(root).splitlines():
        name = Path(rel).name
        if name.startswith("test_") and rel.endswith(".py"):
            return rel
    return None


def _import_neighbors(*, root: Path, rel_path: str | None, limit: int) -> list[str]:
    if not rel_path:
        return []
    path = root / rel_path
    if not path.exists() or not path.is_file():
        return []
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except Exception:
        return []

    modules: list[str] = []
    for line in text.splitlines():
        m_from = re.match(r"\s*from\s+([A-Za-z_][A-Za-z0-9_\.]*)\s+import\s+", line)
        if m_from:
            modules.append(m_from.group(1))
            continue
        m_import = re.match(r"\s*import\s+([A-Za-z_][A-Za-z0-9_\.]*)", line)
        if m_import:
            modules.append(m_import.group(1))

    out: list[str] = []
    for module in modules:
        candidate = module.replace(".", "/") + ".py"
        candidate_path = root / candidate
        if candidate_path.exists() and candidate_path.is_file() and candidate not in out:
            out.append(candidate)
        if len(out) >= limit:
            break
    return out


def _literal_search_matches(text: str, pattern: str, *, max_matches: int) -> list[str]:
    if not pattern:
        return []
    out: list[str] = []
    for idx, line in enumerate(text.splitlines(), start=1):
        if pattern in line:
            rendered = line.rstrip()
            if len(rendered) > 220:
                rendered = rendered[:217] + "..."
            out.append(f"{idx}: {rendered}")
            if len(out) >= max_matches:
                break
    return out


def apply_diff(cwd: Path, diff: str) -> tuple[bool, str]:
    path_ok, path_error = _validate_diff_paths_within_cwd(cwd, diff)
    if not path_ok:
        return False, path_error

    # Prefer POSIX patch so paths are resolved relative to `cwd` (not git toplevel).
    patch = shutil.which("patch")
    if patch:
        try:
            proc = subprocess.run(
                [patch, "-p1", "--batch", "--forward"],
                input=diff,
                text=True,
                cwd=cwd,
                capture_output=True,
                timeout=PATCH_APPLY_TIMEOUT_SEC,
            )
        except subprocess.TimeoutExpired:
            return False, f"patch timed out after {PATCH_APPLY_TIMEOUT_SEC}s"
        if proc.returncode == 0:
            return True, ""
        msg = (proc.stderr or proc.stdout or "").strip()
        relaxed_ok, relaxed_msg = _apply_relaxed_replacements(cwd, diff)
        if relaxed_ok:
            return True, "applied via relaxed replacement fallback"
        return False, f"patch failed: {msg}; relaxed fallback failed: {relaxed_msg}"

    # Fallback to git apply when patch utility is unavailable.
    git = shutil.which("git")
    if git:
        try:
            proc = subprocess.run(
                [git, "apply", "--whitespace=nowarn", "-"],
                input=diff,
                text=True,
                cwd=cwd,
                capture_output=True,
                timeout=PATCH_APPLY_TIMEOUT_SEC,
            )
        except subprocess.TimeoutExpired:
            return False, f"git apply timed out after {PATCH_APPLY_TIMEOUT_SEC}s"
        if proc.returncode == 0:
            return True, ""
        msg = (proc.stderr or proc.stdout or "").strip()
        return False, f"git apply failed: {msg}"

    return False, "No patch tool found (git or patch required)."


def _extract_diff_paths(diff: str) -> list[str]:
    paths: list[str] = []
    for raw_line in diff.splitlines():
        line = raw_line.strip()
        if line.startswith("diff --git "):
            tokens = line.split()
            if len(tokens) >= 4:
                for token in (tokens[2], tokens[3]):
                    normalized = _normalize_diff_path_token(token)
                    if normalized is not None:
                        paths.append(normalized)
            continue
        if line.startswith("--- ") or line.startswith("+++ "):
            token = line[4:].strip().split()[0] if line[4:].strip() else ""
            normalized = _normalize_diff_path_token(token)
            if normalized is not None:
                paths.append(normalized)
    seen: set[str] = set()
    unique: list[str] = []
    for path in paths:
        if path in seen:
            continue
        seen.add(path)
        unique.append(path)
    return unique


def _normalize_diff_path_token(token: str) -> str | None:
    if not token:
        return None
    if token in {"a/", "b/"}:
        return None
    if token.startswith("a/") or token.startswith("b/"):
        token = token[2:]
    return token


def _is_safe_relative_path(path: str) -> bool:
    if not path or path == "/dev/null":
        return False
    normalized = path.replace("\\", "/")
    pure = PurePosixPath(normalized)
    if pure.is_absolute():
        return False
    parts = pure.parts
    if not parts:
        return False
    return not any(part in {"", ".", ".."} for part in parts)


def _validate_diff_paths_within_cwd(cwd: Path, diff: str) -> tuple[bool, str]:
    paths = _extract_diff_paths(diff)
    if not paths:
        return False, "patch rejected: no file paths found in diff"
    root = cwd.resolve()
    for raw_path in paths:
        if raw_path == "/dev/null":
            return False, "patch rejected: /dev/null paths are not supported by eval harness"
        if not _is_safe_relative_path(raw_path):
            return False, f"patch rejected: unsafe path {raw_path!r}"
        resolved = (root / raw_path).resolve()
        try:
            resolved.relative_to(root)
        except Exception:
            return False, f"patch rejected: path escapes workdir {raw_path!r}"
    return True, ""


def _apply_relaxed_replacements(cwd: Path, diff: str) -> tuple[bool, str]:
    """Best-effort fallback for near-correct model diffs with bad hunk context.

    Supports only line-for-line replacements extracted from unified hunks.
    """
    replacements: dict[str, list[tuple[str, str, list[str], list[str]]]] = {}
    current_path: str | None = None
    lines = diff.splitlines()
    i = 0
    while i < len(lines):
        raw_line = lines[i]
        if raw_line.startswith("+++ b/"):
            current_path = raw_line[6:].strip()
            i += 1
            continue
        if raw_line.startswith("@@"):
            if current_path is None:
                i += 1
                continue
            hunk_lines: list[str] = []
            i += 1
            while i < len(lines):
                nxt = lines[i]
                if (
                    nxt.startswith("@@")
                    or nxt.startswith("diff --git ")
                    or nxt.startswith("--- a/")
                    or nxt.startswith("+++ b/")
                ):
                    break
                hunk_lines.append(nxt)
                i += 1
            replacements.setdefault(current_path, []).extend(
                _extract_relaxed_pairs_from_hunk(hunk_lines)
            )
            continue
        i += 1

    if not replacements:
        return False, "no replaceable line pairs found"

    root = cwd.resolve()
    pending_updates: list[tuple[Path, str]] = []
    for rel_path, pairs in replacements.items():
        file_path = (cwd / rel_path).resolve()
        try:
            file_path.relative_to(root)
        except Exception:
            return False, f"path outside working directory: {rel_path}"
        if not file_path.exists():
            return False, f"file not found: {rel_path}"
        text = file_path.read_text(encoding="utf-8")
        had_newline = text.endswith("\n")
        line_values = text.splitlines()

        changed = False
        for old_line, new_line, before_ctx, after_ctx in pairs:
            matching_indices: list[int] = []
            for idx, existing_line in enumerate(line_values):
                if existing_line != old_line:
                    continue
                if before_ctx:
                    start = idx - len(before_ctx)
                    if start < 0 or line_values[start:idx] != before_ctx:
                        continue
                if after_ctx:
                    end = idx + 1 + len(after_ctx)
                    if end > len(line_values) or line_values[idx + 1 : end] != after_ctx:
                        continue
                matching_indices.append(idx)

            if len(matching_indices) == 0:
                return (
                    False,
                    f"could not find context-anchored target in {rel_path!r}: {old_line!r}",
                )
            if len(matching_indices) > 1:
                return False, f"ambiguous relaxed replacement target in {rel_path!r}: {old_line!r}"
            line_values[matching_indices[0]] = new_line
            changed = True

        if changed:
            updated = "\n".join(line_values)
            if had_newline:
                updated += "\n"
            pending_updates.append((file_path, updated))

    for file_path, updated in pending_updates:
        file_path.write_text(updated, encoding="utf-8")

    return True, ""


def _extract_relaxed_pairs_from_hunk(
    hunk_lines: list[str],
) -> list[tuple[str, str, list[str], list[str]]]:
    """Extract replaceable `-`/`+` pairs with nearby context anchors."""
    out: list[tuple[str, str, list[str], list[str]]] = []
    recent_context: list[str] = []
    i = 0
    while i < len(hunk_lines):
        line = hunk_lines[i]
        if line.startswith(" "):
            recent_context.append(line[1:])
            if len(recent_context) > 3:
                recent_context.pop(0)
            i += 1
            continue
        if line.startswith("-") and i + 1 < len(hunk_lines) and hunk_lines[i + 1].startswith("+"):
            old_line = line[1:]
            new_line = hunk_lines[i + 1][1:]
            after_ctx: list[str] = []
            j = i + 2
            while j < len(hunk_lines) and hunk_lines[j].startswith(" ") and len(after_ctx) < 2:
                after_ctx.append(hunk_lines[j][1:])
                j += 1
            before_ctx = recent_context[-2:] if recent_context else []
            out.append((old_line, new_line, before_ctx, after_ctx))
            i += 2
            continue
        i += 1
    return out


def _build_runner_forced_attempt_diff(
    *, cwd: Path, file_index: str, attempt: int
) -> tuple[str, str] | None:
    """Build a deterministic minimal patch when a patcher returns no diff."""
    candidates = [line.strip() for line in file_index.splitlines() if line.strip()]
    if not candidates:
        return None

    target_rel = _select_forced_patch_target(candidates)
    if target_rel is None:
        return None

    target_path = (cwd / target_rel).resolve()
    try:
        target_path.relative_to(cwd.resolve())
    except Exception:
        return None
    if not target_path.exists():
        return None

    before = target_path.read_text(encoding="utf-8")
    marker_base = f"# llmdebug-force-attempt-{attempt}"
    marker = marker_base
    suffix = 2
    while marker in before:
        marker = f"{marker_base}-{suffix}"
        suffix += 1
    sep = "" if before.endswith("\n") else "\n"
    after = before + f"{sep}{marker}\n"
    if after == before:
        return None

    diff = _build_diff_from_changes([(target_rel, before, after)])
    if not diff.strip():
        return None
    return target_rel, diff


def _select_forced_patch_target(candidates: list[str]) -> str | None:
    target_rel: str | None = None
    for rel in candidates:
        if not rel.endswith(".py"):
            continue
        lower = rel.lower()
        name = Path(rel).name.lower()
        if name.startswith("test_") or "/test" in lower or "\\test" in lower:
            continue
        target_rel = rel
        break
    if target_rel is not None:
        return target_rel
    for rel in candidates:
        if rel.endswith(".py"):
            return rel
    return None


def _build_diff_from_changes(changes: list[tuple[str, str, str]]) -> str:
    parts: list[str] = []
    for path, before, after in changes:
        udiff = difflib.unified_diff(
            before.splitlines(),
            after.splitlines(),
            fromfile=f"a/{path}",
            tofile=f"b/{path}",
            lineterm="",
        )
        file_lines = list(udiff)
        if not file_lines:
            continue
        parts.append(f"diff --git a/{path} b/{path}")
        parts.extend(file_lines)
        if parts and parts[-1] != "":
            parts.append("")
    return "\n".join(parts).rstrip("\n") + "\n"


def collect_text_context(root: Path, *, max_total_chars: int = 200_000) -> str:
    """Collect small text files from a case directory for LLM context."""
    max_total_bytes = max_total_chars
    max_file_bytes = 50_000
    include_suffixes = {".py", ".txt", ".json", ".md"}
    exclude_dirs = {".llmdebug", "__pycache__", ".venv", ".git"}

    parts: list[str] = []
    total = 0

    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        if path.suffix not in include_suffixes:
            continue
        if any(p in exclude_dirs for p in path.parts):
            continue
        try:
            rel = path.relative_to(root)
        except Exception:
            rel = path
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue

        if len(text) > max_file_bytes:
            text = text[:max_file_bytes] + "\n...[TRUNC]\n"

        block = f"--- {rel} ---\n{text.rstrip()}\n"
        if total + len(block) > max_total_bytes:
            break
        parts.append(block)
        total += len(block)

    return "\n".join(parts).rstrip() + ("\n" if parts else "")


def collect_file_index(root: Path) -> str:
    include_suffixes = {".py", ".txt", ".json", ".md"}
    exclude_dirs = {".llmdebug", "__pycache__", ".venv", ".git"}
    paths: list[str] = []
    for path in sorted(root.rglob("*")):
        if not path.is_file() or path.suffix not in include_suffixes:
            continue
        if any(p in exclude_dirs for p in path.parts):
            continue
        try:
            rel = str(path.relative_to(root))
        except Exception:
            rel = str(path)
        paths.append(rel)
    return "\n".join(paths)


if __name__ == "__main__":
    raise SystemExit(main())
