Files
cleveragents-core/tools/controller/master/ci_poll.py
T
drew 0db0a15dad feat(controller): RUN_CI_LOCAL verdict source, ci-not-ready outcome, escalation hardening
Adds RUN_CI_LOCAL — an on-demand local-CI verdict source for when the
cluster's Forgejo CI is broken — plus robustness fixes, the telemetry
Live-tab rewrite, and PR-level cost attribution.

Controller:
- RUN_CI_LOCAL: the master swaps its Forgejo CI callbacks for local
  `forgejo-runner exec` runs (tools/run-ci-full-local.sh + local_ci.py).
  Async per-(owner,repo,SHA) on-disk job cache; preflights the
  forgejo-runner binary + Docker daemon at startup (fail loud, not a
  red verdict on every PR); GCs finished run dirs + per-run actcache.
- ci-not-ready implementer outcome + implementer_ci_not_ready event:
  an implementer that runs before the on-demand verdict exists parks
  in AWAITING_CI instead of dead-ending at STUCK; capped against
  ci_red ping-pong.
- ci_poll_exhaustion skips its sweep while a local CI run is in
  flight, so AWAITING_CI workflows queued behind on-demand CI are not
  STUCK'd by the remote-CI-sized timeout.
- Escalate the workflow after repeated worker-internal-error at a
  tier instead of retrying to pickup-exhaustion -> STUCK.
- forgejo_http: normalise Forgejo's per-gate `status` key to `state`
  so failing gates are actually counted (they previously all read as
  pending).
- Per-tier worker timeouts bumped +15 min; a timed-out attempt's
  dirty-worktree residue is preserved on auto-scratch/pr-<N> before
  the next attempt's reset.
- Implementer agents now verify only the CI-flagged gate(s) via a
  targeted re-run rather than the full local battery before claiming
  resolved/noop. Re-running the whole suite CI will run anyway was the
  #1 cause of implementer timeouts; CI remains the real gate and
  re-dispatches the implementer on red.

Telemetry:
- Live tab rebuilt on /api/live (controller DB run state + the live
  OpenCode session forest) after the live_log_writer sidecar was
  retired with the legacy dispatchers.
- Durable per-attempt input/output payloads surfaced in the Live
  drill-down, archived-session detail, and Workflows timeline.
- PR-level cost attribution: worker session tags carry -pr-<n>;
  backfill_llm_activity_pr.py repairs rows written before the fix.

Shared:
- tools/controller/session_tag.py — one canonical controller-tag
  parser shared by the telemetry server and the backfill.

Tests: new coverage for local_ci (state machine, log parsing,
_summarize_run, GC, in-flight probe, preflight), the ci-not-ready
path, the escalation/ci-not-ready SQL counters, the ci_poll
in-flight skip, and CI-status payload parsing across both sources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:49:05 -04:00

206 lines
7.7 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 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
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,
local_ci_in_flight: Callable[[], bool] | 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).
``local_ci_in_flight`` (wired only under ``RUN_CI_LOCAL``): when it
reports a local CI run is executing, the whole sweep is skipped.
Local CI is on-demand and serial — a verdict lands minutes after
the implementer finishes, and other AWAITING_CI workflows queue
behind it. While that pipeline is busy every AWAITING_CI wait is
legitimate progress, so the poll-exhaustion timer (sized for remote
CI) must not STUCK a workflow whose verdict is simply not done yet.
"""
threshold = timeout_s or DEFAULT_AWAITING_CI_TIMEOUT_S
report = CIPollExhaustionReport()
now = datetime.now(timezone.utc)
if local_ci_in_flight is not None:
try:
if local_ci_in_flight():
logger.info(
"ci_poll_exhaustion: local CI run in flight — skipping "
"this sweep; AWAITING_CI workflows keep waiting for the "
"on-demand verdict"
)
return report
except Exception: # noqa: BLE001 — a probe failure must not abort the sweep
logger.exception(
"ci_poll_exhaustion: local-CI in-flight probe raised; "
"proceeding with the sweep"
)
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",
]