Files
cleveragents-core/tools/_cycle_cap.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

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",
)