a103a31bbf
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>
231 lines
7.5 KiB
Python
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",
|
|
]
|