"""Deterministic lint + typecheck gate for the implementer worker. The worker runs this gate on the agent's committed worktree BEFORE pushing (controller spec v3). Two design points: - **Per-slot env-dirs.** Each concurrent worker slot gets its own nox env-dir, so two slots never share — and never race on — a nox venv. The env-dir root is stable across worker-process restarts (so the cold venv build is a genuine one-time cost), and an operator can point separate worker processes at separate roots via ``CONTROLLER_GATE_ENV_ROOT``. - **Manifest-hash staleness.** An env-dir is rebuilt only when the dependency manifests that feed it change — ``lint`` keys on the noxfile alone (ruff is standalone); ``typecheck`` additionally keys on ``pyproject.toml`` / ``uv.lock`` because pyright resolves the installed project. A PR that doesn't touch deps reuses the warm env. Only the cheap, deterministic, never-flaky gates run here. Unit / integration tests stay with the agent's cooperative ``--fast`` sweep and with Forgejo CI — hard-blocking a push on a flaky test would be worse than the disease. """ from __future__ import annotations import hashlib import os import shutil import subprocess import threading from dataclasses import dataclass from pathlib import Path from typing import Literal GateStatus = Literal["pass", "fail", "error"] # The gate sequence run before every implementer push, cheapest first. GATE_SEQUENCE: tuple[str, ...] = ("lint", "typecheck") # Files whose content keys an env-dir's staleness. _GATE_KEY_FILES: dict[str, tuple[str, ...]] = { "lint": ("noxfile.py",), "typecheck": ("noxfile.py", "pyproject.toml", "uv.lock"), } _KEY_SENTINEL = ".cleveragents-gate-key" @dataclass(frozen=True) class GateResult: """Outcome of :func:`run_gates`. - ``pass`` — every gate ran clean. - ``fail`` — a gate ran and found violations (``failing_gate`` is the offender, ``raw_tail`` its output). The agent's code is bad. - ``error`` — a gate could not run at all (infra: no nox, timeout, bad worktree). NOT the agent's fault. """ status: GateStatus failing_gate: str | None raw_tail: str exit_code: int def _env_root() -> Path: """Stable root for nox env-dirs. Survives worker restarts so the cold build is one-time; operators give distinct worker processes distinct roots via the env var for multi-process safety.""" return Path( os.environ.get( "CONTROLLER_GATE_ENV_ROOT", str( Path.home() / ".cache" / "cleveragents-controller" / "gate-envs" ), ) ) # Candidate locations for the ``local_ci_gate.sh`` harness, in the # preference order :func:`_gate_script` applies. Module-level so tests # can substitute them. # # tools/controller/worker/gate.py -> repo root is parents[3]. _IN_REPO_GATE_SCRIPT = ( Path(__file__).resolve().parents[3] / "tools" / "local_ci_gate.sh" ) _SEEDED_GATE_SCRIPT = Path("/tmp/local_tools/tools/local_ci_gate.sh") def _gate_script() -> str: """Resolve the ``local_ci_gate.sh`` harness to invoke. Resolution order: an explicit ``CONTROLLER_LOCAL_CI_GATE`` override, then the in-repo copy, then the seeded ``/tmp/local_tools`` copy. The in-repo copy is preferred because ``gate.py`` and ``local_ci_gate.sh`` are co-evolved — ``run_gates`` invokes the harness with ``--gate`` / ``--repo-root`` / ``--envdir``, flags only the version-matched copy is guaranteed to accept. The seeded ``/tmp/local_tools`` copy is refreshed solely by the retired ``dispatch_implementer.py`` path, so the controller pipeline never updates it; preferring it ran every gate against a stale harness (the PR-46 incident — a seed predating the ``--envdir`` flag made the harness reject its args, erroring every committing implementer's gate). The seeded copy survives only as a last-resort fallback for an unusual install layout where the ``__file__``-relative repo copy is absent. """ override = os.environ.get("CONTROLLER_LOCAL_CI_GATE") if override: return override if _IN_REPO_GATE_SCRIPT.is_file(): return str(_IN_REPO_GATE_SCRIPT) return str(_SEEDED_GATE_SCRIPT) def _manifest_key(worktree: Path, gate: str) -> str: """Hash the manifests that determine ``gate``'s env-dir contents.""" h = hashlib.sha256() for name in _GATE_KEY_FILES.get(gate, ()): h.update(name.encode()) h.update(b"\0") p = worktree / name if p.is_file(): h.update(p.read_bytes()) h.update(b"\0") return h.hexdigest() def slot_envdir(slot_index: int, gate: str) -> Path: """Per-slot, per-gate nox env-dir path.""" return _env_root() / f"slot-{slot_index}" / f"{gate}-env" def _tail(text: str, n: int = 80) -> str: return "\n".join((text or "").splitlines()[-n:]) def run_gates( worktree: Path, *, slot_index: int, gates: tuple[str, ...] = GATE_SEQUENCE, timeout: float = 600.0, _run=subprocess.run, ) -> GateResult: """Run the pre-push gate sequence against ``worktree``. Returns on the FIRST gate that fails (exit 1) or errors (exit 2+); a clean pass runs every gate. ``slot_index`` selects a per-slot env-dir, so two concurrent worker slots never share a nox venv. """ worktree = Path(worktree) script = _gate_script() for gate in gates: envdir = slot_envdir(slot_index, gate) key = _manifest_key(worktree, gate) sentinel = envdir / _KEY_SENTINEL # Staleness: an env-dir built against different manifests is # wiped so nox rebuilds it fresh. if envdir.exists(): prior = ( sentinel.read_text().strip() if sentinel.is_file() else "" ) if prior != key: shutil.rmtree(envdir, ignore_errors=True) envdir.parent.mkdir(parents=True, exist_ok=True) cmd = [ "bash", script, "--gate", gate, "--repo-root", str(worktree), "--envdir", str(envdir), ] try: proc = _run(cmd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired as exc: return GateResult( "error", gate, _tail((exc.stdout or "") + (exc.stderr or "")), 124, ) combined = (proc.stdout or "") + (proc.stderr or "") rc = proc.returncode if rc in (0, 1) and envdir.is_dir(): # The gate RAN (venv built) whether it passed or failed — # record the manifest key so a same-manifest re-run reuses # this env-dir. sentinel.write_text(key) if rc == 0: continue if rc == 1: return GateResult("fail", gate, _tail(combined), 1) # exit 2+ — the gate could not run (no noxfile/nox, bad args). # An infra failure, never the agent's fault. return GateResult("error", gate, _tail(combined), rc) return GateResult("pass", None, "", 0) # ─── per-slot index allocation ─────────────────────────────────────── # # Each in-flight gate run reserves a distinct slot index so two # concurrent worker executions never share — and never race on — a nox # env-dir. The pool hands out the lowest free integer; max_concurrent # is not needed (the pool self-sizes to the live concurrency). _slot_lock = threading.Lock() _slots_in_use: set[int] = set() def acquire_slot() -> int: """Reserve the lowest free per-slot env-dir index (concurrency-safe). Always pair with :func:`release_slot` in a ``finally``.""" with _slot_lock: i = 0 while i in _slots_in_use: i += 1 _slots_in_use.add(i) return i def release_slot(index: int) -> None: """Release a slot index reserved by :func:`acquire_slot`.""" with _slot_lock: _slots_in_use.discard(index) def lint_ruff(slot_index: int) -> Path | None: """Path to the ruff binary in this slot's lint nox env-dir, or None if that env-dir has not been built yet. The auto-fix pass uses this so it runs the SAME pinned ruff the ``lint`` gate runs.""" candidate = slot_envdir(slot_index, "lint") / "lint" / "bin" / "ruff" return candidate if candidate.is_file() else None __all__ = [ "GATE_SEQUENCE", "GateResult", "GateStatus", "acquire_slot", "lint_ruff", "release_slot", "run_gates", "slot_envdir", ]