Repository navigation
Description
github-backup(ghb_common): inventory_owner paginates forever when a page says hasNextPage with a null/empty endCursor — the reconcile sweep hangs re-fetching page 1 instead of raising
infra/github-backup/bin/ghb_common.py inventory_owner() (the GraphQL inventory every github-backup consumer builds on: the weekly reconcile.py sweep, clone_all.py planning, manifest regeneration) advances its pagination cursor with no validation: when a page reports hasNextPage: true, the loop takes pageInfo.endCursor as the next after unconditionally (ghb_common.py:126-127). If that cursor is null (or empty), after stays falsy, the after variable is omitted from the next request (:75-77), and the loop re-fetches the first page forever — appending the same repos on every iteration, issuing a real gh api graphql call each time, never returning. Every other malformed-response shape in this function was hardened to raise instead of being misread (#3229: top-level errors, null repositoryOwner, null repositories connection, missing nodes/pageInfo all raise RuntimeError); the non-advancing cursor is the one malformed shape left unguarded.
Evidence (file:line, origin/main 2bcd46e0dc)
ghb_common.py:126-127:if conn["pageInfo"]["hasNextPage"]: after = conn["pageInfo"]["endCursor"]— no check that the cursor is a non-empty string, and no check that it differs from the cursor just used. A repeated (stuck) cursor loops the same way.ghb_common.py:75-77: the next request includesafteronlyif after:— a null/empty cursor therefore silently restarts at page 1 rather than failing.ghb_common.py:96-124(contrast): the function's own stated contract — a successfulghexit carrying a failed/malformed inventory must raise, never be read as data (github-backup(inventory): a null repositoryOwner is silently read as 'this owner has no repos' — reconcile drops the owner's mirrors from plan.json and MANIFEST.json and exits 0 #3229 comment,:96-102). The cursor path violates that contract by hanging instead of raising.- Callers hang with it:
reconcile.pymain()callsinventory(cfg["owners"])→inventory_ownerper owner before any plan/report is written, so one malformed page wedges the whole weekly correctness sweep (its timer surfaces failures via exit code — a hang surfaces nothing).
Verification (laptop probe, this run)
Probe ~/git/fleet/probe-ghb-cursor/ against the real ghb_common.py at origin/main 2bcd46e0dc, with ghb_common.run stubbed to return canned GraphQL pages:
- Malformed page (
hasNextPage: true,endCursor: null, one node):inventory_ownerwas still looping after 6 identical page fetches — the probe aborted it there; without the abort it never returns (each iteration appends a duplicate repo and makes another API call). - Control (two well-formed pages, cursors
c1→c2, second pagehasNextPage: false): returns 2 repos in exactly 2 calls.
Why it matters
The reconcile sweep is the github-backup system's correctness floor — the guarantee that no repo is missed. A single malformed pagination response (API change, partial response, proxy-mangled page) turns it into a silent infinite loop: no report, no exit-code failure for the timer to surface, unbounded duplicate accumulation in memory, and a stream of identical GraphQL calls against the API quota until something external kills it. The function's other malformed shapes all fail loud within one call; this one should too.
Acceptance criteria
-
inventory_ownerraisesRuntimeError(the function's existing failure mode, naming the owner and the malformed cursor) when a page reportshasNextPage: truewith a null, empty, or non-advancing (same-as-just-used)endCursor, instead of issuing another request. - Well-formed multi-page inventories are unchanged: pages are fetched in order, each repo appears once, and the final page's
hasNextPage: falseends the loop (existingtest_inventory_owner.pysuite passes unchanged). - A regression test in
infra/github-backup/tests/test_inventory_owner.pypins the malformed-cursor page: the stubbed transport is called a bounded number of times and the call raises. -
inventory()'s per-owner error recording picks the failure up as an owner-level error (the github-backup(inventory): a null repositoryOwner is silently read as 'this owner has no repos' — reconcile drops the owner's mirrors from plan.json and MANIFEST.json and exits 0 #3229 path), soreconcile.pyreports it and exits non-zero rather than hanging.
Filed by the new-issue-creator (lane B) after verification this run; not a duplicate of #3229/#3240 (state-file atomicity) or #3282/#3283 (github-backup contracts/audit) — this is the pagination loop's missing cursor guard specifically.
Activity
developing-today commentedon Nov 6, 2026
CODE-1984
dezren39 commentedon Nov 6, 2026
🤖 Agent note · filed
dezren39 commentedon Nov 6, 2026
agent-note:v1
🤖 Agent note · picked up
inventory_ownernull/empty endCursor pagination defect at current main, scope the fix path, record blockers/ordering12 remaining items
dezren39 commentedon Nov 6, 2026
agent-note:v1
🤖 Agent note · picked up
dezren39 commentedon Nov 6, 2026
agent-note:v1
🤖 Agent note · PR opened
dezren39 commentedon Nov 6, 2026
agent-note:v1
🤖 Agent note · parked
dezren39 commentedon Nov 6, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 6, 2026
🤖 Agent note · handed off
opencodedezren39 commentedon Nov 6, 2026
🤖 Agent note · design-architect pass 63 — adopted as a phase of design #6622
files/designs/code-pagination-contract.mdin the keep-the-four-repos-healthy goal workspace. Audit at origin/main075b2de90