Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9244,6 +9244,13 @@
},
"publicQualityMetrics": {
"type": "boolean"
},
"regateSweepOrderMode": {
"type": "string",
"enum": [
"staleness",
"oldest-first"
]
}
},
"required": [
Expand All @@ -9254,6 +9261,7 @@
"checkRunMode",
"checkRunDetailLevel",
"gateCheckMode",
"regateSweepOrderMode",
"reviewCheckMode",
"gatePack",
"linkedIssueGateMode",
Expand Down Expand Up @@ -9965,6 +9973,13 @@
},
"publicQualityMetrics": {
"type": "boolean"
},
"regateSweepOrderMode": {
"type": "string",
"enum": [
"staleness",
"oldest-first"
]
}
},
"required": [
Expand All @@ -9975,6 +9990,7 @@
"checkRunMode",
"checkRunDetailLevel",
"gateCheckMode",
"regateSweepOrderMode",
"reviewCheckMode",
"gatePack",
"linkedIssueGateMode",
Expand Down
3 changes: 3 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions migrations/0116_regate_sweep_order_mode.sql
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -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`
Expand Down
9 changes: 9 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -685,6 +687,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
checkRunMode: settings.checkRunMode ?? "off",
checkRunDetailLevel: settings.checkRunDetailLevel ?? "minimal",
gateCheckMode: settings.gateCheckMode ?? "off",
regateSweepOrderMode: settings.regateSweepOrderMode ?? "staleness",
// Legacy-write compatibility (#2852): a caller that sets ONLY gateCheckMode (never touching the newer,
// more expressive reviewCheckMode) must keep its historical effect -- "enabled" still means the check
// publishes. This is safe under this function's existing "no field is preserved from the DB, an absent
Expand Down Expand Up @@ -769,6 +772,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
checkRunMode: resolved.checkRunMode,
checkRunDetailLevel: resolved.checkRunDetailLevel,
gateCheckMode: resolved.gateCheckMode,
regateSweepOrderMode: resolved.regateSweepOrderMode,
reviewCheckMode: resolved.reviewCheckMode,
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: resolved.autoProjectMilestoneMatchBackend,
Expand Down Expand Up @@ -845,6 +849,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
checkRunMode: resolved.checkRunMode,
checkRunDetailLevel: resolved.checkRunDetailLevel,
gateCheckMode: resolved.gateCheckMode,
regateSweepOrderMode: resolved.regateSweepOrderMode,
reviewCheckMode: resolved.reviewCheckMode,
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: resolved.autoProjectMilestoneMatchBackend,
Expand Down Expand Up @@ -6694,6 +6699,10 @@ function parseGateCheckMode(value: string): RepositorySettings["gateCheckMode"]
return value === "enabled" ? "enabled" : "off";
}

function parseRegateSweepOrderMode(value: string): RepositorySettings["regateSweepOrderMode"] {
return value === "oldest-first" ? "oldest-first" : "staleness";
}

function parseReviewCheckMode(value: string): RepositorySettings["reviewCheckMode"] {
return value === "required" || value === "visible" ? value : "disabled";
}
Expand Down
3 changes: 3 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const repositorySettings = sqliteTable("repository_settings", {
checkRunMode: text("check_run_mode").notNull().default("off"),
checkRunDetailLevel: text("check_run_detail_level").notNull().default("minimal"),
gateCheckMode: text("gate_check_mode").notNull().default("off"),
// Scheduled re-gate sweep candidate ordering (#3815). staleness | oldest-first. Default staleness — see
// RepositorySettings["regateSweepOrderMode"] for the full convergence-guarantee rationale.
regateSweepOrderMode: text("regate_sweep_order_mode").notNull().default("staleness"),
reviewCheckMode: text("review_check_mode").notNull().default("disabled"),
projectMilestoneMatchMode: text("project_milestone_match_mode").notNull().default("off"),
autoProjectMilestoneMatchBackend: text("auto_project_milestone_match_backend").notNull().default("github"),
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export const RepositorySettingsSchema = 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"]),
autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(),
autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(),
Expand Down Expand Up @@ -778,6 +779,7 @@ export const RepoSettingsPreviewSchema = 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"]),
autoProjectMilestoneMatch: z.enum(["off", "suggest", "auto"]).optional(),
autoProjectMilestoneMatchBackend: z.enum(["github", "linear"]).optional(),
Expand Down
1 change: 1 addition & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,7 @@ async function sweepRepoRegate(
now: nowIso(),
priorityPullNumbers,
priorityBypassesFreshness: priorityPullNumbers.length > 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.
Expand Down
63 changes: 60 additions & 3 deletions src/settings/agent-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[];
Expand All @@ -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
Expand All @@ -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 (
Expand All @@ -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));
}

Expand Down
3 changes: 3 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export type FocusManifestSettings = Partial<
| "checkRunMode"
| "checkRunDetailLevel"
| "gateCheckMode"
| "regateSweepOrderMode"
| "reviewCheckMode"
| "autoProjectMilestoneMatch"
| "autoProjectMilestoneMatchBackend"
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading