981ddd6a8e
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>
254 lines
8.6 KiB
Python
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",
|
|
]
|