Files
cleveragents-core/tools/_implementer_gate_preflight.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

335 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Heavy flaky-test pre-flight for the implementer dispatcher.
Runs ``local_ci_gate.sh --fast`` against the pre-cloned worktree
TWICE and classifies failures as persistent (failed both runs)
vs. flaky (passed the second run). The persistent-failure list is
then surfaced in the worker prompt via
:mod:`_diff_aware_gate.render_prompt_stanza`, split by
related-to-diff vs unrelated-to-diff buckets.
Motivation (plan §"#1 Heavy", 2026-05-13): in production on
2026-05-12, gpt-5-mini at Tier 0 ran ``--fast`` once on PR #30,
observed six CLI-feature scenarios failing in unit_tests, decided
they were "probably flaky or environmental" but unrelated to the
LangGraph fix in the diff — and then **gave up**. Kimi (Tier 2)
ran the SAME ``--fast`` seven hours later and got all four gates
PASS. The dispatcher running ``--fast`` twice up front would have
caught the flakiness deterministically; gpt-5-mini would have
either seen empty failures (flakes self-cleared on retry) OR seen
a clean "unrelated to your diff" classification and known not to
bail.
Gating:
- Always-off by default (no behaviour change).
- Activated via ``IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT=1``.
- Independent of the in-cycle escalation flag; this is a strict
upgrade to the worker's pre-fetched context that has no
byte-equivalence concerns of its own (it ADDS a prompt stanza
rather than modifying existing ones).
Cost:
- Two ``local_ci_gate.sh --fast`` invocations per dispatched PR
cycle. First-cold-cache run is ~5 min (per the R1 entry in
CHANGELOG); second is warm-cache, typically <1 min. Net
pre-flight overhead: ~6 min per cycle. Buys back significantly
more (Tier 0 attempts no longer bail on flaky unrelated tests).
The module has NO I/O of its own beyond ``subprocess.run`` calls
on the operator's local checkout — no Forgejo calls, no model
calls. Easy to unit-test by stubbing out the runner via the
``runner`` parameter on :func:`run_preflight`.
"""
from __future__ import annotations
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Callable
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import ( # noqa: E402 type: ignore[import-not-found]
load_sibling as _load_sibling,
)
_diff_aware_gate = _load_sibling("_diff_aware_gate", "_diff_aware_gate.py")
_logger = logging.getLogger("implementer_gate_preflight")
# Env var controlling whether the pre-flight runs at all. Default
# OFF so the dispatcher's pre-feature behaviour is byte-equivalent.
# Set ``IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT=1`` to activate.
#
# **Independent of ``IMPLEMENTER_ESCALATION_ENABLED``.** The
# preflight runs whenever this flag is on, regardless of escalation
# state — the result lands in the PR-context sentinel for the
# worker to read on its first turn, which is useful even outside
# the escalation loop. Operational consequence: enabling preflight
# alone (without escalation) adds 2× ``local_ci_gate.sh --fast``
# wallclock (~6 min cold-cache, ~1 min warm) to every dispatched
# cycle. Enable both together unless you have a specific reason to
# decouple them.
#
# **Heartbeat / watchdog implication:** the preflight runs inside
# the dispatcher's ``prompt_factory`` before
# :func:`_dispatch_runtime._refresh_heartbeat` is established for
# the cycle. The cold-cache ~6-min window is silent from the
# systemd watchdog's perspective and stacks with the existing
# prefetch + preclone pre-heartbeat wallclock. Operators enabling
# this flag should ensure the dispatcher's systemd watchdog
# interval comfortably exceeds the worst-case pre-heartbeat
# budget — bumping ``WatchdogSec`` to 15-20 min when this flag is
# on is the conservative move.
PREFLIGHT_ENABLED_ENV_VAR = "IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT"
# Per-invocation timeout for ``local_ci_gate.sh --fast``. 20 minutes
# matches the R1 ``timeout: 1200000`` recommendation baked into
# the ``quality-gates`` skill — the first cold-cache run can
# legitimately take ~5 min and we want to avoid spurious aborts.
_DEFAULT_GATE_TIMEOUT_SECONDS = 1200
# Wrapper-relative path to the gate script (matches what the worker
# would also use). ``local_ci_gate.sh`` is in the repo's ``tools/``
# dir and the dispatcher's CWD is the repo root, but to keep this
# robust against different CWDs we resolve it at call time.
_GATE_SCRIPT = "tools/local_ci_gate.sh"
def is_preflight_enabled() -> bool:
"""Return ``True`` when the dispatcher should run the gate
pre-flight. Off-by-default."""
raw = os.environ.get(PREFLIGHT_ENABLED_ENV_VAR, "").strip().lower()
return raw in {"1", "true", "yes", "on"}
_TRUTHY = frozenset({"1", "true", "yes", "on"})
# Magic returncode sentinel for the timeout case. Distinct from
# the generic non-zero codes the gate script itself emits on test
# failure (it uses 1 / 2) AND from the negative signal-handling
# codes Python's ``subprocess`` returns when the child dies from a
# signal (e.g. ``-15`` for SIGTERM, ``-9`` for SIGKILL — values in
# roughly ``-1..-64``). ``-9999`` is well outside that range so a
# real subprocess can never produce it, eliminating the collision
# risk of the earlier ``-1`` sentinel. The orchestrator
# (:func:`run_preflight`) checks ``rc == _TIMEOUT_RETURNCODE`` to
# surface ``preflight_timeout=True`` in the classification rather
# than treating timeout-with-no-parsed-failures as "all green".
_TIMEOUT_RETURNCODE = -9999
def _run_gate_once(
worktree: Path,
runner: Callable[[list[str], dict[str, str], int], subprocess.CompletedProcess]
| None = None,
*,
extra_env: dict[str, str] | None = None,
) -> tuple[int, str]:
"""Run ``local_ci_gate.sh --fast`` once against ``worktree``.
Returns ``(returncode, combined_stdout_stderr)``. Caller decides
what to do with the result — this function does not raise on
non-zero exit codes (the gate exits 1 on test failure, which is
the case we WANT to capture and parse).
On :class:`subprocess.TimeoutExpired` the function returns
``(_TIMEOUT_RETURNCODE, "<TIMEOUT after Xs>")``. The marker line
is what :func:`run_preflight` keys off to set
``preflight_timeout=True`` in the classification — without it,
the classifier saw no parseable failures in the timeout text
and (incorrectly) reported "no failures persisted", giving the
worker a silent green light on a wedged gate run.
The ``runner`` indirection exists so unit tests can substitute
a function that returns scripted output without invoking the
real script.
"""
cmd = [_GATE_SCRIPT, "--fast", "--repo-root", str(worktree)]
env = {**os.environ, **(extra_env or {})}
if runner is not None:
result = runner(cmd, env, _DEFAULT_GATE_TIMEOUT_SECONDS)
# subprocess.CompletedProcess fields: returncode + stdout/stderr
out = (result.stdout or "") + (result.stderr or "")
return result.returncode, out
started = time.monotonic()
try:
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=_DEFAULT_GATE_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired:
elapsed = time.monotonic() - started
_logger.warning(
"gate pre-flight timed out after %.1fs running %s",
elapsed,
" ".join(cmd),
)
return _TIMEOUT_RETURNCODE, f"<TIMEOUT after {elapsed:.1f}s>"
return result.returncode, (result.stdout or "") + (result.stderr or "")
def _failed_scenario_keys(gate_output: str) -> set[tuple[str, str]]:
"""Helper: extract the ``(path, line)`` set of failing scenarios
so two runs can be intersected. Mirrors
:func:`_diff_aware_gate.parse_failing_scenarios` but returns a
set for membership testing."""
failures = _diff_aware_gate.parse_failing_scenarios(gate_output)
return {(f["path"], f["line"]) for f in failures}
def run_preflight(
worktree: Path,
changed_files: list[str],
*,
runner: Callable[[list[str], dict[str, str], int], subprocess.CompletedProcess]
| None = None,
) -> dict[str, object]:
"""Run the gate twice, build a persistent-failure classification.
Returns the dict shape :func:`_diff_aware_gate.classify_failures`
produces, but with an additional ``"runs"`` key carrying per-run
summaries:
.. code-block:: python
{
"gate_statuses": {...},
"failures_total": int,
"failures_related_to_diff": int,
"failures_unrelated_to_diff": int,
"related": [...],
"unrelated": [...],
"runs": [
{"run": 1, "returncode": int, "failures": int},
{"run": 2, "returncode": int, "failures": int},
],
"preflight_enabled": True,
}
Persistence rule: a scenario is "persistent" if it appears in
the failure list of BOTH runs (same ``(path, line)`` key).
Scenarios that fail run 1 but pass run 2 are considered flaky
and are NOT surfaced to the agent.
When run 1 already passes (returncode 0, no failures), run 2 is
skipped — the gates passed and there's nothing to re-verify.
"""
if not is_preflight_enabled():
return {
"preflight_enabled": False,
"failures_total": 0,
"failures_related_to_diff": 0,
"failures_unrelated_to_diff": 0,
"related": [],
"unrelated": [],
"gate_statuses": {},
"runs": [],
}
rc1, out1 = _run_gate_once(worktree, runner=runner)
failures1 = _failed_scenario_keys(out1)
run_summaries: list[dict[str, object]] = [
{"run": 1, "returncode": rc1, "failures": len(failures1)}
]
timed_out_run1 = rc1 == _TIMEOUT_RETURNCODE
# ─── Timeout short-circuit ─────────────────────────────────
# When the first run timed out we cannot deterministically
# classify failures (the output is a marker line, not gate
# text). Returning the empty classification would render as
# "no persistent failures" to the worker — a silent green
# light. Instead, surface ``preflight_timeout=True`` so the
# renderer warns the worker that classification is unreliable.
# Counts/lists are explicitly zeroed so the renderer cannot
# show a contradictory "Persistent failures: N" line next to
# the timeout warning — the classification IS suppressed in
# this branch, and the payload should say so unambiguously.
if timed_out_run1:
return {
"runs": run_summaries,
"preflight_enabled": True,
"preflight_timeout": True,
"gate_statuses": {},
"failures_total": 0,
"failures_related_to_diff": 0,
"failures_unrelated_to_diff": 0,
"related": [],
"unrelated": [],
}
if rc1 == 0 and not failures1:
# All gates passed on the first attempt — no need to re-run.
classification = _diff_aware_gate.classify_failures(out1, changed_files)
classification.update({"runs": run_summaries, "preflight_enabled": True})
return classification
rc2, out2 = _run_gate_once(worktree, runner=runner)
failures2 = _failed_scenario_keys(out2)
run_summaries.append({"run": 2, "returncode": rc2, "failures": len(failures2)})
timed_out_run2 = rc2 == _TIMEOUT_RETURNCODE
if timed_out_run2:
# Run 1 produced parseable output but run 2 timed out — we
# can't intersect to find persistent failures, so we treat
# the whole pre-flight as inconclusive (same reasoning as
# the run-1 timeout branch). Surface run-1's gate_statuses
# roll-up (it IS real data) but suppress the failure
# counts: without a second run we cannot distinguish flakes
# from persistent failures, and emitting the run-1 list as
# if it were persistent would mislead the worker. The
# ``preflight_timeout`` flag + zeroed counts give the
# renderer one unambiguous story.
run1_classification = _diff_aware_gate.classify_failures(out1, changed_files)
return {
"runs": run_summaries,
"preflight_enabled": True,
"preflight_timeout": True,
"gate_statuses": run1_classification.get("gate_statuses") or {},
"failures_total": 0,
"failures_related_to_diff": 0,
"failures_unrelated_to_diff": 0,
"related": [],
"unrelated": [],
}
# Persistent = present in BOTH runs. Track flake count
# (failed run 1, passed run 2) so the worker / telemetry can
# see how much the dispatcher filtered out.
persistent = failures1 & failures2
flakes_filtered = len(failures1 - failures2)
synthetic_lines = [f" {p}:{n}" for (p, n) in sorted(persistent)]
# Preserve the gate-status header from the second run's output
# so the classifier still emits a gate_statuses summary. The
# gate emits ``## [unit_tests] PASS (12s)`` lines (see
# _GATE_STATUS_RE in _diff_aware_gate.py); we filter out
# non-status lines while keeping ``## [gate]`` rows.
statuses_text = "\n".join(
line for line in (out2 or "").splitlines() if line.lstrip().startswith("## [")
)
synthetic_output = (
statuses_text
+ "\n\nFailing scenarios (persistent across two runs):\n"
+ "\n".join(synthetic_lines)
)
classification = _diff_aware_gate.classify_failures(synthetic_output, changed_files)
classification.update(
{
"runs": run_summaries,
"preflight_enabled": True,
"flakes_filtered": flakes_filtered,
}
)
return classification