0db0a15dad
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>
206 lines
7.7 KiB
Python
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",
|
|
]
|