Files
cleveragents-core/tools/controller/master/ci_freshness.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

643 lines
27 KiB
Python

"""Deterministic CI-freshness classifier.
Pure functions, no I/O — fully unit-testable. Consumes the Forgejo
combined-CI-status data the controller already fetches via
``get_ci_status`` (``{"state": "...", "sha": "...", "statuses":
[{"context": "...", "state": "...", "description": "...",
"target_url": "..."}]}``) for the *structural* decision (pass / fail /
pending / SHA-match), and the failing jobs' actual **CI log text** for
the infra-vs-real classification. It decides whether a Discovered PR's
CI is a real verdict, infrastructure-broken, missing, or still
running.
THE INCIDENT this addresses
---------------------------
A PR was discovered with 2-day-stale CI where every job failed at the
*checkout* step (a ``git fetch`` connection-reset — pure infra, no
code ever ran). Nothing in the pipeline distinguished that from a
real code failure: the implementer correctly said "nothing to fix,
rerun CI", emitted ``outcome=blocked``, and dead-ended at STUCK after
burning an estimator + a tier-2 implementer attempt.
This classifier is the missing signal. It is timestamp-light: the
load-bearing detection is (1) infra-class failure-signature matching
on the failing jobs' **log content**, and (2) head-SHA matching (CI
that ran on a different/older commit is not a verdict for the current
HEAD).
Why log content, not status descriptions
----------------------------------------
Real Forgejo commit-status ``description`` fields are generic — the
incident's failed jobs reported literally ``"Failing after 1m47s"``,
``"Failing after 2s"``, etc. The actual infra error (``curl 56 Recv
failure: Connection reset by peer``, ``fatal: expected 'packfile'``,
``RPC failed``) lives ONLY in the CI **job logs**. So the
infra-signature scan runs against the ``failure_logs`` text the caller
fetches via ``_ci_logs`` (the controller's existing CI-log
machinery), not against the status ``description``/``context``
strings.
Verdicts
--------
- ``infra_broken`` — CI ran and failed but the failure is
checkout/setup-class: ZERO structured code findings AND the failing
jobs' log content contains a setup/checkout failure signature
(``curl 56``, ``expected 'packfile'``, ``could not read from
remote``, connection reset, ...). Precedent:
``_is_infrastructure_error()`` in the Robot listener and the
``NoParserAvailable`` path in ``ci_summary_parsers``.
- ``stale`` — CI ran on the current head SHA and FAILED, but its
newest status is older than ``CONTROLLER_CI_MAX_AGE_S`` (default 6h)
and no infra signature could be confirmed. An old failure cannot be
trusted — Forgejo may have purged its logs, and an old infra blip is
indistinguishable from an old real failure. Timestamp-based, so it
works even when log fetching is unavailable. Routed to a rerun, same
as ``infra_broken``.
- ``indeterminate`` — CI ran on the current head SHA and FAILED, but
the failing jobs' FULL log content carries **no terminal verdict
marker** at all: no ``##[error]``, no test-runner result summary, no
``Traceback``, no ``nox`` failure line, no exit-code line. The log
simply stops mid-execution. That is the signature of a hard process
kill (OOM-killer / pod eviction / SIGKILL) — a killed process cannot
flush a buffer, print a stack trace, or emit an exit code, so it
leaves a *hole*, not a phrase. Diagnosed live on PR 39 (2026-05-20):
both gates ran 17 minutes, the log captured <3 minutes and ended on
``still running`` / a just-launched runner, with zero error markers.
This is NOT a real code failure (there is nothing to fix — sending an
implementer dead-ends it at ``blocked`` → STUCK) and NOT a confirmed
``infra_broken`` (no network signature). Routed to a *bounded* rerun,
same as ``infra_broken``/``stale``. Detection requires the FULL
untruncated log (the unified ``get_ci_logs`` bundle) — a head-
truncated log would spuriously look marker-less.
- ``no_ci`` — no CI status exists for the current head SHA at all
(empty statuses), or every status is for a different/older commit
(SHA mismatch).
- ``pending`` — CI is currently running/queued.
- ``fresh_real`` — CI ran on the current head SHA with a real result
(success, or a failure whose logs carry no infra signature, or a
failure with real structured code findings). The normal case; the
regular promote-to-ANALYZING path handles it. Conservative default:
an unrecognized failure is ``fresh_real`` so a genuine bug is never
rerun-looped.
"""
from __future__ import annotations
import os
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
# ─── infra-class failure signatures ──────────────────────────────────
# Substrings that, when present in a failing job's LOG TEXT, indicate
# a checkout / job-setup class failure rather than a code failure.
# Matched case-insensitively. Kept deliberately conservative: only
# well-known infra indicators — an unknown failure is assumed to be a
# real code failure (and classified ``fresh_real``).
#
# These are scanned against the CI job-log content the caller fetches
# (via ``_ci_logs``), NOT the Forgejo status description (which is a
# generic duration string like "Failing after 1m47s" and never
# carries the error text).
#
# Every entry is genuine ERROR TEXT — a string that appears ONLY when
# something actually failed. Bare step *names* (``git fetch``, ``Set
# up job``, ``actions/checkout``) were removed (2026-05-20): they
# appear in the checkout/setup preamble of EVERY job log, pass or
# fail, so against a full untruncated log (the unified ``get_ci_logs``
# bundle) they matched every failing run indiscriminately — turning
# genuine code failures into ``infra_broken`` rerun loops. A checkout
# that fails for an infra reason always emits one of the actual
# transport errors below.
#
# Sources:
# - ``curl 56`` / ``expected 'packfile'`` / ``could not read from
# remote`` / ``unable to access`` — the exact git-over-HTTPS
# transport errors a reset connection produces during a clone/fetch.
# - ``download action repository failed`` — Forgejo failed to fetch a
# composite action before any user code ran.
# - connection reset/refused/timeout/resolve — generic network infra.
INFRA_FAILURE_SIGNATURES: tuple[str, ...] = (
"curl 56",
"expected 'packfile'",
"could not read from remote",
"unable to access",
"download action repository failed",
"checkout failed",
"connection reset",
"connection refused",
"connection timed out",
"could not resolve host",
"failed to connect",
"early eof",
"rpc failed",
"the remote end hung up",
"tls handshake",
"runner is offline",
"no space left on device",
)
# ─── terminal verdict markers ────────────────────────────────────────
#
# A failing CI job whose FULL log contains AT LEAST ONE of these has
# produced a real verdict — a test runner, a linter, or the Forgejo
# runner itself reported an error. A failing job whose full log
# contains NONE of them never reached a verdict: its process was
# hard-killed (OOM-killer / pod eviction / SIGKILL), which cannot
# flush a buffer, print a stack trace, or emit an exit code. That is
# the ``indeterminate`` case (diagnosed live on PR 39, 2026-05-20).
#
# This is an ABSENCE test, so it is only sound against a FULL,
# untruncated log (the unified ``get_ci_logs`` bundle). A head-
# truncated log would drop early markers — but the markers below all
# live at the TAIL of a real failure, exactly the region tail-
# truncation keeps, so even a legacy tail is reasonably safe.
_TERMINAL_MARKER_SUBSTRINGS: tuple[str, ...] = (
"##[error]", # Forgejo Actions step-failure annotation
"traceback (most recent call last)", # Python exception
"short test summary info", # pytest summary header
"process completed with exit code", # runner step result line
"the operation was canceled", # step timeout / cancellation
"error:", # mypy / compiler / linter error line
"assertionerror", # an assertion fired
)
# Regex forms of the same idea — compiled case-insensitive.
_TERMINAL_MARKER_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"nox > command .+ failed", re.I), # nox session failed
re.compile( # behave summary line ("1 feature passed, 0 failed")
r"\b\d+\s+(feature|scenario|step)s?\s+(passed|failed)", re.I
),
re.compile(r"\bfailed\s+robot\.", re.I), # robot test-failure line
re.compile( # robot / pabot run summary ("10 tests, 8 passed")
r"\b\d+\s+tests?,\s*\d+\s+(passed|failed)", re.I
),
re.compile(r"\bexit code\s+[1-9]", re.I), # non-zero exit code
re.compile(r"=+\s*\d+\s+(failed|error)", re.I), # pytest "=== N failed"
re.compile(r"\b\d+\s+failed\b", re.I), # test-count summary ("1 failed")
re.compile(r"\bfound\s+\d+\s+error", re.I), # ruff ("Found 3 errors")
)
def _log_has_terminal_marker(text: str) -> bool:
"""True when ``text`` contains any terminal verdict marker — i.e.
CI actually reported a result. ``False`` means the log carries no
verdict at all (a candidate for the ``indeterminate`` case)."""
if not text:
return False
lowered = text.lower()
if any(sub in lowered for sub in _TERMINAL_MARKER_SUBSTRINGS):
return True
return any(pat.search(text) for pat in _TERMINAL_MARKER_PATTERNS)
# Forgejo prepends every log line with an RFC-3339 timestamp + space
# (``2026-05-20T18:02:38.8098496Z ``). Stripped before matching the
# in-progress patterns below so ``^nox >`` anchors correctly.
_TS_PREFIX_RE = re.compile(r"^\S*Z\s+")
def _log_ends_mid_execution(text: str) -> bool:
"""True when the log's TAIL shows work still IN PROGRESS — a test
still running, a session/command only just launched — rather than
a finished result.
Combined with the absence of a terminal marker, this is the
hard-kill (OOM-killer / pod eviction) signature: the process was
reaped mid-run and could not print a verdict. Requiring this
POSITIVE evidence — not merely "no marker" — keeps a real failure
whose tool output simply isn't in the marker set (e.g. a niche
linter) from being mislabelled ``indeterminate``: such a log ends
on a result line, not mid-execution."""
lines = [ln for ln in (text or "").splitlines() if ln.strip()]
if not lines:
return False
for raw in lines[-3:]:
ln = _TS_PREFIX_RE.sub("", raw).strip()
low = ln.lower()
# A parallel test runner (robot/pabot, behave) printing live
# progress — the job was alive and working when the log ended.
if "still running" in low or "executing " in low:
return True
# A nox action line as one of the LAST lines: nox launched a
# session/command and the log stopped before its verdict.
# (A nox FAILURE line is a terminal marker, caught upstream.)
if low.startswith("nox > ") and "success" not in low:
return True
return False
# Forgejo combined-status states that count as "still running".
_PENDING_STATES: frozenset[str] = frozenset(
{
"pending",
"queued",
"in_progress",
"running",
}
)
# Forgejo combined-status states that count as a failure.
_FAILURE_STATES: frozenset[str] = frozenset(
{
"failure",
"error",
"timed_out",
}
)
# Per-status states that count as a failed gate.
_GATE_FAILURE_STATES: frozenset[str] = frozenset(
{
"failure",
"error",
"timed_out",
}
)
# A failed CI run whose newest status is older than this is "stale":
# Forgejo may have purged its job logs, and an old infra blip is
# indistinguishable from an old real failure — rerun for a current
# verdict. Env-tunable; default 6h (CI on the fork takes ~25min, so a
# failed run untouched for 6h is well past any in-progress window).
DEFAULT_CI_MAX_AGE_S: float = float(os.environ.get("CONTROLLER_CI_MAX_AGE_S", "21600"))
VERDICTS: frozenset[str] = frozenset(
{
"infra_broken",
"indeterminate",
"no_ci",
"pending",
"fresh_real",
"stale",
}
)
# get_failure_logs(owner, repo, head_sha) -> concatenated CI job-log
# text for the failing jobs (empty string when none / unreachable).
# Production wires this to ``_ci_logs.fetch_pr_failure_logs``; tests
# inject a fake. The classifier scans the returned text for
# infra-class signatures — the load-bearing input for the
# infra-vs-real decision.
GetFailureLogsCallback = Callable[[str, str, str], str]
@dataclass(frozen=True)
class CIClassification:
"""Result of ``classify_ci_result``.
Attributes:
verdict: one of ``infra_broken`` / ``indeterminate`` /
``stale`` / ``no_ci`` / ``pending`` / ``fresh_real``.
reason: a short human-readable explanation (stored in the
controller_events payload for operator post-mortems).
head_sha: the head SHA the classification was made against.
matched_signatures: the infra signatures that fired (empty
unless ``verdict == 'infra_broken'``). Matched against the
failing jobs' CI log content.
failing_contexts: the gate contexts that were failing.
"""
verdict: str
reason: str
head_sha: str
matched_signatures: list[str] = field(default_factory=list)
failing_contexts: list[str] = field(default_factory=list)
def _norm(value: object) -> str:
"""Lower-cased string coercion; ``None`` → empty string."""
if value is None:
return ""
return str(value).strip().lower()
def _infra_signatures_in(text: str) -> list[str]:
"""Return every infra signature found in ``text`` (lower-cased)."""
lowered = text.lower()
return [sig for sig in INFRA_FAILURE_SIGNATURES if sig in lowered]
def _parse_ts(value: object) -> datetime | None:
"""Parse a Forgejo ISO-8601 timestamp (``2026-05-17T17:40:10Z`` or
with an explicit offset). Returns a tz-aware UTC datetime, or
``None`` for anything unparseable."""
if not isinstance(value, str) or not value.strip():
return None
raw = value.strip()
if raw.endswith(("Z", "z")):
raw = raw[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(raw)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _newest_status_ts(statuses: list[dict]) -> datetime | None:
"""Return the most recent ``updated_at`` (falling back to
``created_at``) across ``statuses``, or ``None`` when none carry a
parseable timestamp — i.e. CI age cannot be determined."""
times: list[datetime] = []
for s in statuses:
for key in ("updated_at", "created_at"):
dt = _parse_ts(s.get(key))
if dt is not None:
times.append(dt)
break
return max(times) if times else None
def combined_status_is_failure(forgejo_status: dict | None) -> bool:
"""True when a Forgejo combined-CI-status reports a failure —
either the overall ``state`` or any per-gate ``state`` is a
failure state. Callers use this to decide whether a CI job-log
fetch is worth doing (no point fetching logs for a green run).
"""
if not isinstance(forgejo_status, dict):
return False
if _norm(forgejo_status.get("state")) in _FAILURE_STATES:
return True
for s in forgejo_status.get("statuses") or []:
if isinstance(s, dict) and _norm(s.get("state")) in _GATE_FAILURE_STATES:
return True
return False
def _status_is_for_sha(status: dict, head_sha: str) -> bool:
"""A per-status entry matches the current head when it carries no
SHA (Forgejo combined-status sub-entries usually omit it — they're
already scoped to the queried commit) OR its SHA matches.
Only a status that explicitly carries a *different* SHA is treated
as a mismatch.
"""
sha = _norm(status.get("sha"))
if not sha:
return True
return sha == head_sha.strip().lower()
def classify_ci_result(
*,
head_sha: str | None,
forgejo_status: dict | None,
failure_logs: str | None = None,
has_structured_findings: bool = False,
now: datetime | None = None,
max_age_s: float | None = None,
) -> CIClassification:
"""Classify the freshness/health of a PR's CI.
The Forgejo combined status drives the *structural* decision
(pass / fail / pending / SHA-match). The infra-vs-real decision
for a failure is made by scanning ``failure_logs`` — the actual
CI job-log text — for checkout/setup-class signatures. Status
descriptions are NOT scanned: real Forgejo descriptions are
generic duration strings ("Failing after 1m47s") that never carry
the error text.
Args:
head_sha: the PR's current head commit SHA. ``None`` / empty
means we couldn't determine HEAD — treated as ``no_ci``
(the gate cannot make a freshness decision without it).
forgejo_status: the response body from
``GET /repos/{owner}/{repo}/commits/{sha}/status`` —
``{"state": str, "sha": str | None, "statuses":
[{"context": str, "state": str, "description": str,
"target_url": str}]}``. ``None`` means the fetch failed.
failure_logs: the concatenated CI **job-log** text for the
failing jobs (fetched by the caller via ``_ci_logs``).
The infra-signature scan runs against this. ``None`` /
empty means the caller could not fetch logs — an
otherwise-infra-looking failure then degrades to
``fresh_real`` (conservative: never rerun-loop a failure
we can't actually confirm is infra).
has_structured_findings: True when the controller already has
structured code findings for this CI run (e.g. a populated
``ci_summary`` with non-empty ``findings``). When True, an
otherwise infra-looking failure is treated as ``fresh_real``
— real findings outrank a signature match. The gate
normally passes ``False`` (it works off the raw combined
status before any parser runs).
now: the reference time for the staleness check. Defaults to
``datetime.now(timezone.utc)``; tests inject a fixed value.
max_age_s: a FAILED CI run whose newest status is older than
this is classified ``stale``. Defaults to
:data:`DEFAULT_CI_MAX_AGE_S` (``CONTROLLER_CI_MAX_AGE_S``
env, 6h).
Returns a :class:`CIClassification`. Never raises.
"""
sha = (head_sha or "").strip()
if not sha:
return CIClassification(
verdict="no_ci",
reason="no head SHA available for the PR; cannot poll CI",
head_sha="",
)
# No combined status at all (fetch failed OR commit has no CI).
if forgejo_status is None:
return CIClassification(
verdict="no_ci",
reason="no CI combined-status returned for the head SHA",
head_sha=sha,
)
raw_statuses = forgejo_status.get("statuses") or []
statuses = [s for s in raw_statuses if isinstance(s, dict)]
# SHA-match guard: if the combined status reports a SHA and it
# disagrees with the PR's HEAD, the status is for an older/other
# commit — it is NOT a verdict for the current code.
combined_sha = _norm(forgejo_status.get("sha"))
if combined_sha and combined_sha != sha.lower():
return CIClassification(
verdict="no_ci",
reason=(
f"CI combined-status is for SHA {combined_sha[:12]} but "
f"PR HEAD is {sha[:12]} — stale/mismatched run"
),
head_sha=sha,
)
# Keep only statuses that belong to the current HEAD.
current = [s for s in statuses if _status_is_for_sha(s, sha)]
if not current:
# Either zero statuses, or every status was for a different
# commit. Both mean "no CI for the current code".
reason = (
"no CI status exists for the current head SHA"
if not statuses
else "every CI status is for a different/older commit"
)
return CIClassification(
verdict="no_ci",
reason=reason,
head_sha=sha,
)
overall = _norm(forgejo_status.get("state"))
gate_states = [_norm(s.get("state")) for s in current]
# Anything still running → pending. Check per-gate AND the overall
# state so a combined "pending" with no sub-statuses is caught too.
if overall in _PENDING_STATES or any(gs in _PENDING_STATES for gs in gate_states):
return CIClassification(
verdict="pending",
reason="CI is still running/queued for the head SHA",
head_sha=sha,
)
failing = [s for s in current if _norm(s.get("state")) in _GATE_FAILURE_STATES]
overall_failed = overall in _FAILURE_STATES
# No failing gate and the overall state isn't a failure → the CI
# ran and passed (or is advisory-only). That's a real verdict.
if not failing and not overall_failed:
return CIClassification(
verdict="fresh_real",
reason="CI ran on the head SHA and did not fail",
head_sha=sha,
)
# There is a failure. If the controller already has structured
# code findings, the failure is real — findings outrank signatures.
if has_structured_findings:
return CIClassification(
verdict="fresh_real",
reason=("CI failed with structured code findings — a real code failure"),
head_sha=sha,
failing_contexts=[str(s.get("context") or "(unknown)") for s in failing],
)
failing_contexts = [str(s.get("context") or "(unknown)") for s in failing] or (
["(combined)"] if overall_failed else []
)
# Age of the CI run, from the newest status timestamp. A failed run
# that is old cannot be trusted: Forgejo may have purged its job
# logs, and an old infra blip is indistinguishable from an old real
# failure — the "don't assume an old stale run means it is down"
# case the gate exists for.
eff_now = now or datetime.now(timezone.utc)
eff_max_age = DEFAULT_CI_MAX_AGE_S if max_age_s is None else max_age_s
newest_ts = _newest_status_ts(current)
is_stale = (
newest_ts is not None and (eff_now - newest_ts).total_seconds() > eff_max_age
)
# Scan the failing jobs' LOG CONTENT for infra-class signatures.
# ``infra_broken`` requires ZERO structured code findings
# (has_structured_findings is False here) AND a setup/checkout
# signature in the actual log text. Status descriptions are NOT
# scanned — they're generic ("Failing after 1m47s") and never
# carry the error.
matched: list[str] = []
if failure_logs and str(failure_logs).strip():
for sig in _infra_signatures_in(str(failure_logs)):
if sig not in matched:
matched.append(sig)
if matched:
return CIClassification(
verdict="infra_broken",
reason=(
"CI failed at a checkout/setup-class step with no "
f"structured code findings (log signatures: {matched})"
),
head_sha=sha,
matched_signatures=matched,
failing_contexts=failing_contexts,
)
# No infra signature. Before the stale/fresh_real fallbacks: a
# failing run whose FULL job log carries NO terminal verdict
# marker (no ``##[error]``, no test-runner summary, no
# ``Traceback``, no exit-code line) AND whose tail shows work
# still in progress never actually produced a verdict — its
# process was hard-killed (OOM-killer / pod eviction). Handing
# that to an implementer as a "real" failure dead-ends it at
# ``blocked`` → STUCK (the PR-39 incident). Class it
# ``indeterminate`` so it is rerun for a verdict we can trust.
#
# BOTH conditions are required. "No marker" alone over-fires on a
# real failure whose tool output simply isn't in the marker set;
# demanding the log also END mid-execution restricts the verdict
# to a genuinely truncated (killed) run. Requires non-empty log
# text: an ABSENT log is "unfetchable" (handled by the
# stale/fresh_real fallbacks below).
log_text = str(failure_logs) if failure_logs else ""
if (
log_text.strip()
and not _log_has_terminal_marker(log_text)
and _log_ends_mid_execution(log_text)
):
return CIClassification(
verdict="indeterminate",
reason=(
"CI failed but the full job logs carry no terminal "
"verdict marker (no ##[error] / test summary / "
"Traceback / exit code) and end mid-execution — the "
"run was hard-killed (OOM / pod eviction) and never "
"produced a verdict; rerun for a real one"
),
head_sha=sha,
failing_contexts=failing_contexts,
)
# No infra signature — logs were absent/purged, or carried no
# signature. If the failing run is STALE, rerun it: an old failure
# whose logs we cannot inspect is exactly the incident case (an
# infra blip frozen as a 2-day-old red verdict). Timestamp-based,
# so it works even when log fetching is unavailable.
if is_stale:
age_h = (eff_now - newest_ts).total_seconds() / 3600.0
return CIClassification(
verdict="stale",
reason=(
f"CI failed and its newest status is {age_h:.1f}h old "
f"(> {eff_max_age / 3600.0:.0f}h threshold) — stale; "
"rerun for a current verdict"
),
head_sha=sha,
failing_contexts=failing_contexts,
)
# Recent failure, no infra signature, no structured findings →
# assume a real code failure. Conservative: an unrecognized recent
# failure is NOT treated as infra/stale (never rerun-loop a bug).
if not failure_logs or not str(failure_logs).strip():
reason = (
"CI failed recently but no job-log content was available "
"to classify it — treated as a real code failure"
)
else:
reason = (
"CI failed but the job logs carry no checkout/setup-class "
"signature — treated as a real code failure"
)
return CIClassification(
verdict="fresh_real",
reason=reason,
head_sha=sha,
failing_contexts=failing_contexts,
)
__all__ = [
"CIClassification",
"DEFAULT_CI_MAX_AGE_S",
"GetFailureLogsCallback",
"INFRA_FAILURE_SIGNATURES",
"VERDICTS",
"classify_ci_result",
"combined_status_is_failure",
]