Files
cleveragents-core/tools/controller/pickup_guard.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

136 lines
4.8 KiB
Python

"""Master-side pickup-exhaustion guard.
Per plan v6 blocker fix: an attempt that's been dequeued
``MAX_PICKUPS`` times without success means something is structurally
wrong (worker bug, persistent OpenCode failure, infrastructure
issue). The master detects these and transitions the workflow to
STUCK with ``reason='attempt-pickup-exhausted'``.
The dequeue helper (``db/dequeue.py``) already enforces the
``pickup_count < MAX_PICKUPS`` filter — so exhausted attempts are
NEVER picked up. This guard handles the secondary case: an attempt
that JUST hit the limit after a failed run; the workflow needs to
transition to STUCK so it stops being re-enqueued.
Runs on the master's tick loop. Cheap query; safe to run every tick.
"""
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
logger = logging.getLogger(__name__)
DEFAULT_MAX_PICKUPS = int(os.environ.get("CONTROLLER_MAX_ATTEMPT_PICKUPS", "3"))
@dataclass
class PickupGuardReport:
"""Per-tick summary: which workflows got transitioned to STUCK."""
workflows_stuck: int = 0
stuck_workflows: list[tuple[int, int]] = field(default_factory=list)
# Each entry: (workflow_id, attempt_id_that_exhausted)
def transition_exhausted_to_stuck(
engine: Engine,
*,
max_pickups: int = DEFAULT_MAX_PICKUPS,
) -> PickupGuardReport:
"""Find attempts at or past MAX_PICKUPS that are also currently
pending (i.e., the last run failed and re-pended), and transition
their workflow to STUCK.
"At or past MAX_PICKUPS" means ``pickup_count >= max_pickups``
AND ``status = 'pending'``. The dequeue helper won't pick these
up (its filter is ``pickup_count < max_pickups``), so they'd loop
in the pending pool forever without this guard.
"""
report = PickupGuardReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
# Find candidate attempts + their workflows. A workflow in
# an already-terminal state doesn't need re-transitioning.
rows = session.execute(
text(
"SELECT a.attempt_id, a.workflow_id, a.pickup_count, "
" w.current_state "
"FROM workflow_attempts a "
"JOIN workflows w ON w.workflow_id = a.workflow_id "
"WHERE a.status = 'pending' "
" AND a.pickup_count >= :limit "
" AND w.current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR')"
),
{"limit": max_pickups},
).all()
if not rows:
return report
for r in rows:
# Transition workflow → STUCK; mark the attempt as reaped
# (so the pending pool is clean).
session.execute(
text(
"UPDATE workflows SET "
" current_state = 'STUCK', "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id"
),
{"now": now, "wf_id": r.workflow_id},
)
session.execute(
text(
"UPDATE workflow_attempts SET status = 'reaped' "
"WHERE attempt_id = :aid"
),
{"aid": r.attempt_id},
)
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, from_state, to_state, "
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'transition', :from_state, 'STUCK', "
" :aid, :payload, 0, 0)"
),
{
"wf_id": r.workflow_id,
"ts": now,
"from_state": r.current_state,
"aid": r.attempt_id,
"payload": json.dumps(
{
"reason": "attempt-pickup-exhausted",
"pickup_count": r.pickup_count,
"max_pickups": max_pickups,
}
),
},
)
report.workflows_stuck += 1
report.stuck_workflows.append((r.workflow_id, r.attempt_id))
if report.workflows_stuck:
logger.warning(
"pickup guard: %d workflow(s) transitioned to STUCK (pickup-exhausted): %s",
report.workflows_stuck,
report.stuck_workflows,
)
return report
__all__ = ["DEFAULT_MAX_PICKUPS", "PickupGuardReport", "transition_exhausted_to_stuck"]