poll.py/nflib.gh: a rate-limited gh read is indistinguishable from an issue with no comments, so a blind tick reports as a quiet one nf-6rj ← Beads

open priority 2 task unassigned

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

A gh call that FAILS and an issue that genuinely has NO comments produce the same value, so a rate-limited poll tick reports the same thing as a quiet one: nothing new.

What happened

2026-08-06 ~11:52 UTC, a routine tick on this box. The digest printed:

``` ════ ISSUES CLAIMED BY THIS SESSION · LAST 3 COMMENTS ════ ════ IDLE ════ ```

Header, no content. My claimed issue #601 has a CLAIM comment on it and had since 06:53, so that section had been three lines every previous tick.

``` $ gh api rate_limit --jq .resources core: remaining 4998 graphql: remaining 0 ```

The GraphQL budget was exhausted; REST was untouched at 4998/5000. gh issue view / gh pr view spend GraphQL, so every issue read in the sweep failed while the tool carried on.

⚠️ Note what did NOT go wrong, because the alarming reading is the first one and it is not right. mine was still non-empty — the label/claim sweep that populates it had already succeeded — so WRAP_UP was correctly suppressed and the stop-condition predicate held. I initially read the empty section as "#601 has been closed or unclaimed" and had to check via REST (gh api repos/cfsh/ninefold/issues/601, which had budget) to establish it is open, labelled active+session:spawn-box, CLAIM intact. This is a missed-feedback defect, not a false-wrap-up one.

Mechanism

Two or fallbacks in tools/nflib/gh.py, each individually reasonable:

```python def issue_view(self, number, fields): return self.json(["issue", "view", ...]) or {}

def issue_comments(self, number): return self.issue_view(number, ["comments"]).get("comments", []) or [] ```

A failed call → {}[]. poll.py's emit_claimed then does (gh.issue_comments(n) or [])[-3:] and prints nothing. Same output as an issue nobody has commented on. Nothing is raised, nothing is logged, exit code is 0.

Why this matters more than a cosmetic gap

CLAUDE.md is explicit that this is a primary feedback channel:

> ⚠️ Issues carry conversations, not just titles. Aedan drops new work and design feedback as comments on *existing* open issues. Poll them.

and the poll loop's own instruction for every tick is "advance the issues in your chunk and read their latest comments." On a rate-limited tick that read silently returns nothing and the session concludes nothing arrived. A comment from Aedan landing in that window is invisible and stays invisible — the next tick's fingerprint may have moved on, and nothing re-reads it.

It is structural, not a freak

So the budget is expected to run out, repeatedly, at the busiest times — which are exactly the times feedback is most likely to be arriving.

Shape of a fix (not prescribing)

The root is that json() or {} erases the difference between "asked and got nothing" and "could not ask". Options that occur to me, in rough order of cost:

  1. Distinguish failure from emptiness at the nflib.gh boundary, and have poll.py print something like ⚠ could not read comments for #601 (gh failed) instead of silence. A tick that could not read is not a quiet tick and should not be reported as one.
  2. Check rate_limit before a full digest and say so up front — one cheap REST call. A digest that runs at 200 GraphQL remaining is going to be partly blind.
  3. Prefer REST for the reads that have a REST equivalent. Today's numbers make this concrete: GraphQL 0, REST 4998. Issue/PR/comment reads are all available over REST, so the tier could degrade to the budget that is not exhausted rather than to silence.

Related: #618 — same failure *direction* (silent degradation reported as "nothing to do"), different input (a missing tracked-prs.txt). Both are the #389 shape the tooling tier already guards against elsewhere; emit_idle's PREDICATE UNEVALUABLE: no sid given is the pattern being asked for here.

Found while polling PR #603 (#601). Unclaimed.

Dependencies

None.

Comments

No comments.

Add a comment