Files
cleveragents-core/tools/_implementer_gate_preflight.py
T
drew b154d48027 feat(auto-agents): deterministic worker-side improvements
Four changes that move judgment off the LLM and into the dispatcher,
motivated by the live PR #30 escalation pilot (2026-05-12): Tier 0
spent 16 min and gave up on flaky-looking unrelated tests; Tier 2
spent 88 min discovering the PR was already correct and only needed
two compliance entries. Together these collapse the typical
"PR is correct, only needs compliance fixups" case from 88 min on
Kimi to ~5 min on gpt-5-mini at Tier 0.

- Outcome-JSON synthesis in dispatch_implementer
  (_synthesize_outcome_if_missing): synthesises a concrete outcome
  from terminal_state when the worker emits no contract JSON,
  routing the escalation predicate to ESCALATE instead of UNKNOWN.

- Diff-aware gate parser (_diff_aware_gate.py): pure-Python
  classifier that splits failing BDD scenarios into related vs.
  unrelated to the PR's changed files via a feature-stem heuristic.

- Flaky-test pre-flight (_implementer_gate_preflight.py): runs
  local_ci_gate.sh --fast twice and surfaces only the persistent
  failures. Off-by-default behind IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT.

- Compliance gap detector (_implementer_compliance.py): deterministic
  check of CHANGELOG / CONTRIBUTORS / commit-footer / worktree-clean
  state. Result is embedded as a "Compliance gap report" stanza so
  the agent fills in known gaps instead of discovering them.

Both prompt stanzas are appended via _append_deterministic_stanzas,
flag-gated, skipped in dry-run, and skipped when no preclone exists.
Flag-off path is byte-equivalent to the pre-feature build.

Suite: 1382 passed, 3 skipped (+59 new tests over 4 modules).

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

243 lines
9.0 KiB
Python

"""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.
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"})
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).
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 -1, 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)}
]
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)}
)
# Persistent = present in BOTH runs. Synthesize a synthetic
# "gate output" containing only the persistent failures so the
# classifier doesn't accidentally see flaky ones too. The
# synthetic output uses the same shape parse_failing_scenarios
# consumes (``features/path.feature:N``).
persistent = 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.
statuses_text = "\n".join(
line for line in (out2 or "").splitlines()
if any(g in line.lower() for g in (
"lint:", "typecheck:", "unit_tests:",
"integration_tests:", "e2e_tests:", "coverage:",
))
)
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}
)
return classification