981ddd6a8e
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>
223 lines
8.2 KiB
Python
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",
|
|
]
|