"""Iteration cap for the reviewer + implementer dispatcher loops. Why this exists --------------- Live-observed run-1 (2026-05-17): the reviewer dispatcher picked up PR #35 ELEVEN times in a row, each cycle emitting a COMMENT-only review (the 422 cursor bug forced ``data_complete=False`` which forced the MUST-NOT-APPROVE gate). No state changed between cycles. The pipeline spent ~$2 of LLM budget on identical work that produced no progress. The implementer's review-implement-review-implement loop has a similar shape: alternating REQUEST_CHANGES + implementer-fix can run forever if the model can't satisfy the reviewer. This module gives both dispatchers a shared mechanism to: 1. Compute a per-(role, pr_number) **signature** that captures "did anything meaningful change since the last cycle." Today the signature is ``(head_sha, comment_count)``. A delta in either means the dispatcher SHOULD run; identical signature for N cycles in a row means it should NOT. 2. Track the consecutive no-progress count per role+PR. Persisted to disk so a dispatcher restart preserves the budget — restart shouldn't be an accidental reset that gives the loop a fresh N cycles. 3. Decide whether to skip the cycle, AND apply the ``auto/needs-human-triage`` label so the PR drops out of every work-group filter until an operator removes the label. Storage ------- JSON files at ``/.dispatcher-logs/cycle-cap/---.json``. One per (role, PR) — independent counts. Atomic write via tmp+rename, guarded by ``fcntl.flock`` on a sidecar ``.lock`` file so two dispatcher processes that pick up the same PR in the same cycle can't double-increment the counter (or under-count via lost update). The state directory lives inside the repo (gitignored via ``.dispatcher-logs/``) rather than ``/tmp`` because ``/tmp`` is tmpfs on most distros — a reboot would reset every PR's iteration budget, defeating the whole point of the safety mechanism. Override via ``CYCLE_CAP_DIR`` if you want the old transient behaviour or a shared-host layout. Schema:: { "role": "review", # "review" or "implementer" "owner": "drew", "repo": "cleveragents-core", "pr_number": 35, "signature": "sha:abc123|comments:7", "count": 5, # consecutive no-progress cycles "first_at": "2026-05-17T15:09:57+00:00", "last_at": "2026-05-17T15:46:11+00:00" } Threshold --------- ``CYCLE_CAP_MAX_NO_PROGRESS`` (default ``5``) — after this many consecutive identical-signature cycles, ``should_skip()`` returns True. Tunable via env ``CYCLE_CAP_MAX_NO_PROGRESS``. Clamped to ``[2, 50]`` so a typo like ``=999999`` can't silently disable the safety mechanism. """ from __future__ import annotations import datetime as _dt import fcntl import json import logging import os from contextlib import contextmanager from pathlib import Path from typing import Any _logger = logging.getLogger("cycle_cap") _REPO_ROOT = Path(__file__).resolve().parent.parent _DEFAULT_DIR = _REPO_ROOT / ".dispatcher-logs" / "cycle-cap" _DIR_ENV = "CYCLE_CAP_DIR" _MAX_DEFAULT = 5 _MAX_FLOOR = 2 _MAX_CEILING = 50 _MAX_ENV = "CYCLE_CAP_MAX_NO_PROGRESS" _DISABLE_ENV = "CYCLE_CAP_DISABLE" TRIAGE_LABEL = "auto/needs-human-triage" def state_dir() -> Path: return Path(os.environ.get(_DIR_ENV) or str(_DEFAULT_DIR)) def state_path(role: str, owner: str, repo: str, pr_number: int) -> Path: """Per-(role, PR) JSON file path. Owner+repo embedded so a multi-repo dispatcher can't accidentally collide PR numbers.""" import re as _re safe_owner = _re.sub(r"[^a-zA-Z0-9_.-]", "", str(owner))[:64] safe_repo = _re.sub(r"[^a-zA-Z0-9_.-]", "", str(repo))[:64] safe_role = _re.sub(r"[^a-zA-Z0-9_-]", "", str(role))[:32] return state_dir() / ( f"{safe_role}-{safe_owner}-{safe_repo}-{int(pr_number)}.json" ) def is_disabled() -> bool: raw = os.environ.get(_DISABLE_ENV, "").strip().lower() return raw in {"1", "true", "yes", "on"} def max_no_progress() -> int: """Resolve the configured threshold, clamped to ``[_MAX_FLOOR, _MAX_CEILING]``. A floor < 2 makes the cap fire on the first cycle (useless); a ceiling > 50 lets a typo'd ``CYCLE_CAP_MAX_NO_PROGRESS=999999`` silently disable the safety mechanism. The default ``5`` is what production has been tuned against.""" raw = os.environ.get(_MAX_ENV) if raw: try: return min(_MAX_CEILING, max(_MAX_FLOOR, int(raw))) except ValueError: pass return _MAX_DEFAULT def _now() -> str: return _dt.datetime.now(_dt.timezone.utc).isoformat() @contextmanager def _per_pr_flock(path: Path): """Hold an exclusive flock on a sidecar ``.lock`` file for the duration of a read-modify-write cycle. Prevents the lost-update race where two dispatcher processes on the same host pick up the same PR simultaneously: both would otherwise read ``count=N``, both write ``count=N+1``, and only one increment would "stick" — undercounting the cap. The lock file is separate from the data file so the atomic ``tmp+rename`` of the data file doesn't invalidate the open file descriptor the lock is held on. """ path.parent.mkdir(parents=True, exist_ok=True) lock_path = path.with_suffix(path.suffix + ".lock") fd = None try: # ``a+`` creates the file if missing without truncating it; # we never read or write its contents — the file is purely # an flock anchor. fd = open(lock_path, "a+") try: fcntl.flock(fd.fileno(), fcntl.LOCK_EX) except OSError as exc: _logger.warning( "cycle-cap flock failed for %s: %s; proceeding without lock", lock_path, exc, ) yield finally: if fd is not None: try: fcntl.flock(fd.fileno(), fcntl.LOCK_UN) except OSError: pass try: fd.close() except OSError: pass def compute_signature(head_sha: str, comment_count: int) -> str: """Canonical signature for a PR's state at pickup time. ``head_sha`` covers code-change progress; ``comment_count`` covers reviewer/operator activity. A change in EITHER axis means the dispatcher's prior cycle is no longer authoritative — reset the counter. Identical signature means: nothing the dispatcher cares about has moved since last time. """ return f"sha:{head_sha or 'unknown'}|comments:{int(comment_count or 0)}" def _read(path: Path) -> dict[str, Any] | None: if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: _logger.warning("cycle-cap state read failed for %s: %s", path, exc) return None if not isinstance(payload, dict): return None return payload def _write(path: Path, payload: dict[str, Any]) -> None: tmp = path.with_suffix(path.suffix + ".tmp") try: path.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") tmp.replace(path) except (OSError, TypeError, ValueError) as exc: _logger.warning("cycle-cap state write failed for %s: %s", path, exc) try: tmp.unlink(missing_ok=True) except OSError: pass def record_pickup( role: str, *, owner: str, repo: str, pr_number: int, signature: str, ) -> dict[str, Any]: """Record this cycle's pickup. Returns the updated state dict with ``count`` reflecting consecutive no-progress cycles (including this one). Behaviour: - First pickup on this PR → count=1, count starts at 1. - Subsequent pickup with SAME signature as prior → count++. - Subsequent pickup with DIFFERENT signature → count=1 (reset). Counter at 1 means "this is the first cycle in the current no-progress streak"; that's NOT a skip-worthy state. Only when count >= ``max_no_progress()`` does ``should_skip`` flip to True. """ if is_disabled(): return {"role": role, "count": 0, "signature": signature, "disabled": True} path = state_path(role, owner, repo, pr_number) now = _now() with _per_pr_flock(path): prior = _read(path) if prior is None or prior.get("signature") != signature: new_state = { "role": role, "owner": owner, "repo": repo, "pr_number": int(pr_number), "signature": signature, "count": 1, "first_at": now, "last_at": now, } else: new_state = { **prior, "count": int(prior.get("count") or 0) + 1, "last_at": now, } _write(path, new_state) return new_state def should_skip(state: dict[str, Any]) -> bool: """True iff the cycle should be skipped per the cap. The caller typically passes the dict ``record_pickup`` just returned.""" if state.get("disabled"): return False return int(state.get("count") or 0) >= max_no_progress() def clear(role: str, *, owner: str, repo: str, pr_number: int) -> None: """Reset the counter for this (role, PR). Called when an operator removes the triage label OR when work that DID make progress completes (e.g., a successful merge). Removes both the data file and the sidecar ``.lock`` file so a future ``record_pickup`` for the same PR starts from a clean slate without inheriting an orphaned lock anchor. """ path = state_path(role, owner, repo, pr_number) for target in (path, path.with_suffix(path.suffix + ".lock")): try: target.unlink(missing_ok=True) except OSError: pass def is_triaged_label(label_name: str) -> bool: """Convenience predicate — ``True`` iff a label-name string matches the triage label exactly.""" return label_name == TRIAGE_LABEL def labels_carry_triage(labels: list[dict[str, Any]] | list[str]) -> bool: """``True`` if the labels list contains the triage label. Accepts either the Forgejo dict shape (``{"name": ...}``) or a flat list of label names.""" for entry in labels or []: if isinstance(entry, dict): if entry.get("name") == TRIAGE_LABEL: return True elif isinstance(entry, str): if entry == TRIAGE_LABEL: return True return False __all__ = ( "TRIAGE_LABEL", "clear", "compute_signature", "is_disabled", "is_triaged_label", "labels_carry_triage", "max_no_progress", "record_pickup", "should_skip", "state_dir", "state_path", )