976817fa22
The deterministic spine of the master controller. State machine is
pure data with 6 load-bearing invariants enforced via property tests.
Reaper resets stale-heartbeat workflow_attempts to pending. Pickup
guard transitions workflows to STUCK when an attempt has been
re-pended too many times without success.
tools/controller/state_machine.py:
- KNOWN_STATES = 12; TERMINAL_STATES = {MERGED, ABANDONED, STUCK,
CREATED_PR}. STUCK's only allowed exit is the operator-driven
operator_unstick event (back to DISCOVERED).
- 32 TRANSITIONS entries covering DISCOVERED → ANALYZING →
IMPLEMENTING ↔ AWAITING_CI / CONFLICT_RESOLVING / ESCALATING →
REVIEWING → MERGING → MERGED. Plus pickup_exhausted exits from
IMPLEMENTING/CONFLICT_RESOLVING/REVIEWING.
- 27 named events with descriptions. apply_event() lookup raises
IllegalTransitionError (lists legal events from current state)
or ValueError on unknown state (per v6 unknown-state guard).
- 6 LOAD-BEARING invariants for v1 (per v9 simplification):
1. no_path_implementing_to_reviewing_skips_ci (Hard Rule #1
constructional fix for the no-mans-land race)
2. terminal_states_have_no_exits (only STUCK→operator_unstick OK)
3. tier_monotonic_non_decreasing
4. every_pr_workflow_includes_reviewing
5. conflict_resolving_bounded (1st→IMPLEMENTING, 2nd→ESCALATING,
3rd→STUCK; structurally encoded)
6. escalation_deterministic
- reachable_from() honors cycles (DISCOVERED ∈ reachable(DISCOVERED)
via STUCK→operator_unstick path; AWAITING_CI self-loops via
ci_flake_retry).
tools/controller/reaper.py:
- reap_stale_attempts(): SELECT in_progress attempts whose
lock_heartbeat_at + lock_ttl_seconds < NOW (per-row TTL respects
per-role differences — estimator 180s, reviewer 720s, tier-2
implementer 2160s). UPDATEs status='pending', clears lock columns,
preserves pickup_count (the pickup guard handles that). Inserts
controller_events row with reason='lock-ttl-expired' per reap.
- Dialect-portable: Postgres uses interval arithmetic; SQLite uses
julianday(). Same logic either way.
tools/controller/pickup_guard.py:
- transition_exhausted_to_stuck(): finds attempts with status='pending'
AND pickup_count >= MAX_PICKUPS (default 3 per v6 blocker fix)
AND workflow not already terminal. Transitions workflow → STUCK,
marks attempt as 'reaped', inserts controller_events with
reason='attempt-pickup-exhausted' + pickup_count + max_pickups.
45 new tests:
- state_machine: basic shape (states partition, every transition uses
known states + defined events), apply_event success/error paths,
events_from + reachable_from helpers (including cycle awareness),
per-invariant zero-violations against the live table, per-invariant
monkeypatch-violations to prove the checks catch the bug class they
claim to, parametrised sanity check "every non-terminal can reach
some terminal".
- reaper: empty DB / fresh heartbeat / stale heartbeat reaped /
per-row TTL respected / event row created / only-in-progress
reaped / multiple stale attempts.
- pickup guard: empty DB / below limit / at limit / in-progress not
checked / terminal workflow skipped / event payload content /
default max_pickups matches v6.
Total: 229 controller tests; full auto_agents suite 2591 pass.
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""Master-side reaper: reset stale-heartbeat workflow_attempts to
|
|
pending so another worker can re-acquire.
|
|
|
|
Per plan v9 simplified: TTL-only reaper. No JOIN-with-workflows
|
|
superseded check (deferred — orphan re-attempts no-op cheaply against
|
|
terminal workflows when the next worker tries to advance them).
|
|
|
|
Runs on the master's tick loop every ``CONTROLLER_REAPER_INTERVAL_S``
|
|
(default 60s). The reaper:
|
|
- Selects ``workflow_attempts WHERE status='in_progress' AND
|
|
lock_heartbeat_at < NOW() - lock_ttl_seconds``
|
|
- For each: status → 'pending'; clear locked_by_instance / locked_at /
|
|
lock_heartbeat_at; insert a ``controller_events`` row with
|
|
reason='lock-ttl-expired'.
|
|
|
|
The reset preserves ``pickup_count`` so the master's separate guard
|
|
catches "this attempt has been re-pended too many times → STUCK."
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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__)
|
|
|
|
|
|
@dataclass
|
|
class ReaperReport:
|
|
"""Per-sweep summary; emitted to structured logs + Prometheus."""
|
|
|
|
rows_reaped: int = 0
|
|
reaped_attempts: list[tuple[int, str | None]] = field(default_factory=list)
|
|
# Each entry: (attempt_id, locked_by_instance) — useful for
|
|
# operator forensics ("which machine's worker died?")
|
|
|
|
|
|
def reap_stale_attempts(engine: Engine) -> ReaperReport:
|
|
"""Reset stale-heartbeat in_progress attempts to pending.
|
|
|
|
The TTL is per-row (``lock_ttl_seconds``) — different roles can
|
|
have different TTLs (estimator ~180s; reviewer ~720s; tier-2
|
|
implementer ~2160s). The WHERE clause uses the per-row column.
|
|
"""
|
|
report = ReaperReport()
|
|
now = datetime.now(timezone.utc)
|
|
# Two-step approach for portability:
|
|
# 1. SELECT the candidates (so we can log them).
|
|
# 2. UPDATE them.
|
|
# We could do this in one statement on Postgres (RETURNING) and
|
|
# SQLite (RETURNING is supported in 3.35+). Splitting for
|
|
# observability — the reaper isn't a hot path.
|
|
with session_scope(engine) as session:
|
|
dialect = session.bind.dialect.name if session.bind else "sqlite"
|
|
if dialect == "postgresql":
|
|
select_sql = text(
|
|
"SELECT attempt_id, locked_by_instance, lock_heartbeat_at "
|
|
"FROM workflow_attempts "
|
|
"WHERE status = 'in_progress' "
|
|
" AND lock_heartbeat_at IS NOT NULL "
|
|
" AND lock_heartbeat_at + (lock_ttl_seconds || ' seconds')::interval < :now"
|
|
)
|
|
else:
|
|
# SQLite: TIMESTAMP arithmetic via julianday().
|
|
select_sql = text(
|
|
"SELECT attempt_id, locked_by_instance, lock_heartbeat_at "
|
|
"FROM workflow_attempts "
|
|
"WHERE status = 'in_progress' "
|
|
" AND lock_heartbeat_at IS NOT NULL "
|
|
" AND (julianday(:now) - julianday(lock_heartbeat_at)) "
|
|
" * 86400 > lock_ttl_seconds"
|
|
)
|
|
rows = session.execute(select_sql, {"now": now}).all()
|
|
if not rows:
|
|
return report
|
|
|
|
# 2. UPDATE — use IN-clause with the candidate ids.
|
|
ids = [r.attempt_id for r in rows]
|
|
# SQLAlchemy's expanding bindparam handles IN over a list.
|
|
from sqlalchemy import bindparam
|
|
update_sql = text(
|
|
"UPDATE workflow_attempts SET "
|
|
" status = 'pending', "
|
|
" locked_by_instance = NULL, "
|
|
" locked_at = NULL, "
|
|
" lock_heartbeat_at = NULL "
|
|
"WHERE attempt_id IN :ids"
|
|
).bindparams(bindparam("ids", expanding=True))
|
|
result = session.execute(update_sql, {"ids": ids})
|
|
report.rows_reaped = result.rowcount
|
|
|
|
# 3. Log + record controller_events.
|
|
events_sql = text(
|
|
"INSERT INTO controller_events "
|
|
"(workflow_id, ts, event_type, payload, forgejo_write_pending, replay_attempts) "
|
|
"VALUES ("
|
|
" (SELECT workflow_id FROM workflow_attempts WHERE attempt_id = :attempt_id), "
|
|
" :ts, 'lock-ttl-expired', :payload, 0, 0"
|
|
")"
|
|
)
|
|
for r in rows:
|
|
report.reaped_attempts.append(
|
|
(r.attempt_id, r.locked_by_instance)
|
|
)
|
|
session.execute(events_sql, {
|
|
"attempt_id": r.attempt_id,
|
|
"ts": now,
|
|
"payload": _payload_for_event(
|
|
r.attempt_id, r.locked_by_instance,
|
|
r.lock_heartbeat_at, now,
|
|
),
|
|
})
|
|
|
|
if report.rows_reaped:
|
|
logger.warning(
|
|
"reaper reset %d stale in_progress attempt(s): %s",
|
|
report.rows_reaped,
|
|
[(aid, inst) for (aid, inst) in report.reaped_attempts],
|
|
)
|
|
return report
|
|
|
|
|
|
def _payload_for_event(
|
|
attempt_id: int,
|
|
locked_by_instance: str | None,
|
|
heartbeat_at: datetime | None,
|
|
now: datetime,
|
|
) -> str:
|
|
"""Build a JSON string payload for the controller_events row."""
|
|
import json
|
|
age_s: float | None = None
|
|
if heartbeat_at is not None:
|
|
try:
|
|
# heartbeat_at may be naive depending on the DB; normalise.
|
|
if heartbeat_at.tzinfo is None:
|
|
from datetime import timezone as _tz
|
|
hb = heartbeat_at.replace(tzinfo=_tz.utc)
|
|
else:
|
|
hb = heartbeat_at
|
|
age_s = (now - hb).total_seconds()
|
|
except Exception:
|
|
age_s = None
|
|
return json.dumps({
|
|
"reaped_attempt_id": attempt_id,
|
|
"previously_locked_by": locked_by_instance,
|
|
"heartbeat_age_s": age_s,
|
|
})
|
|
|
|
|
|
__all__ = ["ReaperReport", "reap_stale_attempts"]
|