Files
cleveragents-core/tools/controller/master/ci_poll.py
T
drew 9b6b64f0d3 fix(controller): batch I — round-3 correctness items (R2, R7, R9)
R2 — STUCK short-circuits the opt-in label gate:
Round-2's batch G shipped reconciliation-ordering with merged/closed
winning over label removal. But _decide_transition returns
("STUCK", "pr-not-found-on-forgejo") for 404s — that STUCK was
falling through to the label gate. Operator removing the label on a
deleted PR could PAUSE it forever.

Fix: extend the short-circuit set in master/reconciliation.py to
include STUCK alongside MERGED/ABANDONED. All Forgejo-terminal
transitions now bypass the label gate.

Test: test_pr_404_takes_priority_over_label_removal pins the
contract end-to-end (404 + no opt-in label → STUCK, not PAUSED).

R7 — defense-in-depth for corrupt workflow state in ci_poll:
ci_poll.py only caught IllegalTransitionError from apply_event, but
apply_event raises ValueError for states not in KNOWN_STATES (DB
row corruption, unknown-state guard miss). A single bad row would
have aborted the whole tick.

Fix: broaden the exception handler to (IllegalTransitionError,
ValueError). One bad row is skipped; valid rows still STUCK.

Test: test_unknown_state_skips_row_doesnt_crash_tick monkey-patches
apply_event to raise ValueError once + verifies the tick processes
the other workflow normally.

R9 — distinct event_types per reconciliation reason:
Pause / resume / external-merge / external-close / external-issue-
close / pr-not-found-on-forgejo all used event_type='reconciliation'.
Operators querying controller_events for "what happened" could
only distinguish via JSON-payload LIKE queries — dialect-specific
(SQLite LIKE vs Postgres ::jsonb->>).

Fix: master/reconciliation.py introduces _REASON_TO_EVENT_TYPE
mapping:
- opt-in-label-removed       → 'label-pause'
- opt-in-label-restored      → 'label-resume'
- externally-merged          → 'external-merge'
- externally-closed-not-merged → 'external-close'
- issue-closed-externally    → 'external-issue-close'
- pr-not-found-on-forgejo    → 'external-pr-deleted'
Unknown reasons fall back to 'reconciliation' so future contributors
adding a new reason still emit a well-formed row.

Both _apply_transition and _apply_transition_with_pre_pause now
derive event_type via _event_type_for(reason).

Tests updated (filter by new event_type per case):
- test_master_reconciliation.py::TestEventRows refactored:
  - test_transition_emits_external_merge_event
  - test_closed_pr_emits_external_close_event (NEW)
  - test_pr_404_emits_external_pr_deleted_event (NEW)
  - test_consistent_workflow_no_event widened to all 7 event_types
- test_label_gate.py: pause/resume tests filter by 'label-pause' /
  'label-resume' respectively + assert the event_type matches.

Total: 693 controller tests pass (+2 net), 0 regressions.

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

167 lines
6.2 KiB
Python

"""AWAITING_CI poll-exhaustion handler.
Per plan v9 + the round-2 review (item N2): without this, workflows
that enter ``AWAITING_CI`` and never receive ``ci_green`` / ``ci_red_*``
events (CI runner outage, broken integration, etc.) hang indefinitely
— only ``operator_unstick`` could rescue them.
This module ships the minimum-viable escape: a periodic scan that
finds workflows whose ``entered_state_at`` for AWAITING_CI exceeds
the configured poll-exhaustion threshold + fires the
``ci_polling_exhausted`` event → STUCK.
What this module DOES NOT do (yet):
- Actual CI status polling against Forgejo. The reconciliation tick
+ the prefetch-driven CI summarizer cover that path; this handler
is the EXIT for workflows that have been polling-without-progress
for too long.
- Differentiating "CI never reported" from "CI reported but the
controller missed it". Both fall under the same timeout.
Default threshold: 2 hours (``CONTROLLER_AWAITING_CI_TIMEOUT_S``).
Operators can tune per repo via env.
"""
from __future__ import annotations
import json
import logging
import os
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
logger = logging.getLogger(__name__)
DEFAULT_AWAITING_CI_TIMEOUT_S = int(
os.environ.get("CONTROLLER_AWAITING_CI_TIMEOUT_S", "7200")
)
@dataclass
class CIPollExhaustionReport:
"""Per-sweep summary."""
workflows_scanned: int = 0
workflows_exhausted: int = 0
exhausted_workflow_ids: list[int] = field(default_factory=list)
def run_ci_poll_exhaustion_tick(
engine: Engine, *,
timeout_s: int | None = None,
) -> CIPollExhaustionReport:
"""One sweep: STUCK any AWAITING_CI workflow whose entered_state_at
is older than ``timeout_s``.
Composes with the master's tick layers (the loop calls this on
the same cadence as reconciliation by default — see loop.py).
"""
threshold = timeout_s or DEFAULT_AWAITING_CI_TIMEOUT_S
report = CIPollExhaustionReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
dialect = session.bind.dialect.name if session.bind else "sqlite"
if dialect == "postgresql":
select_sql = text(
"SELECT workflow_id, current_state, entered_state_at "
" FROM workflows "
" WHERE current_state = 'AWAITING_CI' "
" AND entered_state_at IS NOT NULL "
" AND entered_state_at + (:threshold || ' seconds')::interval < :now"
)
else:
# SQLite: TIMESTAMP arithmetic via julianday.
select_sql = text(
"SELECT workflow_id, current_state, entered_state_at "
" FROM workflows "
" WHERE current_state = 'AWAITING_CI' "
" AND entered_state_at IS NOT NULL "
" AND (julianday(:now) - julianday(entered_state_at)) "
" * 86400 > :threshold"
)
rows = session.execute(
select_sql, {"threshold": threshold, "now": now},
).all()
report.workflows_scanned = len(rows)
for row in rows:
try:
new_state = apply_event(
row.current_state, "ci_polling_exhausted",
)
except (IllegalTransitionError, ValueError) as exc:
# IllegalTransitionError: state doesn't accept the event.
# ValueError: state name isn't in KNOWN_STATES (DB row
# corruption or an unknown-state guard miss). Both
# should skip this row rather than aborting the tick.
logger.warning(
"ci_polling_exhausted: workflow_id=%s state=%r "
"rejected the event (%s); skipping",
row.workflow_id, row.current_state, exc,
)
continue
session.execute(
text(
"UPDATE workflows SET "
" current_state = :to_state, "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id"
),
{
"to_state": new_state, "now": now,
"wf_id": row.workflow_id,
},
)
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, 'ci_poll_exhausted', "
" :from_state, :to_state, :payload, 0, 0)"
),
{
"wf_id": row.workflow_id, "ts": now,
"from_state": row.current_state,
"to_state": new_state,
"payload": json.dumps({
"reason": "awaiting_ci_timeout",
"threshold_seconds": threshold,
# text() SELECTs return TIMESTAMP as a string on
# SQLite (and a datetime on Postgres). Normalize.
"entered_state_at": (
row.entered_state_at.isoformat()
if hasattr(row.entered_state_at, "isoformat")
else (str(row.entered_state_at)
if row.entered_state_at else None)
),
"source": "ci_poll_exhaustion",
}),
},
)
report.workflows_exhausted += 1
report.exhausted_workflow_ids.append(row.workflow_id)
if report.workflows_exhausted:
logger.warning(
"ci_poll_exhaustion: %d workflow(s) STUCK after %ds in "
"AWAITING_CI (ids: %s)",
report.workflows_exhausted, threshold,
report.exhausted_workflow_ids,
)
return report
__all__ = [
"CIPollExhaustionReport",
"DEFAULT_AWAITING_CI_TIMEOUT_S",
"run_ci_poll_exhaustion_tick",
]