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.
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|2857738245 → session: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.
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.
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.
- 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.
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.
None.
No comments.