Files
cleveragents-core/tools/_implementer_gate_preflight.py
T
drew 6315892eb8 feat(auto-agents): sentinel-routed deterministic sections + masking hedge
Five rounds of fresh-eyes review against the b154d480 deterministic
worker-side improvements landed four substantive corrections:

- Sentinel handoff for compliance + preflight (workers read off
  disk via implementer_pr_context.py --field; survives tier-agent
  prompt summarisation)
- _GATE_STATUS_RE fixed to match real `## [unit_tests] PASS (12s)`
  output from local_ci_gate.sh (previously zero gates parsed)
- Preflight timeout returns explicit sentinel (-9999) and the
  orchestrator zeros counts so renderer can't show "Persistent
  failures: N" alongside the timeout warning
- outcome_synthesised plumbed per-attempt through phase 4 telemetry

Plus a fresh-eyes catch: check_worktree_clean / check_commit_has_issues_closed
return True on git rc != 0 (deliberate "don't conflate masking with
real gap"), but a fully-masked worktree coincidentally with valid
CHANGELOG + CONTRIBUTORS would have produced a confident "PR resolved"
verdict. New check_compliance_gaps_with_masking + masked_checks plumb
through the sentinel; both renderers (module-level + dispatcher's
pointer) hedge the verdict; worker docs instruct inspection of
masked_checks before exiting resolved.

Two new env flags, both default-ON kill-switches:
- IMPLEMENTER_OUTCOME_SYNTHESIS (=0 reverts to UNKNOWN-bucket)
- IMPLEMENTER_COMPLIANCE_GAPS_ENABLED (=0 disables compliance scan;
  AND-coupled with escalation since compliance is meaningless
  outside the gap-filling flow)

Byte-equivalence holds when all new flags are unset. Test suite
1432 passed, 3 skipped (+~70 new tests across compliance, masking,
synthesis-across-loop, sentinel round-trip, regex fixes, fixtures).

ISSUES CLOSED: #30

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

338 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