Files
cleveragents-core/tools/controller/master/ci_run_status.py
T
drew 981ddd6a8e fix(controller): incident hardening — PR-44 dispute guard + PR-46 fixes
Bundles fixes for three production incidents (PR-44, PR-46 a/b/c).
Every change has an incident-reference comment in the code and a
named regression test. 199 tests pass on the impacted modules.

PR-44 — fabricated dispute escape
---------------------------------
A tier-0 implementer, bounced twice by red CI, emitted a fabricated
``dispute-reviewer`` outcome and shortcut a red PR into REVIEWING →
APPROVED → MERGING, bypassing the CI gate. ``dispute-reviewer`` is
the only IMPLEMENTING → REVIEWING edge that doesn't pass through
AWAITING_CI, so it must be defended.

tools/controller/master/outcomes.py: ``_map_implementer_outcome``
now guards ``dispute-reviewer`` with two preconditions —
(1) ``attempt_saw_green_ci`` (the HEAD must already be CI-verified;
a dispute can't jump the gate on a red/pending head), and
(2) ``prior_reviews >= 1`` (must reference a review that actually
happened, not a hallucinated one). Either guard fails →
``implementer_competence_failure`` → tier escalation. A weak model
can't game its way past CI; a stronger tier is given the real problem.

tools/controller/master/tick.py: new ``_count_prior_reviews`` helper
counts completed reviewer attempts (epoch-scoped so an
``operator_unstick`` resets the count). Wired into the
``map_outcome_to_event`` call.

PR-46(a) — stale gate-script preferred over in-repo
---------------------------------------------------
``gate.py`` was preferring the seeded ``/tmp/local_tools`` copy of
``local_ci_gate.sh`` over the version-matched in-repo copy. The
seed predated the ``--envdir`` flag; the controller pipeline's
invocations rejected as bad-argv every committing implementer's
gate. The seed-refresher (``dispatch_implementer.py``) is on the
retired dispatcher path, so the staleness was permanent.

tools/controller/worker/gate.py: resolution order is now
``CONTROLLER_LOCAL_CI_GATE`` env > in-repo > seeded ``/tmp``. The
seeded copy survives only as a last-resort fallback. Module-level
constants ``_IN_REPO_GATE_SCRIPT`` / ``_SEEDED_GATE_SCRIPT`` let
tests substitute paths.

PR-46(b) — 6-second-old run flagged zombie
------------------------------------------
``classify_ci_run`` instantly classified a CI run as ``stale`` when
the Actions API reported no active task. A freshly-pushed run has
no task simply because no runner has picked it up yet, and there
are brief gaps between jobs — both false positives. A 6-second-old
PR-46 run was bounced before CI could even start.

tools/controller/master/ci_run_status.py: new ``ZOMBIE_GRACE``
(default 3 min, env: ``CONTROLLER_CI_ZOMBIE_GRACE_MIN``). "No
active task" only classifies a run as stale once the run has ALSO
gone quiet past the grace. Much shorter than ``STALE_AFTER`` since
the active-task absence is corroborating evidence, not the sole
signal.

PR-46(c) — ruff-format-only violation slipping through lint
-----------------------------------------------------------
CI's ``lint`` job runs both the ``lint`` nox session (ruff check)
AND ``ruff format --check``. The pre-push local gate only ran the
former; a formatting-only violation passed pre-push then failed CI.

tools/local_ci_gate.sh: the ``lint`` gate now runs ``ruff check``
followed by ``ruff format --check``, unconditional. Either one
failing marks the gate red. ``ruff format --check`` is whole-repo
and takes no posargs. tests/auto_agents/test_local_ci_gate.py
updated for the new two-call shape.

Supporting changes
------------------
.forgejo/workflows/ci.yml: gates ``coverage`` and ``docker`` jobs
on repo variables ``skip_coverage`` / ``skip_docker`` so the long
reaper-prone jobs can be skipped per-fork without editing CI.
Guard step always runs so the job still reports ``success`` and
``status-check`` stays green.

tools/duplicate_prs_to_fork.py: bakes the same ``skip_coverage`` /
``skip_docker`` gates into every sentinel PR's ci.yml at PR-creation
time so fork-mode runs inherit the gating. Idempotent.

.opencode/opencode.json: adds ``timeout: 1860000`` (31 min) to the
``ci`` MCP server so long CI waits don't timeout the tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:32:35 -04:00

223 lines
8.2 KiB
Python

"""Zombie / stale CI-run detection.
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`` — the
aggregation gate — zombies routinely on this deployment. The controller
must not wait forever on the dead case.
``classify_ci_run`` resolves a CI run to one of:
- ``complete`` — every gate reached a terminal status.
- ``running`` — at least one gate is pending AND the run is live.
- ``stale`` — at least one gate is pending but the run is NOT live;
the pending gates are zombies. The verdict is taken
from the gates that did finish (see ``terminal_verdict``).
"Live" is decided by two checks:
1. ACTIVE-RUN — when an Actions-task fetcher is supplied, ask Forgejo
whether a task for the commit is still running. A running task is
authoritative proof the run is live. The ABSENCE of one is NOT proof
it is dead — a just-pushed run has no task until a runner picks it
up — so "no active task" only counts as stale once the run has ALSO
had no gate update for > ``ZOMBIE_GRACE``.
2. AGE — when the Actions API is unavailable, a run with no gate update
in > ``STALE_AFTER`` has stopped progressing. The fallback.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
logger = logging.getLogger(__name__)
# A run whose newest gate has not changed in this long has stopped
# progressing — any still-pending gate is a zombie. 90 min by default:
# longer than any plausible single CI job here, short enough that a
# dead run is caught well before it wastes a workflow's wallclock.
STALE_AFTER_MIN = int(os.environ.get("CONTROLLER_CI_STALE_AFTER_MIN", "90"))
STALE_AFTER = timedelta(minutes=STALE_AFTER_MIN)
# Grace period before "no active Actions task" is trusted as proof a run
# is dead. A freshly pushed run has no task simply because no runner has
# picked it up yet, and there are brief gaps between jobs — in both
# cases the Actions API momentarily reports no running task. So "no
# active task" only classifies a run as stale once it has ALSO had no
# gate update for this long. Much shorter than STALE_AFTER: the
# active-task signal lets the verdict be aggressive once the grace has
# elapsed. The PR-46 incident: a 6-second-old run, polled before any
# runner picked it up, was flagged a zombie and bounced before CI could
# even start.
ZOMBIE_GRACE_MIN = int(os.environ.get("CONTROLLER_CI_ZOMBIE_GRACE_MIN", "3"))
ZOMBIE_GRACE = timedelta(minutes=ZOMBIE_GRACE_MIN)
# Per-gate commit-status values that mean "not terminal."
_PENDING_GATE_STATES = {"pending", "queued", "running", "in_progress", ""}
# Failure-ish terminal gate states.
_FAILED_GATE_STATES = {"failure", "error", "timed_out"}
# Forgejo Actions *task* statuses that mean "still going."
_ACTIVE_TASK_STATES = {"running", "waiting", "blocked"}
# get_action_tasks(owner, repo, head_sha) -> list[dict] | None
GetActionTasksCallback = Callable[[str, str, str], "list[dict] | None"]
def _gate_state(s: dict) -> str:
"""Per-gate state; tolerates the ``status``/``state`` key split."""
return str(s.get("state") or s.get("status") or "").strip().lower()
def _parse_ts(raw: object) -> datetime | None:
"""Parse an ISO-8601 timestamp (Forgejo emits a trailing ``Z``)."""
if not isinstance(raw, str) or not raw.strip():
return None
try:
dt = datetime.fromisoformat(raw.strip().replace("Z", "+00:00"))
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def newest_gate_update(statuses: list, *, now: datetime) -> datetime | None:
"""Most recent ``updated_at`` / ``created_at`` across all gates.
A future timestamp (clock skew) is clamped to ``now`` so it cannot
make a genuinely-old run look fresh.
"""
newest: datetime | None = None
for s in statuses:
if not isinstance(s, dict):
continue
for key in ("updated_at", "created_at"):
ts = _parse_ts(s.get(key))
if ts is None:
continue
if ts > now:
ts = now
if newest is None or ts > newest:
newest = ts
return newest
def has_pending_gate(statuses: list) -> bool:
return any(
isinstance(s, dict) and _gate_state(s) in _PENDING_GATE_STATES
for s in statuses
)
def _run_is_active(
get_action_tasks: GetActionTasksCallback,
owner: str,
repo: str,
head_sha: str,
) -> bool | None:
"""Authoritative liveness check via Forgejo's Actions API.
Returns True if any Actions task for the commit is still running,
False if every task is terminal (or there are none), None if the
lookup is unavailable / errored (caller falls back to the age check).
"""
try:
tasks = get_action_tasks(owner, repo, head_sha)
except Exception as exc:
logger.warning(
"get_action_tasks raised for %s/%s@%s: %s",
owner,
repo,
(head_sha or "")[:12],
exc,
)
return None
if tasks is None:
return None
for t in tasks:
if (
isinstance(t, dict)
and str(t.get("status") or "").strip().lower() in _ACTIVE_TASK_STATES
):
return True
return False
def classify_ci_run(
statuses: list,
*,
now: datetime,
get_action_tasks: GetActionTasksCallback | None = None,
owner: str = "",
repo: str = "",
head_sha: str = "",
) -> str:
"""Classify a CI run as ``complete`` / ``running`` / ``stale``.
``statuses`` is the per-gate list from Forgejo's combined
commit-status. An empty list yields ``complete`` — the caller's
combined-state logic then governs (no per-gate detail to reason
over).
"""
if not isinstance(statuses, list) or not statuses:
return "complete"
if not has_pending_gate(statuses):
return "complete"
# Pending gate(s) present — genuinely running, or a zombie?
# 1. Active-run check. An executing Actions task is authoritative
# proof the run is alive. The ABSENCE of one is NOT proof it is
# dead: a just-pushed run has no task until a runner picks it up,
# and there are brief gaps between jobs. So "no active task" only
# classifies as stale once the run has ALSO gone quiet past
# ZOMBIE_GRACE — otherwise the controller races its own push
# (the PR-46 incident: a 6-second-old run flagged a zombie).
if get_action_tasks is not None and owner and repo and head_sha:
active = _run_is_active(get_action_tasks, owner, repo, head_sha)
if active is True:
return "running"
if active is False:
newest = newest_gate_update(statuses, now=now)
if newest is not None and (now - newest) > ZOMBIE_GRACE:
return "stale"
return "running"
# active is None → API unavailable; fall through to the age check.
# 2. Age check — the fallback when the Actions API is unavailable.
newest = newest_gate_update(statuses, now=now)
if newest is not None and (now - newest) > STALE_AFTER:
return "stale"
return "running"
def terminal_verdict(statuses: list) -> str:
"""Verdict from the gates that reached a terminal status, ignoring
any zombie pending ones.
``failure`` if any terminal gate failed; ``success`` if there is
≥1 terminal gate and none failed; ``pending`` only when every gate
is still pending (a fully-dead run with no verdict at all — the
caller decides how to route that).
"""
seen_terminal = False
for s in statuses:
if not isinstance(s, dict):
continue
st = _gate_state(s)
if st in _PENDING_GATE_STATES:
continue
seen_terminal = True
if st in _FAILED_GATE_STATES:
return "failure"
return "success" if seen_terminal else "pending"
__all__ = [
"STALE_AFTER",
"STALE_AFTER_MIN",
"GetActionTasksCallback",
"classify_ci_run",
"has_pending_gate",
"newest_gate_update",
"terminal_verdict",
]