diff --git a/.gittensory.yml.example b/.gittensory.yml.example index a47497df9b..49ab5c7152 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -509,6 +509,9 @@ settings: # Check-run detail. minimal | standard | deep. Default: minimal. checkRunDetailLevel: minimal + # Sweep order. staleness | oldest-first. Default: staleness. + regateSweepOrderMode: staleness + # Which public surfaces are used. # off | comment_and_label | comment_only | label_only. Default: comment_and_label. publicSurface: comment_and_label diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 7ab7445463..6b4bd83d41 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9244,6 +9244,13 @@ }, "publicQualityMetrics": { "type": "boolean" + }, + "regateSweepOrderMode": { + "type": "string", + "enum": [ + "staleness", + "oldest-first" + ] } }, "required": [ @@ -9254,6 +9261,7 @@ "checkRunMode", "checkRunDetailLevel", "gateCheckMode", + "regateSweepOrderMode", "reviewCheckMode", "gatePack", "linkedIssueGateMode", @@ -9965,6 +9973,13 @@ }, "publicQualityMetrics": { "type": "boolean" + }, + "regateSweepOrderMode": { + "type": "string", + "enum": [ + "staleness", + "oldest-first" + ] } }, "required": [ @@ -9975,6 +9990,7 @@ "checkRunMode", "checkRunDetailLevel", "gateCheckMode", + "regateSweepOrderMode", "reviewCheckMode", "gatePack", "linkedIssueGateMode", diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 441685217b..c12744dc63 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -522,6 +522,9 @@ settings: # Check-run detail. minimal | standard | deep. Default: minimal. checkRunDetailLevel: minimal + # Sweep order. staleness | oldest-first. Default: staleness. + regateSweepOrderMode: staleness + # Which public surfaces are used. # off | comment_and_label | comment_only | label_only. Default: comment_and_label. publicSurface: comment_and_label diff --git a/migrations/0116_regate_sweep_order_mode.sql b/migrations/0116_regate_sweep_order_mode.sql new file mode 100644 index 0000000000..970ffe98f0 --- /dev/null +++ b/migrations/0116_regate_sweep_order_mode.sql @@ -0,0 +1,3 @@ +-- Opt-in oldest-first ordering mode for the scheduled re-gate sweep (#3815). Default 'staleness' (existing +-- behavior, unchanged) — a repo opts into 'oldest-first' explicitly via the dashboard/API or .gittensory.yml. +ALTER TABLE repository_settings ADD COLUMN regate_sweep_order_mode TEXT NOT NULL DEFAULT 'staleness'; diff --git a/src/api/routes.ts b/src/api/routes.ts index 1344a4bfda..8a974a5d09 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -651,6 +651,7 @@ const repositorySettingsSchema = z.object({ // this full-replace route that omits this field must land on the same safe default as a never-configured row. checkRunDetailLevel: z.enum(["minimal", "standard", "deep"]).default("minimal"), gateCheckMode: z.enum(["off", "enabled"]).default("off"), + regateSweepOrderMode: z.enum(["staleness", "oldest-first"]).default("staleness"), // #2852: deliberately NO `.default()` here (unlike every sibling field above) -- this is a non-partial, // full-replace schema (see upsertRepositorySettings call below, which passes every parsed field straight // through with no read-merge of the current row), so an eager default would mask a legacy caller that only @@ -708,6 +709,7 @@ const maintainerSettingsSchema = z checkRunMode: z.enum(["off", "enabled"]), checkRunDetailLevel: z.enum(["minimal", "standard", "deep"]), gateCheckMode: z.enum(["off", "enabled"]), + regateSweepOrderMode: z.enum(["staleness", "oldest-first"]), reviewCheckMode: z.enum(["required", "visible", "disabled"]), gatePack: z.enum(["gittensor", "oss-anti-slop"]), linkedIssueGateMode: z.enum(["off", "advisory", "block"]), @@ -3768,6 +3770,7 @@ export function createApp() { checkRunMode: parsed.data.checkRunMode, checkRunDetailLevel: parsed.data.checkRunDetailLevel, gateCheckMode: parsed.data.gateCheckMode, + regateSweepOrderMode: parsed.data.regateSweepOrderMode, // #2852: this route is a full-replace, non-partial schema (every sibling field has a `.default()`), // so a caller that only ever sends gateCheckMode must still get its historical effect on the actual // publish authority -- derive it explicitly here rather than relying on a passthrough `undefined` diff --git a/src/db/repositories.ts b/src/db/repositories.ts index cc619c2afb..566ce343cb 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -497,6 +497,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", autoProjectMilestoneMatch: "off", autoProjectMilestoneMatchBackend: "github", @@ -573,6 +574,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise checkRunMode: parseCheckRunMode(row.checkRunMode), checkRunDetailLevel: parseCheckRunDetailLevel(row.checkRunDetailLevel), gateCheckMode: parseGateCheckMode(row.gateCheckMode), + regateSweepOrderMode: parseRegateSweepOrderMode(row.regateSweepOrderMode), reviewCheckMode: parseReviewCheckMode(row.reviewCheckMode), autoProjectMilestoneMatch: parseProjectMilestoneMatchMode(row.projectMilestoneMatchMode), autoProjectMilestoneMatchBackend: parseProjectMilestoneMatchBackend(row.autoProjectMilestoneMatchBackend), @@ -685,6 +687,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial 0, + orderMode: settings.regateSweepOrderMode, ...(repairCandidateLimit !== null ? { max: repairCandidateLimit } : {}), }); // No stale PRs this tick — stay quiet rather than writing an empty heartbeat to the audit feed. diff --git a/src/settings/agent-sweep.ts b/src/settings/agent-sweep.ts index 75bb59aa47..1b4772c09a 100644 --- a/src/settings/agent-sweep.ts +++ b/src/settings/agent-sweep.ts @@ -31,6 +31,11 @@ export const SWEEP_FRESHNESS_MS = 2 * 60 * 1000; // behind a per-PR backlog and drained together). export const SWEEP_FANOUT_DEDUP_MS = 90 * 1000; +// Candidate ordering mode (#3815, RepositorySettings["regateSweepOrderMode"]). "staleness" (default) is +// selectRegateCandidates' original ordering; "oldest-first" is opt-in per repo. See the function doc comment +// for the convergence-guarantee rationale each preserves. +export type RegateSweepOrderMode = "staleness" | "oldest-first"; + /** * Select the open PRs a single repo sweep should recompute: drop drafts and anything a webhook touched within * `freshnessWindowMs` of `now` (don't race an in-flight review), then take the `max` PRs the sweep has gone @@ -42,6 +47,22 @@ export const SWEEP_FANOUT_DEDUP_MS = 90 * 1000; * suppressed), so a just-regated PR sorts freshest and the next pass covers the next-stalest — full coverage of * all open PRs in ceil(open/max) sweeps. GitHub's `updatedAt` is used ONLY for the freshness skip (a PR a * webhook is actively gating), never for the sort. Pure + deterministic: same inputs → same ordered batch. + * + * `orderMode` (#3815, default `"staleness"`): an opt-in `"oldest-first"` mode instead orders candidates by + * `createdAt` ascending, for an operator who wants deterministic creation-order draining over the staleness + * sort's own convergence property. Unlike `regateProgress` above, a PR's `createdAt` never changes, so + * `oldest-first`'s sort key alone cannot advance past an already-dispatched PR — without something else, the + * same oldest `max` PRs would recur every sweep forever. + * + * `oldest-first` instead excludes whichever PR(s) hold the CURRENT candidate pool's single most-recent + * `lastRegatedAt` value — i.e. whichever PR(s) this repo's immediately preceding sweep dispatched (every + * candidate in one dispatch is stamped with the exact same `lastRegatedAt`, see markPullRequestsRegated), + * deferring them until an even-newer dispatch supersedes them. This is a RELATIVE comparison within the pool + * on every call, never an absolute time window against `now` — so, unlike a fixed freshness window, it holds + * regardless of how far apart consecutive sweeps actually run (a delayed/backpressured sweep, or dry-run/pause + * suppressing GitHub's `updatedAt` entirely), giving `oldest-first` the same timing-independent, full-coverage- + * in-ceil(open/max)-sweeps guarantee `staleness` has. Selection-time only: real-time webhook-driven review is + * not gated by this sort and can still process any PR out of order at any moment. */ export function selectRegateCandidates(input: { pulls: PullRequestRecord[]; @@ -50,9 +71,11 @@ export function selectRegateCandidates(input: { priorityBypassesFreshness?: boolean; freshnessWindowMs?: number; max?: number; + orderMode?: RegateSweepOrderMode; }): PullRequestRecord[] { const freshnessWindowMs = input.freshnessWindowMs ?? SWEEP_FRESHNESS_MS; const max = input.max ?? SWEEP_MAX_PRS; + const orderMode = input.orderMode ?? "staleness"; const nowMs = Date.parse(input.now); const freshCutoff = Number.isFinite(nowMs) ? nowMs - freshnessWindowMs : Number.NaN; // Don't-race-webhook guard: a PR whose GitHub `updatedAt` is within the window was almost certainly just gated @@ -70,13 +93,22 @@ export function selectRegateCandidates(input: { const created = pr.createdAt ? Date.parse(pr.createdAt) : Number.NaN; return Number.isFinite(created) ? created : 0; }; + // Creation-order key (#3815, "oldest-first" mode): always the PR's own createdAt, never lastRegatedAt — a + // repeatedly-regated PR must NOT sort as if newly created. A missing/unparseable createdAt falls back to + // epoch (same convention as regateProgress above), so it sorts oldest; ties (including every missing-createdAt + // PR) are broken by PR number, same as every other mode. + const creationOrder = (pr: PullRequestRecord): number => { + const created = pr.createdAt ? Date.parse(pr.createdAt) : Number.NaN; + return Number.isFinite(created) ? created : 0; + }; + const orderKey = orderMode === "oldest-first" ? creationOrder : regateProgress; const priorityPullNumbers = input.priorityPullNumbers instanceof Set ? input.priorityPullNumbers : new Set(input.priorityPullNumbers ?? []); const repairPriority = (pr: PullRequestRecord): number => priorityPullNumbers.has(pr.number) ? 0 : 1; - return input.pulls + const eligible = input.pulls .filter((pr) => pr.state === "open" && !pr.isDraft) .filter((pr) => { if ( @@ -86,8 +118,33 @@ export function selectRegateCandidates(input: { return true; if (!Number.isFinite(freshCutoff)) return true; return webhookFreshness(pr) <= freshCutoff; - }) - .sort((a, b) => repairPriority(a) - repairPriority(b) || regateProgress(a) - regateProgress(b) || a.number - b.number) + }); + // Most-recent-dispatch exclusion (#3815, "oldest-first" mode only — see the doc comment above): find the + // single latest lastRegatedAt value across the currently-eligible pool, then defer whichever PR(s) hold it. + // A pool with no lastRegatedAt at all (nothing ever dispatched) has no most-recent value, so nothing defers. + let mostRecentRegatedMs = Number.NEGATIVE_INFINITY; + if (orderMode === "oldest-first") { + for (const pr of eligible) { + const regated = pr.lastRegatedAt ? Date.parse(pr.lastRegatedAt) : Number.NaN; + if (Number.isFinite(regated) && regated > mostRecentRegatedMs) mostRecentRegatedMs = regated; + } + } + // Only called (via deferredCount/the final filter below) when orderMode is already "oldest-first" — the + // caller gates on that, so this never needs its own mode check. + const isMostRecentlyDispatched = (pr: PullRequestRecord): boolean => { + if (input.priorityBypassesFreshness && priorityPullNumbers.has(pr.number)) return false; + const regated = pr.lastRegatedAt ? Date.parse(pr.lastRegatedAt) : Number.NaN; + return Number.isFinite(regated) && regated === mostRecentRegatedMs; + }; + const deferredCount = orderMode === "oldest-first" ? eligible.filter(isMostRecentlyDispatched).length : 0; + // Starvation guard: only actually defer when doing so still leaves at least one candidate. If EVERY eligible + // PR ties on the same lastRegatedAt (e.g. a small backlog whose entire open-PR count fits in one sweep, so + // every PR was dispatched together last time), deferring all of them would starve the sweep forever with + // nothing better to fall back to — proceed with the full pool instead. + const shouldDefer = deferredCount > 0 && deferredCount < eligible.length; + return eligible + .filter((pr) => !shouldDefer || !isMostRecentlyDispatched(pr)) + .sort((a, b) => repairPriority(a) - repairPriority(b) || orderKey(a) - orderKey(b) || a.number - b.number) .slice(0, Math.max(0, max)); } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index dc09b93ca5..136a156b92 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -213,6 +213,7 @@ export type FocusManifestSettings = Partial< | "checkRunMode" | "checkRunDetailLevel" | "gateCheckMode" + | "regateSweepOrderMode" | "reviewCheckMode" | "autoProjectMilestoneMatch" | "autoProjectMilestoneMatchBackend" @@ -1417,6 +1418,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (checkRunDetailLevel !== null) out.checkRunDetailLevel = checkRunDetailLevel; const gateCheckMode = normalizeOptionalEnum(r.gateCheckMode, "settings.gateCheckMode", ["off", "enabled"] as const, warnings); if (gateCheckMode !== null) out.gateCheckMode = gateCheckMode; + const regateSweepOrderMode = normalizeOptionalEnum(r.regateSweepOrderMode, "settings.regateSweepOrderMode", ["staleness", "oldest-first"] as const, warnings); + if (regateSweepOrderMode !== null) out.regateSweepOrderMode = regateSweepOrderMode; // Same tri-state field as gate.checkMode above (the friendly gate alias overlays onto it in // resolveEffectiveSettings, and wins when both are set). const reviewCheckMode = normalizeOptionalEnum(r.reviewCheckMode, "settings.reviewCheckMode", ["required", "visible", "disabled"] as const, warnings); diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index f4acbafb7f..7f4361218d 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -189,6 +189,7 @@ export type RepoSettingsPreview = { checkRunMode: RepositorySettings["checkRunMode"]; checkRunDetailLevel: RepositorySettings["checkRunDetailLevel"]; gateCheckMode: RepositorySettings["gateCheckMode"]; + regateSweepOrderMode: RepositorySettings["regateSweepOrderMode"]; reviewCheckMode: RepositorySettings["reviewCheckMode"]; gatePack: RepositorySettings["gatePack"]; linkedIssueGateMode: RepositorySettings["linkedIssueGateMode"]; @@ -317,6 +318,7 @@ export function buildRepoSettingsPreview(args: { checkRunMode: settings.checkRunMode, checkRunDetailLevel: settings.checkRunDetailLevel, gateCheckMode: settings.gateCheckMode, + regateSweepOrderMode: settings.regateSweepOrderMode, reviewCheckMode: settings.reviewCheckMode, gatePack: settings.gatePack, linkedIssueGateMode: settings.linkedIssueGateMode, diff --git a/src/types.ts b/src/types.ts index 33d4242cad..81ce0c7411 100644 --- a/src/types.ts +++ b/src/types.ts @@ -612,6 +612,14 @@ export type RepositorySettings = { checkRunMode: "off" | "enabled"; checkRunDetailLevel: "minimal" | "standard" | "deep"; gateCheckMode: "off" | "enabled"; + /** Scheduled re-gate sweep candidate ordering (#3815). `staleness` (default) picks whichever open PR the + * sweep has gone longest WITHOUT re-gating (see selectRegateCandidates), which is what gives the sweep its + * documented full-coverage-in-ceil(open/max)-ticks convergence guarantee even under dry-run/pause (when + * GitHub's own `updatedAt` writes are suppressed). `oldest-first` instead always picks the oldest-created + * open PRs first, for an operator who wants deterministic creation-order draining over that guarantee. + * Selection-time only — real-time webhook-driven review is not gated by this and can process any PR at + * any time regardless of the chosen order. */ + regateSweepOrderMode: "staleness" | "oldest-first"; /** The actual runtime authority for whether the "Gittensory Orb Review Agent" check-run publishes (#2852). * See {@link ReviewCheckMode}. `gateCheckMode` above stays wired for API/back-compat display but no longer * drives the publish decision on its own. */ diff --git a/test/unit/agent-sweep.test.ts b/test/unit/agent-sweep.test.ts index 493a7a490d..05e88cffd6 100644 --- a/test/unit/agent-sweep.test.ts +++ b/test/unit/agent-sweep.test.ts @@ -166,6 +166,127 @@ describe("selectRegateCandidates (#777 re-gate sweep selection)", () => { const pulls = Array.from({ length: 40 }, (_, i) => pr({ number: i + 1, createdAt: minutesAgo(120 + i) })); expect(selectRegateCandidates({ pulls, now: NOW })).toHaveLength(SWEEP_MAX_PRS); }); + + describe("orderMode: oldest-first (#3815)", () => { + it("orders by createdAt ascending when neither PR has ever been regated", () => { + const pulls = [pr({ number: 1, createdAt: minutesAgo(1000) }), pr({ number: 2, createdAt: minutesAgo(1) })]; + const picked = selectRegateCandidates({ pulls, now: NOW, orderMode: "oldest-first" }); + 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) + }); + + it("defers whichever PR holds the pool's single most-recent lastRegatedAt, regardless of its own createdAt age", () => { + // #1 was re-gated most recently (10m ago) despite being the OLDEST-created PR by far; #2 was re-gated + // longer ago (100m) despite being the NEWEST-created. Naively sorting by pure createdAt would put #1 + // first every time even though it was just dispatched — oldest-first instead defers whichever PR holds + // the pool's freshest lastRegatedAt (here, #1), leaving #2 as the only eligible candidate this tick. + const pulls = [ + pr({ number: 1, lastRegatedAt: minutesAgo(10), createdAt: minutesAgo(1000) }), + pr({ number: 2, lastRegatedAt: minutesAgo(100), createdAt: minutesAgo(1) }), + ]; + const picked = selectRegateCandidates({ pulls, now: NOW, orderMode: "oldest-first" }); + expect(picked.map((p) => p.number)).toEqual([2]); + }); + + it("does not starve the sweep when EVERY eligible PR ties on the same lastRegatedAt (a fully-covered small backlog)", () => { + // Both PRs were dispatched together in the exact same prior sweep (identical lastRegatedAt stamp — see + // markPullRequestsRegated, which stamps every candidate in one UPDATE). Deferring "whichever holds the + // most recent value" would defer BOTH here, returning nothing — the starvation guard falls back to the + // full pool instead, since there is nothing better to wait for. + const pulls = [ + pr({ number: 1, createdAt: minutesAgo(1000), lastRegatedAt: minutesAgo(10) }), + pr({ number: 2, createdAt: minutesAgo(500), lastRegatedAt: minutesAgo(10) }), + ]; + const picked = selectRegateCandidates({ pulls, now: NOW, orderMode: "oldest-first" }); + expect(picked.map((p) => p.number)).toEqual([1, 2]); // both tie → guard proceeds with the full pool, oldest first + }); + + it("falls back to the epoch (sorts as oldest) when createdAt is absent, tie broken by PR number", () => { + const pulls = [pr({ number: 9, createdAt: minutesAgo(5) }), pr({ number: 4 }), pr({ number: 7 })]; + const picked = selectRegateCandidates({ pulls, now: NOW, orderMode: "oldest-first" }); + expect(picked.map((p) => p.number)).toEqual([4, 7, 9]); // #4 and #7 (no createdAt) tie at epoch, then #9 + }); + + it("bounds the batch to max after ordering by creation time", () => { + const pulls = [ + pr({ number: 1, createdAt: minutesAgo(120) }), + pr({ number: 2, createdAt: minutesAgo(600) }), + pr({ number: 3, createdAt: minutesAgo(300) }), + ]; + const picked = selectRegateCandidates({ pulls, now: NOW, orderMode: "oldest-first", max: 2 }); + expect(picked.map((p) => p.number)).toEqual([2, 3]); // oldest-created (600m), then 300m; 120m dropped by cap + }); + + it("REGRESSION (repair priority): priority repairs still sort before ordinary oldest-first candidates", () => { + const pulls = [ + pr({ number: 1, createdAt: minutesAgo(10) }), + pr({ number: 2, createdAt: minutesAgo(900) }), + pr({ number: 3, createdAt: minutesAgo(800) }), + ]; + const picked = selectRegateCandidates({ + pulls, + now: NOW, + orderMode: "oldest-first", + max: 2, + priorityPullNumbers: new Set([1]), + }); + expect(picked.map((p) => p.number)).toEqual([1, 2]); // #1 (priority) wins despite being newest-created + }); + + it("REGRESSION (repair priority): a priority repair bypasses the most-recent-dispatch deferral too", () => { + // #1 is a priority repair AND happens to hold the pool's single most-recent lastRegatedAt (it was just + // dispatched). Without priorityBypassesFreshness it would be deferred like any other PR; with it, the + // repair still gets included this tick. + const pulls = [ + pr({ number: 1, createdAt: minutesAgo(10), lastRegatedAt: minutesAgo(1) }), + pr({ number: 2, createdAt: minutesAgo(900) }), + ]; + const picked = selectRegateCandidates({ + pulls, + now: NOW, + orderMode: "oldest-first", + priorityPullNumbers: new Set([1]), + priorityBypassesFreshness: true, + }); + expect(picked.map((p) => p.number)).toEqual([1, 2]); // #1 (priority) included despite being the most-recently-dispatched + }); + + it("a just-regated PR is excluded by the most-recent-dispatch check, not re-selected forever by its fixed createdAt", () => { + // createdAt never changes, so without the most-recent-dispatch exclusion #1 (oldest-created) would recur + // every sweep even after being dispatched. #1 holds the pool's only lastRegatedAt value → it is deferred; + // #2 (never regated) becomes the sole eligible candidate this tick. + const pulls = [pr({ number: 1, createdAt: minutesAgo(1000), lastRegatedAt: minutesAgo(1) }), pr({ number: 2, createdAt: minutesAgo(500) })]; + const picked = selectRegateCandidates({ pulls, now: NOW, orderMode: "oldest-first" }); + expect(picked.map((p) => p.number)).toEqual([2]); // #1 just regated → deferred; #2 is the next-oldest eligible + }); + + it("REGRESSION (convergence): ceil(open/cap) sweeps with all GitHub writes suppressed cover ALL open PRs under oldest-first too", () => { + // Mirrors the staleness-mode convergence test above: dry-run/paused world where GitHub updatedAt never + // moves, AND sweeps are spaced 5 minutes apart — well past any fixed freshness window, proving this + // does NOT rely on wall-clock timing. createdAt is fixed too (by construction), so convergence for + // oldest-first depends entirely on the most-recent-dispatch exclusion advancing each round: after a + // sweep, its dispatched PRs share the freshest lastRegatedAt and are deferred, letting the next-oldest + // batch surface — without it this would loop forever. + const open = SWEEP_MAX_PRS * 2; + const sweepsNeeded = Math.ceil(open / SWEEP_MAX_PRS); + const pulls = Array.from({ length: open }, (_, i) => pr({ number: i + 1, createdAt: minutesAgo(1000 - i), updatedAt: minutesAgo(1000) })); + const stampedAt = new Map(); + const covered = new Set(); + let sweepNow = nowMs; + for (let sweep = 0; sweep < sweepsNeeded; sweep++) { + sweepNow += 5 * 60 * 1000; + const now = new Date(sweepNow).toISOString(); + const view = pulls.map((p) => ({ ...p, lastRegatedAt: stampedAt.get(p.number) ?? p.lastRegatedAt })); + const picked = selectRegateCandidates({ pulls: view, now, orderMode: "oldest-first" }); + expect(picked.length).toBe(SWEEP_MAX_PRS); + for (const p of picked) { + expect(covered.has(p.number)).toBe(false); + covered.add(p.number); + stampedAt.set(p.number, now); + } + } + expect(covered.size).toBe(open); + }); + }); }); describe("isRegateSweepDraining (#audit-sweep-fanout in-flight guard)", () => { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 5dda0ce52e..fd68f72b9e 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -288,6 +288,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { publicSignalLevel: "publicSignalLevel:", checkRunMode: "checkRunMode:", checkRunDetailLevel: "checkRunDetailLevel:", + regateSweepOrderMode: "regateSweepOrderMode:", reviewCheckMode: "checkMode:", // `gate.checkMode` above documents the same underlying knob. autoProjectMilestoneMatch: "autoProjectMilestoneMatch:", autoProjectMilestoneMatchBackend: "autoProjectMilestoneMatchBackend:", diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 7959ac85e5..18da722617 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -28,6 +28,7 @@ function settings(overrides: Partial = {}): RepositorySettin checkRunMode: "off", checkRunDetailLevel: "standard", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index ed10809698..2812d92623 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -62,6 +62,7 @@ function settingsFor(repoFullName: string, overrides: Partial { expect(fanned.map((job) => (job as Extract).prNumber)).toEqual([2, 1, 3]); }); + it("REGRESSION (#3815): regateSweepOrderMode 'oldest-first' fans out per-PR jobs in creation order with a monotonic delaySeconds stagger", async () => { + const dispatched: { prNumber: number; delaySeconds: number | undefined }[] = []; + const env = createTestEnv({ + JOBS: { + async send(m: import("../../src/types").JobMessage, options?: { delaySeconds?: number }) { + if (m.type === "agent-regate-pr") dispatched.push({ prNumber: m.prNumber, delaySeconds: options?.delaySeconds ?? 0 }); + }, + } as unknown as Queue, + }); + await upsertInstallation(env, { action: "created", installation: { id: 9403, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }, 9403); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, regateSweepOrderMode: "oldest-first", gateCheckMode: "off", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + // Deliberately seeded out of PR-number order: #1 is the NEWEST, #3 is the OLDEST — proves the fan-out + // follows createdAt, not insertion/number order. + const created: Record = { 1: "2026-05-20T00:00:00.000Z", 2: "2026-05-10T00:00:00.000Z", 3: "2026-05-01T00:00:00.000Z" }; + for (const number of [1, 2, 3]) { + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { + number, + title: `PR${number}`, + state: "open", + user: { login: "c" }, + head: { sha: `a${number}` }, + labels: [], + body: "", + created_at: created[number]!, + updated_at: created[number]!, + }); + } + vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z")); // well past the 2-min webhook-freshness window for all three + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + expect(dispatched.map((d) => d.prNumber)).toEqual([3, 2, 1]); // oldest-created (#3) first, newest (#1) last + expect(dispatched.map((d) => d.delaySeconds)).toEqual([0, 10, 20]); // strictly increasing with dispatch order + }); + it("REGRESSION: scheduled sweeps repair every missing current Gate check without waiting behind another repo backlog", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ diff --git a/test/unit/registration-readiness.test.ts b/test/unit/registration-readiness.test.ts index 8163d045ab..c19bf18d79 100644 --- a/test/unit/registration-readiness.test.ts +++ b/test/unit/registration-readiness.test.ts @@ -42,6 +42,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin checkRunMode: "enabled", checkRunDetailLevel: "standard", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts index b42f08969e..936c0ef895 100644 --- a/test/unit/repository-settings-enforcement.test.ts +++ b/test/unit/repository-settings-enforcement.test.ts @@ -16,6 +16,7 @@ function settings(over: Partial = {}): RepositorySettings { checkRunMode: "off", checkRunDetailLevel: "standard", gateCheckMode: "enabled", + regateSweepOrderMode: "staleness", reviewCheckMode: "required", gatePack: "gittensor", linkedIssueGateMode: "off", diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index 67ecc3f62f..f41c998963 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -55,6 +55,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin checkRunMode: "off", checkRunDetailLevel: "standard", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index b2244e3214..60fb198eb2 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -2196,6 +2196,7 @@ function repoSettings(repoFullName: string): RepositorySettings { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index d6f93d3757..b01ed8b1b3 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -2085,6 +2085,7 @@ describe("v2 signal builders", () => { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 04a49752ae..df22f9aa5f 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -473,6 +473,7 @@ describe("world-class backend signals", () => { checkRunMode: "off" as const, checkRunDetailLevel: "minimal" as const, gateCheckMode: "off" as const, + regateSweepOrderMode: "staleness" as const, reviewCheckMode: "disabled" as const, gatePack: "gittensor" as const, linkedIssueGateMode: "advisory" as const, @@ -527,6 +528,7 @@ describe("world-class backend signals", () => { checkRunMode: "off" as const, checkRunDetailLevel: "minimal" as const, gateCheckMode: "off" as const, + regateSweepOrderMode: "staleness" as const, reviewCheckMode: "disabled" as const, gatePack: "gittensor" as const, linkedIssueGateMode: "advisory" as const, @@ -601,6 +603,7 @@ describe("world-class backend signals", () => { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", @@ -729,6 +732,7 @@ describe("world-class backend signals", () => { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", @@ -799,6 +803,7 @@ describe("world-class backend signals", () => { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", @@ -913,6 +918,7 @@ describe("world-class backend signals", () => { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory", diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index f9af791227..f0df9e59da 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -52,6 +52,7 @@ const settings: RepositorySettings = { checkRunMode: "off", checkRunDetailLevel: "minimal", gateCheckMode: "off", + regateSweepOrderMode: "staleness", reviewCheckMode: "disabled", gatePack: "gittensor", linkedIssueGateMode: "advisory",