Files
cleveragents-core/tools/controller/worker/gate.py
T
drew a103a31bbf feat(controller): worker-owned gated push for the implementer
The implementer agent no longer pushes to git — the controller worker
now owns the push: it gates the agent's commits on lint+typecheck and
pushes via a single leased primitive. Closes two production defects:

- Clobber: the pre-fix MCP --force-with-lease leased against a
  freshly-fetched tip, so the lease always passed — an in-flight
  implementer destroyed a commit pushed to the PR branch during its
  run (lost a hand-pushed skip_coverage fix on PR #46).
- Gate-skip: the agent verified only the CI-flagged gate, so a fix for
  one gate shipped fresh violations in another (lint flapped
  pass->fail across CI runs 198->199).

Step 1 — worker_push primitive:
- New git_push.py: one leased push, pinned to the SHA the worker
  started from; classifies pushed / stale_input / diverged /
  infra_error; bounded infra-retry.
- mcp_git_server.push gains expected_sha for a correctly-pinned lease.
- finalize_conflict_resolution migrated onto worker_push.

Step 2 — deterministic gate:
- New gate.py: per-slot nox env-dirs (no venv races), manifest-hash
  staleness keying, lazy warm-up.
- local_ci_gate.sh gains --envdir.

Step 3 — worker-owned gated push:
- New implementer_finalize.py: divergence pre-check -> lint+typecheck
  gate -> ruff auto-fix -> leased push. finalize's outcome is
  authoritative over the agent's emitted outcome.
- agent_runner integrates finalize; salvage no longer pushes.
- outcomes/tick: gate-failed + push-time stale-input caps,
  epoch-scoped; WorkerError carries an output_payload so the gate
  report reaches the next attempt's prompt; prefetch surfaces
  gate-failed attempts.
- The 5 task-implementor prompts drop the agent push step.

Reviewed across 4 adversarial rounds; full controller+MCP suite green
(1379 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:38:52 -04:00

231 lines
7.5 KiB
Python

"""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"
),
)
)
def _gate_script() -> str:
"""Resolve ``local_ci_gate.sh``: an explicit override, the seeded
worker copy, or the in-repo copy."""
override = os.environ.get("CONTROLLER_LOCAL_CI_GATE")
if override:
return override
seeded = "/tmp/local_tools/tools/local_ci_gate.sh"
if Path(seeded).is_file():
return seeded
# tools/controller/worker/gate.py -> repo root is parents[3].
return str(
Path(__file__).resolve().parents[3] / "tools" / "local_ci_gate.sh"
)
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",
]