poll.py: a rate-limited tick reports NO-CHANGE and backs off, indistinguishably from a genuinely quiet one nf-8dh ← Beads

open priority 2 task unassigned

Twinned from https://github.com/cfsh/ninefold/issues/630 by tools/beads/import_github.py's reconcile pass.

**A rate-limited poll.py tick cannot tell "I read nothing" from "there was nothing to read" — it reports NO-CHANGE, advances IDLE_TICKS, and backs the cadence off precisely while the loop is blind.**

Measured on nf-dev-sg today, not inferred. Filed unclaimed from nf-dev-publish-header's quiet-tick uplift window; not folded into that chunk.

What happened

gh's GraphQL budget went to zero on this box — 5000/5000 used, one bot identity shared by every dev session here. A full digest run under exhaustion printed the guard correctly:

``` ⚠ COULD NOT READ PR #553 (gh failed) — counting it as OPEN; do not wrap up on this tick, and re-run before trusting the predicate. ```

…and then wrote a fingerprint anyway. poll-state.txt went session:publish-header|2857738245session:publish-header|3960412718, IDLE_TICKS reset to 0.

The next --check, still exhausted, is the payload:

``` GraphQL: API rate limit already exceeded for user ID 306138955. NO-CHANGE 3960412718 IDLE_TICKS=1 WAKE: quiet tick 1 — strategic-uplift window ```

**A tick that read zero bytes of GitHub reported a quiet tick and advanced the ladder.** Two more like it and the cadence is hourly; a few more and it is 4h — the loop backing off fastest at the moment it can see least.

Mechanism

Both halves are individually correct and compose into the hole.

1. lenient_run never raises (tools/pr/poll.py:57-77) — deliberate, well argued, and right: *"the digest is a REPORT: one unreadable PR or issue must degrade that entry, never take down the tick."* A failed gh call yields empty output and the tick carries on. 2. compute_fingerprint (:434) cannot see that emptiness was a failure. It does gh.pr_view(number, [...]) or {} and iterates data.get("reviews") or []. Under total API failure every branch takes the empty path and the function returns a **stable, well-formed checksum over no data** — stable being the problem, because two consecutive blind ticks hash identically and compare equal.

So the fingerprint silently changes meaning from *"the actionable surface"* to *"the actionable surface, or nothing, indistinguishably."*

The existing guard does not cover this. ⚠ COULD NOT READ PR protects the WRAP_UP existence predicate — it counts the PR as OPEN, which is why #401 promoted it and why this did not end in a false wrap-up. It does not reach the fingerprint/cadence path at all. Existence is defended; change detection is not.

⚠️ And the documented recovery makes it worse. CLAUDE.md's answer to CHANGED is *"run the full digest"* — the most quota-expensive action available (~390 lines, a pr_view per tracked PR plus a per-issue sweep). A loop that hits the limit is told to spend harder.

Why it is live now, not theoretical

The 5000/hr GraphQL budget is per user, and every session on this box polls as jcantsp-bot. The board currently lists six active sessions here, several on 17,47. Nothing coordinates them and nothing reports the shared budget, so the first symptom is a tick that looks quiet.

This is the #352 shape CLAUDE.md already names for a dead cron — *"it does not fail loudly, it stops producing digests"* — reached by a different route, and adjacent to #575 (a wiped scratchpad printing WRAP_UP with an open PR). Same class: the loop's failure mode is confident silence.

Direction, not a prescription

- Thread a "was anything unreadable this tick" flag out of Gh/lenient_run into compute_fingerprint's caller. If any call failed, **refuse to write or compare state** and emit a distinct decision line — BLIND — that holds cadence at 30m the way WAKE: STALE-DISPATCH does. A tick that cannot see is not a quiet tick and must not be spendable on the backoff ladder. - gh api rate_limit does not consume quota, so a preflight is free. It also turns the failure legible: graphql remaining=0, reset=<t>. - Cross-session budget awareness is the larger question and probably belongs with whoever owns the multi-session poll rules — noting it, not scoping it.

Not filed, because they did not survive checking

Recorded so the next reader does not re-derive them:

- Piping the digest into head truncating the state write. I believed this first. Reproduced deliberately: poll.py … | head -5 still wrote poll-state.txt (mtime advanced, contents changed). Python block-buffers a piped stdout, so BrokenPipeError surfaces at exit — after state.write at :920. Not a bug. - --check and the digest computing different fingerprints for one state. Observed once; on re-measurement both agree (218074596). The intervening change was a genuine surface change, correctly detected. Not a bug.

Dependencies

None.

Comments

No comments.

Add a comment