Files
cleveragents-core/tools/controller/master/ci_gate.py
T
drew 6ab6df319c feat(controller): indeterminate-CI verdict for no-verdict (OOM) failures
Diagnosed live on PR 39 (STUCK 2026-05-20): both CI gates ran 17
minutes, the captured log was <3 minutes and ended mid-execution
(`still running` / a just-launched runner) with ZERO error markers.
A hard process kill (OOM-killer / pod eviction) cannot flush a
buffer, print a traceback or emit an exit code — it leaves a *hole*,
not a phrase. The classifier called this `fresh_real`, the implementer
was sent to "fix" a failure with nothing to fix, and the PR
dead-ended at blocked → STUCK.

New `indeterminate` verdict: a failing gate whose FULL log carries no
terminal verdict marker (`##[error]`, test summary, Traceback, exit
code) AND ends mid-execution. Both conditions required — "no marker"
alone over-fires on a real failure whose tool output isn't in the
marker set (e.g. `ruff format`). Routed to a bounded rerun, same as
`infra_broken`/`stale`, via `ci_status_poll` and `ci_gate`.

Also fixes an infra-signature regression exposed by the full-log
change: the bare step-name signatures (`git fetch`, `Set up job`,
`actions/checkout`) matched the checkout/setup preamble of EVERY job
log — against a full untruncated log they turned every failing run
into `infra_broken`. Replaced with genuine error-text signatures
(`could not read from remote`, `download action repository failed`,
`unable to access`, `failed to connect`).

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

547 lines
20 KiB
Python

"""CI-freshness gate — the early DISCOVERED tick.
THE INCIDENT this addresses
---------------------------
A PR was discovered with 2-day-stale CI where every job had failed at
the *checkout* step (a ``git fetch`` connection-reset — pure infra, no
code ever ran). The pipeline had no notion of CI freshness: the
workflow went DISCOVERED → ANALYZING → IMPLEMENTING → STUCK, burning
an estimator + a tier-2 implementer attempt before the implementer
correctly said "nothing to fix, rerun CI" and dead-ended. The
controller never *triggers* CI — it only polls.
This tick is the fix. It runs BEFORE the normal DISCOVERED → ANALYZING
promotion (``promote.py``). For each ``kind='pr'`` workflow in
``DISCOVERED``:
1. Fetch the PR's head SHA + the CI combined status.
2. When the combined status shows a failure, fetch the failing jobs'
CI **log content** via the injected ``get_failure_logs`` callback
(backed by ``_ci_logs``). The infra-vs-real decision needs the
actual log text — Forgejo status descriptions are generic duration
strings and never carry the error.
3. Classify via ``ci_freshness.classify_ci_result``:
- ``fresh_real`` / ``pending`` → leave it alone; ``promote.py``
handles the normal path.
- ``infra_broken`` / ``stale`` / ``no_ci`` / ``indeterminate`` →
trigger a CI rerun (empty-commit push). ``indeterminate`` is a
failing run whose full log carries no verdict marker — a
hard-kill / OOM that never produced a result. On success: stash
the new head SHA, fire
``discovery_ci_rerun_triggered`` (DISCOVERED → AWAITING_CI) via a
TOCTOU-guarded UPDATE, and write a ``ci-rerun-triggered`` event.
3. RERUN BUDGET = 3: before triggering, count prior
``ci-rerun-triggered`` events for the workflow. At >= 3, do NOT
rerun again — route the workflow to STUCK (the CI runner itself is
broken; an operator must investigate).
Both this tick and ``promote.py`` filter ``current_state='DISCOVERED'``
with guarded UPDATEs, so they're race-safe even out of order — but the
loop orders this one FIRST so a stale-CI workflow is diverted before
promotion ever fires.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import text
from sqlalchemy.engine import Engine
from ..db.session import session_scope
from ..state_machine import IllegalTransitionError, apply_event
from .ci_freshness import (
GetFailureLogsCallback,
classify_ci_result,
combined_status_is_failure,
)
from .ci_rerun import CIRerunCallback, CIRerunResult
logger = logging.getLogger(__name__)
# Maximum number of automatic CI reruns per workflow before the gate
# gives up and routes the workflow to STUCK for operator attention.
RERUN_BUDGET = 3
# controller_events.event_type written when a rerun is triggered. The
# rerun-budget counter scans for this exact value, and ci_status_poll
# reads the latest such row's payload to recover the gate-pushed SHA.
CI_RERUN_EVENT_TYPE = "ci-rerun-triggered"
CI_RERUN_BUDGET_EXHAUSTED_EVENT_TYPE = "ci-rerun-budget-exhausted"
# get_ci_status(owner, repo, head_sha) -> Forgejo combined-status dict
# or None on fetch failure.
GetCIStatusCallback = Callable[[str, str, str], dict | None]
# get_pr_details(owner, repo, pr_number) -> Forgejo PR dict or None.
GetPRDetailsCallback = Callable[[str, str, int], dict | None]
@dataclass
class CIGateReport:
"""Per-sweep summary of the CI-freshness gate."""
workflows_scanned: int = 0
# fresh / pending — left for the normal promote path.
workflows_left_for_promote: int = 0
# rerun triggered + routed DISCOVERED → AWAITING_CI.
workflows_reran: int = 0
# rerun budget hit — routed DISCOVERED → STUCK.
workflows_stuck_budget: int = 0
# rerun attempt failed (clone/push error) — left in DISCOVERED.
workflows_rerun_failed: int = 0
# could not fetch PR details / head SHA — left in DISCOVERED.
workflows_fetch_failed: int = 0
# (workflow_id, verdict, action) for observability/tests.
actions: list[tuple[int, str, str]] = field(default_factory=list)
def _pr_head(pr: dict | None) -> tuple[str | None, str | None]:
"""Extract (head_sha, head_ref) from a Forgejo PR dict."""
if not isinstance(pr, dict):
return None, None
head = pr.get("head")
if not isinstance(head, dict):
return None, None
sha = head.get("sha")
ref = head.get("ref")
return (
sha if isinstance(sha, str) and sha else None,
ref if isinstance(ref, str) and ref else None,
)
def _count_prior_reruns(session, workflow_id: int) -> int:
"""Count the ``ci-rerun-triggered`` events already recorded for a
workflow — the rerun-budget counter."""
row = session.execute(
text(
"SELECT COUNT(*) AS n FROM controller_events "
"WHERE workflow_id = :wf_id AND event_type = :etype"
),
{"wf_id": workflow_id, "etype": CI_RERUN_EVENT_TYPE},
).first()
return int(row.n) if row is not None else 0
def _guarded_transition(
session,
*,
workflow_id: int,
event: str,
now: datetime,
) -> str | None:
"""Apply ``event`` from DISCOVERED via a TOCTOU-guarded UPDATE.
Returns the new state on success, or None when the row is no
longer DISCOVERED (race lost) / the event is illegal.
"""
try:
new_state = apply_event("DISCOVERED", event)
except (IllegalTransitionError, ValueError) as exc:
logger.warning(
"ci_gate: apply_event(DISCOVERED, %s) rejected for "
"workflow %s (%s); skipping",
event,
workflow_id,
exc,
)
return None
result = session.execute(
text(
"UPDATE workflows SET "
" current_state = :to_state, "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id "
" AND current_state = 'DISCOVERED'"
),
{"to_state": new_state, "now": now, "wf_id": workflow_id},
)
if (result.rowcount or 0) == 0:
logger.info(
"ci_gate: workflow %s no longer DISCOVERED (race lost); skipping %s",
workflow_id,
event,
)
return None
return new_state
def _write_event(
session,
*,
workflow_id: int,
event_type: str,
from_state: str,
to_state: str,
now: datetime,
payload: dict,
) -> None:
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, from_state, to_state, "
" payload, forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, :etype, :from_state, :to_state, "
" :payload, 0, 0)"
),
{
"wf_id": workflow_id,
"ts": now,
"etype": event_type,
"from_state": from_state,
"to_state": to_state,
"payload": json.dumps(payload),
},
)
def _fetch_failure_logs(
get_failure_logs: GetFailureLogsCallback | None,
*,
owner: str,
repo: str,
head_sha: str,
wf_id: int,
) -> str:
"""Best-effort fetch of the failing jobs' CI log content.
Returns the concatenated log text, or an empty string when no
fetcher is wired / the fetch fails. Never raises — a log-fetch
failure must not abort the gate; the classifier degrades an
un-loggable failure to ``fresh_real`` (conservative).
"""
if get_failure_logs is None:
return ""
try:
text_out = get_failure_logs(owner, repo, head_sha)
except Exception as exc: # noqa: BLE001 — never abort the tick
logger.warning(
"ci_gate: get_failure_logs raised for workflow %s (head=%s): %s",
wf_id,
head_sha[:12],
exc,
)
return ""
return text_out if isinstance(text_out, str) else ""
def run_ci_gate_tick(
engine: Engine,
*,
owner: str,
repo: str,
get_ci_status: GetCIStatusCallback,
get_pr_details: GetPRDetailsCallback,
trigger_ci_rerun: CIRerunCallback,
get_failure_logs: GetFailureLogsCallback | None = None,
rerun_budget: int = RERUN_BUDGET,
) -> CIGateReport:
"""One sweep: divert stale/infra-broken DISCOVERED PRs.
For each ``kind='pr'`` workflow in ``DISCOVERED`` for this
(owner, repo): classify CI freshness and either leave it for the
normal promote path, trigger a CI rerun (→ AWAITING_CI), or — once
the rerun budget is exhausted — route it to STUCK.
``get_failure_logs`` is the CI job-log fetcher (backed by
``_ci_logs`` in production). The gate calls it only when the
combined status shows a failure, and feeds the result to
``classify_ci_result`` so the infra-vs-real decision runs against
real log content rather than the generic status descriptions.
When it is not wired (older callers / a transient failure), the
classifier falls back to ``fresh_real`` for a failure it cannot
confirm is infra — conservative by design.
All callbacks are injected so tests run without a live Forgejo /
git. Callback exceptions are caught and treated as "fetch failed"
(the workflow is left in DISCOVERED for the next tick / retry).
"""
report = CIGateReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
rows = session.execute(
text(
"SELECT workflow_id, current_state, entity_number "
" FROM workflows "
" WHERE current_state = 'DISCOVERED' "
" AND kind = 'pr' "
" AND owner = :owner AND repo = :repo"
),
{"owner": owner, "repo": repo},
).all()
report.workflows_scanned = len(rows)
for row in rows:
wf_id = row.workflow_id
pr_number = row.entity_number
# 1. Fetch PR head SHA + branch.
try:
pr = get_pr_details(owner, repo, pr_number)
except Exception as exc: # noqa: BLE001 — transient
logger.warning(
"ci_gate: get_pr_details failed for workflow %s (PR #%s): %s",
wf_id,
pr_number,
exc,
)
report.workflows_fetch_failed += 1
report.actions.append((wf_id, "unknown", "fetch-failed"))
continue
head_sha, head_ref = _pr_head(pr)
if not head_sha:
logger.warning(
"ci_gate: workflow %s (PR #%s) has no head SHA; "
"leaving in DISCOVERED",
wf_id,
pr_number,
)
report.workflows_fetch_failed += 1
report.actions.append((wf_id, "unknown", "fetch-failed"))
continue
# 2. Fetch the CI combined status for the head SHA.
try:
ci_status = get_ci_status(owner, repo, head_sha)
except Exception as exc: # noqa: BLE001 — transient
logger.warning(
"ci_gate: get_ci_status failed for workflow %s (head=%s): %s",
wf_id,
head_sha[:12],
exc,
)
# A fetch failure is itself "no CI verdict available".
# Be conservative: treat as fetch-failed (retry next
# tick) rather than forcing a rerun on transient noise.
report.workflows_fetch_failed += 1
report.actions.append((wf_id, "unknown", "fetch-failed"))
continue
# Fetch the failing jobs' CI log content — only when the
# combined status actually shows a failure (a green/pending
# PR has nothing to scan). The classifier scans this text
# for infra signatures; without it an infra failure would
# be misclassified as fresh_real (the incident's bug).
failure_logs = ""
if combined_status_is_failure(ci_status):
failure_logs = _fetch_failure_logs(
get_failure_logs,
owner=owner,
repo=repo,
head_sha=head_sha,
wf_id=wf_id,
)
classification = classify_ci_result(
head_sha=head_sha,
forgejo_status=ci_status,
failure_logs=failure_logs,
)
verdict = classification.verdict
# fresh_real / pending → leave for promote.py.
if verdict in {"fresh_real", "pending"}:
report.workflows_left_for_promote += 1
report.actions.append((wf_id, verdict, "left-for-promote"))
continue
# infra_broken / stale / no_ci / indeterminate → CI needs
# re-triggering.
prior_reruns = _count_prior_reruns(session, wf_id)
# 3. Rerun budget gate. DISCOVERED has no state-machine
# event mapping straight to STUCK (a broken CI runner is a
# runner-health condition, not a workflow-logic
# transition), so route via a guarded raw UPDATE.
if prior_reruns >= rerun_budget:
new_state = _force_stuck(
session,
workflow_id=wf_id,
now=now,
)
if new_state is None:
# Race lost — another tick moved the row.
report.actions.append((wf_id, verdict, "budget-race-lost"))
continue
_write_event(
session,
workflow_id=wf_id,
event_type=CI_RERUN_BUDGET_EXHAUSTED_EVENT_TYPE,
from_state="DISCOVERED",
to_state=new_state,
now=now,
payload={
"reason": (
"CI infra still failing after "
f"{prior_reruns} reruns — operator must "
"investigate the runner"
),
"verdict": verdict,
"classification_reason": classification.reason,
"prior_reruns": prior_reruns,
"rerun_budget": rerun_budget,
"head_sha": head_sha,
"matched_signatures": classification.matched_signatures,
"source": "ci_gate",
},
)
report.workflows_stuck_budget += 1
report.actions.append((wf_id, verdict, "stuck-budget"))
logger.warning(
"ci_gate: workflow %s (PR #%s) STUCK — CI infra "
"still failing after %d reruns",
wf_id,
pr_number,
prior_reruns,
)
continue
# 4. Trigger a CI rerun (empty-commit push).
if not head_ref:
logger.warning(
"ci_gate: workflow %s (PR #%s) has no head branch "
"ref; cannot push an empty commit",
wf_id,
pr_number,
)
report.workflows_rerun_failed += 1
report.actions.append((wf_id, verdict, "rerun-failed"))
continue
try:
rerun: CIRerunResult = trigger_ci_rerun(
owner,
repo,
head_ref,
)
except Exception as exc: # noqa: BLE001 — never abort tick
logger.warning(
"ci_gate: trigger_ci_rerun raised for workflow %s (PR #%s): %s",
wf_id,
pr_number,
exc,
)
report.workflows_rerun_failed += 1
report.actions.append((wf_id, verdict, "rerun-failed"))
continue
if not rerun.ok or not rerun.new_head_sha:
logger.warning(
"ci_gate: CI rerun failed for workflow %s (PR #%s): %s",
wf_id,
pr_number,
rerun.error,
)
report.workflows_rerun_failed += 1
report.actions.append((wf_id, verdict, "rerun-failed"))
continue
# Rerun succeeded — route DISCOVERED → AWAITING_CI.
new_state = _guarded_transition(
session,
workflow_id=wf_id,
event="discovery_ci_rerun_triggered",
now=now,
)
if new_state is None:
report.actions.append((wf_id, verdict, "rerun-race-lost"))
continue
_write_event(
session,
workflow_id=wf_id,
event_type=CI_RERUN_EVENT_TYPE,
from_state="DISCOVERED",
to_state=new_state,
now=now,
payload={
"reason": "discovery_ci_rerun_triggered",
"verdict": verdict,
"classification_reason": classification.reason,
"matched_signatures": classification.matched_signatures,
"failing_contexts": classification.failing_contexts,
# The OLD head SHA the classifier saw + the NEW one
# the empty-commit push created. ci_status_poll
# reads ``new_head_sha`` to know which commit's CI
# to poll (the workflow has no implementer attempt).
"stale_head_sha": head_sha,
"new_head_sha": rerun.new_head_sha,
"head_ref": head_ref,
"rerun_number": prior_reruns + 1,
"source": "ci_gate",
},
)
report.workflows_reran += 1
report.actions.append((wf_id, verdict, "rerun-triggered"))
logger.info(
"ci_gate: workflow %s (PR #%s) — %s CI; triggered "
"rerun #%d (new HEAD %s), routed to AWAITING_CI",
wf_id,
pr_number,
verdict,
prior_reruns + 1,
rerun.new_head_sha[:12],
)
if report.workflows_reran or report.workflows_stuck_budget:
logger.info(
"ci_gate: %d scanned (reran=%d, stuck-budget=%d, "
"left-for-promote=%d, rerun-failed=%d, fetch-failed=%d)",
report.workflows_scanned,
report.workflows_reran,
report.workflows_stuck_budget,
report.workflows_left_for_promote,
report.workflows_rerun_failed,
report.workflows_fetch_failed,
)
return report
def _force_stuck(
session,
*,
workflow_id: int,
now: datetime,
) -> str | None:
"""Route a DISCOVERED workflow directly to STUCK via a guarded
UPDATE.
DISCOVERED has no state-machine event mapping straight to STUCK
(the budget-exhausted case is an operator-attention condition the
transition table doesn't model — it's a runner-health problem,
not a workflow-logic transition). We therefore do a guarded raw
UPDATE, exactly the TOCTOU pattern ``promote.py`` uses, and pair
it with an audit event so the trail stays intact.
"""
result = session.execute(
text(
"UPDATE workflows SET "
" current_state = 'STUCK', "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id "
" AND current_state = 'DISCOVERED'"
),
{"now": now, "wf_id": workflow_id},
)
if (result.rowcount or 0) == 0:
return None
return "STUCK"
__all__ = [
"CI_RERUN_BUDGET_EXHAUSTED_EVENT_TYPE",
"CI_RERUN_EVENT_TYPE",
"CIGateReport",
"GetCIStatusCallback",
"GetPRDetailsCallback",
"RERUN_BUDGET",
"run_ci_gate_tick",
]