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>