Files
cleveragents-core/tools/_cycle_cap.py
T
drew 205bf56eec feat(auto-agents): cycle-cap safety mechanism + tests + hardening
R3.4 (commit 3f12e4140) added the cycle-cap integration to
_pr_classification_cache / _dispatch_runtime but the implementation
module itself (``tools/_cycle_cap.py``) was never committed — the
branch was broken at HEAD for any fresh checkout. This commit adds
the missing module, a 38-test unit suite, and three hardening
fixes addressed by review:

The mechanism (recap):
After ``CYCLE_CAP_MAX_NO_PROGRESS`` (default 5) consecutive
dispatcher cycles with an identical ``(head_sha, comment_count)``
signature, the dispatcher applies ``auto/needs-human-triage`` to
break a no-progress loop. Live-observed need: run-1 (2026-05-17)
saw PR #35 picked up ELEVEN times in a row with identical state,
burning ~$2 of LLM budget on identical work.

Hardening vs. the WIP version:

- **Storage moved out of ``/tmp``** to
  ``<repo>/.dispatcher-logs/cycle-cap/``. ``/tmp`` is tmpfs on most
  distros — a reboot would reset every PR's iteration budget,
  silently defeating the entire safety mechanism. Override via
  ``CYCLE_CAP_DIR`` for the old transient behaviour.

- **Per-PR ``fcntl.flock``** around the read-modify-write of the
  state file. Without it, two dispatcher processes on the same host
  picking up the same PR simultaneously could lose-update the
  counter (both read N, both write N+1, only one increment sticks).
  Lock lives on a sidecar ``.lock`` file so the atomic
  ``tmp+rename`` of the data file doesn't invalidate the held fd.

- **Sanity ceiling on ``CYCLE_CAP_MAX_NO_PROGRESS``** at 50. A
  typo'd ``=999999`` would otherwise silently disable the cap. The
  floor stays at 2 (a cap of 1 would fire on the first cycle and
  be useless).

Tests:
- 38 unit tests in ``tests/auto_agents/test_cycle_cap.py`` covering
  signature stability + change semantics, record_pickup increment /
  reset / disable, max_no_progress clamping at both bounds, clear()
  removing both data and lock files, label-helper shape acceptance,
  atomic-write resilience to corrupt prior state, default-storage-
  location pin (regression guard against /tmp creep), and flock
  serialisation correctness.

Plus the label substrate:

- ``setup_auto_labels.py`` registers the new
  ``auto/needs-human-triage`` label. Discovered Forgejo's silent
  500 on description > 255 chars (live-verified 2026-05-17); added
  a local guard that surfaces a useful error before the API call.

- ``test_setup_auto_labels.py`` adds the new label to the expected
  registry.

- ``test_pr_classification_cache.py`` sets ``CYCLE_CAP_DISABLE=1``
  in its fixture so tests that loop the same PR through
  ``refresh_then_filter`` multiple times don't spuriously trigger
  the cap and get triage-labeled out of the test assertions.

Finally, commits the project-shared ``.claude/settings.json`` — the
graphify-knowledge-graph reminder hook that every dev working on
this repo benefits from (the per-user ``settings.local.json`` stays
gitignored as before).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:05:05 -04:00

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