0bc734c020
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>
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""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 ``<repo>/.dispatcher-logs/cycle-cap/<role>-<owner>-<repo>-<number>.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",
|
|
)
|