Skip to content

Commit acfe54e

Browse files
authored
Merge pull request #4474 from JSONbored/claude/fix-sweep-endless-reregate
fix(review): stop the sweep from endlessly re-evaluating already-reviewed PRs
2 parents 6020670 + 4a86e8e commit acfe54e

3 files changed

Lines changed: 107 additions & 50 deletions

File tree

src/settings/agent-sweep.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,23 @@ export function selectRegateCandidates(input: {
152152
};
153153
const hasRepairPriority = (pr: PullRequestRecord): boolean =>
154154
priorityPullNumbers.has(pr.number);
155+
// One-shot review, fail-closed (#never-endless-reregate, incident 2026-07-09): a PR the sweep has ALREADY
156+
// regated even once is permanently ineligible for future sweep candidacy -- full stop, no re-check-for-drift
157+
// window, no periodic revisit. This deliberately drops the "catch silent drift" behavior the sweep used to
158+
// provide (a moved base, a merged sibling duplicate, a changed focus-manifest could previously go unnoticed
159+
// until the next real push) -- a PR gets exactly one automatic review at a given head SHA. Re-review is
160+
// opt-in only, through two channels neither of which is this sweep: (1) a genuinely new push stamps a new
161+
// headSha and is handled entirely by the real-time webhook path, never this sort (see doc comment above);
162+
// (2) an explicit maintainer-triggered re-review (the PR panel's re-run checkbox, role-gated, never
163+
// identity-hardlocked) also runs through the webhook path, not the sweep. `hasRepairPriority` remains a
164+
// narrow bypass: it means THIS PR's prior review never actually landed (a crashed/incomplete publish), so
165+
// retrying it delivers the one review it was owed, not a second one.
166+
const candidates = eligible.filter(
167+
(pr) => !hasBeenRegated(pr) || hasRepairPriority(pr),
168+
);
155169
const oldestFirstInitialDrain =
156170
orderMode === "oldest-first" &&
157-
eligible.some((pr) => !hasBeenRegated(pr) && !hasRepairPriority(pr));
158-
const candidates = oldestFirstInitialDrain
159-
? eligible.filter((pr) => !hasBeenRegated(pr) || hasRepairPriority(pr))
160-
: eligible;
171+
candidates.some((pr) => !hasBeenRegated(pr) && !hasRepairPriority(pr));
161172
const orderKey =
162173
orderMode === "oldest-first" && oldestFirstInitialDrain
163174
? creationOrder

test/unit/agent-sweep.test.ts

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
5757
expect(picked.map((p) => p.number)).toEqual([]); // stalest by re-gate, but a webhook just touched it → skip
5858
});
5959

60-
it("live case: when updatedAt and lastRegatedAt move together, the PR is eligible once outside the window (not double-excluded)", () => {
60+
it("a never-regated PR is eligible once its updatedAt is outside the freshness window (not blocked by the guard alone)", () => {
61+
const pulls = [pr({ number: 1, updatedAt: minutesAgo(120) })];
62+
const picked = selectRegateCandidates({ pulls, now: NOW });
63+
expect(picked.map((p) => p.number)).toEqual([1]); // old updatedAt, never regated → eligible
64+
});
65+
66+
it("one-shot review (#never-endless-reregate): a PR already regated even once is excluded regardless of how old both timestamps are", () => {
6167
const pulls = [
6268
pr({
6369
number: 1,
@@ -66,7 +72,7 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
6672
}),
6773
];
6874
const picked = selectRegateCandidates({ pulls, now: NOW });
69-
expect(picked.map((p) => p.number)).toEqual([1]); // both old → freshness allows it, re-gate orders it
75+
expect(picked.map((p) => p.number)).toEqual([]); // already regated once → never re-selected by the sweep
7076
});
7177

7278
it("keeps every open non-draft PR when `now` is unparseable (no freshness cutoff possible)", () => {
@@ -85,9 +91,10 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
8591
});
8692

8793
describe("convergence sort key (lastRegatedAt, NOT GitHub updatedAt)", () => {
88-
it("INVARIANT arm (i): orders by lastRegatedAt ascending when present — the staler RE-GATE sorts first", () => {
89-
// #1 was re-gated recently but created long ago; #2 was re-gated long ago but created recently. The re-gate
90-
// marker — not createdAt — drives the order, so #2 (stalest re-gate) comes first.
94+
it("INVARIANT arm (i): orders by lastRegatedAt ascending when present — the staler RE-GATE sorts first (among repair-eligible candidates; a plain already-regated PR is otherwise excluded, see #never-endless-reregate)", () => {
95+
// Both PRs already have a regate stamp, so both need repairPriority to remain eligible at all post-#never-
96+
// endless-reregate. #1 was re-gated recently but created long ago; #2 was re-gated long ago but created
97+
// recently. The re-gate marker — not createdAt — drives the order, so #2 (stalest re-gate) comes first.
9198
const pulls = [
9299
pr({
93100
number: 1,
@@ -100,7 +107,11 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
100107
createdAt: minutesAgo(1),
101108
}),
102109
];
103-
const picked = selectRegateCandidates({ pulls, now: NOW });
110+
const picked = selectRegateCandidates({
111+
pulls,
112+
now: NOW,
113+
priorityPullNumbers: new Set([1, 2]),
114+
});
104115
expect(picked.map((p) => p.number)).toEqual([2, 1]);
105116
});
106117

@@ -119,47 +130,64 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
119130
expect(picked.map((p) => p.number)).toEqual([4, 7, 9]); // all epoch → deterministic number order
120131
});
121132

122-
it("a never-regated PR (lastRegatedAt absent) outranks a just-regated one — the property that makes the sweep converge", () => {
133+
it("one-shot review (#never-endless-reregate): a just-regated PR is excluded outright, never merely outranked — only the never-regated PR remains", () => {
123134
const pulls = [
124135
pr({
125136
number: 1,
126137
lastRegatedAt: minutesAgo(1),
127138
createdAt: minutesAgo(1000),
128-
}), // just re-gated → freshest
129-
pr({ number: 2, createdAt: minutesAgo(50) }), // never re-gatedits createdAt (50m) is staler than #1's re-gate (1m)
139+
}), // already regated once → permanently ineligible for the sweep, regardless of staleness
140+
pr({ number: 2, createdAt: minutesAgo(50) }), // never regatedthe only real candidate
130141
];
131142
const picked = selectRegateCandidates({ pulls, now: NOW });
132-
expect(picked.map((p) => p.number)).toEqual([2, 1]);
143+
expect(picked.map((p) => p.number)).toEqual([2]);
133144
});
134145

135-
it("bounds the batch to max (rate-aware) after ordering by re-gate staleness", () => {
146+
it("bounds the batch to max (rate-aware) after ordering by re-gate staleness among repair-eligible candidates", () => {
136147
const pulls = [
137148
pr({ number: 1, lastRegatedAt: minutesAgo(120) }),
138149
pr({ number: 2, lastRegatedAt: minutesAgo(600) }),
139150
pr({ number: 3, lastRegatedAt: minutesAgo(300) }),
140151
];
141-
const picked = selectRegateCandidates({ pulls, now: NOW, max: 2 });
152+
const picked = selectRegateCandidates({
153+
pulls,
154+
now: NOW,
155+
max: 2,
156+
priorityPullNumbers: new Set([1, 2, 3]),
157+
});
142158
expect(picked.map((p) => p.number)).toEqual([2, 3]); // stalest re-gate (600m), then 300m; 120m dropped by cap
143159
});
144160

145-
it("#selfhost-fifo-ordering: a repair-flagged PR does NOT jump ahead of staler ordinary PRs — same orderKey for everyone", () => {
146-
// #2 has a missing public surface (surfaceRepairPriorityPullNumbers would flag it) but is also the LEAST
147-
// stale of the three by lastRegatedAt. An earlier revision sorted repair candidates first regardless of
148-
// staleness — this pinned #2 ahead of #1/#3 and was observed live as PRs dispatching out of their
149-
// creation/staleness order ("spraying") whenever a repo had a mixed repair/ordinary backlog. Repair status
150-
// must only affect eligibility (see the freshness-bypass + oldest-first-pool tests below), never order.
161+
it("one-shot review (#never-endless-reregate): bounds the batch to max among never-regated candidates (the ordinary, non-repair case)", () => {
162+
const pulls = [
163+
pr({ number: 1, createdAt: minutesAgo(120) }),
164+
pr({ number: 2, createdAt: minutesAgo(600) }),
165+
pr({ number: 3, createdAt: minutesAgo(300) }),
166+
];
167+
const picked = selectRegateCandidates({ pulls, now: NOW, max: 2 });
168+
expect(picked.map((p) => p.number)).toEqual([2, 3]); // none ever regated → falls back to createdAt; oldest-created first, 120m dropped by cap
169+
});
170+
171+
it("#selfhost-fifo-ordering: a repair-flagged PR does NOT jump ahead of staler ordinary (never-regated) PRs — same orderKey for everyone", () => {
172+
// #1 and #3 have never been regated (the ordinary, post-#never-endless-reregate candidate shape). #2 has a
173+
// missing public surface (surfaceRepairPriorityPullNumbers would flag it) and already has its own regate
174+
// stamp from 10m ago — repair priority keeps it eligible despite that stamp, but its OWN regateProgress
175+
// (10m, very fresh) correctly sorts it LAST, not ahead of the staler ordinary backlog. An earlier revision
176+
// sorted repair candidates first regardless of staleness — this pinned repair work ahead of the ordinary
177+
// backlog and was observed live as PRs dispatching out of their creation/staleness order ("spraying")
178+
// whenever a repo had a mixed repair/ordinary backlog. Repair status must only affect eligibility, never order.
151179
const pulls = [
152-
pr({ number: 1, lastRegatedAt: minutesAgo(900) }),
180+
pr({ number: 1, createdAt: minutesAgo(900) }),
153181
pr({ number: 2, lastRegatedAt: minutesAgo(10) }),
154-
pr({ number: 3, lastRegatedAt: minutesAgo(800) }),
182+
pr({ number: 3, createdAt: minutesAgo(800) }),
155183
];
156184
const picked = selectRegateCandidates({
157185
pulls,
158186
now: NOW,
159187
max: 2,
160188
priorityPullNumbers: new Set([2]),
161189
});
162-
expect(picked.map((p) => p.number)).toEqual([1, 3]); // stalest-by-regate first, same as with no priority set at all
190+
expect(picked.map((p) => p.number)).toEqual([1, 3]); // oldest ordinary candidates first; repair-flagged #2 (freshly stamped) dropped by the cap
163191
});
164192

165193
it("REGRESSION (repair priority): priority repairs can bypass webhook freshness when the current Gate check is missing", () => {
@@ -171,8 +199,10 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
171199
}),
172200
pr({
173201
number: 2,
174-
updatedAt: minutesAgo(120),
175-
lastRegatedAt: minutesAgo(800),
202+
updatedAt: minutesAgo(120), // outside the freshness window on its own
203+
createdAt: minutesAgo(50), // more recent than #1's 900m regate stamp, so it still sorts after #1
204+
// never regated (the ordinary, post-#never-endless-reregate candidate shape) — #1's own regate
205+
// stamp above only stays eligible because of the repair-priority bypass being tested here.
176206
}),
177207
];
178208
const picked = selectRegateCandidates({
@@ -256,9 +286,10 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
256286
expect(picked.map((p) => p.number)).toEqual([1, 2]); // #1 (oldest-created) first, unlike staleness (which has no history here either, so both modes agree in this case)
257287
});
258288

259-
it("orders by re-gate staleness after the oldest-first initial drain is complete", () => {
260-
// #1 was re-gated most recently (10m ago) despite being the OLDEST-created PR by far; #2 was re-gated
261-
// longer ago (100m) despite being the NEWEST-created. Once every eligible PR has a lastRegatedAt stamp,
289+
it("orders by re-gate staleness among repair-eligible candidates once every one of them has a stamp (an ordinary already-regated PR stays excluded, see #never-endless-reregate)", () => {
290+
// Both PRs already have a regate stamp, so both need repairPriority to remain eligible at all. #1 was
291+
// re-gated most recently (10m ago) despite being the OLDEST-created PR by far; #2 was re-gated longer ago
292+
// (100m) despite being the NEWEST-created. Once every eligible (repair) PR has a lastRegatedAt stamp,
262293
// oldest-first uses re-gate staleness so ongoing sweeps keep converging instead of pinning old PRs.
263294
const pulls = [
264295
pr({
@@ -276,14 +307,16 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
276307
pulls,
277308
now: NOW,
278309
orderMode: "oldest-first",
310+
priorityPullNumbers: new Set([1, 2]),
279311
});
280312
expect(picked.map((p) => p.number)).toEqual([2, 1]);
281313
});
282314

283-
it("does not starve the sweep when EVERY eligible PR ties on the same lastRegatedAt (a fully-covered small backlog)", () => {
315+
it("does not starve the sweep when EVERY repair-eligible PR ties on the same lastRegatedAt (a fully-covered small backlog)", () => {
284316
// Both PRs were dispatched together in the exact same prior sweep (identical lastRegatedAt stamp — see
285-
// markPullRequestsRegated, which stamps every candidate in one UPDATE). Since the initial drain is complete,
286-
// oldest-first falls back to the full staleness pool rather than returning nothing.
317+
// markPullRequestsRegated, which stamps every candidate in one UPDATE) and need repairPriority to remain
318+
// eligible post-#never-endless-reregate. Since the initial drain is complete, oldest-first falls back to
319+
// the full (repair) pool rather than returning nothing.
287320
const pulls = [
288321
pr({
289322
number: 1,
@@ -300,6 +333,7 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
300333
pulls,
301334
now: NOW,
302335
orderMode: "oldest-first",
336+
priorityPullNumbers: new Set([1, 2]),
303337
});
304338
expect(picked.map((p) => p.number)).toEqual([1, 2]); // both tie → guard proceeds with the full pool, oldest first
305339
});
@@ -436,7 +470,7 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
436470
expect(covered.size).toBe(open);
437471
});
438472

439-
it("falls back to re-gate staleness once every eligible PR has been swept once", () => {
473+
it("falls back to re-gate staleness among repair-eligible candidates once every one of them has been swept once", () => {
440474
const pulls = [
441475
pr({
442476
number: 1,
@@ -458,9 +492,23 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
458492
pulls,
459493
now: NOW,
460494
orderMode: "oldest-first",
495+
priorityPullNumbers: new Set([1, 2, 3]),
461496
});
462497
expect(picked.map((p) => p.number)).toEqual([2, 3, 1]);
463498
});
499+
500+
it("one-shot review (#never-endless-reregate): an ordinary (non-repair) already-regated PR is excluded outright under oldest-first too", () => {
501+
const pulls = [
502+
pr({ number: 1, createdAt: minutesAgo(1000), lastRegatedAt: minutesAgo(5) }),
503+
pr({ number: 2, createdAt: minutesAgo(900) }), // never regated → the only real candidate
504+
];
505+
const picked = selectRegateCandidates({
506+
pulls,
507+
now: NOW,
508+
orderMode: "oldest-first",
509+
});
510+
expect(picked.map((p) => p.number)).toEqual([2]);
511+
});
464512
});
465513
});
466514

test/unit/queue.test.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7311,15 +7311,13 @@ describe("queue processors", () => {
73117311
}
73127312
}
73137313
await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 5, title: "Draft without a head", state: "open", draft: true, user: { login: "c" }, labels: [], body: "" } as never);
7314+
// Only PR2 gets a regate stamp (post-#never-endless-reregate, an ordinary already-regated PR is permanently
7315+
// excluded from the sweep -- see agent-sweep.test.ts -- so PR1/3/4 must stay never-regated to remain eligible
7316+
// ordinary candidates at all). PR2 is missing its current Gate check (surfaceRepairPriorityPullNumbers would
7317+
// flag it as a repair candidate), so its repair-priority bypass keeps it eligible DESPITE already having a
7318+
// stamp -- this is exactly the scenario the repair-priority bypass exists for.
73147319
await env.DB.prepare(
7315-
`update pull_requests
7316-
set last_regated_at = case number
7317-
when 1 then '2026-05-27T01:00:00.000Z'
7318-
when 2 then '2026-05-28T01:50:00.000Z'
7319-
when 3 then '2026-05-27T02:00:00.000Z'
7320-
when 4 then '2026-05-27T03:00:00.000Z'
7321-
end
7322-
where repo_full_name = ?`,
7320+
`update pull_requests set last_regated_at = '2026-05-28T01:50:00.000Z' where repo_full_name = ? and number = 2`,
73237321
)
73247322
.bind("owner/agent-repo")
73257323
.run();
@@ -7328,14 +7326,14 @@ describe("queue processors", () => {
73287326
await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" });
73297327

73307328
const fanned = sent.filter((job) => job.type === "agent-regate-pr");
7331-
// PR2 is missing its current Gate check (surfaceRepairPriorityPullNumbers would flag it as a repair
7332-
// candidate) but is also the LEAST stale by lastRegatedAt (10 min ago vs. 23-25h for the others). An earlier
7333-
// revision sorted repair candidates first regardless of staleness, jumping PR2 to the front of this batch --
7334-
// that let a PR needing repair cut ahead of older PRs that merely went stale, observed live as PRs
7335-
// dispatching out of order ("spraying") whenever a repo had a mixed repair/ordinary backlog. Repair status
7336-
// now only affects ELIGIBILITY (staying in the pool, bypassing the freshness guard), never final order, so
7337-
// PR2 takes its rightful (last, since it's the freshest-regated) place and is dropped by the max:3 cap this
7338-
// round -- same as it would be with no repair flag at all.
7329+
// PR2 is missing its current Gate check and already has a regate stamp from 10 min ago (very fresh by
7330+
// lastRegatedAt), while PR1/3/4 have never been regated at all (the ordinary, post-#never-endless-reregate
7331+
// candidate shape). An earlier revision sorted repair candidates first regardless of staleness, jumping PR2
7332+
// to the front of this batch -- that let a PR needing repair cut ahead of older PRs that merely went stale,
7333+
// observed live as PRs dispatching out of order ("spraying") whenever a repo had a mixed repair/ordinary
7334+
// backlog. Repair status only affects ELIGIBILITY (staying in the pool despite already having a stamp),
7335+
// never final order, so PR2 takes its rightful (last, since it's the freshest-regated) place and is dropped
7336+
// by the max:3 cap this round -- same as it would be with no repair flag at all.
73397337
expect(fanned.map((job) => (job as Extract<import("../../src/types").JobMessage, { type: "agent-regate-pr" }>).prNumber)).toEqual([1, 3, 4]);
73407338
});
73417339

0 commit comments

Comments
 (0)