Files
cleveragents-core/tools/controller/master/ci_status_poll.py
T
drew 14e592ddd5 feat(controller): zombie-CI detection — stop waiting on dead CI runs
A CI gate stuck `pending` is ambiguous: the job may genuinely be
running, or the run may be dead (a crashed runner, an Actions job whose
terminal commit-status was never posted — `CI / status-check` zombies
routinely here). The "wait for the whole run to finish" fix then waited
forever on the dead case (PR #36: a `status-check` gate pending for 8 h
while the run had actually finished RED 8 h earlier).

New `ci_run_status.classify_ci_run` resolves a still-pending run to
`complete` / `running` / `stale` via two checks, authoritative-first:

  1. ACTIVE-RUN — `get_action_tasks` asks Forgejo's Actions API
     directly whether a task for the commit is still running; catches a
     dead run immediately, regardless of age.
  2. AGE — if no gate has updated in > CONTROLLER_CI_STALE_AFTER_MIN
     (default 90) the run has stopped; the fallback when the Actions
     API is unavailable.

A `stale` run is no longer waited on: the verdict is taken from the
gates that DID finish (`terminal_verdict`) — any failure → red, all
pass → green, fully-dead → red. Applied in both `ci_status_poll` (the
AWAITING_CI verdict) and `ci_summarize` (the implementer's summary —
zombie pending gates drop out of `gates_pending`/`overall_state`) so
the two agree and never ping-pong.

New `get_action_tasks` Forgejo callback wired through forgejo_http →
__main__ → the poll and the prefetch path.

23 new tests; full controller suite (1222) green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:21:12 -04:00

636 lines
27 KiB
Python

"""AWAITING_CI status poll — autonomous CI exit (Phase 1k++++ trial).
Closes the last operator-intervention gap for the Phase 2 trial.
Previously, workflows that transitioned to AWAITING_CI sat there
forever (only ``ci_polling_exhausted`` fired, leading to STUCK after
the 2h timeout). The RUNBOOK had a manual SQL workaround.
This module ships an autonomous tick:
1. SELECT workflows in AWAITING_CI + their latest implementer
attempt's ``head_sha_after`` (the SHA the implementer pushed).
2. For each: call ``get_ci_status(owner, repo, head_sha)`` — wraps
Forgejo's ``/commits/{sha}/status`` combined-status endpoint.
3. Decide the next event based on the combined ``state`` field:
- ``success`` → pre-review mergeable gate (one Forgejo PR-detail
call, no LLM):
- PR still merges cleanly → ``ci_green`` → REVIEWING
- PR no longer merges (base advanced while CI ran) →
``pre_review_base_conflict`` → CONFLICT_RESOLVING. Hands the
conflict to the LLM conflict_resolver and skips the doomed
reviewer pass — code that must be rebased gets re-CI'd and
re-reviewed afterwards anyway. Conservative: only an explicit
``mergeable=false`` diverts; an unknown bit falls through to
``ci_green``.
- ``failure`` / ``error`` / ``timed_out`` → classify the failure
(infra-vs-real) via ``ci_freshness.classify_ci_result`` against
the failing jobs' CI **log content**:
- ``infra_broken`` → ``ci_infra_recheck`` → DISCOVERED. The
CI-freshness gate then re-handles the workflow: its
rerun-budget counter accumulates and triggers another
empty-commit rerun, or routes to STUCK once the budget is
exhausted. This closes the gate⇄AWAITING_CI loop — without
it a reran-but-still-infra-broken PR would send an
implementer to flail on infra (the original incident).
- ``fresh_real`` (or no log fetcher wired / unclassifiable)
→ ``ci_red_retry_same_tier`` → IMPLEMENTING (the scheduler
enqueues a fresh implementer attempt at the same tier;
pickup_guard + ci_poll_exhaustion catch infinite loops).
- ``pending`` / ``queued`` / ``in_progress`` → no-op (wait for
the next tick)
- None / fetch failure → no-op (transient; reconciliation also
re-checks PR state independently)
What this module DOES NOT do (yet):
- Per-gate CI summarization. ``ci_summarize.py`` exists but plugging
it in would require fetching every job log per gate — heavy. For
the trial we only need the overall state to advance the workflow.
The next implementer attempt's prefetch picks up the failing
gates via the existing ci_summary path.
- Escalation to ``ci_red_escalate``. Always uses retry_same_tier;
ESCALATING is reached via the regular attempts-per-tier exhaustion
path (pickup_guard / outcome mapper).
- Flake detection. Future ``ci_flake_retry`` integration ships when
the flake classifier lands.
"""
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
from .ci_run_status import (
GetActionTasksCallback,
classify_ci_run,
terminal_verdict,
)
logger = logging.getLogger(__name__)
# get_ci_status(owner, repo, head_sha) → Forgejo combined-status
# dict ({"state": "...", "statuses": [...]}) or None on transient
# fetch failure.
GetCIStatusCallback = Callable[[str, str, str], dict | None]
# get_pr_details(owner, repo, pr_number) → the full Forgejo PR body
# (which carries the ``mergeable`` bit) or None on fetch failure.
# Used by the pre-review mergeable gate. Mirrors the callback the
# CI-freshness gate already consumes.
GetPRDetailsCallback = Callable[[str, str, int], dict | None]
# CI combined-status states that are a failure needing infra-vs-real
# classification before an event is chosen.
_CLASSIFY_STATES: frozenset[str] = frozenset(
{
"failure",
"error",
"timed_out",
}
)
# Map Forgejo combined-status state → state-machine event.
# None = no-op (wait for next tick).
#
# For failure states (failure / error / timed_out) the entry here is
# the *fallback* event — used when the infra-vs-real classifier yields
# ``fresh_real`` (or no log fetcher is wired). An ``infra_broken``
# verdict overrides it with ``ci_infra_recheck`` (→ DISCOVERED) so the
# CI-freshness gate re-handles the workflow under its rerun budget.
# See ``_decide_event``.
#
# CA8: ``cancelled`` and ``stale`` are NOT genuine CI failures —
# ``cancelled`` is typically an operator hitting "cancel job" or CI
# system shutting down; ``stale`` is when a new push superseded the
# run on a different branch (common during force-pushes). Treating
# these as ci_red burns a ``pickup_count`` slot on a healthy PR,
# which combined with MAX_PICKUPS pushes the workflow to STUCK after
# a few cancels. Map them to None (wait for the next tick) so the
# poller picks up the eventual real status (or operator can re-run).
# ``timed_out`` IS a real failure — keep that as red.
_STATE_TO_EVENT: dict[str | None, str | None] = {
"success": "ci_green",
"failure": "ci_red_retry_same_tier",
"error": "ci_red_retry_same_tier",
"pending": None,
"queued": None,
"in_progress": None,
"warning": "ci_green", # advisory; treat as passed
"neutral": "ci_green",
"skipped": "ci_green",
"cancelled": None, # CA8 — operator/CI-system action, not a failure
"timed_out": "ci_red_retry_same_tier",
"action_required": None, # human intervention needed; wait
"stale": None, # CA8 — superseded run, wait for current to land
None: None,
}
def _head_sha_from_rerun_payload(raw: object) -> str | None:
"""Extract ``new_head_sha`` from a ``ci-rerun-triggered`` event
payload.
The CI-freshness gate (``ci_gate.py``) routes a stale-CI workflow
DISCOVERED → AWAITING_CI after pushing an empty commit, and stashes
the empty commit's SHA as ``new_head_sha`` in the event payload.
A workflow that entered AWAITING_CI this way has no implementer
attempt, so this is the only place the SHA-to-poll lives.
``raw`` may arrive as a dict (Postgres JSON column) or a JSON
string (SQLite ``text()`` SELECT). Returns None for anything
unparseable.
"""
payload: dict | None = None
if isinstance(raw, dict):
payload = raw
elif isinstance(raw, str) and raw:
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
if isinstance(parsed, dict):
payload = parsed
if payload is None:
return None
sha = payload.get("new_head_sha")
return sha if isinstance(sha, str) and sha else None
@dataclass
class CIStatusPollReport:
"""Per-sweep summary."""
workflows_scanned: int = 0
workflows_advanced_green: int = 0
workflows_advanced_red: int = 0
# Reran-but-still-infra-broken: routed AWAITING_CI → DISCOVERED so
# the CI-freshness gate re-handles them under its rerun budget.
workflows_infra_recheck: int = 0
# CI passed but the PR no longer merged cleanly into base; routed
# AWAITING_CI → CONFLICT_RESOLVING by the pre-review mergeable gate
# (skips a doomed reviewer pass).
workflows_pre_review_conflict: int = 0
workflows_waiting: int = 0
workflows_fetch_failed: int = 0
transitions: list[tuple[int, str, str]] = field(default_factory=list)
# Each entry: (workflow_id, ci_state, event_fired)
def _decide_failure_event(
*,
owner: str,
repo: str,
head_sha: str,
wf_id: int,
ci: dict,
fallback_event: str,
get_failure_logs: GetFailureLogsCallback | None,
) -> str:
"""Choose the state-machine event for a failure CI result.
Fetches the failing jobs' CI log content (when a fetcher is wired)
and classifies the failure infra-vs-real via
``classify_ci_result``:
- ``infra_broken`` → ``ci_infra_recheck`` (→ DISCOVERED; the
CI-freshness gate re-handles it under the rerun budget).
- anything else (incl. no fetcher / unclassifiable) →
``fallback_event`` (the real-failure path, normally
``ci_red_retry_same_tier``).
Never raises — a log-fetch failure degrades to ``fallback_event``.
"""
failure_logs = ""
if get_failure_logs is not None:
try:
fetched = get_failure_logs(owner, repo, head_sha)
failure_logs = fetched if isinstance(fetched, str) else ""
except Exception as exc: # noqa: BLE001 — never abort the tick
logger.warning(
"ci_status_poll: get_failure_logs raised for workflow %s (head=%s): %s",
wf_id,
head_sha[:12],
exc,
)
classification = classify_ci_result(
head_sha=head_sha,
forgejo_status=ci,
failure_logs=failure_logs,
)
# infra_broken (log signature) OR stale (the failure is old and
# unconfirmable) OR indeterminate (the full log carries no verdict
# marker — a hard-kill / OOM) → bounce AWAITING_CI → DISCOVERED so
# the CI-freshness gate re-handles it under the rerun budget.
if classification.verdict in ("infra_broken", "stale", "indeterminate"):
logger.info(
"ci_status_poll: workflow %s reran CI not a real verdict "
"(verdict=%s: %s); routing AWAITING_CI → DISCOVERED for the "
"CI-freshness gate to re-handle (signatures: %s)",
wf_id,
classification.verdict,
classification.reason,
classification.matched_signatures,
)
return "ci_infra_recheck"
return fallback_event
def _decide_green_event(
*,
owner: str,
repo: str,
pr_number: int | None,
wf_id: int,
get_pr_details: GetPRDetailsCallback | None,
) -> str:
"""Pre-review mergeable gate — run when CI is green, before the
expensive LLM reviewer pass.
One Forgejo PR-detail fetch: if the PR explicitly reports
``mergeable=false`` the base advanced while CI ran and the code
must be rebased — return ``pre_review_base_conflict`` so the
workflow routes straight to CONFLICT_RESOLVING and skips the
doomed review (the resolved code gets re-CI'd + re-reviewed
afterwards anyway).
Conservative by construction — returns ``ci_green`` (no gating)
whenever the mergeable bit can't be trusted:
- no fetcher wired, or no PR number,
- the fetch failed / returned a non-dict,
- ``mergeable`` is absent or not a bool. Forgejo computes the
bit asynchronously; right after a base push it can be stale
or uncomputed, so only an explicit ``mergeable is False``
diverts the workflow. A fresh PR is never false-routed.
Never raises — any failure degrades to ``ci_green``.
"""
if get_pr_details is None or pr_number is None:
return "ci_green"
try:
pr = get_pr_details(owner, repo, int(pr_number))
except Exception as exc: # noqa: BLE001 — never abort the tick
logger.warning(
"ci_status_poll: pre-review mergeable fetch raised for "
"workflow %s (PR #%s): %s",
wf_id,
pr_number,
exc,
)
return "ci_green"
if not isinstance(pr, dict):
return "ci_green"
# Known trade-off: Forgejo recomputes ``mergeable`` asynchronously,
# so a base advancing *during* CI opens a small window where this
# fetch races the recompute and reads a transient ``false``. The
# gate runs minutes after the push (post-CI), so the bit is settled
# in the overwhelming majority of cases. A false positive is
# bounded and self-heals — the conflict_resolver rebases cleanly
# and routes back to REVIEWING — but it does decrement the
# conflict budget (a phantom conflict count escalates a future
# *real* conflict one tier early). The ``mergeable`` field is
# mirrored into the event payload so this rate stays observable.
if pr.get("mergeable") is False:
logger.info(
"ci_status_poll: workflow %s (PR #%s) CI is green but "
"Forgejo reports mergeable=false; routing AWAITING_CI → "
"CONFLICT_RESOLVING (pre-review gate) to skip the doomed "
"reviewer pass on code that needs rebasing",
wf_id,
pr_number,
)
return "pre_review_base_conflict"
return "ci_green"
def run_ci_status_poll_tick(
engine: Engine,
*,
owner: str,
repo: str,
get_ci_status: GetCIStatusCallback,
get_failure_logs: GetFailureLogsCallback | None = None,
get_pr_details: GetPRDetailsCallback | None = None,
get_action_tasks: GetActionTasksCallback | None = None,
) -> CIStatusPollReport:
"""One sweep: poll Forgejo CI status for every AWAITING_CI
workflow in this (owner, repo) + apply state-machine transitions.
``get_failure_logs`` is the CI job-log fetcher (backed by
``_ci_logs`` in production). On a failure CI result the poll
fetches the failing jobs' log content and classifies the failure
infra-vs-real: an ``infra_broken`` verdict routes the workflow
AWAITING_CI → DISCOVERED (``ci_infra_recheck``) so the
CI-freshness gate re-handles it under its rerun budget, instead of
sending an implementer to flail on infra. When the fetcher is not
wired the poll keeps the legacy ``ci_red_retry_same_tier``
behaviour for every failure (conservative).
``get_pr_details`` is the Forgejo PR-detail fetcher. When wired,
a green CI result passes through the pre-review mergeable gate
(``_decide_green_event``): a PR that no longer merges cleanly into
base is routed AWAITING_CI → CONFLICT_RESOLVING
(``pre_review_base_conflict``) instead of REVIEWING, so the
expensive LLM reviewer is not spent on code that must be rebased.
When the fetcher is not wired every green CI advances straight to
REVIEWING (legacy behaviour).
"""
report = CIStatusPollReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
# Find AWAITING_CI workflows + the SHA whose CI to poll.
#
# CA6: both implementer AND conflict_resolver push commits.
# The previous query filtered ``a.role = 'implementer'`` so
# workflows that re-entered AWAITING_CI after CONFLICT_RESOLVING
# → IMPLEMENTING used a stale pre-conflict SHA. Union both roles
# and pick whichever attempt has the highest attempt_number.
#
# PD9: filter on ``a.outcome = 'resolved'`` (implementer) or
# ``a.outcome = 'resolved'`` (conflict_resolver) — a blocked
# attempt has ``head_sha_after`` equal to ``head_sha_before``
# (no push happened), so the previous query would treat the
# stale pre-blocked SHA as the "latest push" and false-green
# advance to REVIEWING for code that was never re-pushed.
#
# CI-gate fix: a workflow can also enter AWAITING_CI directly
# from DISCOVERED via the CI-freshness gate (an empty-commit
# CI rerun). That path has NO implementer/conflict_resolver
# attempt, so the attempt sub-SELECT yields NULL and the
# workflow would be skipped forever. The COALESCE second arm
# reads the head SHA the gate stashed in the most-recent
# ``ci-rerun-triggered`` event payload.
#
# RUN_CI_LOCAL: ``ci-not-ready`` is also accepted in the
# attempt sub-SELECT. That outcome means the implementer ran
# before the on-demand local CI had a verdict (no push); the
# workflow is parked in AWAITING_CI specifically to poll the PR
# head. Its ``head_sha_after`` == ``head_sha_before`` == the
# current PR head — exactly the SHA to poll. Without it a fresh
# PR (no resolved attempt, no ci-rerun event) would have no SHA
# and sit in AWAITING_CI until ci_poll_exhaustion.
rows = session.execute(
text(
"SELECT w.workflow_id, w.entity_number, "
" (SELECT a.head_sha_after FROM workflow_attempts a "
" WHERE a.workflow_id = w.workflow_id "
" AND a.role IN ('implementer', 'conflict_resolver') "
" AND a.status = 'complete' "
" AND a.outcome IN ('resolved', 'ci-not-ready') "
" AND a.head_sha_after IS NOT NULL "
" ORDER BY a.attempt_number DESC LIMIT 1) AS attempt_sha, "
" (SELECT e.payload FROM controller_events e "
" WHERE e.workflow_id = w.workflow_id "
" AND e.event_type = 'ci-rerun-triggered' "
" ORDER BY e.event_id DESC LIMIT 1) AS rerun_payload "
" FROM workflows w "
" WHERE w.current_state = 'AWAITING_CI' "
" AND w.owner = :owner AND w.repo = :repo "
" AND w.kind = 'pr'"
),
{"owner": owner, "repo": repo},
).all()
report.workflows_scanned = len(rows)
for row in rows:
wf_id = row.workflow_id
head_sha = row.attempt_sha
if not head_sha:
# No attempt-derived SHA — try the CI-gate rerun event.
head_sha = _head_sha_from_rerun_payload(row.rerun_payload)
if not head_sha:
logger.warning(
"ci_status_poll: workflow %s in AWAITING_CI without "
"a head_sha (no completed implementer attempt and "
"no ci-rerun-triggered event); skipping",
wf_id,
)
report.workflows_waiting += 1
continue
try:
ci = get_ci_status(owner, repo, head_sha)
except Exception as exc: # noqa: BLE001 — transient
logger.warning(
"ci_status_poll: fetch failed for workflow %s (head=%s): %s",
wf_id,
head_sha[:12],
exc,
)
report.workflows_fetch_failed += 1
continue
if ci is None:
report.workflows_fetch_failed += 1
continue
# Don't fire ci_green / ci_red until the run is actually
# done. Forgejo's combined ``state`` goes ``failure`` the
# moment one gate fails even mid-run (and can read
# ``success`` before a late gate reports). classify_ci_run
# resolves a still-pending run as genuinely running (wait)
# vs. a zombie (a dead run / a gate whose terminal status
# was never posted — ``status-check`` does this routinely).
run_class = classify_ci_run(
ci.get("statuses") or [],
now=now,
get_action_tasks=get_action_tasks,
owner=owner,
repo=repo,
head_sha=head_sha,
)
if run_class == "running":
report.workflows_waiting += 1
continue
if run_class == "stale":
# Zombie pending gate(s): the run has stopped. Take the
# verdict from the gates that finished — "categorize it
# and move on" — and map it STRAIGHT to the event. Do
# NOT route through _decide_failure_event: it re-reads
# the zombie pending gate and bounces a real, terminal
# red verdict to ci_infra_recheck. The implementer in
# IMPLEMENTING does the infra-vs-real call itself (it
# has the ci-infra-failure outcome + the CI re-trigger).
# A fully-dead run (no terminal gate at all) → red.
verdict = terminal_verdict(ci.get("statuses") or [])
event = (
"ci_green" if verdict == "success" else "ci_red_retry_same_tier"
)
# Downstream logging + the controller_events payload read
# ci_state; for a stale run it IS the terminal verdict.
ci_state = verdict
logger.info(
"ci_status_poll: workflow %s CI run is STALE/zombie "
"(head=%s) — terminal verdict %s -> %s",
wf_id,
head_sha[:12],
verdict,
event,
)
else: # "complete"
ci_state = ci.get("state")
# Warn on unknown Forgejo states so a new state string
# surfaces in operator logs instead of being silently
# treated as "pending" (TE9: previously indistinguishable
# from real pending — operators got no signal).
if ci_state not in _STATE_TO_EVENT:
logger.warning(
"ci_status_poll: workflow %s reported unknown CI "
"state %r (head=%s); treating as wait — add to "
"_STATE_TO_EVENT if recurring",
wf_id,
ci_state,
(head_sha or "")[:12],
)
event = _STATE_TO_EVENT.get(ci_state)
if event is None:
# Pending / queued / in_progress / unknown → wait.
report.workflows_waiting += 1
continue
# A failure result needs an infra-vs-real classification:
# an infra-broken failure routes back to DISCOVERED (the
# CI-freshness gate re-handles it under its rerun budget)
# rather than sending an implementer to flail on infra.
if isinstance(ci_state, str) and ci_state in _CLASSIFY_STATES:
event = _decide_failure_event(
owner=owner,
repo=repo,
head_sha=head_sha,
wf_id=wf_id,
ci=ci,
fallback_event=event,
get_failure_logs=get_failure_logs,
)
# A green CI result passes through the pre-review mergeable
# gate: a PR whose base advanced while CI ran no longer
# merges cleanly, so route it to CONFLICT_RESOLVING now and
# skip the doomed reviewer pass. Cheap detection (one API
# call) gating expensive resolution.
if event == "ci_green":
event = _decide_green_event(
owner=owner,
repo=repo,
pr_number=row.entity_number,
wf_id=wf_id,
get_pr_details=get_pr_details,
)
try:
new_state = apply_event("AWAITING_CI", event)
except (IllegalTransitionError, ValueError) as exc:
logger.warning(
"ci_status_poll: apply_event(AWAITING_CI, %s) "
"raised %s for workflow %s; skipping",
event,
exc,
wf_id,
)
continue
# Apply via direct UPDATE filtered on current_state so a
# concurrent reconciliation can't double-transition (TOCTOU
# defense, same pattern as promote.py).
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 = 'AWAITING_CI'"
),
{
"to_state": new_state,
"now": now,
"wf_id": wf_id,
},
)
if (result.rowcount or 0) == 0:
logger.info(
"ci_status_poll: workflow %s no longer in AWAITING_CI "
"(race lost); skipping event row",
wf_id,
)
continue
event_payload: dict[str, object] = {
"reason": event,
"ci_state": ci_state,
"head_sha": head_sha,
"source": "ci_status_poll",
}
if event == "pre_review_base_conflict":
# Record the bit the pre-review mergeable gate acted on
# so the gate's (rare) false-positive rate is observable
# straight from controller_events — no log-scraping.
event_payload["mergeable"] = False
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, :event_type, 'AWAITING_CI', "
" :to_state, :payload, 0, 0)"
),
{
"wf_id": wf_id,
"ts": now,
# PD8: use the state-machine event name (ci_green /
# ci_red_retry_same_tier) directly so consumers
# joining on event_type don't see schema drift
# between sources.
"event_type": event,
"to_state": new_state,
"payload": json.dumps(event_payload),
},
)
if event == "ci_green":
report.workflows_advanced_green += 1
elif event == "ci_infra_recheck":
report.workflows_infra_recheck += 1
elif event == "pre_review_base_conflict":
report.workflows_pre_review_conflict += 1
else:
report.workflows_advanced_red += 1
report.transitions.append((wf_id, ci_state, event))
if (
report.workflows_advanced_green
or report.workflows_advanced_red
or report.workflows_infra_recheck
or report.workflows_pre_review_conflict
):
logger.info(
"ci_status_poll: %d scanned (green=%d, red=%d, "
"infra_recheck=%d, pre_review_conflict=%d, waiting=%d, "
"fetch_failed=%d)",
report.workflows_scanned,
report.workflows_advanced_green,
report.workflows_advanced_red,
report.workflows_infra_recheck,
report.workflows_pre_review_conflict,
report.workflows_waiting,
report.workflows_fetch_failed,
)
return report
__all__ = [
"CIStatusPollReport",
"GetCIStatusCallback",
"GetPRDetailsCallback",
"run_ci_status_poll_tick",
]