Give the review gauntlet a deterministic retry loop for lanes that FAULT (PIPELINE-FAULT marker, missing verdict sentinel, or a nested graph's own degraded-run wording) without ever re-running a lane that reached a real verdict. - graph.yaml: the four lane maps route to a new `retry_gate` script node (static `next: verdict_gate`; emits `_next: build_items` to retry). initial_state gains lane_attempts / kept_results / retry_lanes / review_incomplete / gauntlet_started_at. settings.timeout 24000 -> 69600 (3 passes x 2 attempts x 11400 + 1200 margin). - scripts/retry_gate.py (new): stashes every lane report that ran (completed and faulted alike) and restores reports a re-run pass overwrote with []; classifies with verdict_gate.py's sentinel regexes verbatim; counts attempts; retries while attempts < 3 and inside a 45600 s wall-clock budget; otherwise records the lane in review_incomplete and falls through. Crash guard appends to signals_error and never retries. - scripts/signals.py: stamps gauntlet_started_at before the git work so a failed diff still starts the clock. - scripts/build_items.py: retry pass emits items only for retry_lanes (with attempt numbers), adds a `retried:` note to lanes_summary, resets retry_lanes; falsy-guarded stamp fallback; crash guard appends to signals_error and preserves lanes_summary. - Tests: hermetic python3-guarded script tests for retry_gate / build_items / signals, graph wiring + initial_state pins, sentinel and LANE_KEYS cross-pins, retry-budget envelope vs settings.timeout, a first-pass golden payload, and an engine-level executor test proving a script `_next` back-edge re-fires a static fan-out and an empty map re-run clears its collect_into. Plan: PLAN-review-retry section A items 1-3 and 5. Verdict/report surface (section A items 4 and 6) is TASK-032.
107 lines
3.8 KiB
Python
107 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic diff signals for the review gauntlet.
|
|
|
|
Pure git — no LLM judgment. Every signal is computed from the changed-file
|
|
list and the added lines of the diff. Failures never sink the gauntlet:
|
|
on any error the script reports `signals_error` and leaves the defaults
|
|
(false/empty) in place, which biases lane selection toward the caller's
|
|
context and forced lanes.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import time
|
|
|
|
def load_state():
|
|
if path := os.environ.get("GRAPH_STATE_FILE"):
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
|
|
|
|
|
state = load_state()
|
|
proj = os.path.expanduser(
|
|
(state.get("project_dir_in") or "").strip() or state.get("project_dir") or "."
|
|
)
|
|
spec = (state.get("diff_spec") or "worktree").strip()
|
|
|
|
|
|
def git(*args):
|
|
r = subprocess.run(
|
|
["git", "-C", proj, *args], capture_output=True, text=True, timeout=90
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError((r.stderr or r.stdout).strip()[:400])
|
|
return r.stdout
|
|
|
|
|
|
out = {"project_dir": proj}
|
|
# retry_gate measures its wall-clock budget from this stamp; it is set
|
|
# before the git work so a failed diff still starts the clock.
|
|
if not state.get("gauntlet_started_at"):
|
|
out["gauntlet_started_at"] = time.time()
|
|
try:
|
|
if spec in ("", "worktree"):
|
|
names = git("diff", "--name-only", "HEAD")
|
|
difftext = git("diff", "HEAD")
|
|
else:
|
|
names = git("diff", "--name-only", spec)
|
|
difftext = git("diff", spec)
|
|
files = [f.strip() for f in names.splitlines() if f.strip()]
|
|
added = "\n".join(
|
|
l for l in difftext.splitlines() if l.startswith("+") and not l.startswith("+++")
|
|
)
|
|
|
|
def any_path(pattern):
|
|
return any(re.search(pattern, f, re.I) for f in files)
|
|
|
|
out.update(
|
|
{
|
|
"changed_files": files[:200],
|
|
"file_count": len(files),
|
|
"touches_proto": any_path(r"\.proto$|buf\.(yaml|gen\.yaml)$"),
|
|
"touches_migrations": any_path(r"migrations?/|\.sql$"),
|
|
# auth(?!or) admits auth/authn/authz/oauth but not "author".
|
|
# Over-firing here is the safe direction: it only ADDS a lane.
|
|
"touches_auth": any_path(
|
|
r"auth(?!or)|login|logout|token|session|permission|rbac|\bacl\b|credential|secret"
|
|
)
|
|
or bool(
|
|
re.search(
|
|
r"auth(?!or)|password|token|secret|credential|jwt|session",
|
|
added,
|
|
re.I,
|
|
)
|
|
),
|
|
"touches_deps": any_path(
|
|
r"go\.(mod|sum)$|package(-lock)?\.json$|yarn\.lock$|pnpm-lock\.yaml$"
|
|
r"|Cargo\.(toml|lock)$|requirements[^/]*\.txt$|pyproject\.toml$"
|
|
r"|Gemfile(\.lock)?$|pom\.xml$|build\.gradle|composer\.(json|lock)$"
|
|
),
|
|
"touches_exec": bool(
|
|
re.search(
|
|
r"exec\.Command|subprocess|os/exec|Runtime\.getRuntime"
|
|
r"|shell_exec|\beval\(|\bsystem\(|child_process",
|
|
added,
|
|
)
|
|
),
|
|
"consumer_surface": any_path(
|
|
r"routes?|handlers?|\bapi\b|endpoints?|\bcmd/|\bcli\b|\.proto$"
|
|
r"|openapi|swagger|controllers?|graphql"
|
|
),
|
|
"docs_only": bool(files)
|
|
and all(re.search(r"\.(md|rst|txt|adoc)$", f, re.I) for f in files),
|
|
"signals_summary": f"{len(files)} file(s) changed (diff: {spec})",
|
|
}
|
|
)
|
|
except Exception as e: # noqa: BLE001 — degraded signals must not sink the gauntlet
|
|
out["signals_error"] = (
|
|
f"NOTE: diff signals could not be computed ({e}); "
|
|
"lane selection falls back to caller context and forced lanes."
|
|
)
|
|
out["signals_summary"] = "signals unavailable"
|
|
|
|
print(json.dumps(out))
|