Files
cleveragents-core/tools/controller/master/discovery.py
T
drew e8045d4b9b feat(controller): Phase 1d-3c-1 — discovery sweep + worker test deflake
Discovery polls Forgejo for open PRs/issues and inserts a fresh
DISCOVERED workflow for any entity the controller hasn't seen yet.
Same callback-injection pattern as the scheduler's prefetch: tests
provide synthetic ListPRsCallback / ListIssuesCallback; production
wires them to the existing _review_fetch helpers in a follow-up.

tools/controller/master/discovery.py:

- run_discovery(engine, owner, repo, list_prs, list_issues=None) —
  idempotent. Skips existing entities via a one-shot
  SELECT(kind, entity_number) WHERE owner+repo membership check.
  Inserts a DISCOVERED workflow + a controller_events 'discovered'
  row per new entity. Per-callback try/except: a PR-list failure
  doesn't block issue discovery, and vice versa.
- Defensive coerce_int: rejects bools (True is an int subclass —
  bug class to avoid). Accepts string digits.
- Returns DiscoveryReport(prs_seen, issues_seen, new_workflows,
  existing_skipped, new_entities[]).

12 new tests:
- basics (empty, PRs only, issues only, both PRs+issues)
- idempotency (2nd sweep skips; PR #42 + issue #42 coexist;
  other (owner, repo) isolated)
- malformed input (non-int + bool skipped; string digit accepted)
- callback failures (PR raises → still process issues; issues
  callback optional)
- event row creation per new workflow

Plus a worker test deflake:
test_stolen_lock_between_agent_return_and_write was asserting the
specific 'lost-lock-at-write' outcome, but under full-suite CPU
contention the heartbeat thread can fire between the agent's return
and _write_outcome — taking the 'lost-lock' (via lost_lock_event)
path instead. Both are valid for this scenario; loosened the
assertion to accept either.

Total: 310 controller tests; full auto_agents suite 2672 pass.
2026-05-18 13:49:30 -04:00

216 lines
6.8 KiB
Python

"""Discovery — populate DISCOVERED workflows from Forgejo's open PRs/issues.
The master polls Forgejo for open PRs + open issues every
``CONTROLLER_DISCOVERY_INTERVAL_S`` (default 30s). New entities (no
existing workflow row) get a fresh ``Workflow(current_state='DISCOVERED')``.
Existing entities are left alone — their state machine is already
running.
Forgejo HTTP is parameterized via two callbacks so tests can inject
synthetic responses. Production wires the callbacks to existing
helpers in ``tools/_review_fetch.py`` (Phase 1d-3c follow-up — this
commit ships the controller-side logic).
"""
from __future__ import annotations
import logging
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
logger = logging.getLogger(__name__)
# Each callback returns a list of dicts. Required keys per entry:
# - PRs: {"number": int, "title": str}
# - Issues: {"number": int, "title": str}
# Extra keys are ignored by discovery; the prefetch step (separate
# module) pulls full details later.
ListPRsCallback = Callable[[str, str], list[dict]]
ListIssuesCallback = Callable[[str, str], list[dict]]
@dataclass
class DiscoveredEntity:
"""One entity discovered by a sweep."""
workflow_id: int
kind: str # 'pr' or 'issue'
entity_number: int
@dataclass
class DiscoveryReport:
"""Per-sweep summary."""
prs_seen: int = 0
issues_seen: int = 0
new_workflows: int = 0
existing_skipped: int = 0
new_entities: list[DiscoveredEntity] = field(default_factory=list)
def run_discovery(
engine: Engine,
*,
owner: str,
repo: str,
list_prs: ListPRsCallback,
list_issues: ListIssuesCallback | None = None,
) -> DiscoveryReport:
"""One discovery sweep against Forgejo.
Idempotent — if an entity already has a workflow row, this is a
no-op for it. Safe to run every tick.
"""
report = DiscoveryReport()
# Fetch from Forgejo (or test fake).
try:
prs = list_prs(owner, repo) or []
except Exception:
logger.exception("discovery: list_prs callback raised; skipping PRs")
prs = []
try:
issues = list_issues(owner, repo) if list_issues else []
except Exception:
logger.exception("discovery: list_issues callback raised; skipping issues")
issues = []
report.prs_seen = len(prs)
report.issues_seen = len(issues)
if not prs and not issues:
return report
with session_scope(engine) as session:
# Build a set of (kind, entity_number) we already track.
existing = session.execute(
text(
"SELECT kind, entity_number FROM workflows "
"WHERE owner = :owner AND repo = :repo"
),
{"owner": owner, "repo": repo},
).all()
existing_set = {(r.kind, r.entity_number) for r in existing}
now = datetime.now(timezone.utc)
for pr in prs:
number = _coerce_int(pr.get("number"))
if number is None:
logger.warning("discovery: PR with non-int number skipped: %r", pr)
continue
if ("pr", number) in existing_set:
report.existing_skipped += 1
continue
wf_id = _insert_discovered(
session, owner, repo, kind="pr",
entity_number=number, now=now,
)
report.new_workflows += 1
report.new_entities.append(DiscoveredEntity(
workflow_id=wf_id, kind="pr", entity_number=number,
))
for issue in issues:
number = _coerce_int(issue.get("number"))
if number is None:
logger.warning("discovery: issue with non-int number skipped: %r", issue)
continue
if ("issue", number) in existing_set:
report.existing_skipped += 1
continue
wf_id = _insert_discovered(
session, owner, repo, kind="issue",
entity_number=number, now=now,
)
report.new_workflows += 1
report.new_entities.append(DiscoveredEntity(
workflow_id=wf_id, kind="issue", entity_number=number,
))
if report.new_workflows:
logger.info(
"discovery: %d new workflow(s) (PRs=%d, issues=%d); %d existing skipped",
report.new_workflows, report.prs_seen, report.issues_seen,
report.existing_skipped,
)
return report
# ─── helpers ──────────────────────────────────────────────────────────
def _coerce_int(value) -> int | None:
if isinstance(value, bool): # True is an int subclass; reject it
return None
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
def _insert_discovered(
session, owner: str, repo: str, *,
kind: str, entity_number: int, now: datetime,
) -> int:
"""Insert a fresh DISCOVERED workflow and return its id.
Uses raw SQL (not the ORM) for symmetry with the rest of the
master module + because lastrowid is the simplest way to get the
autoincremented PK from a text() insert.
"""
result = session.execute(
text(
"INSERT INTO workflows "
"(kind, owner, repo, entity_number, current_state, "
" started_at, last_transition_at, entered_state_at, "
" max_attempts, merging_retry_count) "
"VALUES (:kind, :owner, :repo, :entity_number, 'DISCOVERED', "
" :now, :now, :now, 6, 0)"
),
{
"kind": kind, "owner": owner, "repo": repo,
"entity_number": entity_number, "now": now,
},
)
wf_id = result.lastrowid
# Insert a controller_events row.
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, to_state, payload, "
" forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'discovered', 'DISCOVERED', "
" :payload, 0, 0)"
),
{
"wf_id": wf_id, "ts": now,
"payload": _discovery_payload(kind, entity_number, owner, repo),
},
)
return wf_id
def _discovery_payload(kind: str, entity_number: int, owner: str, repo: str) -> str:
import json
return json.dumps({
"kind": kind, "entity_number": entity_number,
"owner": owner, "repo": repo, "source": "discovery_poll",
})
__all__ = [
"DiscoveredEntity",
"DiscoveryReport",
"ListIssuesCallback",
"ListPRsCallback",
"run_discovery",
]