The done-signal is a per-branch pull lookup and every tracker listing is bounded — no read grows with repo history¶
Issue #176 (2026-07-18): everything that touched the forge got slower as the repo accumulated PRs, forever. Tracker.Pulls() listed every pull the repo ever had — state=all, 50 per page, sequentially until an empty page — and two hot paths ran that walk: the AFK reaper once per repo per tick (854 walks in 17 hours, only to answer "does an open-or-merged PR exist for this run's branch"), and labctl pr list (6.7 s measured at ~174 PRs on the live forge). Cost was linear in history — one extra 1.3 s page per 50 merged PRs, doubling roughly every two weeks at the current merge pace — and the reaper's own request stream loaded the forge enough to slow every other forge read, including the walk itself. The sibling Issues(state) walk had the same shape on its closed/all views.
The decisions, pinned:
-
The reaper's done-signal is
PullsForHead(head, base)— a per-branch seam method, not a listing. One bounded read per active run per tick (O(active runs), independent of history), implemented by all three backends: Forgejo's by-base-head lookup (GET /pulls/{base}/{head}, branch names path-escaped —afk/63travels as one segment), GitHub's server-side filter (head=<owner>:<branch>— the owner prefix is mandatory; a bare name is silently ignored), and an in-memory filter over the builtin store's one cheap query. The method ENUMERATES candidates; the unchanged pure functions (DonePull/PullState, open > merged > closed) still project the winner, so collision precedence lives in exactly one place. New instrumentation oppulls_for_head; the reaper's per-tickop="pulls"count drops to zero. -
The read is base-scoped by design. Forgejo has no head filter on its list endpoint (verified against the live 15.0.5 swagger), so the per-branch lookup needs a base — and the agent API pins
repo.DefaultBranchas the base of every lab-created PR, which is what the reaper passes. A pull retargeted to another base after creation no longer answers the done-signal; that narrowing is accepted and documented on the seam method. -
Two Forgejo quirks are contract pins, verified live. (1) A merged pull whose head branch was deleted renders a synthetic
head.ref(refs/pull/N/head) while remaining findable by branch NAME — so the fast path fillsHeadBranchfrom the queried branch, never the rendered ref, orDonePullwould miss the exact pull that proves the run succeeded. The contract still covers live head branches only (an active run's branch outlives the run). (2) The by-base-head lookup fetches its row with no ORDER BY, so on a re-used branch a closed-unmerged row may shadow a live sibling; closed-unmerged is the one state the done-signal reads as absence, so ONLY that answer pays for a fullstate=allfallback walk — the hot states (404, open, merged) stay one request. -
Pulls()and the closed/allIssues()views are windowed: the full OPEN set plus theRecentClosedWindow(50) most recently closed. Open sets are the working set and stay complete; closed sets grow forever and are a recency view. Forgejo sorts closed pulls byrecentcloseand closed issues byrecentupdate(closing bumps updated; issues have no close sort); GitHub approximates withupdateddescending (it has no recently-closed sort); the builtin store orders by number descending (≈ recency under monotonic numbering) — windowed for contract uniformity, not perf. The closed page is ONE request, never paginated — the walk-to-empty-page pattern (and its guaranteed wasted probe) survives only where the set is bounded by the working set.labctl pr listdocuments the window in its usage line;pr view <n>remains the unbounded-by-number single read. -
What deliberately did NOT change.
DonePull's semantics and collision preference; the reaper's claim protocol and skip-on-missing-data invariant (now per-run instead of per-repo — strictly better isolation); the open-issue paths (ReadyIssues,Issues(StateOpen), the blocked-by gate), already bounded; forge-side caching/ETags and delete-branch-on-merge policy (#130), out of scope.
Status¶
Accepted. Settled via issue #176. Revises ADR-0009's "Pulls are fetched in ALL states" pin (that ADR gains a status pointer here): all-states-ness moves from the listing to the per-branch lookup, where it is affordable; the listing itself becomes a windowed view. ADR-0009's empty-page pagination rule stands wherever a full set is still fetched (open walks, the fallback walk).
Considered options¶
- Windowed
Pulls()for the reaper too (no new seam method). Rejected: a recency window can evict an old-but-live open PR, and the done-signal must never depend on where a pull sits in a listing — the per-branch lookup answers the actual question asked. PullForHeadreturning a single winner. Rejected: it would duplicate the collision-precedence logic per backend; returning candidates keepsDonePullthe single authority the reaper and parked-badge paths already share.- Trusting Forgejo's by-base-head answer unconditionally. Rejected after reading the upstream query (plain xorm
.Get(), no ORDER BY): a closed row may shadow a live sibling, which would falsely fail a run — the one wrong direction the three-state vocabulary exists to prevent.