Files
cleveragents-core/tools/controller/worker/gate.py
T
drew 981ddd6a8e fix(controller): incident hardening — PR-44 dispute guard + PR-46 fixes
Bundles fixes for three production incidents (PR-44, PR-46 a/b/c).
Every change has an incident-reference comment in the code and a
named regression test. 199 tests pass on the impacted modules.

PR-44 — fabricated dispute escape
---------------------------------
A tier-0 implementer, bounced twice by red CI, emitted a fabricated
``dispute-reviewer`` outcome and shortcut a red PR into REVIEWING →
APPROVED → MERGING, bypassing the CI gate. ``dispute-reviewer`` is
the only IMPLEMENTING → REVIEWING edge that doesn't pass through
AWAITING_CI, so it must be defended.

tools/controller/master/outcomes.py: ``_map_implementer_outcome``
now guards ``dispute-reviewer`` with two preconditions —
(1) ``attempt_saw_green_ci`` (the HEAD must already be CI-verified;
a dispute can't jump the gate on a red/pending head), and
(2) ``prior_reviews >= 1`` (must reference a review that actually
happened, not a hallucinated one). Either guard fails →
``implementer_competence_failure`` → tier escalation. A weak model
can't game its way past CI; a stronger tier is given the real problem.

tools/controller/master/tick.py: new ``_count_prior_reviews`` helper
counts completed reviewer attempts (epoch-scoped so an
``operator_unstick`` resets the count). Wired into the
``map_outcome_to_event`` call.

PR-46(a) — stale gate-script preferred over in-repo
---------------------------------------------------
``gate.py`` was preferring the seeded ``/tmp/local_tools`` copy of
``local_ci_gate.sh`` over the version-matched in-repo copy. The
seed predated the ``--envdir`` flag; the controller pipeline's
invocations rejected as bad-argv every committing implementer's
gate. The seed-refresher (``dispatch_implementer.py``) is on the
retired dispatcher path, so the staleness was permanent.

tools/controller/worker/gate.py: resolution order is now
``CONTROLLER_LOCAL_CI_GATE`` env > in-repo > seeded ``/tmp``. The
seeded copy survives only as a last-resort fallback. Module-level
constants ``_IN_REPO_GATE_SCRIPT`` / ``_SEEDED_GATE_SCRIPT`` let
tests substitute paths.

PR-46(b) — 6-second-old run flagged zombie
------------------------------------------
``classify_ci_run`` instantly classified a CI run as ``stale`` when
the Actions API reported no active task. A freshly-pushed run has
no task simply because no runner has picked it up yet, and there
are brief gaps between jobs — both false positives. A 6-second-old
PR-46 run was bounced before CI could even start.

tools/controller/master/ci_run_status.py: new ``ZOMBIE_GRACE``
(default 3 min, env: ``CONTROLLER_CI_ZOMBIE_GRACE_MIN``). "No
active task" only classifies a run as stale once the run has ALSO
gone quiet past the grace. Much shorter than ``STALE_AFTER`` since
the active-task absence is corroborating evidence, not the sole
signal.

PR-46(c) — ruff-format-only violation slipping through lint
-----------------------------------------------------------
CI's ``lint`` job runs both the ``lint`` nox session (ruff check)
AND ``ruff format --check``. The pre-push local gate only ran the
former; a formatting-only violation passed pre-push then failed CI.

tools/local_ci_gate.sh: the ``lint`` gate now runs ``ruff check``
followed by ``ruff format --check``, unconditional. Either one
failing marks the gate red. ``ruff format --check`` is whole-repo
and takes no posargs. tests/auto_agents/test_local_ci_gate.py
updated for the new two-call shape.

Supporting changes
------------------
.forgejo/workflows/ci.yml: gates ``coverage`` and ``docker`` jobs
on repo variables ``skip_coverage`` / ``skip_docker`` so the long
reaper-prone jobs can be skipped per-fork without editing CI.
Guard step always runs so the job still reports ``success`` and
``status-check`` stays green.

tools/duplicate_prs_to_fork.py: bakes the same ``skip_coverage`` /
``skip_docker`` gates into every sentinel PR's ci.yml at PR-creation
time so fork-mode runs inherit the gating. Idempotent.

.opencode/opencode.json: adds ``timeout: 1860000`` (31 min) to the
``ci`` MCP server so long CI waits don't timeout the tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:32:35 -04:00

254 lines
8.6 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"
),
)
)
# 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",
]